Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
371 changes: 371 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5047,6 +5047,273 @@ describe('Maka Pi TUI runner', () => {
await run;
});

test('/session <id> mid-turn detaches from the running Turn instead of refusing', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// The escape hatch a second TUI needs (#3380): switching Sessions
// mid-turn detaches the view and leaves the Host-owned Turn running,
// instead of refusing (trapping the client) or stopping the Turn.
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// The adopted Session's history replaced the old transcript.
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// Late events from the abandoned Turn never reach the adopted
// transcript — neither as content nor as a synthesized failure about
// the stream ending without a completion event.
driver.emit({
type: 'text_delta',
id: 'delta-leak',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 2,
text: 'LEAK-OLD-DELTA',
});
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
const after = plainTerminalOutput(terminal.output());
assert.doesNotMatch(after, /LEAK-OLD-DELTA/);
assert.doesNotMatch(after, /without a completion event/);
assert.equal(driver.stopCalls, 0);
// The orphaned drain released the runner, and only then did the freshly
// attached Turn of session-2 start and complete.
await waitFor(() => terminal.progressStates.at(-1) === false);

// A follow-up prompt lands on the adopted Session.
terminal.input('next step');
terminal.input('\r');
await waitFor(() => driver.displayPrompts.includes('next step'));
assert.deepEqual(driver.sessionIds, ['session-2']);

terminal.input('/exit');
terminal.input('\r');
await run;
});

test('/session mid-turn opens the picker and Escape closes it without arming an interrupt', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
assert.doesNotMatch(
plainTerminalOutput(terminal.output()),
/Cannot run \/session while a turn is running/,
);

// Escape belongs to the overlay while it is open: closing it must not
// arm the double-Escape interrupt — a second Escape would otherwise
// abort the very Turn the user is navigating away from (#3380).
terminal.input('\x1b');
await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
await delay(50);
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

// Settle the parked Turn normally, then leave.
driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a failed mid-turn /session leaves the running Turn fully live', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// A rejected switch must not orphan the in-flight drain: the error is
// reported, nothing was switched, and the Turn keeps streaming into the
// same transcript.
driver.failNextSwitch = true;
terminal.input('/session does-not-exist');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.output()).includes('session not found'));
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

driver.emit({
type: 'text_delta',
id: 'delta-after-failure',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 3,
text: 'still streaming after failure',
});
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('still streaming after failure'),
);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a second mid-turn /session while a detach is in flight is ignored', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// Park the first switch inside driver.switchSession, then fire a second
// /session while `detaching` is still held: re-entry would clear the
// flag early, reopen the interrupt window, and double-apply adoption.
let releaseSwitch!: () => void;
driver.holdSwitch = new Promise<void>((resolve) => {
releaseSwitch = resolve;
});
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() => driver.switchEntries >= 1);
terminal.input('/session session-2');
terminal.input('\r');
releaseSwitch();

await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// Exactly one detach notice: the second switch never ran.
const notices = plainTerminalOutput(terminal.output()).match(
/Detached from the running Turn/g,
);
assert.equal(notices?.length, 1);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a turn prepared after a mid-turn detach does not adopt abandoned metadata', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

// Park preparePrompt itself: while it is unresolved, /session can
// already detach — onPrepared/onSkillInvocation then fire for the
// abandoned Turn after the epoch fence moved.
let releasePrepare!: () => void;
const parkedPrepare = new Promise<void>((resolve) => {
releasePrepare = resolve;
});
const basePrepare = driver.preparePrompt.bind(driver);
driver.preparePrompt = async (prompt, options) => {
const turn = await basePrepare(prompt, options);
await parkedPrepare;
return {
...turn,
summary: fakeSessionSummary('abandoned-session', '/abandoned-cwd', 'ABANDONED TITLE'),
};
};

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session session-2');
terminal.input('\r');
// Nothing else drives the frame loop while preparePrompt stays parked,
// so force a repaint for the detach notices.
terminal.resize(80, 24);
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// The abandoned Turn's prepare resolves only now — its summary must
// not steal the adopted Session's metadata.
releasePrepare();
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
assert.equal(terminal.titles.includes('ABANDONED TITLE (Maka)'), false);
assert.equal(terminal.titles.at(-1), 'Existing chat (Maka)');
assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /\/abandoned-cwd/);

await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('unknown slash-prefixed text still steers into the running turn', async () => {
const terminal = new FakeTerminal();
const driver = new SteeringTurnDriver();
Expand DownExpand Up@@ -6888,6 +7155,110 @@ class ActiveResumeDriver extends SlashCommandDriver {
}
}

// A parking first Turn on session-1 plus a switchable session-2, for the
// mid-turn /session detach tests (#3380). The parked stream ends only when
// the test releases it or stop() lands — a detach leaves it running, exactly
// like a Host-owned Turn surviving a client that switches away. Later prompts
// (submitted after switching) complete immediately.
class DetachingSwitchDriver extends SlashCommandDriver {
stopCalls = 0;
/** When set, the next switchSession rejects — a failed detach must leave
* the running drain fully live. */
failNextSwitch = false;
/** When set, the next switchSession parks until released — a second
* mid-turn /session arriving while the first is still in flight. */
holdSwitch: Promise<void> | undefined;
switchEntries = 0;
private pendingEvents: SessionEvent[] = [];
private wakeTurn: (() => void) | null = null;
private turnEnded = false;
private promptCount = 0;

constructor(sessionTwoMessages: StoredMessage[]) {
super([fakeSessionSummary('session-2', '/repo')], new Map([['session-2', sessionTwoMessages]]));
}

/** Queues an event onto the parked first-session Turn. */
emit(event: SessionEvent): void {
this.pendingEvents.push(event);
this.wakeTurn?.();
this.wakeTurn = null;
}

/** Ends the parked stream the way a Host does when its Turn settles. */
releaseOldTurn(): void {
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

override async *promptEvents(_prompt: string, turnId = 'turn-1'): AsyncIterable<SessionEvent> {
this.promptCount += 1;
if (this.promptCount > 1) {
yield { type: 'complete', id: `complete-${turnId}`, turnId, ts: 9, stopReason: 'end_turn' };
return;
}
for (;;) {
while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!;
if (this.turnEnded) break;
await new Promise<void>((resolve) => {
this.wakeTurn = resolve;
});
}
yield { type: 'abort', id: 'abort-old', turnId, ts: 8, reason: 'user_stop' };
yield { type: 'complete', id: 'complete-old', turnId, ts: 9, stopReason: 'user_stop' };
}

override async stop(): Promise<void> {
this.stopCalls += 1;
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

// session-2 carries a live Turn, so adopting it hands back an activeTurn —
// the reattach path the runner must start once the orphaned drain unwinds.
override async switchSession(sessionId: string): Promise<MakaSessionSwitchResult> {
this.switchEntries += 1;
if (this.failNextSwitch) {
this.failNextSwitch = false;
throw new Error('session not found');
}
if (this.holdSwitch) {
const gate = this.holdSwitch;
this.holdSwitch = undefined;
await gate;
}
const switched = await super.switchSession(sessionId);
if (sessionId !== 'session-2') return switched;
const attachedTurnId = 'turn-attached-2';
return {
...switched,
activeTurn: {
sessionId,
turnId: attachedTurnId,
events: (async function* () {
yield {
type: 'text_complete',
id: 'text-attached',
turnId: attachedTurnId,
messageId: 'assistant-attached',
ts: 3,
text: 'attached replay done',
} satisfies SessionEvent;
yield {
type: 'complete',
id: 'complete-attached',
turnId: attachedTurnId,
ts: 4,
stopReason: 'end_turn',
} satisfies SessionEvent;
})(),
},
};
}
}

class HostSuccessorDriver extends SlashCommandDriver {
#startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined;
readonly #probeFirst = deferred<void>();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(cli): let /session detach from a running Turn instead of trapping the client by me2seeks · Pull Request #3498 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
371 changes: 371 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5047,6 +5047,273 @@ describe('Maka Pi TUI runner', () => {
await run;
});

test('/session <id> mid-turn detaches from the running Turn instead of refusing', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// The escape hatch a second TUI needs (#3380): switching Sessions
// mid-turn detaches the view and leaves the Host-owned Turn running,
// instead of refusing (trapping the client) or stopping the Turn.
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// The adopted Session's history replaced the old transcript.
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// Late events from the abandoned Turn never reach the adopted
// transcript — neither as content nor as a synthesized failure about
// the stream ending without a completion event.
driver.emit({
type: 'text_delta',
id: 'delta-leak',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 2,
text: 'LEAK-OLD-DELTA',
});
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
const after = plainTerminalOutput(terminal.output());
assert.doesNotMatch(after, /LEAK-OLD-DELTA/);
assert.doesNotMatch(after, /without a completion event/);
assert.equal(driver.stopCalls, 0);
// The orphaned drain released the runner, and only then did the freshly
// attached Turn of session-2 start and complete.
await waitFor(() => terminal.progressStates.at(-1) === false);

// A follow-up prompt lands on the adopted Session.
terminal.input('next step');
terminal.input('\r');
await waitFor(() => driver.displayPrompts.includes('next step'));
assert.deepEqual(driver.sessionIds, ['session-2']);

terminal.input('/exit');
terminal.input('\r');
await run;
});

test('/session mid-turn opens the picker and Escape closes it without arming an interrupt', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
assert.doesNotMatch(
plainTerminalOutput(terminal.output()),
/Cannot run \/session while a turn is running/,
);

// Escape belongs to the overlay while it is open: closing it must not
// arm the double-Escape interrupt — a second Escape would otherwise
// abort the very Turn the user is navigating away from (#3380).
terminal.input('\x1b');
await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
await delay(50);
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

// Settle the parked Turn normally, then leave.
driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a failed mid-turn /session leaves the running Turn fully live', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// A rejected switch must not orphan the in-flight drain: the error is
// reported, nothing was switched, and the Turn keeps streaming into the
// same transcript.
driver.failNextSwitch = true;
terminal.input('/session does-not-exist');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.output()).includes('session not found'));
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

driver.emit({
type: 'text_delta',
id: 'delta-after-failure',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 3,
text: 'still streaming after failure',
});
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('still streaming after failure'),
);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a second mid-turn /session while a detach is in flight is ignored', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// Park the first switch inside driver.switchSession, then fire a second
// /session while `detaching` is still held: re-entry would clear the
// flag early, reopen the interrupt window, and double-apply adoption.
let releaseSwitch!: () => void;
driver.holdSwitch = new Promise<void>((resolve) => {
releaseSwitch = resolve;
});
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() => driver.switchEntries >= 1);
terminal.input('/session session-2');
terminal.input('\r');
releaseSwitch();

