a task the agent woke up to starts a turn
Refreshing a chat lost the agent's answers: the turn folded to a seam and printed "Turn finished · no closing message" over a turn that had plainly answered. Reported against a commander, where it happened on most refreshes. The durable log was innocent. A refresh renders from Claude's .jsonl, and the harness delivers a finished background task as the agent's next user message — a <task-notification> envelope. The parser dropped it so it would not replay as a raw XML bubble you appear to have typed, and dropped a turn boundary with it, because turn-rows derives turns from the message list. The woken turn merged into the one before it, so that turn's closing prose became mid-turn work and folded, and the merged segment ended on a tool call, which earned the marker. Measured on the reported session: 30 messages, one user message, one fold hiding seven answers, the marker reproduced at 21 of 30 refresh points. The envelope now rebuilds into the same task row a live task:notification produces, carrying startsTurn. buildRows makes that a boundary and buildTurn exposes it as the turn's opener. Separately, the marker is withheld when a segment ends on a tool call we never saw come back — still running, or cut off holding it. A completed trailing tool call keeps its marker, so the test pinning that live behaviour is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
This commit is contained in:
@@ -14,6 +14,16 @@ const tool = (id: string, isError?: boolean): ChatMessage => ({
|
||||
output: 'ok',
|
||||
...(isError ? { isError: true } : {}),
|
||||
});
|
||||
/** A tool call we never saw come back — no `output`. The turn is still holding it. */
|
||||
const pending = (id: string): ChatMessage => ({ role: 'tool', toolName: 'Bash', toolInput: {}, toolCallId: id });
|
||||
/** A finished background task the agent woke up to — see `startsTurn` in types.ts. */
|
||||
const woke = (id: string): ChatMessage => ({
|
||||
role: 'task',
|
||||
taskId: id,
|
||||
description: 'the task that woke it',
|
||||
status: 'completed',
|
||||
startsTurn: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* The shape under test is the row sequence, not the keys.
|
||||
@@ -243,6 +253,86 @@ describe('buildRows', () => {
|
||||
test('a bare question with no work yet produces no marker and no fold', () => {
|
||||
expect(shape([user('go')], true)).toEqual(['user']);
|
||||
});
|
||||
|
||||
// ── A turn the agent woke up to ──
|
||||
//
|
||||
// The bug these pin, reported 2026-08-25 against a commander: the harness delivers a finished background
|
||||
// task as the agent's next *user* message, and the transcript parser used to drop that message so it would
|
||||
// not render as an XML envelope you appear to have typed. Dropping it also dropped the turn boundary, so
|
||||
// every turn a commander woke up for merged into the one before it. A commander's whole life replayed as
|
||||
// one turn, with every answer it had ever given folded away inside the seam.
|
||||
|
||||
test('a task the agent woke up to starts a turn, and the answer before it stays visible', () => {
|
||||
expect(shape([user('go'), tool('a'), say('done, watching'), woke('t1'), tool('b'), say('it fired')])).toEqual([
|
||||
'user',
|
||||
'fold',
|
||||
'assistant',
|
||||
'task',
|
||||
'fold',
|
||||
'assistant',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the same messages WITHOUT the flag are what used to swallow the answer', () => {
|
||||
const merged: ChatMessage[] = [
|
||||
user('go'),
|
||||
tool('a'),
|
||||
say('done, watching'),
|
||||
{ role: 'task', taskId: 't1', description: 'fired', status: 'completed' },
|
||||
tool('b'),
|
||||
say('it fired'),
|
||||
];
|
||||
expect(shape(merged)).toEqual(['user', 'fold', 'assistant']);
|
||||
});
|
||||
|
||||
test('a task the agent STARTED mid-turn is work, not a boundary', () => {
|
||||
const started: ChatMessage[] = [
|
||||
user('go'),
|
||||
{ role: 'task', taskId: 't1', description: 'building' },
|
||||
say('off it goes'),
|
||||
];
|
||||
expect(shape(started)).toEqual(['user', 'fold', 'assistant']);
|
||||
});
|
||||
|
||||
test('the woken turn is never folded away — it is the question that turn answers', () => {
|
||||
const rows = buildRows([user('go'), say('watching'), woke('t1'), tool('b'), say('it fired')], false, 5);
|
||||
expect(rows[2]?.kind === 'message' && rows[2].message.role).toBe('task');
|
||||
});
|
||||
|
||||
// The reported screenshot, in miniature. Merged, the segment ended on a tool call, so the answer became
|
||||
// mid-turn work and folded, and the marker was stamped over the whole conversation. Split, the answer is
|
||||
// back on screen and the marker — still earned, by a woken turn that really did say nothing — is confined
|
||||
// to the two rows it describes.
|
||||
test('merged, the answer is swallowed and the marker covers everything', () => {
|
||||
const merged: ChatMessage[] = [
|
||||
user('go'),
|
||||
say('armed'),
|
||||
{ role: 'task', taskId: 't1', description: 'fired', status: 'completed' },
|
||||
tool('b'),
|
||||
];
|
||||
expect(shape(merged)).toEqual(['user', 'fold', 'turn-end']);
|
||||
});
|
||||
|
||||
test('split, the answer survives and the marker describes only the woken turn', () => {
|
||||
expect(shape([user('go'), say('armed'), woke('t1'), tool('b')])).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'task',
|
||||
'fold',
|
||||
'turn-end',
|
||||
]);
|
||||
});
|
||||
|
||||
// ── A tool we never saw come back ──
|
||||
|
||||
test('a turn holding an unfinished tool call is not marked finished', () => {
|
||||
expect(shape([user('go'), pending('a')], false, 0)).toEqual(['user', 'tool']);
|
||||
});
|
||||
|
||||
test('a reload mid-turn does not claim the turn finished before the socket answers', () => {
|
||||
// `isGenerating` is false until `attach` comes back, so for those frames the transcript is all we have.
|
||||
expect(shape([user('go'), tool('a'), pending('b')])).toEqual(['user', 'fold']);
|
||||
});
|
||||
});
|
||||
|
||||
/** What tool rows key their expansion off. Getting this wrong expands a whole transcript at once. */
|
||||
|
||||
@@ -106,10 +106,12 @@ function buildTurn({ segment, offset, fold, complete, live }: TurnParams): ChatR
|
||||
const push = (message: ChatMessage, i: number) =>
|
||||
rows.push({ kind: 'message', key: keyOf(message, offset + i), message, live });
|
||||
|
||||
// Your message is never folded — it is the question the whole turn is an answer to.
|
||||
// Whatever prompted the turn is never folded — it is the question the whole turn is an answer to. Usually
|
||||
// that is your message; for a turn the agent woke up to, it is the task that woke it.
|
||||
let start = 0;
|
||||
if (segment[0]?.role === 'user') {
|
||||
push(segment[0], 0);
|
||||
const opener = segment[0];
|
||||
if (opener && (opener.role === 'user' || (opener.role === 'task' && opener.startsTurn))) {
|
||||
push(opener, 0);
|
||||
start = 1;
|
||||
}
|
||||
|
||||
@@ -151,8 +153,24 @@ function buildTurn({ segment, offset, fold, complete, live }: TurnParams): ChatR
|
||||
flush();
|
||||
}
|
||||
|
||||
// A tool we never saw come back. The turn is then not over — it is still running, or it was cut off
|
||||
// holding that call — and either way "finished without saying anything" is a claim the messages do not
|
||||
// support. It matters most on a reload, which renders from the transcript with `isGenerating` still
|
||||
// false until the socket attaches: for those few frames every unfinished turn looks finished.
|
||||
//
|
||||
// A COMPLETED tool call at the end is a different thing and keeps its marker: the tool came back, the
|
||||
// agent said nothing, and that is exactly the silence this row exists to name.
|
||||
const last = segment.at(-1);
|
||||
const toolStillOut = last?.role === 'tool' && last.output === undefined;
|
||||
|
||||
// Nothing was said and nothing went wrong — say so, rather than ending on a collapsed tool call.
|
||||
if (complete && !tail.some(isAnswer) && !tail.some(isFailure) && (head.length > 0 || tail.length > 0)) {
|
||||
if (
|
||||
complete &&
|
||||
!toolStillOut &&
|
||||
!tail.some(isAnswer) &&
|
||||
!tail.some(isFailure) &&
|
||||
(head.length > 0 || tail.length > 0)
|
||||
) {
|
||||
rows.push({ kind: 'turn-end', key: `e:${offset}` });
|
||||
}
|
||||
|
||||
@@ -178,15 +196,20 @@ export function buildRows(messages: ChatMessage[], isGenerating: boolean, histor
|
||||
// half `complete` — tool calls, no answer — and printed "Turn finished · no closing message" across a
|
||||
// turn that was visibly still working, with the header still saying so. Reported as "the output is very
|
||||
// misleading", and it was: the UI said the opposite of what was happening.
|
||||
const userAt: number[] = [];
|
||||
//
|
||||
// A background task reporting in is the other kind of prompt, and it starts a turn for the same reason: the
|
||||
// agent was asleep and this is what it woke up to. Live that boundary needs no stating, because the work
|
||||
// arrives as it happens; on a reload it has to be reconstructed, which is what `startsTurn` is for.
|
||||
const turnAt: number[] = [];
|
||||
messages.forEach((m, i) => {
|
||||
if (m.role === 'user' && !m.injected) userAt.push(i);
|
||||
if (m.role === 'user' && !m.injected) turnAt.push(i);
|
||||
else if (m.role === 'task' && m.startsTurn) turnAt.push(i);
|
||||
});
|
||||
|
||||
// Every user message is a turn boundary — including the last one, which used to be excluded because the
|
||||
// Every prompt is a turn boundary — including the last one, which used to be excluded because the
|
||||
// turn after it was by definition the live one. Now the final segment is an ordinary turn that folds or
|
||||
// not on the same rule as the rest.
|
||||
const bounds = [0, ...userAt.filter((i) => i > 0), messages.length].filter((v, i, a) => i === 0 || v !== a[i - 1]);
|
||||
const bounds = [0, ...turnAt.filter((i) => i > 0), messages.length].filter((v, i, a) => i === 0 || v !== a[i - 1]);
|
||||
|
||||
const rows: ChatRow[] = [];
|
||||
for (let i = 0; i < bounds.length - 1; i++) {
|
||||
|
||||
@@ -77,6 +77,17 @@ export type ChatMessage =
|
||||
taskType?: string;
|
||||
status?: 'completed' | 'failed' | 'stopped';
|
||||
summary?: string;
|
||||
/**
|
||||
* This row is the notification the agent WOKE UP to, not a task it started mid-turn — so it opens a
|
||||
* turn, the way a user message does. Only the transcript parser sets it, because only a reload has to
|
||||
* reconstruct a boundary the live socket never needed to state.
|
||||
*
|
||||
* The mirror image of `injected` on a user message: that one is a message that does not start a turn,
|
||||
* this is a non-message that does. Both exist because `turn-rows` derives turns from the message list
|
||||
* alone, and both were added after that derivation printed "Turn finished · no closing message" over a
|
||||
* turn that had not finished.
|
||||
*/
|
||||
startsTurn?: true;
|
||||
}
|
||||
| {
|
||||
// The harness rewriting its own context, start to finish, in one row — the same shape as `task`,
|
||||
|
||||
Reference in New Issue
Block a user