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
45 changes: 45 additions & 0 deletions apps/desktop/src/main/__tests__/streaming-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,51 @@ describe('single live-turn handoff', () => {
assert.equal(liveTurns.get()['session-1'], undefined);
});

it('reconciles persisted stream evidence while the next tool batch is running', () => {
const projection: LiveTurnProjection = {
turnId: 'turn-1',
phase: 'streamed',
steps: [
{
stepId: 'step-1',
tools: [{
toolUseId: 'old-tool', toolName: 'Bash', status: 'completed', args: {},
outputChunks: [{ seq: 0, stream: 'stdout', text: 'old\n', redacted: false, createdAt: 1 }],
}],
contentOrder: ['tools'],
},
{
stepId: 'step-2',
tools: [{ toolUseId: 'new-tool', toolName: 'Bash', status: 'running', args: {} }],
contentOrder: ['tools'],
},
],
};
const liveTurns = createStateSetter<Record<string, LiveTurnProjection>>({ 'session-1': projection });
const ref = { current: liveTurns.get() };
const permissions = createStateSetter<PermissionQueues>({});
const handlers = createAppShellSessionEventHandlers({
activeIdRef: { current: 'session-1' },
liveTurnBySessionRef: ref,
refreshMessages: async () => true,
refreshSessions: async () => [],
setLiveTurnBySession: (updater) => {
liveTurns.set(updater);
ref.current = liveTurns.get();
},
setPermissionBySession: permissions.set,
showModelSetupToast: () => {},
toastApi: { error: () => {} },
});

handlers.reconcilePersistedMessages('session-1', [
{ type: 'tool_call', id: 'old-tool', turnId: 'turn-1', stepId: 'step-1', ts: 1, toolName: 'Bash', args: {} },
{ type: 'tool_result', id: 'old-result', turnId: 'turn-1', ts: 2, toolUseId: 'old-tool', isError: false, content: { kind: 'text', text: 'old\n' } },
]);

assert.deepEqual(liveTurns.get()['session-1']?.steps, [projection.steps[1]]);
});

it('settles a tool-only terminal projection after persisted history refreshes', async () => {
const liveTurns = createStateSetter<Record<string, LiveTurnProjection>>({
'session-1': {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/app-shell-session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function createAppShellSessionEventHandlers(options: {
function reconcilePersistedMessages(sessionId: string, messages: readonly StoredMessage[]): void {
setLiveTurnBySession((current) => {
const projection = current[sessionId];
if (!projection?.terminal) return current;
if (!projection) return current;
const reconciled = reconcileTerminalLiveTurn(projection, messages);
if (reconciled === projection) return current;
const next = { ...current };
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1039,10 +1039,10 @@ export function AppShell({
},
});

// A terminal tool/thinking projection may survive when its event-triggered
// refresh fails. Reconcile from durable evidence whenever either side
// changes, so a later poll, manual retry, or session refresh closes the
// handoff without deleting text that the smoother still owns.
// Tool/thinking evidence may survive its event-triggered refresh, including
// between steps of one running turn. Reconcile from durable evidence whenever
// either side changes, so old output stays on its original tool instead of
// joining the next batch, without deleting text that the smoother still owns.
const reconcilePersistedMessagesEffect = useEffectEvent(reconcilePersistedMessages);
useEffect(() => {
if (!activeId) return;
Expand Down
35 changes: 34 additions & 1 deletion packages/ui/src/__tests__/live-turn-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
settleLiveTurnStep,
type LiveTurnProjection,
} from '../live-turn-projection.js';
import { overlayLiveTurn } from '../materialize.js';
import { overlayLiveTurn, type ToolActivityItem } from '../materialize.js';

describe('applyLiveTurnEvent', () => {
it('moves an armed turn from waiting to streamed on its first content event', () => {
Expand Down Expand Up @@ -644,4 +644,37 @@ describe('reconcileTerminalLiveTurn', () => {
{ type: 'assistant', id: 'step-1', turnId: 'turn-1', ts: 1, text: '', thinking: { text: 'reasoning' }, modelId: 'm' },
]), undefined);
});

it('drops persisted stream evidence before the next tool batch settles', () => {
const evidence = (toolUseId: string): ToolActivityItem => ({
toolUseId,
toolName: 'Bash',
status: 'completed',
args: {},
outputChunks: [{ seq: 0, stream: 'stdout', text: 'ok\n', redacted: false, createdAt: 1 }],
});
const current = (toolUseId: string): ToolActivityItem => ({
toolUseId,
toolName: 'Bash',
status: 'running',
args: {},
});
const projection: LiveTurnProjection = {
turnId: 'turn-1',
phase: 'streamed',
steps: [
{ stepId: 'step-1', tools: ['old-1', 'old-2', 'old-3'].map(evidence), contentOrder: ['tools'] },
{ stepId: 'step-2', tools: ['new-1', 'new-2', 'new-3', 'new-4'].map(current), contentOrder: ['tools'] },
],
};
const persisted = ['old-1', 'old-2', 'old-3'].flatMap((toolUseId, index) => ([
{ type: 'tool_call' as const, id: toolUseId, turnId: 'turn-1', stepId: 'step-1', ts: index * 2 + 1, toolName: 'Bash', args: {} },
{ type: 'tool_result' as const, id: `result-${toolUseId}`, turnId: 'turn-1', ts: index * 2 + 2, toolUseId, isError: false, content: { kind: 'text' as const, text: 'ok\n' } },
]));

assert.deepEqual(reconcileTerminalLiveTurn(projection, persisted), {
...projection,
steps: [projection.steps[1]!],
});
});
});
8 changes: 4 additions & 4 deletions packages/ui/src/live-turn-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,15 +348,15 @@ function durableStreamEvidence(
}

/**
* Removes terminal non-text steps only when the persisted transcript can
* render the same durable evidence. Text steps remain owned by the smoother,
* whose completion callback performs their handoff after the tail is visible.
* Removes evidence-only steps once the persisted transcript can render the
* same durable output, including while a later step is still running. Text
* steps remain owned by the smoother, whose completion callback performs
* their handoff after the tail is visible.
*/
export function reconcileTerminalLiveTurn(
current: LiveTurnProjection,
messages: readonly StoredMessage[],
): LiveTurnProjection | undefined {
if (!current.terminal) return current;
const turnMessages = messages.filter((message) => message.turnId === current.turnId);
const assistantIds = new Set(turnMessages.flatMap((message) => message.type === 'assistant' ? [message.id] : []));
const toolCallIds = new Set(turnMessages.flatMap((message) => message.type === 'tool_call' ? [message.id] : []));
Expand Down
Loading