@vellumai/assistant 0.9.0-dev.202606171417.d2192ab → 0.9.0-dev.202606171903.06eea08

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.
Files changed (45) hide show
  1. package/openapi.yaml +99 -3
  2. package/package.json +1 -1
  3. package/src/__tests__/agent-loop-compaction-strip.test.ts +17 -16
  4. package/src/__tests__/app-compiler.test.ts +15 -1
  5. package/src/__tests__/cancel-clears-processing.test.ts +86 -0
  6. package/src/__tests__/compactor-summary-call-truncation.test.ts +223 -0
  7. package/src/__tests__/conversation-agent-loop.test.ts +51 -0
  8. package/src/__tests__/conversation-load-history-repair.test.ts +101 -0
  9. package/src/__tests__/dynamic-page-surface.test.ts +51 -0
  10. package/src/__tests__/plugin-api-provider.test.ts +24 -0
  11. package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +214 -0
  12. package/src/__tests__/task-progress-nudge-hook.test.ts +1 -1
  13. package/src/__tests__/weak-open-model.test.ts +29 -0
  14. package/src/agent/loop.ts +31 -31
  15. package/src/context/compactor.ts +83 -47
  16. package/src/context/strip-injections.ts +58 -10
  17. package/src/daemon/conversation-agent-loop.ts +40 -25
  18. package/src/daemon/conversation-history.ts +1 -1
  19. package/src/daemon/conversation-tool-setup.ts +15 -11
  20. package/src/daemon/conversation.ts +45 -1
  21. package/src/daemon/handlers/conversations.ts +60 -31
  22. package/src/daemon/tool-setup-types.ts +4 -0
  23. package/src/notifications/broadcaster.ts +6 -2
  24. package/src/notifications/guardian-question-mode.ts +10 -0
  25. package/src/notifications/home-feed-side-effect.ts +3 -13
  26. package/src/notifications/notification-utils.ts +2 -1
  27. package/src/notifications/signal.ts +79 -43
  28. package/src/notifications/types.ts +98 -128
  29. package/src/plugin-api/index.ts +23 -0
  30. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +22 -9
  31. package/src/plugins/defaults/memory-retrieval/tail-reinjection-strip.ts +64 -0
  32. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +2 -12
  33. package/src/providers/atlascloud/client.ts +1 -3
  34. package/src/providers/inference/adapter-factory.ts +2 -5
  35. package/src/providers/weak-open-model.ts +22 -0
  36. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  37. package/src/runtime/access-request-helper.ts +2 -5
  38. package/src/runtime/agent-wake.ts +42 -0
  39. package/src/runtime/channel-approval-types.ts +5 -1
  40. package/src/runtime/confirmation-request-guardian-bridge.ts +4 -3
  41. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +1 -2
  42. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +1 -2
  43. package/src/runtime/routes/notification-routes.ts +122 -133
  44. package/src/runtime/tool-grant-request-helper.ts +1 -2
  45. package/src/tools/ui-surface/definitions.ts +25 -2
package/openapi.yaml CHANGED
@@ -17692,6 +17692,7 @@ paths:
17692
17692
  properties:
17693
17693
  deliveryId:
17694
17694
  type: string
17695
+ minLength: 1
17695
17696
  description: Notification delivery ID
17696
17697
  success:
17697
17698
  type: boolean
@@ -17769,9 +17770,100 @@ paths:
17769
17770
  type: boolean
17770
17771
  feedItem:
17771
17772
  type: object