await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// Exactly one detach notice: the second switch never ran.
const notices = plainTerminalOutput(terminal.output()).match(
/Detached from the running Turn/g,
);
assert.equal(notices?.length, 1);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a turn prepared after a mid-turn detach does not adopt abandoned metadata', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

// Park preparePrompt itself: while it is unresolved, /session can
// already detach — onPrepared/onSkillInvocation then fire for the
// abandoned Turn after the epoch fence moved.
let releasePrepare!: () => void;
const parkedPrepare = new Promise<void>((resolve) => {
releasePrepare = resolve;
});
const basePrepare = driver.preparePrompt.bind(driver);
driver.preparePrompt = async (prompt, options) => {
const turn = await basePrepare(prompt, options);
await parkedPrepare;
return {
...turn,
summary: fakeSessionSummary('abandoned-session', '/abandoned-cwd', 'ABANDONED TITLE'),
};
};

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session session-2');
terminal.input('\r');
// Nothing else drives the frame loop while preparePrompt stays parked,
// so force a repaint for the detach notices.
terminal.resize(80, 24);
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// The abandoned Turn's prepare resolves only now — its summary must
// not steal the adopted Session's metadata.
releasePrepare();
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
assert.equal(terminal.titles.includes('ABANDONED TITLE (Maka)'), false);
assert.equal(terminal.titles.at(-1), 'Existing chat (Maka)');
assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /\/abandoned-cwd/);

await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('unknown slash-prefixed text still steers into the running turn', async () => {
const terminal = new FakeTerminal();
const driver = new SteeringTurnDriver();
Expand DownExpand Up@@ -6888,6 +7155,110 @@ class ActiveResumeDriver extends SlashCommandDriver {
}
}

// A parking first Turn on session-1 plus a switchable session-2, for the
// mid-turn /session detach tests (#3380). The parked stream ends only when
// the test releases it or stop() lands — a detach leaves it running, exactly
// like a Host-owned Turn surviving a client that switches away. Later prompts
// (submitted after switching) complete immediately.
class DetachingSwitchDriver extends SlashCommandDriver {
stopCalls = 0;
/** When set, the next switchSession rejects — a failed detach must leave
* the running drain fully live. */
failNextSwitch = false;
/** When set, the next switchSession parks until released — a second
* mid-turn /session arriving while the first is still in flight. */
holdSwitch: Promise<void> | undefined;
switchEntries = 0;
private pendingEvents: SessionEvent[] = [];
private wakeTurn: (() => void) | null = null;
private turnEnded = false;
private promptCount = 0;

constructor(sessionTwoMessages: StoredMessage[]) {
super([fakeSessionSummary('session-2', '/repo')], new Map([['session-2', sessionTwoMessages]]));
}

/** Queues an event onto the parked first-session Turn. */
emit(event: SessionEvent): void {
this.pendingEvents.push(event);
this.wakeTurn?.();
this.wakeTurn = null;
}

/** Ends the parked stream the way a Host does when its Turn settles. */
releaseOldTurn(): void {
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

override async *promptEvents(_prompt: string, turnId = 'turn-1'): AsyncIterable<SessionEvent> {
this.promptCount += 1;
if (this.promptCount > 1) {
yield { type: 'complete', id: `complete-${turnId}`, turnId, ts: 9, stopReason: 'end_turn' };
return;
}
for (;;) {
while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!;
if (this.turnEnded) break;
await new Promise<void>((resolve) => {
this.wakeTurn = resolve;
});
}
yield { type: 'abort', id: 'abort-old', turnId, ts: 8, reason: 'user_stop' };
yield { type: 'complete', id: 'complete-old', turnId, ts: 9, stopReason: 'user_stop' };
}

override async stop(): Promise<void> {
this.stopCalls += 1;
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

// session-2 carries a live Turn, so adopting it hands back an activeTurn —
// the reattach path the runner must start once the orphaned drain unwinds.
override async switchSession(sessionId: string): Promise<MakaSessionSwitchResult> {
this.switchEntries += 1;
if (this.failNextSwitch) {
this.failNextSwitch = false;
throw new Error('session not found');
}
if (this.holdSwitch) {
const gate = this.holdSwitch;
this.holdSwitch = undefined;
await gate;
}
const switched = await super.switchSession(sessionId);
if (sessionId !== 'session-2') return switched;
const attachedTurnId = 'turn-attached-2';
return {
...switched,
activeTurn: {
sessionId,
turnId: attachedTurnId,
events: (async function* () {
yield {
type: 'text_complete',
id: 'text-attached',
turnId: attachedTurnId,
messageId: 'assistant-attached',
ts: 3,
text: 'attached replay done',
} satisfies SessionEvent;
yield {
type: 'complete',
id: 'complete-attached',
turnId: attachedTurnId,
ts: 4,
stopReason: 'end_turn',
} satisfies SessionEvent;
})(),
},
};
}
}

class HostSuccessorDriver extends SlashCommandDriver {
#startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined;
readonly #probeFirst = deferred<void>();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): let /session detach from a running Turn instead of trapping the client by me2seeks · Pull Request #3498 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
371 changes: 371 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5047,6 +5047,273 @@ describe('Maka Pi TUI runner', () => {
await run;
});

test('/session <id> mid-turn detaches from the running Turn instead of refusing', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// The escape hatch a second TUI needs (#3380): switching Sessions
// mid-turn detaches the view and leaves the Host-owned Turn running,
// instead of refusing (trapping the client) or stopping the Turn.
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// The adopted Session's history replaced the old transcript.
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// Late events from the abandoned Turn never reach the adopted
// transcript — neither as content nor as a synthesized failure about
// the stream ending without a completion event.
driver.emit({
type: 'text_delta',
id: 'delta-leak',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 2,
text: 'LEAK-OLD-DELTA',
});
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
const after = plainTerminalOutput(terminal.output());
assert.doesNotMatch(after, /LEAK-OLD-DELTA/);
assert.doesNotMatch(after, /without a completion event/);
assert.equal(driver.stopCalls, 0);
// The orphaned drain released the runner, and only then did the freshly
// attached Turn of session-2 start and complete.
await waitFor(() => terminal.progressStates.at(-1) === false);

// A follow-up prompt lands on the adopted Session.
terminal.input('next step');
terminal.input('\r');
await waitFor(() => driver.displayPrompts.includes('next step'));
assert.deepEqual(driver.sessionIds, ['session-2']);

terminal.input('/exit');
terminal.input('\r');
await run;
});

test('/session mid-turn opens the picker and Escape closes it without arming an interrupt', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
assert.doesNotMatch(
plainTerminalOutput(terminal.output()),
/Cannot run \/session while a turn is running/,
);

// Escape belongs to the overlay while it is open: closing it must not
// arm the double-Escape interrupt — a second Escape would otherwise
// abort the very Turn the user is navigating away from (#3380).
terminal.input('\x1b');
await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
await delay(50);
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

// Settle the parked Turn normally, then leave.
driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a failed mid-turn /session leaves the running Turn fully live', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// A rejected switch must not orphan the in-flight drain: the error is
// reported, nothing was switched, and the Turn keeps streaming into the
// same transcript.
driver.failNextSwitch = true;
terminal.input('/session does-not-exist');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.output()).includes('session not found'));
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

driver.emit({
type: 'text_delta',
id: 'delta-after-failure',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 3,
text: 'still streaming after failure',
});
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('still streaming after failure'),
);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a second mid-turn /session while a detach is in flight is ignored', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// Park the first switch inside driver.switchSession, then fire a second
// /session while `detaching` is still held: re-entry would clear the
// flag early, reopen the interrupt window, and double-apply adoption.
let releaseSwitch!: () => void;
driver.holdSwitch = new Promise<void>((resolve) => {
releaseSwitch = resolve;
});
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() => driver.switchEntries >= 1);
terminal.input('/session session-2');
terminal.input('\r');
releaseSwitch();

