@vellumai/assistant 0.11.8-dev.202609012313.7a1acce → 0.11.8-staging.2
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/ARCHITECTURE.md +1 -1
- package/docs/guardian-request-flow.md +13 -13
- package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +18 -0
- package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +21 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
- package/openapi.yaml +0 -53
- package/package.json +1 -1
- package/src/__tests__/channel-delivery-store.test.ts +18 -129
- package/src/__tests__/channel-reply-delivery.test.ts +2 -126
- package/src/__tests__/channel-retry-sweep.test.ts +0 -168
- package/src/__tests__/conversation-error.test.ts +0 -15
- package/src/__tests__/conversation-load-history-repair.test.ts +0 -118
- package/src/__tests__/delete-propagation.test.ts +0 -300
- package/src/__tests__/guardian-card-withdrawal.test.ts +22 -0
- package/src/__tests__/guardian-gateway-sim.ts +23 -0
- package/src/__tests__/guardian-reply-router-answer-mode.test.ts +2 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +5 -0
- package/src/__tests__/list-messages-page-latest.test.ts +0 -85
- package/src/__tests__/outbound-slack-persistence.test.ts +0 -14
- package/src/__tests__/reaction-persistence.test.ts +198 -20
- package/src/__tests__/skill-load-tool.test.ts +0 -27
- package/src/__tests__/skills.test.ts +1 -34
- package/src/__tests__/slack-reaction-approvals.test.ts +97 -0
- package/src/__tests__/slack-reaction-guardian-approval.test.ts +307 -0
- package/src/approvals/AGENTS.md +1 -1
- package/src/channels/__tests__/gateway-guardian-requests.test.ts +23 -1
- package/src/channels/gateway-guardian-requests.ts +22 -0
- package/src/cli/commands/db/__tests__/status.test.ts +0 -22
- package/src/cli/commands/db/index.help.ts +1 -1
- package/src/cli/commands/db/status.ts +1 -172
- package/src/config/bundled-skills/acp/SKILL.md +3 -10
- package/src/config/bundled-skills/schedule/SKILL.md +11 -23
- package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +1 -3
- package/src/daemon/conversation-error.ts +0 -2
- package/src/daemon/conversation-messaging.ts +0 -42
- package/src/daemon/conversation-process.ts +1 -0
- package/src/daemon/conversation.ts +2 -24
- package/src/daemon/handlers/config-channels.ts +3 -33
- package/src/daemon/handlers/shared.ts +0 -9
- package/src/daemon/process-message.ts +16 -87
- package/src/daemon/reaction-record.ts +9 -2
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +1 -70
- package/src/live-voice/live-voice-session.ts +0 -3
- package/src/live-voice/protocol.ts +1 -20
- package/src/messaging/provider-message-metadata.ts +1 -13
- package/src/messaging/providers/slack/api.ts +1 -11
- package/src/messaging/reaction-envelopes.ts +0 -16
- package/src/monitoring/recovery/db.ts +0 -35
- package/src/monitoring/recovery/orphaned-channel-events.ts +22 -3
- package/src/monitoring/recovery/run-recovery.ts +2 -6
- package/src/monitoring/recovery/stale-processing.ts +20 -3
- package/src/notifications/AGENTS.md +1 -1
- package/src/notifications/adapters/discord.ts +1 -1
- package/src/notifications/guardian-delivery-recorder.ts +9 -5
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/prompter.ts +1 -1
- package/src/persistence/delivery-crud.ts +28 -224
- package/src/persistence/delivery-status.ts +2 -21
- package/src/persistence/schema/conversations.ts +2 -71
- package/src/persistence/steps.ts +0 -4
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +0 -50
- package/src/providers/__tests__/registry-native-web-search.test.ts +2 -49
- package/src/providers/call-site-routing.ts +4 -5
- package/src/providers/connection-resolution.ts +1 -29
- package/src/providers/inference/auth.ts +6 -6
- package/src/providers/registry.ts +7 -10
- package/src/providers/routing-identity.ts +1 -2
- package/src/providers/vellum-model-routing.ts +2 -3
- package/src/runtime/AGENTS.md +3 -14
- package/src/runtime/channel-approval-types.ts +5 -1
- package/src/runtime/channel-reply-delivery.ts +42 -99
- package/src/runtime/channel-retry-sweep.ts +0 -94
- package/src/runtime/guardian-reply-router.ts +75 -4
- package/src/runtime/question-request-guardian-bridge.ts +2 -1
- package/src/runtime/routes/acp-routes.ts +1 -1
- package/src/runtime/routes/channel-route-shared.ts +39 -0
- package/src/runtime/routes/channel-verification-routes.ts +1 -4
- package/src/runtime/routes/conversation-routes.ts +1 -0
- package/src/runtime/routes/guardian-approval-interception.ts +5 -0
- package/src/runtime/routes/inbound-message-handler.ts +32 -125
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +4 -141
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -77
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +48 -23
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +94 -452
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +102 -307
- package/src/tools/client-os.ts +0 -9
- package/src/tools/host-filesystem/edit.ts +2 -2
- package/src/tools/host-filesystem/read.ts +2 -2
- package/src/tools/host-filesystem/transfer.ts +2 -2
- package/src/tools/host-filesystem/write.ts +2 -2
- package/src/tools/host-terminal/host-shell.ts +2 -2
- package/src/tools/skills/load.ts +1 -1
- package/src/tools/ui-surface/definitions.ts +3 -4
- package/src/__tests__/provider-meta-persistence.test.ts +0 -67
- package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +0 -208
- package/src/monitoring/recovery/stranded-delivery-events.ts +0 -68
- package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +0 -26
- package/src/persistence/migrations/375-create-channel-outbound-posts.ts +0 -52
- package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +0 -84
- package/src/persistence/schema-contract.test.ts +0 -78
- package/src/persistence/schema-contract.ts +0 -96
package/ARCHITECTURE.md
CHANGED
|
@@ -120,7 +120,7 @@ Scoped approval grants allow a guardian's approval decision on one channel (e.g.
|
|
|
120
120
|
|
|
121
121
|
### Guardian Decision Primitive
|
|
122
122
|
|
|
123
|
-
All guardian approval decisions
|
|
123
|
+
All guardian approval decisions — regardless of how they arrive — route through a single primitive in `src/approvals/guardian-decision-primitive.ts`, which centralizes decision logic for callback button handlers, the conversational approval engine, and channel reactions/text.
|
|
124
124
|
|
|
125
125
|
**Core API:**
|
|
126
126
|
|
|
@@ -28,10 +28,10 @@ anti-pattern was retired in #35642 and again in the ask_question redesign).
|
|
|
28
28
|
telegram: inline keyboard; slack: blocks; vellum: conversation card via approval-card-builder)
|
|
29
29
|
card deliveries recorded per channel (guardian-delivery-recorder.ts → guardian_request_deliveries)
|
|
30
30
|
│
|
|
31
|
-
▼ user responds: button tap / "CODE <reply>" / bare text
|
|
31
|
+
▼ user responds: button tap / emoji reaction / "CODE <reply>" / bare text
|
|
32
32
|
guardian reply router (runtime/guardian-reply-router.ts, invoked from
|
|
33
|
-
callbacks → request codes →
|
|
34
|
-
explicit approve/reject → NL
|
|
33
|
+
reactions → callbacks → request codes → routes/inbound-stages/guardian-reply-intercept.ts,
|
|
34
|
+
bare answer → explicit approve/reject → NL BEFORE background dispatch — replies to parked
|
|
35
35
|
│ prompts resolve inline, never deferred)
|
|
36
36
|
▼ one decision primitive
|
|
37
37
|
applyGuardianDecision (approvals/guardian-decision-primitive.ts:
|
|
@@ -45,14 +45,14 @@ anti-pattern was retired in #35642 and again in the ask_question redesign).
|
|
|
45
45
|
|
|
46
46
|
## Who owns what
|
|
47
47
|
|
|
48
|
-
| Concern | Owner
|
|
49
|
-
| --------------------------------------- |
|
|
50
|
-
| The "what": card text, actions, options | broadcaster context build (once per broadcast)
|
|
51
|
-
| The "how": channel-native rendering | `notifications/adapters/<channel>`
|
|
52
|
-
| Request state | gateway `guardian_requests` (+ deliveries)
|
|
53
|
-
| Decisions | `applyGuardianDecision` (CAS, atomic ACL outcome)
|
|
54
|
-
| Kind-specific follow-through | resolver registry (`kind` → resolver)
|
|
55
|
-
| Reply understanding | guardian reply router (codes, buttons, modes)
|
|
48
|
+
| Concern | Owner | Never |
|
|
49
|
+
| --------------------------------------- | -------------------------------------------------------- | ----------------------------------- |
|
|
50
|
+
| The "what": card text, actions, options | broadcaster context build (once per broadcast) | built per-adapter |
|
|
51
|
+
| The "how": channel-native rendering | `notifications/adapters/<channel>` | domain logic, payload parsing |
|
|
52
|
+
| Request state | gateway `guardian_requests` (+ deliveries) | daemon-side request tables |
|
|
53
|
+
| Decisions | `applyGuardianDecision` (CAS, atomic ACL outcome) | inline decision logic at call sites |
|
|
54
|
+
| Kind-specific follow-through | resolver registry (`kind` → resolver) | switch statements in the router |
|
|
55
|
+
| Reply understanding | guardian reply router (codes, buttons, reactions, modes) | per-feature inbound intercepts |
|
|
56
56
|
|
|
57
57
|
## The canonical home-feed projection
|
|
58
58
|
|
|
@@ -186,8 +186,8 @@ chat the turn is running in. On Slack that chat can be a shared room, and the
|
|
|
186
186
|
card carries the tool, a command preview and live buttons.
|
|
187
187
|
`resolveGuardianPromptDelivery` addresses it to the guardian's bound DM
|
|
188
188
|
instead, by chat id rather than user id because that address is written to the
|
|
189
|
-
delivery row and read back to scope plain-text replies and
|
|
190
|
-
card. It returns the address and its route together, since
|
|
189
|
+
delivery row and read back to match reactions, scope plain-text replies and
|
|
190
|
+
edit the decided card. It returns the address and its route together, since
|
|
191
191
|
the turn's own callback carries a `threadTs` naming a thread that does not
|
|
192
192
|
exist in the DM. When no private address resolves it returns nothing and the
|
|
193
193
|
prompt is left to the in-app confirmation, because the room is the disclosure
|
package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
ExpireGuardianRequestIpcParamsSchema,
|
|
17
17
|
ExpireInteractionBoundIpcResponseSchema,
|
|
18
18
|
GetGuardianRequestByCallSessionIpcParamsSchema,
|
|
19
|
+
GetGuardianRequestByDestinationMessageIpcParamsSchema,
|
|
19
20
|
GetGuardianRequestByPendingQuestionIpcParamsSchema,
|
|
20
21
|
GUARDIAN_REQUESTS_IPC_METHODS,
|
|
21
22
|
GuardianRequestAclOutcomeSchema,
|
|
@@ -481,6 +482,23 @@ describe("delivery + destination IPC schemas", () => {
|
|
|
481
482
|
);
|
|
482
483
|
});
|
|
483
484
|
|
|
485
|
+
test("get_by_destination_message requires all three keys", () => {
|
|
486
|
+
const params = {
|
|
487
|
+
channel: "telegram",
|
|
488
|
+
chatId: "tg-chat-guardian",
|
|
489
|
+
messageId: "msg-42",
|
|
490
|
+
};
|
|
491
|
+
expect(
|
|
492
|
+
GetGuardianRequestByDestinationMessageIpcParamsSchema.parse(params),
|
|
493
|
+
).toEqual(params);
|
|
494
|
+
expect(() =>
|
|
495
|
+
GetGuardianRequestByDestinationMessageIpcParamsSchema.parse({
|
|
496
|
+
channel: "telegram",
|
|
497
|
+
chatId: "tg-chat-guardian",
|
|
498
|
+
}),
|
|
499
|
+
).toThrow();
|
|
500
|
+
});
|
|
501
|
+
|
|
484
502
|
test("list_pending_by_destination accepts either addressing form", () => {
|
|
485
503
|
const byConversation = { conversationId: "conv-1", channel: "telegram" };
|
|
486
504
|
expect(
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* daemon's client-facing HTTP surface owns the distinct `guardian_actions_*`
|
|
16
16
|
* operationIds (`guardian_actions_pending` / `guardian_actions_decision`),
|
|
17
17
|
* which do not change.
|
|
18
|
+
*
|
|
19
|
+
* Destination lookups are deliberately split into a single-message lookup
|
|
20
|
+
* (`get_by_destination_message`) and a pending-list read
|
|
21
|
+
* (`list_pending_by_destination`) so each response schema has exactly one
|
|
22
|
+
* shape instead of a params-dependent polymorphic result.
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
25
|
import { z } from "zod";
|
|
@@ -189,6 +194,7 @@ export const GUARDIAN_REQUESTS_IPC_METHODS = {
|
|
|
189
194
|
updateDelivery: "guardian_requests_update_delivery",
|
|
190
195
|
listDeliveries: "guardian_requests_list_deliveries",
|
|
191
196
|
listDeliveriesByChat: "guardian_requests_list_deliveries_by_chat",
|
|
197
|
+
getByDestinationMessage: "guardian_requests_get_by_destination_message",
|
|
192
198
|
listPendingByDestination: "guardian_requests_list_pending_by_destination",
|
|
193
199
|
listPendingByScope: "guardian_requests_list_pending_by_scope",
|
|
194
200
|
inScope: "guardian_requests_in_scope",
|
|
@@ -621,6 +627,21 @@ export type GuardianRequestDeliveryListIpcResponse = z.infer<
|
|
|
621
627
|
// Destination + scope lookups
|
|
622
628
|
// ---------------------------------------------------------------------------
|
|
623
629
|
|
|
630
|
+
/**
|
|
631
|
+
* Request for `guardian_requests_get_by_destination_message` — reaction
|
|
632
|
+
* routing: recover the pending request whose delivered card is the reacted-to
|
|
633
|
+
* message.
|
|
634
|
+
*/
|
|
635
|
+
export const GetGuardianRequestByDestinationMessageIpcParamsSchema = z.object({
|
|
636
|
+
channel: z.string().min(1),
|
|
637
|
+
chatId: z.string().min(1),
|
|
638
|
+
messageId: z.string().min(1),
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
export type GetGuardianRequestByDestinationMessageIpcParams = z.infer<
|
|
642
|
+
typeof GetGuardianRequestByDestinationMessageIpcParamsSchema
|
|
643
|
+
>;
|
|
644
|
+
|
|
624
645
|
/**
|
|
625
646
|
* Request for `guardian_requests_list_pending_by_destination`. Two forms:
|
|
626
647
|
* by destination conversation (`conversationId`, optionally narrowed by
|
|
@@ -245,6 +245,7 @@ export {
|
|
|
245
245
|
ExpireInteractionBoundIpcResponseSchema,
|
|
246
246
|
GetGuardianRequestByCallSessionIpcParamsSchema,
|
|
247
247
|
GetGuardianRequestByCodeIpcParamsSchema,
|
|
248
|
+
GetGuardianRequestByDestinationMessageIpcParamsSchema,
|
|
248
249
|
GetGuardianRequestByPendingQuestionIpcParamsSchema,
|
|
249
250
|
GetGuardianRequestIpcParamsSchema,
|
|
250
251
|
GUARDIAN_REQUESTS_IPC_METHODS,
|
|
@@ -285,6 +286,7 @@ export type {
|
|
|
285
286
|
ExpireInteractionBoundIpcResponse,
|
|
286
287
|
GetGuardianRequestByCallSessionIpcParams,
|
|
287
288
|
GetGuardianRequestByCodeIpcParams,
|
|
289
|
+
GetGuardianRequestByDestinationMessageIpcParams,
|
|
288
290
|
GetGuardianRequestByPendingQuestionIpcParams,
|
|
289
291
|
GetGuardianRequestIpcParams,
|
|
290
292
|
GuardianRequestAclOutcome,
|
package/openapi.yaml
CHANGED
|
@@ -4076,59 +4076,6 @@ paths:
|
|
|
4076
4076
|
responses:
|
|
4077
4077
|
"200":
|
|
4078
4078
|
description: Successful response
|
|
4079
|
-
content:
|
|
4080
|
-
application/json:
|
|
4081
|
-
schema:
|
|
4082
|
-
type: object
|
|
4083
|
-
properties:
|
|
4084
|
-
success:
|
|
4085
|
-
type: boolean
|
|
4086
|
-
bound:
|
|
4087
|
-
type: boolean
|
|
4088
|
-
channel:
|
|
4089
|
-
type: string
|
|
4090
|
-
enum:
|
|
4091
|
-
- telegram
|
|
4092
|
-
- phone
|
|
4093
|
-
- vellum
|
|
4094
|
-
- whatsapp
|
|
4095
|
-
- slack
|
|
4096
|
-
- email
|
|
4097
|
-
- platform
|
|
4098
|
-
- a2a
|
|
4099
|
-
- discord
|
|
4100
|
-
- plugin
|
|
4101
|
-
assistantId:
|
|
4102
|
-
type: string
|
|
4103
|
-
hasPendingChallenge:
|
|
4104
|
-
type: boolean
|
|
4105
|
-
guardianExternalUserId:
|
|
4106
|
-
type: string
|
|
4107
|
-
guardianDeliveryChatId:
|
|
4108
|
-
type: string
|
|
4109
|
-
guardianUsername:
|
|
4110
|
-
type: string
|
|
4111
|
-
guardianDisplayName:
|
|
4112
|
-
type: string
|
|
4113
|
-
verificationSessionId:
|
|
4114
|
-
type: string
|
|
4115
|
-
expiresAt:
|
|
4116
|
-
type: number
|
|
4117
|
-
nextResendAt:
|
|
4118
|
-
anyOf:
|
|
4119
|
-
- type: number
|
|
4120
|
-
- type: "null"
|
|
4121
|
-
sendCount:
|
|
4122
|
-
type: number
|
|
4123
|
-
pendingBootstrap:
|
|
4124
|
-
type: boolean
|
|
4125
|
-
required:
|
|
4126
|
-
- success
|
|
4127
|
-
- bound
|
|
4128
|
-
- channel
|
|
4129
|
-
- assistantId
|
|
4130
|
-
- hasPendingChallenge
|
|
4131
|
-
additionalProperties: false
|
|
4132
4079
|
/v1/channels/available:
|
|
4133
4080
|
get:
|
|
4134
4081
|
operationId: channels_available_get
|
package/package.json
CHANGED
|
@@ -10,11 +10,10 @@ import { getDb } from "../persistence/db-connection.js";
|
|
|
10
10
|
import { initializeDb } from "../persistence/db-init.js";
|
|
11
11
|
import {
|
|
12
12
|
clearPayload,
|
|
13
|
-
|
|
13
|
+
findConversationByProviderMessageId,
|
|
14
14
|
findMessageBySourceId,
|
|
15
15
|
linkMessage,
|
|
16
16
|
recordInbound,
|
|
17
|
-
recordOutboundPost,
|
|
18
17
|
storePayload,
|
|
19
18
|
} from "../persistence/delivery-crud.js";
|
|
20
19
|
import {
|
|
@@ -53,7 +52,6 @@ await initializeDb();
|
|
|
53
52
|
function resetTables() {
|
|
54
53
|
const db = getDb();
|
|
55
54
|
db.run("DELETE FROM channel_inbound_events");
|
|
56
|
-
db.run("DELETE FROM channel_outbound_posts");
|
|
57
55
|
db.run("DELETE FROM messages");
|
|
58
56
|
db.run("DELETE FROM conversation_keys");
|
|
59
57
|
db.run("DELETE FROM external_conversation_bindings");
|
|
@@ -149,144 +147,35 @@ describe("channel-delivery-store", () => {
|
|
|
149
147
|
.run();
|
|
150
148
|
|
|
151
149
|
expect(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
150
|
+
findConversationByProviderMessageId(
|
|
151
|
+
"discord",
|
|
152
|
+
chatId,
|
|
153
|
+
"1234567890123456789",
|
|
154
|
+
),
|
|
155
|
+
).toBe(minted.conversationId);
|
|
157
156
|
// A split reply posts several provider messages from one row; any of
|
|
158
157
|
// their ids resolves it.
|
|
159
158
|
expect(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
159
|
+
findConversationByProviderMessageId(
|
|
160
|
+
"discord",
|
|
161
|
+
chatId,
|
|
162
|
+
"2234567890123456789",
|
|
163
|
+
),
|
|
164
|
+
).toBe(minted.conversationId);
|
|
165
165
|
// The scan is scoped to the reaction's own channel address: the same id
|
|
166
166
|
// is invisible from another channel's or another chat's key space.
|
|
167
167
|
expect(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
findMessageByProviderMessageId(
|
|
172
|
-
"discord",
|
|
173
|
-
"other-chat",
|
|
168
|
+
findConversationByProviderMessageId(
|
|
169
|
+
"telegram",
|
|
170
|
+
chatId,
|
|
174
171
|
"1234567890123456789",
|
|
175
172
|
),
|
|
176
173
|
).toBeNull();
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test("linked inbound rows are excluded from the provider-id scan; crash-window rows resolve", () => {
|
|
180
|
-
const chatId = "chat-scan-budget";
|
|
181
|
-
const minted = recordInbound("telegram", chatId, "evt-linked", {
|
|
182
|
-
sourceMessageId: "tg-linked-1",
|
|
183
|
-
});
|
|
184
|
-
const envelope = (messageId: string) =>
|
|
185
|
-
JSON.stringify({
|
|
186
|
-
providerMeta: JSON.stringify({
|
|
187
|
-
source: "telegram",
|
|
188
|
-
conversationExternalId: chatId,
|
|
189
|
-
messageId,
|
|
190
|
-
eventKind: "message",
|
|
191
|
-
}),
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// A normally-ingested inbound row: linked to its event, so the
|
|
195
|
-
// inbound-event index owns its resolution and the fallback scan must
|
|
196
|
-
// not spend budget on it.
|
|
197
|
-
insertMessage("linked-user-row", minted.conversationId, {});
|
|
198
|
-
getDb()
|
|
199
|
-
.update(messages)
|
|
200
|
-
.set({ metadata: envelope("tg-linked-1") })
|
|
201
|
-
.where(eq(messages.id, "linked-user-row"))
|
|
202
|
-
.run();
|
|
203
|
-
linkMessage(minted.eventId, "linked-user-row");
|
|
204
|
-
expect(
|
|
205
|
-
findMessageByProviderMessageId("telegram", chatId, "tg-linked-1"),
|
|
206
|
-
).toBeNull();
|
|
207
|
-
|
|
208
|
-
// A crash-window row: persisted with its envelope but its event link
|
|
209
|
-
// never landed. The fallback is the only path that can resolve it.
|
|
210
|
-
insertMessage("orphan-user-row", minted.conversationId, {});
|
|
211
|
-
getDb()
|
|
212
|
-
.update(messages)
|
|
213
|
-
.set({ metadata: envelope("tg-orphan-1") })
|
|
214
|
-
.where(eq(messages.id, "orphan-user-row"))
|
|
215
|
-
.run();
|
|
216
|
-
expect(
|
|
217
|
-
findMessageByProviderMessageId("telegram", chatId, "tg-orphan-1"),
|
|
218
|
-
).toEqual({
|
|
219
|
-
messageId: "orphan-user-row",
|
|
220
|
-
conversationId: minted.conversationId,
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
// A legacy linked row whose event carries no source_message_id:
|
|
224
|
-
// findMessageBySourceId matches only that column, so the fallback must
|
|
225
|
-
// still admit the row or reactions to it become unresolvable.
|
|
226
|
-
const legacy = recordInbound("telegram", chatId, "evt-legacy");
|
|
227
|
-
insertMessage("legacy-user-row", legacy.conversationId, {});
|
|
228
|
-
getDb()
|
|
229
|
-
.update(messages)
|
|
230
|
-
.set({ metadata: envelope("tg-legacy-1") })
|
|
231
|
-
.where(eq(messages.id, "legacy-user-row"))
|
|
232
|
-
.run();
|
|
233
|
-
linkMessage(legacy.eventId, "legacy-user-row");
|
|
234
|
-
expect(
|
|
235
|
-
findMessageByProviderMessageId("telegram", chatId, "tg-legacy-1"),
|
|
236
|
-
).toEqual({
|
|
237
|
-
messageId: "legacy-user-row",
|
|
238
|
-
conversationId: legacy.conversationId,
|
|
239
|
-
});
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
test("the outbound-posts index resolves a post exactly, at any age", () => {
|
|
243
|
-
const chatId = "999000111222333444";
|
|
244
|
-
const minted = recordInbound("discord", chatId, "evt-index-seed");
|
|
245
|
-
const db = getDb();
|
|
246
|
-
// The row's envelope names nothing: resolution comes from the index
|
|
247
|
-
// alone, proving the table is the contract rather than a cache in
|
|
248
|
-
// front of the scan.
|
|
249
|
-
db.insert(messages)
|
|
250
|
-
.values({
|
|
251
|
-
id: "assistant-indexed-post",
|
|
252
|
-
conversationId: minted.conversationId,
|
|
253
|
-
role: "assistant",
|
|
254
|
-
content: "indexed reply",
|
|
255
|
-
createdAt: Date.now(),
|
|
256
|
-
metadata: null,
|
|
257
|
-
})
|
|
258
|
-
.run();
|
|
259
|
-
recordOutboundPost({
|
|
260
|
-
sourceChannel: "discord",
|
|
261
|
-
externalChatId: chatId,
|
|
262
|
-
providerMessageId: "555666777888999000",
|
|
263
|
-
messageId: "assistant-indexed-post",
|
|
264
|
-
conversationId: minted.conversationId,
|
|
265
|
-
});
|
|
266
|
-
// Idempotent on redelivery.
|
|
267
|
-
recordOutboundPost({
|
|
268
|
-
sourceChannel: "discord",
|
|
269
|
-
externalChatId: chatId,
|
|
270
|
-
providerMessageId: "555666777888999000",
|
|
271
|
-
messageId: "assistant-indexed-post",
|
|
272
|
-
conversationId: minted.conversationId,
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
expect(
|
|
276
|
-
findMessageByProviderMessageId("discord", chatId, "555666777888999000"),
|
|
277
|
-
).toEqual({
|
|
278
|
-
messageId: "assistant-indexed-post",
|
|
279
|
-
conversationId: minted.conversationId,
|
|
280
|
-
});
|
|
281
|
-
// Channel and chat scoping hold on the index path too.
|
|
282
|
-
expect(
|
|
283
|
-
findMessageByProviderMessageId("telegram", chatId, "555666777888999000"),
|
|
284
|
-
).toBeNull();
|
|
285
174
|
expect(
|
|
286
|
-
|
|
175
|
+
findConversationByProviderMessageId(
|
|
287
176
|
"discord",
|
|
288
177
|
"other-chat",
|
|
289
|
-
"
|
|
178
|
+
"1234567890123456789",
|
|
290
179
|
),
|
|
291
180
|
).toBeNull();
|
|
292
181
|
});
|
|
@@ -32,13 +32,6 @@ type UpdateMessageMetadataCall = {
|
|
|
32
32
|
};
|
|
33
33
|
const updateMessageMetadataCalls: UpdateMessageMetadataCall[] = [];
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
* Number of leading `updateMessageMetadata` calls that throw a transient
|
|
37
|
-
* SQLite error before the mock starts writing. Lets a test assert that a lost
|
|
38
|
-
* reconciliation write is recovered rather than forfeited.
|
|
39
|
-
*/
|
|
40
|
-
let metadataWriteFailuresRemaining = 0;
|
|
41
|
-
|
|
42
35
|
/** Per-test override for the synthetic Slack `ts` returned by deliverChannelReply. */
|
|
43
36
|
let nextDeliveryTs: string | null = null;
|
|
44
37
|
|
|
@@ -152,14 +145,6 @@ mock.module("../persistence/conversation-crud.js", () => ({
|
|
|
152
145
|
messageId: string,
|
|
153
146
|
updates: Record<string, unknown>,
|
|
154
147
|
) => {
|
|
155
|
-
if (metadataWriteFailuresRemaining > 0) {
|
|
156
|
-
metadataWriteFailuresRemaining -= 1;
|
|
157
|
-
// Shaped like bun:sqlite's error so `withSqliteRetry` classifies it as
|
|
158
|
-
// transient contention rather than a fatal write.
|
|
159
|
-
throw Object.assign(new Error("database is locked"), {
|
|
160
|
-
code: "SQLITE_BUSY",
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
148
|
updateMessageMetadataCalls.push({ messageId, updates });
|
|
164
149
|
const row = conversationMessages.find((m) => m.id === messageId);
|
|
165
150
|
if (!row) {
|
|
@@ -174,15 +159,6 @@ mock.module("../persistence/conversation-crud.js", () => ({
|
|
|
174
159
|
reserveMessage: mock(async () => ({ id: "msg-reserve" })),
|
|
175
160
|
}));
|
|
176
161
|
|
|
177
|
-
// The reconciler writes the outbound-posts index through delivery-crud;
|
|
178
|
-
// stub it so this suite stays DB-free while capturing the writes.
|
|
179
|
-
const recordedOutboundPosts: Array<Record<string, string>> = [];
|
|
180
|
-
mock.module("../persistence/delivery-crud.js", () => ({
|
|
181
|
-
recordOutboundPost: (post: Record<string, string>) => {
|
|
182
|
-
recordedOutboundPosts.push(post);
|
|
183
|
-
},
|
|
184
|
-
}));
|
|
185
|
-
|
|
186
162
|
mock.module("../persistence/attachments-store.js", () => ({
|
|
187
163
|
getAttachmentMetadataForMessage: (messageId: string) =>
|
|
188
164
|
attachmentsByMessageId.get(messageId) ?? [],
|
|
@@ -207,7 +183,6 @@ describe("channel-reply-delivery", () => {
|
|
|
207
183
|
conversationMessages.length = 0;
|
|
208
184
|
attachmentsByMessageId.clear();
|
|
209
185
|
updateMessageMetadataCalls.length = 0;
|
|
210
|
-
metadataWriteFailuresRemaining = 0;
|
|
211
186
|
nextDeliveryTs = null;
|
|
212
187
|
renderedHistoryContentQueue.length = 0;
|
|
213
188
|
renderedHistoryContent = {
|
|
@@ -812,9 +787,7 @@ describe("channel-reply-delivery", () => {
|
|
|
812
787
|
attachments,
|
|
813
788
|
startFromSegment: 1,
|
|
814
789
|
messageTs: "1700000000.000055",
|
|
815
|
-
onMessageTs: (ts) =>
|
|
816
|
-
seenTs.push(ts);
|
|
817
|
-
},
|
|
790
|
+
onMessageTs: (ts) => seenTs.push(ts),
|
|
818
791
|
});
|
|
819
792
|
|
|
820
793
|
expect(deliveryCalls).toHaveLength(1);
|
|
@@ -1281,9 +1254,7 @@ describe("channel-reply-delivery", () => {
|
|
|
1281
1254
|
"http://gateway/deliver/slack",
|
|
1282
1255
|
"assistant-compose",
|
|
1283
1256
|
{
|
|
1284
|
-
onMessageTs: (ts) =>
|
|
1285
|
-
callerTsSeen.push(ts);
|
|
1286
|
-
},
|
|
1257
|
+
onMessageTs: (ts) => callerTsSeen.push(ts),
|
|
1287
1258
|
},
|
|
1288
1259
|
);
|
|
1289
1260
|
|
|
@@ -1323,100 +1294,5 @@ describe("channel-reply-delivery", () => {
|
|
|
1323
1294
|
expect(parsed?.source).toBe("slack");
|
|
1324
1295
|
expect(parsed?.eventKind).toBe("message");
|
|
1325
1296
|
});
|
|
1326
|
-
|
|
1327
|
-
it("retries a reconciliation write that hits transient SQLite contention", async () => {
|
|
1328
|
-
// The stamp is the only durable record of the post's ts: no later sweep
|
|
1329
|
-
// heals a miss once the event is marked delivered, so a SQLITE_BUSY on
|
|
1330
|
-
// the first attempt must not lose it.
|
|
1331
|
-
pushPartialAssistantRow("conv-busy", "msg-busy", "C333");
|
|
1332
|
-
nextDeliveryTs = "1700000800.000444";
|
|
1333
|
-
metadataWriteFailuresRemaining = 1;
|
|
1334
|
-
|
|
1335
|
-
await deliverReplyViaCallback(
|
|
1336
|
-
"conv-busy",
|
|
1337
|
-
"C333",
|
|
1338
|
-
"http://gateway/deliver/slack",
|
|
1339
|
-
"assistant-busy",
|
|
1340
|
-
);
|
|
1341
|
-
|
|
1342
|
-
expect(updateMessageMetadataCalls.length).toBe(1);
|
|
1343
|
-
const merged = updateMessageMetadataCalls[0].updates.slackMeta as string;
|
|
1344
|
-
const parsed = JSON.parse(merged) as Record<string, unknown>;
|
|
1345
|
-
expect(parsed.channelTs).toBe("1700000800.000444");
|
|
1346
|
-
});
|
|
1347
|
-
|
|
1348
|
-
it("stamps from a later segment when the first segment's write is lost", async () => {
|
|
1349
|
-
// The row records the first ts that reaches a DURABLE write, not the
|
|
1350
|
-
// first ts observed. A first-invocation latch would forfeit channelTs
|
|
1351
|
-
// forever when that write fails, which is the reconciliation gap this
|
|
1352
|
-
// reproduces: the reply posts successfully and the row never gains its
|
|
1353
|
-
// own id, so history projects no `slackMessage` for it.
|
|
1354
|
-
pushPartialAssistantRow("conv-lost", "msg-lost", "C444");
|
|
1355
|
-
renderedHistoryContent = {
|
|
1356
|
-
text: "AlphaBeta",
|
|
1357
|
-
textSegments: ["Alpha", "Beta"],
|
|
1358
|
-
toolCalls: [],
|
|
1359
|
-
toolCallsBeforeText: false,
|
|
1360
|
-
contentOrder: ["text:0", "tool:0", "text:1"],
|
|
1361
|
-
surfaces: [],
|
|
1362
|
-
thinkingSegments: [],
|
|
1363
|
-
};
|
|
1364
|
-
nextDeliveryTs = "1700000900.000555";
|
|
1365
|
-
// Exhaust the initial attempt plus every `withSqliteRetry` retry, so the
|
|
1366
|
-
// first segment's reconciliation is genuinely swallowed.
|
|
1367
|
-
metadataWriteFailuresRemaining = 4;
|
|
1368
|
-
|
|
1369
|
-
await deliverReplyViaCallback(
|
|
1370
|
-
"conv-lost",
|
|
1371
|
-
"C444",
|
|
1372
|
-
"http://gateway/deliver/slack",
|
|
1373
|
-
"assistant-lost",
|
|
1374
|
-
);
|
|
1375
|
-
|
|
1376
|
-
// Both segments posted; the surviving write carries the second post's ts
|
|
1377
|
-
// rather than leaving the row without any id of its own.
|
|
1378
|
-
expect(deliveryCalls.length).toBe(2);
|
|
1379
|
-
expect(updateMessageMetadataCalls.length).toBe(1);
|
|
1380
|
-
const merged = updateMessageMetadataCalls[0].updates.slackMeta as string;
|
|
1381
|
-
const { readSlackMetadata } =
|
|
1382
|
-
await import("../messaging/providers/slack/message-metadata.js");
|
|
1383
|
-
expect(readSlackMetadata(merged)).not.toBeNull();
|
|
1384
|
-
});
|
|
1385
|
-
|
|
1386
|
-
it("commits the reconciliation write before posting the next segment", async () => {
|
|
1387
|
-
// `onMessageTs` is awaited, so the row is durable at each segment
|
|
1388
|
-
// boundary. Otherwise a crash between two posts of a split reply loses
|
|
1389
|
-
// the stamp for a message the reader can already see.
|
|
1390
|
-
pushPartialAssistantRow("conv-order", "msg-order", "C555");
|
|
1391
|
-
renderedHistoryContent = {
|
|
1392
|
-
text: "AlphaBeta",
|
|
1393
|
-
textSegments: ["Alpha", "Beta"],
|
|
1394
|
-
toolCalls: [],
|
|
1395
|
-
toolCallsBeforeText: false,
|
|
1396
|
-
contentOrder: ["text:0", "tool:0", "text:1"],
|
|
1397
|
-
surfaces: [],
|
|
1398
|
-
thinkingSegments: [],
|
|
1399
|
-
};
|
|
1400
|
-
nextDeliveryTs = "1700001000.000666";
|
|
1401
|
-
|
|
1402
|
-
await deliverReplyViaCallback(
|
|
1403
|
-
"conv-order",
|
|
1404
|
-
"C555",
|
|
1405
|
-
"http://gateway/deliver/slack",
|
|
1406
|
-
"assistant-order",
|
|
1407
|
-
);
|
|
1408
|
-
|
|
1409
|
-
// The row already parses through the strict reader by the time the
|
|
1410
|
-
// second POST goes out, which is what makes the second segment a no-op.
|
|
1411
|
-
const row = conversationMessages.find((m) => m.id === "msg-order");
|
|
1412
|
-
const envelope = JSON.parse(row?.metadata ?? "{}") as Record<
|
|
1413
|
-
string,
|
|
1414
|
-
unknown
|
|
1415
|
-
>;
|
|
1416
|
-
const { readSlackMetadata } =
|
|
1417
|
-
await import("../messaging/providers/slack/message-metadata.js");
|
|
1418
|
-
expect(readSlackMetadata(envelope.slackMeta as string)).not.toBeNull();
|
|
1419
|
-
expect(updateMessageMetadataCalls.length).toBe(1);
|
|
1420
|
-
});
|
|
1421
1297
|
});
|
|
1422
1298
|
});
|