Skip to content

Commit 3e49062

Browse files
jsell-rhclaude
andcommitted
fix(runner): extract last assistant message, not first, from snapshot
GRPCMessageWriter used next() to find the first assistant message in MESSAGES_SNAPSHOT, which in multi-turn runs is a tool-call-only message with content=None. The real final response is in the last assistant message. Iterate to find the last one with actual content. Remove the fragile UI-side enrichMessages workaround that tried to extract last_assistant_message from nearby hook:Stop system events. Replace with simple filterEmptyMessages that drops empty assistant records (which should no longer occur with the runner fix). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9c40c62 commit 3e49062

3 files changed

Lines changed: 52 additions & 231 deletions

File tree

components/ambient-ui/src/components/__tests__/chat-messages.test.ts

Lines changed: 23 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import type { DomainSessionMessage } from '@/domain/types'
33
import {
44
tryParseToolPayload,
55
tryParseToolResult,
6-
extractLastAssistantMessage,
7-
enrichMessages,
6+
filterEmptyMessages,
87
groupChatItems,
98
buildChatItems,
109
} from '../chat-messages'
@@ -111,95 +110,35 @@ describe('tryParseToolResult', () => {
111110
})
112111
})
113112

114-
// ---- extractLastAssistantMessage ----
113+
// ---- filterEmptyMessages ----
115114

