@nebutra/agent-runtime 0.2.1 → 2.0.0
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/LICENSE +21 -676
- package/README.md +33 -10
- package/dist/adapters/index.d.ts +24 -5
- package/dist/adapters/index.js +27 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/{chunk-KCNN4QUQ.js → chunk-5UGIUYJR.js} +4 -4
- package/dist/chunk-7ELA4SWE.js +73 -0
- package/dist/chunk-7ELA4SWE.js.map +1 -0
- package/dist/chunk-Q62VKHIT.js +178 -0
- package/dist/chunk-Q62VKHIT.js.map +1 -0
- package/dist/chunk-VXILZAXK.js +541 -0
- package/dist/chunk-VXILZAXK.js.map +1 -0
- package/dist/command-exec.d.ts +45 -0
- package/dist/command-exec.js +15 -0
- package/dist/command-exec.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +81 -28
- package/dist/index.js.map +1 -1
- package/dist/orchestration.d.ts +42 -1
- package/dist/orchestration.js +7 -3
- package/dist/pulsar.js +2 -2
- package/dist/sandbox-DfcRttXt.d.ts +219 -0
- package/dist/sandbox.d.ts +2 -64
- package/dist/sandbox.js +25 -3
- package/package.json +81 -30
- package/.turbo/turbo-build.log +0 -130
- package/.turbo/turbo-test.log +0 -47
- package/.turbo/turbo-typecheck.log +0 -4
- package/CHANGELOG.md +0 -250
- package/dist/chunk-MUF7ZZTO.js +0 -57
- package/dist/chunk-MUF7ZZTO.js.map +0 -1
- package/dist/chunk-MX2WL43P.js +0 -90
- package/dist/chunk-MX2WL43P.js.map +0 -1
- package/examples/pulsar-quickstart.ts +0 -35
- package/examples/resume-branch.ts +0 -45
- package/examples/subagent-fanout.ts +0 -20
- package/src/adapters/dispatcher-sse.test.ts +0 -218
- package/src/adapters/dispatcher-sse.ts +0 -222
- package/src/adapters/index.ts +0 -18
- package/src/adapters/mcp-catalog.test.ts +0 -213
- package/src/adapters/mcp-catalog.ts +0 -188
- package/src/adapters/prisma-rollout.test.ts +0 -153
- package/src/adapters/prisma-rollout.ts +0 -104
- package/src/agent-runtime.test.ts +0 -176
- package/src/artifact-stream.test.ts +0 -330
- package/src/artifact-stream.ts +0 -453
- package/src/channel-gateway.test.ts +0 -432
- package/src/channel-gateway.ts +0 -357
- package/src/cli.ts +0 -49
- package/src/code-review.test.ts +0 -501
- package/src/code-review.ts +0 -495
- package/src/command-suggestions.test.ts +0 -251
- package/src/command-suggestions.ts +0 -338
- package/src/commands.test.ts +0 -184
- package/src/commands.ts +0 -140
- package/src/commit-message.test.ts +0 -249
- package/src/commit-message.ts +0 -180
- package/src/context-compaction.test.ts +0 -522
- package/src/context-compaction.ts +0 -438
- package/src/definitions.test.ts +0 -78
- package/src/definitions.ts +0 -190
- package/src/deployment-status.test.ts +0 -215
- package/src/deployment-status.ts +0 -227
- package/src/design-context.test.ts +0 -195
- package/src/design-context.ts +0 -198
- package/src/dispatcher.test.ts +0 -234
- package/src/dispatcher.ts +0 -189
- package/src/durable-turn.test.ts +0 -209
- package/src/durable-turn.ts +0 -135
- package/src/edit-planner.test.ts +0 -204
- package/src/edit-planner.ts +0 -325
- package/src/fuzzy-match.test.ts +0 -311
- package/src/fuzzy-match.ts +0 -444
- package/src/hook-pipeline.test.ts +0 -279
- package/src/hook-pipeline.ts +0 -373
- package/src/inbound-admission.test.ts +0 -394
- package/src/inbound-admission.ts +0 -246
- package/src/index.ts +0 -49
- package/src/loop.test.ts +0 -161
- package/src/loop.ts +0 -211
- package/src/mcp-bridge.test.ts +0 -165
- package/src/mcp-bridge.ts +0 -81
- package/src/memory-provider.test.ts +0 -232
- package/src/memory-provider.ts +0 -257
- package/src/model.ts +0 -168
- package/src/orchestration.test.ts +0 -53
- package/src/orchestration.ts +0 -146
- package/src/permission-ruleset.test.ts +0 -301
- package/src/permission-ruleset.ts +0 -1
- package/src/policy.ts +0 -151
- package/src/project-repo.test.ts +0 -232
- package/src/project-repo.ts +0 -311
- package/src/protocol.ts +0 -159
- package/src/pulsar.test.ts +0 -156
- package/src/pulsar.ts +0 -322
- package/src/rollout-store-persistent.test.ts +0 -217
- package/src/rollout-store-persistent.ts +0 -166
- package/src/rollout.ts +0 -150
- package/src/sandbox.ts +0 -113
- package/src/session-share.test.ts +0 -360
- package/src/session-share.ts +0 -310
- package/src/skill-distillation.test.ts +0 -177
- package/src/skill-distillation.ts +0 -369
- package/src/skills.test.ts +0 -277
- package/src/skills.ts +0 -277
- package/src/subagents.test.ts +0 -290
- package/src/subagents.ts +0 -332
- package/src/tools.test.ts +0 -12
- package/src/tools.ts +0 -129
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +0 -12
- package/tsup.config.ts +0 -36
- /package/dist/{chunk-KCNN4QUQ.js.map → chunk-5UGIUYJR.js.map} +0 -0
|
@@ -1,432 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
asChannelId,
|
|
5
|
-
type ChannelAdapter,
|
|
6
|
-
ChannelGateway,
|
|
7
|
-
type ChannelMeta,
|
|
8
|
-
ChannelRegistry,
|
|
9
|
-
DuplicateChannelError,
|
|
10
|
-
type InboundMessage,
|
|
11
|
-
type OutboundMessage,
|
|
12
|
-
replyTo,
|
|
13
|
-
TenantMismatchError,
|
|
14
|
-
UnknownChannelError,
|
|
15
|
-
} from "./channel-gateway.js";
|
|
16
|
-
|
|
17
|
-
/* -------------------------------------------------------------------------- */
|
|
18
|
-
/* Fixtures */
|
|
19
|
-
/* -------------------------------------------------------------------------- */
|
|
20
|
-
|
|
21
|
-
function meta(id: string, over?: Partial<ChannelMeta>): ChannelMeta {
|
|
22
|
-
return {
|
|
23
|
-
id: asChannelId(id),
|
|
24
|
-
displayName: `Channel ${id}`,
|
|
25
|
-
capabilities: { threads: true, reactions: false, streaming: true },
|
|
26
|
-
...over,
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Fake adapter: parses a minimal "envelope" payload into an InboundMessage.
|
|
32
|
-
* Returns null for non-message events (joins, typing, …) and never throws on
|
|
33
|
-
* junk. `sendOutbound` records every dispatched message.
|
|
34
|
-
*/
|
|
35
|
-
function fakeAdapter(
|
|
36
|
-
id: string,
|
|
37
|
-
metaOver?: Partial<ChannelMeta>,
|
|
38
|
-
): ChannelAdapter & {
|
|
39
|
-
sent: OutboundMessage[];
|
|
40
|
-
} {
|
|
41
|
-
const sent: OutboundMessage[] = [];
|
|
42
|
-
return {
|
|
43
|
-
sent,
|
|
44
|
-
meta: meta(id, metaOver),
|
|
45
|
-
parseInbound(tenantId, raw) {
|
|
46
|
-
if (typeof raw !== "object" || raw === null) return null;
|
|
47
|
-
const r = raw as Record<string, unknown>;
|
|
48
|
-
// Non-message events → null (never throw).
|
|
49
|
-
if (r["type"] !== "message") return null;
|
|
50
|
-
if (typeof r["chatId"] !== "string" || typeof r["text"] !== "string") return null;
|
|
51
|
-
const inbound: InboundMessage = {
|
|
52
|
-
tenantId,
|
|
53
|
-
channelId: asChannelId(id),
|
|
54
|
-
chatId: r["chatId"],
|
|
55
|
-
senderId: typeof r["senderId"] === "string" ? r["senderId"] : "anon",
|
|
56
|
-
text: r["text"],
|
|
57
|
-
receivedAt: "2026-05-17T00:00:00.000Z",
|
|
58
|
-
raw,
|
|
59
|
-
...(typeof r["threadId"] === "string" ? { threadId: r["threadId"] } : {}),
|
|
60
|
-
...(typeof r["senderLabel"] === "string" ? { senderLabel: r["senderLabel"] } : {}),
|
|
61
|
-
};
|
|
62
|
-
return inbound;
|
|
63
|
-
},
|
|
64
|
-
async sendOutbound(msg) {
|
|
65
|
-
sent.push(msg);
|
|
66
|
-
return { messageId: `${id}-${sent.length}` };
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Adapter whose parseInbound returns a structurally-invalid object. */
|
|
72
|
-
function malformedAdapter(id: string): ChannelAdapter {
|
|
73
|
-
return {
|
|
74
|
-
meta: meta(id),
|
|
75
|
-
// @ts-expect-error — intentionally returns a non-conforming shape to
|
|
76
|
-
// exercise the gateway's zod boundary validation.
|
|
77
|
-
parseInbound() {
|
|
78
|
-
return { tenantId: "", chatId: 123, text: null };
|
|
79
|
-
},
|
|
80
|
-
async sendOutbound() {
|
|
81
|
-
return { messageId: "x" };
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/* -------------------------------------------------------------------------- */
|
|
87
|
-
/* asChannelId / branded type */
|
|
88
|
-
/* -------------------------------------------------------------------------- */
|
|
89
|
-
|
|
90
|
-
describe("asChannelId", () => {
|
|
91
|
-
it("brands a non-empty string", () => {
|
|
92
|
-
expect(asChannelId("slack")).toBe("slack");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("rejects empty / blank ids", () => {
|
|
96
|
-
expect(() => asChannelId("")).toThrow(/channel/i);
|
|
97
|
-
expect(() => asChannelId(" ")).toThrow(/channel/i);
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
/* -------------------------------------------------------------------------- */
|
|
102
|
-
/* ChannelAdapter.parseInbound */
|
|
103
|
-
/* -------------------------------------------------------------------------- */
|
|
104
|
-
|
|
105
|
-
describe("ChannelAdapter.parseInbound", () => {
|
|
106
|
-
it("returns null for junk without throwing", () => {
|
|
107
|
-
const a = fakeAdapter("c1");
|
|
108
|
-
expect(a.parseInbound("org_a", null)).toBeNull();
|
|
109
|
-
expect(a.parseInbound("org_a", "garbage")).toBeNull();
|
|
110
|
-
expect(a.parseInbound("org_a", { type: "typing" })).toBeNull();
|
|
111
|
-
expect(a.parseInbound("org_a", { type: "join", user: "u1" })).toBeNull();
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("stamps the tenantId onto the normalized result", () => {
|
|
115
|
-
const a = fakeAdapter("c1");
|
|
116
|
-
const msg = a.parseInbound("org_a", { type: "message", chatId: "ch1", text: "hi" });
|
|
117
|
-
expect(msg).not.toBeNull();
|
|
118
|
-
expect(msg?.tenantId).toBe("org_a");
|
|
119
|
-
expect(msg?.channelId).toBe("c1");
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
/* -------------------------------------------------------------------------- */
|
|
124
|
-
/* ChannelRegistry */
|
|
125
|
-
/* -------------------------------------------------------------------------- */
|
|
126
|
-
|
|
127
|
-
describe("ChannelRegistry", () => {
|
|
128
|
-
it("registers and looks up an adapter by channelId for a tenant", () => {
|
|
129
|
-
const reg = new ChannelRegistry();
|
|
130
|
-
const a = fakeAdapter("c1");
|
|
131
|
-
reg.register("org_a", a);
|
|
132
|
-
expect(reg.lookup("org_a", asChannelId("c1"))).toBe(a);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("throws DuplicateChannelError on duplicate registration", () => {
|
|
136
|
-
const reg = new ChannelRegistry();
|
|
137
|
-
reg.register("org_a", fakeAdapter("c1"));
|
|
138
|
-
expect(() => reg.register("org_a", fakeAdapter("c1"))).toThrow(DuplicateChannelError);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("throws UnknownChannelError on unknown lookup", () => {
|
|
142
|
-
const reg = new ChannelRegistry();
|
|
143
|
-
expect(() => reg.lookup("org_a", asChannelId("nope"))).toThrow(UnknownChannelError);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("fails closed on empty tenantId for register and lookup", () => {
|
|
147
|
-
const reg = new ChannelRegistry();
|
|
148
|
-
expect(() => reg.register("", fakeAdapter("c1"))).toThrow(/tenant/i);
|
|
149
|
-
expect(() => reg.register(" ", fakeAdapter("c1"))).toThrow(/tenant/i);
|
|
150
|
-
expect(() => reg.lookup("", asChannelId("c1"))).toThrow(/tenant/i);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("isolates tenants — A's adapter is invisible to B (foreign tenant fails closed)", () => {
|
|
154
|
-
const reg = new ChannelRegistry();
|
|
155
|
-
reg.register("org_a", fakeAdapter("c1"));
|
|
156
|
-
expect(() => reg.lookup("org_b", asChannelId("c1"))).toThrow(UnknownChannelError);
|
|
157
|
-
// Same channelId may be registered independently per tenant.
|
|
158
|
-
expect(() => reg.register("org_b", fakeAdapter("c1"))).not.toThrow();
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("lists registered channel ids per tenant", () => {
|
|
162
|
-
const reg = new ChannelRegistry();
|
|
163
|
-
reg.register("org_a", fakeAdapter("c1"));
|
|
164
|
-
reg.register("org_a", fakeAdapter("c2"));
|
|
165
|
-
reg.register("org_b", fakeAdapter("c3"));
|
|
166
|
-
expect([...reg.list("org_a")].sort()).toEqual(["c1", "c2"]);
|
|
167
|
-
expect(reg.list("org_b")).toEqual(["c3"]);
|
|
168
|
-
expect(reg.list("org_unknown")).toEqual([]);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
/* -------------------------------------------------------------------------- */
|
|
173
|
-
/* ChannelGateway.ingest */
|
|
174
|
-
/* -------------------------------------------------------------------------- */
|
|
175
|
-
|
|
176
|
-
describe("ChannelGateway.ingest", () => {
|
|
177
|
-
it("normalizes a valid inbound message tenant-stamped", () => {
|
|
178
|
-
const reg = new ChannelRegistry();
|
|
179
|
-
reg.register("org_a", fakeAdapter("c1"));
|
|
180
|
-
const gw = new ChannelGateway(reg);
|
|
181
|
-
|
|
182
|
-
const msg = gw.ingest("org_a", asChannelId("c1"), {
|
|
183
|
-
type: "message",
|
|
184
|
-
chatId: "ch1",
|
|
185
|
-
text: "hello",
|
|
186
|
-
threadId: "t1",
|
|
187
|
-
});
|
|
188
|
-
expect(msg).not.toBeNull();
|
|
189
|
-
expect(msg?.tenantId).toBe("org_a");
|
|
190
|
-
expect(msg?.channelId).toBe("c1");
|
|
191
|
-
expect(msg?.chatId).toBe("ch1");
|
|
192
|
-
expect(msg?.threadId).toBe("t1");
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("swallows non-message events as null (never throws to caller)", () => {
|
|
196
|
-
const reg = new ChannelRegistry();
|
|
197
|
-
reg.register("org_a", fakeAdapter("c1"));
|
|
198
|
-
const gw = new ChannelGateway(reg);
|
|
199
|
-
|
|
200
|
-
expect(gw.ingest("org_a", asChannelId("c1"), { type: "typing" })).toBeNull();
|
|
201
|
-
expect(gw.ingest("org_a", asChannelId("c1"), "junk")).toBeNull();
|
|
202
|
-
expect(gw.ingest("org_a", asChannelId("c1"), null)).toBeNull();
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("rejects a malformed adapter result via zod (typed, no crash)", () => {
|
|
206
|
-
const reg = new ChannelRegistry();
|
|
207
|
-
reg.register("org_a", malformedAdapter("c1"));
|
|
208
|
-
const gw = new ChannelGateway(reg);
|
|
209
|
-
|
|
210
|
-
expect(() => gw.ingest("org_a", asChannelId("c1"), { type: "message" })).toThrow();
|
|
211
|
-
// Does not surface as a raw TypeError — it is a validation error.
|
|
212
|
-
try {
|
|
213
|
-
gw.ingest("org_a", asChannelId("c1"), { type: "message" });
|
|
214
|
-
} catch (e) {
|
|
215
|
-
expect(e).toBeInstanceOf(Error);
|
|
216
|
-
expect((e as Error).message).toMatch(/inbound|valid/i);
|
|
217
|
-
}
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it("throws UnknownChannelError when the channel is not registered", () => {
|
|
221
|
-
const gw = new ChannelGateway(new ChannelRegistry());
|
|
222
|
-
expect(() => gw.ingest("org_a", asChannelId("ghost"), { type: "message" })).toThrow(
|
|
223
|
-
UnknownChannelError,
|
|
224
|
-
);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("fails closed on empty / foreign tenant", () => {
|
|
228
|
-
const reg = new ChannelRegistry();
|
|
229
|
-
reg.register("org_a", fakeAdapter("c1"));
|
|
230
|
-
const gw = new ChannelGateway(reg);
|
|
231
|
-
expect(() => gw.ingest("", asChannelId("c1"), { type: "message" })).toThrow(/tenant/i);
|
|
232
|
-
expect(() => gw.ingest("org_b", asChannelId("c1"), { type: "message" })).toThrow(
|
|
233
|
-
UnknownChannelError,
|
|
234
|
-
);
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
/* -------------------------------------------------------------------------- */
|
|
239
|
-
/* ChannelGateway.route */
|
|
240
|
-
/* -------------------------------------------------------------------------- */
|
|
241
|
-
|
|
242
|
-
describe("ChannelGateway.route", () => {
|
|
243
|
-
it("sends a reply back through the originating channel", async () => {
|
|
244
|
-
const reg = new ChannelRegistry();
|
|
245
|
-
const a = fakeAdapter("c1");
|
|
246
|
-
const b = fakeAdapter("c2");
|
|
247
|
-
reg.register("org_a", a);
|
|
248
|
-
reg.register("org_a", b);
|
|
249
|
-
const gw = new ChannelGateway(reg);
|
|
250
|
-
|
|
251
|
-
const res = await gw.route({
|
|
252
|
-
tenantId: "org_a",
|
|
253
|
-
channelId: asChannelId("c2"),
|
|
254
|
-
chatId: "ch9",
|
|
255
|
-
text: "pong",
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
expect(res.messageId).toBe("c2-1");
|
|
259
|
-
expect(b.sent).toHaveLength(1);
|
|
260
|
-
expect(a.sent).toHaveLength(0);
|
|
261
|
-
expect(b.sent[0]).toMatchObject({ chatId: "ch9", text: "pong" });
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("throws UnknownChannelError when routing to an unknown channel", async () => {
|
|
265
|
-
const gw = new ChannelGateway(new ChannelRegistry());
|
|
266
|
-
await expect(
|
|
267
|
-
gw.route({ tenantId: "org_a", channelId: asChannelId("ghost"), chatId: "c", text: "x" }),
|
|
268
|
-
).rejects.toThrow(UnknownChannelError);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it("fails closed on empty tenantId for route", async () => {
|
|
272
|
-
const reg = new ChannelRegistry();
|
|
273
|
-
reg.register("org_a", fakeAdapter("c1"));
|
|
274
|
-
const gw = new ChannelGateway(reg);
|
|
275
|
-
await expect(
|
|
276
|
-
gw.route({ tenantId: "", channelId: asChannelId("c1"), chatId: "c", text: "x" }),
|
|
277
|
-
).rejects.toThrow(/tenant/i);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it("rejects a foreign tenant routing through another tenant's channel", async () => {
|
|
281
|
-
const reg = new ChannelRegistry();
|
|
282
|
-
reg.register("org_a", fakeAdapter("c1"));
|
|
283
|
-
const gw = new ChannelGateway(reg);
|
|
284
|
-
await expect(
|
|
285
|
-
gw.route({ tenantId: "org_b", channelId: asChannelId("c1"), chatId: "c", text: "x" }),
|
|
286
|
-
).rejects.toThrow(UnknownChannelError);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("rejects a tenant mismatch between reply and resolved adapter scope", async () => {
|
|
290
|
-
const reg = new ChannelRegistry();
|
|
291
|
-
reg.register("org_a", fakeAdapter("c1"));
|
|
292
|
-
const gw = new ChannelGateway(reg);
|
|
293
|
-
// Whitespace tenant — fail closed before any send.
|
|
294
|
-
await expect(
|
|
295
|
-
gw.route({ tenantId: " ", channelId: asChannelId("c1"), chatId: "c", text: "x" }),
|
|
296
|
-
).rejects.toThrow(/tenant/i);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it("rejects a structurally-invalid OutboundMessage via zod", async () => {
|
|
300
|
-
const reg = new ChannelRegistry();
|
|
301
|
-
const a = fakeAdapter("c1");
|
|
302
|
-
reg.register("org_a", a);
|
|
303
|
-
const gw = new ChannelGateway(reg);
|
|
304
|
-
await expect(
|
|
305
|
-
// @ts-expect-error — text missing on purpose to hit the zod boundary.
|
|
306
|
-
gw.route({ tenantId: "org_a", channelId: asChannelId("c1"), chatId: "c" }),
|
|
307
|
-
).rejects.toThrow();
|
|
308
|
-
expect(a.sent).toHaveLength(0);
|
|
309
|
-
});
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
/* -------------------------------------------------------------------------- */
|
|
313
|
-
/* replyTo (pure helper) */
|
|
314
|
-
/* -------------------------------------------------------------------------- */
|
|
315
|
-
|
|
316
|
-
describe("replyTo", () => {
|
|
317
|
-
const inbound: InboundMessage = {
|
|
318
|
-
tenantId: "org_a",
|
|
319
|
-
channelId: asChannelId("c1"),
|
|
320
|
-
chatId: "ch1",
|
|
321
|
-
threadId: "t1",
|
|
322
|
-
senderId: "u1",
|
|
323
|
-
text: "ping",
|
|
324
|
-
receivedAt: "2026-05-17T00:00:00.000Z",
|
|
325
|
-
raw: { id: "m1" },
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
it("derives a correctly-addressed OutboundMessage on the same channel/chat/thread", () => {
|
|
329
|
-
const out = replyTo(inbound, "pong");
|
|
330
|
-
expect(out).toEqual({
|
|
331
|
-
tenantId: "org_a",
|
|
332
|
-
channelId: asChannelId("c1"),
|
|
333
|
-
chatId: "ch1",
|
|
334
|
-
threadId: "t1",
|
|
335
|
-
text: "pong",
|
|
336
|
-
});
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
it("wires replyToMessageId when provided", () => {
|
|
340
|
-
const out = replyTo(inbound, "pong", { replyToMessageId: "m1" });
|
|
341
|
-
expect(out.replyToMessageId).toBe("m1");
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it("does not mutate the inbound message", () => {
|
|
345
|
-
const snapshot = JSON.parse(JSON.stringify(inbound));
|
|
346
|
-
replyTo(inbound, "pong", { replyToMessageId: "m1" });
|
|
347
|
-
expect(inbound).toEqual(snapshot);
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
it("omits threadId when the inbound has none (exactOptional-safe)", () => {
|
|
351
|
-
const noThread: InboundMessage = {
|
|
352
|
-
tenantId: "org_a",
|
|
353
|
-
channelId: asChannelId("c1"),
|
|
354
|
-
chatId: "ch1",
|
|
355
|
-
senderId: "u1",
|
|
356
|
-
text: "ping",
|
|
357
|
-
receivedAt: "2026-05-17T00:00:00.000Z",
|
|
358
|
-
raw: {},
|
|
359
|
-
};
|
|
360
|
-
const out = replyTo(noThread, "pong");
|
|
361
|
-
expect("threadId" in out).toBe(false);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
it("fails closed when the inbound carries an empty tenantId", () => {
|
|
365
|
-
const bad: InboundMessage = { ...inbound, tenantId: "" };
|
|
366
|
-
expect(() => replyTo(bad, "pong")).toThrow(/tenant/i);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it("a replyTo result routes back through the same channel end-to-end", async () => {
|
|
370
|
-
const reg = new ChannelRegistry();
|
|
371
|
-
const a = fakeAdapter("c1");
|
|
372
|
-
reg.register("org_a", a);
|
|
373
|
-
const gw = new ChannelGateway(reg);
|
|
374
|
-
|
|
375
|
-
const out = replyTo(inbound, "pong", { replyToMessageId: "m1" });
|
|
376
|
-
const res = await gw.route(out);
|
|
377
|
-
expect(res.messageId).toBe("c1-1");
|
|
378
|
-
expect(a.sent[0]).toMatchObject({
|
|
379
|
-
chatId: "ch1",
|
|
380
|
-
threadId: "t1",
|
|
381
|
-
text: "pong",
|
|
382
|
-
replyToMessageId: "m1",
|
|
383
|
-
});
|
|
384
|
-
});
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
/* -------------------------------------------------------------------------- */
|
|
388
|
-
/* Capabilities surfaced from meta */
|
|
389
|
-
/* -------------------------------------------------------------------------- */
|
|
390
|
-
|
|
391
|
-
describe("ChannelGateway capabilities", () => {
|
|
392
|
-
it("surfaces channel capabilities from the adapter meta", () => {
|
|
393
|
-
const reg = new ChannelRegistry();
|
|
394
|
-
reg.register(
|
|
395
|
-
"org_a",
|
|
396
|
-
fakeAdapter("c1", { capabilities: { threads: false, reactions: true, streaming: false } }),
|
|
397
|
-
);
|
|
398
|
-
const gw = new ChannelGateway(reg);
|
|
399
|
-
const caps = gw.capabilities("org_a", asChannelId("c1"));
|
|
400
|
-
expect(caps).toEqual({ threads: false, reactions: true, streaming: false });
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it("describe() returns the full meta for a registered channel", () => {
|
|
404
|
-
const reg = new ChannelRegistry();
|
|
405
|
-
reg.register("org_a", fakeAdapter("c1"));
|
|
406
|
-
const gw = new ChannelGateway(reg);
|
|
407
|
-
const m = gw.describe("org_a", asChannelId("c1"));
|
|
408
|
-
expect(m.id).toBe("c1");
|
|
409
|
-
expect(m.displayName).toBe("Channel c1");
|
|
410
|
-
expect(m.capabilities).toEqual({ threads: true, reactions: false, streaming: true });
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
it("capabilities() throws UnknownChannelError for an unregistered channel", () => {
|
|
414
|
-
const gw = new ChannelGateway(new ChannelRegistry());
|
|
415
|
-
expect(() => gw.capabilities("org_a", asChannelId("ghost"))).toThrow(UnknownChannelError);
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
it("TenantMismatchError is exported and distinct from UnknownChannelError", () => {
|
|
419
|
-
expect(new TenantMismatchError("x").name).toBe("TenantMismatchError");
|
|
420
|
-
expect(new TenantMismatchError("x")).not.toBeInstanceOf(UnknownChannelError);
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
it("does not log to console during normal operation", () => {
|
|
424
|
-
const spy = vi.spyOn(console, "log");
|
|
425
|
-
const reg = new ChannelRegistry();
|
|
426
|
-
reg.register("org_a", fakeAdapter("c1"));
|
|
427
|
-
const gw = new ChannelGateway(reg);
|
|
428
|
-
gw.ingest("org_a", asChannelId("c1"), { type: "message", chatId: "c", text: "hi" });
|
|
429
|
-
expect(spy).not.toHaveBeenCalled();
|
|
430
|
-
spy.mockRestore();
|
|
431
|
-
});
|
|
432
|
-
});
|