Skip to content

Commit d9e1d9b

Browse files
committed
fix(chat): preserve activity-only turn history
1 parent 6e9c5c3 commit d9e1d9b

4 files changed

Lines changed: 166 additions & 31 deletions

File tree

src/client/features/chat/MessageList.svelte

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,6 @@ SPDX-License-Identifier: AGPL-3.0-or-later
4343
let promptGroups = $derived(groups.filter((group) => group.kind === 'user'));
4444
let assistantGroups = $derived(groups.filter((group) => group.kind === 'assistant'));
4545
let latestAssistantId = $derived(assistantGroups.at(-1)?.id);
46-
let hasActiveAssistant = $derived(
47-
Boolean(
48-
activeTurnId &&
49-
assistantGroups.some(
50-
(group) =>
51-
group.turnId === activeTurnId || (!group.turnId && group.id === latestAssistantId),
52-
),
53-
),
54-
);
5546
let unassignedInteractions = $derived(
5647
interactions.filter(
5748
(interaction) =>
@@ -75,11 +66,25 @@ SPDX-License-Identifier: AGPL-3.0-or-later
7566
activity.turnId ? activity.turnId === group.turnId : group.id === latestAssistantId,
7667
);
7768
}
78-
function currentActivities(): HistoryActivity[] {
79-
return activities.filter((activity) =>
80-
activity.turnId ? activity.turnId === activeTurnId : true,
69+
function assistantOwnsActivity(activity: HistoryActivity): boolean {
70+
return activity.turnId
71+
? assistantGroups.some((group) => group.turnId === activity.turnId)
72+
: Boolean(latestAssistantId);
73+
}
74+
function activityPromptOwnerId(activity: HistoryActivity): string | null {
75+
if (!activity.turnId || assistantOwnsActivity(activity)) return null;
76+
return promptGroups.findLast((group) => group.turnId === activity.turnId)?.id ?? null;
77+
}
78+
function promptActivities(group: (typeof groups)[number]): HistoryActivity[] {
79+
if (group.kind !== 'user') return [];
80+
return activities.filter((activity) => activityPromptOwnerId(activity) === group.id);
81+
}
82+
function detachedActivities(): HistoryActivity[] {
83+
return activities.filter(
84+
(activity) => !assistantOwnsActivity(activity) && !activityPromptOwnerId(activity),
8185
);
8286
}
87+
let detached = $derived(detachedActivities());
8388
function regularActivities(items: HistoryActivity[]): HistoryActivity[] {
8489
return items.filter(
8590
(activity) => !activity.label.toLowerCase().replaceAll(' ', '').startsWith('filechange'),
@@ -99,6 +104,11 @@ SPDX-License-Identifier: AGPL-3.0-or-later
99104
(group.turnId === activeTurnId || (!group.turnId && group.id === latestAssistantId)),
100105
);
101106
}
107+
function isPromptLive(group: (typeof groups)[number]): boolean {
108+
return Boolean(
109+
group.kind === 'user' && activeTurnId && (group.turnId === activeTurnId || !group.turnId),
110+
);
111+
}
102112
</script>
103113

104114
{#snippet inline(parts: CommentaryPart[])}
@@ -164,6 +174,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
164174
{#each groups as group, index (group.id)}
165175
<li class={group.kind === 'user' ? 'prompt-turn' : 'answer-item'}>
166176
{#if group.kind === 'user'}
177+
{@const ownedActivities = promptActivities(group)}
167178
<div class="entry-heading">
168179
<strong>prompt</strong>
169180
{#if group.occurredAt}
@@ -185,6 +196,17 @@ SPDX-License-Identifier: AGPL-3.0-or-later
185196
{ondecision}
186197
{onretry}
187198
/>
199+
{#if ownedActivities.length}
200+
<section class={isPromptLive(group) ? 'progress-turn' : 'orphan-activity-turn'}>
201+
{#if isPromptLive(group)}
202+
<div class="entry-heading"><strong>working</strong></div>
203+
<ActivityList activities={regularActivities(ownedActivities)} variant="live" />
204+
{:else}
205+
<ActivityList activities={regularActivities(ownedActivities)} />
206+
{/if}
207+
{@render changedFiles(ownedActivities)}
208+
</section>
209+
{/if}
188210
{:else if group.answer !== null}
189211
{@const ownedActivities = turnActivities(group)}
190212
<section class="answer-turn">
@@ -267,12 +289,16 @@ SPDX-License-Identifier: AGPL-3.0-or-later
267289
/>
268290
</li>
269291
{/if}
270-
{#if activeTurnId && !hasActiveAssistant && currentActivities().length}
292+
{#if detached.length}
271293
<li class="progress-item">
272-
<section class="progress-turn">
273-
<div class="entry-heading"><strong>working</strong></div>
274-
<ActivityList activities={regularActivities(currentActivities())} variant="live" />
275-
{@render changedFiles(currentActivities())}
294+
<section class={activeTurnId ? 'progress-turn' : 'orphan-activity-turn'}>
295+
{#if activeTurnId}
296+
<div class="entry-heading"><strong>working</strong></div>
297+
<ActivityList activities={regularActivities(detached)} variant="live" />
298+
{:else}
299+
<ActivityList activities={regularActivities(detached)} />
300+
{/if}
301+
{@render changedFiles(detached)}
276302
</section>
277303
</li>
278304
{/if}
@@ -311,6 +337,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
311337
}
312338
313339
.commentary-turn,
340+
.orphan-activity-turn,
314341
.progress-turn,
315342
.commentary-content {
316343
padding: 0.5rem 0.625rem;

src/client/features/chat/MessageList.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,41 @@ describe('MessageList', () => {
9999
expect(screen.queryByText('activity')).toBeNull();
100100
});
101101

102+
it('keeps a completed activity-only turn in history with its owning prompt', () => {
103+
render(MessageList, {
104+
messages: [
105+
{
106+
id: 'prompt:turn-1',
107+
role: 'user',
108+
turnId: 'turn-1',
109+
text: 'Change the app',
110+
complete: true,
111+
},
112+
],
113+
activities: [
114+
{
115+
id: 'command',
116+
label: 'Command · completed',
117+
detail: 'npm test',
118+
turnId: 'turn-1',
119+
},
120+
{
121+
id: 'change',
122+
label: 'File change · completed',
123+
detail: 'src/app.ts',
124+
turnId: 'turn-1',
125+
},
126+
],
127+
activeTurnId: null,
128+
});
129+
130+
const prompt = screen.getByText('Change the app').closest('.prompt-turn');
131+
expect(prompt?.querySelector('.chat-activity')).not.toBeNull();
132+
expect(prompt?.textContent).toContain('npm test');
133+
expect(prompt?.textContent).toContain('src/app.ts');
134+
expect(screen.getAllByRole('region', { name: 'Files changed' })).toHaveLength(1);
135+
});
136+
102137
it('renders a durable resolved interaction in its owning prompt turn', () => {
103138
render(MessageList, {
104139
messages: [

src/server/platform/codex/normalizer.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,59 @@ describe('normalizeCodexNotification', () => {
7070
});
7171
});
7272

73+
it('prefers the notification turn over stale session and item ownership', () => {
74+
expect(
75+
normalizeCodexNotification(
76+
's',
77+
4,
78+
'2026-01-01T00:00:00.000Z',
79+
{
80+
method: 'item/completed',
81+
params: {
82+
turnId: 'notification-turn',
83+
item: {
84+
id: 'message-2',
85+
type: 'agentMessage',
86+
text: 'Done.',
87+
phase: 'final_answer',
88+
turnId: 'item-turn',
89+
},
90+
},
91+
},
92+
'/workspace',
93+
'session-turn',
94+
),
95+
).toMatchObject({
96+
type: 'agentMessageCompleted',
97+
payload: { turnId: 'notification-turn' },
98+
});
99+
100+
expect(
101+
normalizeCodexNotification(
102+
's',
103+
5,
104+
'2026-01-01T00:00:00.000Z',
105+
{
106+
method: 'item/started',
107+
params: {
108+
turnId: 'notification-turn',
109+
item: {
110+
id: 'command-2',
111+
type: 'commandExecution',
112+
command: 'npm test',
113+
turnId: 'item-turn',
114+
},
115+
},
116+
},
117+
'/workspace',
118+
'session-turn',
119+
),
120+
).toMatchObject({
121+
type: 'activity.updated',
122+
payload: { turnId: 'notification-turn' },
123+
});
124+
});
125+
73126
it('maps a command lifecycle item without its raw output', () => {
74127
expect(
75128
normalizeCodexNotification('s', 2, '2026-01-01T00:00:00.000Z', {

src/server/platform/codex/normalizer.ts

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export function normalizeCodexNotification(
4040
payload: decoded.params,
4141
};
4242
if (decoded.method === 'item/started' || decoded.method === 'item/completed') {
43-
const message = safeAgentMessage(decoded.params.item, activeTurnId);
43+
const message = safeAgentMessage(decoded.params.item, activeTurnId, decoded.params.turnId);
4444
if (message)
4545
return {
4646
sessionId,
@@ -49,7 +49,12 @@ export function normalizeCodexNotification(
4949
type: decoded.method === 'item/started' ? 'agentMessageStarted' : 'agentMessageCompleted',
5050
payload: message,
5151
};
52-
const activity = safeActivity(decoded.params.item, workspacePath, activeTurnId);
52+
const activity = safeActivity(
53+
decoded.params.item,
54+
workspacePath,
55+
activeTurnId,
56+
decoded.params.turnId,
57+
);
5358
if (activity)
5459
return { sessionId, sequence, occurredAt, type: 'activity.updated', payload: activity };
5560
}
@@ -65,7 +70,10 @@ type DecodedNotification =
6570
method: 'turn/completed';
6671
params: { threadId?: string; turn: { id: string; status?: string } };
6772
}
68-
| { method: 'item/started' | 'item/completed'; params: { item: unknown } };
73+
| {
74+
method: 'item/started' | 'item/completed';
75+
params: { item: unknown; turnId?: string };
76+
};
6977

7078
/** Strictly decode only consumed notification shapes; unknown/future methods stay isolated. */
7179
export function decodeNotification(input: {
@@ -107,7 +115,13 @@ export function decodeNotification(input: {
107115
if (input.method === 'item/started' || input.method === 'item/completed') {
108116
const params = record(input.params);
109117
return params && 'item' in params
110-
? { method: input.method, params: { item: params.item } }
118+
? {
119+
method: input.method,
120+
params: {
121+
item: params.item,
122+
...(safeId(params.turnId) ? { turnId: params.turnId } : {}),
123+
},
124+
}
111125
: null;
112126
}
113127
return null;
@@ -117,15 +131,18 @@ function safeActivity(
117131
item: unknown,
118132
workspacePath?: string,
119133
activeTurnId?: string | null,
134+
notificationTurnId?: string,
120135
): { id: string; label: string; detail: string; turnId?: string } | null {
121136
if (!item || typeof item !== 'object') return null;
122137
const value = item as Record<string, unknown>;
123138
if (typeof value.id !== 'string' || typeof value.type !== 'string') return null;
124-
const owner = safeId(value.turnId)
125-
? { turnId: value.turnId as string }
126-
: activeTurnId
127-
? { turnId: activeTurnId }
128-
: {};
139+
const owner = notificationTurnId
140+
? { turnId: notificationTurnId }
141+
: safeId(value.turnId)
142+
? { turnId: value.turnId as string }
143+
: activeTurnId
144+
? { turnId: activeTurnId }
145+
: {};
129146
const status = typeof value.status === 'string' ? ` · ${value.status}` : '';
130147
if (value.type === 'commandExecution' && typeof value.command === 'string')
131148
return { id: value.id, label: `Command${status}`, detail: value.command, ...owner };
@@ -162,6 +179,7 @@ function safeActivity(
162179
function safeAgentMessage(
163180
item: unknown,
164181
activeTurnId?: string | null,
182+
notificationTurnId?: string,
165183
): {
166184
itemId: string;
167185
text: string;
@@ -176,11 +194,13 @@ function safeAgentMessage(
176194
itemId: value.id as string,
177195
text: typeof value.text === 'string' ? value.text : '',
178196
...(phase ? { phase } : {}),
179-
...(safeId(value.turnId)
180-
? { turnId: value.turnId as string }
181-
: activeTurnId
182-
? { turnId: activeTurnId }
183-
: {}),
197+
...(notificationTurnId
198+
? { turnId: notificationTurnId }
199+
: safeId(value.turnId)
200+
? { turnId: value.turnId as string }
201+
: activeTurnId
202+
? { turnId: activeTurnId }
203+
: {}),
184204
};
185205
}
186206

0 commit comments

Comments
 (0)