116-
describe('extractLastAssistantMessage', () => {
117-
it('extracts last_assistant_message from a system event payload', () => {
118-
const payload = JSON.stringify({
119-
value: { last_assistant_message: 'Hello from the assistant' },
120-
})
121-
expect(extractLastAssistantMessage(payload)).toBe('Hello from the assistant')
122-
})
123-
124-
it('returns null when last_assistant_message is missing', () => {
125-
const payload = JSON.stringify({ value: { other_field: 'x' } })
126-
expect(extractLastAssistantMessage(payload)).toBeNull()
127-
})
128-
129-
it('returns null when last_assistant_message is whitespace-only', () => {
130-
const payload = JSON.stringify({ value: { last_assistant_message: ' ' } })
131-
expect(extractLastAssistantMessage(payload)).toBeNull()
132-
})
133-
134-
it('returns null when value is not an object', () => {
135-
const payload = JSON.stringify({ value: 'string-value' })
136-
expect(extractLastAssistantMessage(payload)).toBeNull()
137-
})
138-
139-
it('returns null for invalid JSON', () => {
140-
expect(extractLastAssistantMessage('not json')).toBeNull()
141-
})
142-
143-
it('returns null for a JSON array', () => {
144-
expect(extractLastAssistantMessage('[1]')).toBeNull()
145-
})
146-
})
147-
148-
// ---- enrichMessages ----
149-
150-
describe('enrichMessages', () => {
151-
it('enriches empty assistant messages from a preceding system event', () => {
152-
const messages = [
153-
makeMsg({
154-
eventType: 'system',
155-
payload: JSON.stringify({
156-
value: { last_assistant_message: 'Extracted text' },
157-
}),
158-
}),
159-
makeMsg({ eventType: 'assistant', payload: '' }),
160-
]
161-
162-
const result = enrichMessages(messages)
163-
expect(result).toHaveLength(2)
164-
// The system event passes through
165-
expect(result[0].eventType).toBe('system')
166-
// The assistant message is enriched
167-
expect(result[1].eventType).toBe('assistant')
168-
expect(result[1].payload).toBe('Extracted text')
169-
})
170-
171-
it('drops empty assistant messages with no nearby system event', () => {
115+
describe('filterEmptyMessages', () => {
116+
it('drops empty assistant messages', () => {
172117
const messages = [
173118
makeMsg({ eventType: 'user', payload: 'hello' }),
174119
makeMsg({ eventType: 'assistant', payload: '' }),
175120
]
176121

177-
const result = enrichMessages(messages)
122+
const result = filterEmptyMessages(messages)
178123
expect(result).toHaveLength(1)
179124
expect(result[0].eventType).toBe('user')
180125
})
181126

182-
it('drops empty assistant messages when system event lacks last_assistant_message', () => {
127+
it('drops whitespace-only assistant messages', () => {
183128
const messages = [
184-
makeMsg({
185-
eventType: 'system',
186-
payload: JSON.stringify({ value: { some_other: 'data' } }),
187-
}),
188-
makeMsg({ eventType: 'assistant', payload: '' }),
129+
makeMsg({ eventType: 'assistant', payload: ' ' }),
189130
]
190131

191-
const result = enrichMessages(messages)
192-
// System passes through, empty assistant is dropped
193-
expect(result).toHaveLength(1)
194-
expect(result[0].eventType).toBe('system')
132+
const result = filterEmptyMessages(messages)
133+
expect(result).toHaveLength(0)
195134
})
196135

197-
it('passes through non-empty assistant messages unchanged', () => {
136+
it('passes through non-empty assistant messages', () => {
198137
const messages = [
199138
makeMsg({ eventType: 'assistant', payload: 'I have content' }),
200139
]
201140

202-
const result = enrichMessages(messages)
141+
const result = filterEmptyMessages(messages)
203142
expect(result).toHaveLength(1)
204143
expect(result[0].payload).toBe('I have content')
205144
})
@@ -211,49 +150,10 @@ describe('enrichMessages', () => {
211150
makeMsg({ eventType: 'lifecycle', payload: 'started' }),
212151
]
213152

214-
const result = enrichMessages(messages)
153+
const result = filterEmptyMessages(messages)
215154
expect(result).toHaveLength(3)
216155
expect(result.map(m => m.eventType)).toEqual(['user', 'tool_use', 'lifecycle'])
217156
})
218-
219-
it('looks back up to 3 positions for a system event', () => {
220-
const messages = [
221-
makeMsg({
222-
eventType: 'system',
223-
payload: JSON.stringify({
224-
value: { last_assistant_message: 'Found it' },
225-
}),
226-
}),
227-
makeMsg({ eventType: 'user', payload: 'filler1' }),
228-
makeMsg({ eventType: 'user', payload: 'filler2' }),
229-
makeMsg({ eventType: 'assistant', payload: '' }),
230-
]
231-
232-
const result = enrichMessages(messages)
233-
const assistant = result.find(m => m.eventType === 'assistant')
234-
expect(assistant).toBeDefined()
235-
expect(assistant!.payload).toBe('Found it')
236-
})
237-
238-
it('does not look back more than 3 positions', () => {
239-
const messages = [
240-
makeMsg({
241-
eventType: 'system',
242-
payload: JSON.stringify({
243-
value: { last_assistant_message: 'Too far' },
244-
}),
245-
}),
246-
makeMsg({ eventType: 'user', payload: 'a' }),
247-
makeMsg({ eventType: 'user', payload: 'b' }),
248-
makeMsg({ eventType: 'user', payload: 'c' }),
249-
makeMsg({ eventType: 'assistant', payload: '' }),
250-
]
251-
252-
const result = enrichMessages(messages)
253-
// The empty assistant should be dropped since system event is 4 positions back
254-
const assistant = result.find(m => m.eventType === 'assistant')
255-
expect(assistant).toBeUndefined()
256-
})
257157
})
258158

259159
// ---- groupChatItems ----
@@ -371,12 +271,7 @@ describe('buildChatItems', () => {
371271
const messages = [
372272
makeMsg({ eventType: 'user', payload: 'Fix the bug' }),
373273
makeMsg({ eventType: 'lifecycle', payload: 'session_started' }),
374-
makeMsg({
375-
eventType: 'system',
376-
payload: JSON.stringify({
377-
value: { last_assistant_message: 'I will fix the bug.' },
378-
}),
379-
}),
274+
makeMsg({ eventType: 'system', payload: '{}' }),
380275
makeMsg({ eventType: 'assistant', payload: '' }),
381276
makeMsg({
382277
eventType: 'tool_use',
@@ -391,31 +286,25 @@ describe('buildChatItems', () => {
391286

392287
const items = buildChatItems(messages)
393288

394-
// lifecycle and system events are filtered out
289+
// lifecycle, system, and empty assistant are filtered out
395290
const kinds = items.map(i => i.kind)
396-
expect(kinds).toEqual(['message', 'message', 'tool_call', 'message'])
291+
expect(kinds).toEqual(['message', 'tool_call', 'message'])
397292

398293
// First message is the user message
399294
if (items[0].kind === 'message') {
400295
expect(items[0].message.eventType).toBe('user')
401296
expect(items[0].message.payload).toBe('Fix the bug')
402297
}
403298

404-
// Second message is the enriched assistant
405-
if (items[1].kind === 'message') {
406-
expect(items[1].message.eventType).toBe('assistant')
407-
expect(items[1].message.payload).toBe('I will fix the bug.')
408-
}
409-
410299
// Tool call is grouped
411-
if (items[2].kind === 'tool_call') {
412-
expect(items[2].group.toolResult).not.toBeNull()
300+
if (items[1].kind === 'tool_call') {
301+
expect(items[1].group.toolResult).not.toBeNull()
413302
}
414303

415-
// Final assistant message
416-
if (items[3].kind === 'message') {
417-
expect(items[3].message.eventType).toBe('assistant')
418-
expect(items[3].message.payload).toBe('Done fixing.')
304+
// Final assistant message (with content)
305+
if (items[2].kind === 'message') {
306+
expect(items[2].message.eventType).toBe('assistant')
307+
expect(items[2].message.payload).toBe('Done fixing.')
419308
}
420309
})
421310

@@ -433,23 +322,13 @@ describe('buildChatItems', () => {
433322
}
434323
})
435324

436-
it('includes enriched empty assistant messages derived from system events', () => {
325+
it('drops empty assistant messages', () => {
437326
const messages = [
438-
makeMsg({
439-
eventType: 'system',
440-
payload: JSON.stringify({
441-
value: { last_assistant_message: 'Enriched reply' },
442-
}),
443-
}),
444327
makeMsg({ eventType: 'assistant', payload: '' }),
445328
]
446329

447330
const items = buildChatItems(messages)
448-
expect(items).toHaveLength(1)
449-
if (items[0].kind === 'message') {
450-
expect(items[0].message.eventType).toBe('assistant')
451-
expect(items[0].message.payload).toBe('Enriched reply')
452-
}
331+
expect(items).toHaveLength(0)
453332
})
454333

455334
it('returns empty array when given no messages', () => {

components/ambient-ui/src/components/chat-messages.tsx

Lines changed: 7 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -88,46 +88,12 @@ export function tryFormatJson(payload: string): string {
8888
}
8989
}
9090

91-
// ---- Message Enrichment ----
91+
// ---- Message Filtering ----
9292

93-
export function extractLastAssistantMessage(payload: string): string | null {
94-
try {
95-
const parsed: unknown = JSON.parse(payload)
96-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null
97-
const obj = parsed as Record<string, unknown>
98-
if (typeof obj.value !== 'object' || obj.value === null) return null
99-
const value = obj.value as Record<string, unknown>
100-
if (typeof value.last_assistant_message === 'string' && value.last_assistant_message.trim()) {
101-
return value.last_assistant_message
102-
}
103-
return null
104-
} catch {
105-
return null
106-
}
107-
}
108-
109-
export function enrichMessages(messages: DomainSessionMessage[]): DomainSessionMessage[] {
110-
const enriched: DomainSessionMessage[] = []
111-
for (let i = 0; i < messages.length; i++) {
112-
const msg = messages[i]
113-
if (msg.eventType === 'assistant' && !msg.payload.trim()) {
114-
for (let j = i - 1; j >= Math.max(0, i - 3); j--) {
115-
if (messages[j].eventType === 'system') {
116-
const extracted = extractLastAssistantMessage(messages[j].payload)
117-
if (extracted) {
118-
enriched.push({ ...msg, payload: extracted })
119-
break
120-
}
121-
}
122-
}
123-
// Empty assistant messages with no extractable text are dropped — the
124-
// runner sometimes pushes an empty "assistant" record alongside a system
125-
// event that contains the actual response in last_assistant_message.
126-
continue
127-
}
128-
enriched.push(msg)
129-
}
130-
return enriched
93+
export function filterEmptyMessages(messages: DomainSessionMessage[]): DomainSessionMessage[] {
94+
return messages.filter(
95+
(msg) => !(msg.eventType === 'assistant' && !msg.payload.trim()),
96+
)
13197
}
13298

13399
// ---- Tool Call Grouping ----
@@ -474,7 +440,7 @@ export function ChatItemsList({
474440

475441
/** Filter and group raw messages into chat items */
476442
export function buildChatItems(messages: DomainSessionMessage[]): ChatItem[] {
477-
const enriched = enrichMessages(messages)
478-
const chatOnly = enriched.filter(m => CHAT_EVENT_TYPES.has(m.eventType))
443+
const filtered = filterEmptyMessages(messages)
444+
const chatOnly = filtered.filter(m => CHAT_EVENT_TYPES.has(m.eventType))
479445
return groupChatItems(chatOnly)
480446
}

0 commit comments

Comments
 (0)