@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.
Files changed (114) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +33 -10
  3. package/dist/adapters/index.d.ts +24 -5
  4. package/dist/adapters/index.js +27 -0
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/{chunk-KCNN4QUQ.js → chunk-5UGIUYJR.js} +4 -4
  7. package/dist/chunk-7ELA4SWE.js +73 -0
  8. package/dist/chunk-7ELA4SWE.js.map +1 -0
  9. package/dist/chunk-Q62VKHIT.js +178 -0
  10. package/dist/chunk-Q62VKHIT.js.map +1 -0
  11. package/dist/chunk-VXILZAXK.js +541 -0
  12. package/dist/chunk-VXILZAXK.js.map +1 -0
  13. package/dist/command-exec.d.ts +45 -0
  14. package/dist/command-exec.js +15 -0
  15. package/dist/command-exec.js.map +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +81 -28
  18. package/dist/index.js.map +1 -1
  19. package/dist/orchestration.d.ts +42 -1
  20. package/dist/orchestration.js +7 -3
  21. package/dist/pulsar.js +2 -2
  22. package/dist/sandbox-DfcRttXt.d.ts +219 -0
  23. package/dist/sandbox.d.ts +2 -64
  24. package/dist/sandbox.js +25 -3
  25. package/package.json +81 -30
  26. package/.turbo/turbo-build.log +0 -130
  27. package/.turbo/turbo-test.log +0 -47
  28. package/.turbo/turbo-typecheck.log +0 -4
  29. package/CHANGELOG.md +0 -250
  30. package/dist/chunk-MUF7ZZTO.js +0 -57
  31. package/dist/chunk-MUF7ZZTO.js.map +0 -1
  32. package/dist/chunk-MX2WL43P.js +0 -90
  33. package/dist/chunk-MX2WL43P.js.map +0 -1
  34. package/examples/pulsar-quickstart.ts +0 -35
  35. package/examples/resume-branch.ts +0 -45
  36. package/examples/subagent-fanout.ts +0 -20
  37. package/src/adapters/dispatcher-sse.test.ts +0 -218
  38. package/src/adapters/dispatcher-sse.ts +0 -222
  39. package/src/adapters/index.ts +0 -18
  40. package/src/adapters/mcp-catalog.test.ts +0 -213
  41. package/src/adapters/mcp-catalog.ts +0 -188
  42. package/src/adapters/prisma-rollout.test.ts +0 -153
  43. package/src/adapters/prisma-rollout.ts +0 -104
  44. package/src/agent-runtime.test.ts +0 -176
  45. package/src/artifact-stream.test.ts +0 -330
  46. package/src/artifact-stream.ts +0 -453
  47. package/src/channel-gateway.test.ts +0 -432
  48. package/src/channel-gateway.ts +0 -357
  49. package/src/cli.ts +0 -49
  50. package/src/code-review.test.ts +0 -501
  51. package/src/code-review.ts +0 -495
  52. package/src/command-suggestions.test.ts +0 -251
  53. package/src/command-suggestions.ts +0 -338
  54. package/src/commands.test.ts +0 -184
  55. package/src/commands.ts +0 -140
  56. package/src/commit-message.test.ts +0 -249
  57. package/src/commit-message.ts +0 -180
  58. package/src/context-compaction.test.ts +0 -522
  59. package/src/context-compaction.ts +0 -438
  60. package/src/definitions.test.ts +0 -78
  61. package/src/definitions.ts +0 -190
  62. package/src/deployment-status.test.ts +0 -215
  63. package/src/deployment-status.ts +0 -227
  64. package/src/design-context.test.ts +0 -195
  65. package/src/design-context.ts +0 -198
  66. package/src/dispatcher.test.ts +0 -234
  67. package/src/dispatcher.ts +0 -189
  68. package/src/durable-turn.test.ts +0 -209
  69. package/src/durable-turn.ts +0 -135
  70. package/src/edit-planner.test.ts +0 -204
  71. package/src/edit-planner.ts +0 -325
  72. package/src/fuzzy-match.test.ts +0 -311
  73. package/src/fuzzy-match.ts +0 -444
  74. package/src/hook-pipeline.test.ts +0 -279
  75. package/src/hook-pipeline.ts +0 -373
  76. package/src/inbound-admission.test.ts +0 -394
  77. package/src/inbound-admission.ts +0 -246
  78. package/src/index.ts +0 -49
  79. package/src/loop.test.ts +0 -161
  80. package/src/loop.ts +0 -211
  81. package/src/mcp-bridge.test.ts +0 -165
  82. package/src/mcp-bridge.ts +0 -81
  83. package/src/memory-provider.test.ts +0 -232
  84. package/src/memory-provider.ts +0 -257
  85. package/src/model.ts +0 -168
  86. package/src/orchestration.test.ts +0 -53
  87. package/src/orchestration.ts +0 -146
  88. package/src/permission-ruleset.test.ts +0 -301
  89. package/src/permission-ruleset.ts +0 -1
  90. package/src/policy.ts +0 -151
  91. package/src/project-repo.test.ts +0 -232
  92. package/src/project-repo.ts +0 -311
  93. package/src/protocol.ts +0 -159
  94. package/src/pulsar.test.ts +0 -156
  95. package/src/pulsar.ts +0 -322
  96. package/src/rollout-store-persistent.test.ts +0 -217
  97. package/src/rollout-store-persistent.ts +0 -166
  98. package/src/rollout.ts +0 -150
  99. package/src/sandbox.ts +0 -113
  100. package/src/session-share.test.ts +0 -360
  101. package/src/session-share.ts +0 -310
  102. package/src/skill-distillation.test.ts +0 -177
  103. package/src/skill-distillation.ts +0 -369
  104. package/src/skills.test.ts +0 -277
  105. package/src/skills.ts +0 -277
  106. package/src/subagents.test.ts +0 -290
  107. package/src/subagents.ts +0 -332
  108. package/src/tools.test.ts +0 -12
  109. package/src/tools.ts +0 -129
  110. package/src/workbench.test.ts +0 -0
  111. package/src/workbench.ts +0 -0
  112. package/tsconfig.json +0 -12
  113. package/tsup.config.ts +0 -36
  114. /package/dist/{chunk-KCNN4QUQ.js.map → chunk-5UGIUYJR.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
- });