await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// Exactly one detach notice: the second switch never ran.
const notices = plainTerminalOutput(terminal.output()).match(
/Detached from the running Turn/g,
);
assert.equal(notices?.length, 1);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a turn prepared after a mid-turn detach does not adopt abandoned metadata', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

// Park preparePrompt itself: while it is unresolved, /session can
// already detach — onPrepared/onSkillInvocation then fire for the
// abandoned Turn after the epoch fence moved.
let releasePrepare!: () => void;
const parkedPrepare = new Promise<void>((resolve) => {
releasePrepare = resolve;
});
const basePrepare = driver.preparePrompt.bind(driver);
driver.preparePrompt = async (prompt, options) => {
const turn = await basePrepare(prompt, options);
await parkedPrepare;
return {
...turn,
summary: fakeSessionSummary('abandoned-session', '/abandoned-cwd', 'ABANDONED TITLE'),
};
};

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session session-2');
terminal.input('\r');
// Nothing else drives the frame loop while preparePrompt stays parked,
// so force a repaint for the detach notices.
terminal.resize(80, 24);
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// The abandoned Turn's prepare resolves only now — its summary must
// not steal the adopted Session's metadata.
releasePrepare();
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
assert.equal(terminal.titles.includes('ABANDONED TITLE (Maka)'), false);
assert.equal(terminal.titles.at(-1), 'Existing chat (Maka)');
assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /\/abandoned-cwd/);

await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('unknown slash-prefixed text still steers into the running turn', async () => {
const terminal = new FakeTerminal();
const driver = new SteeringTurnDriver();
Expand DownExpand Up@@ -6888,6 +7155,110 @@ class ActiveResumeDriver extends SlashCommandDriver {
}
}

// A parking first Turn on session-1 plus a switchable session-2, for the
// mid-turn /session detach tests (#3380). The parked stream ends only when
// the test releases it or stop() lands — a detach leaves it running, exactly
// like a Host-owned Turn surviving a client that switches away. Later prompts
// (submitted after switching) complete immediately.
class DetachingSwitchDriver extends SlashCommandDriver {
stopCalls = 0;
/** When set, the next switchSession rejects — a failed detach must leave
* the running drain fully live. */
failNextSwitch = false;
/** When set, the next switchSession parks until released — a second
* mid-turn /session arriving while the first is still in flight. */
holdSwitch: Promise<void> | undefined;
switchEntries = 0;
private pendingEvents: SessionEvent[] = [];
private wakeTurn: (() => void) | null = null;
private turnEnded = false;
private promptCount = 0;

constructor(sessionTwoMessages: StoredMessage[]) {
super([fakeSessionSummary('session-2', '/repo')], new Map([['session-2', sessionTwoMessages]]));
}

/** Queues an event onto the parked first-session Turn. */
emit(event: SessionEvent): void {
this.pendingEvents.push(event);
this.wakeTurn?.();
this.wakeTurn = null;
}

/** Ends the parked stream the way a Host does when its Turn settles. */
releaseOldTurn(): void {
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

override async *promptEvents(_prompt: string, turnId = 'turn-1'): AsyncIterable<SessionEvent> {
this.promptCount += 1;
if (this.promptCount > 1) {
yield { type: 'complete', id: `complete-${turnId}`, turnId, ts: 9, stopReason: 'end_turn' };
return;
}
for (;;) {
while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!;
if (this.turnEnded) break;
await new Promise<void>((resolve) => {
this.wakeTurn = resolve;
});
}
yield { type: 'abort', id: 'abort-old', turnId, ts: 8, reason: 'user_stop' };
yield { type: 'complete', id: 'complete-old', turnId, ts: 9, stopReason: 'user_stop' };
}

override async stop(): Promise<void> {
this.stopCalls += 1;
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

// session-2 carries a live Turn, so adopting it hands back an activeTurn —
// the reattach path the runner must start once the orphaned drain unwinds.
override async switchSession(sessionId: string): Promise<MakaSessionSwitchResult> {
this.switchEntries += 1;
if (this.failNextSwitch) {
this.failNextSwitch = false;
throw new Error('session not found');
}
if (this.holdSwitch) {
const gate = this.holdSwitch;
this.holdSwitch = undefined;
await gate;
}
const switched = await super.switchSession(sessionId);
if (sessionId !== 'session-2') return switched;
const attachedTurnId = 'turn-attached-2';
return {
...switched,
activeTurn: {
sessionId,
turnId: attachedTurnId,
events: (async function* () {
yield {
type: 'text_complete',
id: 'text-attached',
turnId: attachedTurnId,
messageId: 'assistant-attached',
ts: 3,
text: 'attached replay done',
} satisfies SessionEvent;
yield {
type: 'complete',
id: 'complete-attached',
turnId: attachedTurnId,
ts: 4,
stopReason: 'end_turn',
} satisfies SessionEvent;
})(),
},
};
}
}

class HostSuccessorDriver extends SlashCommandDriver {
#startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined;
readonly #probeFirst = deferred<void>();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): let /session detach from a running Turn instead of trapping the client by me2seeks · Pull Request #3498 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
371 changes: 371 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5047,6 +5047,273 @@ describe('Maka Pi TUI runner', () => {
await run;
});

test('/session <id> mid-turn detaches from the running Turn instead of refusing', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// The escape hatch a second TUI needs (#3380): switching Sessions
// mid-turn detaches the view and leaves the Host-owned Turn running,
// instead of refusing (trapping the client) or stopping the Turn.
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// The adopted Session's history replaced the old transcript.
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// Late events from the abandoned Turn never reach the adopted
// transcript — neither as content nor as a synthesized failure about
// the stream ending without a completion event.
driver.emit({
type: 'text_delta',
id: 'delta-leak',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 2,
text: 'LEAK-OLD-DELTA',
});
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
const after = plainTerminalOutput(terminal.output());
assert.doesNotMatch(after, /LEAK-OLD-DELTA/);
assert.doesNotMatch(after, /without a completion event/);
assert.equal(driver.stopCalls, 0);
// The orphaned drain released the runner, and only then did the freshly
// attached Turn of session-2 start and complete.
await waitFor(() => terminal.progressStates.at(-1) === false);

// A follow-up prompt lands on the adopted Session.
terminal.input('next step');
terminal.input('\r');
await waitFor(() => driver.displayPrompts.includes('next step'));
assert.deepEqual(driver.sessionIds, ['session-2']);

terminal.input('/exit');
terminal.input('\r');
await run;
});

test('/session mid-turn opens the picker and Escape closes it without arming an interrupt', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
assert.doesNotMatch(
plainTerminalOutput(terminal.output()),
/Cannot run \/session while a turn is running/,
);

// Escape belongs to the overlay while it is open: closing it must not
// arm the double-Escape interrupt — a second Escape would otherwise
// abort the very Turn the user is navigating away from (#3380).
terminal.input('\x1b');
await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
await delay(50);
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

// Settle the parked Turn normally, then leave.
driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a failed mid-turn /session leaves the running Turn fully live', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// A rejected switch must not orphan the in-flight drain: the error is
// reported, nothing was switched, and the Turn keeps streaming into the
// same transcript.
driver.failNextSwitch = true;
terminal.input('/session does-not-exist');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.output()).includes('session not found'));
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

driver.emit({
type: 'text_delta',
id: 'delta-after-failure',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 3,
text: 'still streaming after failure',
});
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('still streaming after failure'),
);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a second mid-turn /session while a detach is in flight is ignored', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// Park the first switch inside driver.switchSession, then fire a second
// /session while `detaching` is still held: re-entry would clear the
// flag early, reopen the interrupt window, and double-apply adoption.
let releaseSwitch!: () => void;
driver.holdSwitch = new Promise<void>((resolve) => {
releaseSwitch = resolve;
});
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() => driver.switchEntries >= 1);
terminal.input('/session session-2');
terminal.input('\r');
releaseSwitch();

