@vellumai/assistant 0.12.6-dev.202609292019.a6f6d85 → 0.12.6-dev.202609292116.41f7caa

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 (39) hide show
  1. package/ARCHITECTURE.md +3 -3
  2. package/package.json +1 -1
  3. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  4. package/src/__tests__/openai-provider.test.ts +27 -0
  5. package/src/__tests__/skill-execute-input.test.ts +11 -0
  6. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  7. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  8. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  9. package/src/agent/loop.ts +133 -12
  10. package/src/calls/__tests__/call-controller.test.ts +273 -1
  11. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  12. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  13. package/src/calls/call-constants.ts +10 -0
  14. package/src/calls/call-controller.ts +359 -25
  15. package/src/calls/media-stream-server.ts +18 -0
  16. package/src/calls/media-stream-stt-session.ts +30 -9
  17. package/src/cli/lib/bundled-marketplace.json +13 -0
  18. package/src/config/feature-flag-registry.json +8 -0
  19. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  20. package/src/daemon/conversation-agent-loop.ts +3 -0
  21. package/src/daemon/conversation-messaging.ts +4 -0
  22. package/src/daemon/conversation-process.ts +15 -0
  23. package/src/daemon/conversation-queue-manager.ts +2 -0
  24. package/src/daemon/conversation.ts +2 -0
  25. package/src/daemon/handlers/shared.ts +13 -2
  26. package/src/daemon/process-message.ts +5 -0
  27. package/src/i18n/__tests__/i18n.test.ts +25 -0
  28. package/src/i18n/assistant-text.ts +18 -0
  29. package/src/i18n/index.ts +1 -0
  30. package/src/i18n/messages.ts +11 -0
  31. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  32. package/src/providers/openai/chat-completions-provider.ts +10 -1
  33. package/src/providers/unparseable-tool-args.ts +29 -5
  34. package/src/runtime/channel-reply-delivery.ts +2 -0
  35. package/src/runtime/http-types.ts +3 -0
  36. package/src/runtime/routes/conversation-routes.ts +7 -0
  37. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  38. package/src/tools/skills/execute.ts +10 -7
  39. package/src/tools/tool-approval-handler.ts +9 -1
