@jskit-ai/assistant-core 0.1.233 → 0.1.235

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.233",
11
+ "@jskit-ai/assistant-core": "0.1.235",
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.233",
3
+ "version": "0.1.235",
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) {
@@ -130,8 +130,9 @@ export function createConversationStorage({ readRecord, writeRecord, deleteRecor
130
130
 
131
131
  // Saved records are shared and never changed in place. A transaction copies
132
132
  // the turn map and each turn it changes, then commits that new record.
133
+ // Adapter fields the transaction does not manage are carried unchanged.
133
134
  function transaction(saved) {
134
- const draft = { metadata: saved.metadata, turns: saved.turns };
135
+ const { index: _index, ...draft } = saved;
135
136
  const added = new Map();
136
137
  let ownTurns = false;
137
138
  const owned = new Set();
@@ -185,12 +186,16 @@ export function createConversationStorage({ readRecord, writeRecord, deleteRecor
185
186
  const find = (role) => turn.messages.find((message) => message.role === role) || null;
186
187
  const thinking = turn.messages.filter((message) => message.role === "thinking");
187
188
  const commentary = turn.messages.filter((message) => message.role === "commentary");
188
- 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));
189
194
  const system = find("system"), user = find("user"), assistant = find("assistant");
190
195
  return structuredClone({
191
196
  turnId: id, user, assistant, thinking, commentary,
192
197
  ...(system ? { system } : {}), ...(turn.metadata ? { metadata: turn.metadata } : {}),
193
- messages: [system, user, ...activity, assistant].filter(Boolean)
198
+ messages: [system, user, ...activity, replies.at(-1)].filter(Boolean)
194
199
  });
195
200
  },
196
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();
@@ -81,6 +81,15 @@ test("message adaptation retains progress, attachments, system actions and SQL m
81
81
  assert.equal(turns[2].assistant.sql, "select 1");
82
82
  });
83
83
 
84
+ test("record storage carries adapter fields it does not manage through writes", async () => {
85
+ let record = { turns: new Map(), metadata: {}, rewound: [{ turnId: "000001" }] };
86
+ const storage = createConversationStorage({ readRecord: () => record, writeRecord: (_scope, next) => { record = next; } });
87
+ await storage.write("conversation", transaction => transaction.appendMessage("000002",
88
+ { role: "user", at: "2026-10-01T01:00:00.000Z", text: "Hello", messageId: "m1" }));
89
+ assert.deepEqual(record.rewound, [{ turnId: "000001" }]);
90
+ assert.equal(record.turns.get("000002").messages[0].text, "Hello");
91
+ });
92
+
84
93
  test("record storage preserves original role, timestamp and message-id overwrite identity", async () => {
85
94
  let record = { turns: new Map(), metadata: {} };
86
95
  const storage = createConversationStorage({ readRecord: () => record, writeRecord: (_scope, next) => { record = next; } });
@@ -310,3 +319,39 @@ test("reasoning-only histories remain readable without creating phantom message
310
319
  assert.equal(page.pagination.hasMoreBefore, false);
311
320
  assert.equal(page.pagination.nextBeforeTurnId, "");
312
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
+ });