await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// Exactly one detach notice: the second switch never ran.
const notices = plainTerminalOutput(terminal.output()).match(
/Detached from the running Turn/g,
);
assert.equal(notices?.length, 1);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a turn prepared after a mid-turn detach does not adopt abandoned metadata', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

// Park preparePrompt itself: while it is unresolved, /session can
// already detach — onPrepared/onSkillInvocation then fire for the
// abandoned Turn after the epoch fence moved.
let releasePrepare!: () => void;
const parkedPrepare = new Promise<void>((resolve) => {
releasePrepare = resolve;
});
const basePrepare = driver.preparePrompt.bind(driver);
driver.preparePrompt = async (prompt, options) => {
const turn = await basePrepare(prompt, options);
await parkedPrepare;
return {
...turn,
summary: fakeSessionSummary('abandoned-session', '/abandoned-cwd', 'ABANDONED TITLE'),
};
};

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session session-2');
terminal.input('\r');
// Nothing else drives the frame loop while preparePrompt stays parked,
// so force a repaint for the detach notices.
terminal.resize(80, 24);
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// The abandoned Turn's prepare resolves only now — its summary must
// not steal the adopted Session's metadata.
releasePrepare();
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
assert.equal(terminal.titles.includes('ABANDONED TITLE (Maka)'), false);
assert.equal(terminal.titles.at(-1), 'Existing chat (Maka)');
assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /\/abandoned-cwd/);

await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('unknown slash-prefixed text still steers into the running turn', async () => {
const terminal = new FakeTerminal();
const driver = new SteeringTurnDriver();
Expand DownExpand Up@@ -6888,6 +7155,110 @@ class ActiveResumeDriver extends SlashCommandDriver {
}
}

// A parking first Turn on session-1 plus a switchable session-2, for the
// mid-turn /session detach tests (#3380). The parked stream ends only when
// the test releases it or stop() lands — a detach leaves it running, exactly
// like a Host-owned Turn surviving a client that switches away. Later prompts
// (submitted after switching) complete immediately.
class DetachingSwitchDriver extends SlashCommandDriver {
stopCalls = 0;
/** When set, the next switchSession rejects — a failed detach must leave
* the running drain fully live. */
failNextSwitch = false;
/** When set, the next switchSession parks until released — a second
* mid-turn /session arriving while the first is still in flight. */
holdSwitch: Promise<void> | undefined;
switchEntries = 0;
private pendingEvents: SessionEvent[] = [];
private wakeTurn: (() => void) | null = null;
private turnEnded = false;
private promptCount = 0;

constructor(sessionTwoMessages: StoredMessage[]) {
super([fakeSessionSummary('session-2', '/repo')], new Map([['session-2', sessionTwoMessages]]));
}

/** Queues an event onto the parked first-session Turn. */
emit(event: SessionEvent): void {
this.pendingEvents.push(event);
this.wakeTurn?.();
this.wakeTurn = null;
}

/** Ends the parked stream the way a Host does when its Turn settles. */
releaseOldTurn(): void {
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

override async *promptEvents(_prompt: string, turnId = 'turn-1'): AsyncIterable<SessionEvent> {
this.promptCount += 1;
if (this.promptCount > 1) {
yield { type: 'complete', id: `complete-${turnId}`, turnId, ts: 9, stopReason: 'end_turn' };
return;
}
for (;;) {
while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!;
if (this.turnEnded) break;
await new Promise<void>((resolve) => {
this.wakeTurn = resolve;
});
}
yield { type: 'abort', id: 'abort-old', turnId, ts: 8, reason: 'user_stop' };
yield { type: 'complete', id: 'complete-old', turnId, ts: 9, stopReason: 'user_stop' };
}

override async stop(): Promise<void> {
this.stopCalls += 1;
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

// session-2 carries a live Turn, so adopting it hands back an activeTurn —
// the reattach path the runner must start once the orphaned drain unwinds.
override async switchSession(sessionId: string): Promise<MakaSessionSwitchResult> {
this.switchEntries += 1;
if (this.failNextSwitch) {
this.failNextSwitch = false;
throw new Error('session not found');
}
if (this.holdSwitch) {
const gate = this.holdSwitch;
this.holdSwitch = undefined;
await gate;
}
const switched = await super.switchSession(sessionId);
if (sessionId !== 'session-2') return switched;
const attachedTurnId = 'turn-attached-2';
return {
...switched,
activeTurn: {
sessionId,
turnId: attachedTurnId,
events: (async function* () {
yield {
type: 'text_complete',
id: 'text-attached',
turnId: attachedTurnId,
messageId: 'assistant-attached',
ts: 3,
text: 'attached replay done',
} satisfies SessionEvent;
yield {
type: 'complete',
id: 'complete-attached',
turnId: attachedTurnId,
ts: 4,
stopReason: 'end_turn',
} satisfies SessionEvent;
})(),
},
};
}
}

class HostSuccessorDriver extends SlashCommandDriver {
#startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined;
readonly #probeFirst = deferred<void>();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(cli): let /session detach from a running Turn instead of trapping the client by me2seeks · Pull Request #3498 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
371 changes: 371 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5047,6 +5047,273 @@ describe('Maka Pi TUI runner', () => {
await run;
});

test('/session <id> mid-turn detaches from the running Turn instead of refusing', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// The escape hatch a second TUI needs (#3380): switching Sessions
// mid-turn detaches the view and leaves the Host-owned Turn running,
// instead of refusing (trapping the client) or stopping the Turn.
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// The adopted Session's history replaced the old transcript.
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// Late events from the abandoned Turn never reach the adopted
// transcript — neither as content nor as a synthesized failure about
// the stream ending without a completion event.
driver.emit({
type: 'text_delta',
id: 'delta-leak',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 2,
text: 'LEAK-OLD-DELTA',
});
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
const after = plainTerminalOutput(terminal.output());
assert.doesNotMatch(after, /LEAK-OLD-DELTA/);
assert.doesNotMatch(after, /without a completion event/);
assert.equal(driver.stopCalls, 0);
// The orphaned drain released the runner, and only then did the freshly
// attached Turn of session-2 start and complete.
await waitFor(() => terminal.progressStates.at(-1) === false);

// A follow-up prompt lands on the adopted Session.
terminal.input('next step');
terminal.input('\r');
await waitFor(() => driver.displayPrompts.includes('next step'));
assert.deepEqual(driver.sessionIds, ['session-2']);

terminal.input('/exit');
terminal.input('\r');
await run;
});

test('/session mid-turn opens the picker and Escape closes it without arming an interrupt', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
assert.doesNotMatch(
plainTerminalOutput(terminal.output()),
/Cannot run \/session while a turn is running/,
);

// Escape belongs to the overlay while it is open: closing it must not
// arm the double-Escape interrupt — a second Escape would otherwise
// abort the very Turn the user is navigating away from (#3380).
terminal.input('\x1b');
await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
await delay(50);
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

// Settle the parked Turn normally, then leave.
driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a failed mid-turn /session leaves the running Turn fully live', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// A rejected switch must not orphan the in-flight drain: the error is
// reported, nothing was switched, and the Turn keeps streaming into the
// same transcript.
driver.failNextSwitch = true;
terminal.input('/session does-not-exist');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.output()).includes('session not found'));
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

driver.emit({
type: 'text_delta',
id: 'delta-after-failure',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 3,
text: 'still streaming after failure',
});
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('still streaming after failure'),
);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a second mid-turn /session while a detach is in flight is ignored', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// Park the first switch inside driver.switchSession, then fire a second
// /session while `detaching` is still held: re-entry would clear the
// flag early, reopen the interrupt window, and double-apply adoption.
let releaseSwitch!: () => void;
driver.holdSwitch = new Promise<void>((resolve) => {
releaseSwitch = resolve;
});
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() => driver.switchEntries >= 1);
terminal.input('/session session-2');
terminal.input('\r');
releaseSwitch();

