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:
@@ -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];
|
||||
|
||||
/** `&` last, or `&lt;` would decode twice and become `<`. */
|
||||
const decodeEntities = (text: string) =>
|
||||
text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user