@vellumai/assistant 0.10.2-dev.202606242332.3fa9b2b → 0.10.2-dev.202606250106.466483e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.2-dev.202606242332.3fa9b2b",
3
+ "version": "0.10.2-dev.202606250106.466483e",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,8 +10,14 @@ import {
10
10
  } from "bun:test";
11
11
 
12
12
  import type { LoopToolExecutor } from "../agent/loop.js";
13
+ import {
14
+ queueConversationNotice,
15
+ resetConversationNoticesForTests,
16
+ } from "../daemon/conversation-notices.js";
13
17
  import type { ServerMessage } from "../daemon/message-protocol.js";
18
+ import type { UserPromptSubmitContext } from "../plugin-api/types.js";
14
19
  import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
20
+ import { registerPlugin } from "../plugins/registry.js";
15
21
  import type { Message, Provider, ToolDefinition } from "../providers/types.js";
16
22
  import { ContextOverflowError } from "../providers/types.js";
17
23
 
@@ -273,8 +279,8 @@ const deleteMessageByIdMock = mock(() => ({
273
279
  const reserveMessageMock = mock(async () => ({ id: "msg-reserve" }));
274
280
  const updateMessageContentMock = mock(() => {});
275
281
  mock.module("../memory/conversation-crud.js", () => ({
276
- setConversationProcessingStartedAt: () => {},
277
- isConversationProcessing: () => false,
282
+ setConversationProcessingStartedAt: () => {},
283
+ isConversationProcessing: () => false,
278
284
  setConversationOriginChannelIfUnset: () => {},
279
285
  updateConversationUsage: () => {},
280
286
  updateMessageMetadata: updateMessageMetadataMock,
@@ -867,7 +873,14 @@ beforeEach(() => {
867
873
  indexMessageNowMock.mockClear();
868
874
  projectAssistantMessageMock.mockClear();
869
875
  publishSyncInvalidationMock.mockClear();
876
+ resolveAssistantAttachmentsMock.mockClear();
877
+ resolveAssistantAttachmentsMock.mockImplementation(async () => ({
878
+ assistantAttachments: [],
879
+ emittedAttachments: [],
880
+ directiveWarnings: [],
881
+ }));
870
882
  mockMessageById = null;
883
+ resetConversationNoticesForTests();
871
884
  // The compaction pipeline runs through the plugin registry; reset and
872
885
  // re-register every default so it dispatches to middleware backed by the
873
886
  // mocked collaborators these tests install (`syncMessageToDisk`, etc.)
@@ -876,6 +889,120 @@ beforeEach(() => {
876
889
  });
877
890
 
878
891
  describe("session-agent-loop", () => {
892
+ describe("user-prompt-submit hook failures", () => {
893
+ test("logs and continues with prior hook mutations", async () => {
894
+ registerPlugin({
895
+ manifest: {
896
+ name: "test-user-prompt-rewrite",
897
+ version: "1.0.0",
898
+ },
899
+ hooks: {
900
+ "user-prompt-submit": async (_ctx: UserPromptSubmitContext) => ({
901
+ latestMessages: [
902
+ {
903
+ role: "user" as const,
904
+ content: [{ type: "text" as const, text: "rewritten prompt" }],
905
+ },
906
+ ],
907
+ }),
908
+ },
909
+ });
910
+ registerPlugin({
911
+ manifest: {
912
+ name: "test-user-prompt-throw",
913
+ version: "1.0.0",
914
+ },
915
+ hooks: {
916
+ "user-prompt-submit": async () => {
917
+ throw new Error("simulated hook failure");
918
+ },
919
+ },
920
+ });
921
+
922
+ const events: ServerMessage[] = [];
923
+ const ctx = makeCtx({ providerResponses: [textResponse("ok")] });
924
+ const runSpy = spyOn(ctx.agentLoop, "run");
925
+
926
+ await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
927
+
928
+ expect(runSpy).toHaveBeenCalledTimes(1);
929
+ const call = runSpy.mock.calls[0]?.[0] as
930
+ | { messages: Message[] }
931
+ | undefined;
932
+ expect(call?.messages[0]?.content).toEqual([
933
+ { type: "text", text: "rewritten prompt" },
934
+ ]);
935
+ expect(
936
+ events.find((event) => event.type === "conversation_error"),
937
+ ).toBeUndefined();
938
+ expect(
939
+ events.find((event) => event.type === "message_complete"),
940
+ ).toBeDefined();
941
+ });
942
+ });
943
+
944
+ describe("conversation notices", () => {
945
+ test("emits queued billing notices after a successful turn", async () => {
946
+ const events: ServerMessage[] = [];
947
+ const ctx = makeCtx({ providerResponses: [textResponse("ok")] });
948
+ queueConversationNotice(ctx.conversationId, "memory-v3-test", {
949
+ source: "memory_v3",
950
+ code: "PROVIDER_BILLING",
951
+ userMessage: "You've run out of credits.",
952
+ errorCategory: "credits_exhausted",
953
+ });
954
+
955
+ await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
956
+
957
+ expect(
958
+ events.find((event) => event.type === "conversation_error"),
959
+ ).toBeUndefined();
960
+ const messageCompleteIndex = events.findIndex(
961
+ (event) => event.type === "message_complete",
962
+ );
963
+ const conversationNoticeIndex = events.findIndex(
964
+ (event) => event.type === "conversation_notice",
965
+ );
966
+
967
+ expect(messageCompleteIndex).toBeGreaterThanOrEqual(0);
968
+ expect(conversationNoticeIndex).toBeGreaterThan(messageCompleteIndex);
969
+ expect(events[conversationNoticeIndex]).toEqual({
970
+ type: "conversation_notice",
971
+ conversationId: "test-conv",
972
+ source: "memory_v3",
973
+ code: "PROVIDER_BILLING",
974
+ userMessage: "You've run out of credits.",
975
+ errorCategory: "credits_exhausted",
976
+ });
977
+ });
978
+
979
+ test("clears queued notices when post-loop success work fails", async () => {
980
+ resolveAssistantAttachmentsMock.mockImplementation(async () => {
981
+ throw new Error("attachment resolution failed");
982
+ });
983
+ const events: ServerMessage[] = [];
984
+ const ctx = makeCtx({ providerResponses: [textResponse("ok")] });
985
+ queueConversationNotice(ctx.conversationId, "memory-v3-test", {
986
+ source: "memory_v3",
987
+ code: "PROVIDER_BILLING",
988
+ userMessage: "You've run out of credits.",
989
+ errorCategory: "credits_exhausted",
990
+ });
991
+
992
+ await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
993
+
994
+ expect(
995
+ events.find((event) => event.type === "conversation_notice"),
996
+ ).toBeUndefined();
997
+ expect(
998
+ events.find((event) => event.type === "message_complete"),
999
+ ).toBeUndefined();
1000
+ expect(
1001
+ events.find((event) => event.type === "conversation_error"),
1002
+ ).toBeDefined();
1003
+ });
1004
+ });
1005
+
879
1006
  describe("timezone turn context", () => {
880
1007
  test("passes ctx.clientTimezone and ui.detectedTimezone into timezone resolution", async () => {
881
1008
  mockUiConfig = {
@@ -0,0 +1,96 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import { runHook } from "../plugins/pipeline.js";
4
+ import {
5
+ registerPlugin,
6
+ resetPluginRegistryForTests,
7
+ } from "../plugins/registry.js";
8
+
9
+ beforeEach(() => {
10
+ resetPluginRegistryForTests();
11
+ });
12
+
13
+ describe("plugin pipeline", () => {
14
+ test("logs and skips failed hooks while preserving threaded mutations", async () => {
15
+ registerPlugin({
16
+ manifest: {
17
+ name: "test-first-hook",
18
+ version: "1.0.0",
19
+ },
20
+ hooks: {
21
+ "user-prompt-submit": async () => ({
22
+ value: 1,
23
+ }),
24
+ },
25
+ });
26
+ registerPlugin({
27
+ manifest: {
28
+ name: "test-throwing-hook",
29
+ version: "1.0.0",
30
+ },
31
+ hooks: {
32
+ "user-prompt-submit": async () => {
33
+ throw new Error("hook failed");
34
+ },
35
+ },
36
+ });
37
+ registerPlugin({
38
+ manifest: {
39
+ name: "test-final-hook",
40
+ version: "1.0.0",
41
+ },
42
+ hooks: {
43
+ "user-prompt-submit": async (ctx: { value: number }) => ({
44
+ value: ctx.value + 1,
45
+ }),
46
+ },
47
+ });
48
+
49
+ const result = await runHook("user-prompt-submit", { value: 0 });
50
+
51
+ expect(result).toEqual({ value: 2 });
52
+ });
53
+
54
+ test("discards in-place mutations from a failed hook", async () => {
55
+ registerPlugin({
56
+ manifest: {
57
+ name: "test-first-hook",
58
+ version: "1.0.0",
59
+ },
60
+ hooks: {
61
+ "user-prompt-submit": async (ctx: { items: string[] }) => {
62
+ ctx.items.push("first");
63
+ },
64
+ },
65
+ });
66
+ registerPlugin({
67
+ manifest: {
68
+ name: "test-throwing-hook",
69
+ version: "1.0.0",
70
+ },
71
+ hooks: {
72
+ "user-prompt-submit": async (ctx: { items: string[] }) => {
73
+ ctx.items.push("failed");
74
+ throw new Error("hook failed");
75
+ },
76
+ },
77
+ });
78
+ registerPlugin({
79
+ manifest: {
80
+ name: "test-final-hook",
81
+ version: "1.0.0",
82
+ },
83
+ hooks: {
84
+ "user-prompt-submit": async (ctx: { items: string[] }) => {
85
+ ctx.items.push("final");
86
+ },
87
+ },
88
+ });
89
+
90
+ const result = await runHook<{ items: string[] }>("user-prompt-submit", {
91
+ items: [],
92
+ });
93
+
94
+ expect(result.items).toEqual(["first", "final"]);
95
+ });
96
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `conversation_notice` SSE event.
3
+ *
4
+ * Non-terminal, conversation-scoped notice for actionable runtime conditions
5
+ * that should not mark the turn as failed. The client may render CTA UI from
6
+ * this event while preserving the current assistant response.
7
+ */
8
+
9
+ import { z } from "zod";
10
+
11
+ import { ConversationErrorCodeSchema } from "./conversation-error.js";
12
+
13
+ export const ConversationNoticeSourceSchema = z.enum(["memory_v3"]);
14
+
15
+ export const ConversationNoticeEventSchema = z.object({
16
+ type: z.literal("conversation_notice"),
17
+ conversationId: z.string(),
18
+ source: ConversationNoticeSourceSchema,
19
+ code: ConversationErrorCodeSchema,
20
+ userMessage: z.string(),
21
+ errorCategory: z.string().optional(),
22
+ });
23
+
24
+ export type ConversationNoticeEvent = z.infer<
25
+ typeof ConversationNoticeEventSchema
26
+ >;
package/src/api/index.ts CHANGED
@@ -11,6 +11,7 @@ import { ConfirmationRequestEventSchema } from "./events/confirmation-request.js
11
11
  import { ContactRequestEventSchema } from "./events/contact-request.js";
12
12
  import { ConversationErrorEventSchema } from "./events/conversation-error.js";
13
13
  import { ConversationListInvalidatedEventSchema } from "./events/conversation-list-invalidated.js";
14
+ import { ConversationNoticeEventSchema } from "./events/conversation-notice.js";
14
15
  import { ConversationTitleUpdatedEventSchema } from "./events/conversation-title-updated.js";
15
16
  import { DiskPressureStatusChangedEventSchema } from "./events/disk-pressure-status-changed.js";
16
17
  import { DocumentCommentCreatedEventSchema } from "./events/document-comment-created.js";
@@ -138,6 +139,11 @@ export {
138
139
  type ConversationListInvalidatedReason,
139
140
  ConversationListInvalidatedReasonSchema,
140
141
  } from "./events/conversation-list-invalidated.js";
142
+ export {
143
+ type ConversationNoticeEvent,
144
+ ConversationNoticeEventSchema,
145
+ ConversationNoticeSourceSchema,
146
+ } from "./events/conversation-notice.js";
141
147
  export {
142
148
  type ConversationTitleUpdatedEvent,
143
149
  ConversationTitleUpdatedEventSchema,
@@ -509,6 +515,7 @@ export const AssistantEventSchema = z.discriminatedUnion("type", [
509
515
  ContactRequestEventSchema,
510
516
  ConversationErrorEventSchema,
511
517
  ConversationListInvalidatedEventSchema,
518
+ ConversationNoticeEventSchema,
512
519
  ConversationTitleUpdatedEventSchema,
513
520
  DiskPressureStatusChangedEventSchema,
514
521
  DocumentCommentCreatedEventSchema,
@@ -403,11 +403,11 @@
403
403
  "defaultEnabled": false
404
404
  },
405
405
  {
406
- "id": "mcp-settings",
406
+ "id": "mcp-add-server",
407
407
  "scope": "assistant",
408
- "key": "mcp-settings",
409
- "label": "MCP Settings",
410
- "description": "Show the MCP page in Settings for managing Model Context Protocol server connections: view status, enable/disable, configure, and inspect registered tools per server.",
408
+ "key": "mcp-add-server",
409
+ "label": "MCP Add Server",
410
+ "description": "Show the Add Server action on the MCP settings page. The MCP page itself remains visible; this flag gates only creating new Model Context Protocol server connections.",
411
411
  "defaultEnabled": false
412
412
  },
413
413
  {
@@ -100,6 +100,10 @@ import {
100
100
  isUserCancellation,
101
101
  } from "./conversation-error.js";
102
102
  import { raceWithTimeout } from "./conversation-media-retry.js";
103
+ import {
104
+ clearConversationNotices,
105
+ drainConversationNotices,
106
+ } from "./conversation-notices.js";
103
107
  import {
104
108
  getSlackCompactionWatermarkForPrefix,
105
109
  loadSlackChronologicalContext,
@@ -1017,6 +1021,15 @@ export async function runAgentLoopImpl(
1017
1021
  );
1018
1022
  }
1019
1023
 
1024
+ const shouldEmitQueuedConversationNotices =
1025
+ !overflowTerminalReason &&
1026
+ !yieldedForHandoff &&
1027
+ !state.providerErrorUserMessage &&
1028
+ !abortController.signal.aborted;
1029
+ if (!shouldEmitQueuedConversationNotices) {
1030
+ clearConversationNotices(ctx.conversationId);
1031
+ }
1032
+
1020
1033
  // Flush remaining tool results. On a normal turn these drain at the next
1021
1034
  // `message_complete`; an aborted or yielded loop exits with them still
1022
1035
  // buffered, so finalize the (possibly already on-arrival-reserved) grouped
@@ -1409,10 +1422,16 @@ export async function runAgentLoopImpl(
1409
1422
  ? { messageId: state.lastAssistantMessageId }
1410
1423
  : {}),
1411
1424
  });
1425
+ if (shouldEmitQueuedConversationNotices) {
1426
+ for (const notice of drainConversationNotices(ctx.conversationId)) {
1427
+ onEvent(notice);
1428
+ }
1429
+ }
1412
1430
  publishLoopMessagesChanged();
1413
1431
  }
1414
1432
  }
1415
1433
  } catch (err) {
1434
+ clearConversationNotices(ctx.conversationId);
1416
1435
  const errorCtx = {
1417
1436
  phase: "agent_loop" as const,
1418
1437
  aborted: abortController.signal.aborted,
@@ -0,0 +1,60 @@
1
+ import type { ConversationNoticeEvent } from "../api/events/conversation-notice.js";
2
+
3
+ export type PendingConversationNotice = Omit<
4
+ ConversationNoticeEvent,
5
+ "type" | "conversationId"
6
+ >;
7
+
8
+ const MAX_TRACKED_CONVERSATIONS = 256;
9
+
10
+ const pendingNotices = new Map<
11
+ string,
12
+ Map<string, PendingConversationNotice>
13
+ >();
14
+
15
+ function touchConversation(
16
+ conversationId: string,
17
+ ): Map<string, PendingConversationNotice> {
18
+ const existing = pendingNotices.get(conversationId);
19
+ if (existing) {
20
+ pendingNotices.delete(conversationId);
21
+ pendingNotices.set(conversationId, existing);
22
+ return existing;
23
+ }
24
+ if (pendingNotices.size >= MAX_TRACKED_CONVERSATIONS) {
25
+ const oldest = pendingNotices.keys().next().value;
26
+ if (oldest !== undefined) pendingNotices.delete(oldest);
27
+ }
28
+ const next = new Map<string, PendingConversationNotice>();
29
+ pendingNotices.set(conversationId, next);
30
+ return next;
31
+ }
32
+
33
+ export function queueConversationNotice(
34
+ conversationId: string,
35
+ key: string,
36
+ notice: PendingConversationNotice,
37
+ ): void {
38
+ touchConversation(conversationId).set(key, notice);
39
+ }
40
+
41
+ export function drainConversationNotices(
42
+ conversationId: string,
43
+ ): ConversationNoticeEvent[] {
44
+ const notices = pendingNotices.get(conversationId);
45
+ if (!notices) return [];
46
+ pendingNotices.delete(conversationId);
47
+ return Array.from(notices.values(), (notice) => ({
48
+ type: "conversation_notice" as const,
49
+ conversationId,
50
+ ...notice,
51
+ }));
52
+ }
53
+
54
+ export function clearConversationNotices(conversationId: string): void {
55
+ pendingNotices.delete(conversationId);
56
+ }
57
+
58
+ export function resetConversationNoticesForTests(): void {
59
+ pendingNotices.clear();
60
+ }
@@ -4,6 +4,7 @@ import type { CompactionCircuitClosedEvent } from "../../api/events/compaction-c
4
4
  import type { CompactionCircuitOpenEvent } from "../../api/events/compaction-circuit-open.js";
5
5
  import type { ConversationErrorEvent } from "../../api/events/conversation-error.js";
6
6
  import type { ConversationListInvalidatedEvent } from "../../api/events/conversation-list-invalidated.js";
7
+ import type { ConversationNoticeEvent } from "../../api/events/conversation-notice.js";
7
8
  import type { ConversationTitleUpdatedEvent } from "../../api/events/conversation-title-updated.js";
8
9
  import type { GenerationCancelledEvent } from "../../api/events/generation-cancelled.js";
9
10
  import type { GenerationHandoffEvent } from "../../api/events/generation-handoff.js";
@@ -543,6 +544,7 @@ export type _ConversationsServerMessages =
543
544
  | CompactionCircuitOpenEvent
544
545
  | CompactionCircuitClosedEvent
545
546
  | ConversationErrorEvent
547
+ | ConversationNoticeEvent
546
548
  | ConversationInfo
547
549
  | ConversationTitleUpdatedEvent
548
550
  | ConversationListResponse
@@ -67,13 +67,17 @@ let pruneConfig: {
67
67
  maxResidentBytes: number;
68
68
  targetResidentBytes: number;
69
69
  } | null = null;
70
- /** Canned orchestrate result per turnIndex; `null` simulates a failed turn. */
71
- let turnResults = new Map<number, OrchestrateResult | null>();
70
+ /** Canned orchestrate result per turnIndex; `null` simulates an ordinary miss. */
71
+ let turnResults = new Map<number, OrchestrateResult | null | Error>();
72
72
  const observeTurnSpy = mock(
73
73
  async (
74
74
  _conversationId: string,
75
75
  turnIndex: number,
76
- ): Promise<OrchestrateResult | null> => turnResults.get(turnIndex) ?? null,
76
+ ): Promise<OrchestrateResult | null> => {
77
+ const value = turnResults.get(turnIndex) ?? null;
78
+ if (value instanceof Error) throw value;
79
+ return value;
80
+ },
77
81
  );
78
82
 
79
83
  const logCalls: Array<{ data: unknown; msg: string }> = [];
@@ -199,6 +203,9 @@ const {
199
203
  } = await import("../ever-injected-store.js");
200
204
  const { V3_CARDS_INJECTION_HEADER } = await import("../render-injection.js");
201
205
  const { flushPruneValveForTests } = await import("../prune.js");
206
+ const { drainConversationNotices, resetConversationNoticesForTests } =
207
+ await import("../../../../daemon/conversation-notices.js");
208
+ const { MemoryV3RetrievalUnavailableError } = await import("../pool-select.js");
202
209
 
203
210
  // ─── helpers ────────────────────────────────────────────────────────────────
204
211
 
@@ -313,6 +320,7 @@ beforeEach(async () => {
313
320
  logCalls.length = 0;
314
321
  testDb = makeDb();
315
322
  resetMemoryV3InjectorStateForTests();
323
+ resetConversationNoticesForTests();
316
324
  });
317
325
 
318
326
  afterAll(async () => {
@@ -335,6 +343,28 @@ describe("memoryV3Injector — frozen net-new cards", () => {
335
343
  expect(getActiveSlugs("conv-1")).toEqual(new Set());
336
344
  });
337
345
 
346
+ test("live retrieval failure queues a degraded-memory notice", async () => {
347
+ liveEnabled = true;
348
+ turnResults.set(
349
+ 0,
350
+ new MemoryV3RetrievalUnavailableError("selector unavailable"),
351
+ );
352
+
353
+ await expect(produceCardsWithoutCommit("conv-1", 0)).resolves.toBeNull();
354
+
355
+ expect(drainConversationNotices("conv-1")).toEqual([
356
+ {
357
+ type: "conversation_notice",
358
+ conversationId: "conv-1",
359
+ source: "memory_v3",
360
+ code: "UNKNOWN",
361
+ userMessage:
362
+ "Memory is temporarily unavailable, so this response may not use your saved memories. You can retry in a moment.",
363
+ errorCategory: "memory_v3_degraded",
364
+ },
365
+ ]);
366
+ });
367
+
338
368
  test("turn 1 renders cards; turn 2 re-selecting the same pages renders ZERO new cards", async () => {
339
369
  liveEnabled = true;
340
370
  turnResults.set(0, result(["page-a", "page-b"]));
@@ -26,6 +26,7 @@
26
26
  * The provider is stubbed so no network calls fire; mirrors selector.test.ts.
27
27
  */
28
28
 
29
+ import { createRequire } from "node:module";
29
30
  import { beforeEach, describe, expect, mock, test } from "bun:test";
30
31
 
31
32
  import type {
@@ -35,6 +36,7 @@ import type {
35
36
  SendMessageOptions,
36
37
  ToolUseContent,
37
38
  } from "../../../../providers/types.js";
39
+ import { ProviderError } from "../../../../util/errors.js";
38
40
  import type { MemoryRoutingTurn } from "../types.js";
39
41
 
40
42
  // ---------------------------------------------------------------------------
@@ -43,6 +45,11 @@ import type { MemoryRoutingTurn } from "../types.js";
43
45
  // ---------------------------------------------------------------------------
44
46
 
45
47
  let providerStub: Provider | null = null;
48
+ const registryReal = {
49
+ ...(createRequire(import.meta.url)(
50
+ "../../../../providers/registry.js",
51
+ ) as Record<string, unknown>),
52
+ };
46
53
 
47
54
  interface ProviderCall {
48
55
  messages: Message[];
@@ -57,6 +64,12 @@ mock.module("../../../../providers/provider-send-message.js", () => ({
57
64
  response.content.find((b): b is ToolUseContent => b.type === "tool_use"),
58
65
  }));
59
66
 
67
+ mock.module("../../../../providers/registry.js", () => ({
68
+ ...registryReal,
69
+ getProviderRoutingSource: (providerName: string) =>
70
+ providerName === "managed" ? "managed-proxy" : "user-key",
71
+ }));
72
+
60
73
  mock.module("../../../../util/logger.js", () => ({
61
74
  getLogger: () => ({
62
75
  warn: (...args: unknown[]) => warnCalls.push({ args }),
@@ -253,10 +266,9 @@ describe("selectPool — id mapping", () => {
253
266
  });
254
267
 
255
268
  // ---------------------------------------------------------------------------
256
- // selectPool — infrastructure failures THROW (no silent degradation). A
257
- // deliberate empty selection and an empty pool (covered above) still return
258
- // normally; only a genuine infra failure throws so the LIVE injector can
259
- // hard-fail the turn instead of shipping it with no memory.
269
+ // selectPool — infrastructure failures THROW. A deliberate empty selection and
270
+ // an empty pool (covered above) still return normally; only a genuine infra
271
+ // failure throws so callers can log it distinctly from an empty selection.
260
272
  // ---------------------------------------------------------------------------
261
273
 
262
274
  describe("selectPool — infrastructure failures throw", () => {
@@ -371,6 +383,38 @@ describe("selectPool — infrastructure failures throw", () => {
371
383
  ]);
372
384
  });
373
385
 
386
+ test("managed provider 402 attaches a non-terminal credits notice", async () => {
387
+ providerStub = {
388
+ name: "managed",
389
+ sendMessage: async (messages, options) => {
390
+ providerCalls.push({ messages, options });
391
+ throw new ProviderError(
392
+ "Together AI API error (402): 402 status code (no body)",
393
+ "managed",
394
+ 402,
395
+ );
396
+ },
397
+ };
398
+ let caught: unknown;
399
+ try {
400
+ await selectPool(makePool(), makeTurn("x"));
401
+ } catch (err) {
402
+ caught = err;
403
+ }
404
+
405
+ expect(caught).toBeInstanceOf(MemoryV3RetrievalUnavailableError);
406
+ const notice = (
407
+ caught as InstanceType<typeof MemoryV3RetrievalUnavailableError>
408
+ ).conversationNotice;
409
+ expect(notice).toEqual({
410
+ source: "memory_v3",
411
+ code: "PROVIDER_BILLING",
412
+ userMessage:
413
+ "You've run out of credits. Add funds to continue using the assistant.",
414
+ errorCategory: "credits_exhausted",
415
+ });
416
+ });
417
+
374
418
  test("provider throw redacts sensitive message details in diagnostics", async () => {
375
419
  const providerSecret = ["sk-proj-", "a".repeat(40)].join("");
376
420
  const message = `provider rejected Authorization: Bearer ${providerSecret}`;
@@ -808,7 +808,7 @@ describe("memory-v3 shadow plugin", () => {
808
808
  });
809
809
  });
810
810
 
811
- describe("memory-v3 infrastructure-failure handling (hard-fail vs swallow)", () => {
811
+ describe("memory-v3 infrastructure-failure handling", () => {
812
812
  const throwInfra = () =>
813
813
  orchestrateSpy.mockImplementationOnce(async () => {
814
814
  throw new MemoryV3RetrievalUnavailableError(
@@ -816,14 +816,12 @@ describe("memory-v3 infrastructure-failure handling (hard-fail vs swallow)", ()
816
816
  );
817
817
  });
818
818
 
819
- test("LIVE injector HARD-FAILS the turn on an infra failure (no silent memory loss)", async () => {
819
+ test("LIVE injector logs and degrades to no v3 block on an infra failure", async () => {
820
820
  liveEnabled = true;
821
821
  shadowEnabled = false;
822
822
  throwInfra();
823
823
 
824
- await expect(produce("conv-infra-live", 0)).rejects.toThrow(
825
- MemoryV3RetrievalUnavailableError,
826
- );
824
+ expect(await produce("conv-infra-live", 0)).toBeNull();
827
825
  });
828
826
 
829
827
  test("SHADOW injector swallows an infra failure (v2 fallback) — no throw, no block", async () => {
@@ -832,7 +830,7 @@ describe("memory-v3 infrastructure-failure handling (hard-fail vs swallow)", ()
832
830
  throwInfra();
833
831
 
834
832
  // Shadow mode: v2 retrieval still ran this turn, so the v3 injector returns
835
- // null rather than failing the turn.
833
+ // null.
836
834
  expect(await produce("conv-infra-shadow", 0)).toBeNull();
837
835
  });
838
836
 
@@ -853,8 +851,6 @@ describe("memory-v3 infrastructure-failure handling (hard-fail vs swallow)", ()
853
851
  throw new Error("some unexpected non-infra bug");
854
852
  });
855
853
 
856
- // Only INFRA failures hard-fail; any other error stays non-fatal so a bug
857
- // in one lane can't take every turn down.
858
854
  expect(await produce("conv-nonfatal-live", 0)).toBeNull();
859
855
  });
860
856
  });
@@ -63,6 +63,10 @@
63
63
  import { isAssistantFeatureFlagEnabled } from "../../../config/assistant-feature-flags.js";
64
64
  import { getConfig } from "../../../config/loader.js";
65
65
  import { isMemoryV3Live } from "../../../config/memory-v3-gate.js";
66
+ import {
67
+ type PendingConversationNotice,
68
+ queueConversationNotice,
69
+ } from "../../../daemon/conversation-notices.js";
66
70
  import { isPersonalMemoryAllowed } from "../../../daemon/trust-context.js";
67
71
  import {
68
72
  wrapMemoryBlock,
@@ -120,6 +124,26 @@ function lruSet<V>(map: Map<string, V>, key: string, value: V): void {
120
124
  map.set(key, value);
121
125
  }
122
126
 
127
+ function queueMemoryV3ConversationNotice(
128
+ err: MemoryV3RetrievalUnavailableError,
129
+ ctx: TurnContext,
130
+ live: boolean,
131
+ ): void {
132
+ if (!live) return;
133
+ const notice: PendingConversationNotice = err.conversationNotice ?? {
134
+ source: "memory_v3",
135
+ code: "UNKNOWN",
136
+ userMessage:
137
+ "Memory is temporarily unavailable, so this response may not use your saved memories. You can retry in a moment.",
138
+ errorCategory: "memory_v3_degraded",
139
+ };
140
+ queueConversationNotice(
141
+ ctx.conversationId,
142
+ `memory_v3:${ctx.turnIndex}:${notice.errorCategory ?? notice.code}`,
143
+ notice,
144
+ );
145
+ }
146
+
123
147
  // ─── shared per-turn orchestration memo ─────────────────────────────────────
124
148
 
125
149
  interface ObservedTurn {
@@ -245,16 +269,16 @@ export const memoryV3Injector: Injector = {
245
269
  try {
246
270
  observed = await observeTurnOnce(ctx.conversationId, ctx.turnIndex);
247
271
  } catch (err) {
248
- // A memory-v3 INFRASTRUCTURE failure (the selector lost its provider —
249
- // e.g. a transient CES credential blip). Under `memory-v3-live` the
250
- // user-prompt-submit hook already skipped v2 retrieval, so swallowing
251
- // here would ship the turn with NO memory at all — exactly the silent
252
- // degradation we want to eliminate. Hard-fail the turn instead (a clean,
253
- // retryable error). In shadow mode v2 still ran this turn, so fall back
254
- // to it (return null). Non-infra errors are already swallowed inside
255
- // observeTurn; anything else reaching here stays non-fatal.
256
- if (live && err instanceof MemoryV3RetrievalUnavailableError) {
257
- throw err;
272
+ if (err instanceof MemoryV3RetrievalUnavailableError) {
273
+ queueMemoryV3ConversationNotice(err, ctx, live);
274
+ log.error(
275
+ {
276
+ err: err.message,
277
+ conversationId: ctx.conversationId,
278
+ mode: live ? "live" : "shadow",
279
+ },
280
+ "memory-v3 selection failed; skipping v3 memory for this turn",
281
+ );
258
282
  }
259
283
  return null;
260
284
  }
@@ -405,12 +429,16 @@ export const memoryV3SpotlightInjector: Injector = {
405
429
  placement: "after-memory-prefix",
406
430
  };
407
431
  } catch (err) {
408
- // Live-only injector: an infra failure must hard-fail the turn. The cards
409
- // injector (ordered ahead of this one) normally throws first, so this
410
- // path is defensive — it keeps the behavior correct if the cards injector
411
- // is ever disabled or reordered.
412
432
  if (err instanceof MemoryV3RetrievalUnavailableError) {
413
- throw err;
433
+ queueMemoryV3ConversationNotice(err, ctx, true);
434
+ log.error(
435
+ {
436
+ err: err.message,
437
+ conversationId: ctx.conversationId,
438
+ },
439
+ "memory-v3 spotlight selection failed; skipping spotlight",
440
+ );
441
+ return null;
414
442
  }
415
443
  log.warn(
416
444
  {
@@ -41,15 +41,15 @@
41
41
  * - infrastructure failure (selector provider unavailable — e.g. a transient
42
42
  * CES credential blip drops the API key — or no usable `tool_use` / schema
43
43
  * mismatch surviving the short re-prompt retry) → throw
44
- * {@link MemoryV3RetrievalUnavailableError}. There is NO deterministic-lane
45
- * fallback: the LIVE injector propagates this to hard-fail the turn
46
- * (retryable) rather than silently shipping it with no `<memory>` block,
47
- * while the shadow/observation path swallows it and lets v2 retrieval serve
48
- * the turn.
44
+ * {@link MemoryV3RetrievalUnavailableError}. The live injector treats this
45
+ * as a logged memory miss for the turn; shadow/observation callers swallow
46
+ * it so v2 retrieval can serve the turn.
49
47
  */
50
48
 
51
49
  import { z } from "zod";
52
50
 
51
+ import { classifyConversationError } from "../../../daemon/conversation-error.js";
52
+ import type { PendingConversationNotice } from "../../../daemon/conversation-notices.js";
53
53
  import { loadPromptOverride } from "../../../memory/prompt-override.js";
54
54
  import { cachedTextBlock } from "../../../providers/cache-control.js";
55
55
  import {
@@ -77,17 +77,43 @@ const log = getLogger("memory-v3-pool-select");
77
77
  * re-prompt retry. Deliberately DISTINCT from a deliberate empty selection
78
78
  * (`ids: []`) and an empty candidate pool, both of which return normally.
79
79
  *
80
- * The LIVE memory-v3 injector propagates this to hard-fail the turn (a clean,
81
- * retryable failure) rather than silently shipping with no memory; the
80
+ * The live memory-v3 injector logs this as a memory miss for the turn; the
82
81
  * shadow/observation path catches and swallows it.
83
82
  */
84
83
  export class MemoryV3RetrievalUnavailableError extends Error {
85
- constructor(message: string) {
86
- super(message);
84
+ readonly conversationNotice?: PendingConversationNotice;
85
+
86
+ constructor(
87
+ message: string,
88
+ options?: {
89
+ cause?: unknown;
90
+ conversationNotice?: PendingConversationNotice;
91
+ },
92
+ ) {
93
+ super(
94
+ message,
95
+ options?.cause === undefined ? undefined : { cause: options.cause },
96
+ );
87
97
  this.name = "MemoryV3RetrievalUnavailableError";
98
+ this.conversationNotice = options?.conversationNotice;
88
99
  }
89
100
  }
90
101
 
102
+ function providerBillingNoticeFromError(
103
+ error: unknown,
104
+ ): PendingConversationNotice | undefined {
105
+ const classified = classifyConversationError(error, {
106
+ phase: "agent_loop",
107
+ });
108
+ if (classified.code !== "PROVIDER_BILLING") return undefined;
109
+ return {
110
+ source: "memory_v3",
111
+ code: classified.code,
112
+ userMessage: classified.userMessage,
113
+ errorCategory: classified.errorCategory,
114
+ };
115
+ }
116
+
91
117
  /** A dynamic-tail (finder) candidate: the slug plus the descriptor that
92
118
  * justifies it — a matched section for a needle/dense hit, or a curated link
93
119
  * description for an edge page. Rendered as a one-line snippet, prefixed
@@ -368,7 +394,7 @@ export async function selectPool(
368
394
  stableCount: pool.stable.length,
369
395
  finderCount: pool.finder.length,
370
396
  },
371
- "pool selector provider unavailable — failing the turn rather than dropping memory",
397
+ "pool selector provider unavailable",
372
398
  );
373
399
  throw new MemoryV3RetrievalUnavailableError(
374
400
  "memory-v3 pool selector provider unavailable",
@@ -422,6 +448,12 @@ export async function selectPool(
422
448
  // (no usable tool_use, or tool input that fails the schema) re-prompts before
423
449
  // we give up. `null` from an attempt means "unusable, retry"; the provider
424
450
  // layer already backs off transient throws, so this loop adds no delay.
451
+ //
452
+ // `lastError` captures the most recent attempt's thrown provider error —
453
+ // `retryForResult` swallows attempt throws, so without this an infrastructure
454
+ // failure (e.g. an upstream HTTP 4xx/5xx) is indistinguishable from a 200 that
455
+ // carried no usable tool_use. It is cleared on every attempt that reaches a
456
+ // response, so it reflects the LAST attempt's failure mode.
425
457
  let lastError: unknown = null;
426
458
  const parsed = await retryForResult(async () => {
427
459
  attempt += 1;
@@ -504,10 +536,14 @@ export async function selectPool(
504
536
  providerName: provider.name,
505
537
  failures,
506
538
  },
507
- "pool selector provider call failed after retries — failing the turn rather than dropping memory",
539
+ "pool selector provider call failed after retries",
508
540
  );
509
541
  throw new MemoryV3RetrievalUnavailableError(
510
542
  `memory-v3 pool selector provider call failed after retries: ${redactedDetail}`,
543
+ {
544
+ cause: lastError,
545
+ conversationNotice: providerBillingNoticeFromError(lastError),
546
+ },
511
547
  );
512
548
  }
513
549
  log.warn(
@@ -519,7 +555,7 @@ export async function selectPool(
519
555
  providerName: provider.name,
520
556
  failures,
521
557
  },
522
- "pool selector returned no usable tool_use after retries — failing the turn rather than dropping memory",
558
+ "pool selector returned no usable tool_use after retries",
523
559
  );
524
560
  throw new MemoryV3RetrievalUnavailableError(
525
561
  "memory-v3 pool selector returned no usable selection after retries",
@@ -605,13 +605,9 @@ export async function observeTurn(
605
605
  writeSelections(conversationId, turnIndex, rows);
606
606
  return result;
607
607
  } catch (err) {
608
- // An INFRASTRUCTURE failure (the selector lost its provider — e.g. a
609
- // transient CES credential blip) must NOT be silently swallowed: re-throw
610
- // so the LIVE injector hard-fails the turn (a clean, retryable failure)
611
- // rather than shipping it with no `<memory>` block. The shadow/observation
612
- // callers (the injector in shadow mode, runShadowObservation) catch this
613
- // and swallow it, so observation never fails a turn. Other (non-infra)
614
- // errors stay non-fatal and degrade to no v3 block, as before.
608
+ // Infrastructure failures are surfaced to callers that want distinct
609
+ // logging from ordinary orchestration misses. Observation callers swallow
610
+ // them so memory-v3 never fails the turn.
615
611
  if (err instanceof MemoryV3RetrievalUnavailableError) {
616
612
  throw err;
617
613
  }
@@ -641,8 +637,6 @@ export async function runShadowObservation(
641
637
  try {
642
638
  await observeTurn(conversationId, turnIndex);
643
639
  } catch {
644
- // Shadow observation is fire-and-forget and must NEVER fail a turn.
645
- // `observeTurn` now re-throws infra failures so the LIVE injector can
646
- // hard-fail on them; here (observation only) we swallow them.
640
+ // Shadow observation is fire-and-forget and must never fail a turn.
647
641
  }
648
642
  }
@@ -4,9 +4,10 @@
4
4
  * A "hook" is a named lifecycle event (`user-prompt-submit`, `post-tool-use`,
5
5
  * ...) that every registered plugin may handle. The runner walks each plugin's
6
6
  * hook for a given event in registration order, threading a context value
7
- * through the chain so hooks can observe and transform it. A hook either
8
- * mutates the context in place (returning `void`) or returns a partial
9
- * context whose fields are merged onto the threaded value.
7
+ * through the chain so hooks can observe and transform it. Each hook receives
8
+ * an isolated draft of the current context. A hook either mutates the draft in
9
+ * place (returning `void`) or returns a partial context whose fields are merged
10
+ * onto the draft. Failed hook drafts are discarded.
10
11
  *
11
12
  * `getHooksFor` is now async — it pulls user-land hooks from the mtime
12
13
  * cache (filesystem-as-truth) and default plugin hooks from the registry
@@ -16,34 +17,131 @@
16
17
  */
17
18
 
18
19
  import type { HookName } from "../plugin-api/constants.js";
20
+ import { getLogger } from "../util/logger.js";
19
21
  import { getHooksFor } from "./registry.js";
22
+ import type { PluginHookFn } from "./types.js";
20
23
 
21
24
  // ─── Hook runner ────────────────────────────────────────────────────────────
22
25
 
26
+ const log = getLogger("plugin-pipeline");
27
+
28
+ function isPluginLogger(value: unknown): value is {
29
+ info: unknown;
30
+ warn: unknown;
31
+ error: unknown;
32
+ debug: unknown;
33
+ } {
34
+ return (
35
+ value !== null &&
36
+ typeof value === "object" &&
37
+ typeof (value as { info?: unknown }).info === "function" &&
38
+ typeof (value as { warn?: unknown }).warn === "function" &&
39
+ typeof (value as { error?: unknown }).error === "function" &&
40
+ typeof (value as { debug?: unknown }).debug === "function"
41
+ );
42
+ }
43
+
44
+ function isPlainObject(value: object): boolean {
45
+ const prototype = Object.getPrototypeOf(value);
46
+ return prototype === Object.prototype || prototype === null;
47
+ }
48
+
49
+ function cloneHookValue<T>(value: T, seen = new WeakMap<object, unknown>()): T {
50
+ if (value === null || typeof value !== "object") return value;
51
+ if (value instanceof Error || isPluginLogger(value)) return value;
52
+
53
+ const existing = seen.get(value);
54
+ if (existing !== undefined) return existing as T;
55
+
56
+ if (Array.isArray(value)) {
57
+ const copy: unknown[] = [];
58
+ seen.set(value, copy);
59
+ for (const item of value) {
60
+ copy.push(cloneHookValue(item, seen));
61
+ }
62
+ return copy as T;
63
+ }
64
+
65
+ if (value instanceof Date) {
66
+ return new Date(value.getTime()) as T;
67
+ }
68
+
69
+ if (value instanceof Map) {
70
+ const copy = new Map();
71
+ seen.set(value, copy);
72
+ for (const [key, mapValue] of value) {
73
+ copy.set(cloneHookValue(key, seen), cloneHookValue(mapValue, seen));
74
+ }
75
+ return copy as T;
76
+ }
77
+
78
+ if (value instanceof Set) {
79
+ const copy = new Set();
80
+ seen.set(value, copy);
81
+ for (const item of value) {
82
+ copy.add(cloneHookValue(item, seen));
83
+ }
84
+ return copy as T;
85
+ }
86
+
87
+ if (!isPlainObject(value)) return value;
88
+
89
+ const copy: Record<PropertyKey, unknown> = {};
90
+ seen.set(value, copy);
91
+ for (const key of Reflect.ownKeys(value)) {
92
+ copy[key] = cloneHookValue(
93
+ (value as Record<PropertyKey, unknown>)[key],
94
+ seen,
95
+ );
96
+ }
97
+ return copy as T;
98
+ }
99
+
23
100
  /**
24
101
  * Execute a hook chain: walk every registered plugin's hook for `name` in
25
102
  * registration order, threading `initialCtx` through each. Hooks may either
26
- * mutate the context in place (returning `void`) or return a partial context
27
- * whose fields are merged onto the threaded value — keys the hook returns
28
- * overwrite the running context, every other field is preserved. The final
29
- * context after the chain settles is returned.
103
+ * mutate their draft context in place (returning `void`) or return a partial
104
+ * context whose fields are merged onto the draft — keys the hook returns
105
+ * overwrite the running context, every other field is preserved. If a hook
106
+ * throws, its draft is discarded and the next hook receives the last
107
+ * successfully committed context. The final context after the chain settles is
108
+ * returned.
30
109
  *
31
110
  * @param name The hook identifier — pick one from {@link HOOKS}.
32
111
  * @param initialCtx Context the first hook receives.
33
112
  * @returns The final context after the chain settles. Same reference as
34
- * `initialCtx` when no plugin registers `name`, and when every
35
- * chained hook returns `void` (mutation-in-place style).
113
+ * `initialCtx` when no plugin registers `name`.
36
114
  */
37
115
  export async function runHook<TCtx>(
38
116
  name: HookName,
39
117
  initialCtx: TCtx,
40
118
  ): Promise<TCtx> {
41
- const hooks = await getHooksFor<TCtx>(name);
119
+ let hooks: PluginHookFn<TCtx>[];
120
+ try {
121
+ hooks = await getHooksFor<TCtx>(name);
122
+ } catch (err) {
123
+ log.error(
124
+ { err, hookName: name },
125
+ "plugin hook discovery failed — proceeding without hooks",
126
+ );
127
+ return initialCtx;
128
+ }
129
+
42
130
  let active = initialCtx;
43
131
  for (const hook of hooks) {
44
- const result = await hook(active);
45
- if (result !== undefined) {
46
- active = { ...active, ...result };
132
+ const draft = cloneHookValue(active);
133
+ try {
134
+ const result = await hook(draft);
135
+ if (result !== undefined) {
136
+ active = { ...draft, ...result };
137
+ } else {
138
+ active = draft;
139
+ }
140
+ } catch (err) {
141
+ log.error(
142
+ { err, hookName: name },
143
+ "plugin hook failed — proceeding with current context",
144
+ );
47
145
  }
48
146
  }
49
147
  return active;