await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// Exactly one detach notice: the second switch never ran.
const notices = plainTerminalOutput(terminal.output()).match(
/Detached from the running Turn/g,
);
assert.equal(notices?.length, 1);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a turn prepared after a mid-turn detach does not adopt abandoned metadata', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

// Park preparePrompt itself: while it is unresolved, /session can
// already detach — onPrepared/onSkillInvocation then fire for the
// abandoned Turn after the epoch fence moved.
let releasePrepare!: () => void;
const parkedPrepare = new Promise<void>((resolve) => {
releasePrepare = resolve;
});
const basePrepare = driver.preparePrompt.bind(driver);
driver.preparePrompt = async (prompt, options) => {
const turn = await basePrepare(prompt, options);
await parkedPrepare;
return {
...turn,
summary: fakeSessionSummary('abandoned-session', '/abandoned-cwd', 'ABANDONED TITLE'),
};
};

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session session-2');
terminal.input('\r');
// Nothing else drives the frame loop while preparePrompt stays parked,
// so force a repaint for the detach notices.
terminal.resize(80, 24);
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// The abandoned Turn's prepare resolves only now — its summary must
// not steal the adopted Session's metadata.
releasePrepare();
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
assert.equal(terminal.titles.includes('ABANDONED TITLE (Maka)'), false);
assert.equal(terminal.titles.at(-1), 'Existing chat (Maka)');
assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /\/abandoned-cwd/);

await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('unknown slash-prefixed text still steers into the running turn', async () => {
const terminal = new FakeTerminal();
const driver = new SteeringTurnDriver();
Expand DownExpand Up@@ -6888,6 +7155,110 @@ class ActiveResumeDriver extends SlashCommandDriver {
}
}

// A parking first Turn on session-1 plus a switchable session-2, for the
// mid-turn /session detach tests (#3380). The parked stream ends only when
// the test releases it or stop() lands — a detach leaves it running, exactly
// like a Host-owned Turn surviving a client that switches away. Later prompts
// (submitted after switching) complete immediately.
class DetachingSwitchDriver extends SlashCommandDriver {
stopCalls = 0;
/** When set, the next switchSession rejects — a failed detach must leave
* the running drain fully live. */
failNextSwitch = false;
/** When set, the next switchSession parks until released — a second
* mid-turn /session arriving while the first is still in flight. */
holdSwitch: Promise<void> | undefined;
switchEntries = 0;
private pendingEvents: SessionEvent[] = [];
private wakeTurn: (() => void) | null = null;
private turnEnded = false;
private promptCount = 0;

constructor(sessionTwoMessages: StoredMessage[]) {
super([fakeSessionSummary('session-2', '/repo')], new Map([['session-2', sessionTwoMessages]]));
}

/** Queues an event onto the parked first-session Turn. */
emit(event: SessionEvent): void {
this.pendingEvents.push(event);
this.wakeTurn?.();
this.wakeTurn = null;
}

/** Ends the parked stream the way a Host does when its Turn settles. */
releaseOldTurn(): void {
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

override async *promptEvents(_prompt: string, turnId = 'turn-1'): AsyncIterable<SessionEvent> {
this.promptCount += 1;
if (this.promptCount > 1) {
yield { type: 'complete', id: `complete-${turnId}`, turnId, ts: 9, stopReason: 'end_turn' };
return;
}
for (;;) {
while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!;
if (this.turnEnded) break;
await new Promise<void>((resolve) => {
this.wakeTurn = resolve;
});
}
yield { type: 'abort', id: 'abort-old', turnId, ts: 8, reason: 'user_stop' };
yield { type: 'complete', id: 'complete-old', turnId, ts: 9, stopReason: 'user_stop' };
}

override async stop(): Promise<void> {
this.stopCalls += 1;
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

// session-2 carries a live Turn, so adopting it hands back an activeTurn —
// the reattach path the runner must start once the orphaned drain unwinds.
override async switchSession(sessionId: string): Promise<MakaSessionSwitchResult> {
this.switchEntries += 1;
if (this.failNextSwitch) {
this.failNextSwitch = false;
throw new Error('session not found');
}
if (this.holdSwitch) {
const gate = this.holdSwitch;
this.holdSwitch = undefined;
await gate;
}
const switched = await super.switchSession(sessionId);
if (sessionId !== 'session-2') return switched;
const attachedTurnId = 'turn-attached-2';
return {
...switched,
activeTurn: {
sessionId,
turnId: attachedTurnId,
events: (async function* () {
yield {
type: 'text_complete',
id: 'text-attached',
turnId: attachedTurnId,
messageId: 'assistant-attached',
ts: 3,
text: 'attached replay done',
} satisfies SessionEvent;
yield {
type: 'complete',
id: 'complete-attached',
turnId: attachedTurnId,
ts: 4,
stopReason: 'end_turn',
} satisfies SessionEvent;
})(),
},
};
}
}

class HostSuccessorDriver extends SlashCommandDriver {
#startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined;
readonly #probeFirst = deferred<void>();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): let /session detach from a running Turn instead of trapping the client by me2seeks · Pull Request #3498 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
371 changes: 371 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5047,6 +5047,273 @@ describe('Maka Pi TUI runner', () => {
await run;
});

test('/session <id> mid-turn detaches from the running Turn instead of refusing', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// The escape hatch a second TUI needs (#3380): switching Sessions
// mid-turn detaches the view and leaves the Host-owned Turn running,
// instead of refusing (trapping the client) or stopping the Turn.
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// The adopted Session's history replaced the old transcript.
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// Late events from the abandoned Turn never reach the adopted
// transcript — neither as content nor as a synthesized failure about
// the stream ending without a completion event.
driver.emit({
type: 'text_delta',
id: 'delta-leak',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 2,
text: 'LEAK-OLD-DELTA',
});
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
const after = plainTerminalOutput(terminal.output());
assert.doesNotMatch(after, /LEAK-OLD-DELTA/);
assert.doesNotMatch(after, /without a completion event/);
assert.equal(driver.stopCalls, 0);
// The orphaned drain released the runner, and only then did the freshly
// attached Turn of session-2 start and complete.
await waitFor(() => terminal.progressStates.at(-1) === false);

// A follow-up prompt lands on the adopted Session.
terminal.input('next step');
terminal.input('\r');
await waitFor(() => driver.displayPrompts.includes('next step'));
assert.deepEqual(driver.sessionIds, ['session-2']);

terminal.input('/exit');
terminal.input('\r');
await run;
});

test('/session mid-turn opens the picker and Escape closes it without arming an interrupt', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
assert.doesNotMatch(
plainTerminalOutput(terminal.output()),
/Cannot run \/session while a turn is running/,
);

// Escape belongs to the overlay while it is open: closing it must not
// arm the double-Escape interrupt — a second Escape would otherwise
// abort the very Turn the user is navigating away from (#3380).
terminal.input('\x1b');
await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
await delay(50);
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

// Settle the parked Turn normally, then leave.
driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a failed mid-turn /session leaves the running Turn fully live', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// A rejected switch must not orphan the in-flight drain: the error is
// reported, nothing was switched, and the Turn keeps streaming into the
// same transcript.
driver.failNextSwitch = true;
terminal.input('/session does-not-exist');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.output()).includes('session not found'));
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

driver.emit({
type: 'text_delta',
id: 'delta-after-failure',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 3,
text: 'still streaming after failure',
});
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('still streaming after failure'),
);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a second mid-turn /session while a detach is in flight is ignored', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// Park the first switch inside driver.switchSession, then fire a second
// /session while `detaching` is still held: re-entry would clear the
// flag early, reopen the interrupt window, and double-apply adoption.
let releaseSwitch!: () => void;
driver.holdSwitch = new Promise<void>((resolve) => {
releaseSwitch = resolve;
});
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() => driver.switchEntries >= 1);
terminal.input('/session session-2');
terminal.input('\r');
releaseSwitch();

