Skip to content

Commit eea8833

Browse files
committed
Fix judge final-message extraction (closes #2)
Rewrite extractFinalAssistantMessage to prefer aggregate `message` events emitted by claude after each `message_stop`, drop the per-turn reset that erased multi-turn content, and fall back to trailing partials only when an aggregate never lands (wallclock-truncated last turn). Add test/judge.spec.ts covering empty, aggregate-only, partial-only, both-present, multi-turn, truncated-tail, tool-using, thinking-only, empty-aggregate, string-content, user-role-ignored, and the explicit no-reset-on-turn regression. Wire up `npm run test:judge`.
1 parent b44eddb commit eea8833

3 files changed

Lines changed: 253 additions & 10 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"dev:server": "tsx watch src/server/index.ts",
1919
"dev:web": "vite",
2020
"start": "node dist/server/index.js",
21-
"test:runner": "tsx test/runner.smoke.ts"
21+
"test:runner": "tsx test/runner.smoke.ts",
22+
"test:judge": "tsx test/judge.spec.ts"
2223
},
2324
"engines": {
2425
"node": ">=20.0.0"

src/server/judge.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,18 +121,49 @@ function buildJudgePrompt(input: JudgeInput): string {
121121
return lines.join('\n');
122122
}
123123

124-
function extractFinalAssistantMessage(transcript: TranscriptFile): string {
125-
const buffer: string[] = [];
126-
for (const e of transcript.events) {
127-
if (e.t === 'turn') {
128-
buffer.length = 0;
129-
continue;
124+
// Aggregate `assistant` messages from real claude come after each `message_stop` and
125+
// carry a `content` array of blocks like `[{type:"text", text:"…"}, {type:"tool_use", …}]`.
126+
// Pull out the text blocks and concatenate.
127+
function extractTextFromMessageContent(content: unknown): string {
128+
if (typeof content === 'string') return content;
129+
if (!Array.isArray(content)) return '';
130+
const parts: string[] = [];
131+
for (const block of content) {
132+
if (!block || typeof block !== 'object') continue;
133+
const obj = block as { type?: unknown; text?: unknown };
134+
if (obj.type === 'text' && typeof obj.text === 'string') {
135+
parts.push(obj.text);
130136
}
131-
if (e.t === 'partial' && e.kind === 'text') {
132-
buffer.push(e.chunk);
137+
}
138+
return parts.join('');
139+
}
140+
141+
export function extractFinalAssistantMessage(transcript: TranscriptFile): string {
142+
// Prefer aggregate `message` events with role==='assistant' — they are the canonical
143+
// assistant text emitted by claude after each `message_stop`. Concatenate across
144+
// turns so multi-turn analyses are visible to the judge (a brief "Done" closer
145+
// should not erase the analysis that came before it). Fall back to the partial
146+
// stream only when an aggregate never arrived — typically the wallclock-truncated
147+
// last turn, where partials were emitted but `message_stop` never fired.
148+
const segments: string[] = [];
149+
let pendingPartials: string[] = [];
150+
151+
for (const e of transcript.events) {
152+
if (e.t === 'message' && e.role === 'assistant') {
153+
const text = extractTextFromMessageContent(e.content);
154+
if (text) {
155+
pendingPartials = [];
156+
segments.push(text);
157+
}
158+
} else if (e.t === 'partial' && e.kind === 'text') {
159+
pendingPartials.push(e.chunk);
133160
}
134161
}
135-
return buffer.join('') || '(no final message emitted)';
162+
if (pendingPartials.length > 0) {
163+
const tail = pendingPartials.join('');
164+
if (tail) segments.push(tail);
165+
}
166+
return segments.length === 0 ? '(no final message emitted)' : segments.join('\n\n');
136167
}
137168

138169
function extractToolSummary(transcript: TranscriptFile): string[] {

test/judge.spec.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { extractFinalAssistantMessage } from '../src/server/judge.js';
2+
import type { NormalizedEvent } from '@shared/schemas/events.js';
3+
import type { TranscriptFile } from '@shared/schemas/run.js';
4+
5+
const NO_FINAL = '(no final message emitted)';
6+
7+
function makeTranscript(events: NormalizedEvent[]): TranscriptFile {
8+
return {
9+
runFolder: 'test',
10+
events,
11+
status: 'completed',
12+
startedAt: '2026-04-25T00:00:00Z',
13+
endedAt: '2026-04-25T00:00:01Z',
14+
turnCount: 0,
15+
wallClockMs: 1000,
16+
truncationReason: null,
17+
};
18+
}
19+
20+
function partial(chunk: string, kind: 'text' | 'thinking' = 'text'): NormalizedEvent {
21+
return { t: 'partial', chunk, kind, ts: 0 };
22+
}
23+
24+
function turn(n: number): NormalizedEvent {
25+
return { t: 'turn', turn: n, ts: 0 };
26+
}
27+
28+
function assistantMessage(content: unknown): NormalizedEvent {
29+
return { t: 'message', role: 'assistant', content, ts: 0 };
30+
}
31+
32+
function userMessage(content: unknown): NormalizedEvent {
33+
return { t: 'message', role: 'user', content, ts: 0 };
34+
}
35+
36+
function textBlock(text: string): { type: string; text: string } {
37+
return { type: 'text', text };
38+
}
39+
40+
function toolUseBlock(name: string): { type: string; name: string; input: unknown } {
41+
return { type: 'tool_use', name, input: {} };
42+
}
43+
44+
function expect(actual: string, expected: string, label: string): void {
45+
if (actual !== expected) {
46+
throw new Error(`${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`);
47+
}
48+
}
49+
50+
function scenario(name: string, run: () => void): void {
51+
process.stdout.write(`• ${name} … `);
52+
try {
53+
run();
54+
process.stdout.write('PASS\n');
55+
} catch (err) {
56+
process.stdout.write('FAIL\n');
57+
console.error(err);
58+
process.exit(1);
59+
}
60+
}
61+
62+
scenario('empty transcript returns sentinel', () => {
63+
const out = extractFinalAssistantMessage(makeTranscript([]));
64+
expect(out, NO_FINAL, 'empty');
65+
});
66+
67+
scenario('aggregate-only single turn returns its text', () => {
68+
const out = extractFinalAssistantMessage(
69+
makeTranscript([
70+
turn(1),
71+
assistantMessage([textBlock('the answer is 42')]),
72+
]),
73+
);
74+
expect(out, 'the answer is 42', 'aggregate-only');
75+
});
76+
77+
scenario('partial-only (truncated, no aggregate emitted)', () => {
78+
// Wallclock cap fires after partials but before message_stop. No aggregate ever lands.
79+
const out = extractFinalAssistantMessage(
80+
makeTranscript([
81+
partial('hello '),
82+
partial('world'),
83+
]),
84+
);
85+
expect(out, 'hello world', 'partial-only');
86+
});
87+
88+
scenario('aggregate present discards partials that fed it', () => {
89+
// Real claude emits partials, then message_stop, then the aggregate. We should
90+
// prefer the aggregate (canonical) and drop the partial buffer.
91+
const out = extractFinalAssistantMessage(
92+
makeTranscript([
93+
partial('par'),
94+
partial('tial'),
95+
turn(1),
96+
assistantMessage([textBlock('aggregate')]),
97+
]),
98+
);
99+
expect(out, 'aggregate', 'prefer-aggregate');
100+
});
101+
102+
scenario('multi-turn aggregates: all turns visible to judge', () => {
103+
// Regression test for opus's critique: a long analysis across 3 turns followed
104+
// by a brief closer should not erase the analysis.
105+
const out = extractFinalAssistantMessage(
106+
makeTranscript([
107+
assistantMessage([textBlock('detailed analysis turn 1')]),
108+
turn(1),
109+
assistantMessage([textBlock('continuing analysis turn 2')]),
110+
turn(2),
111+
assistantMessage([textBlock('Done.')]),
112+
turn(3),
113+
]),
114+
);
115+
expect(
116+
out,
117+
'detailed analysis turn 1\n\ncontinuing analysis turn 2\n\nDone.',
118+
'multi-turn',
119+
);
120+
});
121+
122+
scenario('truncated last turn: earlier aggregates + trailing partials', () => {
123+
// Turn 1 completes with an aggregate. Turn 2 begins streaming partials and the
124+
// wallclock cap fires before message_stop. Both should reach the judge.
125+
const out = extractFinalAssistantMessage(
126+
makeTranscript([
127+
assistantMessage([textBlock('turn 1 final')]),
128+
turn(1),
129+
partial('turn 2 in '),
130+
partial('progress'),
131+
]),
132+
);
133+
expect(out, 'turn 1 final\n\nturn 2 in progress', 'truncated-tail');
134+
});
135+
136+
scenario('tool-using turn: only text block extracted, tool_use ignored', () => {
137+
const out = extractFinalAssistantMessage(
138+
makeTranscript([
139+
assistantMessage([textBlock('let me check the file'), toolUseBlock('Read')]),
140+
// tool_use turns do not emit a `turn` event (stop_reason='tool_use')
141+
userMessage([{ type: 'tool_result', tool_use_id: 'tu-0', content: 'file body' }]),
142+
assistantMessage([textBlock('the file contains foo')]),
143+
turn(1),
144+
]),
145+
);
146+
expect(out, 'let me check the file\n\nthe file contains foo', 'tool-using');
147+
});
148+
149+
scenario('thinking-only partials are ignored', () => {
150+
const out = extractFinalAssistantMessage(
151+
makeTranscript([
152+
partial('hidden reasoning', 'thinking'),
153+
partial('more thinking', 'thinking'),
154+
]),
155+
);
156+
expect(out, NO_FINAL, 'thinking-only');
157+
});
158+
159+
scenario('aggregate with no text content is skipped', () => {
160+
// Tool-only turn (no text block, just a tool_use). Aggregate emits but contributes
161+
// nothing. Falls through to sentinel when nothing else exists.
162+
const out = extractFinalAssistantMessage(
163+
makeTranscript([
164+
assistantMessage([toolUseBlock('Glob')]),
165+
turn(1),
166+
]),
167+
);
168+
expect(out, NO_FINAL, 'tool-only-aggregate');
169+
});
170+
171+
scenario('aggregate with string content (defensive) is handled', () => {
172+
// Older claude shapes occasionally pass `content` as a plain string rather than
173+
// an array of blocks. Don't break.
174+
const out = extractFinalAssistantMessage(
175+
makeTranscript([
176+
assistantMessage('legacy string content'),
177+
turn(1),
178+
]),
179+
);
180+
expect(out, 'legacy string content', 'string-content');
181+
});
182+
183+
scenario('user-role messages are ignored', () => {
184+
const out = extractFinalAssistantMessage(
185+
makeTranscript([
186+
userMessage([textBlock('user wrote this — must not appear in judge input')]),
187+
assistantMessage([textBlock('assistant response')]),
188+
turn(1),
189+
]),
190+
);
191+
expect(out, 'assistant response', 'user-role-ignored');
192+
});
193+
194+
scenario('regression: turn marker no longer resets buffer', () => {
195+
// Previously, a `turn` event cleared the partial buffer, dropping all content
196+
// from earlier turns. With aggregates present, the bug manifested as "(no final
197+
// message emitted)" because aggregates land *after* the turn marker and the
198+
// partial-only path was the only one that worked. With this fix, both aggregate
199+
// and partial paths capture the full content.
200+
const partialOnly = extractFinalAssistantMessage(
201+
makeTranscript([
202+
partial('first turn text'),
203+
turn(1),
204+
partial('second turn text'),
205+
turn(2),
206+
]),
207+
);
208+
expect(partialOnly, 'first turn textsecond turn text', 'no-reset-on-turn');
209+
});
210+
211+
console.log('\nAll judge scenarios passed.');

0 commit comments

Comments
 (0)