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
106 changes: 105 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 @@ -43,7 +43,11 @@ import {
createRuntimeHostMakaSessionDriver,
type RuntimeHostMakaSessionDriverInput,
} from '../runtime-host-session-driver.js';
import { SkillInvocationBlockedError, type MakaAttachedSessionTurn } from '../session-driver.js';
import {
SkillInvocationBlockedError,
type MakaAttachedSessionTurn,
type MakaTranscriptReplacementReason,
} from '../session-driver.js';
import { WAIT_BUDGET_MS } from './tui-terminal-mock.js';

describe('Runtime Host Maka Session driver', () => {
Expand Down Expand Up @@ -1357,6 +1361,106 @@ describe('Runtime Host Maka Session driver', () => {
assert.deepEqual(replacements, [secondMessages]);
});

test('does not let an older live refresh overwrite the terminal transcript', async () => {
const attached = new FakeSubscription(continuitySnapshot(), Promise.resolve([]));
const liveTranscript = deferred<StoredMessage[]>();
const staleLiveMessages = [userMessage('turn-1', 'Run it')];
const terminalMessages = [userMessage('turn-1', 'Run it'), assistantMessage('turn-1', 'Done')];
const liveRefresh = new FakeSubscription(
continuitySnapshot(),
liveTranscript.promise,
'subscription-2',
);
const terminalRefresh = new FakeSubscription(
continuitySnapshot({ rootTurn: completedTurn('turn-1', 'run-1') }),
Promise.resolve(terminalMessages),
'subscription-3',
);
const connection = new FakeConnection([attached, liveRefresh, terminalRefresh]);
const driver = createRuntimeHostMakaSessionDriver({
connection: connection.value,
cwd: '/tmp',
llmConnectionSlug: 'openai-main',
model: 'gpt-5',
});
await driver.switchSession('session-1');
const replacements: Array<{
messages: StoredMessage[];
reason: MakaTranscriptReplacementReason;
}> = [];
driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => {
replacements.push({ messages, reason });
});

attached.push(toolResultFrame(1));
await waitFor(() => connection.openedSubscriptions === 2);
attached.push({
kind: 'subscription.session_projection',
hostEpoch: 'host-1',
subscriptionId: 'subscription-1',
sequence: 2,
snapshot: continuitySnapshot({
projectionRevision: 2,
rootTurn: completedTurn('turn-1', 'run-1'),
}),
});
await waitFor(() => replacements.length === 1);
assert.deepEqual(replacements, [{ messages: terminalMessages, reason: 'terminal' }]);

liveTranscript.resolve(staleLiveMessages);
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(replacements, [{ messages: terminalMessages, reason: 'terminal' }]);
});

test('does not let a retired-channel live refresh overwrite a reconnect snapshot', async () => {
const initial = new FakeSubscription(continuitySnapshot(), Promise.resolve([]));
const liveTranscript = deferred<StoredMessage[]>();
const staleLiveMessages = [userMessage('turn-1', 'Run it')];
const reconnectMessages = [
userMessage('turn-1', 'Run it'),
assistantMessage('turn-1', 'Still running'),
];
const liveRefresh = new FakeSubscription(
continuitySnapshot(),
liveTranscript.promise,
'subscription-2',
);
const replacement = new FakeSubscription(
continuitySnapshot({ projectionRevision: 2 }),
Promise.resolve(reconnectMessages),
'subscription-3',
);
const connection = new FakeConnection([initial, liveRefresh, replacement], true);
const driver = createRuntimeHostMakaSessionDriver({
connection: connection.value,
cwd: '/tmp',
llmConnectionSlug: 'openai-main',
model: 'gpt-5',
});
await driver.switchSession('session-1');
const replacements: Array<{
messages: StoredMessage[];
reason: MakaTranscriptReplacementReason;
}> = [];
driver.subscribeTranscriptReplacements!((_sessionId, _turnId, messages, reason) => {
replacements.push({ messages, reason });
});

initial.push(toolResultFrame(1));
await waitFor(() => connection.openedSubscriptions === 2);
initial.fail(
new RuntimeHostSubscriptionError('connection_closed', 'connection lost during active Turn'),
);
await waitFor(() => replacements.length === 1);
assert.deepEqual(replacements, [{ messages: reconnectMessages, reason: 'reconnect' }]);

liveTranscript.resolve(staleLiveMessages);
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(replacements, [{ messages: reconnectMessages, reason: 'reconnect' }]);
});

test('resnapshots an active Session after reconnect and continues its live stream', async () => {
const initial = new FakeSubscription(
continuitySnapshot(),
Expand Down
31 changes: 23 additions & 8 deletions packages/cli/src/runtime-host-session-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver {
readonly #startedTurnReattachTails = new Map<number, Promise<void>>();
#sessionGeneration = 0;
#channelGeneration = 0;
#liveTranscriptRefreshSequence = 0;
#transcriptRefreshSequence = 0;
readonly #startedTurnListeners = new Set<(turn: MakaAttachedSessionTurn) => void>();
readonly #goalListeners = new Set<(goal: GoalProjection | null) => void>();
readonly #pendingInteractionListeners = new Set<(pending: InteractionPendingSnapshot) => void>();
Expand Down Expand Up @@ -1028,10 +1028,18 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver {
for (const listener of this.#pendingInteractionListeners) listener(pending);
},
onInteractionResolved: (pending) => this.#resolveExternalInteraction(pending),
onTurnTerminal: (turn) => this.#refreshTerminalTranscript(turn),
onTurnTerminal: (turn) => this.#refreshTerminalTranscript(turn, sessionGeneration),
onToolResult: (turnId) => this.#refreshLiveTranscript(sessionId, sessionGeneration, turnId),
onTranscriptReplaced: (turnId, messages) =>
this.#publishTranscriptReplacement(sessionId, turnId, messages, 'reconnect'),
onTranscriptReplaced: (turnId, messages) => {
if (this.#sessionId !== sessionId || this.#sessionGeneration !== sessionGeneration) {
return;
}
// A reconnect snapshot is newer than every transcript read started by
// the retired channel. Invalidate those reads before publishing so a
// late tool-result snapshot cannot roll the transcript back.
this.#transcriptRefreshSequence += 1;
this.#publishTranscriptReplacement(sessionId, turnId, messages, 'reconnect');
},
onGoalChanged: (goal) => {
// A closing channel from a previous session can still be draining a
// frame when the swap happens; only the live session may publish.
Expand Down Expand Up @@ -1072,23 +1080,30 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver {
.catch(() => undefined);
}

#refreshTerminalTranscript(turn: TerminalTurnSnapshot): void {
#refreshTerminalTranscript(turn: TerminalTurnSnapshot, sessionGeneration: number): void {
const refreshSequence = ++this.#transcriptRefreshSequence;
void loadCurrentMessages(this.#connection, turn.sessionId)
.then((messages) => {
if (this.#sessionId !== turn.sessionId) return;
if (
this.#sessionId !== turn.sessionId ||
this.#sessionGeneration !== sessionGeneration ||
refreshSequence !== this.#transcriptRefreshSequence
) {
return;
}
this.#publishTranscriptReplacement(turn.sessionId, turn.turnId, messages, 'terminal');
})
.catch(() => undefined);
}

#refreshLiveTranscript(sessionId: string, sessionGeneration: number, turnId: string): void {
const refreshSequence = ++this.#liveTranscriptRefreshSequence;
const refreshSequence = ++this.#transcriptRefreshSequence;
void loadCurrentMessages(this.#connection, sessionId)
.then((messages) => {
if (
this.#sessionId !== sessionId ||
this.#sessionGeneration !== sessionGeneration ||
refreshSequence !== this.#liveTranscriptRefreshSequence
refreshSequence !== this.#transcriptRefreshSequence
) {
return;
}
Expand Down