await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// Exactly one detach notice: the second switch never ran.
const notices = plainTerminalOutput(terminal.output()).match(
/Detached from the running Turn/g,
);
assert.equal(notices?.length, 1);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a turn prepared after a mid-turn detach does not adopt abandoned metadata', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

// Park preparePrompt itself: while it is unresolved, /session can
// already detach — onPrepared/onSkillInvocation then fire for the
// abandoned Turn after the epoch fence moved.
let releasePrepare!: () => void;
const parkedPrepare = new Promise<void>((resolve) => {
releasePrepare = resolve;
});
const basePrepare = driver.preparePrompt.bind(driver);
driver.preparePrompt = async (prompt, options) => {
const turn = await basePrepare(prompt, options);
await parkedPrepare;
return {
...turn,
summary: fakeSessionSummary('abandoned-session', '/abandoned-cwd', 'ABANDONED TITLE'),
};
};

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session session-2');
terminal.input('\r');
// Nothing else drives the frame loop while preparePrompt stays parked,
// so force a repaint for the detach notices.
terminal.resize(80, 24);
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// The abandoned Turn's prepare resolves only now — its summary must
// not steal the adopted Session's metadata.
releasePrepare();
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
assert.equal(terminal.titles.includes('ABANDONED TITLE (Maka)'), false);
assert.equal(terminal.titles.at(-1), 'Existing chat (Maka)');
assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /\/abandoned-cwd/);

await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('unknown slash-prefixed text still steers into the running turn', async () => {
const terminal = new FakeTerminal();
const driver = new SteeringTurnDriver();
Expand DownExpand Up@@ -6888,6 +7155,110 @@ class ActiveResumeDriver extends SlashCommandDriver {
}
}

// A parking first Turn on session-1 plus a switchable session-2, for the
// mid-turn /session detach tests (#3380). The parked stream ends only when
// the test releases it or stop() lands — a detach leaves it running, exactly
// like a Host-owned Turn surviving a client that switches away. Later prompts
// (submitted after switching) complete immediately.
class DetachingSwitchDriver extends SlashCommandDriver {
stopCalls = 0;
/** When set, the next switchSession rejects — a failed detach must leave
* the running drain fully live. */
failNextSwitch = false;
/** When set, the next switchSession parks until released — a second
* mid-turn /session arriving while the first is still in flight. */
holdSwitch: Promise<void> | undefined;
switchEntries = 0;
private pendingEvents: SessionEvent[] = [];
private wakeTurn: (() => void) | null = null;
private turnEnded = false;
private promptCount = 0;

constructor(sessionTwoMessages: StoredMessage[]) {
super([fakeSessionSummary('session-2', '/repo')], new Map([['session-2', sessionTwoMessages]]));
}

/** Queues an event onto the parked first-session Turn. */
emit(event: SessionEvent): void {
this.pendingEvents.push(event);
this.wakeTurn?.();
this.wakeTurn = null;
}

/** Ends the parked stream the way a Host does when its Turn settles. */
releaseOldTurn(): void {
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

override async *promptEvents(_prompt: string, turnId = 'turn-1'): AsyncIterable<SessionEvent> {
this.promptCount += 1;
if (this.promptCount > 1) {
yield { type: 'complete', id: `complete-${turnId}`, turnId, ts: 9, stopReason: 'end_turn' };
return;
}
for (;;) {
while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!;
if (this.turnEnded) break;
await new Promise<void>((resolve) => {
this.wakeTurn = resolve;
});
}
yield { type: 'abort', id: 'abort-old', turnId, ts: 8, reason: 'user_stop' };
yield { type: 'complete', id: 'complete-old', turnId, ts: 9, stopReason: 'user_stop' };
}

override async stop(): Promise<void> {
this.stopCalls += 1;
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

// session-2 carries a live Turn, so adopting it hands back an activeTurn —
// the reattach path the runner must start once the orphaned drain unwinds.
override async switchSession(sessionId: string): Promise<MakaSessionSwitchResult> {
this.switchEntries += 1;
if (this.failNextSwitch) {
this.failNextSwitch = false;
throw new Error('session not found');
}
if (this.holdSwitch) {
const gate = this.holdSwitch;
this.holdSwitch = undefined;
await gate;
}
const switched = await super.switchSession(sessionId);
if (sessionId !== 'session-2') return switched;
const attachedTurnId = 'turn-attached-2';
return {
...switched,
activeTurn: {
sessionId,
turnId: attachedTurnId,
events: (async function* () {
yield {
type: 'text_complete',
id: 'text-attached',
turnId: attachedTurnId,
messageId: 'assistant-attached',
ts: 3,
text: 'attached replay done',
} satisfies SessionEvent;
yield {
type: 'complete',
id: 'complete-attached',
turnId: attachedTurnId,
ts: 4,
stopReason: 'end_turn',
} satisfies SessionEvent;
})(),
},
};
}
}

class HostSuccessorDriver extends SlashCommandDriver {
#startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined;
readonly #probeFirst = deferred<void>();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(cli): let /session detach from a running Turn instead of trapping the client by me2seeks · Pull Request #3498 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
371 changes: 371 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5047,6 +5047,273 @@ describe('Maka Pi TUI runner', () => {
await run;
});

test('/session <id> mid-turn detaches from the running Turn instead of refusing', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// The escape hatch a second TUI needs (#3380): switching Sessions
// mid-turn detaches the view and leaves the Host-owned Turn running,
// instead of refusing (trapping the client) or stopping the Turn.
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// The adopted Session's history replaced the old transcript.
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// Late events from the abandoned Turn never reach the adopted
// transcript — neither as content nor as a synthesized failure about
// the stream ending without a completion event.
driver.emit({
type: 'text_delta',
id: 'delta-leak',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 2,
text: 'LEAK-OLD-DELTA',
});
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
const after = plainTerminalOutput(terminal.output());
assert.doesNotMatch(after, /LEAK-OLD-DELTA/);
assert.doesNotMatch(after, /without a completion event/);
assert.equal(driver.stopCalls, 0);
// The orphaned drain released the runner, and only then did the freshly
// attached Turn of session-2 start and complete.
await waitFor(() => terminal.progressStates.at(-1) === false);

// A follow-up prompt lands on the adopted Session.
terminal.input('next step');
terminal.input('\r');
await waitFor(() => driver.displayPrompts.includes('next step'));
assert.deepEqual(driver.sessionIds, ['session-2']);

terminal.input('/exit');
terminal.input('\r');
await run;
});

test('/session mid-turn opens the picker and Escape closes it without arming an interrupt', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
assert.doesNotMatch(
plainTerminalOutput(terminal.output()),
/Cannot run \/session while a turn is running/,
);

// Escape belongs to the overlay while it is open: closing it must not
// arm the double-Escape interrupt — a second Escape would otherwise
// abort the very Turn the user is navigating away from (#3380).
terminal.input('\x1b');
await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
await delay(50);
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

// Settle the parked Turn normally, then leave.
driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a failed mid-turn /session leaves the running Turn fully live', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// A rejected switch must not orphan the in-flight drain: the error is
// reported, nothing was switched, and the Turn keeps streaming into the
// same transcript.
driver.failNextSwitch = true;
terminal.input('/session does-not-exist');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.output()).includes('session not found'));
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

driver.emit({
type: 'text_delta',
id: 'delta-after-failure',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 3,
text: 'still streaming after failure',
});
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('still streaming after failure'),
);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a second mid-turn /session while a detach is in flight is ignored', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// Park the first switch inside driver.switchSession, then fire a second
// /session while `detaching` is still held: re-entry would clear the
// flag early, reopen the interrupt window, and double-apply adoption.
let releaseSwitch!: () => void;
driver.holdSwitch = new Promise<void>((resolve) => {
releaseSwitch = resolve;
});
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() => driver.switchEntries >= 1);
terminal.input('/session session-2');
terminal.input('\r');
releaseSwitch();

