@jskit-ai/assistant-core 0.1.237 → 0.1.238

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.237",
11
+ "@jskit-ai/assistant-core": "0.1.238",
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.237",
3
+ "version": "0.1.238",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -593,21 +593,18 @@ export function createClaudeConversationOwner({
593
593
  deliveryTiming?.("process-ready");
594
594
  if (command && !command.steering) command.signal.throwIfAborted();
595
595
  const steering = command ? command.steering === true : Boolean(entry.turn?.active);
596
- const sendNow = steering && native.client.supportsSendNow?.() === true;
597
596
  if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
598
597
  if (steering) {
599
598
  if (command) {
600
599
  await command.beforeDispatch({ threadId: entry.id });
601
600
  command.signal.throwIfAborted();
602
601
  }
603
- try {
604
- await entry.nativeTurn.interruptGeneration(command ? entry.process.client : native.client,
605
- { ...(command ? { timeoutMs: command.timeoutMs } : {}), sendNow });
606
- if (command) command.interrupted = !sendNow;
607
- } catch (error) {
602
+ // A mid-turn message is never an interrupt: Claude Code queues it and
603
+ // reads it after the running tool call. Only explicit Stop interrupts.
604
+ try { await entry.nativeTurn.prepareSteering(); }
605
+ catch (error) {
608
606
  if (!command) await entry.nativeTurn.stopProcess(error.message);
609
- // Interruption precedes this input's pipe write. Preserve that definite
610
- // non-delivery so a failed interrupt cannot strand an attempted receipt.
607
+ // This precedes the input's pipe write: a refused steer is definitely not sent.
611
608
  throw Object.assign(error, { delivery: "not-sent" });
612
609
  }
613
610
  }
@@ -688,7 +685,7 @@ export function createClaudeConversationOwner({
688
685
  else await input.onPromptSending?.({ threadId: entry.id, displayAttachments: input.displayAttachments, turnMetadata: actorMetadata });
689
686
  if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
690
687
  await entry.nativeTurn.send(command ? entry.process.client : native.client, command ? preparation.message(entry, input) : prompt,
691
- { messageId: uuid, accept, sendNow, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
688
+ { messageId: uuid, accept, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
692
689
  } catch (error) {
693
690
  if (!command) await entry.nativeTurn.stopProcess(error.message);
694
691
  throw error;
@@ -1026,11 +1023,11 @@ export function createClaudeConversationOwner({
1026
1023
  active.steer = async ({ input, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted, deliveryTiming }) => {
1027
1024
  await ready.promise;
1028
1025
  const nativeDispatch = { active, signal, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted, deliveryTiming,
1029
- timeoutMs, steering: true, interrupted: false, attempted: false, admitted: false };
1026
+ timeoutMs, steering: true, attempted: false, admitted: false };
1030
1027
  try { await dispatch(control, { input, context, signal }, { acquire, entry, dispatch: nativeDispatch }); }
1031
1028
  catch (error) {
1032
1029
  if (nativeDispatch.attempted && !nativeDispatch.admitted && error.delivery !== "not-sent") error.delivery = "uncertain";
1033
- if (nativeDispatch.interrupted || nativeDispatch.attempted) completion.reject(error);
1030
+ if (nativeDispatch.attempted) completion.reject(error);
1034
1031
  throw error;
1035
1032
  }
1036
1033
  };
@@ -1142,6 +1139,9 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1142
1139
  const assistantRoles = new Map();
1143
1140
  const admissions = new Map();
1144
1141
  const inFlight = new Set();
1142
+ // Inputs Claude has begun reading. A queued steer joins the running loop, so
1143
+ // one result can finish several of them; queued-only input stays in flight.
1144
+ const startedInputs = new Set();
1145
1145
  const tasks = new Set();
1146
1146
  let turnId = "";
1147
1147
  let messageId = "";
@@ -1153,7 +1153,6 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1153
1153
  let generation = 0;
1154
1154
  let stopped = false;
1155
1155
  let cancelling = false;
1156
- let steering;
1157
1156
  let maximumOutput = 4 * 1024 * 1024;
1158
1157
  let inputOwner = null;
1159
1158
  let acceptedInput = null;
@@ -1418,11 +1417,13 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1418
1417
  if (frame.type === "user" && !inFlight.has(uuid) && !admissions.has(uuid)) return;
1419
1418
  // Native notifications have their own lifecycle UUIDs, including work
1420
1419
  // folded into this input. They must not steal its receipt/result owner.
1421
- if ((frame.state === "started" || frame.type === "user") && inFlight.has(uuid)) currentCommandId = uuid;
1422
- if (frame.state === "completed") inFlight.delete(uuid);
1423
- if (frame.state === "cancelled" && steering) {
1420
+ if ((frame.state === "started" || frame.type === "user") && inFlight.has(uuid)) {
1421
+ currentCommandId = uuid;
1422
+ startedInputs.add(uuid);
1423
+ }
1424
+ if (frame.state === "completed") {
1424
1425
  inFlight.delete(uuid);
1425
- steering.resolve();
1426
+ startedInputs.delete(uuid);
1426
1427
  }
1427
1428
  if (frame.state === "queued" || frame.type === "user") {
1428
1429
  const pending = admissions.get(uuid);
@@ -1559,10 +1560,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1559
1560
  } : {}) });
1560
1561
  }
1561
1562
  inFlight.delete(currentCommandId);
1562
- if (steering) {
1563
- steering.resolve();
1564
- return;
1565
- }
1563
+ for (const uuid of startedInputs) inFlight.delete(uuid);
1564
+ startedInputs.clear();
1566
1565
  if (cancelling) return;
1567
1566
  outcome = { status: interrupted ? "interrupted" : failed ? "failed" : "completed", error: message };
1568
1567
  // The reply is final, but native background tasks still own the turn
@@ -1601,6 +1600,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1601
1600
  }
1602
1601
  admissions.clear();
1603
1602
  inFlight.clear();
1603
+ startedInputs.clear();
1604
1604
  tasks.clear();
1605
1605
  // Verified process cleanup can precede Claude's terminal generation frame.
1606
1606
  // Retain already displayed text for the existing transcript reader, with
@@ -1616,7 +1616,6 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1616
1616
  }
1617
1617
  pendingAssistantMessages.clear();
1618
1618
  assistantRoles.clear();
1619
- steering?.reject(failure(reason));
1620
1619
  if (active) outcome = { status: "interrupted", error: reason };
1621
1620
  active = false;
1622
1621
  phase = "";
@@ -1670,7 +1669,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1670
1669
  acceptedInput = null;
1671
1670
  clearFinalAssistantResult();
1672
1671
  },
1673
- async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000, sendNow = false } = {}) {
1672
+ async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000 } = {}) {
1674
1673
  if (!active || stopped || cancelling) throw Object.assign(
1675
1674
  failure("Claude has no active turn for this message.", !active && !stopped && !cancelling
1676
1675
  ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
@@ -1691,7 +1690,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1691
1690
  )), timeoutMs);
