@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.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.234",
11
+ "@jskit-ai/assistant-core": "0.1.236",
12
12
  "@jskit-ai/http-runtime": "0.1.242",
13
13
  "@jskit-ai/kernel": "0.1.244",
14
14
  "@mdi/js": "^7.4.47",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.234",
3
+ "version": "0.1.236",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -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
- if (nextPhase !== phase) {
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: event.phase === "compacting" ? "compacting" : "working" });
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" ? nativeTurn.phase === "compacting" ? "compacting" : "working" : ""),
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
- await access(context, entry.id, request.operation, entry);
446
- signal.throwIfAborted();
447
- // A host may free native context first; show that as compacting.
448
- const bytes = await budget({ ...options, context, signal,
449
- setPhase: phase => setPhase(entry, phase === "compacting" ? "compacting" : "preparing") });
450
- signal.throwIfAborted();
451
- return bytes;
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
- const activity = [...thinking, ...commentary].sort((a, b) => a.at.localeCompare(b.at));
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, assistant].filter(Boolean)
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
- updated.assistant
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
@@ -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" }), /Current native context usage unavailable/u);
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
+ });