@vellumai/assistant 0.10.10-dev.202607170508.cf52585 → 0.10.10-dev.202607170655.fa2b150

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 (27) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/conversation-runtime-assembly.test.ts +78 -0
  3. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +125 -5
  4. package/src/__tests__/interrupted-turn-reconciler.test.ts +46 -0
  5. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  6. package/src/context/refusal-quarantine.ts +57 -12
  7. package/src/daemon/conversation-runtime-assembly.ts +38 -3
  8. package/src/daemon/interrupted-turn-reconciler.ts +9 -6
  9. package/src/daemon/process-message.ts +15 -2
  10. package/src/daemon/tool-side-effects.ts +51 -27
  11. package/src/persistence/embeddings/graph-node-orphan-sweep.ts +89 -0
  12. package/src/persistence/job-utils.ts +16 -6
  13. package/src/persistence/jobs-store.ts +1 -0
  14. package/src/persistence/migrations/341-sweep-cacheless-graph-node-vectors.test.ts +165 -0
  15. package/src/persistence/migrations/341-sweep-cacheless-graph-node-vectors.ts +51 -0
  16. package/src/persistence/steps.ts +2 -0
  17. package/src/platform/managed-speech.ts +9 -0
  18. package/src/plugin-api/index.ts +5 -0
  19. package/src/plugins/defaults/memory/__tests__/db-async-query.test.ts +5 -9
  20. package/src/plugins/defaults/memory/job-handlers/index-maintenance.ts +28 -10
  21. package/src/plugins/defaults/memory/job-handlers.ts +5 -0
  22. package/src/plugins/defaults/memory/jobs-worker.ts +1 -0
  23. package/src/providers/speech-to-text/__tests__/vellum-managed.test.ts +129 -10
  24. package/src/providers/speech-to-text/vellum-managed.ts +13 -6
  25. package/src/runtime/http-types.ts +11 -1
  26. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +36 -0
  27. package/src/runtime/routes/inbound-stages/background-dispatch.ts +11 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.10-dev.202607170508.cf52585",
3
+ "version": "0.10.10-dev.202607170655.fa2b150",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -6211,6 +6211,84 @@ describe("assembleSlackChronologicalMessages", () => {
6211
6211
  expect(serialized).toContain("hi assistant");
6212
6212
  expect(serialized).toContain("back to normal");
6213
6213
  });