1692
1691
  admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
1693
1692
  try {
1694
- await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId, ...(sendNow ? { sendNow: true } : {}) }), admitted.promise]);
1693
+ await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId }), admitted.promise]);
1695
1694
  } catch (error) {
1696
1695
  if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
1697
1696
  throw error;
@@ -1699,49 +1698,22 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1699
1698
  clearTimeout(timer);
1700
1699
  }
1701
1700
  },
1702
- async interruptGeneration(client, { timeoutMs = 30_000, sendNow = false } = {}) {
1703
- if (!active || steering || cancelling) {
1704
- const completed = !active && !steering && !stopped && !cancelling && outcome?.status === "completed";
1701
+ async prepareSteering() {
1702
+ if (!active || cancelling) {
1703
+ const completed = !active && !stopped && !cancelling && outcome?.status === "completed";
1705
1704
  throw Object.assign(failure("This Claude turn cannot accept steering now.", completed
1706
1705
  ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
1707
1706
  { delivery: "not-sent", ...(completed ? { statusCode: 409 } : {}) });
1708
1707
  }
1709
- // Targeted Send now admits the new input before asking the native loop to
1710
- // make way; unlike plain Stop it preserves background agents. A completed
1711
- // main loop also has no further interruption result to wait for.
1712
- if (sendNow || outcome && inFlight.size === 0) {
1713
- // The next input owns completion now. A task finishing during host
1714
- // preparation must not settle this turn using the previous result.
1715
- outcome = null;
1716
- acceptedInput = null;
1717
- clearFinalAssistantResult();
1718
- await publish({ type: "diagnostic", stage: sendNow ? "steering_send_now" : "steering_background_only", backgroundTasks: tasks.size });
1719
- return;
1720
- }
1721
- // Streaming user input otherwise queues behind the entire running turn.
1722
- // Claude's supported steering path interrupts generation, then continues
1723
- // the same native history with the new instruction.
1708
+ // Claude Code acknowledges streamed input as queued and folds it into the
1709
+ // running turn after the current tool call, or starts it once the main
1710
+ // loop is idle. Interrupting instead would kill a running foreground tool.
1711
+ // The next input owns completion now: a result or task finishing before
1712
+ // it starts must not settle this turn with the previous answer.
1713
+ outcome = null;
1724
1714
  acceptedInput = null;
1725
1715
  clearFinalAssistantResult();
1726
- steering = Promise.withResolvers();
1727
- let timer;
1728
- try {
1729
- await publish({ type: "diagnostic", stage: "steering_requested", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1730
- await Promise.all([client.interrupt(), Promise.race([
1731
- steering.promise,
1732
- new Promise((_, reject) => {
1733
- timer = setTimeout(() => reject(Object.assign(failure(
1734
- "Claude did not confirm the interrupted generation. This instruction was not sent; retry after reconnecting.",
1735
- "assistant_claude_interrupt_timeout"
1736
- ), { statusCode: 503 })), timeoutMs);
1737
- })
1738
- ])]);
1739
- await publish({ type: "diagnostic", stage: "steering_confirmed", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1740
- } catch (error) {
1741
- await publish({ type: "diagnostic", stage: "steering_failed", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1742
- throw error;
1743
- }
1744
- finally { clearTimeout(timer); steering = null; }
1716
+ await publish({ type: "diagnostic", stage: "steering_queued", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1745
1717
  },
1746
1718
  async cancel(client, stopProcess) {
1747
1719
  try {
@@ -472,7 +472,7 @@ test("Claude reports common work and compaction phases and clears phase after se
472
472
  assert.deepEqual(phases, ["preparing", "working", "compacting", "working", ""]);
473
473
  });
474
474
 
475
- test("Claude steering interrupts generation and admits a distinct message without replacing its process", { timeout: 30_000 }, async t => {
475
+ test("Claude steering queues a distinct message on the running process without interrupting it", { timeout: 30_000 }, async t => {
476
476
  const f = await fixture(t);
477
477
  const started = Promise.withResolvers();
478
478
  const events = [];
@@ -493,7 +493,7 @@ test("Claude steering interrupts generation and admits a distinct message withou
493
493
  const trace = await f.trace();
494
494
  assert.equal(trace.filter(row => row.args?.includes("--print")).length, 1);
495
495
  assert.equal(trace.filter(row => row.frame?.type === "user").length, 2);
496
- assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length, 1);
496
+ assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length, 0, "Steering queues behind running work and never interrupts it");
497
497
  assert.equal((await f.conversation.send(steering)).duplicate, true);
498
498
  for (const turn of state.conversationLog) assert.equal(turn.assistant.messageId,
499
499
  events.find(event => event.role === "assistant" && event.status === "complete" && event.turnId === turn.turnId).messageId);
@@ -1193,7 +1193,7 @@ test("Claude accepts a goal during an active turn through the existing steering
1193
1193
  assert.equal(trace.filter(row => row.args?.includes("--print")).length, 1);
1194
1194
  assert.deepEqual(trace.filter(row => row.frame?.type === "user").map(row => row.frame.message.content),
1195
1195
  ["wait", "/goal Finish the current work"]);
1196
- assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length, 1);
1196
+ assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length, 0, "Steering queues behind running work and never interrupts it");
1197
1197
  });
1198
1198
 
1199
1199
  test("Claude rejects reserved goals and waits for ordinary Send to deliver a replacement briefing", async t => {
@@ -2290,7 +2290,7 @@ test("completed Claude native-tool tracking preserves ordinary native steering i
2290
2290
  const trace = await f.trace();
2291
2291
  assert.equal(trace.filter(row => row.args?.includes("--print")).length, 1);
2292
2292
  assert.equal(trace.filter(row => row.frame?.type === "user").length, 2);
2293
- assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length, 1);
2293
+ assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length, 0, "Steering queues behind running work and never interrupts it");
2294
2294
  assert.equal(f.effects(), 0);
2295
2295
  } finally { await f.close(); }
2296
2296
  });
@@ -163,30 +163,64 @@ test("a pipe write is not admission; queued and replayed acknowledgements persis
163
163
  turn.stop();
164
164
  });
165
165
 
166
- test("steering waits for the generation's terminal event after the control acknowledgement", async () => {
166
+ for (const lifecycle of [true, false]) test(`steering queues behind the running tool without an interrupt and one joined result completes both inputs, lifecycle=${lifecycle}`, async () => {
167
167
  const { turn, send, client, events } = fixture();
168
- await send();
169
- let interrupted = false;
170
- const steering = turn.interruptGeneration(client).then(() => { interrupted = true; });
171
- await Promise.resolve();
172
- assert.equal(interrupted, false);
173
- await turn.receive({ type: "result", subtype: "success", terminal_reason: "aborted_streaming", result: "" });
168
+ const first = claudeNativeMessageId("first");
169
+ const followUp = claudeNativeMessageId("follow-up");
170
+ const writes = [];
171
+ let interrupts = 0;
172
+ client.interrupt = async () => { interrupts++; };
173
+ // Claude Code 2.1.307 acknowledges streamed input as queued at once and
174
+ // replays it only when its loop reads it; older CLIs only replay it.
175
+ client.send = async (_message, input) => {
176
+ writes.push(input);
177
+ if (lifecycle) await turn.receive({ type: "command_lifecycle", command_uuid: input.messageId, state: "queued" });
178
+ };
179
+ const sending = send();
180
+ if (lifecycle) await turn.receive({ type: "command_lifecycle", command_uuid: first, state: "started" });
181
+ await turn.receive({ type: "user", uuid: first });
182
+ await sending;
183
+ await turn.receive({ type: "assistant", uuid: "tool-call", message: { id: "tool-api", stop_reason: "tool_use",
184
+ content: [{ type: "tool_use", id: "deploy", name: "Bash", input: { command: "eb deploy" } }] } });
185
+ await turn.prepareSteering();
186
+ // Without lifecycle frames the replay is the only receipt, so it waits for the tool.
187
+ const steering = send("follow-up");
188
+ if (lifecycle) await steering;
189
+ assert.equal(interrupts, 0, "A mid-turn message never interrupts the running tool");
190
+ assert.deepEqual(writes.map(input => Object.keys(input).sort()), [["messageId", "sessionId"], ["messageId", "sessionId"]],
191
+ "The follow-up is a plain user message, never Send now");
192
+ assert.equal(turn.read().active, true);
193
+ // Captured order: the tool finishes, Claude reads the queued input inside
194
+ // the same loop, answers both, then completes the queued and original input.
195
+ await turn.receive({ type: "user", uuid: "tool-result", message: { content: [{ type: "tool_result", tool_use_id: "deploy", content: "Deployed" }] } });
196
+ await turn.receive({ type: "user", uuid: followUp });
174
197
  await steering;
175
- assert.equal(events.some(event => event.type === "settled"), false);
176
- await send("follow-up");
177
- await turn.receive({ type: "result", subtype: "success", result: "Revised answer" });
178
- assert.equal(turn.read().text, "Revised answer");
179
- assert.equal(turn.read().status, "completed");
198
+ if (lifecycle) await turn.receive({ type: "command_lifecycle", command_uuid: followUp, state: "started" });
199
+ await turn.receive({ type: "assistant", uuid: "answer", message: { id: "answer-api", stop_reason: "end_turn",
200
+ content: [{ type: "text", text: "Deployed; still on track." }] } });
201
+ if (lifecycle) {
202
+ await turn.receive({ type: "command_lifecycle", command_uuid: followUp, state: "completed" });
203
+ assert.equal(turn.read().active, true, "The queued input's completion cannot settle the running loop");
204
+ }
205
+ await turn.receive({ type: "result", subtype: "success", terminal_reason: "completed", result: "Deployed; still on track." });
206
+ if (lifecycle) await turn.receive({ type: "command_lifecycle", command_uuid: first, state: "completed" });
207
+ assert.deepEqual([turn.read().active, turn.read().status, turn.read().text, turn.read().pendingCommands],
208
+ [false, "completed", "Deployed; still on track.", 0]);
209
+ assert.equal(events.filter(event => event.type === "settled").length, 1);
210
+ const answer = events.findLast(event => event.type === "message" && event.message.complete && event.message.role === "assistant");
211
+ assert.equal(answer.message.userId, followUp, "The joined answer belongs to the newest accepted input");
212
+ assert.equal(interrupts, 0);
180
213
  });
181
214
 
182
- test("interrupted Claude output keeps its input identity after a follow-up is accepted", async t => {
215
+ test("Claude output before a queued follow-up keeps its input identity after the follow-up is accepted", async t => {
183
216
  const f = await publishedClaudeFixture(t);
184
217
  await f.owner.send(f.entry, { messageId: "first", message: "First request" });
185
218
  const native = f.processes.at(-1);
186
219
  await native.options.onEvent({ type: "stream_event", event: { type: "message_start", message: { id: "first-api" } } });
187
220
  await native.options.onEvent({ type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } });
188
221
  await native.options.onEvent({ type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { text: "FIRST-PARTIAL" } } });
189
- await native.options.onEvent({ type: "assistant", uuid: "first-output", message: { id: "first-api", content: [{ type: "text", text: "FIRST-PARTIAL" }] } });
222
+ await native.options.onEvent({ type: "assistant", uuid: "first-output", message: { id: "first-api", stop_reason: "tool_use", content: [{ type: "text", text: "FIRST-PARTIAL" }] } });
223
+ native.client.interrupt = async () => assert.fail("A follow-up is queued, never an interrupt");
190
224
  await f.owner.send(f.entry, { messageId: "second", message: "Second request" });
191
225
  await native.options.onEvent({ type: "assistant", uuid: "second-output", message: { id: "second-api", content: [{ type: "text", text: "SECOND-OK" }], stop_reason: "end_turn" } });
192
226
  await native.options.onEvent({ type: "result", subtype: "success", result: "SECOND-OK" });
@@ -337,22 +371,34 @@ for (const background of [false, true]) test(`folded native command cannot stran
337
371
  assert.equal(events.filter(event => event.type === "settled").length, 1);
338
372
  });
339
373
 
340
- test("folded lifecycle completion cannot acknowledge interruption before the generation result", async () => {
341
- const { turn, send, client } = fixture();
374
+ test("a steer queued during the final reply runs next; neither that result nor folded work settles it", async () => {
375
+ const { turn, send, client, events } = fixture();
376
+ client.interrupt = async () => assert.fail("A mid-turn message never interrupts");
377
+ client.send = async (_message, input) => {
378
+ await turn.receive({ type: "command_lifecycle", command_uuid: input.messageId, state: "queued" });
379
+ };
380
+ const first = claudeNativeMessageId("first");
381
+ const goal = claudeNativeMessageId("goal");
342
382
  await send();
383
+ await turn.receive({ type: "command_lifecycle", command_uuid: first, state: "started" });
384
+ await turn.receive({ type: "user", uuid: first });
343
385
  await turn.receive({ type: "command_lifecycle", command_uuid: "internal-notification", state: "started" });
344
- let confirmed = false;
345
- const steering = turn.interruptGeneration(client).then(() => { confirmed = true; });
346
386
  await turn.receive({ type: "command_lifecycle", command_uuid: "internal-notification", state: "completed" });
347
- await Promise.resolve();
348
- assert.equal(confirmed, false);
349
- await turn.receive({ type: "result", subtype: "success", terminal_reason: "aborted_streaming", result: "" });
350
- await steering;
387
+ await turn.receive({ type: "assistant", uuid: "story", message: { id: "story-api", stop_reason: "end_turn", content: [{ type: "text", text: "Story" }] } });
388
+ await turn.prepareSteering();
351
389
  await send("goal");
352
- await turn.receive({ type: "result", subtype: "success", result: "Goal accepted" });
353
- await turn.receive({ type: "command_lifecycle", command_uuid: claudeNativeMessageId("goal"), state: "completed" });
390
+ // Captured 2.1.307 order when input arrives after the last model request.
391
+ await turn.receive({ type: "result", subtype: "success", terminal_reason: "completed", result: "Story" });
392
+ await turn.receive({ type: "command_lifecycle", command_uuid: first, state: "completed" });
393
+ assert.equal(turn.read().active, true, "The queued input still needs its own run");
394
+ await turn.receive({ type: "command_lifecycle", command_uuid: goal, state: "started" });
395
+ await turn.receive({ type: "user", uuid: goal });
396
+ await turn.receive({ type: "assistant", uuid: "goal-answer", message: { id: "goal-api", stop_reason: "end_turn", content: [{ type: "text", text: "Goal accepted" }] } });
397
+ await turn.receive({ type: "result", subtype: "success", terminal_reason: "completed", result: "Goal accepted" });
398
+ await turn.receive({ type: "command_lifecycle", command_uuid: goal, state: "completed" });
354
399
  assert.equal(turn.read().pendingCommands, 0);
355
- assert.equal(turn.read().status, "completed");
400
+ assert.deepEqual([turn.read().status, turn.read().text], ["completed", "Goal accepted"]);
401
+ assert.equal(events.filter(event => event.type === "settled").length, 1);
356
402
  });
357
403
 
358
404
  test("late input completion cannot settle a newer native continuation", async () => {
@@ -451,7 +497,7 @@ test("Claude can steer background-only work without waiting for an already compl
451
497
  await turn.receive({ type: "system", subtype: "task_started", task_id: "build", task_type: "local_bash" });
452
498
  await turn.receive({ type: "result", subtype: "success", result: "Build started" });
453
499
  client.interrupt = () => assert.fail("There is no active generation to interrupt");
454
- await turn.interruptGeneration(client, { timeoutMs: 5 });
500
+ await turn.prepareSteering();
455
501
  await send("follow-up");
456
502
  await turn.receive({ type: "system", subtype: "task_notification", task_id: "build", status: "completed" });
457
503
  assert.equal(turn.read().active, true, "The new human input still needs its own result");
@@ -483,7 +529,7 @@ test("Claude steering survives background completion while the follow-up is bein
483
529
  await turn.receive({ type: "system", subtype: "task_started", task_id: "build", task_type: "local_bash" });
484
530
  await turn.receive({ type: "result", subtype: "success", result: "Build started" });
485
531
  client.interrupt = () => assert.fail("The generation has already completed");
486
- await turn.interruptGeneration(client);
532
+ await turn.prepareSteering();
487
533
  await turn.receive({ type: "system", subtype: "task_notification", task_id: "build", status: "completed" });
488
534
  assert.equal(events.some(event => event.type === "settled"), false, "The reserved steering input still needs admission");
489
535
  await send("follow-up");
@@ -1504,24 +1550,20 @@ for (const tools of [true, false]) test(`Claude supplied common runtime checkpoi
1504
1550
  if (tools) f.admissions.at(-1).assertCompletionCurrent();
1505
1551
  });
1506
1552
 
1507
- for (const sendNow of [false, true]) test(`Claude supplied common completion joins accepted steering and checkpoints only its own final, sendNow=${sendNow}`, { timeout: 10_000 }, async t => {
1553
+ for (const worker of [false, true]) test(`Claude supplied common completion joins accepted steering and checkpoints only its own final, worker=${worker}`, { timeout: 10_000 }, async t => {
1508
1554
  const f = await suppliedCommonFixture(t);
1509
1555
  await sendCommon(f, "common-first");
1510
- if (sendNow) {
1511
- const native = f.entry.process;
1512
- const send = native.client.send;
1513
- native.client.supportsSendNow = () => true;
1514
- native.client.interrupt = async () => { throw Error("Steering must preserve the background worker"); };
1515
- await f.frame({ type: "system", subtype: "task_started", task_id: "common-worker" });
1516
- native.client.send = async (message, input) => {
1517
- assert.equal(input.sendNow, true);
1518
- await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "queued" });
1519
- await f.frame({ type: "result", subtype: "error_during_execution", is_error: true,
1520
- terminal_reason: "aborted_tools", user_message_uuid: claudeNativeMessageId("common-first") });
1521
- await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "started" });
1522
- await send(message, input);
1523
- };
1524
- }
1556
+ const native = f.entry.process;
1557
+ const send = native.client.send;
1558
+ native.client.supportsSendNow = () => true;
1559
+ native.client.interrupt = async () => { throw Error("Steering must not interrupt running tools or workers"); };
1560
+ if (worker) await f.frame({ type: "system", subtype: "task_started", task_id: "common-worker" });
1561
+ native.client.send = async (message, input) => {
1562
+ assert.equal(input.sendNow, undefined, "Steering is a plain queued user message");
1563
+ await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "queued" });
1564
+ await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "started" });
1565
+ await send(message, input);
1566
+ };
1525
1567
  await f.loadContract("first-contract");
1526
1568
  const first = f.admissions.at(-1);
1527
1569
  const legacyCompletion = f.entry.completion.promise;
@@ -1535,7 +1577,7 @@ for (const sendNow of [false, true]) test(`Claude supplied common completion joi
1535
1577
  await f.loadContract("successor-contract");
1536
1578
  assert.throws(() => first.assertCompletionCurrent(), { code: "conversation_tool_request_retired" });
1537
1579
  await f.frame({ type: "result", subtype: "success", result: "The successor question", uuid: "successor-final" });
1538
- if (sendNow) {
1580
+ if (worker) {
1539
1581
  assert.equal(f.checkpoints.length, 0, "Foreground completion cannot checkpoint live background work");
1540
1582
  await f.frame({ type: "system", subtype: "task_notification", task_id: "common-worker", status: "completed" });
1541
1583
  assert.equal(f.checkpoints.length, 0);
@@ -1758,25 +1800,18 @@ test("only natural completion marks a rejected steer eligible for ordinary Send;
1758
1800
  }
1759
1801
  });
1760
1802
 
1761
- test("interruption before a late steer distinguishes completed work from Stop, cancellation and idle state", async () => {
1762
- for (const ending of ["complete", "stop", "cancel", "idle", "steering"]) {
1803
+ test("a late steer distinguishes completed work from Stop, cancellation and idle state", async () => {
1804
+ for (const ending of ["complete", "stop", "cancel", "idle"]) {
1763
1805
  const f = fixture();
1764
1806
  await f.send();
1765
- let firstInterrupt;
1766
1807
  if (ending === "complete" || ending === "idle") {
1767
1808
  await f.turn.receive({ type: "result", subtype: "success", result: "Done" });
1768
1809
  if (ending === "idle") f.turn.reset();
1769
1810
  } else if (ending === "stop") f.turn.stop();
1770
- else if (ending === "cancel") await f.turn.interrupt(f.client);
1771
- else {
1772
- const began = Promise.withResolvers();
1773
- f.client.interrupt = async () => { began.resolve(); };
1774
- firstInterrupt = f.turn.interruptGeneration(f.client).catch(() => {});
1775
- await began.promise;
1776
- }
1811
+ else await f.turn.interrupt(f.client);
1777
1812
  let interrupts = 0;
1778
1813
  f.client.interrupt = async () => { interrupts += 1; };
1779
- await assert.rejects(f.turn.interruptGeneration(f.client), error => {
1814
+ await assert.rejects(f.turn.prepareSteering(), error => {
1780
1815
  assert.equal(error.delivery, "not-sent");
1781
1816
  assert.equal(error.code, ending === "complete" ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed");
1782
1817
  assert.equal(error.statusCode, ending === "complete" ? 409 : undefined);
@@ -1784,10 +1819,23 @@ test("interruption before a late steer distinguishes completed work from Stop, c
1784
1819
  });
1785
1820
  assert.equal(interrupts, 0);
1786
1821
  f.turn.stop();
1787
- await firstInterrupt;
1788
1822
  }
1789
1823
  });
1790
1824
 
1825
+ test("consecutive steers queue together without waiting for or sending an interrupt", async () => {
1826
+ const { turn, send, client, events } = fixture();
1827
+ client.interrupt = async () => assert.fail("Steering never interrupts");
1828
+ await send();
1829
+ for (const id of ["second", "third"]) {
1830
+ await turn.prepareSteering();
1831
+ await send(id);
1832
+ }
1833
+ assert.equal(turn.read().pendingCommands, 3);
1834
+ await turn.receive({ type: "result", subtype: "success", result: "All three handled" });
1835
+ assert.deepEqual([turn.read().status, turn.read().pendingCommands], ["completed", 0]);
1836
+ assert.deepEqual(events.filter(event => event.type === "diagnostic" && event.stage === "steering_queued").length, 2);
1837
+ });
1838
+
1791
1839
  test("folded internal command preserves the original published answer owner", async t => {
1792
1840
  const f = await publishedClaudeFixture(t);
1793
1841
  await f.send();
@@ -1800,28 +1848,21 @@ test("folded internal command preserves the original published answer owner", as
1800
1848
  });
1801
1849
 
1802
1850
 
1803
- test("failed interruption reports definite non-delivery after the original process cleanup", async t => {
1851
+ test("a steer never depends on the interrupt control: an unresponsive interrupt cannot fail or stop it", async t => {
1804
1852
  const f = await publishedClaudeFixture(t);
1805
1853
  await f.send("original-input");
1806
- let writes = 0;
1807
- f.entry.process.client.send = async () => { writes++; };
1808
- f.entry.process.client.interrupt = async () => { throw new Error("Interrupt was not acknowledged"); };
1809
- await assert.rejects(f.send("goal-input"), error => error.delivery === "not-sent" &&
1810
- error.message === "Interrupt was not acknowledged");
1811
- assert.equal(writes, 0);
1812
- assert.equal(f.entry.process, null, "The original stop owner still proves cleanup before returning");
1813
- });
1814
-
1815
- test("interrupt confirmation timeout is a recoverable typed failure, not a generic server error", async () => {
1816
- const { turn, send, client } = fixture();
1817
- await send();
1818
- await assert.rejects(turn.interruptGeneration(client, { timeoutMs: 5 }), error =>
1819
- error.code === "assistant_claude_interrupt_timeout" && error.statusCode === 503 && /not sent/.test(error.message));
1820
- turn.stop();
1854
+ const native = f.entry.process;
1855
+ let interrupts = 0;
1856
+ native.client.interrupt = async () => { interrupts++; throw new Error("Interrupt was not acknowledged"); };
1857
+ const receipt = await f.send("steer-input");
1858
+ assert.deepEqual([receipt.delivered, receipt.deliveryMode], [true, "steer"]);
1859
+ assert.equal(interrupts, 0);
1860
+ assert.equal(f.entry.process, native, "Steering keeps the running process and its tools");
1861
+ assert.equal(f.entry.nativeTurn.read().active, true);
1821
1862
  });
1822
1863
 
1823
1864
 
1824
- test("failed cleanup after interrupt failure retains the cleanup error and execution custody", async t => {
1865
+ test("failed Stop cleanup after interrupt failure retains the cleanup error and execution custody", async t => {
1825
1866
  const f = await publishedClaudeFixture(t);
1826
1867
  await f.send("original-input");
1827
1868
  const native = f.entry.process;
@@ -1829,8 +1870,7 @@ test("failed cleanup after interrupt failure retains the cleanup error and execu
1829
1870
  native.client.interrupt = async () => { throw new Error("Interrupt was not acknowledged"); };
1830
1871
  native.stop = async () => ({ scopeEmpty: false, exited: false });
1831
1872
  try {
1832
- await assert.rejects(f.send("goal-input"), error => error.code === "claude_stop_unconfirmed" &&
1833
- error.delivery !== "not-sent");
1873
+ await assert.rejects(f.owner.interrupt(f.entry), error => error.code === "claude_stop_unconfirmed");
1834
1874
  assert.equal(f.entry.process, native);
1835
1875
  assert.ok(f.entry.executionId);
1836
1876
  } finally { native.stop = originalStop; }
@@ -2194,7 +2234,7 @@ test("Claude goal command proof survives only evaluations of its own condition",
2194
2234
  assert.equal(changed.goalCommandId, null, "an old command cannot prove a different goal");
2195
2235
  });
2196
2236
 
2197
- for (const action of ["steer", "set", "resume"]) test(`Claude ${action} uses targeted Send now without cancelling background workers`, async t => {
2237
+ for (const action of ["steer", "set", "resume"]) test(`Claude ${action} queues a plain message without Stop or Send now, preserving the running tool and background workers`, async t => {
2198
2238
  const f = await publishedClaudeFixture(t);
2199
2239
  if (action === "resume") await f.owner.updateGoal(f.entry, { action: "set", objective: "Finish checks", messageId: "first" });
2200
2240
  else await f.send("first");
@@ -2205,13 +2245,11 @@ for (const action of ["steer", "set", "resume"]) test(`Claude ${action} uses tar
2205
2245
  let stops = 0;
2206
2246
  native.client.interrupt = async () => { stops++; throw Error("A steer must not send Stop"); };
2207
2247
  native.client.send = async (message, input) => {
2208
- assert.equal(input.sendNow, true);
2209
- // Native 2.1.289 may end the foreground generation before consuming the
2210
- // queued goal. Its background task remains alive throughout this sequence.
2248
+ assert.equal(input.sendNow, undefined, "An advertised Send now capability is still not used for steering");
2249
+ // Claude 2.1.307 acknowledges the input as queued and reads it after the
2250
+ // running tool call. Its background task remains alive throughout.
2211
2251
  await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "queued" });
2212
- await f.frame({ type: "result", subtype: "error_during_execution", is_error: true,
2213
- terminal_reason: "aborted_tools", user_message_uuid: claudeNativeMessageId("first") });
2214
- assert.equal(f.entry.nativeTurn.read().active, true, "Old result cannot finish a queued input or its worker");
2252
+ assert.equal(f.entry.nativeTurn.read().active, true, "Queued input keeps the running turn and its worker");
2215
2253
  await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "started" });
2216
2254
  await send(message, input);
2217
2255
  };