17772
- propertyNames:
17773
- type: string
17774
- additionalProperties: {}
17773
+ properties:
17774
+ id:
17775
+ type: string
17776
+ type:
17777
+ type: string
17778
+ const: notification
17779
+ priority:
17780
+ type: integer
17781
+ minimum: 0
17782
+ maximum: 100
17783
+ title:
17784
+ type: string
17785
+ summary:
17786
+ type: string
17787
+ timestamp:
17788
+ type: string
17789
+ status:
17790
+ default: new
17791
+ type: string
17792
+ enum:
17793
+ - new
17794
+ - seen
17795
+ - acted_on
17796
+ - dismissed
17797
+ expiresAt:
17798
+ type: string
17799
+ actions:
17800
+ type: array
17801
+ items:
17802
+ type: object
17803
+ properties:
17804
+ id:
17805
+ type: string
17806
+ label:
17807
+ type: string
17808
+ prompt:
17809
+ type: string
17810
+ required:
17811
+ - id
17812
+ - label
17813
+ - prompt
17814
+ additionalProperties: false
17815
+ urgency:
17816
+ type: string
17817
+ enum:
17818
+ - low
17819
+ - medium
17820
+ - high
17821
+ - critical
17822
+ conversationId:
17823
+ type: string
17824
+ detailPanel:
17825
+ type: object
17826
+ properties:
17827
+ kind:
17828
+ type: string
17829
+ enum:
17830
+ - emailDraft
17831
+ - documentPreview
17832
+ - permissionChat
17833
+ - paymentAuth
17834
+ - toolPermission
17835
+ - updatesList
17836
+ required:
17837
+ - kind
17838
+ additionalProperties: false
17839
+ category:
17840
+ type: string
17841
+ enum:
17842
+ - security
17843
+ - scheduling
17844
+ - background
17845
+ - email
17846
+ - system
17847
+ noteworthy:
17848
+ type: boolean
17849
+ fromAssistant:
17850
+ type: boolean
17851
+ metadata:
17852
+ type: object
17853
+ propertyNames:
17854
+ type: string
17855
+ additionalProperties: {}
17856
+ createdAt:
17857
+ type: string
17858
+ required:
17859
+ - id
17860
+ - type
17861
+ - priority
17862
+ - summary
17863
+ - timestamp
17864
+ - status
17865
+ - createdAt
17866
+ additionalProperties: false
17775
17867
  channels:
17776
17868
  type: array
17777
17869
  items:
@@ -17826,7 +17918,11 @@ paths:
17826
17918
  - vellum
17827
17919
  - phone
17828
17920
  - telegram
17921
+ - whatsapp
17829
17922
  - slack
17923
+ - email
17924
+ - platform
17925
+ - a2a
17830
17926
  - scheduler
17831
17927
  - watcher
17832
17928
  sourceContextId:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.9.0-dev.202606171417.d2192ab",
3
+ "version": "0.9.0-dev.202606171903.06eea08",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,12 +1,13 @@
1
1
  /**
2
- * The compaction summarizer's input routing.
2
+ * The compaction summarizer's input.
3
3
  *
4
- * Budget-gate compaction summarizes the *injected* history so the summary
5
- * call's prompt prefix matches the agent's warm prefix cache (a cache read
6
- * rather than a fresh cache write). Overflow recovery summarizes the
7
- * injection-*stripped* history because its emergency split keeps an
8
- * un-stripped verbatim prefix that the post-compaction re-injection would
9
- * otherwise double-stack.
4
+ * Both the budget gate and overflow recovery summarize the *injected* history
5
+ * (the genuine conversation as the agent saw it) so the summary call's prompt
6
+ * prefix matches the agent's warm prefix cache — a cache read rather than a
7
+ * fresh cache write. The agent loop hands the full injected history to
8
+ * compaction unconditionally; the post-compaction re-injection hook owns
9
+ * injection idempotency by stripping the tail's per-turn blocks before
10
+ * re-applying them.
10
11
  */
11
12
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
13
 
