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
43 changes: 43 additions & 0 deletions packages/cli/src/__tests__/pi-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,49 @@ describe('Maka Pi TUI transcript', () => {
assert.doesNotMatch(stripAnsi(renderMakaPiStatusLine({ ...meta(), goal: null }, 120)), /goal/);
});

test('renders side conversation status in English for every UI locale', () => {
assert.equal(
stripAnsi(
renderMakaPiStatusLine(
{ ...meta(), uiLocale: 'zh', sideConversation: { view: 'side' } },
200,
),
),
'Side from main thread · Ctrl+/ to switch · Ctrl+C to close',
);
for (const [parentStatus, label] of [
['needs_input', 'main needs input'],
['needs_approval', 'main needs approval'],
['failed', 'main failed'],
['interrupted', 'main interrupted'],
['closed', 'main closed'],
['finished', 'main finished'],
] as const) {
assert.equal(
stripAnsi(
renderMakaPiStatusLine(
{
...meta(),
uiLocale: 'zh',
sideConversation: { view: 'side', parentStatus },
},
200,
),
),
`Side from main thread · ${label} · Ctrl+/ to switch · Ctrl+C to close`,
);
}
assert.match(
stripAnsi(
renderMakaPiStatusLine(
{ ...meta(), uiLocale: 'zh', sideConversation: { view: 'parent' } },
200,
),
),
/Ctrl\+\/ for side/,
);
});