await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// Exactly one detach notice: the second switch never ran.
const notices = plainTerminalOutput(terminal.output()).match(
/Detached from the running Turn/g,
);
assert.equal(notices?.length, 1);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a turn prepared after a mid-turn detach does not adopt abandoned metadata', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

// Park preparePrompt itself: while it is unresolved, /session can
// already detach — onPrepared/onSkillInvocation then fire for the
// abandoned Turn after the epoch fence moved.
let releasePrepare!: () => void;
const parkedPrepare = new Promise<void>((resolve) => {
releasePrepare = resolve;
});
const basePrepare = driver.preparePrompt.bind(driver);
driver.preparePrompt = async (prompt, options) => {
const turn = await basePrepare(prompt, options);
await parkedPrepare;
return {
...turn,
summary: fakeSessionSummary('abandoned-session', '/abandoned-cwd', 'ABANDONED TITLE'),
};
};

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session session-2');
terminal.input('\r');
// Nothing else drives the frame loop while preparePrompt stays parked,
// so force a repaint for the detach notices.
terminal.resize(80, 24);
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// The abandoned Turn's prepare resolves only now — its summary must
// not steal the adopted Session's metadata.
releasePrepare();
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
assert.equal(terminal.titles.includes('ABANDONED TITLE (Maka)'), false);
assert.equal(terminal.titles.at(-1), 'Existing chat (Maka)');
assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /\/abandoned-cwd/);

await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('unknown slash-prefixed text still steers into the running turn', async () => {
const terminal = new FakeTerminal();
const driver = new SteeringTurnDriver();
Expand DownExpand Up@@ -6888,6 +7155,110 @@ class ActiveResumeDriver extends SlashCommandDriver {
}
}

// A parking first Turn on session-1 plus a switchable session-2, for the
// mid-turn /session detach tests (#3380). The parked stream ends only when
// the test releases it or stop() lands — a detach leaves it running, exactly
// like a Host-owned Turn surviving a client that switches away. Later prompts
// (submitted after switching) complete immediately.
class DetachingSwitchDriver extends SlashCommandDriver {
stopCalls = 0;
/** When set, the next switchSession rejects — a failed detach must leave
* the running drain fully live. */
failNextSwitch = false;
/** When set, the next switchSession parks until released — a second
* mid-turn /session arriving while the first is still in flight. */
holdSwitch: Promise<void> | undefined;
switchEntries = 0;
private pendingEvents: SessionEvent[] = [];
private wakeTurn: (() => void) | null = null;
private turnEnded = false;
private promptCount = 0;

constructor(sessionTwoMessages: StoredMessage[]) {
super([fakeSessionSummary('session-2', '/repo')], new Map([['session-2', sessionTwoMessages]]));
}

/** Queues an event onto the parked first-session Turn. */
emit(event: SessionEvent): void {
this.pendingEvents.push(event);
this.wakeTurn?.();
this.wakeTurn = null;
}

/** Ends the parked stream the way a Host does when its Turn settles. */
releaseOldTurn(): void {
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

override async *promptEvents(_prompt: string, turnId = 'turn-1'): AsyncIterable<SessionEvent> {
this.promptCount += 1;
if (this.promptCount > 1) {
yield { type: 'complete', id: `complete-${turnId}`, turnId, ts: 9, stopReason: 'end_turn' };
return;
}
for (;;) {
while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!;
if (this.turnEnded) break;
await new Promise<void>((resolve) => {
this.wakeTurn = resolve;
});
}
yield { type: 'abort', id: 'abort-old', turnId, ts: 8, reason: 'user_stop' };
yield { type: 'complete', id: 'complete-old', turnId, ts: 9, stopReason: 'user_stop' };
}

override async stop(): Promise<void> {
this.stopCalls += 1;
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

// session-2 carries a live Turn, so adopting it hands back an activeTurn —
// the reattach path the runner must start once the orphaned drain unwinds.
override async switchSession(sessionId: string): Promise<MakaSessionSwitchResult> {
this.switchEntries += 1;
if (this.failNextSwitch) {
this.failNextSwitch = false;
throw new Error('session not found');
}
if (this.holdSwitch) {
const gate = this.holdSwitch;
this.holdSwitch = undefined;
await gate;
}
const switched = await super.switchSession(sessionId);
if (sessionId !== 'session-2') return switched;
const attachedTurnId = 'turn-attached-2';
return {
...switched,
activeTurn: {
sessionId,
turnId: attachedTurnId,
events: (async function* () {
yield {
type: 'text_complete',
id: 'text-attached',
turnId: attachedTurnId,
messageId: 'assistant-attached',
ts: 3,
text: 'attached replay done',
} satisfies SessionEvent;
yield {
type: 'complete',
id: 'complete-attached',
turnId: attachedTurnId,
ts: 4,
stopReason: 'end_turn',
} satisfies SessionEvent;
})(),
},
};
}
}

class HostSuccessorDriver extends SlashCommandDriver {
#startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined;
readonly #probeFirst = deferred<void>();
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(cli): let /session detach from a running Turn instead of trapping the client by me2seeks · Pull Request #3498 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
371 changes: 371 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5047,6 +5047,273 @@ describe('Maka Pi TUI runner', () => {
await run;
});

test('/session <id> mid-turn detaches from the running Turn instead of refusing', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// The escape hatch a second TUI needs (#3380): switching Sessions
// mid-turn detaches the view and leaves the Host-owned Turn running,
// instead of refusing (trapping the client) or stopping the Turn.
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// The adopted Session's history replaced the old transcript.
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// Late events from the abandoned Turn never reach the adopted
// transcript — neither as content nor as a synthesized failure about
// the stream ending without a completion event.
driver.emit({
type: 'text_delta',
id: 'delta-leak',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 2,
text: 'LEAK-OLD-DELTA',
});
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
const after = plainTerminalOutput(terminal.output());
assert.doesNotMatch(after, /LEAK-OLD-DELTA/);
assert.doesNotMatch(after, /without a completion event/);
assert.equal(driver.stopCalls, 0);
// The orphaned drain released the runner, and only then did the freshly
// attached Turn of session-2 start and complete.
await waitFor(() => terminal.progressStates.at(-1) === false);

// A follow-up prompt lands on the adopted Session.
terminal.input('next step');
terminal.input('\r');
await waitFor(() => driver.displayPrompts.includes('next step'));
assert.deepEqual(driver.sessionIds, ['session-2']);

terminal.input('/exit');
terminal.input('\r');
await run;
});

test('/session mid-turn opens the picker and Escape closes it without arming an interrupt', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
assert.doesNotMatch(
plainTerminalOutput(terminal.output()),
/Cannot run \/session while a turn is running/,
);

// Escape belongs to the overlay while it is open: closing it must not
// arm the double-Escape interrupt — a second Escape would otherwise
// abort the very Turn the user is navigating away from (#3380).
terminal.input('\x1b');
await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('Resume Session'));
await delay(50);
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

// Settle the parked Turn normally, then leave.
driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a failed mid-turn /session leaves the running Turn fully live', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// A rejected switch must not orphan the in-flight drain: the error is
// reported, nothing was switched, and the Turn keeps streaming into the
// same transcript.
driver.failNextSwitch = true;
terminal.input('/session does-not-exist');
terminal.input('\r');
await waitFor(() => plainTerminalOutput(terminal.output()).includes('session not found'));
assert.equal(driver.stopCalls, 0);
assert.equal(terminal.progressStates.at(-1), true);

driver.emit({
type: 'text_delta',
id: 'delta-after-failure',
turnId: 'turn-1',
messageId: 'assistant-old',
ts: 3,
text: 'still streaming after failure',
});
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('still streaming after failure'),
);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a second mid-turn /session while a detach is in flight is ignored', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

// Park the first switch inside driver.switchSession, then fire a second
// /session while `detaching` is still held: re-entry would clear the
// flag early, reopen the interrupt window, and double-apply adoption.
let releaseSwitch!: () => void;
driver.holdSwitch = new Promise<void>((resolve) => {
releaseSwitch = resolve;
});
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() => driver.switchEntries >= 1);
terminal.input('/session session-2');
terminal.input('\r');
releaseSwitch();

await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.equal(driver.stopCalls, 0);
assert.deepEqual(driver.sessionIds, ['session-2']);
// Exactly one detach notice: the second switch never ran.
const notices = plainTerminalOutput(terminal.output()).match(
/Detached from the running Turn/g,
);
assert.equal(notices?.length, 1);

driver.releaseOldTurn();
await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('a turn prepared after a mid-turn detach does not adopt abandoned metadata', async () => {
const terminal = new FakeTerminal();
const driver = new DetachingSwitchDriver([
storedUserMessage('user-s2', 'turn-old-2', 'history from session two'),
storedAssistantMessage('assistant-s2', 'turn-old-2', 'prior answer'),
]);
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'm',
connectionSlug: 'c',
permissionMode: 'bypass',
terminal,
});

// Park preparePrompt itself: while it is unresolved, /session can
// already detach — onPrepared/onSkillInvocation then fire for the
// abandoned Turn after the epoch fence moved.
let releasePrepare!: () => void;
const parkedPrepare = new Promise<void>((resolve) => {
releasePrepare = resolve;
});
const basePrepare = driver.preparePrompt.bind(driver);
driver.preparePrompt = async (prompt, options) => {
const turn = await basePrepare(prompt, options);
await parkedPrepare;
return {
...turn,
summary: fakeSessionSummary('abandoned-session', '/abandoned-cwd', 'ABANDONED TITLE'),
};
};

terminal.input('start the long task');
terminal.input('\r');
await waitFor(() => terminal.progressStates.at(-1) === true);

terminal.input('/session session-2');
terminal.input('\r');
// Nothing else drives the frame loop while preparePrompt stays parked,
// so force a repaint for the detach notices.
terminal.resize(80, 24);
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes('Detached from the running Turn'),
);
assert.match(plainTerminalOutput(terminal.screenOutput()), /history from session two/);