@@ -46,17 +47,17 @@ const testPostCompactPlugin = {
46
47
  const CONVERSATION_ID = "compaction-strip-conversation";
47
48
 
48
49
  /**
49
- * A runtime `<workspace>` injection block. `stripInjectionsForCompaction`
50
- * matches its full `{ prefix: "<workspace>\n", suffix: "\n</workspace>" }`
51
- * wrapper and drops it from user-message content.
50
+ * A runtime `<workspace>` injection block. Its presence in the summarizer's
51
+ * input proves compaction received the injected history rather than a stripped
52
+ * copy.
52
53
  */
53
54
  const WORKSPACE_INJECTION =
54
55
  "<workspace>\nActive workspace: project-x\n</workspace>";
55
56
  const TURN_BODY = "Hello there, this is the turn body.";
56
57
 
57
58
  /**
58
- * A user turn carrying a real text block plus a runtime injection block.
59
- * Stripping drops the injection block and keeps the real one.
59
+ * A user turn carrying a real text block plus a runtime injection block, both
60
+ * of which ride into compaction's input as-is.
60
61
  */
61
62
  const injectedUserMessage: Message = {
62
63
  role: "user",
@@ -184,7 +185,7 @@ describe("AgentLoop compaction summarizer input", () => {
184
185
  expect(events.some((e) => e.type === "history_stripped")).toBe(true);
185
186
  });
186
187
 
187
- test("overflow-driven compaction summarizes the injection-stripped history", async () => {
188
+ test("overflow-driven compaction summarizes the injected history", async () => {
188
189
  // GIVEN a history whose user turn carries a runtime-injection block
189
190
  const capture: CompactionInputCapture = { budget: null, overflow: null };
190
191
 
@@ -229,10 +230,10 @@ describe("AgentLoop compaction summarizer input", () => {
229
230
  ...installCapturingManager(capture),
230
231
  });
231
232
 
232
- // THEN overflow recovery received the history with the injection removed
233
+ // THEN overflow recovery received the injected history, injection intact
233
234
  expect(capture.overflow).not.toBeNull();
234
- expect(serialize(capture.overflow)).not.toContain("<workspace>");
235
- // AND the real turn body survives the strip
235
+ expect(serialize(capture.overflow)).toContain("<workspace>");
236
+ // AND the real turn body is present alongside the injection
236
237
  expect(serialize(capture.overflow)).toContain(TURN_BODY);
237
238
  // AND the budget summarizer was not invoked
238
239
  expect(capture.budget).toBeNull();
@@ -2,9 +2,17 @@ import { existsSync } from "node:fs";
2
2
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
+ import {
6
+ afterAll,
7
+ beforeAll,
8
+ describe,
9
+ expect,
10
+ setDefaultTimeout,
11
+ test,
12
+ } from "bun:test";
6
13
 
7
14
  import { compileApp } from "../bundler/app-compiler.js";
15
+ import { ensureCompilerTools } from "../bundler/compiler-tools.js";
8
16
  import {
9
17
  ALLOWED_PACKAGES,
10
18
  getCacheDir,
@@ -19,8 +27,14 @@ import {
19
27
 
20
28
  let tempDir: string;
21
29
 
30
+ // Compiler-tool download (esbuild + preact from npm) can take 10s+ on a cold
31
+ // CI cache. Raise the file-level default so beforeAll isn't killed mid-install.
32
+ // Each test file runs in its own process, so this doesn't leak.
33
+ setDefaultTimeout(30_000);
34
+
22
35
  beforeAll(async () => {
23
36
  tempDir = await mkdtemp(join(tmpdir(), "app-compiler-test-"));
37
+ await ensureCompilerTools();
24
38
  });
25
39
 
26
40
  afterAll(async () => {
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Regression test for the stuck "Thinking…" bug: a user cancel must drive the
3
+ * conversation's processing flag to false even when the in-flight turn never
4
+ * observes the AbortController signal (a wedged agent loop that never reaches
5
+ * its `finally`). The processing flag is the authoritative source for the
6
+ * client thinking indicator, so a latched-true flag pins every client on
7
+ * "Thinking…" indefinitely.
8
+ */
9
+ import { afterEach, describe, expect, test } from "bun:test";
10
+
11
+ import type { Conversation } from "../daemon/conversation.js";
12
+ import {
13
+ deleteConversation,
14
+ setConversation,
15
+ } from "../daemon/conversation-registry.js";
16
+ import { cancelGeneration } from "../daemon/handlers/conversations.js";
17
+
18
+ interface WedgedTurnConversation {
19
+ isProcessing: () => boolean;
20
+ setProcessingCalls: () => boolean[];
21
+ abortCount: () => number;
22
+ }
23
+
24
+ /**
25
+ * Register a conversation whose in-flight turn is wedged: it accepts the abort
26
+ * signal but never unwinds, so it leaves the processing flag untouched. This
27
+ * is the exact condition that latches `processing` true in production.
28
+ */
29
+ function registerWedgedTurn(id: string): WedgedTurnConversation {
30
+ let processing = true;
31
+ let abortCount = 0;
32
+ const setProcessingCalls: boolean[] = [];
33
+ const fake = {
34
+ isProcessing: () => processing,
35
+ setProcessing: (value: boolean) => {
36
+ setProcessingCalls.push(value);
37
+ processing = value;
38
+ },
39
+ abort: () => {
40
+ abortCount += 1;
41
+ },
42
+ };
43
+ setConversation(id, fake as unknown as Conversation);
44
+ return {
45
+ isProcessing: () => processing,
46
+ setProcessingCalls: () => setProcessingCalls,
47
+ abortCount: () => abortCount,
48
+ };
49
+ }
50
+
51
+ describe("cancelGeneration", () => {
52
+ const conversationId = "cancel-clears-processing-test-conversation";
53
+
54
+ afterEach(() => {
55
+ deleteConversation(conversationId);
56
+ });
57
+
58
+ test("clears the processing flag when the in-flight turn ignores the abort signal", () => {
59
+ // GIVEN a registered conversation that is processing
60
+ // AND whose in-flight turn ignores the abort signal (never clears processing)
61
+ const fake = registerWedgedTurn(conversationId);
62
+ expect(fake.isProcessing()).toBe(true);
63
+
64
+ // WHEN the user cancels generation
65
+ const cancelled = cancelGeneration(conversationId);
66
+
67
+ // THEN the cancel is acknowledged
68
+ expect(cancelled).toBe(true);
69
+ // AND the abort signal is still raised on the conversation
70
+ expect(fake.abortCount()).toBe(1);
71
+ // AND the processing flag is cleared through setProcessing(false), which
72
+ // publishes the metadata sync invalidation that unblocks every client
73
+ expect(fake.setProcessingCalls()).toContain(false);
74
+ expect(fake.isProcessing()).toBe(false);
75
+ });
76
+
77
+ test("returns false for a conversation that is not registered", () => {
78
+ // GIVEN no conversation registered under the id
79
+
80
+ // WHEN the user cancels generation for the unknown id
81
+ const cancelled = cancelGeneration("cancel-clears-processing-unknown-id");
82
+
83
+ // THEN the cancel reports that nothing was found
84
+ expect(cancelled).toBe(false);
85
+ });
86
+ });
@@ -0,0 +1,223 @@
1
+ /**
2
+ * The compaction summary call bounds its own input to the context window.
3
+ *
4
+ * With no tool pair to anchor an emergency split, an overflow recovery routes
5
+ * the full conversation straight into `runAssistantDrivenCompaction`. If that
6
+ * history exceeds the window, the summary call must front-truncate its own
7
+ * request or it overflows in turn and recovery stalls. Below the window the
8
+ * request is sent untouched so its prefix stays byte-aligned with the agent's
9
+ * warm cache (the budget-path cache reuse must not regress).
10
+ */
11
+ import { describe, expect, mock, test } from "bun:test";
12
+
13
+ function makeLoggerStub(): Record<string, unknown> {
14
+ const stub: Record<string, unknown> = {};
15
+ for (const m of [
16
+ "info",
17
+ "warn",
18
+ "error",
19
+ "debug",
20
+ "trace",
21
+ "fatal",
22
+ "silent",
23
+ "child",
24
+ ]) {
25
+ stub[m] = m === "child" ? () => makeLoggerStub() : () => {};
26
+ }
27
+ return stub;
28
+ }
29
+
30
+ mock.module("../util/logger.js", () => ({
31
+ getLogger: () => makeLoggerStub(),
32
+ }));
33
+
34
+ mock.module("../memory/conversation-crud.js", () => ({
35
+ getMessages: () => [],
36
+ }));
37
+
38
+ mock.module("../memory/attachments-store.js", () => ({
39
+ getAttachmentMetadataForMessage: () => [],
40
+ getAttachmentContent: () => null,
41
+ }));
42
+
43
+ mock.module("../memory/llm-request-log-store.js", () => ({
44
+ recordRequestLog: () => {},
45
+ }));
46
+
47
+ import { runAssistantDrivenCompaction } from "../context/compactor.js";
48
+ import { estimatePromptTokens } from "../context/token-estimator.js";
49
+ import type { Message, Provider } from "../providers/types.js";
50
+
51
+ const TRUNCATION_MARKER = "summary covers only the visible portion";
52
+
53
+ function turnTimestamp(turn: number): string {
54
+ const hour = String(10 + Math.floor(turn / 60)).padStart(2, "0");
55
+ const minute = String(turn % 60).padStart(2, "0");
56
+ return `2026-05-21 (Thursday) ${hour}:${minute}:00 -05:00 (America/Chicago)`;
57
+ }
58
+
59
+ function userTurn(turn: number, body: string): Message {
60
+ return {
61
+ role: "user",
62
+ content: [
63
+ {
64
+ type: "text",
65
+ text: `<turn_context>\ncurrent_time: ${turnTimestamp(
66
+ turn,
67
+ )}\n</turn_context>\n[U${turn}] ${body}`,
68
+ },
69
+ ],
70
+ };
71
+ }
72
+
73
+ function assistantTurn(turn: number, body: string): Message {
74
+ return {
75
+ role: "assistant",
76
+ content: [{ type: "text", text: `[A${turn}] ${body}` }],
77
+ };
78
+ }
79
+
80
+ const SUMMARY = "Earlier turns summarized in the assistant's own voice.";
81
+
82
+ function compactionResponse(tailTurn: number, preview: string): string {
83
+ return `<compaction_result>
84
+ <summary>
85
+ ${SUMMARY}
86
+ </summary>
87
+ <key_state>
88
+ - Nothing critical pending.
89
+ </key_state>
90
+ <tail_start timestamp="${turnTimestamp(tailTurn)}" preview="${preview}" />
91
+ </compaction_result>`;
92
+ }
93
+
94
+ function makeCapturingProvider(response: string): {
95
+ provider: Provider;
96
+ lastRequest: () => Message[] | null;
97
+ } {
98
+ let captured: Message[] | null = null;
99
+ const provider: Provider = {
100
+ name: "mock-provider",
101
+ sendMessage: async (messages: Message[]) => {
102
+ captured = messages;
103
+ return {
104
+ content: [{ type: "text", text: response }],
105
+ model: "mock-model",
106
+ usage: { inputTokens: 100, outputTokens: 50 },
107
+ stopReason: "end_turn",
108
+ };
109
+ },
110
+ };
111
+ return { provider, lastRequest: () => captured };
112
+ }
113
+
114
+ function estimate(messages: Message[]): number {
115
+ return estimatePromptTokens(messages, "system", {
116
+ providerName: "mock-provider",
117
+ });
118
+ }
119
+
120
+ describe("runAssistantDrivenCompaction — summary call self-truncation", () => {
121
+ test("front-truncates the outbound request when the full history exceeds the context window", async () => {
122
+ // GIVEN a tool-pair-free history whose estimate exceeds the window, so an
123
+ // overflow recovery would route it straight into the summary call.
124
+ const messages: Message[] = [];
125
+ for (let i = 0; i < 40; i++) {
126
+ messages.push(
127
+ userTurn(
128
+ i,
129
+ "Heavy user turn body that carries real weight. ".repeat(8),
130
+ ),
131
+ );
132
+ messages.push(
133
+ assistantTurn(
134
+ i,
135
+ "Heavy assistant reply that also carries weight. ".repeat(8),
136
+ ),
137
+ );
138
+ }
139
+
140
+ const maxInputTokens = 10_000;
141
+ // Budget mirrors compactor.compactionPrefixBudget: window minus the
142
+ // instruction reserve (800) and the 15% output reserve.
143
+ const prefixBudget =
144
+ maxInputTokens - 800 - Math.floor(maxInputTokens * 0.15);
145
+ expect(estimate(messages)).toBeGreaterThan(prefixBudget);
146
+
147
+ const { provider, lastRequest } = makeCapturingProvider(
148
+ compactionResponse(38, "Heavy user turn body"),
149
+ );
150
+
151
+ // WHEN the summary call runs against that oversized history.
152
+ const result = await runAssistantDrivenCompaction({
153
+ conversationId: "conv-test",
154
+ messages,
155
+ provider,
156
+ systemPrompt: "system",
157
+ compaction: { enabled: true, autoThreshold: 0.7 },
158
+ maxInputTokens,
159
+ force: true,
160
+ previousEstimatedInputTokens: 90_000,
161
+ });
162
+
163
+ expect(result.compacted).toBe(true);
164
+
165
+ // THEN the request actually sent fits within the context window.
166
+ const sent = lastRequest();
167
+ expect(sent).not.toBeNull();
168
+ expect(estimate(sent ?? [])).toBeLessThan(maxInputTokens);
169
+
170
+ // AND it opens with a marker noting the dropped leading messages so the
171
+ // model knows the summary covers only the visible portion.
172
+ const firstBlock = sent?.[0]?.content[0];
173
+ const firstText = firstBlock && "text" in firstBlock ? firstBlock.text : "";
174
+ expect(firstText).toContain(TRUNCATION_MARKER);
175
+ });
176
+
177
+ test("sends the full history untouched when it already fits below the budget", async () => {
178
+ // GIVEN a small history whose estimate is well under the window — the
179
+ // common budget-triggered compaction.
180
+ const messages: Message[] = [];
181
+ for (let i = 0; i < 8; i++) {
182
+ messages.push(userTurn(i, "short user turn body"));
183
+ messages.push(assistantTurn(i, "short assistant reply body"));
184
+ }
185
+
186
+ const maxInputTokens = 200_000;
187
+ const prefixBudget =
188
+ maxInputTokens - 800 - Math.floor(maxInputTokens * 0.15);
189
+ expect(estimate(messages)).toBeLessThan(prefixBudget);
190
+
191
+ const { provider, lastRequest } = makeCapturingProvider(
192
+ compactionResponse(6, "short user turn body"),
193
+ );
194
+
195
+ // WHEN the summary call runs against the below-budget history.
196
+ const result = await runAssistantDrivenCompaction({
197
+ conversationId: "conv-test",
198
+ messages,
199
+ provider,
200
+ systemPrompt: "system",
201
+ compaction: { enabled: true, autoThreshold: 0.7 },
202
+ maxInputTokens,
203
+ force: true,
204
+ previousEstimatedInputTokens: 90_000,
205
+ });
206
+
207
+ expect(result.compacted).toBe(true);
208
+
209
+ // THEN no truncation marker is prepended and the request carries every
210
+ // history message plus the single instruction, with the leading message
211
+ // byte-identical to the original — keeping the prefix cache warm.
212
+ const sent = lastRequest();
213
+ expect(sent).not.toBeNull();
214
+ expect(sent?.length).toBe(messages.length + 1);
215
+ const firstBlock = sent?.[0]?.content[0];
216
+ const firstText = firstBlock && "text" in firstBlock ? firstBlock.text : "";
217
+ expect(firstText).not.toContain(TRUNCATION_MARKER);
218
+ const originalFirst = messages[0]?.content[0];
219
+ expect(firstText).toBe(
220
+ originalFirst && "text" in originalFirst ? originalFirst.text : "",
221
+ );
222
+ });
223
+ });
@@ -1748,6 +1748,57 @@ describe("session-agent-loop", () => {
1748
1748
  // THEN the queue is drained with the loop-complete reason
1749
1749
  expect(drainReason).toBe("loop_complete");
1750
1750
  });
1751
+
1752
+ test("leaves a newer turn's state intact when superseded during unwind", async () => {
1753
+ // GIVEN a fresh AbortController and request id that a newer turn installs
1754
+ // (as persistUserMessage would once a user cancel released `processing`)
1755
+ const newerTurnController = new AbortController();
1756
+ let drained = false;
1757
+
1758
+ // AND a provider that, on this turn's single model call, simulates that
1759
+ // newer turn taking ownership of the conversation before this turn
1760
+ // reaches its finally
1761
+ const ctxHolder: { conversation?: Conversation } = {};
1762
+ const provider: Provider = {
1763
+ name: "mock-provider",
1764
+ async sendMessage(_messages, options) {
1765
+ const conversation = ctxHolder.conversation;
1766
+ if (conversation) {
1767
+ conversation.abortController = newerTurnController;
1768
+ conversation.setProcessing(true);
1769
+ conversation.currentRequestId = "newer-turn-req";
1770
+ }
1771
+ options?.onEvent?.({ type: "text_delta", text: "done" });
1772
+ return {
1773
+ content: [{ type: "text", text: "done" }],
1774
+ model: "mock-model",
1775
+ usage: { inputTokens: 0, outputTokens: 0 },
1776
+ stopReason: "end_turn",
1777
+ };
1778
+ },
1779
+ };
1780
+ const ctx = makeCtx({
1781
+ loopProvider: provider,
1782
+ drainQueue: async () => {
1783
+ drained = true;
1784
+ },
1785
+ });
1786
+ ctxHolder.conversation = ctx;
1787
+
1788
+ // WHEN the superseded turn runs to completion
1789
+ await runAgentLoopImpl(ctx, "hi", "msg-1", () => {});
1790
+
1791
+ // THEN the finally leaves the newer turn's per-turn state intact so it
1792
+ // stays cancellable and visible
1793
+ expect(ctx.abortController).toBe(newerTurnController);
1794
+ expect(ctx.isProcessing()).toBe(true);
1795
+ expect(ctx.currentRequestId).toBe("newer-turn-req");
1796
+ // AND it does not drain the newer turn's queue out from under it
1797
+ expect(drained).toBe(false);
1798
+ // AND this turn's own finalization that does not touch shared state still
1799
+ // runs (the turn is still counted)
1800
+ expect(ctx.turnCount).toBe(1);
1801
+ });
1751
1802
  });
1752
1803
 
1753
1804
  describe("stale pending surface cleanup", () => {