@nebutra/agent-runtime 0.2.0 → 0.2.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.
Files changed (137) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +2 -0
  3. package/dist/adapters/dispatcher-sse.js +1 -0
  4. package/dist/adapters/index.d.ts +24 -5
  5. package/dist/adapters/index.js +28 -0
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/adapters/mcp-catalog.js +1 -0
  8. package/dist/adapters/prisma-rollout.js +1 -0
  9. package/dist/chunk-424PT5DM.js +23 -0
  10. package/dist/chunk-424PT5DM.js.map +1 -0
  11. package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
  12. package/dist/chunk-4Y25ZTKI.js.map +1 -0
  13. package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
  14. package/dist/chunk-D4YAPLOW.js.map +1 -0
  15. package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
  16. package/dist/chunk-GQZKYWFT.js.map +1 -0
  17. package/dist/chunk-KCNN4QUQ.js +255 -0
  18. package/dist/chunk-KCNN4QUQ.js.map +1 -0
  19. package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
  20. package/dist/chunk-NI4EDT4T.js.map +1 -0
  21. package/dist/chunk-Q62VKHIT.js +178 -0
  22. package/dist/chunk-Q62VKHIT.js.map +1 -0
  23. package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
  24. package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
  25. package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +52 -0
  28. package/dist/cli.js.map +1 -0
  29. package/dist/commands.js +1 -0
  30. package/dist/definitions.js +1 -0
  31. package/dist/dispatcher.js +1 -0
  32. package/dist/durable-turn.js +3 -2
  33. package/dist/hook-pipeline.js +1 -0
  34. package/dist/index.d.ts +8 -85
  35. package/dist/index.js +249 -134
  36. package/dist/index.js.map +1 -1
  37. package/dist/loop.d.ts +2 -2
  38. package/dist/loop.js +3 -2
  39. package/dist/mcp-bridge.d.ts +3 -3
  40. package/dist/mcp-bridge.js +3 -2
  41. package/dist/model.js +1 -0
  42. package/dist/orchestration.d.ts +84 -0
  43. package/dist/orchestration.js +16 -0
  44. package/dist/orchestration.js.map +1 -0
  45. package/dist/policy.js +1 -0
  46. package/dist/protocol.js +1 -0
  47. package/dist/pulsar.d.ts +78 -0
  48. package/dist/pulsar.js +17 -0
  49. package/dist/pulsar.js.map +1 -0
  50. package/dist/rollout-store-persistent.js +1 -0
  51. package/dist/rollout.js +1 -0
  52. package/dist/sandbox.js +2 -1
  53. package/dist/skills.js +2 -1
  54. package/dist/subagents.js +1 -0
  55. package/dist/tools.d.ts +4 -3
  56. package/dist/tools.js +5 -3
  57. package/package.json +84 -27
  58. package/.turbo/turbo-build.log +0 -115
  59. package/.turbo/turbo-test.log +0 -44
  60. package/.turbo/turbo-typecheck.log +0 -4
  61. package/CHANGELOG.md +0 -253
  62. package/dist/chunk-BJBBR3QA.js.map +0 -1
  63. package/dist/chunk-NN7DATXA.js.map +0 -1
  64. package/dist/chunk-PGGWSUTM.js.map +0 -1
  65. package/dist/chunk-ZMYX5VBU.js.map +0 -1
  66. package/src/adapters/dispatcher-sse.test.ts +0 -218
  67. package/src/adapters/dispatcher-sse.ts +0 -222
  68. package/src/adapters/index.ts +0 -18
  69. package/src/adapters/mcp-catalog.test.ts +0 -213
  70. package/src/adapters/mcp-catalog.ts +0 -188
  71. package/src/adapters/prisma-rollout.test.ts +0 -153
  72. package/src/adapters/prisma-rollout.ts +0 -104
  73. package/src/agent-runtime.test.ts +0 -176
  74. package/src/artifact-stream.test.ts +0 -330
  75. package/src/artifact-stream.ts +0 -453
  76. package/src/channel-gateway.test.ts +0 -432
  77. package/src/channel-gateway.ts +0 -357
  78. package/src/code-review.test.ts +0 -501
  79. package/src/code-review.ts +0 -495
  80. package/src/command-suggestions.test.ts +0 -251
  81. package/src/command-suggestions.ts +0 -338
  82. package/src/commands.test.ts +0 -184
  83. package/src/commands.ts +0 -140
  84. package/src/commit-message.test.ts +0 -249
  85. package/src/commit-message.ts +0 -180
  86. package/src/context-compaction.test.ts +0 -522
  87. package/src/context-compaction.ts +0 -434
  88. package/src/definitions.test.ts +0 -78
  89. package/src/definitions.ts +0 -190
  90. package/src/deployment-status.test.ts +0 -215
  91. package/src/deployment-status.ts +0 -227
  92. package/src/design-context.test.ts +0 -195
  93. package/src/design-context.ts +0 -198
  94. package/src/dispatcher.test.ts +0 -234
  95. package/src/dispatcher.ts +0 -189
  96. package/src/durable-turn.test.ts +0 -209
  97. package/src/durable-turn.ts +0 -135
  98. package/src/edit-planner.test.ts +0 -204
  99. package/src/edit-planner.ts +0 -325
  100. package/src/fuzzy-match.test.ts +0 -311
  101. package/src/fuzzy-match.ts +0 -444
  102. package/src/hook-pipeline.test.ts +0 -279
  103. package/src/hook-pipeline.ts +0 -373
  104. package/src/inbound-admission.test.ts +0 -394
  105. package/src/inbound-admission.ts +0 -246
  106. package/src/index.ts +0 -47
  107. package/src/loop.test.ts +0 -161
  108. package/src/loop.ts +0 -211
  109. package/src/mcp-bridge.test.ts +0 -165
  110. package/src/mcp-bridge.ts +0 -76
  111. package/src/memory-provider.test.ts +0 -232
  112. package/src/memory-provider.ts +0 -257
  113. package/src/model.ts +0 -168
  114. package/src/permission-ruleset.test.ts +0 -301
  115. package/src/permission-ruleset.ts +0 -200
  116. package/src/policy.ts +0 -151
  117. package/src/project-repo.test.ts +0 -232
  118. package/src/project-repo.ts +0 -311
  119. package/src/protocol.ts +0 -159
  120. package/src/rollout-store-persistent.test.ts +0 -217
  121. package/src/rollout-store-persistent.ts +0 -166
  122. package/src/rollout.ts +0 -150
  123. package/src/sandbox.ts +0 -113
  124. package/src/session-share.test.ts +0 -360
  125. package/src/session-share.ts +0 -310
  126. package/src/skill-distillation.test.ts +0 -177
  127. package/src/skill-distillation.ts +0 -369
  128. package/src/skills.test.ts +0 -277
  129. package/src/skills.ts +0 -255
  130. package/src/subagents.test.ts +0 -290
  131. package/src/subagents.ts +0 -332
  132. package/src/tools.ts +0 -126
  133. package/src/workbench.test.ts +0 -0
  134. package/src/workbench.ts +0 -0
  135. package/tsconfig.json +0 -12
  136. package/tsup.config.ts +0 -33
  137. /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.js.map} +0 -0
