@vellumai/assistant 0.10.10-dev.202607170508.cf52585 → 0.10.10-dev.202607170744.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.
- package/package.json +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +78 -0
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +125 -5
- package/src/__tests__/interrupted-turn-reconciler.test.ts +46 -0
- package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
- package/src/context/refusal-quarantine.ts +57 -12
- package/src/daemon/conversation-runtime-assembly.ts +38 -3
- package/src/daemon/interrupted-turn-reconciler.ts +9 -6
- package/src/daemon/process-message.ts +15 -2
- package/src/daemon/tool-side-effects.ts +51 -27
- package/src/persistence/embeddings/graph-node-orphan-sweep.ts +89 -0
- package/src/persistence/job-utils.ts +16 -6
- package/src/persistence/jobs-store.ts +1 -0
- package/src/persistence/migrations/341-sweep-cacheless-graph-node-vectors.test.ts +165 -0
- package/src/persistence/migrations/341-sweep-cacheless-graph-node-vectors.ts +51 -0
- package/src/persistence/steps.ts +2 -0
- package/src/platform/managed-speech.ts +9 -0
- package/src/plugin-api/index.ts +5 -0
- package/src/plugins/defaults/memory/__tests__/db-async-query.test.ts +5 -9
- package/src/plugins/defaults/memory/job-handlers/index-maintenance.ts +28 -10
- package/src/plugins/defaults/memory/job-handlers.ts +5 -0
- package/src/plugins/defaults/memory/jobs-worker.ts +1 -0
- package/src/providers/speech-to-text/__tests__/vellum-managed.test.ts +129 -10
- package/src/providers/speech-to-text/vellum-managed.ts +13 -6
- package/src/runtime/http-types.ts +11 -1
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +36 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +11 -1
package/package.json
CHANGED
|
@@ -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,
|
|
6
|
-
* fire correctly
|
|
7
|
-
*
|
|
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("
|
|
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("
|
|
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
|
});
|
|
@@ -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
|
-
*
|
|
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(
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1323
|
-
// `
|
|
1324
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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;
|