diff --git a/assistant/src/__tests__/agent-loop-override-profile.test.ts b/assistant/src/__tests__/agent-loop-override-profile.test.ts index ce36359fd32..5c606cc5f42 100644 --- a/assistant/src/__tests__/agent-loop-override-profile.test.ts +++ b/assistant/src/__tests__/agent-loop-override-profile.test.ts @@ -353,6 +353,7 @@ mock.module("../memory/conversation-crud.js", () => ({ // Always return undefined for the row read so the test fails fast unless // executeSubagentSpawn reads from context.overrideProfile first. getConversationOverrideProfile: () => undefined, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); import { getSubagentManager } from "../subagent/index.js"; diff --git a/assistant/src/__tests__/agent-wake-disk-pressure-callsite.test.ts b/assistant/src/__tests__/agent-wake-disk-pressure-callsite.test.ts index 7d1147582b7..4694aea641b 100644 --- a/assistant/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +++ b/assistant/src/__tests__/agent-wake-disk-pressure-callsite.test.ts @@ -22,6 +22,7 @@ import type { Message } from "../providers/types.js"; mock.module("../memory/conversation-crud.js", () => ({ getConversationOverrideProfile: () => undefined, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../config/loader.js", () => ({ diff --git a/assistant/src/__tests__/agent-wake-override-profile.test.ts b/assistant/src/__tests__/agent-wake-override-profile.test.ts index c621456cd74..7e45c46ec59 100644 --- a/assistant/src/__tests__/agent-wake-override-profile.test.ts +++ b/assistant/src/__tests__/agent-wake-override-profile.test.ts @@ -32,6 +32,7 @@ let mockOverrideProfile: string | undefined = undefined; mock.module("../memory/conversation-crud.js", () => ({ getConversationOverrideProfile: (_id: string) => mockOverrideProfile, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); // Mutable stub for `getConfig().llm` consumed by `RetryProvider`'s diff --git a/assistant/src/__tests__/annotate-risk-options.test.ts b/assistant/src/__tests__/annotate-risk-options.test.ts index 92fcb61c3a9..97c62ac21fe 100644 --- a/assistant/src/__tests__/annotate-risk-options.test.ts +++ b/assistant/src/__tests__/annotate-risk-options.test.ts @@ -57,6 +57,7 @@ mock.module("../memory/conversation-crud.js", () => ({ updates.push({ id, content }); }, provenanceFromTrustContext: () => ({}), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/llm-request-log-store.js", () => ({ diff --git a/assistant/src/__tests__/approval-cascade.test.ts b/assistant/src/__tests__/approval-cascade.test.ts index 24fd5d743f8..700762339b6 100644 --- a/assistant/src/__tests__/approval-cascade.test.ts +++ b/assistant/src/__tests__/approval-cascade.test.ts @@ -151,6 +151,7 @@ mock.module("../memory/conversation-crud.js", () => ({ addMessage: () => ({ id: `msg-${Date.now()}` }), updateConversationUsage: () => {}, updateConversationTitle: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/background-workers-disk-pressure.test.ts b/assistant/src/__tests__/background-workers-disk-pressure.test.ts index 0565bed61a3..4188ed39cf0 100644 --- a/assistant/src/__tests__/background-workers-disk-pressure.test.ts +++ b/assistant/src/__tests__/background-workers-disk-pressure.test.ts @@ -152,6 +152,7 @@ mock.module("../memory/conversation-crud.js", () => ({ setLastNotifiedInferenceProfile: mock(() => {}), setConversationHistoryStrippedAt: mock(() => {}), wipeConversation: mock(() => ({ memoryIds: [] })), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-title-service.js", () => ({ diff --git a/assistant/src/__tests__/btw-routes.test.ts b/assistant/src/__tests__/btw-routes.test.ts index db2e9b8951d..9e3770fce20 100644 --- a/assistant/src/__tests__/btw-routes.test.ts +++ b/assistant/src/__tests__/btw-routes.test.ts @@ -38,6 +38,7 @@ const mockAddMessage = mock(() => {}); mock.module("../memory/conversation-crud.js", () => ({ addMessage: mockAddMessage, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); const MOCK_TOOLS = [ diff --git a/assistant/src/__tests__/channel-reply-delivery.test.ts b/assistant/src/__tests__/channel-reply-delivery.test.ts index 2f7b20970f8..c0d95053a96 100644 --- a/assistant/src/__tests__/channel-reply-delivery.test.ts +++ b/assistant/src/__tests__/channel-reply-delivery.test.ts @@ -128,6 +128,7 @@ mock.module("../memory/conversation-crud.js", () => ({ : {}; row.metadata = JSON.stringify({ ...existing, ...updates }); }, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/attachments-store.js", () => ({ diff --git a/assistant/src/__tests__/compaction-events.test.ts b/assistant/src/__tests__/compaction-events.test.ts index f90dc06f02e..d5ab0a8312b 100644 --- a/assistant/src/__tests__/compaction-events.test.ts +++ b/assistant/src/__tests__/compaction-events.test.ts @@ -150,6 +150,7 @@ mock.module("../memory/conversation-crud.js", () => ({ addMessage: () => ({ id: `msg-${Date.now()}` }), updateConversationUsage: () => {}, updateConversationTitle: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/compactor-call-site-logging.test.ts b/assistant/src/__tests__/compactor-call-site-logging.test.ts index 03fb7d6f7bb..63066b5e97f 100644 --- a/assistant/src/__tests__/compactor-call-site-logging.test.ts +++ b/assistant/src/__tests__/compactor-call-site-logging.test.ts @@ -31,6 +31,7 @@ mock.module("../util/logger.js", () => ({ mock.module("../memory/conversation-crud.js", () => ({ getMessages: () => [], + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/attachments-store.js", () => ({ diff --git a/assistant/src/__tests__/compactor-preserved-tail-count.test.ts b/assistant/src/__tests__/compactor-preserved-tail-count.test.ts index d0797418a55..00973e6d69c 100644 --- a/assistant/src/__tests__/compactor-preserved-tail-count.test.ts +++ b/assistant/src/__tests__/compactor-preserved-tail-count.test.ts @@ -23,6 +23,7 @@ mock.module("../util/logger.js", () => ({ mock.module("../memory/conversation-crud.js", () => ({ getMessages: () => [], + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/attachments-store.js", () => ({ diff --git a/assistant/src/__tests__/conversation-abort-tool-results.test.ts b/assistant/src/__tests__/conversation-abort-tool-results.test.ts index ec9bfc5546c..642f955bfa4 100644 --- a/assistant/src/__tests__/conversation-abort-tool-results.test.ts +++ b/assistant/src/__tests__/conversation-abort-tool-results.test.ts @@ -138,6 +138,7 @@ mock.module("../memory/conversation-crud.js", () => ({ updateConversationTitle: () => {}, getMessageById: () => null, getLastUserTimestampBefore: () => 0, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-agent-loop-disk-pressure.test.ts b/assistant/src/__tests__/conversation-agent-loop-disk-pressure.test.ts index 0110037d4d9..cf1e1c860c1 100644 --- a/assistant/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +++ b/assistant/src/__tests__/conversation-agent-loop-disk-pressure.test.ts @@ -100,6 +100,7 @@ mock.module("../memory/conversation-crud.js", () => ({ setConversationHistoryStrippedAt: () => {}, updateConversationContextWindow: () => {}, updateConversationSlackContextWatermark: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); import { runAgentLoopImpl } from "../daemon/conversation-agent-loop.js"; diff --git a/assistant/src/__tests__/conversation-agent-loop-inference-profile.test.ts b/assistant/src/__tests__/conversation-agent-loop-inference-profile.test.ts index 9d4b57bfdd8..3f0d94e2c26 100644 --- a/assistant/src/__tests__/conversation-agent-loop-inference-profile.test.ts +++ b/assistant/src/__tests__/conversation-agent-loop-inference-profile.test.ts @@ -170,6 +170,7 @@ mock.module("../memory/conversation-crud.js", () => ({ getMessageById: () => null, getLastUserTimestampBefore: () => 0, setLastNotifiedInferenceProfile: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-disk-view.js", () => ({ diff --git a/assistant/src/__tests__/conversation-agent-loop-overflow.test.ts b/assistant/src/__tests__/conversation-agent-loop-overflow.test.ts index 8d5f98d321e..cba8d6d93b6 100644 --- a/assistant/src/__tests__/conversation-agent-loop-overflow.test.ts +++ b/assistant/src/__tests__/conversation-agent-loop-overflow.test.ts @@ -198,6 +198,7 @@ mock.module("../memory/conversation-crud.js", () => ({ setLastNotifiedInferenceProfile: () => {}, getLastUserTimestampBefore: () => 0, getConversationOverrideProfileFromRow: () => undefined, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); afterAll(() => { diff --git a/assistant/src/__tests__/conversation-agent-loop.test.ts b/assistant/src/__tests__/conversation-agent-loop.test.ts index 9289f660442..6afebd292f8 100644 --- a/assistant/src/__tests__/conversation-agent-loop.test.ts +++ b/assistant/src/__tests__/conversation-agent-loop.test.ts @@ -197,6 +197,7 @@ mock.module("../memory/conversation-crud.js", () => ({ getConversationOriginChannel: () => null, getMessageById: () => mockMessageById, getLastUserTimestampBefore: () => 0, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); afterAll(() => { diff --git a/assistant/src/__tests__/conversation-analysis-routes.test.ts b/assistant/src/__tests__/conversation-analysis-routes.test.ts index 74d900108a5..8efd0108a6d 100644 --- a/assistant/src/__tests__/conversation-analysis-routes.test.ts +++ b/assistant/src/__tests__/conversation-analysis-routes.test.ts @@ -26,6 +26,7 @@ mock.module("../memory/conversation-crud.js", () => ({ getMessages: mockGetMessages, createConversation: mockCreateConversation, addMessage: mockAddMessage, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../export/transcript-formatter.js", () => ({ diff --git a/assistant/src/__tests__/conversation-app-control-lifecycle.test.ts b/assistant/src/__tests__/conversation-app-control-lifecycle.test.ts index 431e71b6d90..29612fcd4a9 100644 --- a/assistant/src/__tests__/conversation-app-control-lifecycle.test.ts +++ b/assistant/src/__tests__/conversation-app-control-lifecycle.test.ts @@ -92,6 +92,7 @@ mock.module("../memory/conversation-crud.js", () => ({ addMessage: async () => ({ id: "persisted-1" }), setConversationOriginChannelIfUnset: () => {}, setConversationOriginInterfaceIfUnset: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-confirmation-signals.test.ts b/assistant/src/__tests__/conversation-confirmation-signals.test.ts index 3064ebeda27..3298fd11211 100644 --- a/assistant/src/__tests__/conversation-confirmation-signals.test.ts +++ b/assistant/src/__tests__/conversation-confirmation-signals.test.ts @@ -142,6 +142,7 @@ mock.module("../memory/conversation-crud.js", () => ({ addMessage: () => ({ id: `msg-${Date.now()}` }), updateConversationUsage: () => {}, updateConversationTitle: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-history-web-search.test.ts b/assistant/src/__tests__/conversation-history-web-search.test.ts index 1bf412e9875..f52fe4ea9b1 100644 --- a/assistant/src/__tests__/conversation-history-web-search.test.ts +++ b/assistant/src/__tests__/conversation-history-web-search.test.ts @@ -50,6 +50,7 @@ mock.module("../memory/conversation-crud.js", () => ({ }, relinkAttachments: () => 0, deleteLastExchange: () => 0, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-lifecycle.test.ts b/assistant/src/__tests__/conversation-lifecycle.test.ts index 65e3e8438a5..7777035b410 100644 --- a/assistant/src/__tests__/conversation-lifecycle.test.ts +++ b/assistant/src/__tests__/conversation-lifecycle.test.ts @@ -102,6 +102,7 @@ mock.module("../memory/conversation-crud.js", () => ({ }, setConversationOriginChannelIfUnset: () => {}, setConversationOriginInterfaceIfUnset: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-load-history-repair.test.ts b/assistant/src/__tests__/conversation-load-history-repair.test.ts index 226403d86ba..11fef5c1652 100644 --- a/assistant/src/__tests__/conversation-load-history-repair.test.ts +++ b/assistant/src/__tests__/conversation-load-history-repair.test.ts @@ -104,6 +104,7 @@ mock.module("../memory/conversation-crud.js", () => ({ }, setConversationOriginChannelIfUnset: () => {}, setConversationOriginInterfaceIfUnset: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-load-history-stripped.test.ts b/assistant/src/__tests__/conversation-load-history-stripped.test.ts index 3911c31c382..1b886cf874b 100644 --- a/assistant/src/__tests__/conversation-load-history-stripped.test.ts +++ b/assistant/src/__tests__/conversation-load-history-stripped.test.ts @@ -87,6 +87,7 @@ mock.module("../memory/conversation-crud.js", () => ({ setConversationHistoryStrippedAt: () => {}, setConversationOriginChannelIfUnset: () => {}, setConversationOriginInterfaceIfUnset: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-pairing.test.ts b/assistant/src/__tests__/conversation-pairing.test.ts index 2cdadcb1e4e..e5f0298c99f 100644 --- a/assistant/src/__tests__/conversation-pairing.test.ts +++ b/assistant/src/__tests__/conversation-pairing.test.ts @@ -67,6 +67,7 @@ mock.module("../memory/conversation-crud.js", () => ({ createConversation: createConversationMock, addMessage: addMessageMock, getConversation: getConversationMock, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); /** Simulated bindings for external-conversation-store mock. */ diff --git a/assistant/src/__tests__/conversation-process-app-control-preactivation.test.ts b/assistant/src/__tests__/conversation-process-app-control-preactivation.test.ts index b451dc99c9d..4be45ffc4ec 100644 --- a/assistant/src/__tests__/conversation-process-app-control-preactivation.test.ts +++ b/assistant/src/__tests__/conversation-process-app-control-preactivation.test.ts @@ -50,6 +50,7 @@ mock.module("../memory/conversation-crud.js", () => ({ trustContext: undefined, }), addMessage: () => ({ id: "msg-mock" }), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/canonical-guardian-store.js", () => ({ diff --git a/assistant/src/__tests__/conversation-process-callsite.test.ts b/assistant/src/__tests__/conversation-process-callsite.test.ts index ae0f7033757..f82c1e32434 100644 --- a/assistant/src/__tests__/conversation-process-callsite.test.ts +++ b/assistant/src/__tests__/conversation-process-callsite.test.ts @@ -183,6 +183,7 @@ mock.module("../memory/conversation-crud.js", () => ({ updateConversationTitle: () => {}, getMessageById: () => null, getLastUserTimestampBefore: () => 0, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-provider-retry-repair.test.ts b/assistant/src/__tests__/conversation-provider-retry-repair.test.ts index d7609b5206e..286d0de9b0c 100644 --- a/assistant/src/__tests__/conversation-provider-retry-repair.test.ts +++ b/assistant/src/__tests__/conversation-provider-retry-repair.test.ts @@ -185,6 +185,7 @@ mock.module("../memory/conversation-crud.js", () => ({ provenanceFromTrustContext: () => ({}), getMessageById: () => null, getLastUserTimestampBefore: () => 0, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-queue.test.ts b/assistant/src/__tests__/conversation-queue.test.ts index d256eef9b7b..f62da7d1082 100644 --- a/assistant/src/__tests__/conversation-queue.test.ts +++ b/assistant/src/__tests__/conversation-queue.test.ts @@ -193,6 +193,7 @@ mock.module("../memory/conversation-crud.js", () => ({ updateConversationTitle: () => {}, getMessageById: () => null, getLastUserTimestampBefore: () => 0, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-routes-guardian-reply.test.ts b/assistant/src/__tests__/conversation-routes-guardian-reply.test.ts index c337c9dec27..049420bded8 100644 --- a/assistant/src/__tests__/conversation-routes-guardian-reply.test.ts +++ b/assistant/src/__tests__/conversation-routes-guardian-reply.test.ts @@ -83,6 +83,7 @@ mock.module("../memory/conversation-crud.js", () => ({ content: string, metadata?: Record, ) => addMessageMock(conversationId, role, content, metadata), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../runtime/local-actor-identity.js", () => ({ diff --git a/assistant/src/__tests__/conversation-routes-slash-commands.test.ts b/assistant/src/__tests__/conversation-routes-slash-commands.test.ts index cf3295591c5..0eda843653e 100644 --- a/assistant/src/__tests__/conversation-routes-slash-commands.test.ts +++ b/assistant/src/__tests__/conversation-routes-slash-commands.test.ts @@ -139,6 +139,7 @@ mock.module("../memory/conversation-crud.js", () => ({ : { provenanceTrustClass: "unknown" }, setConversationOriginChannelIfUnset: () => {}, setConversationOriginInterfaceIfUnset: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../daemon/conversation-process.js", () => ({ diff --git a/assistant/src/__tests__/conversation-slash-queue.test.ts b/assistant/src/__tests__/conversation-slash-queue.test.ts index ed87b181d5f..ae319f04609 100644 --- a/assistant/src/__tests__/conversation-slash-queue.test.ts +++ b/assistant/src/__tests__/conversation-slash-queue.test.ts @@ -135,6 +135,7 @@ mock.module("../memory/conversation-crud.js", () => ({ updateConversationTitle: () => {}, getMessageById: () => null, getLastUserTimestampBefore: () => 0, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-slash-unknown.test.ts b/assistant/src/__tests__/conversation-slash-unknown.test.ts index f7abadda9bd..098f6174810 100644 --- a/assistant/src/__tests__/conversation-slash-unknown.test.ts +++ b/assistant/src/__tests__/conversation-slash-unknown.test.ts @@ -136,6 +136,7 @@ mock.module("../memory/conversation-crud.js", () => ({ updateConversationTitle: () => {}, getMessageById: () => null, getLastUserTimestampBefore: () => 0, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-speed-override.test.ts b/assistant/src/__tests__/conversation-speed-override.test.ts index 6b540ed8e49..9e2be67b41b 100644 --- a/assistant/src/__tests__/conversation-speed-override.test.ts +++ b/assistant/src/__tests__/conversation-speed-override.test.ts @@ -150,6 +150,7 @@ mock.module("../memory/conversation-crud.js", () => ({ addMessage: () => ({ id: `msg-${Date.now()}` }), updateConversationUsage: () => {}, updateConversationTitle: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-surfaces-data-persist.test.ts b/assistant/src/__tests__/conversation-surfaces-data-persist.test.ts index 752501269a9..4f2055dc2e0 100644 --- a/assistant/src/__tests__/conversation-surfaces-data-persist.test.ts +++ b/assistant/src/__tests__/conversation-surfaces-data-persist.test.ts @@ -29,6 +29,7 @@ mock.module("../memory/conversation-crud.js", () => ({ getMessages: (conversationId: string) => getMessagesImpl(conversationId), updateMessageContent: (id: string, content: string) => updateMessageContentSpy(id, content), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); // Imports must come AFTER mock.module so the surface module picks up diff --git a/assistant/src/__tests__/conversation-title-service.test.ts b/assistant/src/__tests__/conversation-title-service.test.ts index 93213b8b8f3..2a4d9057df9 100644 --- a/assistant/src/__tests__/conversation-title-service.test.ts +++ b/assistant/src/__tests__/conversation-title-service.test.ts @@ -37,6 +37,7 @@ mock.module("../memory/conversation-crud.js", () => ({ getConversation: mockGetConversation, getMessages: mockGetMessages, updateConversationTitle: mockUpdateConversationTitle, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../providers/provider-send-message.js", () => ({ diff --git a/assistant/src/__tests__/conversation-usage.test.ts b/assistant/src/__tests__/conversation-usage.test.ts index e8c9cbf1bdc..17b296282ef 100644 --- a/assistant/src/__tests__/conversation-usage.test.ts +++ b/assistant/src/__tests__/conversation-usage.test.ts @@ -36,6 +36,7 @@ mock.module("../memory/conversation-crud.js", () => ({ estimatedCost, }); }, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); import { recordUsage } from "../daemon/conversation-usage.js"; diff --git a/assistant/src/__tests__/conversation-workspace-cache-state.test.ts b/assistant/src/__tests__/conversation-workspace-cache-state.test.ts index 9a330d17437..ffc4a2e43f7 100644 --- a/assistant/src/__tests__/conversation-workspace-cache-state.test.ts +++ b/assistant/src/__tests__/conversation-workspace-cache-state.test.ts @@ -111,6 +111,7 @@ mock.module("../memory/conversation-crud.js", () => ({ updateConversationContextWindow: () => {}, deleteMessageById: () => ({ segmentIds: [], deletedSummaryIds: [] }), deleteLastExchange: () => 0, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-workspace-injection.test.ts b/assistant/src/__tests__/conversation-workspace-injection.test.ts index 6c73cb4a4a1..c1ccac68ad2 100644 --- a/assistant/src/__tests__/conversation-workspace-injection.test.ts +++ b/assistant/src/__tests__/conversation-workspace-injection.test.ts @@ -145,6 +145,7 @@ mock.module("../memory/conversation-crud.js", () => ({ setLastNotifiedInferenceProfile: () => {}, getConversationOverrideProfileFromRow: () => undefined, updateMessageMetadata: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/conversation-workspace-tool-tracking.test.ts b/assistant/src/__tests__/conversation-workspace-tool-tracking.test.ts index 72c7402347c..17b01ce7f4c 100644 --- a/assistant/src/__tests__/conversation-workspace-tool-tracking.test.ts +++ b/assistant/src/__tests__/conversation-workspace-tool-tracking.test.ts @@ -142,6 +142,7 @@ mock.module("../memory/conversation-crud.js", () => ({ setLastNotifiedInferenceProfile: () => {}, getConversationOverrideProfileFromRow: () => undefined, updateMessageMetadata: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/dm-persistence.test.ts b/assistant/src/__tests__/dm-persistence.test.ts index 7909fcf5d77..739d6d759f1 100644 --- a/assistant/src/__tests__/dm-persistence.test.ts +++ b/assistant/src/__tests__/dm-persistence.test.ts @@ -43,6 +43,7 @@ mock.module("../memory/conversation-crud.js", () => ({ provenanceFromTrustContext: () => ({}), setConversationOriginChannelIfUnset: () => {}, setConversationOriginInterfaceIfUnset: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-disk-view.js", () => ({ diff --git a/assistant/src/__tests__/filing-service.test.ts b/assistant/src/__tests__/filing-service.test.ts index ea2d9bbfc4b..2ed0ee1e63c 100644 --- a/assistant/src/__tests__/filing-service.test.ts +++ b/assistant/src/__tests__/filing-service.test.ts @@ -83,6 +83,7 @@ mock.module("../memory/conversation-crud.js", () => ({ createdConversations.push(opts); return { id: `conv-${++conversationIdCounter}`, ...opts }; }, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); // Mock logger diff --git a/assistant/src/__tests__/handlers-user-message-approval-consumption.test.ts b/assistant/src/__tests__/handlers-user-message-approval-consumption.test.ts index 8621c0c2441..f98e9048ccd 100644 --- a/assistant/src/__tests__/handlers-user-message-approval-consumption.test.ts +++ b/assistant/src/__tests__/handlers-user-message-approval-consumption.test.ts @@ -92,6 +92,7 @@ mock.module("../runtime/pending-interactions.js", () => ({ mock.module("../memory/conversation-crud.js", () => ({ addMessage: mock(async () => ({ id: "persisted-message-id" })), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../config/loader.js", () => ({ diff --git a/assistant/src/__tests__/heartbeat-disk-pressure.test.ts b/assistant/src/__tests__/heartbeat-disk-pressure.test.ts index 117e700abcf..fe7d8ac41e9 100644 --- a/assistant/src/__tests__/heartbeat-disk-pressure.test.ts +++ b/assistant/src/__tests__/heartbeat-disk-pressure.test.ts @@ -74,6 +74,7 @@ mock.module("../memory/conversation-crud.js", () => ({ // addMessage. Disk-pressure short-circuits before addMessage ever runs, // but the mock module must still expose every name the real module does. addMessage: () => Promise.resolve({ id: "mock-msg-id" }), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); const mockProcessMessage = mock(() => Promise.resolve({ messageId: "msg-1" })); diff --git a/assistant/src/__tests__/heartbeat-service.test.ts b/assistant/src/__tests__/heartbeat-service.test.ts index 9e002a8e74c..163b55535e5 100644 --- a/assistant/src/__tests__/heartbeat-service.test.ts +++ b/assistant/src/__tests__/heartbeat-service.test.ts @@ -159,6 +159,7 @@ mock.module("../memory/conversation-crud.js", () => ({ createdConversations.push(opts); return { id: `conv-${++conversationIdCounter}`, ...opts }; }, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); // Mock logger — capture warn calls for unreachable-credential assertions diff --git a/assistant/src/__tests__/http-user-message-parity.test.ts b/assistant/src/__tests__/http-user-message-parity.test.ts index 1ce80d5ef77..ae63979da36 100644 --- a/assistant/src/__tests__/http-user-message-parity.test.ts +++ b/assistant/src/__tests__/http-user-message-parity.test.ts @@ -100,6 +100,7 @@ mock.module("../memory/conversation-crud.js", () => ({ content: string, metadata?: Record, ) => addMessageMock(conversationId, role, content, metadata), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../runtime/local-actor-identity.js", () => ({ diff --git a/assistant/src/__tests__/inbound-slack-persistence.test.ts b/assistant/src/__tests__/inbound-slack-persistence.test.ts index 941e01adace..aec90d61ded 100644 --- a/assistant/src/__tests__/inbound-slack-persistence.test.ts +++ b/assistant/src/__tests__/inbound-slack-persistence.test.ts @@ -48,6 +48,7 @@ mock.module("../memory/conversation-crud.js", () => ({ provenanceFromTrustContext: () => ({}), setConversationOriginChannelIfUnset: () => {}, setConversationOriginInterfaceIfUnset: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-disk-view.js", () => ({ diff --git a/assistant/src/__tests__/messaging-send-tool.test.ts b/assistant/src/__tests__/messaging-send-tool.test.ts index 3d6c18a2759..3c8d76fadf6 100644 --- a/assistant/src/__tests__/messaging-send-tool.test.ts +++ b/assistant/src/__tests__/messaging-send-tool.test.ts @@ -79,6 +79,7 @@ const getBindingByChannelChatMock = mock( mock.module("../memory/conversation-crud.js", () => ({ addMessage: addMessageMock, getConversation: getConversationMock, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-disk-view.js", () => ({ diff --git a/assistant/src/__tests__/outbound-slack-persistence.test.ts b/assistant/src/__tests__/outbound-slack-persistence.test.ts index 34b785ab1e6..89c25f0cdc5 100644 --- a/assistant/src/__tests__/outbound-slack-persistence.test.ts +++ b/assistant/src/__tests__/outbound-slack-persistence.test.ts @@ -111,6 +111,7 @@ mock.module("../memory/conversation-crud.js", () => ({ // The handler treats provenance as a flat spread; returning {} keeps the // metadata snapshot focused on the fields under test. provenanceFromTrustContext: () => ({}), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/llm-request-log-store.js", () => ({ diff --git a/assistant/src/__tests__/persistence-pipeline.test.ts b/assistant/src/__tests__/persistence-pipeline.test.ts index c08cae06a08..ac6d5699c72 100644 --- a/assistant/src/__tests__/persistence-pipeline.test.ts +++ b/assistant/src/__tests__/persistence-pipeline.test.ts @@ -25,6 +25,8 @@ import { createConversation, getMessageById, getMessages, + reserveMessage, + updateMessageContent, updateMessageMetadata, } from "../memory/conversation-crud.js"; import { getDb } from "../memory/db-connection.js"; @@ -44,6 +46,7 @@ import type { PersistAddResult, PersistArgs, PersistDeleteResult, + PersistReserveResult, PersistResult, Plugin, TurnContext, @@ -215,6 +218,80 @@ describe("persistence pipeline", () => { expect(getMessages(conv.id)).toHaveLength(0); }); + test("default plugin: reserve op pre-allocates an empty assistant row", async () => { + registerPlugin(defaultPersistencePlugin); + + const conv = createConversation(); + + const result = (await runPipeline( + "persistence", + getMiddlewaresFor("persistence"), + defaultPersistenceTerminal, + { + op: "reserve", + conversationId: conv.id, + role: "assistant", + metadata: { reservedFor: "anchor" }, + }, + makeCtx({ conversationId: conv.id }), + DEFAULT_TIMEOUTS.persistence, + )) as PersistReserveResult; + + expect(result.op).toBe("reserve"); + expect(result.message.id).toBeTruthy(); + expect(result.message.role).toBe("assistant"); + // Reserve places an empty JSON array so consumers that parse content + // observe a no-content payload. + expect(result.message.content).toBe("[]"); + + // Row must exist with the expected shape and live in the conversation. + const row = getMessageById(result.message.id, conv.id); + expect(row).not.toBeNull(); + expect(row?.content).toBe("[]"); + expect(JSON.parse(row!.metadata!)).toEqual({ reservedFor: "anchor" }); + expect(getMessages(conv.id).map((m) => m.id)).toContain(result.message.id); + }); + + test("default plugin: updateContent op overwrites an existing row's content", async () => { + registerPlugin(defaultPersistencePlugin); + + const conv = createConversation(); + + // Reserve first, then overwrite — exactly the B3 sequence consumers will + // follow. + const reserved = await reserveMessage(conv.id, "assistant"); + expect(getMessageById(reserved.id, conv.id)?.content).toBe("[]"); + + const result = await runPipeline( + "persistence", + getMiddlewaresFor("persistence"), + defaultPersistenceTerminal, + { + op: "updateContent", + messageId: reserved.id, + content: JSON.stringify([{ type: "text", text: "Hello" }]), + }, + makeCtx({ conversationId: conv.id }), + DEFAULT_TIMEOUTS.persistence, + ); + expect(result).toEqual({ op: "updateContent" }); + + // Content updated in place; row id unchanged. + const row = getMessageById(reserved.id, conv.id); + expect(row).not.toBeNull(); + expect(JSON.parse(row!.content)).toEqual([{ type: "text", text: "Hello" }]); + + // Direct-call parity: a fresh reserve + direct updateMessageContent + // should land the same content shape. + const baselineReserved = await reserveMessage(conv.id, "assistant"); + updateMessageContent( + baselineReserved.id, + JSON.stringify([{ type: "text", text: "Hello" }]), + ); + const baselineRow = getMessageById(baselineReserved.id, conv.id); + expect(baselineRow?.content).toBe(row?.content); + }); + test("custom plugin: short-circuits every op onto a mock in-memory store", async () => { type Stored = { id: string; @@ -249,6 +326,33 @@ describe("persistence pipeline", () => { }, }; } + case "reserve": { + const id = `mock-${nextId++}`; + mockStore.set(id, { + id, + conversationId: args.conversationId, + role: args.role, + content: "[]", + metadata: { ...(args.metadata ?? {}) }, + }); + return { + op: "reserve", + message: { + id, + conversationId: args.conversationId, + role: args.role, + content: "[]", + createdAt: 123, + }, + }; + } + case "updateContent": { + const existing = mockStore.get(args.messageId); + if (existing) { + existing.content = args.content; + } + return { op: "updateContent" }; + } case "update": { const existing = mockStore.get(args.messageId); if (existing) { @@ -298,6 +402,40 @@ describe("persistence pipeline", () => { expect(addResult.message.id).toBe("mock-1"); expect(mockStore.size).toBe(1); + const reserveResult = (await runPipeline( + "persistence", + getMiddlewaresFor("persistence"), + defaultPersistenceTerminal, + { + op: "reserve", + conversationId: conv.id, + role: "assistant", + metadata: { reservedFor: "mock-anchor" }, + }, + makeCtx({ conversationId: conv.id }), + DEFAULT_TIMEOUTS.persistence, + )) as PersistReserveResult; + expect(reserveResult.op).toBe("reserve"); + expect(reserveResult.message.id).toBe("mock-2"); + expect(reserveResult.message.content).toBe("[]"); + expect(mockStore.get("mock-2")?.content).toBe("[]"); + + await runPipeline( + "persistence", + getMiddlewaresFor("persistence"), + defaultPersistenceTerminal, + { + op: "updateContent", + messageId: "mock-2", + content: '[{"type":"text","text":"finalized"}]', + }, + makeCtx({ conversationId: conv.id }), + DEFAULT_TIMEOUTS.persistence, + ); + expect(mockStore.get("mock-2")?.content).toBe( + '[{"type":"text","text":"finalized"}]', + ); + await runPipeline( "persistence", getMiddlewaresFor("persistence"), @@ -326,7 +464,7 @@ describe("persistence pipeline", () => { expect(delResult.op).toBe("delete"); expect(mockStore.has("mock-1")).toBe(false); - // The real DB must not have been touched by any of the three ops. + // The real DB must not have been touched by any of the ops. expect(getMessages(conv.id)).toHaveLength(dbRowsBefore); }); diff --git a/assistant/src/__tests__/persistence-secret-redaction.test.ts b/assistant/src/__tests__/persistence-secret-redaction.test.ts index db89d33a76e..ce926835754 100644 --- a/assistant/src/__tests__/persistence-secret-redaction.test.ts +++ b/assistant/src/__tests__/persistence-secret-redaction.test.ts @@ -60,6 +60,7 @@ mock.module("../memory/conversation-crud.js", () => ({ getMessageById: () => null, updateMessageContent: () => {}, provenanceFromTrustContext: () => ({}), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/llm-request-log-store.js", () => ({ diff --git a/assistant/src/__tests__/process-message-background-slack.test.ts b/assistant/src/__tests__/process-message-background-slack.test.ts index 1aefaef0e73..df96189b48a 100644 --- a/assistant/src/__tests__/process-message-background-slack.test.ts +++ b/assistant/src/__tests__/process-message-background-slack.test.ts @@ -24,6 +24,7 @@ mock.module("../memory/conversation-crud.js", () => ({ provenanceFromTrustContext: () => ({}), setConversationOriginChannelIfUnset: () => {}, setConversationOriginInterfaceIfUnset: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-disk-view.js", () => ({ diff --git a/assistant/src/__tests__/process-message-display-content.test.ts b/assistant/src/__tests__/process-message-display-content.test.ts index 94cb38b1b7d..fe0aa426321 100644 --- a/assistant/src/__tests__/process-message-display-content.test.ts +++ b/assistant/src/__tests__/process-message-display-content.test.ts @@ -50,6 +50,7 @@ mock.module("../memory/conversation-crud.js", () => ({ provenanceFromTrustContext: () => ({}), setConversationOriginChannelIfUnset: () => {}, setConversationOriginInterfaceIfUnset: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-disk-view.js", () => ({ diff --git a/assistant/src/__tests__/recording-handler.test.ts b/assistant/src/__tests__/recording-handler.test.ts index 1dcef3dd46a..548c69a829c 100644 --- a/assistant/src/__tests__/recording-handler.test.ts +++ b/assistant/src/__tests__/recording-handler.test.ts @@ -68,6 +68,7 @@ mock.module("../memory/conversation-crud.js", () => ({ }, createConversation: () => ({ id: "conv-mock" }), getConversation: () => ({ id: "conv-mock" }), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); // Attachments store mock diff --git a/assistant/src/__tests__/regenerate-fire-and-forget-trace.test.ts b/assistant/src/__tests__/regenerate-fire-and-forget-trace.test.ts index ca8068240eb..d03b2b622c7 100644 --- a/assistant/src/__tests__/regenerate-fire-and-forget-trace.test.ts +++ b/assistant/src/__tests__/regenerate-fire-and-forget-trace.test.ts @@ -32,6 +32,7 @@ mock.module("../memory/conversation-crud.js", () => ({ updateMessageContent: () => {}, relinkAttachments: () => 0, deleteLastExchange: () => 0, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/conversation-queries.js", () => ({ diff --git a/assistant/src/__tests__/secret-ingress-http.test.ts b/assistant/src/__tests__/secret-ingress-http.test.ts index 17c8af3a6c6..c4f8f8bf3d5 100644 --- a/assistant/src/__tests__/secret-ingress-http.test.ts +++ b/assistant/src/__tests__/secret-ingress-http.test.ts @@ -106,6 +106,7 @@ mock.module("../memory/conversation-crud.js", () => ({ provenanceFromTrustContext: () => undefined, setConversationOriginChannelIfUnset: () => {}, setConversationOriginInterfaceIfUnset: () => {}, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../runtime/local-actor-identity.js", () => ({ diff --git a/assistant/src/__tests__/subagent-notify-parent.test.ts b/assistant/src/__tests__/subagent-notify-parent.test.ts index 7acdcbb4d2b..64bdbb24396 100644 --- a/assistant/src/__tests__/subagent-notify-parent.test.ts +++ b/assistant/src/__tests__/subagent-notify-parent.test.ts @@ -27,6 +27,7 @@ mock.module("../memory/conversation-crud.js", () => ({ getConversationOriginChannel: () => null, getMessages: () => null, createConversation: () => ({ id: "mock-conv" }), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); /** diff --git a/assistant/src/__tests__/subagent-spawn-tool-fork.test.ts b/assistant/src/__tests__/subagent-spawn-tool-fork.test.ts index 77a3c0f8703..85f4230a40d 100644 --- a/assistant/src/__tests__/subagent-spawn-tool-fork.test.ts +++ b/assistant/src/__tests__/subagent-spawn-tool-fork.test.ts @@ -26,6 +26,7 @@ mock.module("../memory/conversation-crud.js", () => ({ getConversationOriginChannel: () => null, getMessages: () => null, createConversation: () => ({ id: "mock-conv" }), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); import { diff --git a/assistant/src/__tests__/subagent-tools.test.ts b/assistant/src/__tests__/subagent-tools.test.ts index 255df572a71..cbc3bd1e32c 100644 --- a/assistant/src/__tests__/subagent-tools.test.ts +++ b/assistant/src/__tests__/subagent-tools.test.ts @@ -30,6 +30,7 @@ mock.module("../memory/conversation-crud.js", () => ({ getConversationOriginChannel: () => null, getMessages: (conversationId: string) => mockGetMessages(conversationId), createConversation: () => ({ id: "mock-conv" }), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); import { getSubagentManager } from "../subagent/index.js"; diff --git a/assistant/src/__tests__/suggestion-routes.test.ts b/assistant/src/__tests__/suggestion-routes.test.ts index 9d0e1325a10..38a41ad7cca 100644 --- a/assistant/src/__tests__/suggestion-routes.test.ts +++ b/assistant/src/__tests__/suggestion-routes.test.ts @@ -52,6 +52,7 @@ const mockGetMessages = mock((_conversationId: string) => [ mock.module("../memory/conversation-crud.js", () => ({ getMessages: mockGetMessages, getConversation: (_id: string) => null, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); const mockGetConfiguredProvider = mock(async () => ({ diff --git a/assistant/src/__tests__/tool-executor-lifecycle-events.test.ts b/assistant/src/__tests__/tool-executor-lifecycle-events.test.ts index fca0c9213c3..a85facfd4c7 100644 --- a/assistant/src/__tests__/tool-executor-lifecycle-events.test.ts +++ b/assistant/src/__tests__/tool-executor-lifecycle-events.test.ts @@ -72,6 +72,7 @@ mock.module("../permissions/checker.js", () => ({ mock.module("../memory/conversation-crud.js", () => ({ createConversation: (title: string) => ({ id: "conversation-1", title }), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); // Mock every export so downstream test files that dynamically import modules diff --git a/assistant/src/__tests__/tool-preview-lifecycle.test.ts b/assistant/src/__tests__/tool-preview-lifecycle.test.ts index 7e07d9c3552..514bba9cb73 100644 --- a/assistant/src/__tests__/tool-preview-lifecycle.test.ts +++ b/assistant/src/__tests__/tool-preview-lifecycle.test.ts @@ -45,6 +45,7 @@ mock.module("../memory/conversation-crud.js", () => ({ getMessageById: () => null, updateMessageContent: () => {}, provenanceFromTrustContext: () => ({}), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/llm-request-log-store.js", () => ({ diff --git a/assistant/src/__tests__/tool-result-metadata-plumbing.test.ts b/assistant/src/__tests__/tool-result-metadata-plumbing.test.ts index e2f3c1d5bfa..19aaa5ea87c 100644 --- a/assistant/src/__tests__/tool-result-metadata-plumbing.test.ts +++ b/assistant/src/__tests__/tool-result-metadata-plumbing.test.ts @@ -44,6 +44,7 @@ mock.module("../memory/conversation-crud.js", () => ({ getMessageById: () => null, updateMessageContent: () => {}, provenanceFromTrustContext: () => ({}), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../memory/llm-request-log-store.js", () => ({ diff --git a/assistant/src/__tests__/verification-control-plane-policy.test.ts b/assistant/src/__tests__/verification-control-plane-policy.test.ts index fc53ebf65a7..f06d6c2ee9a 100644 --- a/assistant/src/__tests__/verification-control-plane-policy.test.ts +++ b/assistant/src/__tests__/verification-control-plane-policy.test.ts @@ -73,6 +73,7 @@ mock.module("../permissions/checker.js", () => ({ mock.module("../memory/conversation-crud.js", () => ({ createConversation: (title: string) => ({ id: "conversation-1", title }), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); // Mock every export so downstream test files that dynamically import modules diff --git a/assistant/src/daemon/__tests__/conversation-surfaces-launch.test.ts b/assistant/src/daemon/__tests__/conversation-surfaces-launch.test.ts index d3c2b1bf7c4..98314a60931 100644 --- a/assistant/src/daemon/__tests__/conversation-surfaces-launch.test.ts +++ b/assistant/src/daemon/__tests__/conversation-surfaces-launch.test.ts @@ -73,6 +73,7 @@ mock.module("../../memory/conversation-crud.js", () => ({ ) => { updateTitleCalls.push({ conversationId, title }); }, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); // Stub conversation-store so the real `launchConversation` can hydrate diff --git a/assistant/src/daemon/__tests__/daemon-skill-host.test.ts b/assistant/src/daemon/__tests__/daemon-skill-host.test.ts index 6ef1c89a011..dc2667f3d84 100644 --- a/assistant/src/daemon/__tests__/daemon-skill-host.test.ts +++ b/assistant/src/daemon/__tests__/daemon-skill-host.test.ts @@ -95,6 +95,7 @@ mock.module("../../security/secure-keys.js", () => ({ mock.module("../../memory/conversation-crud.js", () => ({ addMessage: async () => ({ id: "msg-123" }), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../../runtime/agent-wake.js", () => ({ diff --git a/assistant/src/daemon/__tests__/native-web-search-metadata.test.ts b/assistant/src/daemon/__tests__/native-web-search-metadata.test.ts index 3d51268edb1..204b7b0ccfb 100644 --- a/assistant/src/daemon/__tests__/native-web-search-metadata.test.ts +++ b/assistant/src/daemon/__tests__/native-web-search-metadata.test.ts @@ -42,6 +42,7 @@ mock.module("../../memory/conversation-crud.js", () => ({ getMessageById: () => null, updateMessageContent: () => {}, provenanceFromTrustContext: () => ({}), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../../memory/llm-request-log-store.js", () => ({ diff --git a/assistant/src/daemon/__tests__/web-search-status-text.test.ts b/assistant/src/daemon/__tests__/web-search-status-text.test.ts index 9beb2e177fe..f7673a3d3f8 100644 --- a/assistant/src/daemon/__tests__/web-search-status-text.test.ts +++ b/assistant/src/daemon/__tests__/web-search-status-text.test.ts @@ -40,6 +40,7 @@ mock.module("../../memory/conversation-crud.js", () => ({ getMessageById: () => null, updateMessageContent: () => {}, provenanceFromTrustContext: () => ({}), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../../memory/llm-request-log-store.js", () => ({ diff --git a/assistant/src/export/__tests__/transcript-formatter.test.ts b/assistant/src/export/__tests__/transcript-formatter.test.ts index 1362c2bd1e5..bb1a7acd003 100644 --- a/assistant/src/export/__tests__/transcript-formatter.test.ts +++ b/assistant/src/export/__tests__/transcript-formatter.test.ts @@ -76,6 +76,7 @@ mock.module("../../memory/conversation-crud.js", () => ({ getMessages: (id: string) => id === "child-conv-1" ? childMessages : parentMessages, messageMetadataSchema, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../../util/truncate.js", () => ({ diff --git a/assistant/src/ipc/__tests__/cli-ipc.test.ts b/assistant/src/ipc/__tests__/cli-ipc.test.ts index 65dd23ddc0c..f34759bf6c3 100644 --- a/assistant/src/ipc/__tests__/cli-ipc.test.ts +++ b/assistant/src/ipc/__tests__/cli-ipc.test.ts @@ -37,6 +37,7 @@ mock.module("../../runtime/agent-wake.js", () => ({ mock.module("../../memory/conversation-crud.js", () => ({ getConversation: (id: string) => ({ id, createdAt: Date.now() }), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); // --------------------------------------------------------------------------- diff --git a/assistant/src/ipc/skill-routes/__tests__/memory.test.ts b/assistant/src/ipc/skill-routes/__tests__/memory.test.ts index 66a07141dd9..b418fef77a7 100644 --- a/assistant/src/ipc/skill-routes/__tests__/memory.test.ts +++ b/assistant/src/ipc/skill-routes/__tests__/memory.test.ts @@ -24,6 +24,7 @@ const addMessageSpy = mock( ); mock.module("../../../memory/conversation-crud.js", () => ({ addMessage: addMessageSpy, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); const wakeAgentSpy = mock( diff --git a/assistant/src/memory/__tests__/jobs-store-enqueue-gate.test.ts b/assistant/src/memory/__tests__/jobs-store-enqueue-gate.test.ts index 9c97c4aa9dc..258f995c614 100644 --- a/assistant/src/memory/__tests__/jobs-store-enqueue-gate.test.ts +++ b/assistant/src/memory/__tests__/jobs-store-enqueue-gate.test.ts @@ -57,6 +57,7 @@ mock.module("../../runtime/actor-trust-resolver.js", () => ({ // retrospective and auto-analysis paths fall through to the enqueue. mock.module("../conversation-crud.js", () => ({ getConversationSource: () => null, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../auto-analysis-guard.js", () => ({ isAutoAnalysisConversation: () => false, diff --git a/assistant/src/memory/__tests__/memory-retrospective-enqueue.test.ts b/assistant/src/memory/__tests__/memory-retrospective-enqueue.test.ts index 6c9933de357..723aa67f07a 100644 --- a/assistant/src/memory/__tests__/memory-retrospective-enqueue.test.ts +++ b/assistant/src/memory/__tests__/memory-retrospective-enqueue.test.ts @@ -19,6 +19,7 @@ const upsertCalls: Array<{ mock.module("../conversation-crud.js", () => ({ getConversationSource: (_id: string) => sourceTag, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../jobs-store.js", () => ({ diff --git a/assistant/src/memory/__tests__/memory-retrospective-job.test.ts b/assistant/src/memory/__tests__/memory-retrospective-job.test.ts index 0b7e0b03463..5a0903c5885 100644 --- a/assistant/src/memory/__tests__/memory-retrospective-job.test.ts +++ b/assistant/src/memory/__tests__/memory-retrospective-job.test.ts @@ -144,6 +144,7 @@ mock.module("../conversation-crud.js", () => ({ deleteConversation: (id: string) => { deletedConversationIds.push(id); }, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../../config/assistant-feature-flags.js", () => ({ diff --git a/assistant/src/memory/__tests__/memory-retrospective-startup-cleanup.test.ts b/assistant/src/memory/__tests__/memory-retrospective-startup-cleanup.test.ts index a19b9e11790..73c289f2c76 100644 --- a/assistant/src/memory/__tests__/memory-retrospective-startup-cleanup.test.ts +++ b/assistant/src/memory/__tests__/memory-retrospective-startup-cleanup.test.ts @@ -108,6 +108,7 @@ mock.module("../conversation-crud.js", () => ({ deletedIds.push(id); mockConversations = mockConversations.filter((c) => c.id !== id); }, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); import { sweepOrphanMemoryRetrospectiveConversations } from "../memory-retrospective-startup-cleanup.js"; diff --git a/assistant/src/memory/conversation-crud.ts b/assistant/src/memory/conversation-crud.ts index 3bdec7fd3da..c86ea2a70cb 100644 --- a/assistant/src/memory/conversation-crud.ts +++ b/assistant/src/memory/conversation-crud.ts @@ -1977,6 +1977,104 @@ interface WipeConversationResult extends DeletedMemoryIds { cancelledJobCount: number; } +/** + * Reserve an empty message row at a known id, so the agent loop can stamp + * outbound streaming events with a stable identity before any content is + * produced. The returned row has `content: "[]"` and no metadata-side + * effects beyond `addMessage`'s usual conversation-row bump. + * + * Intentionally skips both Qdrant indexing and attention projection — an + * empty placeholder is not meaningful for either. The caller will later + * write final content via {@link updateMessageContent} (and is responsible + * for triggering any indexing/projection at that point if it cares). + * + * Mirrors {@link addMessage}'s SQLITE_BUSY/SQLITE_IOERR retry shape so the + * primitives behave consistently under WAL contention. + */ +export async function reserveMessage( + conversationId: string, + role: string, + metadata?: Record, +) { + const db = getDb(); + const messageId = uuid(); + + if (metadata) { + const result = messageMetadataSchema.safeParse(metadata); + if (!result.success) { + log.warn( + { conversationId, messageId, issues: result.error.issues }, + "Invalid message metadata, storing as-is", + ); + } + } + + const metadataStr = metadata ? JSON.stringify(metadata) : undefined; + const originChannelCandidate = + metadata && isChannelId(metadata.userMessageChannel) + ? metadata.userMessageChannel + : null; + + const MAX_RETRIES = 3; + let now!: number; + for (let attempt = 0; ; attempt++) { + now = monotonicNow(); + try { + const values = { + id: messageId, + conversationId, + role, + content: "[]", + createdAt: now, + ...(metadataStr ? { metadata: metadataStr } : {}), + }; + db.transaction((tx) => { + tx.insert(messages).values(values).run(); + if (originChannelCandidate) { + tx.update(conversations) + .set({ originChannel: originChannelCandidate }) + .where( + and( + eq(conversations.id, conversationId), + isNull(conversations.originChannel), + ), + ) + .run(); + } + tx.update(conversations) + .set({ updatedAt: now, lastMessageAt: now }) + .where(eq(conversations.id, conversationId)) + .run(); + }); + break; + } catch (err) { + const errCode = (err as { code?: string }).code ?? ""; + if ( + attempt < MAX_RETRIES && + (errCode.startsWith("SQLITE_BUSY") || + errCode.startsWith("SQLITE_IOERR")) + ) { + log.warn( + { attempt, conversationId, code: errCode }, + "reserveMessage: transient SQLite error, retrying", + ); + await Bun.sleep(50 * (attempt + 1)); + continue; + } + throw err; + } + } + + return { + id: messageId, + conversationId, + role, + content: "[]", + createdAt: now, + ...(metadataStr ? { metadata: metadataStr } : {}), + }; +} + /** * Update the content of an existing message. Used when consolidating * multiple assistant messages into one. diff --git a/assistant/src/notifications/__tests__/emit-signal-home-feed.test.ts b/assistant/src/notifications/__tests__/emit-signal-home-feed.test.ts index 13c9181892f..51909817f6f 100644 --- a/assistant/src/notifications/__tests__/emit-signal-home-feed.test.ts +++ b/assistant/src/notifications/__tests__/emit-signal-home-feed.test.ts @@ -34,6 +34,7 @@ mock.module("../../home/feed-writer.js", () => ({ // home-feed-side-effect.ts only consumes `getConversation`. mock.module("../../memory/conversation-crud.js", () => ({ getConversation: () => conversationRow, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); // Stub the broadcaster so emit-signal's `getBroadcaster()` does not need diff --git a/assistant/src/notifications/__tests__/home-feed-side-effect.test.ts b/assistant/src/notifications/__tests__/home-feed-side-effect.test.ts index e03afa99206..822915b9002 100644 --- a/assistant/src/notifications/__tests__/home-feed-side-effect.test.ts +++ b/assistant/src/notifications/__tests__/home-feed-side-effect.test.ts @@ -30,6 +30,7 @@ mock.module("../../memory/conversation-crud.js", () => ({ } return conversationRow; }, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); const { writeHomeFeedItemForSignal } = diff --git a/assistant/src/plugins/defaults/persistence.ts b/assistant/src/plugins/defaults/persistence.ts index 36326c668b0..a271ca19559 100644 --- a/assistant/src/plugins/defaults/persistence.ts +++ b/assistant/src/plugins/defaults/persistence.ts @@ -15,12 +15,17 @@ * * The terminal dispatches on the discriminated {@link PersistArgs.op} field: * - * - `add` → {@link addMessage}, optionally followed by - * {@link syncMessageToDisk} when `args.syncToDisk` is true. - * - `update` → {@link updateMessageMetadata} (returns `void`, wrapped as - * `{ op: "update" }`). - * - `delete` → {@link deleteMessageById} (returns the segment/summary IDs - * the caller must clean up out-of-band). + * - `add` → {@link addMessage}, optionally followed by + * {@link syncMessageToDisk} when `args.syncToDisk` is true. + * - `reserve` → {@link reserveMessage} — pre-allocates an empty row + * for assistant anchor stamping. + * - `updateContent` → {@link updateMessageContent} — overwrites an existing + * row's content (returns `void`, wrapped as + * `{ op: "updateContent" }`). + * - `update` → {@link updateMessageMetadata} (returns `void`, wrapped + * as `{ op: "update" }`). + * - `delete` → {@link deleteMessageById} (returns the segment/summary + * IDs the caller must clean up out-of-band). * * Manifest declares `provides.persistence: "v1"` so other plugins can * negotiate against the pipeline surface and `requires.pluginRuntime: "v1"` @@ -36,6 +41,8 @@ import { addMessage, deleteMessageById, + reserveMessage, + updateMessageContent, updateMessageMetadata, } from "../../memory/conversation-crud.js"; import { syncMessageToDisk } from "../../memory/conversation-disk-view.js"; @@ -74,6 +81,18 @@ export async function defaultPersistenceTerminal( } return { op: "add", message }; } + case "reserve": { + const message = await reserveMessage( + args.conversationId, + args.role, + args.metadata, + ); + return { op: "reserve", message }; + } + case "updateContent": { + updateMessageContent(args.messageId, args.content); + return { op: "updateContent" }; + } case "update": { updateMessageMetadata(args.messageId, args.updates); return { op: "update" }; diff --git a/assistant/src/plugins/types.ts b/assistant/src/plugins/types.ts index d51241141a0..1b74d6df7cb 100644 --- a/assistant/src/plugins/types.ts +++ b/assistant/src/plugins/types.ts @@ -439,18 +439,25 @@ export interface OverflowReduceResult { * Pipeline arguments for `persistence` — a discriminated union over the * message-CRUD operations plugins may observe, redirect, or short-circuit: * - * - `add` — append a new message (`addMessage`). Mirrors - * `addMessage(conversationId, role, content, metadata?, opts?)`. - * When `syncToDisk` is set, the default plugin also runs - * {@link syncMessageToDisk} against the just-persisted row so - * the JSONL disk view stays consistent. The `createdAtMs` field - * carries the conversation's creation timestamp — needed to - * resolve the disk-view directory path. - * - `update` — shallow-merge metadata into an existing message - * (`updateMessageMetadata`). Returns `void`. - * - `delete` — remove a single message (`deleteMessageById`). Returns the - * {@link DeletedMemoryIds}-shaped segment/summary IDs the caller - * must clean up out-of-band. + * - `add` — append a new message (`addMessage`). Mirrors + * `addMessage(conversationId, role, content, metadata?, opts?)`. + * When `syncToDisk` is set, the default plugin also runs + * {@link syncMessageToDisk} against the just-persisted row + * so the JSONL disk view stays consistent. The + * `createdAtMs` field carries the conversation's creation + * timestamp — needed to resolve the disk-view directory. + * - `reserve` — pre-allocate an empty assistant anchor row + * (`reserveMessage`) so the agent loop can stamp streaming + * events with stable identity before any content is + * produced. Returns the same row shape as `add`. + * - `updateContent` — overwrite the content of an existing message + * (`updateMessageContent`). Used to finalize a previously + * reserved row, and by consolidation paths. + * - `update` — shallow-merge metadata into an existing message + * (`updateMessageMetadata`). Returns `void`. + * - `delete` — remove a single message (`deleteMessageById`). Returns + * the {@link DeletedMemoryIds}-shaped segment/summary IDs + * the caller must clean up out-of-band. * * The discriminated `op` field lets plugin middleware narrow the union and * tailor behavior per-operation (e.g. "only observe deletes", "redirect @@ -473,6 +480,19 @@ export type PersistAddArgs = { readonly createdAtMs?: number; }; +export type PersistReserveArgs = { + readonly op: "reserve"; + readonly conversationId: string; + readonly role: string; + readonly metadata?: Record; +}; + +export type PersistUpdateContentArgs = { + readonly op: "updateContent"; + readonly messageId: string; + readonly content: string; +}; + export type PersistUpdateArgs = { readonly op: "update"; readonly messageId: string; @@ -486,6 +506,8 @@ export type PersistDeleteArgs = { export type PersistArgs = | PersistAddArgs + | PersistReserveArgs + | PersistUpdateContentArgs | PersistUpdateArgs | PersistDeleteArgs; @@ -506,6 +528,25 @@ export type PersistAddResult = { }; }; +/** + * Result row returned by a `reserve` op — same row shape as `add` but with + * empty `content` (`"[]"`) and tagged distinctly so middleware can branch + * on intent. + */ +export type PersistReserveResult = { + readonly op: "reserve"; + readonly message: { + readonly id: string; + readonly conversationId: string; + readonly role: string; + readonly content: string; + readonly createdAt: number; + readonly metadata?: string; + }; +}; + +export type PersistUpdateContentResult = { readonly op: "updateContent" }; + export type PersistUpdateResult = { readonly op: "update" }; /** IDs of segments/summaries the caller must remove from Qdrant. */ @@ -517,6 +558,8 @@ export type PersistDeleteResult = { export type PersistResult = | PersistAddResult + | PersistReserveResult + | PersistUpdateContentResult | PersistUpdateResult | PersistDeleteResult; diff --git a/assistant/src/proactive-artifact/job.test.ts b/assistant/src/proactive-artifact/job.test.ts index 177f49eb992..2b95b89b562 100644 --- a/assistant/src/proactive-artifact/job.test.ts +++ b/assistant/src/proactive-artifact/job.test.ts @@ -163,6 +163,7 @@ mock.module("../memory/conversation-crud.js", () => ({ addMessageCalls.push({ conversationId, role, content, metadata, opts }); return { id: `msg-${addMessageCalls.length}` }; }, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); // emitNotificationSignal mock diff --git a/assistant/src/runtime/__tests__/agent-wake.test.ts b/assistant/src/runtime/__tests__/agent-wake.test.ts index 40e1bb6530a..59beeb27ad5 100644 --- a/assistant/src/runtime/__tests__/agent-wake.test.ts +++ b/assistant/src/runtime/__tests__/agent-wake.test.ts @@ -29,6 +29,7 @@ import type { DiskPressureStatus } from "../../daemon/disk-pressure-guard.js"; mock.module("../../memory/conversation-crud.js", () => ({ getConversationOverrideProfile: () => undefined, getConversation: () => ({ archivedAt: null }), + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); const mockGetOrCreateConversationCalls: Array<{ diff --git a/assistant/src/runtime/__tests__/background-job-runner.test.ts b/assistant/src/runtime/__tests__/background-job-runner.test.ts index 5c187d6f0e7..8ab01444608 100644 --- a/assistant/src/runtime/__tests__/background-job-runner.test.ts +++ b/assistant/src/runtime/__tests__/background-job-runner.test.ts @@ -40,6 +40,7 @@ mock.module("../../memory/conversation-crud.js", () => ({ addMessageCalls.push({ conversationId, role, content }); return { id: `msg-${addMessageCalls.length}` }; }, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); let processMessageImpl: ( diff --git a/assistant/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts b/assistant/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts index 36641026209..525a2ded8e1 100644 --- a/assistant/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +++ b/assistant/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts @@ -70,6 +70,7 @@ mock.module("../../../memory/conversation-crud.js", () => ({ state.conversation && state.conversation.id === id ? state.conversation : null, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../../../memory/llm-request-log-source.js", () => ({ diff --git a/assistant/src/runtime/routes/__tests__/home-feed-routes.test.ts b/assistant/src/runtime/routes/__tests__/home-feed-routes.test.ts index d71d20f47e0..5bb4757e382 100644 --- a/assistant/src/runtime/routes/__tests__/home-feed-routes.test.ts +++ b/assistant/src/runtime/routes/__tests__/home-feed-routes.test.ts @@ -62,6 +62,7 @@ mock.module("../../../memory/conversation-crud.js", () => ({ getMessages: () => [], getMessagesPaginated: () => ({ messages: [], hasMore: false }), getMessageById: () => null, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); // Dynamic imports so module mocks are wired before evaluation. diff --git a/assistant/src/runtime/services/__tests__/analyze-conversation.test.ts b/assistant/src/runtime/services/__tests__/analyze-conversation.test.ts index 64be2592a45..3a57906542d 100644 --- a/assistant/src/runtime/services/__tests__/analyze-conversation.test.ts +++ b/assistant/src/runtime/services/__tests__/analyze-conversation.test.ts @@ -46,6 +46,7 @@ mock.module("../../../memory/conversation-crud.js", () => ({ addMessage: mockAddMessage, findAnalysisConversationFor: mockFindAnalysisConversationFor, getConversationSource: mockGetConversationSource, + reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../../../export/transcript-formatter.js", () => ({