test('status line degrades to ctx ?/window when the window is known but usage is not (#3371)', () => {
// No usage object at all: the window is known, so degrade explicitly.
assert.match(
Expand Down
169 changes: 152 additions & 17 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import type {
MakaAttachedSessionTurn,
MakaSessionMoveResult,
MakaSessionDriver,
MakaSideConversationParentStatus,
MakaSessionRewindResult,
MakaSessionSwitchOptions,
MakaSessionSwitchResult,
Expand Down Expand Up @@ -6398,7 +6399,7 @@ describe('Maka Pi TUI runner', () => {
await run;
});

test('blocks Session identity changes until the side conversation closes', async () => {
test('Ctrl+/ toggles side views, preserves drafts, and projects parent status in English', async () => {
const terminal = new FakeTerminal();
const driver = new SideConversationDriver();
const run = runMakaPiTui({
Expand All @@ -6408,38 +6409,140 @@ describe('Maka Pi TUI runner', () => {
model: 'claude-sonnet-4-5',
connectionSlug: 'claude-subscription',
permissionMode: 'ask',
locale: 'zh',
terminal,
});

terminal.input('/side');
terminal.input('\r');
await waitFor(() => driver.getSessionId() === 'side-1');
terminal.input('side draft');
terminal.input('\x1f');
await waitFor(() => driver.getSessionId() === 'session-1');
assert.equal(editorInputText(terminal), '');

terminal.input('/session session-2');
terminal.input('\r');
terminal.input('parent draft');
terminal.input('\x1b[47;5u');
await waitFor(() => driver.getSessionId() === 'side-1');
assert.equal(editorInputText(terminal), 'side draft');

driver.publishParentStatus('needs_approval');
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes(
'Close the side conversation before switching Sessions.',
plainTerminalOutput(terminal.screenOutput()).includes(
'Side from main thread · main needs approval · Ctrl+/ to switch · Ctrl+C to close',
),
);
terminal.input('/new');

exitMaka(terminal);
await run;
});

test('closing an empty side conversation restores the retained parent draft', async () => {
const terminal = new FakeTerminal();
const driver = new SideConversationDriver();
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'claude-sonnet-4-5',
connectionSlug: 'claude-subscription',
permissionMode: 'ask',
terminal,
});

terminal.input('/side');
terminal.input('\r');
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes(
'Close the side conversation before starting a new Session.',
),
);
terminal.input('/rewind');
await waitFor(() => driver.getSessionId() === 'side-1');
terminal.input('\x1f');
await waitFor(() => driver.getSessionId() === 'session-1');
terminal.input('parent draft');
terminal.input('\x1f');
await waitFor(() => driver.getSessionId() === 'side-1');
assert.equal(editorInputText(terminal), '');

terminal.input('\x03');
await waitFor(() => editorInputText(terminal) === 'parent draft');
assert.equal(driver.getSessionId(), 'session-1');

exitMaka(terminal);
await run;
});

test('does not report main closed when the parent observer cannot open', async () => {
const terminal = new FakeTerminal();
const driver = new FailingParentObserverSideConversationDriver();
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'claude-sonnet-4-5',
connectionSlug: 'claude-subscription',
permissionMode: 'ask',
terminal,
});

terminal.input('/side');
terminal.input('\r');
await waitFor(() => driver.getSessionId() === 'side-1');
await waitFor(() =>
plainTerminalOutput(terminal.output()).includes(
'Close the side conversation before rewinding.',
plainTerminalOutput(terminal.screenOutput()).includes(
'Side from main thread · Ctrl+/ to switch · Ctrl+C to close',
),
);
assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /main closed/u);

assert.equal(driver.getSessionId(), 'side-1');
assert.deepEqual(driver.sessionIds, []);
assert.equal(driver.startNewSessionCalls, 0);
exitMaka(terminal);
await run;
});

test('Ctrl+/ detaches from a running side Turn without stopping it', async () => {
const terminal = new FakeTerminal();
const driver = new RunningSideConversationDriver();
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'claude-sonnet-4-5',
connectionSlug: 'claude-subscription',
permissionMode: 'ask',
terminal,
});

terminal.input('/side keep checking');
terminal.input('\r');
await driver.sideStarted.promise;
terminal.input('\x1f');
await waitFor(() => driver.getSessionId() === 'session-1');

assert.equal(driver.stopCalls, 0);
exitMaka(terminal);
await run;
});

test('/session outside the pair discards its retained side conversation', async () => {
const terminal = new FakeTerminal();
const driver = new SideConversationDriver();
const run = runMakaPiTui({
title: 'Maka',
driver,
cwd: '/repo',
model: 'claude-sonnet-4-5',
connectionSlug: 'claude-subscription',
permissionMode: 'ask',
terminal,
});

terminal.input('/side');
terminal.input('\r');
await waitFor(() => driver.getSessionId() === 'side-1');
terminal.input('\x1f');
await waitFor(() => driver.getSessionId() === 'session-1');
terminal.input('/session session-2');
terminal.input('\r');
await waitFor(() => driver.discardedSides.length === 1);

assert.deepEqual(driver.discardedSides, ['side-1']);
assert.equal(driver.getSessionId(), 'session-2');

exitMaka(terminal);
await run;
Expand Down Expand Up @@ -7762,6 +7865,10 @@ class SideConversationDriver extends SlashCommandDriver {
readonly openedFrom: string[] = [];
readonly closedSides: Array<{ sideSessionId: string; parentSessionId: string }> = [];
readonly promptSessionIds: string[] = [];
readonly discardedSides: string[] = [];
parentStatusListener:
| ((status: MakaSideConversationParentStatus | undefined) => void)
| undefined;

override preparePrompt(
prompt: string,
Expand Down Expand Up @@ -7793,6 +7900,34 @@ class SideConversationDriver extends SlashCommandDriver {
this.sessionId = parentSessionId;
return { ...switchResult(fakeSessionSummary(parentSessionId)), cleanup: 'removed' as const };
}

async observeSideConversationParent(
_parentSessionId: string,
listener: (status: MakaSideConversationParentStatus | undefined) => void,
) {
this.parentStatusListener = listener;
return async () => {
if (this.parentStatusListener === listener) this.parentStatusListener = undefined;
};
}

publishParentStatus(status: MakaSideConversationParentStatus | undefined): void {
this.parentStatusListener?.(status);
}

async discardSideConversation(sideSessionId: string) {
this.discardedSides.push(sideSessionId);
return 'removed' as const;
}
}

class FailingParentObserverSideConversationDriver extends SideConversationDriver {
override async observeSideConversationParent(
_parentSessionId: string,
_listener: (status: MakaSideConversationParentStatus | undefined) => void,
): Promise<() => Promise<void>> {
throw new Error('parent observer unavailable');
}
}

class RunningParentSideConversationDriver extends SideConversationDriver {
Expand Down
69 changes: 68 additions & 1 deletion packages/cli/src/__tests__/runtime-host-session-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ import {
createRuntimeHostMakaSessionDriver,
type RuntimeHostMakaSessionDriverInput,
} from '../runtime-host-session-driver.js';
import { SkillInvocationBlockedError, type MakaAttachedSessionTurn } from '../session-driver.js';
import {
SkillInvocationBlockedError,
type MakaAttachedSessionTurn,
type MakaSideConversationParentStatus,
} from '../session-driver.js';
import { WAIT_BUDGET_MS } from './tui-terminal-mock.js';

describe('Runtime Host Maka Session driver', () => {
Expand Down Expand Up @@ -1319,6 +1323,69 @@ describe('Runtime Host Maka Session driver', () => {
);
});

test('observes actionable and terminal parent status from the Host projection', async () => {
const subscription = new FakeSubscription(
continuitySnapshot({ interactions: { pending: [pendingPermission()] } }),
Promise.resolve([]),
);
const driver = createRuntimeHostMakaSessionDriver({
connection: new FakeConnection([subscription]).value,
cwd: '/tmp',
llmConnectionSlug: 'openai-main',
model: 'gpt-5',
});
const statuses: Array<MakaSideConversationParentStatus | undefined> = [];

const stop = await driver.observeSideConversationParent!('session-1', (status) => {
statuses.push(status);
});
assert.equal(statuses.at(-1), 'needs_approval');

subscription.push(projectionFrame(1, runningTurn('turn-2', 'run-2'), 2));
await waitFor(() => statuses.at(-1) === undefined);
subscription.push(projectionFrame(2, completedTurn('turn-2', 'run-2'), 3));
await waitFor(() => statuses.at(-1) === 'finished');

await stop();
});

test('clears parent status when observer recovery is exhausted', async () => {
const snapshot = continuitySnapshot({ interactions: { pending: [pendingPermission()] } });
const initial = new FakeSubscription(snapshot, Promise.resolve([]));
const ended = Array.from({ length: 8 }, (_, index) => {
const subscription = new FakeSubscription(
{ ...snapshot, projectionRevision: index + 2 },
Promise.resolve([]),
`subscription-${index + 2}`,
);
void subscription.close();
return subscription;
});
const connection = new FakeConnection([initial, ...ended], true);
const driver = createRuntimeHostMakaSessionDriver({
connection: connection.value,
cwd: '/tmp',
llmConnectionSlug: 'openai-main',
model: 'gpt-5',
});
const statuses: Array<MakaSideConversationParentStatus | undefined> = [];
const cleared = deferred<void>();

await driver.observeSideConversationParent!('session-1', (status) => {
statuses.push(status);
if (status === undefined) cleared.resolve();
});
assert.equal(statuses.at(-1), 'needs_approval');

await initial.close();
await Promise.race([
cleared.promise,
delay(3_000).then(() => assert.fail('Timed out waiting for observer recovery exhaustion')),
]);
assert.equal(connection.openedSubscriptions, 9);
assert.equal(statuses.at(-1), undefined);
});

test('reopens a failed Session channel before starting the next turn', async () => {
const first = new FakeSubscription(continuitySnapshot({ rootTurn: null }), Promise.resolve([]));
const second = new FakeSubscription(
Expand Down
Loading