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:
2026-08-25 06:37:21 +00:00
co-authored by Claude Opus 5
parent b5c91a81c5
commit 0096392cde
7 changed files with 360 additions and 48 deletions
+57 -4
View File
@@ -423,7 +423,17 @@ export type ClaudeChatMessage =
// Where the agent rewrote its own context. Kept for the same reason as `divider`: the conversation
// above it is still yours to read, and the agent's memory of it is a summary. It is also the answer to
// "why did it go quiet for two minutes there", which is only useful if it survives a reload.
| { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true };
| { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true }
// A background task reporting in — see `taskNotificationFrom`. `startsTurn` is the load-bearing field:
// this is the prompt the agent woke up to, so it is where one turn ends and the next begins.
| {
role: 'task';
taskId: string;
description: string;
status?: 'completed' | 'failed' | 'stopped';
summary?: string;
startsTurn: true;
};
/**
* Claude records an interrupted turn by writing one of these as the *user's* next message — it is how the
@@ -457,16 +467,59 @@ function blockText(content: unknown): string {
* A background task reporting in. The harness delivers these to the agent by writing them as the *user's*
* next message — the same trick as `INTERRUPTION_MARKERS`, and the same consequence: replayed literally,
* the transcript shows a raw XML envelope in a bubble on your side of the conversation that you never
* typed. Live it never appears, because a finished task travels as its own `task:notification` event; it
* surfaces only when a refresh rebuilds the conversation from Claude's file.
* typed. Live it never appears as prose, because a finished task travels as its own `task:notification`
* event and lands in the row the task already had; it surfaces only when a refresh rebuilds the
* conversation from Claude's file.
*
* Anchored to the start of the message rather than matched anywhere in it, so quoting one inside a real
* message — this file's own conversation does exactly that — stays yours.
*/
const isTaskNotification = (text: string) => text.trimStart().startsWith('<task-notification>');
const tagOf = (envelope: string, tag: string): string | undefined =>
envelope.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1];
/** `&amp;` last, or `&amp;lt;` would decode twice and become `<`. */
const decodeEntities = (text: string) =>
text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
const TASK_STATUSES = new Set(['completed', 'failed', 'stopped']);
/**
* The envelope, rebuilt into the same row a live `task:notification` produces when the task's announcing
* row is gone (`useChat`, `task:notification`). It used to be dropped outright, and dropping it lost more
* than a bubble: **turn boundaries are derived from the user's messages**, so deleting one merged the
* woken turn into the one before it. Everything the agent had said closing that earlier turn stopped being
* the turn's trailing prose and became mid-turn work, which folds — and the merged turn then ended on a
* tool call, which printed "Turn finished · no closing message" over a turn that had plainly finished
* talking. A commander wakes on these, so its whole life replayed as a single turn with every answer
* folded away.
*
* Malformed envelopes still return null and are still dropped: a row we cannot identify is worse than a
* missing boundary.
*/
function taskNotificationFrom(text: string): ClaudeChatMessage | null {
const taskId = tagOf(text, 'task-id')?.trim();
if (!taskId) return null;
const status = tagOf(text, 'status')?.trim();
const summary = decodeEntities((tagOf(text, 'summary') ?? '').trim());
return {
role: 'task',
taskId,
description: summary,
status: status && TASK_STATUSES.has(status) ? (status as 'completed' | 'failed' | 'stopped') : undefined,
summary,
startsTurn: true,
};
}
function userMessageFrom(text: string): ClaudeChatMessage | null {
if (isTaskNotification(text)) return null;
if (isTaskNotification(text)) return taskNotificationFrom(text);
if (INTERRUPTION_MARKERS.has(text.trim())) return { role: 'interrupted' };
return { role: 'user', text };
}
@@ -0,0 +1,91 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadClaudeSession, projectSlug, type ChatIdentity } from './claude-sessions';
/**
* The harness delivers a finished background task to the agent by writing it as the agent's next *user*
* message. Replayed literally that is a raw XML envelope in a bubble on your side of the conversation, so
* the parser used to drop it — and dropped the turn boundary with it, because `turn-rows` derives turns
* from the message list alone. Reported 2026-08-25 against a commander, which wakes on these: its whole
* history replayed as one turn with every answer folded away inside the seam.
*
* Driven through the real loader rather than the parser, because the drop and the boundary live one call
* apart and testing them separately is how they came apart in the first place.
*/
const CWD = '/home/someone/commanders/1';
const SESSION = '00000000-0000-4000-8000-000000000001';
const home = mkdtempSync(join(tmpdir(), 'officer-transcript-'));
afterAll(() => rmSync(home, { recursive: true, force: true }));
const who: ChatIdentity = { email: 'someone@example.com', home, osUser: null, isOwner: true };
const write = (lines: unknown[]): void => {
const dir = join(home, '.claude', 'projects', projectSlug(CWD));
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${SESSION}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n'));
};
const load = () => loadClaudeSession(who, CWD, SESSION)!.messages;
const NOTIFICATION = [
'<task-notification>',
'<task-id>b99iu40k1</task-id>',
'<tool-use-id>toolu_01JDH</tool-use-id>',
'<status>completed</status>',
'<summary>Background command "wc -l &lt; ledger.tsv" completed (exit code 0)</summary>',
'</task-notification>',
].join('\n');
const say = (text: string) => ({
type: 'assistant',
cwd: CWD,
message: { role: 'assistant', content: [{ type: 'text', text }] },
});
const prompt = (text: string) => ({ type: 'user', cwd: CWD, message: { role: 'user', content: text } });
describe('a background task reporting in, replayed from the transcript', () => {
test('becomes a task row rather than being dropped or shown as something you typed', () => {
write([prompt('watch the folder'), say('armed'), prompt(NOTIFICATION), say('it fired')]);
const messages = load();
expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'task', 'assistant']);
const task = messages[2];
expect(task).toMatchObject({ role: 'task', taskId: 'b99iu40k1', status: 'completed', startsTurn: true });
});
test('carries the summary with its entities decoded, not the raw envelope', () => {
write([prompt('watch the folder'), prompt(NOTIFICATION)]);
const task = load()[1] as Extract<ReturnType<typeof load>[number], { role: 'task' }>;
expect(task.summary).toBe('Background command "wc -l < ledger.tsv" completed (exit code 0)');
expect(task.summary).not.toContain('&lt;');
expect(task.summary).not.toContain('<task-notification>');
});
test('an envelope with no task id is still dropped — an unidentifiable row is worse than none', () => {
write([
prompt('watch the folder'),
prompt('<task-notification>\n<status>completed</status>\n</task-notification>'),
]);
expect(load().map((m) => m.role)).toEqual(['user']);
});
test('a message merely QUOTING an envelope stays yours', () => {
// This file's own conversation does exactly that, which is why the match is anchored to the start.
write([prompt(`what does ${NOTIFICATION} mean?`)]);
expect(load().map((m) => m.role)).toEqual(['user']);
});
test('an unknown status is dropped rather than passed through to the row', () => {
write([prompt('go'), prompt(NOTIFICATION.replace('completed', 'wedged'))]);
expect(load()[1]).toMatchObject({ role: 'task', status: undefined });
});
});