@jskit-ai/assistant-core 0.1.234 → 0.1.236
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/examples/conversation/package.json +1 -1
- package/package.json +1 -1
- package/src/server/conversation/claudeTurn.js +16 -1
- package/src/server/conversation/providers/claudeDriver.js +1 -1
- package/src/server/conversation/runtime.js +16 -10
- package/src/server/conversation/storage.js +6 -2
- package/src/shared/conversation/patches.js +11 -2
- package/test/claudeTurn.test.js +25 -1
- package/test/conversation.test.js +36 -0
package/package.json
CHANGED
|
@@ -1408,6 +1408,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1408
1408
|
}
|
|
1409
1409
|
active = true;
|
|
1410
1410
|
if (!wasActive) await publish({ type: "continued", source: frame.type });
|
|
1411
|
+
if (phase === "background") {
|
|
1412
|
+
phase = "";
|
|
1413
|
+
await publish({ type: "phase", phase });
|
|
1414
|
+
}
|
|
1411
1415
|
}
|
|
1412
1416
|
if (frame.type === "command_lifecycle" || frame.type === "user") {
|
|
1413
1417
|
const uuid = frame.type === "user" ? frame.uuid : frame.command_uuid;
|
|
@@ -1443,7 +1447,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1443
1447
|
} else if (frame.type === "system") {
|
|
1444
1448
|
if (active && ["status", "compact_boundary"].includes(frame.subtype)) {
|
|
1445
1449
|
const nextPhase = frame.subtype === "status" && frame.status === "compacting" ? "compacting" : "";
|
|
1446
|
-
|
|
1450
|
+
// An idle status does not end a wait that only background tasks own.
|
|
1451
|
+
if (nextPhase !== phase && (nextPhase || phase !== "background")) {
|
|
1447
1452
|
phase = nextPhase;
|
|
1448
1453
|
await publish({ type: "phase", phase });
|
|
1449
1454
|
}
|
|
@@ -1466,6 +1471,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1466
1471
|
// response to it. A foreground result written while that worker was live
|
|
1467
1472
|
// cannot complete this follow-up or retire its admitted application tools.
|
|
1468
1473
|
if (tasks.size < previousTaskCount) outcome = null;
|
|
1474
|
+
if (phase === "background" && !outcome) {
|
|
1475
|
+
phase = "";
|
|
1476
|
+
await publish({ type: "phase", phase });
|
|
1477
|
+
}
|
|
1469
1478
|
if (["background_tasks_changed", "task_started", "task_notification", "task_updated"].includes(frame.subtype)) {
|
|
1470
1479
|
if (tasks.size && turnId && !active && !cancelling) {
|
|
1471
1480
|
generation++;
|
|
@@ -1556,6 +1565,12 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1556
1565
|
}
|
|
1557
1566
|
if (cancelling) return;
|
|
1558
1567
|
outcome = { status: interrupted ? "interrupted" : failed ? "failed" : "completed", error: message };
|
|
1568
|
+
// The reply is final, but native background tasks still own the turn
|
|
1569
|
+
// (and may continue it). Keep it active and say what it is waiting for.
|
|
1570
|
+
if (active && tasks.size && !inFlight.size && phase !== "background") {
|
|
1571
|
+
phase = "background";
|
|
1572
|
+
await publish({ type: "phase", phase });
|
|
1573
|
+
}
|
|
1559
1574
|
await publish({ type: "diagnostic", stage: "native_result", status: outcome.status,
|
|
1560
1575
|
terminalReason: frame.terminal_reason, active, pendingCommands: inFlight.size, backgroundTasks: tasks.size });
|
|
1561
1576
|
}
|
|
@@ -171,7 +171,7 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
|
|
|
171
171
|
else if (event.type === "message-complete") await current.onEvent(event);
|
|
172
172
|
else if ((event.type === "admitted" && current.goal) || event.type === "settled") {
|
|
173
173
|
await current.onEvent({ type: "goal", goal: claudeGoalValue(await entry.goals.read(), entry.id) });
|
|
174
|
-
} else if (event.type === "phase") await current.onEvent({ type: "phase", phase:
|
|
174
|
+
} else if (event.type === "phase") await current.onEvent({ type: "phase", phase: ["compacting", "background"].includes(event.phase) ? event.phase : "working" });
|
|
175
175
|
}
|
|
176
176
|
});
|
|
177
177
|
return standalone;
|
|
@@ -142,7 +142,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
async function setPhase(entry, phase, turnId) {
|
|
145
|
-
if (!["", "preparing", "working", "compacting", "retrying"].includes(phase)) throw new Error("Invalid conversation phase from the engine adapter.");
|
|
145
|
+
if (!["", "preparing", "working", "compacting", "retrying", "background"].includes(phase)) throw new Error("Invalid conversation phase from the engine adapter.");
|
|
146
146
|
if ((entry.phase || "") === phase) return;
|
|
147
147
|
entry.phase = phase;
|
|
148
148
|
await emit(entry, { type: "phase", phase, turnId });
|
|
@@ -239,7 +239,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
239
239
|
reason: metadata.runtime.replacement.reason, segmentId: metadata.runtime.replacement.segmentId } : null,
|
|
240
240
|
status: logical ? logical.status : entry.storageFailure || entry.executionFailure ? "unavailable" : entry.active || nativeTurn?.active ? "working" : metadata.runtime.replacement ? "replacement-pending"
|
|
241
241
|
: metadata.runtime.request && (entry.engine === "api" || metadata.runtime.request.attempted || metadata.runtime.request.inspectionOnly) ? "unconfirmed" : "ready",
|
|
242
|
-
phase: logical ? logical.phase || "" : entry.phase || (nativeTurn?.active && nativeTurn.status !== "observation_lost" ?
|
|
242
|
+
phase: logical ? logical.phase || "" : entry.phase || (nativeTurn?.active && nativeTurn.status !== "observation_lost" ? ["compacting", "background"].includes(nativeTurn.phase) ? nativeTurn.phase : "working" : ""),
|
|
243
243
|
error: logical ? logical.error || "" : entry.error || nativeTurn?.error || request?.error || last?.error || "",
|
|
244
244
|
goal: entry.goal || null,
|
|
245
245
|
pendingRequest: logical ? logical.pendingRequest || null : pendingRequest,
|
|
@@ -309,7 +309,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
309
309
|
const nativeTurn = payload.agentSession?.turn;
|
|
310
310
|
if (nativeTurn) {
|
|
311
311
|
entry.nativePhase = nativeTurn.active
|
|
312
|
-
? ["preparing", "compacting", "retrying"].includes(nativeTurn.phase) ? nativeTurn.phase : "working"
|
|
312
|
+
? ["preparing", "compacting", "retrying", "background"].includes(nativeTurn.phase) ? nativeTurn.phase : "working"
|
|
313
313
|
: "";
|
|
314
314
|
const request = entry.active?.request;
|
|
315
315
|
if (!entry.active || request.accepted) {
|
|
@@ -442,13 +442,19 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
442
442
|
: Math.max(0, initialWindow - (request?.input.origin === "user" ? 1 : 0)),
|
|
443
443
|
maximumInitialMessageCharacters: limits.maxInitialNativeHistoryMessageCharacters,
|
|
444
444
|
...(budget ? { maximumHistoryBytes: async options => {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
445
|
+
try {
|
|
446
|
+
await access(context, entry.id, request.operation, entry);
|
|
447
|
+
signal.throwIfAborted();
|
|
448
|
+
// A host may free native context first; show that as compacting.
|
|
449
|
+
const bytes = await budget({ ...options, context, signal,
|
|
450
|
+
setPhase: phase => setPhase(entry, phase === "compacting" ? "compacting" : "preparing") });
|
|
451
|
+
signal.throwIfAborted();
|
|
452
|
+
return bytes;
|
|
453
|
+
} catch (error) {
|
|
454
|
+
// Sizing missed history precedes native dispatch, so this message
|
|
455
|
+
// was definitely not sent. Hosts and clients report it as such.
|
|
456
|
+
throw Object.assign(error, { delivery: "not-sent" });
|
|
457
|
+
}
|
|
452
458
|
} } : {}),
|
|
453
459
|
presentation: entry.conversation?.presentation || { unconfirmedCode: "conversation_delivery_uncertain",
|
|
454
460
|
unconfirmedMessage: "The previous submission has uncertain delivery. Retry this message to check its native receipt; it will not be resent automatically." },
|
|
@@ -186,12 +186,16 @@ export function createConversationStorage({ readRecord, writeRecord, deleteRecor
|
|
|
186
186
|
const find = (role) => turn.messages.find((message) => message.role === role) || null;
|
|
187
187
|
const thinking = turn.messages.filter((message) => message.role === "thinking");
|
|
188
188
|
const commentary = turn.messages.filter((message) => message.role === "commentary");
|
|
189
|
-
|
|
189
|
+
// Native continuations (for example Claude background-task notifications)
|
|
190
|
+
// can add later replies to the same request turn. Every saved reply stays
|
|
191
|
+
// in the ordered message list; the latest one still closes it.
|
|
192
|
+
const replies = turn.messages.filter((message) => message.role === "assistant");
|
|
193
|
+
const activity = [...thinking, ...commentary, ...replies.slice(0, -1)].sort((a, b) => a.at.localeCompare(b.at));
|
|
190
194
|
const system = find("system"), user = find("user"), assistant = find("assistant");
|
|
191
195
|
return structuredClone({
|
|
192
196
|
turnId: id, user, assistant, thinking, commentary,
|
|
193
197
|
...(system ? { system } : {}), ...(turn.metadata ? { metadata: turn.metadata } : {}),
|
|
194
|
-
messages: [system, user, ...activity,
|
|
198
|
+
messages: [system, user, ...activity, replies.at(-1)].filter(Boolean)
|
|
195
199
|
});
|
|
196
200
|
},
|
|
197
201
|
async appendMessage(id, { turnMetadata, ...message }) {
|
|
@@ -108,11 +108,20 @@ function applyConversationLogPatch(payload = {}, patch = null, options = {}) {
|
|
|
108
108
|
}
|
|
109
109
|
updated[role] = chronologicalConversationActivity([...messages.values()]);
|
|
110
110
|
}
|
|
111
|
+
// A request turn can hold several saved replies (native continuations).
|
|
112
|
+
// Keep each one; the latest reply still closes the message list.
|
|
113
|
+
const replies = new Map();
|
|
114
|
+
for (const message of [existing, patch.turn].flatMap(turn => Array.isArray(turn.messages)
|
|
115
|
+
? turn.messages.filter(message => message?.role === "assistant") : [turn.assistant].filter(Boolean))) {
|
|
116
|
+
replies.set(message.messageId || JSON.stringify(message.at ? [message.at] : ["", message.text]), message);
|
|
117
|
+
}
|
|
118
|
+
if (!replies.size && updated.assistant) replies.set("", updated.assistant);
|
|
119
|
+
const orderedReplies = chronologicalConversationActivity([...replies.values()]);
|
|
111
120
|
updated.messages = [
|
|
112
121
|
updated.system,
|
|
113
122
|
updated.user,
|
|
114
|
-
...chronologicalConversationActivity([...updated.thinking, ...updated.commentary]),
|
|
115
|
-
|
|
123
|
+
...chronologicalConversationActivity([...updated.thinking, ...updated.commentary, ...orderedReplies.slice(0, -1)]),
|
|
124
|
+
orderedReplies.at(-1)
|
|
116
125
|
].filter(Boolean);
|
|
117
126
|
}
|
|
118
127
|
const nextTurns = (existingIndex >= 0
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -422,6 +422,29 @@ test("Claude authoritative task snapshots repair membership and ignore ambient w
|
|
|
422
422
|
assert.equal(turn.read().active, false);
|
|
423
423
|
});
|
|
424
424
|
|
|
425
|
+
test("Claude reports a final reply that waits only for background tasks as the background phase", async () => {
|
|
426
|
+
const { turn, send, events } = fixture();
|
|
427
|
+
const phases = () => events.filter(event => event.type === "phase").map(event => event.phase);
|
|
428
|
+
await send();
|
|
429
|
+
await turn.receive({ type: "system", subtype: "task_started", task_id: "build", task_type: "local_bash" });
|
|
430
|
+
await turn.receive({ type: "system", subtype: "task_started", task_id: "helper", task_type: "local_bash" });
|
|
431
|
+
await turn.receive({ type: "result", subtype: "success", result: "Started" });
|
|
432
|
+
assert.deepEqual([turn.read().active, turn.read().phase], [true, "background"]);
|
|
433
|
+
await turn.receive({ type: "system", subtype: "status", status: null });
|
|
434
|
+
assert.equal(turn.read().phase, "background", "An idle status keeps the background wait");
|
|
435
|
+
await turn.receive({ type: "system", subtype: "task_notification", task_id: "build", status: "completed" });
|
|
436
|
+
assert.equal(turn.read().phase, "", "A finished task means Claude is working on it again");
|
|
437
|
+
await turn.receive({ type: "stream_event", event: { type: "message_start", message: { id: "report" } } });
|
|
438
|
+
await turn.receive({ type: "result", subtype: "success", result: "Report" });
|
|
439
|
+
assert.deepEqual([turn.read().active, turn.read().phase, turn.read().text], [true, "background", "Report"]);
|
|
440
|
+
await turn.receive({ type: "system", subtype: "task_notification", task_id: "helper", status: "killed" });
|
|
441
|
+
await turn.receive({ type: "user", uuid: "notice", origin: { kind: "task-notification" } });
|
|
442
|
+
assert.equal(turn.read().phase, "");
|
|
443
|
+
await turn.receive({ type: "result", subtype: "success", result: "Helper stopped" });
|
|
444
|
+
assert.deepEqual([turn.read().active, turn.read().phase], [false, ""]);
|
|
445
|
+
assert.deepEqual(phases(), ["background", "", "background", ""]);
|
|
446
|
+
});
|
|
447
|
+
|
|
425
448
|
test("Claude can steer background-only work without waiting for an already completed generation", async () => {
|
|
426
449
|
const { turn, send, client } = fixture();
|
|
427
450
|
await send();
|
|
@@ -2338,7 +2361,8 @@ test("bound Main cannot dispatch or retain a pending prompt when its history bud
|
|
|
2338
2361
|
f.binding.transcript.history = async () => [{ id: "earlier", version: "v1", role: "user", text: "Earlier work." }];
|
|
2339
2362
|
await f.binding.state.write({ lastEngine: "codex", engines: { codex: { seen: {} } } });
|
|
2340
2363
|
f.binding.maximumHistoryBytes = async () => { throw new Error("Current native context usage unavailable"); };
|
|
2341
|
-
await assert.rejects(f.conversation.send({ messageId: "no-budget", text: "Continue" }),
|
|
2364
|
+
await assert.rejects(f.conversation.send({ messageId: "no-budget", text: "Continue" }), error =>
|
|
2365
|
+
/Current native context usage unavailable/u.test(error.message) && error.delivery === "not-sent");
|
|
2342
2366
|
assert.equal(f.processes.length, 0);
|
|
2343
2367
|
assert.equal(f.messages.length, 0);
|
|
2344
2368
|
assert.equal((await f.binding.state.read())?.engines?.claude?.pending, undefined);
|
|
@@ -319,3 +319,39 @@ test("reasoning-only histories remain readable without creating phantom message
|
|
|
319
319
|
assert.equal(page.pagination.hasMoreBefore, false);
|
|
320
320
|
assert.equal(page.pagination.nextBeforeTurnId, "");
|
|
321
321
|
});
|
|
322
|
+
|
|
323
|
+
test("native continuation replies in one request turn all stay in saved and patched history", async () => {
|
|
324
|
+
const storage = createMemoryConversationStorage();
|
|
325
|
+
const transcript = createConversationTranscript({ storage });
|
|
326
|
+
const request = await transcript.writeConversationUserMessage("chat", { messageId: "request", text: "Write a report" });
|
|
327
|
+
const write = (role, messageId, outputId, at, text) => transcript[{
|
|
328
|
+
thinking: "writeConversationThinkingMessage", commentary: "writeConversationCommentaryMessage",
|
|
329
|
+
assistant: "writeConversationAssistantMessage"
|
|
330
|
+
}[role]]("chat", { messageId, outputId, at, text, turnId: request.turnId });
|
|
331
|
+
await write("thinking", "t1", "api_a_0", "2026-10-01T01:00:01.000Z", "Planning the query");
|
|
332
|
+
await write("commentary", "c1", "api_a_1", "2026-10-01T01:00:02.000Z", "Running the query");
|
|
333
|
+
// A text-only API message ends one native run while a background task continues.
|
|
334
|
+
await write("assistant", "interim", "api_b_0", "2026-10-01T01:00:03.000Z", "The query is still running.");
|
|
335
|
+
// The task notification continues the same request: thinking and the report share one API message.
|
|
336
|
+
await write("thinking", "t2", "api_c_0", "2026-10-01T01:00:04.000Z", "Time for the report");
|
|
337
|
+
await write("assistant", "report", "api_c_1", "2026-10-01T01:00:05.000Z", "| Group | Share |\n| --- | --- |\n| A | 60% |");
|
|
338
|
+
const beforeFollowUp = await transcript.readConversationLogPage("chat", { limit: 12, presentation: true });
|
|
339
|
+
// An unsolicited later notification adds a short follow-up to the same request turn.
|
|
340
|
+
await write("thinking", "t3", "api_d_0", "2026-10-01T01:00:40.000Z", "That helper was stale");
|
|
341
|
+
const followUp = await write("assistant", "follow-up", "api_d_1", "2026-10-01T01:00:41.000Z", "The stale helper stopped.");
|
|
342
|
+
const ids = turn => turn.messages.map(message => message.messageId);
|
|
343
|
+
const expected = ["request", "t1", "c1", "interim", "t2", "report", "t3", "follow-up"];
|
|
344
|
+
const [saved] = await transcript.readConversationLog("chat", { presentation: true });
|
|
345
|
+
assert.equal(saved.turnId, request.turnId);
|
|
346
|
+
assert.deepEqual(ids(saved), expected, "every saved reply is presented in order, after its own progress");
|
|
347
|
+
assert.equal(saved.assistant.messageId, "interim", "the turn's first reply keeps its existing field identity");
|
|
348
|
+
assert.deepEqual(ids(followUp), expected);
|
|
349
|
+
// A live upsert from the late follow-up must not hide the report already shown.
|
|
350
|
+
const patched = applyConversationLogPatch(beforeFollowUp, { type: "upsert-turn", turn: followUp }, { limit: 12 });
|
|
351
|
+
assert.deepEqual(ids(patched.conversationLog[0]), expected);
|
|
352
|
+
const partial = applyConversationLogPatch(beforeFollowUp, { type: "upsert-turn", turn: {
|
|
353
|
+
turnId: request.turnId, assistant: followUp.messages.at(-1), messages: [followUp.messages.at(-1)]
|
|
354
|
+
} }, { limit: 12 });
|
|
355
|
+
assert.deepEqual(ids(partial.conversationLog[0]).filter(id => ["interim", "report", "follow-up"].includes(id)),
|
|
356
|
+
["interim", "report", "follow-up"], "a partial patch adds its reply without dropping earlier ones");
|
|
357
|
+
});
|