// The abandoned Turn's prepare resolves only now — its summary must
// not steal the adopted Session's metadata.
releasePrepare();
driver.releaseOldTurn();
await waitFor(() => plainTerminalOutput(terminal.output()).includes('attached replay done'));
assert.equal(terminal.titles.includes('ABANDONED TITLE (Maka)'), false);
assert.equal(terminal.titles.at(-1), 'Existing chat (Maka)');
assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /\/abandoned-cwd/);

await waitFor(() => terminal.progressStates.at(-1) === false);
terminal.input('/exit');
terminal.input('\r');
await run;
});

test('unknown slash-prefixed text still steers into the running turn', async () => {
const terminal = new FakeTerminal();
const driver = new SteeringTurnDriver();
Expand DownExpand Up@@ -6888,6 +7155,110 @@ class ActiveResumeDriver extends SlashCommandDriver {
}
}

// A parking first Turn on session-1 plus a switchable session-2, for the
// mid-turn /session detach tests (#3380). The parked stream ends only when
// the test releases it or stop() lands — a detach leaves it running, exactly
// like a Host-owned Turn surviving a client that switches away. Later prompts
// (submitted after switching) complete immediately.
class DetachingSwitchDriver extends SlashCommandDriver {
stopCalls = 0;
/** When set, the next switchSession rejects — a failed detach must leave
* the running drain fully live. */
failNextSwitch = false;
/** When set, the next switchSession parks until released — a second
* mid-turn /session arriving while the first is still in flight. */
holdSwitch: Promise<void> | undefined;
switchEntries = 0;
private pendingEvents: SessionEvent[] = [];
private wakeTurn: (() => void) | null = null;
private turnEnded = false;
private promptCount = 0;

constructor(sessionTwoMessages: StoredMessage[]) {
super([fakeSessionSummary('session-2', '/repo')], new Map([['session-2', sessionTwoMessages]]));
}

/** Queues an event onto the parked first-session Turn. */
emit(event: SessionEvent): void {
this.pendingEvents.push(event);
this.wakeTurn?.();
this.wakeTurn = null;
}

/** Ends the parked stream the way a Host does when its Turn settles. */
releaseOldTurn(): void {
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

override async *promptEvents(_prompt: string, turnId = 'turn-1'): AsyncIterable<SessionEvent> {
this.promptCount += 1;
if (this.promptCount > 1) {
yield { type: 'complete', id: `complete-${turnId}`, turnId, ts: 9, stopReason: 'end_turn' };
return;
}
for (;;) {
while (this.pendingEvents.length > 0) yield this.pendingEvents.shift()!;
if (this.turnEnded) break;
await new Promise<void>((resolve) => {
this.wakeTurn = resolve;
});
}
yield { type: 'abort', id: 'abort-old', turnId, ts: 8, reason: 'user_stop' };
yield { type: 'complete', id: 'complete-old', turnId, ts: 9, stopReason: 'user_stop' };
}

override async stop(): Promise<void> {
this.stopCalls += 1;
this.turnEnded = true;
this.wakeTurn?.();
this.wakeTurn = null;
}

// session-2 carries a live Turn, so adopting it hands back an activeTurn —
// the reattach path the runner must start once the orphaned drain unwinds.
override async switchSession(sessionId: string): Promise<MakaSessionSwitchResult> {
this.switchEntries += 1;
if (this.failNextSwitch) {
this.failNextSwitch = false;
throw new Error('session not found');
}
if (this.holdSwitch) {
const gate = this.holdSwitch;
this.holdSwitch = undefined;
await gate;
}
const switched = await super.switchSession(sessionId);
if (sessionId !== 'session-2') return switched;
const attachedTurnId = 'turn-attached-2';
return {
...switched,
activeTurn: {
sessionId,
turnId: attachedTurnId,
events: (async function* () {
yield {
type: 'text_complete',
id: 'text-attached',
turnId: attachedTurnId,
messageId: 'assistant-attached',
ts: 3,
text: 'attached replay done',
} satisfies SessionEvent;
yield {
type: 'complete',
id: 'complete-attached',
turnId: attachedTurnId,
ts: 4,
stopReason: 'end_turn',
} satisfies SessionEvent;
})(),
},
};
}
}

class HostSuccessorDriver extends SlashCommandDriver {
#startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined;
readonly #probeFirst = deferred<void>();
Expand Down
Loading