6214
+
6215
+ test("quarantines the refused exchange across an interleaved sibling thread", () => {
6216
+ // Multi-party channel: @alice's flagged prompt roots a thread, the
6217
+ // assistant's refusal fallback replies inside it (`threadTs` = the prompt's
6218
+ // ts), and @bob posts in a different thread in between. Chronological
6219
+ // adjacency would pair the fallback with @bob's unrelated post — dropping
6220
+ // it and stranding the actual refused prompt, which then re-trips the
6221
+ // classifier every turn. Thread provenance pairs the fallback with @alice's
6222
+ // prompt instead and leaves @bob's post in place.
6223
+ const CHANNEL_ID = "C0MULTI01";
6224
+ const SLACK_CAPS_CHANNEL: ChannelCapabilities = {
6225
+ channel: "slack",
6226
+ dashboardCapable: false,
6227
+ supportsDynamicUi: false,
6228
+ supportsVoiceInput: false,
6229
+ chatType: "channel",
6230
+ };
6231
+ const T_HI = "1699971900.000100";
6232
+ const T_PROMPT = "1699972000.000200"; // roots @alice's thread
6233
+ const T_BOB = "1699972030.000300"; // sibling-thread post, interleaved
6234
+ const T_FALLBACK = "1699972060.000400"; // assistant refusal, in @alice's thread
6235
+ const T_LATER = "1699972200.000500";
6236
+ const meta = (
6237
+ channelTs: string,
6238
+ displayName: string,
6239
+ threadTs?: string,
6240
+ ): SlackMessageMetadata => ({
6241
+ source: "slack",
6242
+ channelId: CHANNEL_ID,
6243
+ channelTs,
6244
+ ...(threadTs ? { threadTs } : {}),
6245
+ eventKind: "message",
6246
+ displayName,
6247
+ });
6248
+ const rows: SlackTranscriptInputRow[] = [
6249
+ row(
6250
+ "user",
6251
+ "hi assistant",
6252
+ 1699971900_000,
6253
+ metadataEnvelope(meta(T_HI, "@alice")),
6254
+ ),
6255
+ row(
6256
+ "user",
6257
+ "disallowed question",
6258
+ 1699972000_000,
6259
+ metadataEnvelope(meta(T_PROMPT, "@alice")),
6260
+ ),
6261
+ row(
6262
+ "user",
6263
+ "what's for lunch",
6264
+ 1699972030_000,
6265
+ metadataEnvelope(meta(T_BOB, "@bob")),
6266
+ ),
6267
+ row(
6268
+ "assistant",
6269
+ REFUSAL_FALLBACK_TEXT,
6270
+ 1699972060_000,
6271
+ metadataEnvelope(meta(T_FALLBACK, "@assistant", T_PROMPT)),
6272
+ ),
6273
+ row(
6274
+ "user",
6275
+ "back to normal",
6276
+ 1699972200_000,
6277
+ metadataEnvelope(meta(T_LATER, "@alice")),
6278
+ ),
6279
+ ];
6280
+
6281
+ const result = assembleSlackChronologicalMessages(rows, SLACK_CAPS_CHANNEL);
6282
+ const serialized = JSON.stringify(result);
6283
+ // The refusal and the prompt that tripped it are both gone …
6284
+ expect(serialized).not.toContain(REFUSAL_FALLBACK_TEXT);
6285
+ expect(serialized).not.toContain("disallowed question");
6286
+ // … the interleaved sibling-thread post from another user is untouched …
6287
+ expect(serialized).toContain("what's for lunch");
6288
+ // … as are the benign turns on either side.
6289
+ expect(serialized).toContain("hi assistant");
6290
+ expect(serialized).toContain("back to normal");
6291
+ });
6214
6292
  });
6215
6293
 
6216
6294
  // ---------------------------------------------------------------------------
@@ -2,9 +2,10 @@
2
2
  * Regression tests for app surface refresh and eventing side effects in
3
3
  * createToolExecutor (conversation-tool-setup.ts).
4
4
  *
5
- * Tests verify that app_refresh, app_update, app_create, and app_delete hooks
6
- * fire correctly, and that non-hooked tools (app_file_edit, app_file_write) do
7
- * not trigger side effects.
5
+ * Tests verify that app_refresh, app_update, app_create, app_delete, and
6
+ * app_generate_icon hooks fire correctly including recovering an omitted
7
+ * app_id from the executor's echoed result — and that non-hooked tools
8
+ * (app_file_edit, app_file_write) do not trigger side effects.
8
9
  *
9
10
  * File-change detection for file_write/file_edit is handled by
10
11
  * AppSourceWatcher (see app-source-watcher.test.ts).
@@ -222,7 +223,35 @@ describe("session-tool-setup app refresh side effects", () => {
222
223
  expect(updatePublishedSpy).not.toHaveBeenCalled();
223
224
  });
224
225
 