package/ARCHITECTURE.md CHANGED
@@ -618,12 +618,12 @@ Every phone call connects over Twilio Media Streams: the voice webhook emits `<C
618
618
 
619
619
  Transcription mode is selected once per session in `media-stream-stt-session.ts`:
620
620
 
621
- - **Streaming** (default): when `calls.voice.telephonyStreaming` is enabled and the `telephony` role resolves a streaming transcriber (`resolveStreamingTranscriber({ role: "telephony" })`), inbound audio is decoded (mu-law → PCM16, resampled 8 kHz → 16 kHz) and fed to the provider's realtime adapter. Replies trigger only on utterance-boundary finals (for Deepgram, `speech_final`/`UtteranceEnd`, never mid-sentence `is_final` segments), and barge-in fires from local energy VAD, never from transcriber partials. While something is interruptible (an assistant turn in flight, thinking or speaking, or a completed turn's tail still playing from Twilio's buffer), caller speech arms the shared sustained-speech barge-in guard (`src/calls/barge-in-guard.ts`, the same accounting live voice uses: speech accumulates toward 250 ms, short gaps are tolerated, a run that is mostly silence resets) and every inbound frame feeds it; outside that window the guard is dropped, so the caller's own utterance never carries into a turn that starts before the local VAD ends it. Only a fired guard reaches `CallController.handleBargeIn`, which interrupts a turn in either phase and ignores an idle controller (the playing tail is cleared instead).
621
+ - **Streaming** (default): when `calls.voice.telephonyStreaming` is enabled and the `telephony` role resolves a streaming transcriber (`resolveStreamingTranscriber({ role: "telephony" })`), inbound audio is decoded (mu-law → PCM16, resampled 8 kHz → 16 kHz) and fed to the provider's realtime adapter. Replies trigger only on utterance-boundary finals (for Deepgram, `speech_final`/`UtteranceEnd`, never mid-sentence `is_final` segments), and barge-in fires from local energy VAD, never from transcriber partials. Interim `partial` events are forwarded to the controller as `handleCallerPartial`: they commit nothing and start no turn, they are never persisted and never reach a setup flow, and they exist so a boundary the front door held mid-thought can be replayed on the caller's newest words rather than only the ones the provider has finalized. While something is interruptible (an assistant turn in flight, thinking or speaking, or a completed turn's tail still playing from Twilio's buffer), caller speech arms the shared sustained-speech barge-in guard (`src/calls/barge-in-guard.ts`, the same accounting live voice uses: speech accumulates toward 250 ms, short gaps are tolerated, a run that is mostly silence resets) and every inbound frame feeds it; outside that window the guard is dropped, so the caller's own utterance never carries into a turn that starts before the local VAD ends it. Only a fired guard reaches `CallController.handleBargeIn`, which interrupts a turn in either phase and ignores an idle controller (the playing tail is cleared instead).
622
622
  - **Batch fallback**: otherwise the session segments turns with the energy-based `MediaTurnDetector` and transcribes each completed turn via the same role's batch API. Both halves of a call read the `telephony` role, which is why a role names its consumer rather than a boundary.
623
623
 
624
- Every phone turn runs the same two-leg triage as live voice through `startVoiceTurn` (`src/calls/voice-session-bridge.ts`): `call-controller.ts` opens on a toolless front-door leg (`routingLeg: "front-door"`, the `voiceFrontDoor` call site) and drives it through the shared `createFrontDoorLegCoordinator` (`src/calls/voice-leg-coordinator.ts`), which reads the stream through the verdict machine and sequences the hand-off (pause narration, abort the leg, resolve and speak the bridge, mark it as the floor holder, start the escalated leg pinned to the conversation's own model, re-arm narration); each driver supplies only a host for how text and the bridge are spoken, how a leg is started or aborted, and (live voice only) the speculative hold and commit. Phone has no partial transcripts, so the hold verdict is never taught and routing is escalate-only. The controller also passes the bridge's turn callbacks (tool activity is recorded as `tool_use_started` / `tool_use_completed` call events, persisted row ids ride the `assistant_spoke` event), `launchedAtMs` for dispatch timing, and a `voiceTelemetry` bag keyed by the call session with a `phone_inbound` / `phone_outbound` entry. Both drivers share the spoken progress narration cadence (`src/calls/voice-progress-cadence.ts`, tuned by `voice.frontModel.progress`): the cadence owns the tool-activity log, the triggers (an ops burst, a long operation completing, a full interval of audible silence with news, the `maxSilenceMs` heartbeat) and the generated or static phrase, while each driver supplies its own view of audible silence (live voice from its TTS queue and playback-tail estimate; the media-stream transport from `isPlaybackIdle()` and a running sum of sent frame durations) and how to speak a phrase.
624
+ Every phone turn runs the same two-leg triage as live voice through `startVoiceTurn` (`src/calls/voice-session-bridge.ts`): `call-controller.ts` opens on a toolless front-door leg (`routingLeg: "front-door"`, the `voiceFrontDoor` call site) and drives it through the shared `createFrontDoorLegCoordinator` (`src/calls/voice-leg-coordinator.ts`), which reads the stream through the verdict machine and sequences the hand-off (pause narration, abort the leg, resolve and speak the bridge, mark it as the floor holder, start the escalated leg pinned to the conversation's own model, re-arm narration); each driver supplies only a host for how text and the bridge are spoken, how a leg is started or aborted, and (live voice only) the speculative hold and commit. A leg that carries the caller's own words is also the turn's endpoint decision, so it is taught the hold verdict: silence alone cannot tell a finished sentence from a thinking pause, and answering a fragment makes the rest of the sentence read as a barge-in that interrupts the assistant's own reply. On a hold the leg is discarded (its persisted user row rolled back through the handle's `discard`), the floor goes straight back to the caller, and the words are carried into their next final so the whole sentence is judged together. If no next words arrive, a replay timer (`voice.frontModel.endpointExtensionMs`) answers the sentence as it stands; any partial re-arms that timer and the next final cancels it. A partial also stretches the wait to at least `calls.voice.utteranceEndMs` plus a margin, because the provider still owes a final for those words and that final is the better boundary: racing it would ask the same words twice, once on the interim guess and once on the committed text. Reaching the timer with a partial outstanding therefore means no final is coming, so the interim text rides along as the only record of what the caller said. `voice.frontModel.endpointMaxExtensions` caps consecutive holds per sentence, after which the leg must answer. Openers, instructions, and other assistant-initiated turns are never dispatched that way. The controller also passes the bridge's turn callbacks (tool activity is recorded as `tool_use_started` / `tool_use_completed` call events, persisted row ids ride the `assistant_spoke` event), `launchedAtMs` for dispatch timing, and a `voiceTelemetry` bag keyed by the call session with a `phone_inbound` / `phone_outbound` entry. Both drivers share the spoken progress narration cadence (`src/calls/voice-progress-cadence.ts`, tuned by `voice.frontModel.progress`): the cadence owns the tool-activity log, the triggers (an ops burst, a long operation completing, a full interval of audible silence with news, the `maxSilenceMs` heartbeat) and the generated or static phrase, while each driver supplies its own view of audible silence (live voice from its TTS queue and playback-tail estimate; the media-stream transport from `isPlaybackIdle()` and a running sum of sent frame durations) and how to speak a phrase.
625
625
 
626
- Both drivers also share the per-turn latency marks (`src/calls/voice-metrics.ts`). The controller opens a turn's marks when it dispatches, seeded with when the caller's transcript arrived, and stamps the leg dispatch, the first assistant delta, the first audible audio, a barge-in, and how the turn settled. Live voice streams a snapshot to its client on every mark; a call has no client on the line, so the aggregate is logged once per turn, cancelled turns included. Each call also records a `phone_call_started` / `phone_call_ended` pair on the onboarding telemetry substrate (`src/telemetry/phone-call-funnel.ts`), written by the call store at session creation and at the single terminal status transition: duration is the gap between the two rows, and a call that took no caller turn carries why on the end row (`silent_no_connect` when it never connected, `silent_no_turn` when it did and nobody spoke).
626
+ Both drivers also share the per-turn latency marks (`src/calls/voice-metrics.ts`). The controller opens a turn's marks when it dispatches, seeded with when the caller's transcript arrived and when the provider first had interim words for that same utterance, and stamps the leg dispatch, the first assistant delta, the first audible audio, a barge-in, an endpoint hold, and how the turn settled. A held turn settles as `endpoint_hold` and hands both seeds to the turn that finally answers, so the caller's wait is still measured from the moment they stopped talking. Live voice streams a snapshot to its client on every mark; a call has no client on the line, so the aggregate is logged once per turn, cancelled turns included. Each call also records a `phone_call_started` / `phone_call_ended` pair on the onboarding telemetry substrate (`src/telemetry/phone-call-funnel.ts`), written by the call store at session creation and at the single terminal status transition: duration is the gap between the two rows, and a call that took no caller turn carries why on the end row (`silent_no_connect` when it never connected, `silent_no_turn` when it did and nobody spoke).
627
627
 
628
628
  A credential preflight (`resolveTelephonyCredentialReadiness()` in `src/calls/telephony-credential-preflight.ts`) gates every call: it requires a credentialed, telephony-capable STT provider **and** a media-stream-playable TTS provider (the configured one or a credentialed playable fallback). Inbound calls that fail the preflight receive `<Say>` setup-required copy plus `<Hangup/>` instead of a doomed stream; outbound placement fails before dialing via `preflightVoiceIngress()` with the same user-facing message.
629
629
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.6-dev.202609292019.a6f6d85",
3
+ "version": "0.12.6-dev.202609292116.41f7caa",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { beforeEach, describe, expect, test } from "bun:test";
10
10
 
11
+ import { MESSAGE_KEYS } from "../i18n/index.js";
11
12
  import { setConfig } from "./helpers/set-config.js";
12
13
 
13
14
  // Keep the memory system off so addMessage skips indexing side effects.
@@ -31,6 +32,7 @@ function resetTables() {
31
32
 
32
33
  interface MessagePayload {
33
34
  role: string;
35
+ textSegments?: string[];
34
36
  clientMessageId?: string;
35
37
  }
36
38
 
@@ -59,6 +61,47 @@ describe("handleListMessages clientMessageId", () => {
59
61
  expect(body.messages[0].clientMessageId).toBe("nonce-abc");
60
62
  });
61
63
 
64
+ test("localizes persisted assistant keys without rewriting legacy text", async () => {
65
+ const localizedConv = createConversation();
66
+ await addMessage(
67
+ localizedConv.id,
68
+ "assistant",
69
+ JSON.stringify([
70
+ {
71
+ type: "text",
72
+ text: MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP,
73
+ },
74
+ ]),
75
+ { skipIndexing: true },
76
+ );
77
+
78
+ const localizedResponse = await handleListMessages({
79
+ queryParams: { conversationId: localizedConv.id },
80
+ headers: { "accept-language": "es" },
81
+ });
82
+ const localizedBody = localizedResponse as { messages: MessagePayload[] };
83
+ expect(localizedBody.messages[0]?.textSegments).toEqual([
84
+ "Me detuve después de varias llamadas de herramienta con formato incorrecto para evitar que esta conversación creciera indefinidamente. Inténtalo de nuevo o cambia de modelo si el problema continúa.",
85
+ ]);
86
+
87
+ const legacyConv = createConversation();
88
+ const legacy =
89
+ "I stopped after repeated malformed tool calls to keep this conversation from growing indefinitely. Please try again, or switch models if the problem continues.";
90
+ await addMessage(
91
+ legacyConv.id,
92
+ "assistant",
93
+ JSON.stringify([{ type: "text", text: legacy }]),
94
+ { skipIndexing: true },
95
+ );
96
+
97
+ const legacyResponse = await handleListMessages({
98
+ queryParams: { conversationId: legacyConv.id },
99
+ headers: { "accept-language": "es" },
100
+ });
101
+ const legacyBody = legacyResponse as { messages: MessagePayload[] };
102
+ expect(legacyBody.messages[0]?.textSegments).toEqual([legacy]);
103
+ });
104
+
62
105
  test("omits clientMessageId when the row was persisted without one", async () => {
63
106
  // GIVEN a user message persisted without a client nonce (e.g. a channel send)
64
107
  const conv = createConversation();
@@ -1178,6 +1178,33 @@ describe("OpenAIProvider", () => {
1178
1178
  });
1179
1179
  });
1180
1180
 
1181
+ test("bounds malformed arguments in the diagnostic raw response only", async () => {
1182
+ const raw = '{"content":"' + "x".repeat(1_000);
1183
+ fakeChunks = [
1184
+ ...toolCallChunks([{ id: "call_large_bad", name: "test", args: raw }]),
1185
+ usageChunk(10, 5),
1186
+ ];
1187
+
1188
+ const result = await provider.sendMessage([userMsg("test")]);
1189
+
1190
+ expect(result.content[0]).toEqual({
1191
+ type: "tool_use",
1192
+ id: "call_large_bad",
1193
+ name: "test",
1194
+ input: { _raw: raw },
1195
+ });
1196
+ const rawResponse = result.rawResponse as {
1197
+ choices: Array<{
1198
+ message: {
1199
+ tool_calls: Array<{ function: { arguments: string } }>;
1200
+ };
1201
+ }>;
1202
+ };
1203
+ expect(
1204
+ rawResponse.choices[0]!.message.tool_calls[0]!.function.arguments,
1205
+ ).toBe(raw.slice(0, 200) + "…");
1206
+ });
1207
+
1181
1208
  // -----------------------------------------------------------------------
1182
1209
  // stream_options and model
1183
1210
  // -----------------------------------------------------------------------
@@ -188,6 +188,17 @@ describe("recoverSkillExecuteEnvelope", () => {
188
188
  expect(recoverSkillExecuteEnvelope(wrapped)).toBe(wrapped);
189
189
  });
190
190
 
191
+ test("keeps recovered JSON wrapped when it lacks a string tool name", () => {
192
+ for (const raw of [
193
+ JSON.stringify({ input: { content: "hi" }, activity: "x" }),
194
+ JSON.stringify({ tool: 42, input: {}, activity: "x" }),
195
+ JSON.stringify(["document_update"]),
196
+ ]) {
197
+ const wrapped = { _raw: raw };
198
+ expect(recoverSkillExecuteEnvelope(wrapped)).toBe(wrapped);
199
+ }
200
+ });
201
+
191
202
  test("passes a normal envelope through untouched", () => {
192
203
  const envelope = {
193
204
  tool: "document_update",
@@ -1357,6 +1357,10 @@ describe("ToolApprovalHandler / unparseable tool args gate", () => {
1357
1357
  }
1358
1358
  expect(result.result.content).not.toContain(raw);
1359
1359
  expect(result.result.content).toContain("…");
1360
+ expect(auditCalls.error).toHaveLength(1);
1361
+ expect(auditCalls.error[0].input).toEqual({
1362
+ _raw: raw.slice(0, 200) + "…",
1363
+ });
1360
1364
  });
1361
1365
 
1362
1366
  test("legitimate input containing a _raw field among others is not rejected", async () => {
@@ -341,6 +341,32 @@ describe("tool preview lifecycle", () => {
341
341
  expect((emitted as any).conversationId).toBe("test-session-id");
342
342
  });
343
343
 
344
+ test("caps cumulative app preview payloads and stops forwarding growth", () => {
345
+ const collector = createEventCollector();
346
+ const deps = createMockDeps({ onEvent: collector.onEvent });
347
+ const accumulatedJson = '{"code":"' + "x".repeat(300_000);
348
+
349
+ handleInputJsonDelta(state, deps, {
350
+ type: "input_json_delta",
351
+ toolName: "app_create",
352
+ toolUseId: "toolu_large_app",
353
+ accumulatedJson,
354
+ });
355
+ handleInputJsonDelta(state, deps, {
356
+ type: "input_json_delta",
357
+ toolName: "app_create",
358
+ toolUseId: "toolu_large_app",
359
+ accumulatedJson: accumulatedJson + "more",
360
+ });
361
+
362
+ expect(collector.events).toHaveLength(1);
363
+ const emitted = collector.events[0] as Extract<
364
+ AssistantEvent,
365
+ { type: "tool_input_delta" }
366
+ >;
367
+ expect(emitted.content).toBe(accumulatedJson.slice(0, 256_000));
368
+ });
369
+
344
370
  test("a streaming ui_show visual announces its surface exactly once", () => {
345
371
  const collector = createEventCollector();
346
372
  const deps = createMockDeps({ onEvent: collector.onEvent });
@@ -0,0 +1,333 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import { createMockProvider } from "../__tests__/helpers/mock-provider.js";
4
+ import { MESSAGE_CATALOGS, MESSAGE_KEYS } from "../i18n/index.js";
5
+ import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
6
+ import type {
7
+ ContentBlock,
8
+ Message,
9
+ ProviderResponse,
10
+ } from "../providers/types.js";
11
+ import { wrapUnparseableToolArgs } from "../providers/unparseable-tool-args.js";
12
+ import { type AgentEvent, AgentLoop } from "./loop.js";
13
+
14
+ const userMessage: Message = {
15
+ role: "user",
16
+ content: [{ type: "text", text: "Write the file" }],
17
+ };
18
+
19
+ function malformedToolResponse(
20
+ id: string,
21
+ raw: string,
22
+ options: { name?: string; text?: string } = {},
23
+ ): ProviderResponse {
24
+ return {
25
+ content: [
26
+ ...(options.text ? [{ type: "text" as const, text: options.text }] : []),
27
+ {
28
+ type: "tool_use",
29
+ id,
30
+ name: options.name ?? "file_write",
31
+ input: wrapUnparseableToolArgs(raw),
32
+ },
33
+ ],
34
+ model: "mock-model",
35
+ usage: { inputTokens: 1, outputTokens: 1 },
36
+ stopReason: "tool_use",
37
+ };
38
+ }
39
+
40
+ function toolBlocks(
41
+ history: Message[],
42
+ type: "tool_use" | "tool_result",
43
+ ): ContentBlock[] {
44
+ return history
45
+ .flatMap((message) => message.content)
46
+ .filter((block) => {
47
+ return block.type === type;
48
+ });
49
+ }
50
+
51
+ describe("AgentLoop: unparseable tool retries", () => {
52
+ beforeEach(() => {
53
+ resetPluginRegistryAndRegisterDefaults();
54
+ });
55
+
56
+ test("bounds persisted input and stops after three consecutive malformed turns", async () => {
57
+ const raw =
58
+ '{"path":"/workspace/file.md","content":"' + "x".repeat(2_000_000);
59
+ const { provider, calls } = createMockProvider([
60
+ malformedToolResponse("bad-1", raw),
61
+ malformedToolResponse("bad-2", raw),
62
+ malformedToolResponse("bad-3", raw, { text: "Still trying." }),
63
+ malformedToolResponse("bad-4", raw),
64
+ ]);
65
+ const executionInputs: Record<string, unknown>[] = [];
66
+ const events: AgentEvent[] = [];
67
+ const loop = new AgentLoop({
68
+ provider,
69
+ systemPrompt: "system",
70
+ conversationId: "conversation-123",
71
+ tools: [
72
+ {
73
+ name: "file_write",
74
+ description: "",
75
+ input_schema: { type: "object" },
76
+ },
77
+ ],
78
+ toolExecutor: async (_name, input) => {
79
+ executionInputs.push(input);
80
+ return { content: "invalid JSON", isError: true };
81
+ },
82
+ });
83
+
84
+ const result = await loop.run({
85
+ requestId: "request-123",
86
+ messages: [userMessage],
87
+ onEvent: (event) => {
88
+ events.push(event);
89
+ },
90
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
91
+ });
92
+
93
+ expect(calls).toHaveLength(3);
94
+ expect(executionInputs).toHaveLength(2);
95
+ expect(executionInputs[0]).toEqual(wrapUnparseableToolArgs(raw));
96
+ expect(toolBlocks(result.history, "tool_use")).toHaveLength(2);
97
+ expect(toolBlocks(result.history, "tool_result")).toHaveLength(2);
98
+
99
+ const serializedHistory = JSON.stringify(result.history);
100
+ expect(serializedHistory.length).toBeLessThan(5_000);
101
+ expect(serializedHistory).not.toContain("x".repeat(1_000));
102
+ expect(result.history.at(-1)?.content).toEqual([
103
+ { type: "text", text: "Still trying." },
104
+ {
105
+ type: "text",
106
+ text: `\n\n${MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP}`,
107
+ },
108
+ ]);
109
+ expect(events.filter((event) => event.type === "tool_use")).toHaveLength(2);
110
+ expect(
111
+ events
112
+ .filter((event) => event.type === "text_delta")
113
+ .map((event) => event.text)
114
+ .join(""),
115
+ ).toEndWith(
116
+ `Still trying.\n\n${
117
+ MESSAGE_CATALOGS.en[MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP]
118
+ }`,
119
+ );
120
+ });
121
+
122
+ test("surfaces only the stop notice when assistant text is tool-gated", async () => {
123
+ const raw = "{" + "x".repeat(1_000);
124
+ const { provider } = createMockProvider([
125
+ malformedToolResponse("bad-1", raw, { text: "Private notes one." }),
126
+ malformedToolResponse("bad-2", raw, { text: "Private notes two." }),
127
+ malformedToolResponse("bad-3", raw, { text: "Private notes three." }),
128
+ ]);
129
+ const events: AgentEvent[] = [];
130
+ const loop = new AgentLoop({
131
+ provider,
132
+ systemPrompt: "system",
133
+ conversationId: "conversation-gated",
134
+ tools: [
135
+ {
136
+ name: "file_write",
137
+ description: "",
138
+ input_schema: { type: "object" },
139
+ },
140
+ ],
141
+ toolExecutor: async () => ({ content: "invalid JSON", isError: true }),
142
+ });
143
+
144
+ const result = await loop.run({
145
+ requestId: "request-gated",
146
+ messages: [userMessage],
147
+ onEvent: (event) => {
148
+ events.push(event);
149
+ },
150
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
151
+ suppressAssistantText: true,
152
+ });
153
+
154
+ expect(result.history.at(-1)?.content).toEqual([
155
+ {
156
+ type: "text",
157
+ text: MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP,
158
+ },
159
+ ]);
160
+ expect(
161
+ events
162
+ .filter((event) => event.type === "text_delta")
163
+ .map((event) => event.text)
164
+ .join(""),
165
+ ).toBe(
166
+ MESSAGE_CATALOGS.en[MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP],
167
+ );
168
+ const finalMessage = events
169
+ .filter((event) => event.type === "message_complete")
170
+ .at(-1);
171
+ expect(finalMessage?.assistantTextVisibility).toBe("visible");
172
+ });
173
+
174
+ test("streams the localized stop notice while persisting its key", async () => {
175
+ const raw = "{" + "x".repeat(1_000);
176
+ const { provider } = createMockProvider([
177
+ malformedToolResponse("bad-1", raw),
178
+ malformedToolResponse("bad-2", raw),
179
+ malformedToolResponse("bad-3", raw),
180
+ ]);
181
+ const events: AgentEvent[] = [];
182
+ const loop = new AgentLoop({
183
+ provider,
184
+ systemPrompt: "system",
185
+ conversationId: "conversation-spanish",
186
+ tools: [
187
+ {
188
+ name: "file_write",
189
+ description: "",
190
+ input_schema: { type: "object" },
191
+ },
192
+ ],
193
+ toolExecutor: async () => ({ content: "invalid JSON", isError: true }),
194
+ });
195
+
196
+ const result = await loop.run({
197
+ requestId: "request-spanish",
198
+ messages: [userMessage],
199
+ onEvent: (event) => {
200
+ events.push(event);
201
+ },
202
+ locale: "es",
203
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
204
+ });
205
+
206
+ expect(result.history.at(-1)?.content).toEqual([
207
+ {
208
+ type: "text",
209
+ text: MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP,
210
+ },
211
+ ]);
212
+ expect(
213
+ events
214
+ .filter((event) => event.type === "text_delta")
215
+ .map((event) => event.text)
216
+ .join(""),
217
+ ).toBe(
218
+ MESSAGE_CATALOGS.es[MESSAGE_KEYS.AGENT_LOOP_UNPARSEABLE_TOOL_RETRY_STOP],
219
+ );
220
+ });
221
+
222
+ test("does not count skill_execute envelopes recovered from valid outer JSON", async () => {
223
+ const recoverableRaw = JSON.stringify({
224
+ tool: "document_update",
225
+ input: "body",
226
+ activity: "Updating the document",
227
+ });
228
+ const malformedRaw = "{" + "x".repeat(1_000);
229
+ const done: ProviderResponse = {
230
+ content: [{ type: "text", text: "Done" }],
231
+ model: "mock-model",
232
+ usage: { inputTokens: 1, outputTokens: 1 },
233
+ stopReason: "end_turn",
234
+ };
235
+ const { provider, calls } = createMockProvider([
236
+ malformedToolResponse("recover-1", recoverableRaw, {
237
+ name: "skill_execute",
238
+ }),
239
+ malformedToolResponse("bad-1", malformedRaw),
240
+ malformedToolResponse("recover-2", recoverableRaw, {
241
+ name: "skill_execute",
242
+ }),
243
+ malformedToolResponse("bad-2", malformedRaw),
244
+ done,
245
+ ]);
246
+ const loop = new AgentLoop({
247
+ provider,
248
+ systemPrompt: "system",
249
+ conversationId: "conversation-789",
250
+ tools: [
251
+ {
252
+ name: "skill_execute",
253
+ description: "",
254
+ input_schema: { type: "object" },
255
+ },
256
+ {
257
+ name: "file_write",
258
+ description: "",
259
+ input_schema: { type: "object" },
260
+ },
261
+ ],
262
+ toolExecutor: async () => ({ content: "result", isError: false }),
263
+ });
264
+
265
+ const result = await loop.run({
266
+ requestId: "request-789",
267
+ messages: [userMessage],
268
+ onEvent: () => {},
269
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
270
+ });
271
+
272
+ expect(calls).toHaveLength(5);
273
+ expect(result.history.at(-1)?.content).toEqual([
274
+ { type: "text", text: "Done" },
275
+ ]);
276
+ });
277
+
278
+ test("resets the streak after a well-formed tool turn", async () => {
279
+ const raw = "{" + "x".repeat(1_000);
280
+ const valid: ProviderResponse = {
281
+ content: [
282
+ {
283
+ type: "tool_use",
284
+ id: "valid",
285
+ name: "file_write",
286
+ input: { path: "/workspace/file.md", content: "ok" },
287
+ },
288
+ ],
289
+ model: "mock-model",
290
+ usage: { inputTokens: 1, outputTokens: 1 },
291
+ stopReason: "tool_use",
292
+ };
293
+ const done: ProviderResponse = {
294
+ content: [{ type: "text", text: "Done" }],
295
+ model: "mock-model",
296
+ usage: { inputTokens: 1, outputTokens: 1 },
297
+ stopReason: "end_turn",
298
+ };
299
+ const { provider, calls } = createMockProvider([
300
+ malformedToolResponse("bad-1", raw),
301
+ malformedToolResponse("bad-2", raw),
302
+ valid,
303
+ malformedToolResponse("bad-3", raw),
304
+ malformedToolResponse("bad-4", raw),
305
+ done,
306
+ ]);
307
+ const loop = new AgentLoop({
308
+ provider,
309
+ systemPrompt: "system",
310
+ conversationId: "conversation-456",
311
+ tools: [
312
+ {
313
+ name: "file_write",
314
+ description: "",
315
+ input_schema: { type: "object" },
316
+ },
317
+ ],
318
+ toolExecutor: async () => ({ content: "result", isError: false }),
319
+ });
320
+
321
+ const result = await loop.run({
322
+ requestId: "request-456",
323
+ messages: [userMessage],
324
+ onEvent: () => {},
325
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
326
+ });
327
+
328
+ expect(calls).toHaveLength(6);
329
+ expect(result.history.at(-1)?.content).toEqual([
330
+ { type: "text", text: "Done" },
331
+ ]);
332
+ });
333
+ });