@jskit-ai/assistant-core 0.2.49 → 0.2.50
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/CHANGELOG.md +2 -0
- package/examples/conversation/package.json +3 -3
- package/package.json +1 -1
- package/src/server/conversation/claudeEntryStop.js +84 -0
- package/src/server/conversation/claudeNativeTurn.js +120 -117
- package/src/server/conversation/claudePromptDelivery.js +24 -3
- package/src/server/conversation/claudeTurn.js +20 -2
- package/src/server/conversation/conversationDelivery.js +10 -2
- package/src/server/conversation/providers/claudeDriver.js +1 -1
- package/test/claudeConversationSession.test.js +7 -4
- package/test/claudeTurn.test.js +427 -0
- package/test/fixtures/hostWaits.js +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- A Claude conversation keeps working when a Stop, a crashed Claude process or the next message arrives while Claude's previous turn still waits on its host (publishing, saving, storing an admission). The shared Claude turn now gives each native frame a lease on its turn: a Stop, a reset or the next turn's start supersedes it, and a superseded frame ends at that wait before it reads, changes or publishes anything of the turn that replaced it. Such a frame used to fail with "Cannot read properties of null (reading 'status')", publish the old turn's message, phase or diagnostic into the next turn, or complete the next turn with the old turn's result; a finished turn's goal refresh now resolves that turn's own completion. A steer to the same turn still lands, and a frame decides from the state it finds after each wait. A message being prepared for a turn that a Stop or the next message replaces meanwhile is no longer written to the stopped Claude, and its failure no longer stops the Claude that replaced it: it fails as not sent (`conversation_claude_turn_superseded`).
|
|
6
|
+
- A message lost with a failed Claude process no longer makes every later message fail as unconfirmed (`conversation_delivery_uncertain`, or the host's own changeover code). When the next message checks the earlier attempt, a Claude history without it and no live process that it was written to prove that it never arrived (`not-admitted`), and the conversation runtime now passes that proof to the changeover instead of reading every answer but `accepted` as unknown. A message Claude did record is accepted and never sent again, a prompt whose pipe write failed is reported `delivery: "not-sent"`, and a prompt written to a process still running stays unknown until that process settles it. The server records which process it wrote each prompt to, so a prompt it has no record of was written, if at all, by an earlier server, and only that server's Claude can still record it while its exit is unproven: a conversation already holding such an attempt clears it with its next message.
|
|
5
7
|
- A Claude goal's running time no longer counts the time a server was stopped mid-turn. When Claude starts again after a request that was cut off, it saves a "No response requested." placeholder just before the goal continues, and the history reader counted the wait up to that placeholder as work: a restart in the middle of a turn added the whole downtime to the goal's clock. The wait before that placeholder now counts like the wait before an authored message; the cut-off request's unsaved part is not counted.
|
|
6
8
|
- `AssistantGoalControl`'s chip says why a goal stopped in a few words, so it no longer runs across the message box: a goal stopped at its AI account's usage limit reads **Goal paused: usage limit**, or **Goal paused: no credit** when it is out of credit, with the orange paused light, instead of "Waiting: AI usage limit, resets at Wed 09:00" or "Out of credit: top up or switch AI account". Its details say what happened, when the limit resets and what to do. `jskit.assistant.goal.usageLimitedUntil` is replaced by `jskit.assistant.goal.usageLimitedUntilHint`, and `jskit.assistant.goal.outOfCreditHint` is new; `usageLimited`, `outOfCredit` and `usageLimitedHint` are reworded.
|
|
7
9
|
- The Claude engine keeps an application tool result within its tool set's raised `maxToolResultBytes` whole. Claude Code saves an MCP tool result past 50,000 characters, or past its MCP output token cap, to a file and shows the model only a preview, which a tool-free conversation cannot read: a 128 KiB result reached the model as its first 2 KB. A tool set whose result limit is above the default 48 KiB now starts Claude with `MAX_MCP_OUTPUT_TOKENS` at that limit plus 1 KiB, and its application tools declare that many characters as their `anthropic/maxResultSizeChars` (Claude Code allows at most 500,000); a character and a token each cover at least one byte of the result. At the default limit Claude keeps its own. `createClaudeCodeProcess` takes `maximumToolResultBytes`, and conversation tools give engines `maximumResultBytes` beside `maximumArgumentBytes`.
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.2.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.2.50",
|
|
12
12
|
"@jskit-ai/http-runtime": "0.2.21",
|
|
13
13
|
"@jskit-ai/kernel": "0.2.21",
|
|
14
14
|
"@jskit-ai/material-web": "0.1.6",
|
|
15
15
|
"vue": "^3.5.13",
|
|
16
16
|
"vuetify": "^4.0.0",
|
|
17
|
-
"@jskit-ai/connectors-core": "0.2.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.2.
|
|
17
|
+
"@jskit-ai/connectors-core": "0.2.30",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.2.30"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
package/package.json
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Stopping a retained Claude entry's process, and the turn record its host saves.
|
|
2
|
+
import { stopClaudeCodeProcess } from "./claudeProcess.js";
|
|
3
|
+
|
|
4
|
+
/** Records the entry's turn state, as its host saves and shows it. */
|
|
5
|
+
export function setClaudeTurnState(entry, state, message = "") {
|
|
6
|
+
if (!entry.turn) return;
|
|
7
|
+
const active = ["starting", "active", "finalizing"].includes(state);
|
|
8
|
+
Object.assign(entry.turn, { active, state, error: message, updatedAt: new Date().toISOString(),
|
|
9
|
+
phase: active && !entry.observationError ? String(entry.turn.phase ?? "").trim() : "" });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Stops an entry's Claude process for the entry's turn (`turn` carries the
|
|
14
|
+
* entry, its host and the turn's own steps): proves the process exit, stops
|
|
15
|
+
* the turn and runs the host's interrupted checkpoint. A Stop while another
|
|
16
|
+
* is pending joins it.
|
|
17
|
+
*/
|
|
18
|
+
export async function stopClaudeEntryProcess(turn, reason = "", { preparing = false } = {}) {
|
|
19
|
+
const { entry, bound, process, onEvent, read, stop, updateState, readResult, resolveCompletion } = turn;
|
|
20
|
+
clearTimeout(entry.goalToolStop?.timer);
|
|
21
|
+
entry.goalToolStop = null;
|
|
22
|
+
if (!preparing) entry.preparationRevision++;
|
|
23
|
+
turn.invalidateFinalAssistantResult();
|
|
24
|
+
// Runtime bindings commit execution release before dropping the handle.
|
|
25
|
+
// Retained host entries keep their original post-proof checkpoint order.
|
|
26
|
+
if (entry.stopPending) return entry.stopPending;
|
|
27
|
+
const active = read().active;
|
|
28
|
+
if (!bound) entry.stopping = true;
|
|
29
|
+
const operation = async () => {
|
|
30
|
+
const startup = entry.startupCleanup?.executionId === entry.executionId ? entry.startupCleanup : null;
|
|
31
|
+
let proof;
|
|
32
|
+
try {
|
|
33
|
+
if (!bound || entry.process || entry.executionId) {
|
|
34
|
+
proof = await stopClaudeCodeProcess({ process: entry.process || startup, executionId: entry.executionId,
|
|
35
|
+
stopExecution: process.stopExecution });
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (bound || error.code !== "claude_stop_unconfirmed") throw error;
|
|
39
|
+
entry.observationError = reason || "Claude process exit has not been confirmed. Try Stop again.";
|
|
40
|
+
await onEvent({ type: "save" });
|
|
41
|
+
await onEvent({ type: "stop-unconfirmed" });
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
if (bound && (entry.process || entry.executionId)) await process.releaseExecution();
|
|
45
|
+
entry.process = null;
|
|
46
|
+
entry.executionId = "";
|
|
47
|
+
entry.exitProof = proof;
|
|
48
|
+
stop(bound ? reason : reason || "Claude stopped before acknowledging the prompt.");
|
|
49
|
+
if (bound) {
|
|
50
|
+
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
51
|
+
if (active) entry.completion?.resolve({ status: "interrupted", error: reason || "Work stopped." });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (entry.turn?.active && process.ownerClosing()) {
|
|
55
|
+
// At shutdown the turn's end belongs to the next server, as after a
|
|
56
|
+
// crash: its saved record keeps the execution and the active turn, so
|
|
57
|
+
// that server's recovery proves this exit, interrupts the turn and runs
|
|
58
|
+
// the host's checkpoint. Run here, that checkpoint (a Git snapshot, say)
|
|
59
|
+
// would hold the host's bounded shutdown for as long as it takes.
|
|
60
|
+
resolveCompletion({ ...readResult(), status: "interrupted", error: reason });
|
|
61
|
+
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
62
|
+
return entry.exitProof;
|
|
63
|
+
}
|
|
64
|
+
if (entry.turn?.active) {
|
|
65
|
+
try { await updateState("interrupted", reason); }
|
|
66
|
+
catch (error) {
|
|
67
|
+
// Native cleanup is verified even when the host refuses its checkpoint.
|
|
68
|
+
// Retain that refusal without publishing a completed answer or active work.
|
|
69
|
+
setClaudeTurnState(entry, "interrupted", error.message);
|
|
70
|
+
await onEvent({ type: "save" });
|
|
71
|
+
await onEvent({ type: "state", state: "interrupted", active: false, message: error.message });
|
|
72
|
+
resolveCompletion(readResult());
|
|
73
|
+
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
} else await onEvent({ type: "save" });
|
|
77
|
+
resolveCompletion(readResult());
|
|
78
|
+
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
79
|
+
return entry.exitProof;
|
|
80
|
+
};
|
|
81
|
+
entry.stopPending = operation().catch(error => { if (bound) error.cleanupFailed = true; throw error; })
|
|
82
|
+
.finally(() => { entry.stopPending = null; });
|
|
83
|
+
return entry.stopPending;
|
|
84
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
2
|
import { claudeMessageBlocks } from "./claudeHistory.js";
|
|
3
|
-
import {
|
|
3
|
+
import { setClaudeTurnState, stopClaudeEntryProcess } from "./claudeEntryStop.js";
|
|
4
4
|
import { createClaudeConversationAdapter } from "./providers/claude.js";
|
|
5
5
|
import { createClaudeSteeringBackground } from "./claudeSteeringBackground.js";
|
|
6
6
|
|
|
@@ -19,11 +19,19 @@ function steeringRefusal(message, { messageCode, finished }) {
|
|
|
19
19
|
|
|
20
20
|
const CLAUDE_SIGNED_OUT_MESSAGE = "Claude isn't connected. Sign in to Claude, then send your message again.";
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
// Ends a frame whose turn a Stop, reset or the next turn's start superseded
|
|
23
|
+
// while the frame waited on its host. receive() catches it; it never escapes.
|
|
24
|
+
const SUPERSEDED_FRAME = new Error("A later Stop or turn superseded this Claude frame.");
|
|
25
|
+
|
|
26
|
+
// A failed pipe write is a definite non-delivery: Claude never read that
|
|
27
|
+
// message. The copy carries that verdict without changing the error other
|
|
28
|
+
// callers share (the client's failure is the same object for every write).
|
|
29
|
+
function unsentDelivery(error) {
|
|
30
|
+
return Object.assign(new Error(error.message, { cause: error }), {
|
|
31
|
+
...(error.code ? { code: error.code } : {}),
|
|
32
|
+
...(error.messageCode ? { messageCode: error.messageCode, messageParams: error.messageParams || {} } : {}),
|
|
33
|
+
delivery: "not-sent"
|
|
34
|
+
});
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
// The entry's turn result, with the text and messages the native view holds.
|
|
@@ -131,6 +139,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
131
139
|
if (!conversationId) throw new TypeError("A Claude conversation id is required.");
|
|
132
140
|
const instructions = process ? createClaudeConversationAdapter(process) : null;
|
|
133
141
|
const publish = entry ? publishEntryEvent : onEvent;
|
|
142
|
+
// The steps of this turn that the entry's process stop runs.
|
|
143
|
+
const entryStop = { entry, bound, process, onEvent, read, stop, updateState, readResult, resolveCompletion, invalidateFinalAssistantResult };
|
|
134
144
|
const messages = new Map();
|
|
135
145
|
const pendingAssistantMessages = new Map();
|
|
136
146
|
const assistantRoles = new Map();
|
|
@@ -154,6 +164,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
154
164
|
let outcome;
|
|
155
165
|
let active = false;
|
|
156
166
|
let generation = 0;
|
|
167
|
+
// The lease every frame, and every message being prepared for this turn,
|
|
168
|
+
// holds while it waits on its host. stop(), begin() and reset() replace the
|
|
169
|
+
// turn and advance it.
|
|
170
|
+
let epoch = 0;
|
|
157
171
|
let stopped = false;
|
|
158
172
|
let cancelling = false;
|
|
159
173
|
let maximumOutput = 4 * 1024 * 1024;
|
|
@@ -222,10 +236,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
222
236
|
return claudeTurnResult(entry, read());
|
|
223
237
|
}
|
|
224
238
|
|
|
225
|
-
function resolveCompletion(result) {
|
|
226
|
-
|
|
227
|
-
resolvedCompletion =
|
|
228
|
-
if (
|
|
239
|
+
function resolveCompletion(result, completion = entry.completion, command = entry.command) {
|
|
240
|
+
completion?.resolve(result);
|
|
241
|
+
resolvedCompletion = completion;
|
|
242
|
+
if (command?.finishIfCurrent) command.completion.resolve(result);
|
|
229
243
|
}
|
|
230
244
|
|
|
231
245
|
async function updateState(state, message = "", expectedGeneration = generation) {
|
|
@@ -241,71 +255,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
241
255
|
return expectedGeneration === generation;
|
|
242
256
|
}
|
|
243
257
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
entry.goalToolStop = null;
|
|
247
|
-
if (!preparing) entry.preparationRevision++;
|
|
248
|
-
invalidateFinalAssistantResult();
|
|
249
|
-
// Runtime bindings commit execution release before dropping the handle.
|
|
250
|
-
// Retained host entries keep their original post-proof checkpoint order.
|
|
251
|
-
if (entry.stopPending) return entry.stopPending;
|
|
252
|
-
const active = read().active;
|
|
253
|
-
if (!bound) entry.stopping = true;
|
|
254
|
-
const operation = async () => {
|
|
255
|
-
const startup = entry.startupCleanup?.executionId === entry.executionId ? entry.startupCleanup : null;
|
|
256
|
-
let proof;
|
|
257
|
-
try {
|
|
258
|
-
if (!bound || entry.process || entry.executionId) {
|
|
259
|
-
proof = await stopClaudeCodeProcess({ process: entry.process || startup, executionId: entry.executionId,
|
|
260
|
-
stopExecution: process.stopExecution });
|
|
261
|
-
}
|
|
262
|
-
} catch (error) {
|
|
263
|
-
if (bound || error.code !== "claude_stop_unconfirmed") throw error;
|
|
264
|
-
entry.observationError = reason || "Claude process exit has not been confirmed. Try Stop again.";
|
|
265
|
-
await onEvent({ type: "save" });
|
|
266
|
-
await onEvent({ type: "stop-unconfirmed" });
|
|
267
|
-
throw error;
|
|
268
|
-
}
|
|
269
|
-
if (bound && (entry.process || entry.executionId)) await process.releaseExecution();
|
|
270
|
-
entry.process = null;
|
|
271
|
-
entry.executionId = "";
|
|
272
|
-
entry.exitProof = proof;
|
|
273
|
-
stop(bound ? reason : reason || "Claude stopped before acknowledging the prompt.");
|
|
274
|
-
if (bound) {
|
|
275
|
-
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
276
|
-
if (active) entry.completion?.resolve({ status: "interrupted", error: reason || "Work stopped." });
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
if (entry.turn?.active && process.ownerClosing()) {
|
|
280
|
-
// At shutdown the turn's end belongs to the next server, as after a
|
|
281
|
-
// crash: its saved record keeps the execution and the active turn, so
|
|
282
|
-
// that server's recovery proves this exit, interrupts the turn and runs
|
|
283
|
-
// the host's checkpoint. Run here, that checkpoint (a Git snapshot, say)
|
|
284
|
-
// would hold the host's bounded shutdown for as long as it takes.
|
|
285
|
-
resolveCompletion({ ...readResult(), status: "interrupted", error: reason });
|
|
286
|
-
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
287
|
-
return entry.exitProof;
|
|
288
|
-
}
|
|
289
|
-
if (entry.turn?.active) {
|
|
290
|
-
try { await updateState("interrupted", reason); }
|
|
291
|
-
catch (error) {
|
|
292
|
-
// Native cleanup is verified even when the host refuses its checkpoint.
|
|
293
|
-
// Retain that refusal without publishing a completed answer or active work.
|
|
294
|
-
setClaudeTurnState(entry, "interrupted", error.message);
|
|
295
|
-
await onEvent({ type: "save" });
|
|
296
|
-
await onEvent({ type: "state", state: "interrupted", active: false, message: error.message });
|
|
297
|
-
resolveCompletion(readResult());
|
|
298
|
-
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
299
|
-
throw error;
|
|
300
|
-
}
|
|
301
|
-
} else await onEvent({ type: "save" });
|
|
302
|
-
resolveCompletion(readResult());
|
|
303
|
-
if (entry.startupCleanup === startup) entry.startupCleanup = null;
|
|
304
|
-
return entry.exitProof;
|
|
305
|
-
};
|
|
306
|
-
entry.stopPending = operation().catch(error => { if (bound) error.cleanupFailed = true; throw error; })
|
|
307
|
-
.finally(() => { entry.stopPending = null; });
|
|
308
|
-
return entry.stopPending;
|
|
258
|
+
function stopProcess(reason, options) {
|
|
259
|
+
return stopClaudeEntryProcess(entryStop, reason, options);
|
|
309
260
|
}
|
|
310
261
|
|
|
311
262
|
async function publishEntryEvent(event, deferred = false) {
|
|
@@ -341,16 +292,17 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
341
292
|
});
|
|
342
293
|
return;
|
|
343
294
|
}
|
|
295
|
+
// A state the host refused to save for a superseded turn is not published.
|
|
344
296
|
if (event.type === "continued") {
|
|
345
297
|
if (entry.completion === resolvedCompletion) entry.completion = Promise.withResolvers();
|
|
346
298
|
if (bound) setClaudeTurnState(entry, "active");
|
|
347
|
-
else await updateState("active");
|
|
299
|
+
else if (!await updateState("active")) return;
|
|
348
300
|
} else if (event.type === "admitted") {
|
|
349
301
|
if (event.userEcho) entry.sent = true;
|
|
350
302
|
if (!bound) await onEvent({ type: "save" });
|
|
351
303
|
} else if (event.type === "phase") {
|
|
352
304
|
entry.turn.phase = event.phase;
|
|
353
|
-
if (!bound
|
|
305
|
+
if (!bound && !await updateState(entry.turn.state)) return;
|
|
354
306
|
} else if (event.type === "settled") {
|
|
355
307
|
const state = event.status === "interrupted" ? "interrupted" : event.status === "failed" ? "failed" : "completed";
|
|
356
308
|
if (bound) setClaudeTurnState(entry, state, event.error);
|
|
@@ -369,8 +321,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
369
321
|
if (change) await onEvent({ type: "provider-update", change });
|
|
370
322
|
if (event.type === "settled") {
|
|
371
323
|
if (event.generation !== generation) return;
|
|
324
|
+
// The settled turn resolves its own completion: the next turn can begin
|
|
325
|
+
// while the goal refreshes, and it owns entry.completion by then.
|
|
326
|
+
const result = bound ? event : readResult();
|
|
327
|
+
const { completion, command: settledCommand } = entry;
|
|
372
328
|
if (event.source === "result") await onEvent({ type: "provider-update", change: "goal" });
|
|
373
|
-
resolveCompletion(
|
|
329
|
+
resolveCompletion(result, completion, settledCommand);
|
|
374
330
|
}
|
|
375
331
|
return published;
|
|
376
332
|
}
|
|
@@ -381,13 +337,13 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
381
337
|
}
|
|
382
338
|
}
|
|
383
339
|
|
|
384
|
-
async function publishMessage(message) {
|
|
340
|
+
async function publishMessage(message, emit) {
|
|
385
341
|
if (!message.userId && currentCommandId) message = { ...message, userId: currentCommandId };
|
|
386
342
|
messages.set(message.id, message);
|
|
387
343
|
const owner = acceptedInput;
|
|
388
344
|
const completeAssistant = message.complete && message.role === "assistant";
|
|
389
345
|
if (completeAssistant) clearFinalAssistantResult();
|
|
390
|
-
const conversationTurn = await
|
|
346
|
+
const conversationTurn = await emit({ type: "message", message });
|
|
391
347
|
if (completeAssistant && message.text && currentReceiptOwner(owner) && !stopped && !cancelling &&
|
|
392
348
|
conversationTurn && typeof conversationTurn === "object" && !Array.isArray(conversationTurn)) {
|
|
393
349
|
publishedAssistant = { owner, itemId: message.id, outputId: message.outputId || message.id,
|
|
@@ -396,24 +352,49 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
396
352
|
return conversationTurn;
|
|
397
353
|
}
|
|
398
354
|
|
|
399
|
-
async function flushAssistantMessages(apiMessageId, role) {
|
|
355
|
+
async function flushAssistantMessages(apiMessageId, role, emit) {
|
|
400
356
|
assistantRoles.set(apiMessageId, role);
|
|
401
357
|
const pending = pendingAssistantMessages.get(apiMessageId) || [];
|
|
402
358
|
pendingAssistantMessages.delete(apiMessageId);
|
|
403
359
|
for (const block of pending) {
|
|
404
|
-
await publishMessage({ ...block, role });
|
|
360
|
+
await publishMessage({ ...block, role }, emit);
|
|
405
361
|
if (block.outputId) {
|
|
406
362
|
if (block.outputId !== block.id) messages.delete(block.outputId);
|
|
407
|
-
await
|
|
363
|
+
await emit({ type: "message-complete", messageId: block.outputId });
|
|
408
364
|
}
|
|
409
365
|
}
|
|
410
366
|
}
|
|
411
367
|
|
|
368
|
+
/**
|
|
369
|
+
* One native frame. The process's reader hands over frames one at a time;
|
|
370
|
+
* Stop, a reset and the next turn's start are synchronous steps that advance
|
|
371
|
+
* the lease. So the turn changes under a frame only while the frame waits on
|
|
372
|
+
* its host, and the frame does all of that waiting through its lease: when a
|
|
373
|
+
* Stop, a reset or the next turn's start has superseded its turn, the frame
|
|
374
|
+
* ends at that wait, before it reads, changes or publishes anything of the
|
|
375
|
+
* turn that replaced it. A message being prepared holds the lease the same
|
|
376
|
+
* way (see holds()). Input to the same turn (a steer) may change it meanwhile,
|
|
377
|
+
* so a frame decides from the state it finds after each wait, never from
|
|
378
|
+
* earlier reads.
|
|
379
|
+
*/
|
|
412
380
|
async function receive(frame) {
|
|
413
381
|
if (stopped) return;
|
|
414
382
|
if (frame.session_id && frame.session_id !== conversationId) throw claudeTurnFailure("Claude returned a different conversation id.");
|
|
415
383
|
if (frame.parent_tool_use_id) return; // Nested agents do not replace the main reply.
|
|
416
|
-
|
|
384
|
+
const lease = epoch;
|
|
385
|
+
const hold = () => { if (lease !== epoch) throw SUPERSEDED_FRAME; };
|
|
386
|
+
const emit = async event => {
|
|
387
|
+
hold();
|
|
388
|
+
const published = await publish(event);
|
|
389
|
+
hold();
|
|
390
|
+
return published;
|
|
391
|
+
};
|
|
392
|
+
try { await receiveFrame(frame, emit, hold); }
|
|
393
|
+
catch (error) { if (error !== SUPERSEDED_FRAME) throw error; }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function receiveFrame(frame, emit, hold) {
|
|
397
|
+
await emit({ type: "provider-event", event: frame });
|
|
417
398
|
if (continuesClaudeTurn(frame) && turnId && !cancelling) {
|
|
418
399
|
const wasActive = active;
|
|
419
400
|
if (outcome || !active) {
|
|
@@ -423,10 +404,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
423
404
|
clearFinalAssistantResult();
|
|
424
405
|
}
|
|
425
406
|
active = true;
|
|
426
|
-
if (!wasActive) await
|
|
407
|
+
if (!wasActive) await emit({ type: "continued", source: frame.type });
|
|
427
408
|
if (phase === "background") {
|
|
428
409
|
phase = "";
|
|
429
|
-
await
|
|
410
|
+
await emit({ type: "phase", phase });
|
|
430
411
|
}
|
|
431
412
|
}
|
|
432
413
|
if (frame.type === "command_lifecycle" || frame.type === "user") {
|
|
@@ -449,19 +430,22 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
449
430
|
// Once acknowledged, storage owns the admission decision. Its write
|
|
450
431
|
// must not race a native-ack timeout and a second submission.
|
|
451
432
|
clearTimeout(pending.timer);
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
433
|
+
let failure;
|
|
434
|
+
try { await pending.accept(); }
|
|
435
|
+
catch (error) { failure = error; }
|
|
436
|
+
// A Stop while storage decided has already refused this admission.
|
|
437
|
+
hold();
|
|
438
|
+
if (failure) {
|
|
458
439
|
acceptedInput = null;
|
|
459
440
|
clearFinalAssistantResult();
|
|
460
|
-
pending.reject(
|
|
461
|
-
throw
|
|
441
|
+
pending.reject(failure);
|
|
442
|
+
throw failure;
|
|
462
443
|
}
|
|
444
|
+
if (inputOwner === pending.receiptOwner && !stopped && !cancelling) acceptedInput = pending.receiptOwner;
|
|
445
|
+
admissions.delete(uuid);
|
|
446
|
+
pending.resolve();
|
|
463
447
|
}
|
|
464
|
-
await
|
|
448
|
+
await emit({ type: "admitted", messageId: uuid, userEcho: frame.type === "user" });
|
|
465
449
|
}
|
|
466
450
|
} else if (frame.type === "system") {
|
|
467
451
|
if (active && ["status", "compact_boundary"].includes(frame.subtype)) {
|
|
@@ -469,12 +453,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
469
453
|
// An idle status does not end a wait that only background tasks own.
|
|
470
454
|
if (nextPhase !== phase && (nextPhase || phase !== "background")) {
|
|
471
455
|
phase = nextPhase;
|
|
472
|
-
await
|
|
456
|
+
await emit({ type: "phase", phase });
|
|
473
457
|
}
|
|
474
458
|
}
|
|
475
459
|
if (frame.subtype === "local_command_output" && frame.content) {
|
|
476
460
|
await publishMessage({ id: `claude_${frame.uuid}`, role: "commentary", text: frame.content, complete: true,
|
|
477
|
-
data: { native: { engineId: "claude", type: "system", subtype: "local_command_output" } } });
|
|
461
|
+
data: { native: { engineId: "claude", type: "system", subtype: "local_command_output" } } }, emit);
|
|
478
462
|
}
|
|
479
463
|
const previousTaskCount = tasks.size;
|
|
480
464
|
updateClaudeBackgroundTasks(tasks, frame);
|
|
@@ -487,16 +471,16 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
487
471
|
if (tasks.size < previousTaskCount) outcome = null;
|
|
488
472
|
if (phase === "background" && !outcome) {
|
|
489
473
|
phase = "";
|
|
490
|
-
await
|
|
474
|
+
await emit({ type: "phase", phase });
|
|
491
475
|
}
|
|
492
476
|
if (["background_tasks_changed", "task_started", "task_notification", "task_updated"].includes(frame.subtype)) {
|
|
493
477
|
if (tasks.size && turnId && !active && !cancelling) {
|
|
494
478
|
generation++;
|
|
495
479
|
active = true;
|
|
496
480
|
clearFinalAssistantResult();
|
|
497
|
-
await
|
|
481
|
+
await emit({ type: "continued", source: frame.subtype });
|
|
498
482
|
}
|
|
499
|
-
await
|
|
483
|
+
await emit({ type: "diagnostic", stage: "background_tasks", nativeEvent: frame.subtype,
|
|
500
484
|
active, pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
501
485
|
}
|
|
502
486
|
} else if (frame.type === "stream_event") {
|
|
@@ -512,21 +496,21 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
512
496
|
if (block && typeof delta === "string") {
|
|
513
497
|
block.text += delta;
|
|
514
498
|
checkOutput(block.text, block.role);
|
|
515
|
-
await publishMessage(block);
|
|
516
|
-
await
|
|
499
|
+
await publishMessage(block, emit);
|
|
500
|
+
await emit({ type: block.role === "thinking" ? "thinking" : "text", text: delta, messageId: id });
|
|
517
501
|
}
|
|
518
502
|
} else if (event.type === "content_block_stop") {
|
|
519
503
|
if (![...pendingAssistantMessages.values()].some(blocks => blocks.some(block => block.outputId === id))) {
|
|
520
504
|
messages.delete(id);
|
|
521
|
-
await
|
|
505
|
+
await emit({ type: "message-complete", messageId: id });
|
|
522
506
|
}
|
|
523
507
|
} else if (event.type === "message_delta" && event.delta?.stop_reason) {
|
|
524
|
-
await flushAssistantMessages(messageId, event.delta.stop_reason === "tool_use" ? "commentary" : "assistant");
|
|
508
|
+
await flushAssistantMessages(messageId, event.delta.stop_reason === "tool_use" ? "commentary" : "assistant", emit);
|
|
525
509
|
}
|
|
526
510
|
} else if (frame.type === "assistant") {
|
|
527
511
|
const apiMessageId = frame.message?.id;
|
|
528
512
|
if (apiMessageId && frame.message.content?.some(block => block.type === "tool_use")) {
|
|
529
|
-
await flushAssistantMessages(apiMessageId, "commentary");
|
|
513
|
+
await flushAssistantMessages(apiMessageId, "commentary", emit);
|
|
530
514
|
}
|
|
531
515
|
for (const block of frame.message?.content || []) steeringBackground.called(block);
|
|
532
516
|
for (const block of claudeMessageBlocks(frame)) {
|
|
@@ -539,41 +523,43 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
539
523
|
}
|
|
540
524
|
if (apiMessageId && block.role === "assistant") {
|
|
541
525
|
const role = assistantRoles.get(apiMessageId);
|
|
542
|
-
if (role) await publishMessage({ ...block, role });
|
|
526
|
+
if (role) await publishMessage({ ...block, role }, emit);
|
|
543
527
|
else {
|
|
544
528
|
const pending = pendingAssistantMessages.get(apiMessageId) || [];
|
|
545
529
|
pending.push(block);
|
|
546
530
|
pendingAssistantMessages.set(apiMessageId, pending);
|
|
547
531
|
}
|
|
548
|
-
} else await publishMessage(block);
|
|
532
|
+
} else await publishMessage(block, emit);
|
|
549
533
|
}
|
|
550
534
|
if (apiMessageId && frame.message.stop_reason) {
|
|
551
|
-
await flushAssistantMessages(apiMessageId, frame.message.stop_reason === "tool_use" ? "commentary" : "assistant");
|
|
535
|
+
await flushAssistantMessages(apiMessageId, frame.message.stop_reason === "tool_use" ? "commentary" : "assistant", emit);
|
|
552
536
|
}
|
|
553
537
|
} else if (frame.type === "result") {
|
|
554
538
|
const failed = frame.is_error || frame.subtype !== "success";
|
|
555
539
|
const interrupted = ["aborted_streaming", "aborted_tools"].includes(frame.terminal_reason);
|
|
556
540
|
const role = failed || interrupted ? "commentary" : "assistant";
|
|
557
|
-
for (const id of pendingAssistantMessages.keys()) await flushAssistantMessages(id, role);
|
|
541
|
+
for (const id of pendingAssistantMessages.keys()) await flushAssistantMessages(id, role, emit);
|
|
558
542
|
const output = typeof frame.structured_output === "object" ? JSON.stringify(frame.structured_output) : String(frame.result || "");
|
|
559
543
|
checkOutput(output);
|
|
560
544
|
const message = !failed ? "" : claudeResultError(frame, output);
|
|
561
545
|
result = failed ? "" : output;
|
|
562
546
|
if (result && ![...messages.values()].some((block) => block.complete && block.role === "assistant" && block.text === result)) {
|
|
563
|
-
await publishMessage(claudeResultMessage(frame, turnId, result));
|
|
547
|
+
await publishMessage(claudeResultMessage(frame, turnId, result), emit);
|
|
564
548
|
}
|
|
565
549
|
inFlight.delete(currentCommandId);
|
|
566
550
|
for (const uuid of startedInputs) inFlight.delete(uuid);
|
|
567
551
|
startedInputs.clear();
|
|
568
552
|
if (cancelling) return;
|
|
569
|
-
|
|
553
|
+
const status = interrupted ? "interrupted" : failed ? "failed" : "completed";
|
|
554
|
+
outcome = { status, error: message };
|
|
570
555
|
// The reply is final, but native background tasks still own the turn
|
|
571
556
|
// (and may continue it). Keep it active and say what it is waiting for.
|
|
572
557
|
if (active && tasks.size && !inFlight.size && phase !== "background") {
|
|
573
558
|
phase = "background";
|
|
574
|
-
await
|
|
559
|
+
await emit({ type: "phase", phase });
|
|
575
560
|
}
|
|
576
|
-
|
|
561
|
+
// A steer prepared during that wait owns completion and cleared this outcome.
|
|
562
|
+
await emit({ type: "diagnostic", stage: "native_result", status,
|
|
577
563
|
terminalReason: frame.terminal_reason, active, pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
578
564
|
}
|
|
579
565
|
if ((frame.type === "system" || frame.type === "result" || frame.type === "command_lifecycle" && frame.state === "completed") &&
|
|
@@ -587,13 +573,18 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
587
573
|
itemId: candidate.itemId, outputId: candidate.outputId, text: candidate.text,
|
|
588
574
|
conversationTurn: candidate.conversationTurn } : null;
|
|
589
575
|
latestSettlement = { type: "settled", ...read(), generation, source: frame.type };
|
|
590
|
-
try { await
|
|
591
|
-
catch (error) {
|
|
576
|
+
try { await emit(latestSettlement); }
|
|
577
|
+
catch (error) {
|
|
578
|
+
// A superseded frame leaves the final receipt to the turn that replaced it.
|
|
579
|
+
if (error !== SUPERSEDED_FRAME) clearFinalAssistantResult();
|
|
580
|
+
throw error;
|
|
581
|
+
}
|
|
592
582
|
}
|
|
593
583
|
}
|
|
594
584
|
|
|
595
585
|
function stop(reason = "Claude stopped before acknowledging the prompt.") {
|
|
596
586
|
generation++;
|
|
587
|
+
epoch++;
|
|
597
588
|
stopped = true;
|
|
598
589
|
invalidateFinalAssistantResult();
|
|
599
590
|
for (const pending of admissions.values()) {
|
|
@@ -624,6 +615,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
624
615
|
if (active) throw claudeTurnFailure("This Claude turn is still working.", { messageCode: "assistant.claude.turnStillWorking" });
|
|
625
616
|
if (!id || !Number.isSafeInteger(maxOutputCharacters) || maxOutputCharacters < 1) throw new TypeError("Invalid Claude turn configuration.");
|
|
626
617
|
generation++;
|
|
618
|
+
epoch++;
|
|
627
619
|
clearTurnView();
|
|
628
620
|
turnId = id;
|
|
629
621
|
maximumOutput = maxOutputCharacters;
|
|
@@ -633,10 +625,17 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
633
625
|
cancelling = false;
|
|
634
626
|
stopped = false;
|
|
635
627
|
active = true;
|
|
628
|
+
return epoch;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Whether the turn a lease from begin() or prepareSteering() names is still this one.
|
|
632
|
+
function holds(lease) {
|
|
633
|
+
return lease === epoch;
|
|
636
634
|
}
|
|
637
635
|
|
|
638
636
|
function reset() {
|
|
639
637
|
if (active || inFlight.size || tasks.size) throw claudeTurnFailure("Stop Claude before resetting its turn view.", { messageCode: "assistant.claude.stopBeforeReset" });
|
|
638
|
+
epoch++;
|
|
640
639
|
clearTurnView();
|
|
641
640
|
}
|
|
642
641
|
|
|
@@ -663,13 +662,14 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
663
662
|
{ delivery: "uncertain" }
|
|
664
663
|
)), timeoutMs);
|
|
665
664
|
admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
|
|
665
|
+
let unwritten = false;
|
|
666
666
|
try {
|
|
667
667
|
await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId })
|
|
668
|
-
.then(() => { if (steer) backgroundForegroundWork(); }), admitted.promise]);
|
|
668
|
+
.then(() => { if (steer) backgroundForegroundWork(); }, error => { unwritten = true; throw error; }), admitted.promise]);
|
|
669
669
|
} catch (error) {
|
|
670
670
|
waitingSteers.delete(uuid);
|
|
671
671
|
if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
|
|
672
|
-
throw error;
|
|
672
|
+
throw unwritten && !error.delivery ? unsentDelivery(error) : error;
|
|
673
673
|
} finally {
|
|
674
674
|
clearTimeout(timer);
|
|
675
675
|
}
|
|
@@ -691,7 +691,9 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
691
691
|
outcome = null;
|
|
692
692
|
acceptedInput = null;
|
|
693
693
|
clearFinalAssistantResult();
|
|
694
|
+
const lease = epoch;
|
|
694
695
|
await publish({ type: "diagnostic", stage: "steering_queued", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
696
|
+
return lease;
|
|
695
697
|
}
|
|
696
698
|
|
|
697
699
|
async function cancel(client, stopProcess) {
|
|
@@ -721,6 +723,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
721
723
|
invalidateInstructions: instructions.invalidate
|
|
722
724
|
} : {}),
|
|
723
725
|
begin,
|
|
726
|
+
holds,
|
|
724
727
|
reset,
|
|
725
728
|
send,
|
|
726
729
|
prepareSteering,
|