@@ -1,195 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import {
4
- type DesignContext,
5
- ingestDesignContext,
6
- normalizeScrapeResult,
7
- type RawScrapeResult,
8
- type ScrapeProvider,
9
- toGenerationSeed,
10
- } from "./design-context.js";
11
-
12
- const FIXED_ISO = "2026-05-17T00:00:00.000Z";
13
- const fixedClock = () => new Date(FIXED_ISO);
14
-
15
- function fakeProvider(result: RawScrapeResult): ScrapeProvider & {
16
- calls: Array<{ url: string; formats: string[] }>;
17
- } {
18
- const calls: Array<{ url: string; formats: string[] }> = [];
19
- return {
20
- calls,
21
- async scrape(url, opts) {
22
- calls.push({ url, formats: [...opts.formats] });
23
- return result;
24
- },
25
- };
26
- }
27
-
28
- describe("normalizeScrapeResult", () => {
29
- it("prefers markdown over html for content", () => {
30
- const ctx = normalizeScrapeResult(
31
- "org_1",
32
- "https://example.com",
33
- { markdown: "# Hello MD", html: "<h1>Hello HTML</h1>" },
34
- { clock: fixedClock },
35
- );
36
- expect(ctx.content).toBe("# Hello MD");
37
- });
38
-
39
- it("strips tags from html when markdown absent", () => {
40
- const ctx = normalizeScrapeResult(
41
- "org_1",
42
- "https://example.com",
43
- { html: "<div><h1>Title</h1><p>Body &amp; more</p></div>" },
44
- { clock: fixedClock },
45
- );
46
- expect(ctx.content).not.toContain("<");
47
- expect(ctx.content).toContain("Title");
48
- expect(ctx.content).toContain("Body");
49
- });
50
-
51
- it("dedups and validates colors, drops junk", () => {
52
- const ctx = normalizeScrapeResult(
53
- "org_1",
54
- "https://example.com",
55
- {
56
- markdown: "x",
57
- branding: {
58
- colors: ["#fff", "#fff", "#FFFFFF", "rgb(0,0,0)", "not-a-color", "", " "],
59
- fonts: ["Inter", "Inter", " Roboto ", ""],
60
- },
61
- },
62
- { clock: fixedClock },
63
- );
64
- expect(ctx.brand.colors).toEqual(["#fff", "#FFFFFF", "rgb(0,0,0)"]);
65
- expect(ctx.brand.fonts).toEqual(["Inter", "Roboto"]);
66
- });
67
-
68
- it("degrades gracefully on empty raw (no throw, empty brand)", () => {
69
- const ctx = normalizeScrapeResult("org_1", "https://example.com", {}, { clock: fixedClock });
70
- expect(ctx.content).toBe("");
71
- expect(ctx.brand.colors).toEqual([]);
72
- expect(ctx.brand.fonts).toEqual([]);
73
- expect(ctx.screenshotRef).toBeUndefined();
74
- expect(ctx.title).toBeUndefined();
75
- });
76
-
77
- it("passes screenshot and title through", () => {
78
- const ctx = normalizeScrapeResult(
79
- "org_1",
80
- "https://example.com",
81
- { markdown: "x", screenshot: "https://cdn/shot.png", metadata: { title: "My Site" } },
82
- { clock: fixedClock },
83
- );
84
- expect(ctx.screenshotRef).toBe("https://cdn/shot.png");
85
- expect(ctx.title).toBe("My Site");
86
- });
87
-
88
- it("treats null screenshot as undefined", () => {
89
- const ctx = normalizeScrapeResult(
90
- "org_1",
91
- "https://example.com",
92
- { markdown: "x", screenshot: null },
93
- { clock: fixedClock },
94
- );
95
- expect(ctx.screenshotRef).toBeUndefined();
96
- });
97
-
98
- it("uses injectable clock for fetchedAt", () => {
99
- const ctx = normalizeScrapeResult(
100
- "org_1",
101
- "https://example.com",
102
- { markdown: "x" },
103
- {
104
- clock: fixedClock,
105
- },
106
- );
107
- expect(ctx.fetchedAt).toBe(FIXED_ISO);
108
- });
109
-
110
- it("carries tenantId through", () => {
111
- const ctx = normalizeScrapeResult(
112
- "org_xyz",
113
- "https://example.com",
114
- { markdown: "x" },
115
- {
116
- clock: fixedClock,
117
- },
118
- );
119
- expect(ctx.tenantId).toBe("org_xyz");
120
- });
121
-
122
- it("fails closed on empty tenantId", () => {
123
- expect(() =>
124
- normalizeScrapeResult("", "https://example.com", { markdown: "x" }, { clock: fixedClock }),
125
- ).toThrow();
126
- });
127
-
128
- it("fails closed on empty url", () => {
129
- expect(() =>
130
- normalizeScrapeResult("org_1", "", { markdown: "x" }, { clock: fixedClock }),
131
- ).toThrow();
132
- });
133
- });
134
-
135
- describe("ingestDesignContext", () => {
136
- it("delegates to the injected provider (no real network)", async () => {
137
- const provider = fakeProvider({ markdown: "seed", metadata: { title: "T" } });
138
- const ctx = await ingestDesignContext("org_1", "https://example.com", provider, {
139
- clock: fixedClock,
140
- });
141
- expect(provider.calls).toHaveLength(1);
142
- expect(provider.calls[0]?.url).toBe("https://example.com");
143
- expect(ctx.content).toBe("seed");
144
- expect(ctx.title).toBe("T");
145
- expect(ctx.tenantId).toBe("org_1");
146
- });
147
-
148
- it("passes requested formats to provider", async () => {
149
- const provider = fakeProvider({ markdown: "x" });
150
- await ingestDesignContext("org_1", "https://example.com", provider, {
151
- formats: ["markdown", "branding"],
152
- clock: fixedClock,
153
- });
154
- expect(provider.calls[0]?.formats).toEqual(["markdown", "branding"]);
155
- });
156
-
157
- it("fails closed on empty tenantId", async () => {
158
- const provider = fakeProvider({ markdown: "x" });
159
- await expect(
160
- ingestDesignContext("", "https://example.com", provider, { clock: fixedClock }),
161
- ).rejects.toThrow();
162
- expect(provider.calls).toHaveLength(0);
163
- });
164
- });
165
-
166
- describe("toGenerationSeed", () => {
167
- const base: DesignContext = {
168
- tenantId: "org_1",
169
- sourceUrl: "https://example.com",
170
- content: "Lorem ipsum dolor sit amet ".repeat(500),
171
- brand: { colors: ["#fff", "rgb(0,0,0)"], fonts: ["Inter"] },
172
- screenshotRef: undefined,
173
- title: "Example",
174
- fetchedAt: FIXED_ISO,
175
- };
176
-
177
- it("is deterministic", () => {
178
- expect(toGenerationSeed(base)).toBe(toGenerationSeed(base));
179
- });
180
-
181
- it("is length-bounded by default", () => {
182
- expect(toGenerationSeed(base).length).toBeLessThanOrEqual(4000);
183
- });
184
-
185
- it("respects injectable max chars", () => {
186
- expect(toGenerationSeed(base, { maxChars: 200 }).length).toBeLessThanOrEqual(200);
187
- });
188
-
189
- it("includes title, palette and fonts", () => {
190
- const seed = toGenerationSeed({ ...base, content: "short" });
191
- expect(seed).toContain("Example");
192
- expect(seed).toContain("#fff");
193
- expect(seed).toContain("Inter");
194
- });
195
- });
@@ -1,198 +0,0 @@
1
- import { z } from "zod";
2
-
3
- /**
4
- * Design-context ingestion: turn an existing website into a structured
5
- * generation seed for a codegen turn.
6
- *
7
- * The scrape implementation is provider-specific and injected via a port;
8
- * this module never performs network I/O itself.
9
- */
10
-
11
- /** Structured generation seed extracted from an existing site. */
12
- export interface DesignContext {
13
- tenantId: string;
14
- sourceUrl: string;
15
- content: string;
16
- brand: {
17
- colors: string[];
18
- fonts: string[];
19
- };
20
- screenshotRef?: string | undefined;
21
- title?: string | undefined;
22
- fetchedAt: string;
23
- }
24
-
25
- /** Loose shape returned by a scrape provider (providers vary widely). */
26
- export interface RawScrapeResult {
27
- markdown?: string | undefined;
28
- html?: string | undefined;
29
- screenshot?: string | null | undefined;
30
- metadata?: { title?: string | undefined } | undefined;
31
- branding?:
32
- | {
33
- colors?: string[] | undefined;
34
- fonts?: string[] | undefined;
35
- }
36
- | undefined;
37
- }
38
-
39
- export type ScrapeFormat = "markdown" | "screenshot" | "branding";
40
-
41
- /** Injected provider port — callers wire a concrete scrape implementation. */
42
- export interface ScrapeProvider {
43
- scrape(url: string, opts: { formats: ScrapeFormat[] }): Promise<RawScrapeResult>;
44
- }
45
-
46
- /** Clock injection for deterministic tests. */
47
- export type Clock = () => Date;
48
-
49
- const tenantSchema = z.string().trim().min(1, "tenantId is required");
50
- const urlSchema = z.string().trim().min(1, "sourceUrl is required");
51
-
52
- const DEFAULT_FORMATS: readonly ScrapeFormat[] = ["markdown", "screenshot", "branding"];
53
- const DEFAULT_MAX_CHARS = 4000;
54
-
55
- /** Permissive colour matcher: hex (3/4/6/8) or rgb/rgba/hsl(...)-ish. */
56
- const COLOR_RE = /^(#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|(?:rgb|rgba|hsl|hsla)\([^)]+\))$/i;
57
-
58
- function stripTags(html: string): string {
59
- const withoutTags = html.replace(/<[^>]*>/g, " ");
60
- const decoded = withoutTags
61
- .replace(/&amp;/g, "&")
62
- .replace(/&lt;/g, "<")
63
- .replace(/&gt;/g, ">")
64
- .replace(/&quot;/g, '"')
65
- .replace(/&#39;/g, "'")
66
- .replace(/&nbsp;/g, " ");
67
- return decoded.replace(/\s+/g, " ").trim();
68
- }
69
-
70
- function dedupe(values: readonly string[]): string[] {
71
- const seen = new Set<string>();
72
- const out: string[] = [];
73
- for (const v of values) {
74
- if (!seen.has(v)) {
75
- seen.add(v);
76
- out.push(v);
77
- }
78
- }
79
- return out;
80
- }
81
-
82
- function normalizeColors(colors: readonly string[] | undefined): string[] {
83
- if (!colors) return [];
84
- const cleaned = colors
85
- .map((c) => (typeof c === "string" ? c.trim() : ""))
86
- .filter((c) => c.length > 0 && COLOR_RE.test(c));
87
- return dedupe(cleaned);
88
- }
89
-
90
- function normalizeFonts(fonts: readonly string[] | undefined): string[] {
91
- if (!fonts) return [];
92
- const cleaned = fonts
93
- .map((f) => (typeof f === "string" ? f.trim() : ""))
94
- .filter((f) => f.length > 0);
95
- return dedupe(cleaned);
96
- }
97
-
98
- function resolveContent(raw: RawScrapeResult): string {
99
- if (typeof raw.markdown === "string" && raw.markdown.length > 0) {
100
- return raw.markdown;
101
- }
102
- if (typeof raw.html === "string" && raw.html.length > 0) {
103
- return stripTags(raw.html);
104
- }
105
- return "";
106
- }
107
-
108
- /**
109
- * Pure transform from a loose provider result to a {@link DesignContext}.
110
- * Never throws on missing fields — degrades gracefully. Fails closed on
111
- * empty tenantId / url.
112
- */
113
- export function normalizeScrapeResult(
114
- tenantId: string,
115
- url: string,
116
- raw: RawScrapeResult,
117
- opts?: { clock?: Clock | undefined },
118
- ): DesignContext {
119
- const safeTenant = tenantSchema.parse(tenantId);
120
- const safeUrl = urlSchema.parse(url);
121
- const clock = opts?.clock ?? (() => new Date());
122
-
123
- const screenshot =
124
- typeof raw.screenshot === "string" && raw.screenshot.length > 0 ? raw.screenshot : undefined;
125
- const title =
126
- typeof raw.metadata?.title === "string" && raw.metadata.title.length > 0
127
- ? raw.metadata.title
128
- : undefined;
129
-
130
- return {
131
- tenantId: safeTenant,
132
- sourceUrl: safeUrl,
133
- content: resolveContent(raw),
134
- brand: {
135
- colors: normalizeColors(raw.branding?.colors),
136
- fonts: normalizeFonts(raw.branding?.fonts),
137
- },
138
- screenshotRef: screenshot,
139
- title,
140
- fetchedAt: clock().toISOString(),
141
- };
142
- }
143
-
144
- /**
145
- * Validate inputs, call the injected provider, normalize the result.
146
- * tenantId is mandatory and validated before the provider is invoked
147
- * (fail-closed: no provider call for an invalid tenant).
148
- */
149
- export async function ingestDesignContext(
150
- tenantId: string,
151
- url: string,
152
- provider: ScrapeProvider,
153
- opts?: {
154
- formats?: ScrapeFormat[] | undefined;
155
- clock?: Clock | undefined;
156
- },
157
- ): Promise<DesignContext> {
158
- const safeTenant = tenantSchema.parse(tenantId);
159
- const safeUrl = urlSchema.parse(url);
160
- const formats = opts?.formats ?? [...DEFAULT_FORMATS];
161
- const raw = await provider.scrape(safeUrl, { formats });
162
- return normalizeScrapeResult(safeTenant, safeUrl, raw, {
163
- clock: opts?.clock,
164
- });
165
- }
166
-
167
- /**
168
- * Render a compact, deterministic prompt-seed block suitable for
169
- * prepending to a codegen turn. Pure; output length is bounded.
170
- */
171
- export function toGenerationSeed(
172
- ctx: DesignContext,
173
- opts?: { maxChars?: number | undefined },
174
- ): string {
175
- const maxChars =
176
- typeof opts?.maxChars === "number" && opts.maxChars > 0 ? opts.maxChars : DEFAULT_MAX_CHARS;
177
-
178
- const header: string[] = ["## Design Context", `Source: ${ctx.sourceUrl}`];
179
- if (ctx.title) header.push(`Title: ${ctx.title}`);
180
- if (ctx.brand.colors.length > 0) {
181
- header.push(`Palette: ${ctx.brand.colors.join(", ")}`);
182
- }
183
- if (ctx.brand.fonts.length > 0) {
184
- header.push(`Fonts: ${ctx.brand.fonts.join(", ")}`);
185
- }
186
- if (ctx.screenshotRef) header.push(`Screenshot: ${ctx.screenshotRef}`);
187
-
188
- const headerBlock = header.join("\n");
189
- const contentLabel = "\n\nContent:\n";
190
- const budget = maxChars - headerBlock.length - contentLabel.length;
191
-
192
- if (budget <= 0) {
193
- return headerBlock.slice(0, maxChars);
194
- }
195
-
196
- const truncated = ctx.content.slice(0, budget);
197
- return `${headerBlock}${contentLabel}${truncated}`;
198
- }
@@ -1,234 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { ProtocolDispatcher } from "./dispatcher";
3
-
4
- function envelope(over: Record<string, unknown> = {}) {
5
- return {
6
- jsonrpc: "2.0",
7
- id: 1,
8
- method: "model/list",
9
- tenantId: "org_123",
10
- ...over,
11
- };
12
- }
13
-
14
- describe("ProtocolDispatcher — envelope validation (fail-closed)", () => {
15
- it("rejects an envelope missing tenantId with a JSON-RPC error (never throws)", async () => {
16
- const d = new ProtocolDispatcher();
17
- const res = await d.dispatch({ jsonrpc: "2.0", id: 7, method: "model/list" });
18
- expect(res).toMatchObject({ jsonrpc: "2.0", id: 7 });
19
- expect("error" in res && res.error.code).toBe(-32600);
20
- expect("result" in res).toBe(false);
21
- });
22
-
23
- it("rejects an empty tenantId fail-closed", async () => {
24
- const d = new ProtocolDispatcher();
25
- const res = await d.dispatch(envelope({ tenantId: "" }));
26
- expect("error" in res && res.error.code).toBe(-32600);
27
- });
28
-
29
- it("rejects a completely malformed envelope without throwing", async () => {
30
- const d = new ProtocolDispatcher();
31
- const res = await d.dispatch("not-an-object");
32
- expect("error" in res && res.error.code).toBe(-32600);
33
- // id falls back to null when unparseable
34
- expect((res as { id: unknown }).id).toBeNull();
35
- });
36
- });
37
-
38
- describe("ProtocolDispatcher — method lookup", () => {
39
- it("returns -32601 for an unknown method", async () => {
40
- const d = new ProtocolDispatcher();
41
- const res = await d.dispatch(envelope({ method: "does/not-exist" }));
42
- expect("error" in res && res.error.code).toBe(-32601);
43
- });
44
-
45
- it("returns -32601 when no handler is registered for a known method", async () => {
46
- const d = new ProtocolDispatcher();
47
- const res = await d.dispatch(envelope({ method: "model/list" }));
48
- expect("error" in res && res.error.code).toBe(-32601);
49
- });
50
-
51
- it("invokes a registered handler and returns its result", async () => {
52
- const d = new ProtocolDispatcher();
53
- d.register("model/list", async () => ({ models: ["a"] }));
54
- const res = await d.dispatch(envelope());
55
- expect(res).toMatchObject({
56
- jsonrpc: "2.0",
57
- id: 1,
58
- result: { models: ["a"] },
59
- });
60
- });
61
- });
62
-
63
- describe("ProtocolDispatcher — scope resolution (fail-closed)", () => {
64
- it("returns a JSON-RPC error when a thread-scoped method is missing threadId", async () => {
65
- const d = new ProtocolDispatcher();
66
- d.register("turn/start", async () => "ok");
67
- const res = await d.dispatch(envelope({ method: "turn/start", params: {} }));
68
- expect("error" in res && res.error.code).toBe(-32602);
69
- expect("result" in res).toBe(false);
70
- });
71
-
72
- it("passes the resolved thread scope to the handler", async () => {
73
- const d = new ProtocolDispatcher();
74
- let seenScope: unknown;
75
- d.register("turn/start", async (_p, scope) => {
76
- seenScope = scope;
77
- return "ok";
78
- });
79
- await d.dispatch(envelope({ method: "turn/start", params: { threadId: "th_1" } }));
80
- expect(seenScope).toEqual({
81
- tenantId: "org_123",
82
- kind: "thread",
83
- threadId: "th_1",
84
- });
85
- });
86
- });
87
-
88
- describe("ProtocolDispatcher — serialization semantics", () => {
89
- it("serializes same-scope requests in FIFO order", async () => {
90
- const d = new ProtocolDispatcher();
91
- const order: string[] = [];
92
- d.register("turn/start", async (params) => {
93
- const p = params as { tag: string; delay: number };
94
- await new Promise((r) => setTimeout(r, p.delay));
95
- order.push(p.tag);
96
- return p.tag;
97
- });
98
-
99
- const a = d.dispatch(
100
- envelope({
101
- method: "turn/start",
102
- id: "a",
103
- params: { threadId: "th_same", tag: "A", delay: 40 },
104
- }),
105
- );
106
- const b = d.dispatch(
107
- envelope({
108
- method: "turn/start",
109
- id: "b",
110
- params: { threadId: "th_same", tag: "B", delay: 1 },
111
- }),
112
- );
113
-
114
- await Promise.all([a, b]);
115
- // B is fast but must wait for A because they share tenant+thread scope.
116
- expect(order).toEqual(["A", "B"]);
117
- });
118
-
119
- it("runs distinct-scope requests concurrently", async () => {
120
- const d = new ProtocolDispatcher();
121
- const order: string[] = [];
122
- d.register("turn/start", async (params) => {
123
- const p = params as { tag: string; delay: number };
124
- await new Promise((r) => setTimeout(r, p.delay));
125
- order.push(p.tag);
126
- return p.tag;
127
- });
128
-
129
- const slow = d.dispatch(
130
- envelope({
131
- method: "turn/start",
132
- id: "slow",
133
- params: { threadId: "th_slow", tag: "SLOW", delay: 40 },
134
- }),
135
- );
136
- const fast = d.dispatch(
137
- envelope({
138
- method: "turn/start",
139
- id: "fast",
140
- params: { threadId: "th_fast", tag: "FAST", delay: 1 },
141
- }),
142
- );
143
-
144
- await Promise.all([slow, fast]);
145
- // Different threads → different scope keys → concurrent; FAST finishes first.
146
- expect(order).toEqual(["FAST", "SLOW"]);
147
- });
148
-
149
- it("isolates same-thread ids across different tenants (cross-tenant concurrency)", async () => {
150
- const d = new ProtocolDispatcher();
151
- const order: string[] = [];
152
- d.register("turn/start", async (params) => {
153
- const p = params as { tag: string; delay: number };
154
- await new Promise((r) => setTimeout(r, p.delay));
155
- order.push(p.tag);
156
- return p.tag;
157
- });
158
-
159
- const t1 = d.dispatch(
160
- envelope({
161
- tenantId: "org_A",
162
- method: "turn/start",
163
- id: "t1",
164
- params: { threadId: "th_shared", tag: "A", delay: 40 },
165
- }),
166
- );
167
- const t2 = d.dispatch(
168
- envelope({
169
- tenantId: "org_B",
170
- method: "turn/start",
171
- id: "t2",
172
- params: { threadId: "th_shared", tag: "B", delay: 1 },
173
- }),
174
- );
175
-
176
- await Promise.all([t1, t2]);
177
- expect(order).toEqual(["B", "A"]);
178
- });
179
- });
180
-
181
- describe("ProtocolDispatcher — handler errors", () => {
182
- it("maps a thrown handler error to JSON-RPC -32603 without leaking a stack", async () => {
183
- const d = new ProtocolDispatcher();
184
- d.register("model/list", async () => {
185
- throw new Error("boom internal");
186
- });
187
- const res = await d.dispatch(envelope());
188
- expect("error" in res && res.error.code).toBe(-32603);
189
- const msg = ("error" in res && res.error.message) || "";
190
- expect(msg).toBe("boom internal");
191
- expect(msg).not.toContain("at ");
192
- });
193
-
194
- it("recovers the serial lane after a handler throws", async () => {
195
- const d = new ProtocolDispatcher();
196
- let calls = 0;
197
- d.register("turn/start", async () => {
198
- calls += 1;
199
- if (calls === 1) throw new Error("first fails");
200
- return "second ok";
201
- });
202
- const first = await d.dispatch(
203
- envelope({ method: "turn/start", id: "f1", params: { threadId: "th_x" } }),
204
- );
205
- const second = await d.dispatch(
206
- envelope({ method: "turn/start", id: "f2", params: { threadId: "th_x" } }),
207
- );
208
- expect("error" in first && first.error.code).toBe(-32603);
209
- expect("result" in second && second.result).toBe("second ok");
210
- });
211
- });
212
-
213
- describe("ProtocolDispatcher — notifications", () => {
214
- it("emits to registered listeners for a valid notification name", () => {
215
- const d = new ProtocolDispatcher();
216
- const spy = vi.fn();
217
- d.onNotification("turn/completed", spy);
218
- d.emitNotification("turn/completed", { turnId: "t1" });
219
- expect(spy).toHaveBeenCalledWith({ turnId: "t1" });
220
- });
221
-
222
- it("throws for an unknown notification name (validate name ∈ NOTIFICATIONS)", () => {
223
- const d = new ProtocolDispatcher();
224
- expect(() => d.emitNotification("not/a/real/notification" as never, {})).toThrow();
225
- });
226
-
227
- it("does not invoke listeners bound to a different notification", () => {
228
- const d = new ProtocolDispatcher();
229
- const spy = vi.fn();
230
- d.onNotification("turn/started", spy);
231
- d.emitNotification("turn/completed", {});
232
- expect(spy).not.toHaveBeenCalled();
233
- });
234
- });