@canonmsg/claude-code-plugin 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/host.js +651 -144
- package/dist/session-state.d.ts +535 -2
- package/dist/session-state.js +879 -7
- package/package.json +4 -4
package/dist/session-state.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { USAGE_LIMIT_ERROR_PREFIXES } from '@anthropic-ai/claude-agent-sdk';
|
|
3
|
+
import { DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES, isCanonMessageIdConflict, isRetryableCanonDeliveryError, utf8ByteLength, VERB_LIMITS, } from '@canonmsg/core';
|
|
4
|
+
import { boundTrailBlockMap, buildTrailBlockId, buildUndeliverableFinalNotice as buildHostUndeliverableFinalNotice, normalizePlanStepStatus, normalizeTrailKey, PLAN_BLOCK_TITLE, renderPlanSteps, truncateFailureDetail, } from '@canonmsg/coding-agent-host';
|
|
3
5
|
export function createClaudeInputEnvelope(input) {
|
|
4
6
|
const sourceMessageId = input.sourceMessageId ?? null;
|
|
5
7
|
const fallbackId = randomUUID();
|
|
8
|
+
// Stamped so each turn result can be matched back to the envelope that caused
|
|
9
|
+
// it via SDKResultMessage.user_message_uuid, instead of inferring ownership
|
|
10
|
+
// from a mutable slot and arrival order.
|
|
11
|
+
const messageUuid = randomUUID();
|
|
6
12
|
return {
|
|
7
13
|
kind: input.kind,
|
|
8
|
-
|
|
14
|
+
messageUuid,
|
|
15
|
+
msg: { ...input.msg, uuid: messageUuid },
|
|
9
16
|
intent: input.intent ?? 'queue',
|
|
10
17
|
sourceMessageId,
|
|
11
18
|
markAccepted: Boolean(input.markAccepted),
|
|
@@ -49,8 +56,61 @@ export function shouldDeliverClaudeFinal(input) {
|
|
|
49
56
|
return false;
|
|
50
57
|
return true;
|
|
51
58
|
}
|
|
59
|
+
/** Bounds the correlation map if the SDK ever stops reporting a uuid we sent. */
|
|
60
|
+
const MAX_TRACKED_DISPATCHED_INPUTS = 64;
|
|
61
|
+
export function rememberDispatchedClaudeInput(dispatched, input) {
|
|
62
|
+
dispatched.set(input.messageUuid, input);
|
|
63
|
+
while (dispatched.size > MAX_TRACKED_DISPATCHED_INPUTS) {
|
|
64
|
+
const oldest = dispatched.keys().next();
|
|
65
|
+
if (oldest.done)
|
|
66
|
+
break;
|
|
67
|
+
dispatched.delete(oldest.value);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The envelope a turn result belongs to, matched by `user_message_uuid`.
|
|
72
|
+
*
|
|
73
|
+
* The SDK coalesces queued inputs — three rapid messages can produce two turns —
|
|
74
|
+
* and reports the LAST message of a coalesced batch, which is the correct owner
|
|
75
|
+
* of the reply. Entries up to and including the match are consumed, so messages
|
|
76
|
+
* folded into that batch do not linger. Returns null when the result carries no
|
|
77
|
+
* usable uuid, leaving the caller to fall back.
|
|
78
|
+
*/
|
|
79
|
+
export function takeClaudeResultOwner(dispatched, userMessageUuid) {
|
|
80
|
+
if (typeof userMessageUuid !== 'string' || !dispatched.has(userMessageUuid)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
let owner = null;
|
|
84
|
+
for (const [key, envelope] of dispatched) {
|
|
85
|
+
dispatched.delete(key);
|
|
86
|
+
if (key === userMessageUuid) {
|
|
87
|
+
owner = envelope;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return owner;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether an envelope owns the session's active-turn slot. Only Canon turns do;
|
|
95
|
+
* any future internal envelope must remain invisible to user-facing turn state.
|
|
96
|
+
*/
|
|
97
|
+
export function claudeInputOwnsTurnSlot(input) {
|
|
98
|
+
return input?.kind === 'canon';
|
|
99
|
+
}
|
|
100
|
+
/** Why a non-empty final was gated instead of sent. Diagnostics only. */
|
|
101
|
+
export function describeUndeliveredClaudeFinal(input) {
|
|
102
|
+
if (!input.turn)
|
|
103
|
+
return 'no Canon turn owns this result';
|
|
104
|
+
if (input.turn.kind !== 'canon')
|
|
105
|
+
return `owning turn is '${input.turn.kind}'`;
|
|
106
|
+
if (input.interruptedTurnKeys.has(input.turn.turnKey))
|
|
107
|
+
return 'turn was interrupted';
|
|
108
|
+
if (input.finalizedTurnKeys.has(input.turn.turnKey))
|
|
109
|
+
return 'turn was already finalized';
|
|
110
|
+
return 'turn did not qualify for delivery';
|
|
111
|
+
}
|
|
52
112
|
export function resetClaudeCompletedTurnState(session) {
|
|
53
|
-
session
|
|
113
|
+
releaseClaudeTurnSlot(session);
|
|
54
114
|
session.currentTurnId = null;
|
|
55
115
|
session.currentTurnOpenedAt = null;
|
|
56
116
|
session.currentTurnUpdatedAt = null;
|
|
@@ -58,8 +118,575 @@ export function resetClaudeCompletedTurnState(session) {
|
|
|
58
118
|
session.pendingFinalText = null;
|
|
59
119
|
session.pendingFinalDelivery = null;
|
|
60
120
|
session.activeInput = null;
|
|
121
|
+
session.dispatchingInput = null;
|
|
61
122
|
session.toolInProgress = false;
|
|
62
123
|
session.turnState = 'idle';
|
|
124
|
+
// The trail bookkeeping only ever describes blocks of the turn that just
|
|
125
|
+
// ended; a tool_use id surviving into the next turn would resolve to a block
|
|
126
|
+
// that no longer exists.
|
|
127
|
+
if (session.turnActivity)
|
|
128
|
+
resetClaudeTurnActivityState(session.turnActivity);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Whether the session already owns the turn slot.
|
|
132
|
+
*
|
|
133
|
+
* 'running' covers both a turn the SDK is working on and one still being
|
|
134
|
+
* dispatched — the distinction matters only to an interrupt (below), never to
|
|
135
|
+
* whether the session is free to start something else. Deliberately NOT a new
|
|
136
|
+
* published state: `session.state.state` is the host's internal busy flag (the
|
|
137
|
+
* published session snapshot has no such field), and the user-facing signal is
|
|
138
|
+
* the turn state, which `openTurn` already publishes as 'thinking' at dequeue.
|
|
139
|
+
*
|
|
140
|
+
* 'requires_action' counts too: an approval or a question waiting on a human is
|
|
141
|
+
* a turn that is very much alive, holding the turn id and the live node. Reading
|
|
142
|
+
* it as free let a message arriving during the prompt open a SECOND turn over
|
|
143
|
+
* the waiting one — the same collision the reservation exists to stop, and
|
|
144
|
+
* inconsistent with every interrupt path, which already treats it as active.
|
|
145
|
+
*/
|
|
146
|
+
export function isClaudeTurnSlotReserved(state) {
|
|
147
|
+
return state === 'running' || state === 'requires_action';
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Whether the SDK's `session_state_changed` echo may overwrite the host's own
|
|
151
|
+
* busy flag.
|
|
152
|
+
*
|
|
153
|
+
* The echo and the reservation share one field, and the echo lags: the 'idle'
|
|
154
|
+
* that closes turn A is emitted after A's result has flushed, and the host
|
|
155
|
+
* drains its queue INSIDE that result handler — so by the time the echo is
|
|
156
|
+
* processed, turn B may already hold the slot. Applying it there releases a
|
|
157
|
+
* slot that is taken, re-opening the dispatch-window race and killing the
|
|
158
|
+
* typing signal B just started. An 'idle' echo is therefore ignored while the
|
|
159
|
+
* host knows it is busy; every other echo is authoritative, so the SDK can
|
|
160
|
+
* still take the session busy on its own (a resumed session, an internal
|
|
161
|
+
* envelope) and nothing can wedge the flag at 'running'.
|
|
162
|
+
*/
|
|
163
|
+
export function shouldApplyClaudeEchoedSessionState(input) {
|
|
164
|
+
if (input.echoed !== 'idle')
|
|
165
|
+
return true;
|
|
166
|
+
return !input.dispatching && !input.hasPendingFinalDelivery;
|
|
167
|
+
}
|
|
168
|
+
/** Claim the slot for an input about to be dispatched. Must precede any await. */
|
|
169
|
+
export function reserveClaudeTurnSlot(session) {
|
|
170
|
+
session.state.state = 'running';
|
|
171
|
+
}
|
|
172
|
+
/** Give the slot back — dispatch failed, or the turn is over. */
|
|
173
|
+
export function releaseClaudeTurnSlot(session) {
|
|
174
|
+
session.state.state = 'idle';
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* What to do with a stop signal from the control channel.
|
|
178
|
+
*
|
|
179
|
+
* The dispatch window is the interesting case. The slot is reserved but the
|
|
180
|
+
* input has not reached Claude, so there is nothing for `query.interrupt()` to
|
|
181
|
+
* stop — yet consuming the signal as stale would make a Stop pressed in that
|
|
182
|
+
* window do NOTHING while the turn it was aimed at ran to completion, silently.
|
|
183
|
+
* Deferring hands it back to the poller, which revisits the same node a cycle
|
|
184
|
+
* later, by which time the input really is in flight.
|
|
185
|
+
*
|
|
186
|
+
* Outside that window a signal with nothing to act on is consumed, except that
|
|
187
|
+
* 'stop_and_drop' still has work to do while inputs are queued: dropping them.
|
|
188
|
+
*/
|
|
189
|
+
export function decideClaudeControlSignalAction(input) {
|
|
190
|
+
if (input.dispatching)
|
|
191
|
+
return 'defer';
|
|
192
|
+
if (isClaudeTurnSlotReserved(input.sessionState))
|
|
193
|
+
return 'act';
|
|
194
|
+
return input.type === 'stop_and_drop' && input.queueDepth > 0 ? 'act' : 'consume';
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* What to do with an inbound Canon message.
|
|
198
|
+
*
|
|
199
|
+
* `hasInterruptibleInput` is the one piece that is not just "is the session
|
|
200
|
+
* busy": an interrupt can only stop an input the SDK has actually pulled. In
|
|
201
|
+
* the dispatch window there is nothing in flight to interrupt — calling
|
|
202
|
+
* `query.interrupt()` there would fire at an idle SDK and then let the input it
|
|
203
|
+
* was meant to pre-empt reach the SDK a moment later, unaffected, while the
|
|
204
|
+
* interrupting message sat in the queue behind it. Such a message goes to the
|
|
205
|
+
* FRONT of the queue instead, and starts as soon as the dispatched turn ends.
|
|
206
|
+
*
|
|
207
|
+
* A session waiting on an approval ('requires_action') is busy like any other
|
|
208
|
+
* live turn: an ordinary message queues behind the prompt, and an interrupt
|
|
209
|
+
* stops the waiting turn rather than racing a second one alongside it.
|
|
210
|
+
*/
|
|
211
|
+
export function decideClaudeInboundDispatch(input) {
|
|
212
|
+
// A final still being handed to Canon owns the turn until it settles, even
|
|
213
|
+
// though the SDK itself is done.
|
|
214
|
+
if (input.hasPendingFinalDelivery)
|
|
215
|
+
return 'queue';
|
|
216
|
+
if (!isClaudeTurnSlotReserved(input.sessionState))
|
|
217
|
+
return 'start';
|
|
218
|
+
if (input.intent !== 'interrupt')
|
|
219
|
+
return 'queue';
|
|
220
|
+
return input.hasInterruptibleInput ? 'interrupt' : 'queue-front';
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Whether a turn that has just finished may hand the slot back.
|
|
224
|
+
*
|
|
225
|
+
* Finishing spans awaits — the final send, the context-usage read — and the
|
|
226
|
+
* session reads free to inbound messages from the SDK's idle echo onward, so a
|
|
227
|
+
* message arriving in that window can reserve the slot for ITSELF. Releasing
|
|
228
|
+
* unconditionally afterwards would hand the same slot to a second message and
|
|
229
|
+
* put two turns into the SDK at once, which is exactly what the reservation
|
|
230
|
+
* exists to stop.
|
|
231
|
+
*/
|
|
232
|
+
export function shouldReleaseClaudeTurnSlot(input) {
|
|
233
|
+
if (input.dispatching)
|
|
234
|
+
return false;
|
|
235
|
+
return input.activeTurnKey === null || input.activeTurnKey === input.completedTurnKey;
|
|
236
|
+
}
|
|
237
|
+
/** Whether the next queued input may be dequeued now. */
|
|
238
|
+
export function canDrainClaudeQueuedInput(input) {
|
|
239
|
+
return !input.closed
|
|
240
|
+
&& !input.controlInterruptPending
|
|
241
|
+
&& !isClaudeTurnSlotReserved(input.sessionState)
|
|
242
|
+
&& input.queueDepth > 0;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Dispatch one input: reserve the slot, publish the turn, then do the slow
|
|
246
|
+
* work, then hand it to the SDK.
|
|
247
|
+
*
|
|
248
|
+
* The ORDER is the point, which is why this lives here rather than inline in
|
|
249
|
+
* the host: the reservation and `openTurn` must both happen before the first
|
|
250
|
+
* await. Marking the message accepted is a network round-trip and the artifact
|
|
251
|
+
* baseline walks the workspace; leaving the session readable as free across
|
|
252
|
+
* that window let a second message bypass the queue and open a competing turn
|
|
253
|
+
* over this one (`seedTurnStreaming` wiping the first turn's live node, two
|
|
254
|
+
* inputs in the SDK under mismatched attribution).
|
|
255
|
+
*
|
|
256
|
+
* Never rejects. An input that cannot be handed over — a throw in the window,
|
|
257
|
+
* or a session torn down mid-dispatch — is ABANDONED rather than dropped: the
|
|
258
|
+
* host settles the message and retires the turn, instead of leaving the sender
|
|
259
|
+
* on 'accepted' forever with a 'thinking' row nothing will complete.
|
|
260
|
+
*/
|
|
261
|
+
export async function dispatchClaudeInput(session, input, deps) {
|
|
262
|
+
reserveClaudeTurnSlot(session);
|
|
263
|
+
session.dispatchingInput = input;
|
|
264
|
+
deps.openTurn(input);
|
|
265
|
+
let failure = null;
|
|
266
|
+
try {
|
|
267
|
+
await deps.markAccepted(input);
|
|
268
|
+
await deps.prepareArtifacts(input);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
failure = { error };
|
|
272
|
+
}
|
|
273
|
+
if (session.dispatchingInput === input)
|
|
274
|
+
session.dispatchingInput = null;
|
|
275
|
+
if (failure) {
|
|
276
|
+
deps.abandon(input, 'error', failure.error);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (session.closed) {
|
|
280
|
+
deps.abandon(input, 'closed');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
deps.send(input);
|
|
284
|
+
}
|
|
285
|
+
// ── Turn lifecycle: opening a turn before the SDK says anything ──────
|
|
286
|
+
//
|
|
287
|
+
// The host used to publish turn state only when the SDK echoed
|
|
288
|
+
// `session_state_changed: running`, so everything between "message accepted"
|
|
289
|
+
// and "first token" was silent — no thinking header, no dots. Opening is now
|
|
290
|
+
// the host's own decision, taken synchronously at dequeue; these helpers hold
|
|
291
|
+
// the part of that decision that is pure state.
|
|
292
|
+
/** States in which a turn is live for the clients (mirrors the turn-state write). */
|
|
293
|
+
export function isOpenClaudeTurnState(state) {
|
|
294
|
+
return state === 'thinking'
|
|
295
|
+
|| state === 'streaming'
|
|
296
|
+
|| state === 'tool'
|
|
297
|
+
|| state === 'waiting_input';
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Whether the SDK's `running` confirmation should open the turn itself.
|
|
301
|
+
*
|
|
302
|
+
* When the host already opened this turn the confirmation is a no-op: re-seeding
|
|
303
|
+
* `/streaming` would clobber the blocks and text the turn has produced since.
|
|
304
|
+
*/
|
|
305
|
+
export function shouldOpenClaudeTurnOnRunning(state) {
|
|
306
|
+
return !isOpenClaudeTurnState(state);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Move the session into a fresh open turn: 'thinking', with a turn id, before
|
|
310
|
+
* a single SDK message has arrived.
|
|
311
|
+
*
|
|
312
|
+
* The id is minted per OPEN, unconditionally. Keying the mint on "the turn
|
|
313
|
+
* state is not open" reads equivalent and is not: the state a finished turn
|
|
314
|
+
* actually sits in during the final-handoff window is 'streaming'
|
|
315
|
+
* (`markPendingFinalDelivery` puts it there and only the reset clears it), so a
|
|
316
|
+
* message arriving in that window — precisely the case this exists for — would
|
|
317
|
+
* inherit an id that already carries a `turn_complete` message, and the client
|
|
318
|
+
* drops a live turn whose id is already complete.
|
|
319
|
+
*
|
|
320
|
+
* Nothing needs the guard: a turn is only ever opened when none is running —
|
|
321
|
+
* at dequeue, or on the SDK's `running` echo for a turn the host did not open
|
|
322
|
+
* itself (`shouldOpenClaudeTurnOnRunning` has already proved the state is not
|
|
323
|
+
* an open one there).
|
|
324
|
+
*/
|
|
325
|
+
export function openClaudeTurn(session, now = Date.now()) {
|
|
326
|
+
session.currentTurnId = randomUUID();
|
|
327
|
+
session.currentTurnOpenedAt = now;
|
|
328
|
+
session.currentTurnUpdatedAt = now;
|
|
329
|
+
session.turnState = 'thinking';
|
|
330
|
+
session.toolInProgress = false;
|
|
331
|
+
session.pendingFinalText = null;
|
|
332
|
+
if (session.turnActivity)
|
|
333
|
+
resetClaudeTurnActivityState(session.turnActivity);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Whether an SDK message describes the agent's OWN work rather than a
|
|
337
|
+
* subagent's. Nested messages carry the id of the Task call that spawned them;
|
|
338
|
+
* that Task's row already stands for the whole subagent, and publishing its
|
|
339
|
+
* internals would flood the trail — and the 20-block budget the persisted turn
|
|
340
|
+
* trail is bounded to.
|
|
341
|
+
*/
|
|
342
|
+
export function isClaudeMainTurnMessage(parentToolUseId) {
|
|
343
|
+
return parentToolUseId == null;
|
|
344
|
+
}
|
|
345
|
+
export function createClaudeTurnActivityState() {
|
|
346
|
+
return {
|
|
347
|
+
blockIdByIndex: new Map(),
|
|
348
|
+
blockIdByToolUseId: new Map(),
|
|
349
|
+
planBlockId: null,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
export function resetClaudeTurnActivityState(state) {
|
|
353
|
+
state.blockIdByIndex.clear();
|
|
354
|
+
state.blockIdByToolUseId.clear();
|
|
355
|
+
state.planBlockId = null;
|
|
356
|
+
}
|
|
357
|
+
export function claudeToolBlockId(toolUseId) {
|
|
358
|
+
return buildTrailBlockId('tool', toolUseId);
|
|
359
|
+
}
|
|
360
|
+
export function claudePlanBlockId(turnId) {
|
|
361
|
+
return buildTrailBlockId('plan', turnId ?? 'turn');
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* This turn's plan block, claimed once and then reused.
|
|
365
|
+
*
|
|
366
|
+
* Reading the claimed id back — rather than re-deriving it from the turn id on
|
|
367
|
+
* every TodoWrite — is what keeps a plan card whole: a turn id that changed
|
|
368
|
+
* mid-turn would otherwise split the plan into a second card instead of
|
|
369
|
+
* updating the first.
|
|
370
|
+
*/
|
|
371
|
+
export function claimClaudePlanBlockId(state, turnId) {
|
|
372
|
+
if (!state.planBlockId)
|
|
373
|
+
state.planBlockId = claudePlanBlockId(turnId);
|
|
374
|
+
return state.planBlockId;
|
|
375
|
+
}
|
|
376
|
+
function readTrimmedString(value) {
|
|
377
|
+
return normalizeTrailKey(value) ?? null;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Claim the trail block for a tool call the model has started emitting, and
|
|
381
|
+
* remember how to find it again from either the `tool_use` id (tool_result) or
|
|
382
|
+
* the content-block index (content_block_stop).
|
|
383
|
+
*/
|
|
384
|
+
export function registerClaudeToolCallBlock(state, input) {
|
|
385
|
+
const toolUseId = readTrimmedString(input.toolUseId);
|
|
386
|
+
const index = typeof input.index === 'number' && Number.isInteger(input.index)
|
|
387
|
+
? input.index
|
|
388
|
+
: null;
|
|
389
|
+
// Without an id (older runtimes) the index still separates the parallel calls
|
|
390
|
+
// of one assistant message, which is where collisions actually happen.
|
|
391
|
+
const blockId = toolUseId
|
|
392
|
+
? claudeToolBlockId(toolUseId)
|
|
393
|
+
: buildTrailBlockId('tool', input.turnId ?? 'turn', index ?? state.blockIdByIndex.size);
|
|
394
|
+
if (toolUseId) {
|
|
395
|
+
state.blockIdByToolUseId.set(toolUseId, blockId);
|
|
396
|
+
boundTrailBlockMap(state.blockIdByToolUseId);
|
|
397
|
+
}
|
|
398
|
+
if (index !== null) {
|
|
399
|
+
state.blockIdByIndex.set(index, blockId);
|
|
400
|
+
boundTrailBlockMap(state.blockIdByIndex);
|
|
401
|
+
}
|
|
402
|
+
return blockId;
|
|
403
|
+
}
|
|
404
|
+
/** The block a `tool_result` belongs to. Null when this turn never claimed one. */
|
|
405
|
+
export function resolveClaudeToolBlockId(state, toolUseId) {
|
|
406
|
+
const id = readTrimmedString(toolUseId);
|
|
407
|
+
if (!id)
|
|
408
|
+
return null;
|
|
409
|
+
return state.blockIdByToolUseId.get(id) ?? null;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The block a `content_block_stop` closes, consumed on read — indexes restart
|
|
413
|
+
* at 0 with every assistant message, so a stale entry would mis-resolve.
|
|
414
|
+
*/
|
|
415
|
+
export function takeClaudeToolBlockIdByIndex(state, index) {
|
|
416
|
+
if (typeof index !== 'number' || !Number.isInteger(index))
|
|
417
|
+
return null;
|
|
418
|
+
const blockId = state.blockIdByIndex.get(index) ?? null;
|
|
419
|
+
if (blockId)
|
|
420
|
+
state.blockIdByIndex.delete(index);
|
|
421
|
+
return blockId;
|
|
422
|
+
}
|
|
423
|
+
// ── Tool call presentation ───────────────────────────────────────────
|
|
424
|
+
const CLAUDE_TOOL_TITLE_MAX_CHARS = 120;
|
|
425
|
+
const CLAUDE_TOOL_FAILURE_SUMMARY_MAX_CHARS = 120;
|
|
426
|
+
/** TodoWrite is the plan card, not a tool receipt. */
|
|
427
|
+
export const CLAUDE_PLAN_TOOL_NAME = 'TodoWrite';
|
|
428
|
+
/** The margin row's title for the plan card, which is the card's own heading. */
|
|
429
|
+
export const CLAUDE_PLAN_BLOCK_TITLE = PLAN_BLOCK_TITLE;
|
|
430
|
+
function truncateClaudeToolText(value, maxChars) {
|
|
431
|
+
const collapsed = value.replace(/\s+/g, ' ').trim();
|
|
432
|
+
if (collapsed.length <= maxChars)
|
|
433
|
+
return collapsed;
|
|
434
|
+
let end = maxChars - 1;
|
|
435
|
+
// Cut on a code-point boundary. `slice` counts UTF-16 units, so a boundary
|
|
436
|
+
// that lands inside a surrogate pair (any emoji, and plenty of CJK) would
|
|
437
|
+
// leave a lone surrogate as the last character — an unpaired half that is not
|
|
438
|
+
// well-formed UTF-8 and renders as a replacement character wherever this
|
|
439
|
+
// title goes, from the live margin row to the persisted turn trail.
|
|
440
|
+
const lastUnit = collapsed.charCodeAt(end - 1);
|
|
441
|
+
if (lastUnit >= 0xd800 && lastUnit <= 0xdbff)
|
|
442
|
+
end -= 1;
|
|
443
|
+
return `${collapsed.slice(0, end)}…`;
|
|
444
|
+
}
|
|
445
|
+
function readToolInput(input, key) {
|
|
446
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
447
|
+
return null;
|
|
448
|
+
return readTrimmedString(input[key]);
|
|
449
|
+
}
|
|
450
|
+
function claudeToolCallDetail(toolName, input) {
|
|
451
|
+
switch (toolName) {
|
|
452
|
+
case 'Bash': {
|
|
453
|
+
const command = readToolInput(input, 'command');
|
|
454
|
+
return command ? `Running: ${command}` : null;
|
|
455
|
+
}
|
|
456
|
+
case 'BashOutput': {
|
|
457
|
+
const id = readToolInput(input, 'bash_id');
|
|
458
|
+
return id ? `Reading output of ${id}` : null;
|
|
459
|
+
}
|
|
460
|
+
case 'Read':
|
|
461
|
+
case 'Write':
|
|
462
|
+
case 'Edit': {
|
|
463
|
+
const path = readToolInput(input, 'file_path');
|
|
464
|
+
return path ? `${toolName} ${path}` : null;
|
|
465
|
+
}
|
|
466
|
+
case 'NotebookEdit': {
|
|
467
|
+
const path = readToolInput(input, 'notebook_path');
|
|
468
|
+
return path ? `Edit ${path}` : null;
|
|
469
|
+
}
|
|
470
|
+
case 'Grep':
|
|
471
|
+
case 'Glob': {
|
|
472
|
+
const pattern = readToolInput(input, 'pattern');
|
|
473
|
+
return pattern ? `${toolName} ${pattern}` : null;
|
|
474
|
+
}
|
|
475
|
+
case 'Task': {
|
|
476
|
+
const description = readToolInput(input, 'description');
|
|
477
|
+
return description ? `Task: ${description}` : null;
|
|
478
|
+
}
|
|
479
|
+
case 'WebFetch': {
|
|
480
|
+
const url = readToolInput(input, 'url');
|
|
481
|
+
return url ? `Fetch ${url}` : null;
|
|
482
|
+
}
|
|
483
|
+
case 'WebSearch': {
|
|
484
|
+
const query = readToolInput(input, 'query');
|
|
485
|
+
return query ? `Search ${query}` : null;
|
|
486
|
+
}
|
|
487
|
+
default:
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The margin row's title for a tool call: what the agent actually did, not just
|
|
493
|
+
* which tool it reached for. Falls back to the tool name whenever the typed
|
|
494
|
+
* input is missing or shaped differently than expected — an unknown tool is
|
|
495
|
+
* still worth a row.
|
|
496
|
+
*/
|
|
497
|
+
export function summarizeClaudeToolCall(name, input) {
|
|
498
|
+
const toolName = readTrimmedString(name) ?? 'Tool';
|
|
499
|
+
return truncateClaudeToolText(claudeToolCallDetail(toolName, input) ?? toolName, CLAUDE_TOOL_TITLE_MAX_CHARS);
|
|
500
|
+
}
|
|
501
|
+
function readToolResultText(content) {
|
|
502
|
+
const direct = readTrimmedString(content);
|
|
503
|
+
if (direct)
|
|
504
|
+
return direct;
|
|
505
|
+
if (!Array.isArray(content))
|
|
506
|
+
return null;
|
|
507
|
+
for (const entry of content) {
|
|
508
|
+
if (!entry || typeof entry !== 'object')
|
|
509
|
+
continue;
|
|
510
|
+
const text = readTrimmedString(entry.text);
|
|
511
|
+
if (text)
|
|
512
|
+
return text;
|
|
513
|
+
}
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
/** One-line reason for a failed tool call, for the receipt's summary slot. */
|
|
517
|
+
export function summarizeClaudeToolFailure(content) {
|
|
518
|
+
const text = readToolResultText(content);
|
|
519
|
+
return text
|
|
520
|
+
? truncateClaudeToolText(text, CLAUDE_TOOL_FAILURE_SUMMARY_MAX_CHARS)
|
|
521
|
+
: 'Tool call failed';
|
|
522
|
+
}
|
|
523
|
+
const CLAUDE_ELAPSED_SUMMARY_MIN_SECONDS = 5;
|
|
524
|
+
/**
|
|
525
|
+
* Progress text for a call that is taking a while. Short calls get nothing:
|
|
526
|
+
* every summary change is an RTDB write, and "Running for 1s" says nothing the
|
|
527
|
+
* running row does not already say.
|
|
528
|
+
*/
|
|
529
|
+
export function formatClaudeToolElapsed(seconds) {
|
|
530
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds))
|
|
531
|
+
return null;
|
|
532
|
+
const whole = Math.floor(seconds);
|
|
533
|
+
if (whole < CLAUDE_ELAPSED_SUMMARY_MIN_SECONDS)
|
|
534
|
+
return null;
|
|
535
|
+
if (whole < 60)
|
|
536
|
+
return `Running for ${whole}s`;
|
|
537
|
+
const minutes = Math.floor(whole / 60);
|
|
538
|
+
const remainder = whole % 60;
|
|
539
|
+
return remainder
|
|
540
|
+
? `Running for ${minutes}m ${remainder}s`
|
|
541
|
+
: `Running for ${minutes}m`;
|
|
542
|
+
}
|
|
543
|
+
// ── Plan card ────────────────────────────────────────────────────────
|
|
544
|
+
const CLAUDE_PLAN_MAX_ITEMS = 30;
|
|
545
|
+
const CLAUDE_PLAN_ITEM_MAX_CHARS = 120;
|
|
546
|
+
/**
|
|
547
|
+
* TodoWrite's typed input mapped onto the shared plan grammar (`renderPlanSteps`
|
|
548
|
+
* in @canonmsg/coding-agent-host), which both coding-agent hosts render through
|
|
549
|
+
* so a plan card reads the same whichever runtime produced it. What stays here
|
|
550
|
+
* is what is genuinely Claude's: the SDK's todo shape, its `activeForm` wording
|
|
551
|
+
* for the step being worked on ("Running the tests"), the per-step and
|
|
552
|
+
* per-plan bounds, and publishing NOTHING rather than a bare heading when a
|
|
553
|
+
* TodoWrite call carries no usable step.
|
|
554
|
+
*
|
|
555
|
+
* Unusable entries are dropped before rendering, so the numbering the reader
|
|
556
|
+
* sees stays contiguous.
|
|
557
|
+
*/
|
|
558
|
+
export function renderTodosAsPlan(todos) {
|
|
559
|
+
if (!Array.isArray(todos))
|
|
560
|
+
return null;
|
|
561
|
+
const steps = [];
|
|
562
|
+
for (const entry of todos.slice(0, CLAUDE_PLAN_MAX_ITEMS)) {
|
|
563
|
+
if (!entry || typeof entry !== 'object')
|
|
564
|
+
continue;
|
|
565
|
+
const record = entry;
|
|
566
|
+
const content = readTrimmedString(record.content);
|
|
567
|
+
const activeForm = readTrimmedString(record.activeForm);
|
|
568
|
+
const status = normalizePlanStepStatus(record.status);
|
|
569
|
+
const label = status === 'in_progress'
|
|
570
|
+
? activeForm ?? content
|
|
571
|
+
: content ?? activeForm;
|
|
572
|
+
if (!label)
|
|
573
|
+
continue;
|
|
574
|
+
steps.push({ text: truncateClaudeToolText(label, CLAUDE_PLAN_ITEM_MAX_CHARS), status });
|
|
575
|
+
}
|
|
576
|
+
if (steps.length === 0)
|
|
577
|
+
return null;
|
|
578
|
+
return renderPlanSteps(steps);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* `content_block_start` for a `tool_use`: the call claims its row, titled with
|
|
582
|
+
* the tool's name until the typed input arrives on the assistant message.
|
|
583
|
+
*/
|
|
584
|
+
export function planClaudeToolCallStart(state, input) {
|
|
585
|
+
// Registered even for TodoWrite: `content_block_stop` resolves the turn out of
|
|
586
|
+
// its 'tool' state by index, and an unclaimed index would leave it stuck.
|
|
587
|
+
const blockId = registerClaudeToolCallBlock(state, input);
|
|
588
|
+
const toolName = readTrimmedString(input.name);
|
|
589
|
+
// TodoWrite is the plan card, published once its todos arrive; no receipt.
|
|
590
|
+
if (toolName === CLAUDE_PLAN_TOOL_NAME)
|
|
591
|
+
return [];
|
|
592
|
+
return [{
|
|
593
|
+
op: 'add',
|
|
594
|
+
id: blockId,
|
|
595
|
+
kind: 'tool',
|
|
596
|
+
status: 'running',
|
|
597
|
+
title: toolName ?? 'Claude tool use',
|
|
598
|
+
}];
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The assistant message is the first place a call's typed INPUT is visible —
|
|
602
|
+
* `content_block_start` carries only the name. It is what turns "Bash" into
|
|
603
|
+
* "Running: npm test" and TodoWrite into the plan card.
|
|
604
|
+
*/
|
|
605
|
+
export function planClaudeAssistantTrail(state, content, turnId) {
|
|
606
|
+
if (!Array.isArray(content))
|
|
607
|
+
return [];
|
|
608
|
+
const commands = [];
|
|
609
|
+
for (const block of content) {
|
|
610
|
+
if (!block || typeof block !== 'object')
|
|
611
|
+
continue;
|
|
612
|
+
const candidate = block;
|
|
613
|
+
if (candidate.type !== 'tool_use')
|
|
614
|
+
continue;
|
|
615
|
+
if (candidate.name === CLAUDE_PLAN_TOOL_NAME) {
|
|
616
|
+
const text = renderTodosAsPlan(candidate.input?.todos);
|
|
617
|
+
if (!text)
|
|
618
|
+
continue;
|
|
619
|
+
// One stable id per turn, so the card updates in place as steps tick over
|
|
620
|
+
// instead of stacking a new card per TodoWrite call.
|
|
621
|
+
commands.push({
|
|
622
|
+
op: 'add',
|
|
623
|
+
id: claimClaudePlanBlockId(state, turnId),
|
|
624
|
+
kind: 'plan',
|
|
625
|
+
status: 'running',
|
|
626
|
+
title: CLAUDE_PLAN_BLOCK_TITLE,
|
|
627
|
+
text,
|
|
628
|
+
});
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
// Without an id there is no way to tell whether this call already has a
|
|
632
|
+
// row; claiming another one would duplicate it. The name-only row that
|
|
633
|
+
// content_block_start published stands.
|
|
634
|
+
if (!readTrimmedString(candidate.id))
|
|
635
|
+
continue;
|
|
636
|
+
// Registering here as well as on content_block_start keeps the receipt
|
|
637
|
+
// (and its later completion) working when partial messages are off.
|
|
638
|
+
const blockId = registerClaudeToolCallBlock(state, { toolUseId: candidate.id, turnId });
|
|
639
|
+
commands.push({
|
|
640
|
+
op: 'add',
|
|
641
|
+
id: blockId,
|
|
642
|
+
// No status: an existing row keeps whatever it has, so a result that
|
|
643
|
+
// somehow landed first is not reopened.
|
|
644
|
+
kind: 'tool',
|
|
645
|
+
title: summarizeClaudeToolCall(candidate.name, candidate.input),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return commands;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* `tool_result` on a `user` message: where a call's OUTCOME finally arrives.
|
|
652
|
+
* Rows settle here and nowhere else — `content_block_stop` only means the model
|
|
653
|
+
* finished writing the call.
|
|
654
|
+
*/
|
|
655
|
+
export function planClaudeToolResults(state, content) {
|
|
656
|
+
if (!Array.isArray(content))
|
|
657
|
+
return [];
|
|
658
|
+
const commands = [];
|
|
659
|
+
for (const block of content) {
|
|
660
|
+
if (!block || typeof block !== 'object')
|
|
661
|
+
continue;
|
|
662
|
+
const candidate = block;
|
|
663
|
+
if (candidate.type !== 'tool_result')
|
|
664
|
+
continue;
|
|
665
|
+
// Keyed by tool_use id, so results that come back out of order still
|
|
666
|
+
// resolve the row that asked for them.
|
|
667
|
+
const blockId = resolveClaudeToolBlockId(state, candidate.tool_use_id);
|
|
668
|
+
if (!blockId)
|
|
669
|
+
continue;
|
|
670
|
+
if (candidate.is_error === true) {
|
|
671
|
+
commands.push({ op: 'fail', id: blockId, summary: summarizeClaudeToolFailure(candidate.content) });
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
// The empty summary drops any progress note the row picked up while it
|
|
675
|
+
// ran: the last reading is not the call's duration (progress stops
|
|
676
|
+
// arriving before the tool returns), and "Running for 12s" on a row that
|
|
677
|
+
// has settled reads as a call still in flight.
|
|
678
|
+
commands.push({ op: 'complete', id: blockId, summary: '' });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return commands;
|
|
682
|
+
}
|
|
683
|
+
/** `tool_progress`: a long call says how long it has been going. */
|
|
684
|
+
export function planClaudeToolProgress(state, input) {
|
|
685
|
+
const blockId = resolveClaudeToolBlockId(state, input.toolUseId);
|
|
686
|
+
const elapsed = formatClaudeToolElapsed(input.elapsedSeconds);
|
|
687
|
+
if (!blockId || !elapsed)
|
|
688
|
+
return [];
|
|
689
|
+
return [{ op: 'update', id: blockId, summary: elapsed }];
|
|
63
690
|
}
|
|
64
691
|
// Provenance for a Canon message entering the SDK. From 0.3.220 an absent
|
|
65
692
|
// `origin` is "unattributed" and fails closed at strict isHuman() gates, so a
|
|
@@ -87,17 +714,15 @@ export function buildClaudeFinalTurnMetadata(input) {
|
|
|
87
714
|
...(input.turnTrail && input.turnTrail.length > 0 ? { turnTrail: input.turnTrail } : {}),
|
|
88
715
|
};
|
|
89
716
|
}
|
|
90
|
-
const CLAUDE_FAILURE_DETAIL_MAX_CHARS = 280;
|
|
91
717
|
function claudeFailureDetail(errors) {
|
|
92
718
|
if (!Array.isArray(errors))
|
|
93
719
|
return null;
|
|
94
720
|
const first = errors.find((entry) => typeof entry === 'string' && entry.trim());
|
|
95
721
|
if (typeof first !== 'string')
|
|
96
722
|
return null;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
: trimmed;
|
|
723
|
+
// The shared bound on error text quoted back into a chat message, so no
|
|
724
|
+
// notice can let a multi-KB error body push itself over Canon's size limit.
|
|
725
|
+
return truncateFailureDetail(first);
|
|
101
726
|
}
|
|
102
727
|
function claudeUsageLimitDetail(errors) {
|
|
103
728
|
if (!Array.isArray(errors))
|
|
@@ -144,6 +769,253 @@ export function composeClaudeFinalText(input) {
|
|
|
144
769
|
return primary;
|
|
145
770
|
return primary ? `${primary}\n\n${input.failureNotice}` : input.failureNotice;
|
|
146
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* How the host should react to a failed final-reply send.
|
|
774
|
+
*
|
|
775
|
+
* Core's `isRetryableCanonDeliveryError` is already right about what a retry
|
|
776
|
+
* can fix (429, 5xx, transport errors); what the host lacked was the other
|
|
777
|
+
* half — everything else is PERMANENT and must be surfaced instead of retried.
|
|
778
|
+
* A 409 from the server's idempotency guard means a message already exists
|
|
779
|
+
* under this id, so the reply is durable: that is a success, not a failure.
|
|
780
|
+
*
|
|
781
|
+
* A CHUNKED final is the exception (#616's rule, kept). A chunked send is N
|
|
782
|
+
* sequential messages that abort on the first failure, so a 409 says only that
|
|
783
|
+
* ONE part id is taken, with nothing to say which — and since the part ids are
|
|
784
|
+
* a pure hash of agent + conversation + turnKey, an id can be taken by an
|
|
785
|
+
* ENTIRELY DIFFERENT answer to the same inbound message, written by a previous
|
|
786
|
+
* process. Reading that as "the final is delivered" would finalize the turn
|
|
787
|
+
* with the tail never sent, so it stays 'permanent': a stops-short notice beats
|
|
788
|
+
* silently dropping the end of an answer.
|
|
789
|
+
*
|
|
790
|
+
* Resume support does not soften this. Core absorbs the one conflict that IS
|
|
791
|
+
* provably ours — the first part of a resumed attempt, whose write may have
|
|
792
|
+
* landed before its response was lost — and reports every other one, so a
|
|
793
|
+
* conflict reaching this function is precisely the case that must not be read
|
|
794
|
+
* as success.
|
|
795
|
+
*/
|
|
796
|
+
export function classifyFinalDeliveryFailure(error, options = {}) {
|
|
797
|
+
if (isCanonMessageIdConflict(error) && !options.chunked) {
|
|
798
|
+
return 'already-delivered';
|
|
799
|
+
}
|
|
800
|
+
return isRetryableCanonDeliveryError(error) ? 'retry' : 'permanent';
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* The pending-delivery record for a final that has to be retried, carrying the
|
|
804
|
+
* progress the next attempt needs.
|
|
805
|
+
*
|
|
806
|
+
* Progress is monotonic: a later attempt can only ever add parts, so the longer
|
|
807
|
+
* of the two lists wins and a retry can never rewind the cursor. The metadata
|
|
808
|
+
* and the self-context are pinned to the FIRST attempt's values, because those
|
|
809
|
+
* are what the durable parts were fingerprinted against.
|
|
810
|
+
*/
|
|
811
|
+
export function buildClaudePendingFinalDelivery(input) {
|
|
812
|
+
const previous = input.previous?.turnKey === input.turnKey ? input.previous : null;
|
|
813
|
+
const previousDelivered = previous?.deliveredMessageIds ?? [];
|
|
814
|
+
const nextDelivered = input.deliveredMessageIds ?? [];
|
|
815
|
+
const delivered = nextDelivered.length >= previousDelivered.length
|
|
816
|
+
? [...nextDelivered]
|
|
817
|
+
: [...previousDelivered];
|
|
818
|
+
const metadata = previous?.metadata ?? input.metadata;
|
|
819
|
+
// `previous` may pin a null self-context (a conversation with none), which
|
|
820
|
+
// must still win over a later inbound message's value — hence the key check
|
|
821
|
+
// rather than `??`.
|
|
822
|
+
const selfContextId = previous && 'selfContextId' in previous
|
|
823
|
+
? previous.selfContextId
|
|
824
|
+
: input.selfContextId;
|
|
825
|
+
const lastErrorMessage = input.lastErrorMessage ?? previous?.lastErrorMessage;
|
|
826
|
+
return {
|
|
827
|
+
turnKey: input.turnKey,
|
|
828
|
+
text: input.text,
|
|
829
|
+
messageId: input.messageId,
|
|
830
|
+
retryCount: previous?.retryCount ?? 0,
|
|
831
|
+
...(input.suppressAutoReply ? { suppressAutoReply: true } : {}),
|
|
832
|
+
...(delivered.length > 0 ? { deliveredMessageIds: delivered } : {}),
|
|
833
|
+
...(metadata ? { metadata } : {}),
|
|
834
|
+
...(selfContextId !== undefined ? { selfContextId } : {}),
|
|
835
|
+
...(lastErrorMessage ? { lastErrorMessage: truncateFailureDetail(lastErrorMessage) } : {}),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* What a retry of this turn's final should reuse: the parts already durable,
|
|
840
|
+
* and the metadata and self-context they were written with. Empty for a first
|
|
841
|
+
* attempt, or when the pending record belongs to a different turn.
|
|
842
|
+
*/
|
|
843
|
+
export function readClaudeFinalDeliveryResume(pending, turnKey) {
|
|
844
|
+
if (!pending || pending.turnKey !== turnKey)
|
|
845
|
+
return { deliveredMessageIds: [] };
|
|
846
|
+
return {
|
|
847
|
+
deliveredMessageIds: [...(pending.deliveredMessageIds ?? [])],
|
|
848
|
+
...(pending.metadata ? { metadata: pending.metadata } : {}),
|
|
849
|
+
...('selfContextId' in pending ? { selfContextId: pending.selfContextId } : {}),
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* The chunking options a final send opts into, built from the resume cursor.
|
|
854
|
+
*
|
|
855
|
+
* A helper rather than an inline literal so the opt-in is one testable seam:
|
|
856
|
+
* dropping `resumable` restores restart-from-part-1, which no assertion on a
|
|
857
|
+
* hand-built call could catch.
|
|
858
|
+
*/
|
|
859
|
+
export function buildClaudeFinalChunkingOptions(resume) {
|
|
860
|
+
return { resumable: true, deliveredMessageIds: resume.deliveredMessageIds };
|
|
861
|
+
}
|
|
862
|
+
/** True when a final of this size will be split across several messages. */
|
|
863
|
+
export function claudeFinalWillChunk(finalText, maxTextBytes = DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES) {
|
|
864
|
+
// Mirrors core's splitTextByUtf8Bytes threshold so the host's pre-flight and
|
|
865
|
+
// the actual send always agree on whether chunking happens.
|
|
866
|
+
return utf8ByteLength(finalText) > maxTextBytes;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* The turn trail to attach to a final that is about to be sent.
|
|
870
|
+
*
|
|
871
|
+
* A chunked final's LAST message carries the answer's tail, while the trail's
|
|
872
|
+
* text blocks carry the compacted head. chat-domain's `buildTurnRenderParts`
|
|
873
|
+
* appends `message.text` after structured speech that is not a prefix of it, so
|
|
874
|
+
* an unfiltered trail would render the head again above the tail. Text blocks
|
|
875
|
+
* are therefore dropped from a chunked final; tool/plan/approval/status blocks
|
|
876
|
+
* are the margin's activity record and stay. Unchunked finals are untouched,
|
|
877
|
+
* which keeps the common case byte-identical to before.
|
|
878
|
+
*/
|
|
879
|
+
export function prepareTurnTrailForDelivery(trail, willChunk) {
|
|
880
|
+
if (!willChunk)
|
|
881
|
+
return [...trail];
|
|
882
|
+
return trail.filter((block) => block.kind !== 'text');
|
|
883
|
+
}
|
|
884
|
+
export const CLAUDE_FINAL_TRUNCATION_MARKER = "\n\n_[truncated — the full answer exceeded Canon's message size limit]_";
|
|
885
|
+
/** How far back from the cut a paragraph break is still worth cutting at. */
|
|
886
|
+
const CLAUDE_TRUNCATION_BOUNDARY_WINDOW_CHARS = 400;
|
|
887
|
+
function sliceByUtf8Bytes(text, maxBytes) {
|
|
888
|
+
let out = '';
|
|
889
|
+
let bytes = 0;
|
|
890
|
+
// Iterating code points (not UTF-16 units) keeps surrogate pairs intact,
|
|
891
|
+
// matching how core splits chunks.
|
|
892
|
+
for (const char of text) {
|
|
893
|
+
const charBytes = utf8ByteLength(char);
|
|
894
|
+
if (bytes + charBytes > maxBytes)
|
|
895
|
+
break;
|
|
896
|
+
out += char;
|
|
897
|
+
bytes += charBytes;
|
|
898
|
+
}
|
|
899
|
+
return out;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Last-resort degradation for a final Canon refuses to accept: keep as much of
|
|
903
|
+
* the answer as fits in ONE message and say that the rest was dropped. Prefers
|
|
904
|
+
* to cut at a paragraph break near the end so the survivor reads as prose. The
|
|
905
|
+
* result — marker included — always fits the byte budget.
|
|
906
|
+
*
|
|
907
|
+
* Deliberate trade-off: this keeps the HEAD, so a trailing paragraph is lost —
|
|
908
|
+
* including the turn-failure notice `composeClaudeFinalText` appends last. That
|
|
909
|
+
* is acceptable only because the host calls this on a final that already fits
|
|
910
|
+
* (an oversized final is chunked, never truncated), so nothing is actually cut;
|
|
911
|
+
* it is the bound that keeps the degraded send legal, not a text editor. A
|
|
912
|
+
* caller that truncates for real must re-attach its own trailing notice.
|
|
913
|
+
*/
|
|
914
|
+
export function buildTruncatedFinalText(finalText, maxTextBytes = DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES) {
|
|
915
|
+
if (utf8ByteLength(finalText) <= maxTextBytes)
|
|
916
|
+
return finalText;
|
|
917
|
+
const markerBytes = utf8ByteLength(CLAUDE_FINAL_TRUNCATION_MARKER);
|
|
918
|
+
const budget = maxTextBytes - markerBytes;
|
|
919
|
+
if (budget <= 0)
|
|
920
|
+
return CLAUDE_FINAL_TRUNCATION_MARKER.trim();
|
|
921
|
+
const head = sliceByUtf8Bytes(finalText, budget);
|
|
922
|
+
const windowStart = Math.max(0, head.length - CLAUDE_TRUNCATION_BOUNDARY_WINDOW_CHARS);
|
|
923
|
+
const boundary = head.lastIndexOf('\n\n');
|
|
924
|
+
const kept = boundary > 0 && boundary >= windowStart ? head.slice(0, boundary) : head;
|
|
925
|
+
return `${kept.trimEnd()}${CLAUDE_FINAL_TRUNCATION_MARKER}`;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Server-side `metadata` cap, taken from the shared contract rather than
|
|
929
|
+
* re-typed here. Note the UNIT: unlike message text, which the server measures
|
|
930
|
+
* in UTF-8 bytes, metadata is capped by `JSON.stringify(metadata).length` —
|
|
931
|
+
* UTF-16 code units — in both places it is checked (the request schema's
|
|
932
|
+
* misleadingly named `maxJsonBytes` and the handler's inline check). Mirroring
|
|
933
|
+
* it in bytes would be a different, stricter rule that drops the margin's
|
|
934
|
+
* activity record on payloads Canon would have accepted.
|
|
935
|
+
*/
|
|
936
|
+
export const CLAUDE_MESSAGE_METADATA_MAX_CHARS = VERB_LIMITS.messageMetadataJsonChars;
|
|
937
|
+
/**
|
|
938
|
+
* Defensive pre-flight for message metadata. Hosts already bound the trail via
|
|
939
|
+
* core's `buildBoundedTurnTrail`, so this should never fire; if it ever does,
|
|
940
|
+
* losing the margin's activity record beats losing the answer.
|
|
941
|
+
*/
|
|
942
|
+
export function boundClaudeFinalMetadata(metadata, maxChars = CLAUDE_MESSAGE_METADATA_MAX_CHARS) {
|
|
943
|
+
if (JSON.stringify(metadata).length <= maxChars)
|
|
944
|
+
return metadata;
|
|
945
|
+
const { turnTrail: _turnTrail, ...rest } = metadata;
|
|
946
|
+
return rest;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* How Claude says a final could not be delivered. The sentence's shape and the
|
|
950
|
+
* bound on the quoted error are shared with the other coding-agent hosts; only
|
|
951
|
+
* the voice is Claude's — it speaks as itself, in the first person, because
|
|
952
|
+
* that is who the reader has been talking to all turn.
|
|
953
|
+
*/
|
|
954
|
+
const CLAUDE_UNDELIVERABLE_FINAL_WORDING = {
|
|
955
|
+
lead: 'I finished this turn, but Canon rejected the reply',
|
|
956
|
+
partialLead: 'I finished this turn, but Canon rejected the rest of the reply, so it stops short',
|
|
957
|
+
};
|
|
958
|
+
/**
|
|
959
|
+
* The standalone notice for a final Canon will not accept — the last thing the
|
|
960
|
+
* host can say when the answer itself cannot be delivered.
|
|
961
|
+
*
|
|
962
|
+
* `partiallyDelivered` marks a chunked final whose earlier parts are already in
|
|
963
|
+
* the conversation: the reader can see text, and needs to be told it stops
|
|
964
|
+
* short rather than being left to assume the answer ended there.
|
|
965
|
+
*/
|
|
966
|
+
export function buildUndeliverableFinalNotice(input) {
|
|
967
|
+
return buildHostUndeliverableFinalNotice({
|
|
968
|
+
...input,
|
|
969
|
+
wording: CLAUDE_UNDELIVERABLE_FINAL_WORDING,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* What is left to say when the bounded retries for a final run out.
|
|
974
|
+
*
|
|
975
|
+
* With parts of a chunked answer already in the conversation, silence is the
|
|
976
|
+
* wrong ending: the reader takes the last delivered part for the end of the
|
|
977
|
+
* answer. That case gets the stops-short notice, which completes the turn.
|
|
978
|
+
*
|
|
979
|
+
* With NOTHING delivered there is deliberately no notice, so the host can clear
|
|
980
|
+
* `/streaming` and let functions' `onStreamingCleared` preserve the streamed
|
|
981
|
+
* text as a durable progress message — a notice would complete the turn and
|
|
982
|
+
* suppress exactly that fallback, leaving the reader with an apology and no
|
|
983
|
+
* answer at all.
|
|
984
|
+
*/
|
|
985
|
+
export function planExhaustedFinalDelivery(pending) {
|
|
986
|
+
const partiallyDelivered = (pending.deliveredMessageIds?.length ?? 0) > 0;
|
|
987
|
+
return {
|
|
988
|
+
partiallyDelivered,
|
|
989
|
+
notice: partiallyDelivered
|
|
990
|
+
? buildUndeliverableFinalNotice({
|
|
991
|
+
error: pending.lastErrorMessage ?? 'delivery retries were exhausted',
|
|
992
|
+
partiallyDelivered: true,
|
|
993
|
+
})
|
|
994
|
+
: null,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* End a final whose retries ran out: say it stops short, THEN retire the turn.
|
|
999
|
+
*
|
|
1000
|
+
* The order is the point, which is why the sequence lives here. The pending
|
|
1001
|
+
* record is what makes the session read busy to inbound messages, and the
|
|
1002
|
+
* notice is a network send with its own retries — clearing the record first
|
|
1003
|
+
* left the session 'running' with no timer and no pending delivery, so an
|
|
1004
|
+
* interrupt arriving in that window fired `query.interrupt()` at an SDK whose
|
|
1005
|
+
* turn had already produced its result, flipped the turn row to 'interrupted'
|
|
1006
|
+
* and cleared `/streaming` out from under the retirement.
|
|
1007
|
+
*/
|
|
1008
|
+
export async function runClaudeExhaustedFinalDelivery(session, pending, deps) {
|
|
1009
|
+
const { notice } = planExhaustedFinalDelivery(pending);
|
|
1010
|
+
if (notice) {
|
|
1011
|
+
// Best effort: the notice failing is no reason to leave the turn open.
|
|
1012
|
+
await deps.sendNotice(notice).catch(() => { });
|
|
1013
|
+
}
|
|
1014
|
+
if (session.pendingFinalDelivery?.turnKey === pending.turnKey) {
|
|
1015
|
+
session.pendingFinalDelivery = null;
|
|
1016
|
+
}
|
|
1017
|
+
deps.retireTurn();
|
|
1018
|
+
}
|
|
147
1019
|
// Model options are discovery-driven: the host publishes whatever the Claude
|
|
148
1020
|
// Code CLI it spawns reports. A CLI older than this predates whole model
|
|
149
1021
|
// families (2.1.220 is the first to know Opus 5), so an old binary silently
|