@vellumai/assistant 0.12.2-dev.202609182015.188de8a → 0.12.2-dev.202609182114.335fa56
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 +8 -6
- package/docs/architecture/turn-actor.md +2 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-event-targeting.test.ts +186 -0
- package/src/__tests__/assistant-stream-state.test.ts +7 -9
- package/src/__tests__/channel-retry-sweep.test.ts +9 -0
- package/src/__tests__/dm-backfill.test.ts +83 -1
- package/src/__tests__/helpers/verification-sessions-ipc-sim.ts +5 -0
- package/src/__tests__/message-provenance-contact-id.test.ts +69 -0
- package/src/__tests__/process-message-display-content.test.ts +56 -0
- package/src/__tests__/reaction-persistence.test.ts +5 -0
- package/src/__tests__/runtime-events-sse-reconnect.test.ts +1 -1
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +73 -0
- package/src/__tests__/thread-backfill.test.ts +53 -0
- package/src/approvals/AGENTS.md +1 -1
- package/src/cli/commands/roadmap.help.ts +2 -3
- package/src/daemon/conversation-messaging.ts +19 -1
- package/src/daemon/message-provenance.ts +23 -0
- package/src/daemon/process-message.ts +13 -4
- package/src/persistence/conversation-crud.ts +8 -0
- package/src/plugins/defaults/memory/__tests__/archive-removal-framing.test.ts +64 -0
- package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +2 -2
- package/src/plugins/defaults/memory/tools.test.ts +24 -1
- package/src/plugins/defaults/memory/tools.ts +12 -1
- package/src/runtime/assistant-event-hub.ts +16 -86
- package/src/runtime/assistant-event-targeting.ts +79 -0
- package/src/runtime/assistant-stream-state.ts +13 -112
- package/src/runtime/channel-retry-sweep.ts +1 -0
- package/src/runtime/http-types.ts +6 -0
- package/src/runtime/routes/events-routes.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +35 -38
- package/src/runtime/routes/inbound-stages/backfill-sender-provenance.test.ts +165 -0
- package/src/runtime/routes/inbound-stages/backfill-sender-provenance.ts +92 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +29 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -0
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +2 -0
- package/src/util/canonicalize-identity.test.ts +21 -0
- package/src/util/canonicalize-identity.ts +16 -0
package/ARCHITECTURE.md
CHANGED
|
@@ -1930,12 +1930,14 @@ Keep-alive heartbeats (every 30 s by default):
|
|
|
1930
1930
|
|
|
1931
1931
|
### Key Source Files
|
|
1932
1932
|
|
|
1933
|
-
| File
|
|
1934
|
-
|
|
|
1935
|
-
| `assistant/src/runtime/assistant-event.ts`
|
|
1936
|
-
| `assistant/src/runtime/assistant-event-hub.ts`
|
|
1937
|
-
| `assistant/src/runtime/
|
|
1938
|
-
| `assistant/src/
|
|
1933
|
+
| File | Role |
|
|
1934
|
+
| ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
|
1935
|
+
| `assistant/src/runtime/assistant-event.ts` | `AssistantEvent` type, `buildAssistantEvent()` factory, SSE framing helpers |
|
|
1936
|
+
| `assistant/src/runtime/assistant-event-hub.ts` | `AssistantEventHub` class and process-level singleton |
|
|
1937
|
+
| `assistant/src/runtime/assistant-event-targeting.ts` | `matchesTargeting()`, the targeting check live fanout and replay share |
|
|
1938
|
+
| `assistant/src/runtime/assistant-stream-state.ts` | Per-assistant `seq` counter and ring buffer behind reconnect and `events/tail` replay |
|
|
1939
|
+
| `assistant/src/runtime/routes/events-routes.ts` | `handleSubscribeAssistantEvents()`, the SSE route handler |
|
|
1940
|
+
| `assistant/src/daemon/server.ts` | Session event paths that publish to the hub (`send` → `publishAssistantEvent`) |
|
|
1939
1941
|
|
|
1940
1942
|
---
|
|
1941
1943
|
|
|
@@ -29,6 +29,8 @@ Do not read `trustContext` or `currentTurnTrustContext` directly. Call sites han
|
|
|
29
29
|
|
|
30
30
|
**Use `getTurnOrRestingTrust()`** for provenance stamped onto persisted rows (see `provenanceFromTrustContext`). Provenance is read back by the memory indexer, which runs extraction only for guardian rows, and by the transcript assembly, which wraps non-guardian user content before it reaches the model. The turn's actor is correct when a turn is running; rows persisted with no turn in flight (wake notices) must keep the owner's class, because stamping `"unknown"` there silently stops memory extraction for the owner's own flows.
|
|
31
31
|
|
|
32
|
+
That provenance describes the turn, so it is stamped on every row the turn writes, the assistant's replies and tool results included. Who wrote a row is a separate fact: `provenanceContactId` names the author's contact and needs positive evidence. It is added only where a person's own inbound words or actions are persisted (`actorAuthorProvenance`): a message channel ingress or its retry replay relays with an explicit `author`, unless that row is scripted or hidden, and the actor's reaction row. The shared user-message writer never infers an author from the conversation's trust, because machine-authored rows (ACP and subagent notifications, pointer turns) persist through it too. Rows the assistant writes during someone's turn never carry it. Slack backfill imports other people's messages, so the turn's actor says nothing about them; each backfilled sender is classified by the same gateway verdict live ingress uses (`resolve_inbound_trust`, see `inbound-stages/backfill-sender-provenance.ts`), which supplies both its trust class and its contact id.
|
|
33
|
+
|
|
32
34
|
**Use `getTrustContext()`** when there is no turn: HTTP routes reporting conversation state, hydration, and persistence of conversation-level options. Also when a caller deliberately wants the conversation's owner rather than whoever is currently acting; that intent should be obvious from the call site, and if it is not, it is probably the wrong accessor.
|
|
33
35
|
|
|
34
36
|
## When the acting actor is unknown
|
package/package.json
CHANGED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `matchesTargeting`, the one targeting check behind live fanout
|
|
3
|
+
* (`AssistantEventHub.publish`) and replay (`getReplayWindow`).
|
|
4
|
+
*
|
|
5
|
+
* The parity block runs every case through both paths and requires them to
|
|
6
|
+
* agree, so a rule added to one path alone fails here.
|
|
7
|
+
*/
|
|
8
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
import type { AssistantEventEnvelope } from "../api/index.js";
|
|
11
|
+
import type { HostProxyCapability, InterfaceId } from "../channels/types.js";
|
|
12
|
+
import { AssistantEventHub } from "../runtime/assistant-event-hub.js";
|
|
13
|
+
import type { AssistantEventPublishOptions } from "../runtime/assistant-event-publish-options.js";
|
|
14
|
+
import { matchesTargeting } from "../runtime/assistant-event-targeting.js";
|
|
15
|
+
import {
|
|
16
|
+
_resetStreamStateForTesting,
|
|
17
|
+
getReplayWindow,
|
|
18
|
+
stampAndBuffer,
|
|
19
|
+
} from "../runtime/assistant-stream-state.js";
|
|
20
|
+
|
|
21
|
+
const CONV = "conv-targeting";
|
|
22
|
+
|
|
23
|
+
interface ClientIdentity {
|
|
24
|
+
type: "client";
|
|
25
|
+
clientId: string;
|
|
26
|
+
interfaceId: InterfaceId;
|
|
27
|
+
capabilities: HostProxyCapability[];
|
|
28
|
+
actorPrincipalId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MAC: ClientIdentity = {
|
|
32
|
+
type: "client",
|
|
33
|
+
clientId: "mac-1",
|
|
34
|
+
interfaceId: "macos",
|
|
35
|
+
capabilities: ["host_bash", "host_browser"],
|
|
36
|
+
actorPrincipalId: "principal-g",
|
|
37
|
+
};
|
|
38
|
+
const WEB: ClientIdentity = {
|
|
39
|
+
type: "client",
|
|
40
|
+
clientId: "web-1",
|
|
41
|
+
interfaceId: "web",
|
|
42
|
+
capabilities: [],
|
|
43
|
+
actorPrincipalId: "principal-g",
|
|
44
|
+
};
|
|
45
|
+
const OTHER_USER: ClientIdentity = {
|
|
46
|
+
type: "client",
|
|
47
|
+
clientId: "web-2",
|
|
48
|
+
interfaceId: "web",
|
|
49
|
+
capabilities: [],
|
|
50
|
+
actorPrincipalId: "principal-x",
|
|
51
|
+
};
|
|
52
|
+
const NO_PRINCIPAL: ClientIdentity = {
|
|
53
|
+
type: "client",
|
|
54
|
+
clientId: "legacy-1",
|
|
55
|
+
interfaceId: "web",
|
|
56
|
+
capabilities: [],
|
|
57
|
+
};
|
|
58
|
+
interface ProcessIdentity {
|
|
59
|
+
type: "process";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const PROCESS: ProcessIdentity = { type: "process" };
|
|
63
|
+
|
|
64
|
+
const SUBSCRIBERS: Array<[string, ClientIdentity | ProcessIdentity]> = [
|
|
65
|
+
["mac", MAC],
|
|
66
|
+
["web", WEB],
|
|
67
|
+
["other user", OTHER_USER],
|
|
68
|
+
["no principal", NO_PRINCIPAL],
|
|
69
|
+
["process", PROCESS],
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/** Each targeting case with the subscribers it must reach, by label. */
|
|
73
|
+
const CASES: Array<{
|
|
74
|
+
name: string;
|
|
75
|
+
targeting: AssistantEventPublishOptions | undefined;
|
|
76
|
+
reaches: string[];
|
|
77
|
+
}> = [
|
|
78
|
+
{
|
|
79
|
+
name: "untargeted",
|
|
80
|
+
targeting: undefined,
|
|
81
|
+
reaches: ["mac", "web", "other user", "no principal", "process"],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "self-echo exclusion",
|
|
85
|
+
targeting: { excludeClientId: "web-1" },
|
|
86
|
+
reaches: ["mac", "other user", "no principal", "process"],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "interface",
|
|
90
|
+
targeting: { targetInterfaceId: "web" },
|
|
91
|
+
reaches: ["web", "other user", "no principal"],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "principal",
|
|
95
|
+
targeting: { targetActorPrincipalId: "principal-g" },
|
|
96
|
+
reaches: ["mac", "web"],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "client",
|
|
100
|
+
targeting: { targetClientId: "mac-1" },
|
|
101
|
+
reaches: ["mac"],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "capability",
|
|
105
|
+
targeting: { targetCapability: "host_browser" },
|
|
106
|
+
reaches: ["mac"],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "client without the capability",
|
|
110
|
+
targeting: { targetClientId: "web-1", targetCapability: "host_browser" },
|
|
111
|
+
reaches: [],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "principal and interface together",
|
|
115
|
+
targeting: {
|
|
116
|
+
targetActorPrincipalId: "principal-g",
|
|
117
|
+
targetInterfaceId: "web",
|
|
118
|
+
},
|
|
119
|
+
reaches: ["web"],
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
function makeEvent(): AssistantEventEnvelope {
|
|
124
|
+
return {
|
|
125
|
+
id: `evt-${Math.random().toString(36).slice(2, 10)}`,
|
|
126
|
+
conversationId: CONV,
|
|
127
|
+
emittedAt: new Date().toISOString(),
|
|
128
|
+
message: { type: "assistant_text_delta", conversationId: CONV, text: "x" },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function reachedLive(
|
|
133
|
+
targeting: AssistantEventPublishOptions | undefined,
|
|
134
|
+
): Promise<string[]> {
|
|
135
|
+
const hub = new AssistantEventHub();
|
|
136
|
+
const reached: string[] = [];
|
|
137
|
+
for (const [label, identity] of SUBSCRIBERS) {
|
|
138
|
+
const callback = () => {
|
|
139
|
+
reached.push(label);
|
|
140
|
+
};
|
|
141
|
+
if (identity.type === "process") {
|
|
142
|
+
hub.subscribe({ type: "process", callback });
|
|
143
|
+
} else {
|
|
144
|
+
hub.subscribe({ ...identity, callback });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
await hub.publish(makeEvent(), targeting);
|
|
148
|
+
return reached.sort();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function reachedOnReplay(
|
|
152
|
+
targeting: AssistantEventPublishOptions | undefined,
|
|
153
|
+
): string[] {
|
|
154
|
+
_resetStreamStateForTesting();
|
|
155
|
+
stampAndBuffer(makeEvent(), targeting ? { targeting } : undefined);
|
|
156
|
+
return SUBSCRIBERS.filter(
|
|
157
|
+
([, identity]) => (getReplayWindow(0, identity) ?? []).length > 0,
|
|
158
|
+
)
|
|
159
|
+
.map(([label]) => label)
|
|
160
|
+
.sort();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
describe("matchesTargeting", () => {
|
|
164
|
+
for (const { name, targeting, reaches } of CASES) {
|
|
165
|
+
test(`${name}: reaches exactly its subscribers`, () => {
|
|
166
|
+
const reached = SUBSCRIBERS.filter(([, identity]) =>
|
|
167
|
+
matchesTargeting(targeting, identity),
|
|
168
|
+
).map(([label]) => label);
|
|
169
|
+
expect(reached.sort()).toEqual([...reaches].sort());
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("live fanout and replay agree", () => {
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
_resetStreamStateForTesting();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
for (const { name, targeting, reaches } of CASES) {
|
|
180
|
+
test(`${name}: both paths deliver to the same subscribers`, async () => {
|
|
181
|
+
const expected = [...reaches].sort();
|
|
182
|
+
expect(await reachedLive(targeting)).toEqual(expected);
|
|
183
|
+
expect(reachedOnReplay(targeting)).toEqual(expected);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
});
|
|
@@ -9,10 +9,8 @@ import { dirname, join } from "node:path";
|
|
|
9
9
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
10
10
|
|
|
11
11
|
import type { AssistantEventEnvelope } from "../api/index.js";
|
|
12
|
-
import type {
|
|
13
|
-
|
|
14
|
-
ReplaySubscriber,
|
|
15
|
-
} from "../runtime/assistant-stream-state.js";
|
|
12
|
+
import type { AssistantEventPublishOptions } from "../runtime/assistant-event-publish-options.js";
|
|
13
|
+
import type { SubscriberIdentity } from "../runtime/assistant-event-targeting.js";
|
|
16
14
|
import {
|
|
17
15
|
_peekStreamForTesting,
|
|
18
16
|
_resetStreamStateForTesting,
|
|
@@ -103,7 +101,7 @@ describe("assistant-stream-state", () => {
|
|
|
103
101
|
/** Targeted events stay in the ring so replay can filter them. */
|
|
104
102
|
|
|
105
103
|
// GIVEN a targeting modifier
|
|
106
|
-
const targeting:
|
|
104
|
+
const targeting: AssistantEventPublishOptions = {
|
|
107
105
|
targetCapability: "host_bash",
|
|
108
106
|
};
|
|
109
107
|
|
|
@@ -334,28 +332,28 @@ describe("assistant-stream-state", () => {
|
|
|
334
332
|
});
|
|
335
333
|
|
|
336
334
|
describe("getReplayWindow — targeting filter", () => {
|
|
337
|
-
const MACOS_CLIENT:
|
|
335
|
+
const MACOS_CLIENT: SubscriberIdentity = {
|
|
338
336
|
type: "client",
|
|
339
337
|
clientId: "mac-1",
|
|
340
338
|
interfaceId: "macos",
|
|
341
339
|
capabilities: ["host_bash", "host_file", "host_cu", "host_browser"],
|
|
342
340
|
};
|
|
343
341
|
|
|
344
|
-
const WEB_CLIENT:
|
|
342
|
+
const WEB_CLIENT: SubscriberIdentity = {
|
|
345
343
|
type: "client",
|
|
346
344
|
clientId: "web-1",
|
|
347
345
|
interfaceId: "web",
|
|
348
346
|
capabilities: [],
|
|
349
347
|
};
|
|
350
348
|
|
|
351
|
-
const CHROME_EXT_CLIENT:
|
|
349
|
+
const CHROME_EXT_CLIENT: SubscriberIdentity = {
|
|
352
350
|
type: "client",
|
|
353
351
|
clientId: "ext-1",
|
|
354
352
|
interfaceId: "chrome-extension",
|
|
355
353
|
capabilities: ["host_browser"],
|
|
356
354
|
};
|
|
357
355
|
|
|
358
|
-
const PROCESS_SUB:
|
|
356
|
+
const PROCESS_SUB: SubscriberIdentity = { type: "process" };
|
|
359
357
|
|
|
360
358
|
test("untargeted events are replayed to all subscriber types", () => {
|
|
361
359
|
/** Events without targeting metadata go to everyone. */
|
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
clearConversations,
|
|
99
99
|
setConversation,
|
|
100
100
|
} from "../daemon/conversation-registry.js";
|
|
101
|
+
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
101
102
|
import { getDb } from "../persistence/db-connection.js";
|
|
102
103
|
import { initializeDb } from "../persistence/db-init.js";
|
|
103
104
|
import * as deliveryCrud from "../persistence/delivery-crud.js";
|
|
@@ -318,7 +319,11 @@ describe("channel-retry-sweep", () => {
|
|
|
318
319
|
}
|
|
319
320
|
| undefined;
|
|
320
321
|
|
|
322
|
+
let capturedAuthor: TrustContext | undefined;
|
|
323
|
+
let capturedTrust: TrustContext | undefined;
|
|
321
324
|
await sweepFailedEvents(async (conversationId, _content, options) => {
|
|
325
|
+
capturedAuthor = options?.author;
|
|
326
|
+
capturedTrust = options?.trustContext;
|
|
322
327
|
capturedOptions = options as {
|
|
323
328
|
trustContext?: {
|
|
324
329
|
trustClass?: string;
|
|
@@ -345,6 +350,10 @@ describe("channel-retry-sweep", () => {
|
|
|
345
350
|
"principal-1",
|
|
346
351
|
);
|
|
347
352
|
expect(capturedOptions?.isInteractive).toBe(c.expectedInteractive);
|
|
353
|
+
// The replay persists the sender's own message, so it names them as
|
|
354
|
+
// the row's author, exactly as live ingress does.
|
|
355
|
+
expect(capturedAuthor?.trustClass).toBe(c.trustClass);
|
|
356
|
+
expect(capturedAuthor).toEqual(capturedTrust);
|
|
348
357
|
|
|
349
358
|
const db = getDb();
|
|
350
359
|
const row = db
|
|
@@ -11,7 +11,15 @@
|
|
|
11
11
|
* - When `backfillDm` throws, the turn proceeds without a crash and
|
|
12
12
|
* nothing extra is persisted.
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
afterAll,
|
|
16
|
+
beforeEach,
|
|
17
|
+
describe,
|
|
18
|
+
expect,
|
|
19
|
+
mock,
|
|
20
|
+
spyOn,
|
|
21
|
+
test,
|
|
22
|
+
} from "bun:test";
|
|
15
23
|
|
|
16
24
|
// ---------------------------------------------------------------------------
|
|
17
25
|
// Mocks (must precede module imports under test)
|
|
@@ -68,6 +76,7 @@ mock.module("../messaging/providers/slack/backfill.js", () => ({
|
|
|
68
76
|
backfillThread: () => backfillThreadMock(),
|
|
69
77
|
}));
|
|
70
78
|
|
|
79
|
+
import * as inboundTrustReader from "../calls/inbound-trust-reader.js";
|
|
71
80
|
import {
|
|
72
81
|
loadRawConfig,
|
|
73
82
|
saveRawConfig,
|
|
@@ -87,6 +96,15 @@ import {
|
|
|
87
96
|
|
|
88
97
|
await initializeDb();
|
|
89
98
|
|
|
99
|
+
// Backfilled senders are classified by the gateway verdict; with no gateway
|
|
100
|
+
// in the test process the read reports unreachable unless a test says so.
|
|
101
|
+
// A spy rather than mock.module, so the stub does not leak into other files.
|
|
102
|
+
const readInboundTrustMock = spyOn(inboundTrustReader, "readInboundTrust");
|
|
103
|
+
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
readInboundTrustMock.mockRestore();
|
|
106
|
+
});
|
|
107
|
+
|
|
90
108
|
// ---------------------------------------------------------------------------
|
|
91
109
|
// Helpers
|
|
92
110
|
// ---------------------------------------------------------------------------
|
|
@@ -180,6 +198,7 @@ function readPersistedSlackRows(): Array<{
|
|
|
180
198
|
provenanceSourceChannel: string | undefined;
|
|
181
199
|
provenanceGuardianExternalUserId: string | undefined;
|
|
182
200
|
provenanceRequesterIdentifier: string | undefined;
|
|
201
|
+
provenanceContactId: string | undefined;
|
|
183
202
|
sentAt: number | undefined;
|
|
184
203
|
createdAt: number;
|
|
185
204
|
}> {
|
|
@@ -234,6 +253,10 @@ function readPersistedSlackRows(): Array<{
|
|
|
234
253
|
typeof envelope.provenanceRequesterIdentifier === "string"
|
|
235
254
|
? envelope.provenanceRequesterIdentifier
|
|
236
255
|
: undefined,
|
|
256
|
+
provenanceContactId:
|
|
257
|
+
typeof envelope.provenanceContactId === "string"
|
|
258
|
+
? envelope.provenanceContactId
|
|
259
|
+
: undefined,
|
|
237
260
|
sentAt:
|
|
238
261
|
typeof envelope.sentAt === "number" ? envelope.sentAt : undefined,
|
|
239
262
|
createdAt: row.createdAt,
|
|
@@ -279,6 +302,8 @@ describe("PR 23 — Slack DM cold-start backfill", () => {
|
|
|
279
302
|
backfillDmMock.mockReset();
|
|
280
303
|
backfillDmMock.mockImplementation(async () => []);
|
|
281
304
|
backfillThreadMock.mockReset();
|
|
305
|
+
readInboundTrustMock.mockReset();
|
|
306
|
+
readInboundTrustMock.mockImplementation(async () => ({ ok: false }));
|
|
282
307
|
});
|
|
283
308
|
|
|
284
309
|
test("first DM in cold conversation triggers backfill exactly once and persists history", async () => {
|
|
@@ -523,6 +548,63 @@ describe("PR 23 — Slack DM cold-start backfill", () => {
|
|
|
523
548
|
expect(botRow?.provenanceRequesterIdentifier).toBe("B_BOT");
|
|
524
549
|
});
|
|
525
550
|
|
|
551
|
+
test("backfilled rows record the sender's gateway trust class and contact id", async () => {
|
|
552
|
+
readInboundTrustMock.mockImplementation(async (input) =>
|
|
553
|
+
input.actorExternalId === SLACK_DM_USER_ID
|
|
554
|
+
? {
|
|
555
|
+
ok: true,
|
|
556
|
+
verdict: {
|
|
557
|
+
trustClass: "trusted_contact",
|
|
558
|
+
canonicalSenderId: SLACK_DM_USER_ID,
|
|
559
|
+
contactId: "contact-dm-user",
|
|
560
|
+
channelId: "channel-dm-user",
|
|
561
|
+
status: "active",
|
|
562
|
+
policy: "allow",
|
|
563
|
+
},
|
|
564
|
+
admissionPolicy: null,
|
|
565
|
+
}
|
|
566
|
+
: { ok: false },
|
|
567
|
+
);
|
|
568
|
+
backfillDmMock.mockImplementation(async () => [
|
|
569
|
+
makeBackfilledMessage({
|
|
570
|
+
id: "1700000000.000001",
|
|
571
|
+
text: "first from member",
|
|
572
|
+
}),
|
|
573
|
+
makeBackfilledMessage({
|
|
574
|
+
id: "1700000000.000002",
|
|
575
|
+
text: "assistant's own post",
|
|
576
|
+
sender: { id: "U_BOT", name: "assistant-bot" },
|
|
577
|
+
metadata: { isBot: true },
|
|
578
|
+
}),
|
|
579
|
+
makeBackfilledMessage({
|
|
580
|
+
id: "1700000000.000003",
|
|
581
|
+
text: "second from member",
|
|
582
|
+
}),
|
|
583
|
+
]);
|
|
584
|
+
|
|
585
|
+
await handleChannelInbound(
|
|
586
|
+
buildDmRequest("live new DM"),
|
|
587
|
+
noopProcessMessage,
|
|
588
|
+
TEST_BEARER_TOKEN,
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
const rows = readPersistedSlackRows();
|
|
592
|
+
const memberRows = rows.filter((r) => r.role === "user");
|
|
593
|
+
expect(memberRows.map((r) => r.content).sort()).toEqual([
|
|
594
|
+
"first from member",
|
|
595
|
+
"second from member",
|
|
596
|
+
]);
|
|
597
|
+
for (const row of memberRows) {
|
|
598
|
+
expect(row.provenanceTrustClass).toBe("trusted_contact");
|
|
599
|
+
expect(row.provenanceContactId).toBe("contact-dm-user");
|
|
600
|
+
}
|
|
601
|
+
const ownPost = rows.find((r) => r.role === "assistant");
|
|
602
|
+
expect(ownPost?.provenanceTrustClass).toBe("unknown");
|
|
603
|
+
expect(ownPost?.provenanceContactId).toBeUndefined();
|
|
604
|
+
// One read per distinct sender, the member's rows sharing theirs.
|
|
605
|
+
expect(readInboundTrustMock).toHaveBeenCalledTimes(2);
|
|
606
|
+
});
|
|
607
|
+
|
|
526
608
|
test("skips Slack assistant new-thread placeholder during DM backfill", async () => {
|
|
527
609
|
backfillDmMock.mockImplementation(async () => [
|
|
528
610
|
makeBackfilledMessage({
|
|
@@ -276,6 +276,11 @@ export function createOutboundSessionGuarded(
|
|
|
276
276
|
}
|
|
277
277
|
source.status = "revoked";
|
|
278
278
|
source.updatedAt = Date.now();
|
|
279
|
+
// Like the gateway, the replacement keeps the claimed session's purpose.
|
|
280
|
+
return createOutboundSession({
|
|
281
|
+
...createParams,
|
|
282
|
+
verificationPurpose: source.verificationPurpose,
|
|
283
|
+
});
|
|
279
284
|
}
|
|
280
285
|
|
|
281
286
|
return createOutboundSession(createParams);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { actorAuthorProvenance } from "../daemon/message-provenance.js";
|
|
4
|
+
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
5
|
+
import {
|
|
6
|
+
addMessage,
|
|
7
|
+
createConversation,
|
|
8
|
+
getMessageById,
|
|
9
|
+
parseMessageMetadata,
|
|
10
|
+
provenanceFromTrustContext,
|
|
11
|
+
} from "../persistence/conversation-crud.js";
|
|
12
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
13
|
+
import { initializeDb } from "../persistence/db-init.js";
|
|
14
|
+
|
|
15
|
+
await initializeDb();
|
|
16
|
+
|
|
17
|
+
const ALICE: TrustContext = {
|
|
18
|
+
sourceChannel: "slack",
|
|
19
|
+
trustClass: "trusted_contact",
|
|
20
|
+
requesterIdentifier: "@alice",
|
|
21
|
+
requesterContactId: "contact-alice",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function persistWith(
|
|
25
|
+
role: "user" | "assistant",
|
|
26
|
+
metadata: Record<string, unknown>,
|
|
27
|
+
): Promise<ReturnType<typeof parseMessageMetadata>> {
|
|
28
|
+
const conversation = createConversation("provenance-contact-id");
|
|
29
|
+
const row = await addMessage(conversation.id, role, "hello", { metadata });
|
|
30
|
+
const stored = getMessageById(row.id, conversation.id);
|
|
31
|
+
return parseMessageMetadata(stored?.metadata ?? null);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("message provenance contact id", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
const db = getDb();
|
|
37
|
+
db.run(`DELETE FROM messages`);
|
|
38
|
+
db.run(`DELETE FROM conversations`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a person's own row names them as its author", async () => {
|
|
42
|
+
const metadata = await persistWith("user", {
|
|
43
|
+
...provenanceFromTrustContext(ALICE),
|
|
44
|
+
...actorAuthorProvenance(ALICE),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(metadata?.provenanceTrustClass).toBe("trusted_contact");
|
|
48
|
+
expect(metadata?.provenanceContactId).toBe("contact-alice");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a row written during their turn carries the turn's trust but no author", async () => {
|
|
52
|
+
// The assistant's replies, tool results, and notices are stamped with the
|
|
53
|
+
// turn's provenance alone.
|
|
54
|
+
const metadata = await persistWith(
|
|
55
|
+
"assistant",
|
|
56
|
+
provenanceFromTrustContext(ALICE),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(metadata?.provenanceTrustClass).toBe("trusted_contact");
|
|
60
|
+
expect(metadata?.provenanceContactId).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("an actor with no resolved contact names no author", () => {
|
|
64
|
+
expect(
|
|
65
|
+
actorAuthorProvenance({ sourceChannel: "slack", trustClass: "unknown" }),
|
|
66
|
+
).toEqual({});
|
|
67
|
+
expect(actorAuthorProvenance(undefined)).toEqual({});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -148,6 +148,7 @@ import { persistQueuedMessageBody } from "../daemon/conversation-messaging.js";
|
|
|
148
148
|
import type { MessageQueue } from "../daemon/conversation-queue-manager.js";
|
|
149
149
|
import type { UserMessageAttachment } from "../daemon/message-protocol.js";
|
|
150
150
|
import { processMessage } from "../daemon/process-message.js";
|
|
151
|
+
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
151
152
|
import type { Message } from "../providers/types.js";
|
|
152
153
|
|
|
153
154
|
function makeTestConversation() {
|
|
@@ -554,3 +555,58 @@ describe("processMessage skipUserMessageIndexing", () => {
|
|
|
554
555
|
expect(addMessageCalls[0]!.skipIndexing).toBeUndefined();
|
|
555
556
|
});
|
|
556
557
|
});
|
|
558
|
+
|
|
559
|
+
describe("processMessage author", () => {
|
|
560
|
+
const author: TrustContext = {
|
|
561
|
+
sourceChannel: "slack",
|
|
562
|
+
trustClass: "trusted_contact",
|
|
563
|
+
requesterContactId: "contact-alice",
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
beforeEach(() => {
|
|
567
|
+
addMessageCalls.length = 0;
|
|
568
|
+
activeConversation = undefined;
|
|
569
|
+
resolveSlashForTest = (content) => ({ kind: "passthrough", content });
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("a relayed message names its author on the user row", async () => {
|
|
573
|
+
activeConversation = makeTestConversation();
|
|
574
|
+
|
|
575
|
+
await processMessage("conv-display-content", "the export needs a token", {
|
|
576
|
+
author,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
expect(addMessageCalls[0]!.role).toBe("user");
|
|
580
|
+
expect(addMessageCalls[0]!.metadata?.provenanceContactId).toBe(
|
|
581
|
+
"contact-alice",
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("a message nobody relayed names no author", async () => {
|
|
586
|
+
activeConversation = makeTestConversation();
|
|
587
|
+
|
|
588
|
+
await processMessage("conv-display-content", "kickoff prompt body", {});
|
|
589
|
+
|
|
590
|
+
expect(addMessageCalls[0]!.metadata?.provenanceContactId).toBeUndefined();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("a relayed slash command names its author on the user row, not the reply", async () => {
|
|
594
|
+
resolveSlashForTest = () => ({
|
|
595
|
+
kind: "unknown",
|
|
596
|
+
message: "Unknown slash command.",
|
|
597
|
+
});
|
|
598
|
+
activeConversation = makeTestConversation();
|
|
599
|
+
|
|
600
|
+
await processMessage("conv-display-content", "/missing-command", {
|
|
601
|
+
author,
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
expect(addMessageCalls).toHaveLength(2);
|
|
605
|
+
expect(addMessageCalls[0]!.role).toBe("user");
|
|
606
|
+
expect(addMessageCalls[0]!.metadata?.provenanceContactId).toBe(
|
|
607
|
+
"contact-alice",
|
|
608
|
+
);
|
|
609
|
+
expect(addMessageCalls[1]!.role).toBe("assistant");
|
|
610
|
+
expect(addMessageCalls[1]!.metadata?.provenanceContactId).toBeUndefined();
|
|
611
|
+
});
|
|
612
|
+
});
|
|
@@ -116,9 +116,12 @@ function resetState(): void {
|
|
|
116
116
|
_conversationMocks.clear();
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
const REACTOR_CONTACT_ID = "contact-reactor";
|
|
120
|
+
|
|
119
121
|
function seedActiveMember(): void {
|
|
120
122
|
seedContactChannel({
|
|
121
123
|
sourceChannel: "slack",
|
|
124
|
+
contactId: REACTOR_CONTACT_ID,
|
|
122
125
|
externalUserId: SLACK_USER_ID,
|
|
123
126
|
externalChatId: SLACK_CHANNEL_ID,
|
|
124
127
|
status: "active",
|
|
@@ -276,6 +279,8 @@ describe("Slack reaction event persistence", () => {
|
|
|
276
279
|
// filterMessagesForUntrustedActor drops rows with no trust class.
|
|
277
280
|
expect(envelope.provenanceTrustClass).toBe("trusted_contact");
|
|
278
281
|
expect(envelope.provenanceSourceChannel).toBe("slack");
|
|
282
|
+
// The reactor wrote this row, so it names them as its author.
|
|
283
|
+
expect(envelope.provenanceContactId).toBe(REACTOR_CONTACT_ID);
|
|
279
284
|
const slackMetaRaw = envelope.slackMeta;
|
|
280
285
|
expect(typeof slackMetaRaw).toBe("string");
|
|
281
286
|
|
|
@@ -270,7 +270,7 @@ describe("SSE reconnect replay (B7.2)", () => {
|
|
|
270
270
|
test("replay excludes events the subscriber is not targeted to receive", async () => {
|
|
271
271
|
// Stamp three events: two untargeted (seq 1, 3) + one with a
|
|
272
272
|
// capability target (seq 2, host_bash). A process subscriber
|
|
273
|
-
// does NOT match capability targeting (
|
|
273
|
+
// does NOT match capability targeting (matchesTargeting requires
|
|
274
274
|
// type=client + matching capability), so the replay filters out
|
|
275
275
|
// seq 2. The subscriber receives seq 1 and 3.
|
|
276
276
|
const { conversationId } = getOrCreateConversation("replay-targeting");
|