225
- test("skips side effects when app_id is missing", async () => {
226
+ test("recovers appId from the result when input omits app_id", async () => {
227
+ // app_id is optional: the skill script resolves the active app and the
228
+ // executor echoes it back as `appId`. The hook must act on that resolved
229
+ // id so an omitted-id refresh still refreshes surfaces and re-deploys.
230
+ const ctx = makeCtx();
231
+ const executor = makeFakeExecutor({
232
+ content: '{"refreshed":true,"appId":"app-resolved"}',
233
+ isError: false,
234
+ });
235
+
236
+ const toolFn = createToolExecutor(
237
+ executor as unknown as ToolExecutor,
238
+ noopPrompter,
239
+ noopSecretPrompter,
240
+ ctx,
241
+ );
242
+
243
+ await toolFn("app_refresh", {});
244
+
245
+ expect(refreshSpy).toHaveBeenCalledTimes(1);
246
+ expect((refreshSpy.mock.calls as unknown[][])[0][1]).toBe("app-resolved");
247
+ expectAppChangeBroadcast("app-resolved");
248
+ expect(updatePublishedSpy).toHaveBeenCalledTimes(1);
249
+ expect((updatePublishedSpy.mock.calls as unknown[][])[0][0]).toBe(
250
+ "app-resolved",
251
+ );
252
+ });
253
+
254
+ test("skips side effects when app_id is absent from input and result", async () => {
226
255
  const ctx = makeCtx();
227
256
  const executor = makeFakeExecutor({ content: "{}", isError: false });
228
257
 
@@ -289,7 +318,34 @@ describe("session-tool-setup app refresh side effects", () => {
289
318
  expect(updatePublishedSpy).not.toHaveBeenCalled();
290
319
  });
291
320
 
292
- test("skips side effects when app_id is missing", async () => {
321
+ test("recovers appId from the result when input omits app_id", async () => {
322
+ const ctx = makeCtx();
323
+ const executor = makeFakeExecutor({
324
+ content: '{"updated":true,"appId":"app-resolved-u"}',
325
+ isError: false,
326
+ });
327
+
328
+ const toolFn = createToolExecutor(
329
+ executor as unknown as ToolExecutor,
330
+ noopPrompter,
331
+ noopSecretPrompter,
332
+ ctx,
333
+ );
334
+
335
+ await toolFn("app_update", {});
336
+
337
+ expect(refreshSpy).toHaveBeenCalledTimes(1);
338
+ expect((refreshSpy.mock.calls as unknown[][])[0][1]).toBe(
339
+ "app-resolved-u",
340
+ );
341
+ expectAppChangeBroadcast("app-resolved-u");
342
+ expect(updatePublishedSpy).toHaveBeenCalledTimes(1);
343
+ expect((updatePublishedSpy.mock.calls as unknown[][])[0][0]).toBe(
344
+ "app-resolved-u",
345
+ );
346
+ });
347
+
348
+ test("skips side effects when app_id is absent from input and result", async () => {
293
349
  const ctx = makeCtx();
294
350
  const executor = makeFakeExecutor({ content: "{}", isError: false });
295
351
 
@@ -511,6 +567,70 @@ describe("session-tool-setup app refresh side effects", () => {
511
567
  });
512
568
  });
513
569
 
570
+ // ── app_generate_icon side effects ──────────────────────────────────
571
+
572
+ describe("app_generate_icon side effects", () => {
573
+ test("broadcasts app_files_changed with explicit app_id", async () => {
574
+ const ctx = makeCtx();
575
+ const executor = makeFakeExecutor({
576
+ content: '{"generated":true,"appId":"icon-app"}',
577
+ isError: false,
578
+ });
579
+
580
+ const toolFn = createToolExecutor(
581
+ executor as unknown as ToolExecutor,
582
+ noopPrompter,
583
+ noopSecretPrompter,
584
+ ctx,
585
+ );
586
+
587
+ await toolFn("app_generate_icon", { app_id: "icon-app" });
588
+
589
+ expect(broadcastSpy).toHaveBeenCalledTimes(2);
590
+ expectAppChangeBroadcast("icon-app");
591
+ // Icon regen only rebroadcasts the app list — it neither refreshes open
592
+ // surfaces nor re-deploys the published app.
593
+ expect(refreshSpy).not.toHaveBeenCalled();
594
+ expect(updatePublishedSpy).not.toHaveBeenCalled();
595
+ });
596
+
597
+ test("recovers appId from the result when input omits app_id", async () => {
598
+ const ctx = makeCtx();
599
+ const executor = makeFakeExecutor({
600
+ content: '{"generated":true,"appId":"icon-resolved"}',
601
+ isError: false,
602
+ });
603
+
604
+ const toolFn = createToolExecutor(
605
+ executor as unknown as ToolExecutor,
606
+ noopPrompter,
607
+ noopSecretPrompter,
608
+ ctx,
609
+ );
610
+
611
+ await toolFn("app_generate_icon", {});
612
+
613
+ expect(broadcastSpy).toHaveBeenCalledTimes(2);
614
+ expectAppChangeBroadcast("icon-resolved");
615
+ });
616
+
617
+ test("skips broadcast when app_id is absent from input and result", async () => {
618
+ const ctx = makeCtx();
619
+ const executor = makeFakeExecutor({ content: "{}", isError: false });
620
+
621
+ const toolFn = createToolExecutor(
622
+ executor as unknown as ToolExecutor,
623
+ noopPrompter,
624
+ noopSecretPrompter,
625
+ ctx,
626
+ );
627
+
628
+ await toolFn("app_generate_icon", {});
629
+
630
+ expect(broadcastSpy).not.toHaveBeenCalled();
631
+ });
632
+ });
633
+
514
634
  // ── Name-based hook targeting (skill-origin tools) ──────────────────
515
635
 
516
636
  describe("name-based hooks fire for skill-origin tools", () => {
@@ -19,6 +19,9 @@ import { initializeDb } from "../persistence/db-init.js";
19
19
 
20
20
  await initializeDb();
21
21
 
22
+ const actualConversationCrud =
23
+ await import("../persistence/conversation-crud.js");
24
+
22
25
  function readRow(id: string): {
23
26
  processing_started_at: number | null;
24
27
  processing_resume_attempts: number;
@@ -254,4 +257,47 @@ describe("resumeInterruptedConversations", () => {
254
257
  expect(readRow("conv-bad").processing_resume_attempts).toBe(1);
255
258
  expect(readRow("conv-good").processing_resume_attempts).toBe(1);
256
259
  });
260
+
261
+ test("a failing counter write is isolated to its conversation and does not abort the rest", async () => {
262
+ createConversation({ id: "conv-bad" });
263
+ createConversation({ id: "conv-good" });
264
+
265
+ // A transient counter-write failure (e.g. a locked SQLite db during
266
+ // startup) must be logged and skipped for that conversation, exactly like a
267
+ // failing wake — never rejected out of the loop, which would strand every
268
+ // conversation queued behind it.
269
+ // Captured before mock.module: the namespace binding is live post-mock, so
270
+ // delegating through it would recurse into this override.
271
+ const realIncrement =
272
+ actualConversationCrud.incrementProcessingResumeAttempts;
273
+ mock.module("../persistence/conversation-crud.js", () => ({
274
+ ...actualConversationCrud,
275
+ incrementProcessingResumeAttempts: (id: string) => {
276
+ if (id === "conv-bad") {
277
+ throw new Error("counter write failed");
278
+ }
279
+ return realIncrement(id);
280
+ },
281
+ }));
282
+
283
+ const wakeCalls: string[] = [];
284
+ const wakeMock = mock(async (opts: Record<string, unknown>) => {
285
+ wakeCalls.push(opts.conversationId as string);
286
+ return { invoked: true, producedToolCalls: false };
287
+ });
288
+ mock.module("../runtime/agent-wake.js", () => ({
289
+ wakeAgentForOpportunity: wakeMock,
290
+ }));
291
+
292
+ await resumeInterruptedConversations([
293
+ guardianTarget("conv-bad"),
294
+ guardianTarget("conv-good"),
295
+ ]);
296
+
297
+ // conv-bad's counter threw before its wake ran, so it was skipped and never
298
+ // charged; conv-good still resumed and charged its own attempt.
299
+ expect(wakeCalls).toEqual(["conv-good"]);
300
+ expect(readRow("conv-bad").processing_resume_attempts).toBe(0);
301
+ expect(readRow("conv-good").processing_resume_attempts).toBe(1);
302
+ });
257
303
  });
@@ -13,6 +13,7 @@ const MEMORY_JOB_TYPES = [
13
13
  "backfill",
14
14
  "rebuild_index",
15
15
  "delete_qdrant_vectors",
16
+ "sweep_orphaned_graph_node_points",
16
17
  "embed_media",
17
18
  "embed_attachment",
18
19
  "embed_graph_node",
@@ -66,18 +66,56 @@ export function isRefusalFallbackMessage(message: Message): boolean {
66
66
  /**
67
67
  * Indices of every message belonging to a previously-refused exchange: for each
68
68
  * assistant message that is exactly the refusal fallback, its own index plus the
69
- * contiguous run back to (and including) the nearest genuine user prompt — the
69
+ * run back to (and including) the genuine user prompt that tripped it — the
70
70
  * flagged prompt and any tool exchange in that run.
71
71
  *
72
+ * Pairing the fallback with its true prompt:
73
+ *
74
+ * - By default (no `threadKeys`) the walk-back is pure array adjacency: the
75
+ * nearest preceding genuine user prompt. Correct for a single-party history
76
+ * (the in-memory working set, a Slack DM) where turns strictly alternate.
77
+ * - In a multi-party Slack channel, another party's post can land between the
78
+ * refused prompt and the fallback in chronological order, so adjacency would
79
+ * pair the fallback with that unrelated post and leave the actual refused
80
+ * prompt behind. `threadKeys` (a per-message Slack thread identity aligned
81
+ * 1:1 with `messages`, `threadTs ?? channelTs`) fixes this: when the
82
+ * fallback's thread is shared by another message — i.e. it is a real thread,
83
+ * not a lone top-level/DM row whose key is just its own `channelTs` — the
84
+ * walk-back skips over messages from other threads and pairs the fallback
85
+ * with the nearest genuine prompt in its own thread. A `null` key (non-Slack
86
+ * / legacy rows with no provenance) always uses adjacency.
87
+ *
72
88
  * Exposed as a set of indices (rather than only the filtered messages) so a
73
89
  * caller holding an array rendered in lockstep with `messages` — e.g. the Slack
74
90
  * chronological transcript, whose per-message compaction provenance rides in a
75
91
  * sibling array — can drop the same exchanges from both and keep them aligned.
76
92
  */
77
- export function computeRefusedExchangeDrops(messages: Message[]): {
93
+ export function computeRefusedExchangeDrops(
94
+ messages: Message[],
95
+ opts: { threadKeys?: ReadonlyArray<string | null> } = {},
96
+ ): {
78
97
  dropIndices: Set<number>;
79
98
  droppedExchanges: number;
80
99
  } {
100
+ const { threadKeys } = opts;
101
+ // A thread key only carries pairing signal when it is shared: a lone
102
+ // top-level or DM row has its own `channelTs` as its key, so treating it as a
103
+ // one-message "thread" would strand the prompt. Count keys once so each
104
+ // fallback can tell whether it sits in a real (multi-row) thread.
105
+ const sharedThreadKeys = new Set<string>();
106
+ if (threadKeys) {
107
+ const seen = new Set<string>();
108
+ for (const key of threadKeys) {
109
+ if (key === null) {
110
+ continue;
111
+ }
112
+ if (seen.has(key)) {
113
+ sharedThreadKeys.add(key);
114
+ } else {
115
+ seen.add(key);
116
+ }
117
+ }
118
+ }
81
119
  const dropIndices = new Set<number>();
82
120
  let droppedExchanges = 0;
83
121
  for (let r = 0; r < messages.length; r++) {
@@ -85,17 +123,24 @@ export function computeRefusedExchangeDrops(messages: Message[]): {
85
123
  continue;
86
124
  }
87
125
  dropIndices.add(r);
88
- // Walk back over tool-result / assistant tool churn to the genuine prompt.
89
- let s = r - 1;
90
- while (
91
- s >= 0 &&
92
- !(messages[s].role === "user" && !isToolResultMessage(messages[s]))
93
- ) {
126
+ const fallbackThreadKey = threadKeys?.[r] ?? null;
127
+ // Scope the walk-back to the fallback's own thread only when that thread is
128
+ // real — shared by another row. A lone top-level/DM row (keyed by its own
129
+ // channelTs) or a provenance-less null key falls through to adjacency.
130
+ const threadScope =
131
+ fallbackThreadKey !== null && sharedThreadKeys.has(fallbackThreadKey)
132
+ ? threadKeys
133
+ : undefined;
134
+ // Walk back to the genuine prompt over tool-result / assistant tool churn,
135
+ // skipping (leaving in place) any interleaved message from another thread.
136
+ for (let s = r - 1; s >= 0; s--) {
137
+ if (threadScope && (threadScope[s] ?? null) !== fallbackThreadKey) {
138
+ continue;
139
+ }
94
140
  dropIndices.add(s);
95
- s--;
96
- }
97
- if (s >= 0) {
98
- dropIndices.add(s); // the genuine user prompt that was refused
141
+ if (messages[s].role === "user" && !isToolResultMessage(messages[s])) {
142
+ break; // the genuine user prompt that was refused
143
+ }
99
144
  }
100
145
  droppedExchanges++;
101
146
  }
@@ -1304,6 +1304,30 @@ export function getSlackWatermarkAdvanceForRowPrefix(
1304
1304
  return ts;
1305
1305
  }
1306
1306
 
1307
+ /**
1308
+ * Map each row's Slack `channelTs` to its thread key (`threadTs ?? channelTs`).
1309
+ *
1310
+ * A rendered transcript message carries its source row's `channelTs` in
1311
+ * `sourceChannelTs`, so this map lets the refusal-exchange sweep resolve each
1312
+ * rendered message's thread and pair a persisted refusal fallback with the
1313
+ * prompt in its own thread — rather than the nearest chronological neighbour,
1314
+ * which in a multi-party channel can be an unrelated sibling-thread post.
1315
+ */
1316
+ function buildSlackThreadKeyByChannelTs(
1317
+ rows: SlackTranscriptInputRow[],
1318
+ ): Map<string, string> {
1319
+ const byChannelTs = new Map<string, string>();
1320
+ for (const row of rows) {
1321
+ const meta = readSlackMetadataFromMessageMetadata(row.metadata, {
1322
+ allowFlatLegacy: true,
1323
+ });
1324
+ if (meta?.channelTs) {
1325
+ byChannelTs.set(meta.channelTs, meta.threadTs ?? meta.channelTs);
1326
+ }
1327
+ }
1328
+ return byChannelTs;
1329
+ }
1330
+
1307
1331
  function assembleSlackChronologicalContext(
1308
1332
  rows: SlackTranscriptInputRow[],
1309
1333
  capabilities: ChannelCapabilities,
@@ -1319,9 +1343,20 @@ function assembleSlackChronologicalContext(
1319
1343
  // Drop previously-refused exchanges — a persisted safety-classifier refusal
1320
1344
  // and the prompt that tripped it — so the model isn't re-fed a refusal it
1321
1345
  // will just repeat (the dead-end `empty-response` sweeps off Slack turns).
1322
- // `renderedMessages` is filtered by the same indices to stay aligned with the
1323
- // `messages` projection compaction slices.
1324
- const { dropIndices } = computeRefusedExchangeDrops(rendered.messages);
1346
+ // Pairing is thread-aware: each rendered message's Slack thread is resolved
1347
+ // from its `sourceChannelTs`, so an interleaved sibling-thread post that
1348
+ // landed between the refused prompt and the fallback is neither dropped nor
1349
+ // mistaken for the prompt. `renderedMessages` is filtered by the same indices
1350
+ // to stay aligned with the `messages` projection compaction slices.
1351
+ const threadKeyByChannelTs = buildSlackThreadKeyByChannelTs(rows);
1352
+ const threadKeys = rendered.renderedMessages.map((entry) =>
1353
+ entry.sourceChannelTs !== null
1354
+ ? (threadKeyByChannelTs.get(entry.sourceChannelTs) ?? null)
1355
+ : null,
1356
+ );
1357
+ const { dropIndices } = computeRefusedExchangeDrops(rendered.messages, {
1358
+ threadKeys,
1359
+ });
1325
1360
  const renderedMessages =
1326
1361
  dropIndices.size > 0
1327
1362
  ? rendered.renderedMessages.filter((_, i) => !dropIndices.has(i))
@@ -168,13 +168,16 @@ export async function resumeInterruptedConversations(
168
168
  ): Promise<void> {
169
169
  const { wakeAgentForOpportunity } = await import("../runtime/agent-wake.js");
170
170
  for (const { conversationId, trustContext } of targets) {
171
- // Charge the attempt as the wake begins, before the turn runs. The cap
172
- // exists to stop a resumed turn that keeps killing the process, so the
173
- // counter must be durably incremented before that turn can crash the
174
- // daemon; a sequential increment here also means a crash mid-resume never
175
- // charges the conversations still queued behind it.
176
- incrementProcessingResumeAttempts(conversationId);
177
171
  try {
172
+ // Charge the attempt before the wake runs. The cap exists to stop a
173
+ // resumed turn that keeps killing the process, so the counter must be
174
+ // durably incremented before that turn can crash the daemon; charging it
175
+ // per-conversation (never up-front for the batch) also means a crash
176
+ // mid-resume never burns the budget of conversations still queued behind
177
+ // it. Doing it inside this guarded block keeps a transient counter-write
178
+ // failure scoped to its own conversation instead of aborting every
179
+ // resume still queued.
180
+ incrementProcessingResumeAttempts(conversationId);
178
181
  const result = await wakeAgentForOpportunity({
179
182
  conversationId,
180
183
  hint: INTERRUPTED_TURN_RESUME_HINT,
@@ -409,6 +409,13 @@ export async function processMessage(
409
409
  ): Promise<{
410
410
  messageId: string;
411
411
  assistantMessageId?: string;
412
+ /**
413
+ * True when this ingress deduplicated against a prior turn (same idempotency
414
+ * key) and the agent loop was skipped. Channel finalization must skip reply
415
+ * delivery on this signal so an at-least-once redelivery does not re-emit the
416
+ * original turn's reply.
417
+ */
418
+ deduplicated?: boolean;
412
419
  /**
413
420
  * The agent turn's failure outcome, or `null` when it replied normally. Set
414
421
  * when the turn failed (e.g. its LLM call failed with an invalid provider) —
@@ -676,12 +683,18 @@ export async function processMessage(
676
683
 
677
684
  if (deduplicated) {
678
685
  // This exact ingress (same idempotency key) already ran a turn; skip the
679
- // loop so an at-least-once redelivery does not emit a second reply.
686
+ // loop so an at-least-once redelivery does not emit a second reply. The
687
+ // `deduplicated` signal also stops channel finalization from re-delivering
688
+ // the original reply.
680
689
  log.info(
681
690
  { conversationId, messageId },
682
691
  "Skipping agent loop for deduplicated ingress message",
683
692
  );
684
- return { messageId, turnFailure: readTurnFailure(messageId) };
693
+ return {
694
+ messageId,
695
+ deduplicated: true,
696
+ turnFailure: readTurnFailure(messageId),
697
+ };
685
698
  }
686
699
 
687
700
  if (options?.isInteractive === true) {
@@ -70,6 +70,35 @@ function broadcastAppFilesChanged(appId: string): void {
70
70
  publishAppsChanged();
71
71
  }
72
72
 
73
+ /**
74
+ * Resolve the app id a post-execution hook should act on.
75
+ *
76
+ * `app_id` is optional for the app-builder fallback tools (`app_update`,
77
+ * `app_refresh`, `app_generate_icon`): when the model omits it, the skill
78
+ * script resolves the conversation's active app and the executor echoes that
79
+ * id back as `appId` in its result. Prefer the explicit tool input; fall back
80
+ * to the resolved id the result carries so an omitted-id call still refreshes
81
+ * surfaces, rebroadcasts, and re-deploys instead of silently no-op'ing.
82
+ */
83
+ function resolveHookAppId(
84
+ input: Record<string, unknown>,
85
+ result: ToolExecutionResult,
86
+ ): string | undefined {
87
+ const explicit = input.app_id;
88
+ if (typeof explicit === "string" && explicit.trim().length > 0) {
89
+ return explicit;
90
+ }
91
+ try {
92
+ const parsed = JSON.parse(result.content) as { appId?: unknown };
93
+ if (typeof parsed.appId === "string" && parsed.appId.length > 0) {
94
+ return parsed.appId;
95
+ }
96
+ } catch {
97
+ // Result wasn't valid JSON — no resolved id to recover.
98
+ }
99
+ return undefined;
100
+ }
101
+
73
102
  // ── Registry ─────────────────────────────────────────────────────────
74
103
 
75
104
  /**
@@ -130,8 +159,8 @@ registerHook("app_create", (_name, _input, result, { ctx }) => {
130
159
  }
131
160
  });
132
161
 
133
- registerHook("app_generate_icon", (_name, input) => {
134
- const appId = input.app_id as string | undefined;
162
+ registerHook("app_generate_icon", (_name, input, result) => {
163
+ const appId = resolveHookAppId(input, result);
135
164
  if (appId) {
136
165
  broadcastAppFilesChanged(appId);
137
166
  }
@@ -144,31 +173,26 @@ registerHook("app_delete", (_name, input) => {
144
173
  }
145
174
  });
146
175
 
147
- registerHook("app_refresh", (_name, input, _result, { ctx }) => {
148
- const appId = input.app_id as string | undefined;
149
- if (!appId) return;
150
- try {
151
- addAppConversationId(appId, ctx.conversationId);
152
- } catch (err) {
153
- log.warn({ err, appId }, "Failed to track conversation ID on app_refresh");
154
- }
155
- notifyAppChanged(ctx, appId, { fileChange: true });
156
- });
157
-
158
- // app_update compiles internally (like app_refresh) but emits no events of its
159
- // own, so without this hook an updated app leaves open surfaces rendering the
160
- // stale dist and never re-deploys or invalidates the Library. The executor owns
161
- // the compile; notifyAppChanged only refreshes surfaces and broadcasts.
162
- registerHook("app_update", (_name, input, _result, { ctx }) => {
163
- const appId = input.app_id as string | undefined;
164
- if (!appId) return;
165
- try {
166
- addAppConversationId(appId, ctx.conversationId);
167
- } catch (err) {
168
- log.warn({ err, appId }, "Failed to track conversation ID on app_update");
169
- }
170
- notifyAppChanged(ctx, appId, { fileChange: true });
171
- });
176
+ // app_refresh and app_update mutate an app's source but emit no events of their
177
+ // own, so without a hook an updated app leaves open surfaces rendering the stale
178
+ // dist and never re-deploys or invalidates the Library. The executor owns the
179
+ // compile; notifyAppChanged only refreshes surfaces and broadcasts.
180
+ function registerAppSurfaceRefreshHook(toolName: string): void {
181
+ registerHook(toolName, (name, input, result, { ctx }) => {
182
+ const appId = resolveHookAppId(input, result);
183
+ if (!appId) {
184
+ return;
185
+ }
186
+ try {
187
+ addAppConversationId(appId, ctx.conversationId);
188
+ } catch (err) {
189
+ log.warn({ err, appId }, `Failed to track conversation ID on ${name}`);
190
+ }
191
+ notifyAppChanged(ctx, appId, { fileChange: true });
192
+ });
193
+ }
194
+ registerAppSurfaceRefreshHook("app_refresh");
195
+ registerAppSurfaceRefreshHook("app_update");
172
196
 
173
197
  registerHook("voice_config_update", (_name, input) => {
174
198
  const setting = input.setting as string | undefined;