@vellumai/assistant 0.12.2-dev.202609182214.bd8f92f → 0.12.2-dev.202609182314.c515b72

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609182214.bd8f92f",
3
+ "version": "0.12.2-dev.202609182314.c515b72",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,6 +21,7 @@ import {
21
21
  abortedToolResultText,
22
22
  CANCELLED_TOOL_RESULT_TEXT,
23
23
  createAbortReason,
24
+ INTERRUPTED_TURN_NOTE_TEXT,
24
25
  PREEMPTED_TOOL_RESULT_TEXT,
25
26
  } from "../util/abort-reasons.js";
26
27
 
@@ -378,8 +379,8 @@ describe("interruptRunningTurn", () => {
378
379
 
379
380
  test("arms the interrupted-turn note when the abort caught no tool call", async () => {
380
381
  // The abort landed during the provider call, so there is no `tool_use` to
381
- // answer and nothing in the history says the turn was cut off. The note
382
- // rides on the interrupting user message instead.
382
+ // answer. The note on the interrupting user message is the only thing that
383
+ // tells the model what happened.
383
384
  const turn = registerBusyTurn({
384
385
  messages: [
385
386
  { role: "user", content: [{ type: "text", text: "research this" }] },
@@ -393,9 +394,9 @@ describe("interruptRunningTurn", () => {
393
394
  expect(persisted).toEqual([]);
394
395
  });
395
396
 
396
- test("leaves the note disarmed when the repair answered a tool call", async () => {
397
- // The preempted `tool_result` already tells the model a message cut it
398
- // off, so repeating that in a note on the message is noise.
397
+ test("arms the note alongside the fact-only result of a stopped tool call", async () => {
398
+ // The two texts do different jobs: the `tool_result` states what happened
399
+ // to that one call, the note says what to do about the interruption.
399
400
  const turn = registerBusyTurn({
400
401
  messages: [assistantWithToolUse("tool-1")],
401
402
  });
@@ -404,13 +405,36 @@ describe("interruptRunningTurn", () => {
404
405
 
405
406
  expect(persisted).toHaveLength(1);
406
407
  expect(persisted[0].content).toContain(PREEMPTED_TOOL_RESULT_TEXT);
407
- expect(turn.conversation.pendingInterruptNote).toBe(false);
408
+ expect(turn.conversation.pendingInterruptNote).toBe(true);
409
+ });
410
+
411
+ test("arms exactly one note however many parallel calls were stopped", async () => {
412
+ // Every stopped call gets its own fact-only result; the behavior
413
+ // instruction is read once, off the interrupting message.
414
+ const turn = registerBusyTurn({
415
+ messages: [assistantWithToolUse("tool-1", "tool-2", "tool-3")],
416
+ });
417
+
418
+ await interruptRunningTurn(turn.conversation, { origin: "test" });
419
+
420
+ const results = JSON.parse(persisted[0].content) as Array<{
421
+ tool_use_id: string;
422
+ content: string;
423
+ }>;
424
+ expect(results.map((block) => block.tool_use_id)).toEqual([
425
+ "tool-1",
426
+ "tool-2",
427
+ "tool-3",
428
+ ]);
429
+ for (const block of results) {
430
+ expect(block.content).toBe(PREEMPTED_TOOL_RESULT_TEXT);
431
+ }
432
+ expect(turn.conversation.pendingInterruptNote).toBe(true);
408
433
  });
409
434
 
410
- test("leaves the note disarmed when the loop wrote its own preempted result", async () => {
435
+ test("arms the note when the loop wrote its own preempted result", async () => {
411
436
  // The agent loop's abort handler unwound cleanly and answered the batch
412
- // itself, so the repair finds nothing to do and the notice is already
413
- // there.
437
+ // itself, so the repair finds nothing to do. The note is owed all the same.
414
438
  const turn = registerBusyTurn({
415
439
  messages: [
416
440
  assistantWithToolUse("tool-1"),
@@ -421,12 +445,10 @@ describe("interruptRunningTurn", () => {
421
445
  await interruptRunningTurn(turn.conversation, { origin: "test" });
422
446
 
423
447
  expect(persisted).toEqual([]);
424
- expect(turn.conversation.pendingInterruptNote).toBe(false);
448
+ expect(turn.conversation.pendingInterruptNote).toBe(true);
425
449
  });
426
450
 
427
451
  test("arms the note when the cut-off tool call was an ordinary cancel", async () => {
428
- // A tail answered with the plain cancel wording is a stop the user made
429
- // earlier, not this interrupt's notice, so the note is still owed.
430
452
  const turn = registerBusyTurn({
431
453
  messages: [
432
454
  assistantWithToolUse("tool-1"),
@@ -633,6 +655,9 @@ describe("interruptRunningTurn", () => {
633
655
  // Armed instead, so the drain that runs the queued message repairs it.
634
656
  expect(turn.conversation.pendingInterruptRepair).toBe(true);
635
657
  expect(turn.activityEvents).toEqual([]);
658
+ // The message joins the queue rather than replacing the turn, so it is not
659
+ // the message that interrupted anything and carries no note.
660
+ expect(turn.conversation.pendingInterruptNote).toBe(false);
636
661
  });
637
662
 
638
663
  test("a second interrupt stops the turn the first one started", async () => {
@@ -932,7 +957,7 @@ describe("repairInterruptedToolUseBlocks", () => {
932
957
  } as unknown as Conversation;
933
958
  }
934
959
 
935
- test("writes one result per abandoned call, worded for a preemption", async () => {
960
+ test("writes one fact-only result per abandoned call", async () => {
936
961
  const messages: Message[] = [assistantWithToolUse("tool-1", "tool-2")];
937
962
 
938
963
  await repairInterruptedToolUseBlocks(fakeConversation(messages), {
@@ -961,31 +986,6 @@ describe("repairInterruptedToolUseBlocks", () => {
961
986
  );
962
987
  });
963
988
 
964
- test("reports the preempted result it wrote", async () => {
965
- const messages: Message[] = [assistantWithToolUse("tool-1")];
966
-
967
- const result = await repairInterruptedToolUseBlocks(
968
- fakeConversation(messages),
969
- { force: true },
970
- );
971
-
972
- expect(result.preemptedToolResultOnTail).toBe(true);
973
- });
974
-
975
- test("reports no preempted result on a history that ends on plain text", async () => {
976
- const messages: Message[] = [
977
- { role: "user", content: [{ type: "text", text: "hi" }] },
978
- { role: "assistant", content: [{ type: "text", text: "hello" }] },
979
- ];
980
-
981
- const result = await repairInterruptedToolUseBlocks(
982
- fakeConversation(messages),
983
- { force: true },
984
- );
985
-
986
- expect(result.preemptedToolResultOnTail).toBe(false);
987
- });
988
-
989
989
  test("adds nothing when the loop already wrote its own results", async () => {
990
990
  // The agent loop's abort handler synthesizes results of its own when it
991
991
  // unwinds cleanly, so exactly one result per call exists either way.
@@ -994,14 +994,12 @@ describe("repairInterruptedToolUseBlocks", () => {
994
994
  toolResult("tool-1", PREEMPTED_TOOL_RESULT_TEXT),
995
995
  ];
996
996
 
997
- const result = await repairInterruptedToolUseBlocks(
998
- fakeConversation(messages),
999
- { force: true },
1000
- );
997
+ await repairInterruptedToolUseBlocks(fakeConversation(messages), {
998
+ force: true,
999
+ });
1001
1000
 
1002
1001
  expect(messages).toHaveLength(2);
1003
1002
  expect(persisted).toEqual([]);
1004
- expect(result.preemptedToolResultOnTail).toBe(true);
1005
1003
  });
1006
1004
 
1007
1005
  test("does nothing on a history that ends on an ordinary assistant reply", async () => {
@@ -1083,3 +1081,19 @@ describe("abortedToolResultText", () => {
1083
1081
  );
1084
1082
  });
1085
1083
  });
1084
+
1085
+ describe("the interrupt texts", () => {
1086
+ test("the tool result states the fact and nothing more", () => {
1087
+ // Repeated once per stopped call in a parallel batch, so an instruction
1088
+ // here is read as many times as the batch was wide.
1089
+ expect(PREEMPTED_TOOL_RESULT_TEXT).toBe(
1090
+ "Stopped early because the user sent a new message. This call may have completed anyway; check before repeating it.",
1091
+ );
1092
+ });
1093
+
1094
+ test("the note carries the behavior and leads with the reply", () => {
1095
+ expect(INTERRUPTED_TURN_NOTE_TEXT).toBe(
1096
+ "<interrupted_turn>The user sent this while you were still working, so that work stopped. Reply right away, in a line or two, before any more thinking or tool calls, so the user knows you heard them. Then pick the earlier work back up only if it is still wanted. Never apologize for or mention the interruption.</interrupted_turn>",
1097
+ );
1098
+ });
1099
+ });
@@ -1,10 +1,9 @@
1
1
  /**
2
- * An interrupt whose abort landed during the provider call leaves no
3
- * `tool_use` to answer, so nothing in the history tells the model its turn was
4
- * cut off. The interrupting user message carries the notice instead, on its
5
- * LLM-facing content only: the persisted row stays exactly what the user
6
- * typed, so no client renders it, and `interruptedPriorTurn` in the row's
7
- * metadata is what rebuilds the identical block on every later load.
2
+ * The message that interrupted a turn carries the note saying what to do about
3
+ * the interruption, on its LLM-facing content only: the persisted row stays
4
+ * exactly what the user typed, so no client renders it, and
5
+ * `interruptedPriorTurn` in the row's metadata is what rebuilds the identical
6
+ * block on every later load.
8
7
  */
9
8
  import { beforeEach, describe, expect, test } from "bun:test";
10
9
 
@@ -15,37 +15,6 @@ import type { Conversation } from "./conversation.js";
15
15
 
16
16
  const log = getLogger("conversation-interrupt-repair");
17
17
 
18
- /**
19
- * What the repair leaves behind, for the caller that has to decide whether the
20
- * model was told its turn was cut off.
21
- */
22
- export interface InterruptRepairResult {
23
- /**
24
- * True when the tail of the history carries a preempted `tool_result`,
25
- * whether the agent loop's own abort handler wrote it or this repair did.
26
- *
27
- * That result is the model's notice that a message preempted it. False means
28
- * the abort landed with no tool call in flight, so the notice has to ride on
29
- * the interrupting user message instead.
30
- */
31
- preemptedToolResultOnTail: boolean;
32
- }
33
-
34
- /** Whether the last message answers a tool call with the preemption text. */
35
- function tailCarriesPreemptedToolResult(messages: Message[]): boolean {
36
- const tail = messages.at(-1);
37
- if (!tail || tail.role !== "user") {
38
- return false;
39
- }
40
- return tail.content.some(
41
- (block) =>
42
- // Only a synthetic tool_result carries the preemption text; a
43
- // server-side web_search_tool_result never does.
44
- block.type === "tool_result" &&
45
- block.content === PREEMPTED_TOOL_RESULT_TEXT,
46
- );
47
- }
48
-
49
18
  /**
50
19
  * Give every abandoned `tool_use` block at the tail of the history a
51
20
  * `tool_result`, so the next provider request is well formed.
@@ -78,22 +47,18 @@ function tailCarriesPreemptedToolResult(messages: Message[]): boolean {
78
47
  export async function repairInterruptedToolUseBlocks(
79
48
  conversation: Conversation,
80
49
  options: { force?: boolean; requireDurable?: boolean } = {},
81
- ): Promise<InterruptRepairResult> {
50
+ ): Promise<void> {
82
51
  const wasSteerArmed = conversation.pendingSteerRepair;
83
52
  const wasArmed = conversation.pendingInterruptRepair;
84
53
  if (!wasSteerArmed && !wasArmed && options.force !== true) {
85
- return {
86
- preemptedToolResultOnTail: tailCarriesPreemptedToolResult(
87
- conversation.messages,
88
- ),
89
- };
54
+ return;
90
55
  }
91
56
  conversation.pendingSteerRepair = false;
92
57
  conversation.pendingInterruptRepair = false;
93
58
 
94
59
  const messages = conversation.messages;
95
60
  if (messages.length === 0) {
96
- return { preemptedToolResultOnTail: false };
61
+ return;
97
62
  }
98
63
 
99
64
  // Walk backwards from the tail to find the last assistant message with
@@ -127,10 +92,7 @@ export async function repairInterruptedToolUseBlocks(
127
92
  if (pendingToolUseIds.length === 0) {
128
93
  // Nothing dangling either because the turn made no tool call before the
129
94
  // abort, or because the loop's abort handler already answered the batch.
130
- // Only the second case leaves the model a notice.
131
- return {
132
- preemptedToolResultOnTail: tailCarriesPreemptedToolResult(messages),
133
- };
95
+ return;
134
96
  }
135
97
 
136
98
  log.info(
@@ -181,5 +143,4 @@ export async function repairInterruptedToolUseBlocks(
181
143
  "Failed to persist the synthetic tool_result repair row; the in-memory history carries the repair and the next turn runs on it",
182
144
  );
183
145
  }
184
- return { preemptedToolResultOnTail: true };
185
146
  }
@@ -316,13 +316,11 @@ export async function interruptRunningTurn(
316
316
  }
317
317
 
318
318
  let repaired = false;
319
- let preemptedToolResultOnTail = false;
320
319
  try {
321
- const repair = await repairInterruptedToolUseBlocks(conversation, {
320
+ await repairInterruptedToolUseBlocks(conversation, {
322
321
  force: true,
323
322
  requireDurable: true,
324
323
  });
325
- preemptedToolResultOnTail = repair.preemptedToolResultOnTail;
326
324
  repaired = true;
327
325
  } catch (err) {
328
326
  // The repair row is not durable, so the caller must not write the
@@ -352,14 +350,12 @@ export async function interruptRunningTurn(
352
350
  // busy conversation with nothing running and no path back to idle.
353
351
  conversation.pendingInterruptActivityBridge = true;
354
352
 
355
- // An abort that landed during the provider call answered no tool call, so
356
- // nothing in the history tells the model its turn was cut off. Arm the note
357
- // the interrupting user message carries instead. A tail that already holds a
358
- // preempted `tool_result` needs none: that result says the same thing, and
359
- // saying it twice in one prompt is noise.
360
- if (!preemptedToolResultOnTail) {
361
- conversation.pendingInterruptNote = true;
362
- }
353
+ // Arm the note the interrupting user message carries. It is the one place
354
+ // the behavior after an interrupt is spelled out, so it rides on every
355
+ // handover, tool call stopped or not: a synthetic `tool_result` states only
356
+ // what happened to the one call it answers, and it lands ahead of the user's
357
+ // words rather than on them.
358
+ conversation.pendingInterruptNote = true;
363
359
 
364
360
  return "released";
365
361
  }
@@ -1611,10 +1611,12 @@ export async function persistQueuedMessageBody(
1611
1611
  updateMessageMetadata(persistedUserMessage.id, { attachmentStoredPaths });
1612
1612
  }
1613
1613
 
1614
- // An interrupt whose abort landed before the turn made a tool call left
1615
- // the model no `tool_result` saying it was cut off, so this message
1616
- // carries the notice instead. Consumed here, once: a second message must
1617
- // not repeat a note about a turn it did not interrupt. Stamped after the
1614
+ // This is the first user row after a handover, so it carries the note that
1615
+ // tells the model what to do about the work the handover stopped. The note
1616
+ // goes on this row because that work sits directly above it in the history,
1617
+ // which is also why it is usually the interrupting message and never has to
1618
+ // be the one that armed the flag. Consumed here, once: a later message sits
1619
+ // under a completed turn, where the note would be stale. Stamped after the
1618
1620
  // insert, like the stored paths above, so a persist that never lands
1619
1621
  // leaves the flag armed for the send that replaces it.
1620
1622
  const carriesInterruptNote = ctx.pendingInterruptNote === true;
@@ -796,17 +796,22 @@ export class Conversation {
796
796
  */
797
797
  pendingInterruptActivityBridge = false;
798
798
  /**
799
- * Set by `interruptRunningTurn` when the abort it ran landed with no tool
800
- * call in flight, and consumed exactly once by the persist of the
801
- * interrupting user message, which appends
799
+ * Set by `interruptRunningTurn` on every handover it completes, and consumed
800
+ * exactly once by the first user message persisted after it, which appends
802
801
  * {@link INTERRUPTED_TURN_NOTE_TEXT} to that message's LLM-facing content
803
802
  * and stamps `interruptedPriorTurn` on the row.
804
803
  *
805
- * An interrupt caught mid-tool needs nothing here: the synthetic
806
- * `tool_result` the loop or the repair writes already tells the model a
807
- * message preempted it. Caught mid-provider-call there is no `tool_use` to
808
- * answer, so the note is the only signal, and it rides on the message that
809
- * did the interrupting.
804
+ * The note is the only place the behavior after an interrupt is spelled out.
805
+ * A synthetic `tool_result` states what happened to the one call it answers
806
+ * and nothing more, so an interrupt caught mid-tool arms this too.
807
+ *
808
+ * It belongs to the history position, not to the send that armed it. That
809
+ * first row is the one sitting directly under the work the handover stopped,
810
+ * so it is the row whose note the model reads in the right place. Normally it
811
+ * is the interrupting message itself. When that send loses the lock race and
812
+ * queues, the message that persists first was also sent while the assistant
813
+ * was working, and the note is true of it; the queued one drains after a
814
+ * completed turn, where the same note would be stale.
810
815
  * @internal
811
816
  */
812
817
  pendingInterruptNote = false;
@@ -489,10 +489,10 @@ export const messageMetadataSchema = z
489
489
  */
490
490
  attachmentStoredPaths: z.record(z.string(), z.string()).optional(),
491
491
  /**
492
- * Marks a role-`"user"` row whose arrival interrupted a turn that had made
493
- * no tool call yet. `loadFromDb` rebuilds the LLM-facing
494
- * `<interrupted_turn>` note from it; the row's own content is exactly what
495
- * the user sent, so clients render nothing extra.
492
+ * Marks a role-`"user"` row whose arrival interrupted a running turn.
493
+ * `loadFromDb` rebuilds the LLM-facing `<interrupted_turn>` note from it;
494
+ * the row's own content is exactly what the user sent, so clients render
495
+ * nothing extra.
496
496
  */
497
497
  interruptedPriorTurn: z.boolean().optional(),
498
498
  memoryInjectedBlock: z.string().optional(),
@@ -24,7 +24,7 @@ Queueing remains the fallback, not a legacy path, and every non-`released` outco
24
24
 
25
25
  A repair that fell back to the queue carries its durable requirement onto the drain that runs the queued message, and `pendingInterruptRepair` is what carries it: the drain reads that flag and asks `repairInterruptedToolUseBlocks` for `requireDurable`, so a second persist failure leaves the flag armed and the message queued instead of persisting the prompt behind an unmatched durable `tool_use`. Only a steered drain settles for an in-memory repair, because it has no row of its own to persist behind it.
26
26
 
27
- **The model has to be told its turn was cut off, once.** An interrupt caught mid-tool says so through the synthetic `tool_result` (`PREEMPTED_TOOL_RESULT_TEXT`). An interrupt caught during the provider call has no `tool_use` to answer, so `repairInterruptedToolUseBlocks` reports `preemptedToolResultOnTail: false` and the handover arms `pendingInterruptNote`. The persist of the interrupting user message consumes that flag exactly once: it stamps `interruptedPriorTurn` on the row and appends `INTERRUPTED_TURN_NOTE_TEXT` to the message's LLM-facing content only, so the persisted row stays what the user typed and no client renders the note. `reinjectInterruptTurnNote` rebuilds the identical block at history load, next to the attachment annotations and for the same reason: a reload that rebuilds it differently breaks provider prefix-cache parity. Do not add a second annotation for the same interrupt; one notice per cut-off turn.
27
+ **The two interrupt texts are split by job, and the behavior instruction lives in exactly one of them.** A synthetic `tool_result` (`PREEMPTED_TOOL_RESULT_TEXT`) states the fact about the one call it answers: the call stopped early and may have taken effect anyway. It is repeated once per stopped call in a parallel batch, so it holds no instruction. Every completed handover arms `pendingInterruptNote`, whether or not a tool call was stopped. The persist of the interrupting user message consumes that flag exactly once: it stamps `interruptedPriorTurn` on the row and appends `INTERRUPTED_TURN_NOTE_TEXT` to the message's LLM-facing content only, so the persisted row stays what the user typed and no client renders the note. That note is the single place the behavior after an interrupt is written, it leads with the short acknowledgement the user is waiting for, and it sits after the user's words instead of before them. `reinjectInterruptTurnNote` rebuilds the identical block at history load, next to the attachment annotations and for the same reason: a reload that rebuilds it differently breaks provider prefix-cache parity. Do not add a third annotation, and do not put instructions back in the tool result.
28
28
 
29
29
  Two ordering rules a change here must not break. The synthetic `tool_result` is persisted BEFORE the caller writes the interrupting user row, because a user message between a `tool_use` and its result is a sequence every provider rejects. And the `thinking` / `message_interrupted` activity state is emitted by the agent loop at the head of the replacement turn, never at the handover: activity states are cached in `lastActivityStateMsg` and replayed to reconnecting clients, so a send that fails before its turn starts would otherwise strand every client on a busy conversation that is running nothing.
30
30
 
@@ -78,49 +78,41 @@ export function isUserInterruptAbort(reason: AbortReason | undefined): boolean {
78
78
  */
79
79
  export const CANCELLED_TOOL_RESULT_TEXT = "Cancelled by user";
80
80
 
81
- /**
82
- * The instruction both interrupt annotations end on: answer the message that
83
- * interrupted, then decide what happens to the work it cut off.
84
- *
85
- * Shared so the two annotations cannot drift. Resuming the work inline is the
86
- * trap the last clause closes: the reply the user is waiting for arrives only
87
- * once the resumed work finishes, so an interrupt that was meant to get their
88
- * question answered first buys nothing. A subagent carries the work in
89
- * parallel and leaves the conversation free.
90
- */
91
- const INTERRUPT_PRIORITY_INSTRUCTION =
92
- "reply to it first, then decide whether to resume or abandon the work that was in progress, and if it is still wanted and too big to finish inline, hand it to a subagent so it continues in parallel instead of holding up the conversation. This is the normal way a conversation flows, not a problem: do not apologize, do not mention that anything was stopped or interrupted, and do not ask the user to wait.";
93
-
94
81
  /**
95
82
  * Synthetic `tool_result` text for a tool call a newly arrived user message
96
83
  * cut off.
97
84
  *
85
+ * States the fact about this one call and nothing else. What the model should
86
+ * do about the interruption belongs on the interrupting message
87
+ * ({@link INTERRUPTED_TURN_NOTE_TEXT}), which is read once no matter how many
88
+ * calls the abort stopped and which sits after the user's words rather than
89
+ * before them.
90
+ *
98
91
  * Distinct from {@link CANCELLED_TOOL_RESULT_TEXT} because the model's next
99
92
  * decision is different. A plain cancel ends the work; a preemption means a
100
- * message is waiting, the abandoned call may well have taken effect on the
101
- * outside world before the abort landed, and the model has to answer the new
102
- * message before it decides what to do about the work it was doing.
93
+ * message is waiting, and the abandoned call may well have taken effect on the
94
+ * outside world before the abort landed.
103
95
  */
104
- export const PREEMPTED_TOOL_RESULT_TEXT = `A new message from the user arrived, so this tool call was stopped early. It may still have completed; check before repeating it. Treat the new message as the priority: ${INTERRUPT_PRIORITY_INSTRUCTION}`;
96
+ export const PREEMPTED_TOOL_RESULT_TEXT =
97
+ "Stopped early because the user sent a new message. This call may have completed anyway; check before repeating it.";
105
98
 
106
99
  /**
107
100
  * Annotation appended to the LLM-facing content of the user message that
108
- * interrupted a turn, for the interrupt that landed with no tool call in
109
- * flight.
101
+ * interrupted a turn.
110
102
  *
111
- * A preemption caught mid-tool tells the model what happened through the
112
- * synthetic {@link PREEMPTED_TOOL_RESULT_TEXT} result. An abort that lands
113
- * during the provider call has no `tool_use` to answer, so the history the
114
- * next turn reads is the abandoned turn followed by this message, with nothing
115
- * saying the plan behind it was cut off. Without the note the model answers
116
- * the new message and drops the work it had just committed to.
103
+ * The one place the behavior after an interrupt is spelled out, and it rides
104
+ * on every interrupt, whether or not a tool call was stopped. Leading with the
105
+ * short reply is the point: the user is waiting to learn they were heard, and
106
+ * a turn that opens with more reasoning or another tool call leaves them
107
+ * staring at silence.
117
108
  *
118
109
  * Tagged rather than written as prose so it reads as a system annotation on
119
110
  * the message instead of something the user typed. It rides on the LLM-facing
120
111
  * content only: the persisted row stays exactly what the user sent, so no
121
112
  * client renders it.
122
113
  */
123
- export const INTERRUPTED_TURN_NOTE_TEXT = `<interrupted_turn>This message arrived while the previous turn was still thinking, before it had made any tool call, so that turn ended here. Treat this message as the priority: ${INTERRUPT_PRIORITY_INSTRUCTION}</interrupted_turn>`;
114
+ export const INTERRUPTED_TURN_NOTE_TEXT =
115
+ "<interrupted_turn>The user sent this while you were still working, so that work stopped. Reply right away, in a line or two, before any more thinking or tool calls, so the user knows you heard them. Then pick the earlier work back up only if it is still wanted. Never apologize for or mention the interruption.</interrupted_turn>";
124
116
 
125
117
  /**
126
118
  * The synthetic `tool_result` text that matches why the turn was aborted.