@gethmy/mcp 2.4.6 → 2.5.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.
@@ -1,12 +1,17 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { HarmonyApiClient } from "../api-client.js";
2
3
  import {
3
4
  type CardData,
5
+ computeContentHash,
4
6
  generatePrompt,
5
7
  getAvailableCategories,
6
8
  getAvailableVariants,
7
9
  getRoleFraming,
8
10
  inferCategoryFromLabels,
9
11
  type MemoryData,
12
+ PROMPT_TEMPLATE_VERSION,
13
+ type PromptCohortRow,
14
+ proposePromptVariant,
10
15
  } from "../prompt-builder.js";
11
16
 
12
17
  function makeCard(overrides: Partial<CardData> = {}): CardData {
@@ -503,3 +508,232 @@ describe("utility exports", () => {
503
508
  }
504
509
  });
505
510
  });
511
+
512
+ // ─── AGP P2: snapshot identity (promptId, contentHash, version) ──────
513
+
514
+ describe("generatePrompt — AGP P2 snapshot fields", () => {
515
+ test("returns promptId, contentHash, version", () => {
516
+ const result = generatePrompt({
517
+ card: makeCard(),
518
+ variant: "execute",
519
+ });
520
+
521
+ expect(typeof result.promptId).toBe("string");
522
+ expect(result.promptId.length).toBeGreaterThan(0);
523
+ expect(typeof result.contentHash).toBe("string");
524
+ // sha256 hex = 64 chars
525
+ expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
526
+ expect(result.version).toBe(PROMPT_TEMPLATE_VERSION);
527
+ });
528
+
529
+ test("identical inputs produce identical contentHash", () => {
530
+ const a = generatePrompt({ card: makeCard(), variant: "analysis" });
531
+ const b = generatePrompt({ card: makeCard(), variant: "analysis" });
532
+ expect(a.contentHash).toBe(b.contentHash);
533
+ });
534
+
535
+ test("different inputs produce different contentHash", () => {
536
+ const a = generatePrompt({ card: makeCard(), variant: "analysis" });
537
+ const b = generatePrompt({
538
+ card: makeCard({ title: "Different title" }),
539
+ variant: "analysis",
540
+ });
541
+ expect(a.contentHash).not.toBe(b.contentHash);
542
+ });
543
+
544
+ test("each call produces a fresh promptId", () => {
545
+ const a = generatePrompt({ card: makeCard(), variant: "execute" });
546
+ const b = generatePrompt({ card: makeCard(), variant: "execute" });
547
+ expect(a.promptId).not.toBe(b.promptId);
548
+ });
549
+
550
+ test("computeContentHash matches generated prompt hash", () => {
551
+ const result = generatePrompt({ card: makeCard(), variant: "execute" });
552
+ expect(computeContentHash(result.prompt)).toBe(result.contentHash);
553
+ });
554
+ });
555
+
556
+ // ─── AGP P2: persistence wiring via api-client ────────────────────────
557
+ //
558
+ // The api-client wraps generatePrompt() with a best-effort POST to
559
+ // /prompt-history. We unit-test by stubbing `request` so we don't need
560
+ // a live API.
561
+
562
+ describe("api-client.generateCardPrompt — prompt_history persistence", () => {
563
+ test("POSTs a prompt-history row with matching contentHash", async () => {
564
+ const recorded: Array<{ method: string; path: string; body?: unknown }> =
565
+ [];
566
+ const client = new HarmonyApiClient({
567
+ apiUrl: "http://test",
568
+ apiKey: "test-key",
569
+ });
570
+ (client as any).request = async (
571
+ method: string,
572
+ path: string,
573
+ body?: unknown,
574
+ ) => {
575
+ recorded.push({ method, path, body });
576
+ // Minimal stubs for the subset of routes generateCardPrompt hits.
577
+ if (path.startsWith("/cards/") && method === "GET") {
578
+ return {
579
+ card: {
580
+ id: "card-1",
581
+ short_id: 7,
582
+ title: "Test card",
583
+ description: "desc",
584
+ priority: "medium",
585
+ done: false,
586
+ labels: [],
587
+ subtasks: [],
588
+ links: [],
589
+ project_id: "proj-1",
590
+ column_id: "col-1",
591
+ },
592
+ };
593
+ }
594
+ if (path.startsWith("/board/")) {
595
+ return { columns: [] };
596
+ }
597
+ if (path.startsWith("/memory/")) {
598
+ return { entities: [] };
599
+ }
600
+ if (path === "/prompt-history") {
601
+ return { entry: { id: "ph-1" } };
602
+ }
603
+ return {};
604
+ };
605
+
606
+ const result = await client.generateCardPrompt({
607
+ cardId: "card-1",
608
+ workspaceId: "ws-1",
609
+ projectId: "proj-1",
610
+ variant: "execute",
611
+ });
612
+
613
+ expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
614
+
615
+ const phCall = recorded.find(
616
+ (c) => c.method === "POST" && c.path === "/prompt-history",
617
+ );
618
+ expect(phCall).toBeDefined();
619
+ const body = phCall!.body as Record<string, unknown>;
620
+ expect(body.cardId).toBe("card-1");
621
+ expect(body.contentHash).toBe(result.contentHash);
622
+ expect(body.templateVersion).toBe(PROMPT_TEMPLATE_VERSION);
623
+ expect(body.confidence).toBe(0.5);
624
+ expect(body.variant).toBe("execute");
625
+ });
626
+
627
+ test("returns successfully when persistence fails (best-effort)", async () => {
628
+ const client = new HarmonyApiClient({
629
+ apiUrl: "http://test",
630
+ apiKey: "test-key",
631
+ });
632
+ (client as any).request = async (method: string, path: string) => {
633
+ if (path.startsWith("/cards/") && method === "GET") {
634
+ return {
635
+ card: {
636
+ id: "card-1",
637
+ short_id: 7,
638
+ title: "Test card",
639
+ description: null,
640
+ priority: "medium",
641
+ done: false,
642
+ labels: [],
643
+ subtasks: [],
644
+ links: [],
645
+ project_id: "proj-1",
646
+ column_id: "col-1",
647
+ },
648
+ };
649
+ }
650
+ if (path.startsWith("/board/")) return { columns: [] };
651
+ if (path.startsWith("/memory/")) return { entities: [] };
652
+ if (path === "/prompt-history") {
653
+ throw new Error("network down");
654
+ }
655
+ return {};
656
+ };
657
+
658
+ const result = await client.generateCardPrompt({
659
+ cardId: "card-1",
660
+ workspaceId: "ws-1",
661
+ variant: "analysis",
662
+ });
663
+ // Generation still succeeds even when logging fails.
664
+ expect(result.prompt.length).toBeGreaterThan(0);
665
+ expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
666
+ });
667
+ });
668
+
669
+ // ─── AGP P2: variant proposal helper (logged-only) ────────────────────
670
+
671
+ describe("proposePromptVariant", () => {
672
+ function row(overrides: Partial<PromptCohortRow> = {}): PromptCohortRow {
673
+ return {
674
+ status: "completed",
675
+ progressPercent: 100,
676
+ hadBlockers: false,
677
+ ...overrides,
678
+ };
679
+ }
680
+
681
+ test("returns null when cohort is smaller than 10", async () => {
682
+ const cohort = Array.from({ length: 5 }, () =>
683
+ row({ status: "paused", progressPercent: 30 }),
684
+ );
685
+ const suggestion = await proposePromptVariant("hash-1", async () => cohort);
686
+ expect(suggestion).toBeNull();
687
+ });
688
+
689
+ test("returns null when completion rate is acceptable (>=0.4)", async () => {
690
+ const cohort = [
691
+ ...Array.from({ length: 5 }, () => row()),
692
+ ...Array.from({ length: 5 }, () =>
693
+ row({ status: "paused", progressPercent: 50 }),
694
+ ),
695
+ ];
696
+ const suggestion = await proposePromptVariant("hash-2", async () => cohort);
697
+ expect(suggestion).toBeNull();
698
+ });
699
+
700
+ test("returns suggestion when cohort is >=10 and completion rate <0.4", async () => {
701
+ // 2 successes / 10 sessions = 0.2 completion rate, below 0.4 threshold.
702
+ const cohort = [
703
+ ...Array.from({ length: 2 }, () => row()),
704
+ ...Array.from({ length: 8 }, () =>
705
+ row({ status: "paused", progressPercent: 30, hadBlockers: false }),
706
+ ),
707
+ ];
708
+ const suggestion = await proposePromptVariant("hash-3", async () => cohort);
709
+ expect(suggestion).not.toBeNull();
710
+ expect(suggestion!.contentHash).toBe("hash-3");
711
+ expect(suggestion!.cohortSize).toBe(10);
712
+ expect(suggestion!.completionRate).toBeCloseTo(0.2, 5);
713
+ expect(suggestion!.framingHint).toContain("action-forcing");
714
+ });
715
+
716
+ test("returns blocker-flavoured framing hint when blockers dominate", async () => {
717
+ // 1 success, 9 blocked sessions → completionRate=0.1, blockerRate=0.9
718
+ const cohort = [
719
+ row(),
720
+ ...Array.from({ length: 9 }, () =>
721
+ row({ status: "paused", progressPercent: 25, hadBlockers: true }),
722
+ ),
723
+ ];
724
+ const suggestion = await proposePromptVariant("hash-4", async () => cohort);
725
+ expect(suggestion).not.toBeNull();
726
+ expect(suggestion!.framingHint).toContain("diagnostic");
727
+ });
728
+
729
+ test("blockers count against completion rate", async () => {
730
+ // Even at 100% status="completed"+100%, blockers should disqualify them
731
+ // from being counted as success.
732
+ const cohort = Array.from({ length: 10 }, () =>
733
+ row({ status: "completed", progressPercent: 100, hadBlockers: true }),
734
+ );
735
+ const suggestion = await proposePromptVariant("hash-5", async () => cohort);
736
+ expect(suggestion).not.toBeNull();
737
+ expect(suggestion!.completionRate).toBe(0);
738
+ });
739
+ });
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Routing/auth tests for the remote MCP server (`src/remote.ts`).
3
+ *
4
+ * Covers the four session-routing branches mandated by the MCP Streamable
5
+ * HTTP spec — the regression that surfaced as "Harmony MCP not responding"
6
+ * after a server restart, OAuth refresh, or stale-session GC was a missing
7
+ * 404 branch for unknown `Mcp-Session-Id` values.
8
+ *
9
+ * These tests stub `globalThis.fetch` so we don't depend on harmony-api
10
+ * being reachable.
11
+ *
12
+ * Run with: bun test packages/mcp-server/src/__tests__/remote-routing.test.ts
13
+ */
14
+
15
+ import {
16
+ afterAll,
17
+ afterEach,
18
+ beforeAll,
19
+ beforeEach,
20
+ describe,
21
+ expect,
22
+ test,
23
+ } from "bun:test";
24
+
25
+ const ORIGINAL_FETCH = globalThis.fetch;
26
+
27
+ // Fixed values used across tests
28
+ const TEST_USER = "user-alpha";
29
+ const OTHER_USER = "user-beta";
30
+ const TEST_WORKSPACE = "ws-1";
31
+ const VALID_TOKEN = "hmy_at_alpha_valid";
32
+ const REFRESHED_TOKEN = "hmy_at_alpha_refreshed";
33
+ const OTHER_USER_TOKEN = "hmy_at_beta_valid";
34
+ const REVOKED_TOKEN = "hmy_at_revoked";
35
+
36
+ function makeFetchStub() {
37
+ return async (
38
+ input: string | URL | Request,
39
+ init?: RequestInit,
40
+ ): Promise<Response> => {
41
+ const url = typeof input === "string" ? input : input.toString();
42
+ const apiKey =
43
+ (init?.headers as Record<string, string> | undefined)?.["X-API-Key"] ??
44
+ "";
45
+
46
+ if (url.endsWith("/v1/auth/context")) {
47
+ const map: Record<
48
+ string,
49
+ { userId: string; workspaceId: string | null; source: string } | null
50
+ > = {
51
+ [VALID_TOKEN]: {
52
+ userId: TEST_USER,
53
+ workspaceId: TEST_WORKSPACE,
54
+ source: "oauth",
55
+ },
56
+ [REFRESHED_TOKEN]: {
57
+ userId: TEST_USER,
58
+ workspaceId: TEST_WORKSPACE,
59
+ source: "oauth",
60
+ },
61
+ [OTHER_USER_TOKEN]: {
62
+ userId: OTHER_USER,
63
+ workspaceId: "ws-2",
64
+ source: "oauth",
65
+ },
66
+ };
67
+ const ctx = map[apiKey];
68
+ if (!ctx) {
69
+ return new Response(JSON.stringify({ error: "unauthorized" }), {
70
+ status: 401,
71
+ headers: { "Content-Type": "application/json" },
72
+ });
73
+ }
74
+ return new Response(JSON.stringify(ctx), {
75
+ status: 200,
76
+ headers: { "Content-Type": "application/json" },
77
+ });
78
+ }
79
+
80
+ // Anything else — return a benign 404 so unrelated lookups don't blow up.
81
+ return new Response("{}", {
82
+ status: 404,
83
+ headers: { "Content-Type": "application/json" },
84
+ });
85
+ };
86
+ }
87
+
88
+ let fetchHandler: (req: Request) => Promise<Response>;
89
+ let _sessionsForTests: Map<string, unknown>;
90
+
91
+ beforeAll(async () => {
92
+ globalThis.fetch = makeFetchStub() as unknown as typeof fetch;
93
+ // Import after the stub is in place (module init is synchronous-only.)
94
+ const mod = await import("../remote.js");
95
+ fetchHandler = mod.fetchHandler as (req: Request) => Promise<Response>;
96
+ _sessionsForTests = mod._sessionsForTests as Map<string, unknown>;
97
+ });
98
+
99
+ afterAll(() => {
100
+ globalThis.fetch = ORIGINAL_FETCH;
101
+ });
102
+
103
+ beforeEach(() => {
104
+ // Refresh the fetch stub each test (preserves across the cache TTL).
105
+ globalThis.fetch = makeFetchStub() as unknown as typeof fetch;
106
+ });
107
+
108
+ afterEach(() => {
109
+ // Wipe sessions between tests so state doesn't leak.
110
+ _sessionsForTests.clear();
111
+ });
112
+
113
+ const INIT_BODY = {
114
+ jsonrpc: "2.0",
115
+ id: 1,
116
+ method: "initialize",
117
+ params: {
118
+ protocolVersion: "2025-06-18",
119
+ capabilities: {},
120
+ clientInfo: { name: "test", version: "0.0.1" },
121
+ },
122
+ };
123
+
124
+ const TOOLS_LIST_BODY = {
125
+ jsonrpc: "2.0",
126
+ id: 2,
127
+ method: "tools/list",
128
+ params: {},
129
+ };
130
+
131
+ function makePost(
132
+ body: unknown,
133
+ opts: { token?: string; sessionId?: string } = {},
134
+ ): Request {
135
+ const headers: Record<string, string> = {
136
+ "Content-Type": "application/json",
137
+ Accept: "application/json, text/event-stream",
138
+ };
139
+ if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
140
+ if (opts.sessionId) headers["Mcp-Session-Id"] = opts.sessionId;
141
+ return new Request("http://localhost/mcp", {
142
+ method: "POST",
143
+ headers,
144
+ body: JSON.stringify(body),
145
+ });
146
+ }
147
+
148
+ describe("remote MCP routing", () => {
149
+ test("no Authorization header → 401 + WWW-Authenticate", async () => {
150
+ const res = await fetchHandler(
151
+ new Request("http://localhost/mcp", { method: "POST" }),
152
+ );
153
+ expect(res.status).toBe(401);
154
+ const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
155
+ expect(wwwAuth).toContain("Bearer");
156
+ expect(wwwAuth).toContain("resource_metadata=");
157
+ });
158
+
159
+ test("invalid bearer on initialize → 401 invalid_token", async () => {
160
+ const res = await fetchHandler(
161
+ makePost(INIT_BODY, { token: REVOKED_TOKEN }),
162
+ );
163
+ expect(res.status).toBe(401);
164
+ expect(res.headers.get("WWW-Authenticate") ?? "").toContain(
165
+ "invalid_token",
166
+ );
167
+ });
168
+
169
+ test("POST without session id and not initialize → 400", async () => {
170
+ const res = await fetchHandler(
171
+ makePost(TOOLS_LIST_BODY, { token: VALID_TOKEN }),
172
+ );
173
+ expect(res.status).toBe(400);
174
+ const body = (await res.json()) as { error: { message: string } };
175
+ expect(body.error.message).toMatch(/Mcp-Session-Id header required/i);
176
+ });
177
+
178
+ test("POST with unknown session id → 404 (forces client re-init)", async () => {
179
+ const res = await fetchHandler(
180
+ makePost(TOOLS_LIST_BODY, {
181
+ token: VALID_TOKEN,
182
+ sessionId: "ghost-session-id",
183
+ }),
184
+ );
185
+ // This is the spec-mandated behavior that fixes "Harmony MCP not responding".
186
+ expect(res.status).toBe(404);
187
+ const body = (await res.json()) as { error: { code: number } };
188
+ expect(body.error.code).toBe(-32001);
189
+ expect(res.headers.get("Mcp-Session-Id")).toBe("ghost-session-id");
190
+ });
191
+
192
+ test("initialize succeeds and registers a session", async () => {
193
+ const res = await fetchHandler(makePost(INIT_BODY, { token: VALID_TOKEN }));
194
+ expect(res.status).toBe(200);
195
+ const sid = res.headers.get("mcp-session-id");
196
+ expect(sid).toBeTruthy();
197
+ expect(_sessionsForTests.has(sid!)).toBe(true);
198
+ });
199
+
200
+ test("rotated token (same user) is accepted on hot-swap", async () => {
201
+ // Bootstrap a session.
202
+ const initRes = await fetchHandler(
203
+ makePost(INIT_BODY, { token: VALID_TOKEN }),
204
+ );
205
+ const sid = initRes.headers.get("mcp-session-id")!;
206
+
207
+ // Send a follow-up with the *new* token + same session id.
208
+ // We're not asserting on the body — just that the rotation didn't
209
+ // produce a 401/404, which it would if hot-swap were broken.
210
+ const follow = await fetchHandler(
211
+ makePost(TOOLS_LIST_BODY, {
212
+ token: REFRESHED_TOKEN,
213
+ sessionId: sid,
214
+ }),
215
+ );
216
+ expect(follow.status).not.toBe(401);
217
+ expect(follow.status).not.toBe(404);
218
+ });
219
+
220
+ test("token from a different user is REJECTED on hot-swap", async () => {
221
+ const initRes = await fetchHandler(
222
+ makePost(INIT_BODY, { token: VALID_TOKEN }),
223
+ );
224
+ const sid = initRes.headers.get("mcp-session-id")!;
225
+
226
+ // Attempt to ride the session with another user's bearer.
227
+ const hijack = await fetchHandler(
228
+ makePost(TOOLS_LIST_BODY, {
229
+ token: OTHER_USER_TOKEN,
230
+ sessionId: sid,
231
+ }),
232
+ );
233
+ expect(hijack.status).toBe(401);
234
+ const wwwAuth = hijack.headers.get("WWW-Authenticate") ?? "";
235
+ expect(wwwAuth).toContain("invalid_token");
236
+ });
237
+
238
+ test("GET without valid session id → 400", async () => {
239
+ const res = await fetchHandler(
240
+ new Request("http://localhost/mcp", {
241
+ method: "GET",
242
+ headers: {
243
+ Authorization: `Bearer ${VALID_TOKEN}`,
244
+ Accept: "text/event-stream",
245
+ },
246
+ }),
247
+ );
248
+ // GET without a session id falls into "session required" — 400.
249
+ expect(res.status).toBe(400);
250
+ });
251
+
252
+ test("GET with unknown session id → 404", async () => {
253
+ const res = await fetchHandler(
254
+ new Request("http://localhost/mcp", {
255
+ method: "GET",
256
+ headers: {
257
+ Authorization: `Bearer ${VALID_TOKEN}`,
258
+ Accept: "text/event-stream",
259
+ "Mcp-Session-Id": "ghost",
260
+ },
261
+ }),
262
+ );
263
+ expect(res.status).toBe(404);
264
+ });
265
+
266
+ test("/.well-known/oauth-protected-resource is unauthenticated", async () => {
267
+ const res = await fetchHandler(
268
+ new Request("http://localhost/.well-known/oauth-protected-resource"),
269
+ );
270
+ expect(res.status).toBe(200);
271
+ const body = (await res.json()) as {
272
+ authorization_servers: string[];
273
+ bearer_methods_supported: string[];
274
+ };
275
+ expect(body.authorization_servers.length).toBeGreaterThan(0);
276
+ expect(body.bearer_methods_supported).toContain("header");
277
+ });
278
+
279
+ test("/health is unauthenticated", async () => {
280
+ const res = await fetchHandler(new Request("http://localhost/health"));
281
+ expect(res.status).toBe(200);
282
+ const body = (await res.json()) as { status: string };
283
+ expect(body.status).toBe("ok");
284
+ });
285
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Verification harness for skill rendering. Card #182.
3
+ *
4
+ * Pins the contract that backs `~/.claude/skills/` delivery: frontmatter shape,
5
+ * version-marker injection, agent-id substitution. If any of these drift the
6
+ * MCP bridge installs broken skills silently, so this file is the floor.
7
+ *
8
+ * Run with: bun test packages/mcp-server/src/__tests__/skills.test.ts
9
+ */
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import {
13
+ buildSkillFile,
14
+ SKILL_DEFINITIONS,
15
+ SKILLS_VERSION,
16
+ } from "../skills.js";
17
+
18
+ const SKILL_IDS = Object.keys(SKILL_DEFINITIONS);
19
+ const VERSION_MARKER_RE = /<!-- skills-version:(\d+) -->\s*$/;
20
+
21
+ describe("SKILL_DEFINITIONS", () => {
22
+ test("registry is non-empty", () => {
23
+ expect(SKILL_IDS.length).toBeGreaterThan(0);
24
+ });
25
+
26
+ test("SKILLS_VERSION is a numeric string", () => {
27
+ expect(SKILLS_VERSION).toMatch(/^\d+$/);
28
+ });
29
+
30
+ test.each(SKILL_IDS)("definition %s has required fields", (skillId) => {
31
+ const skill = SKILL_DEFINITIONS[skillId];
32
+ expect(skill.name).toBe(skillId);
33
+ expect(skill.description.length).toBeGreaterThan(0);
34
+ expect(skill.argumentHint.length).toBeGreaterThan(0);
35
+ expect(skill.content.length).toBeGreaterThan(0);
36
+ });
37
+
38
+ test("all skill names are unique", () => {
39
+ const names = SKILL_IDS.map((id) => SKILL_DEFINITIONS[id].name);
40
+ expect(new Set(names).size).toBe(names.length);
41
+ });
42
+ });
43
+
44
+ describe("buildSkillFile()", () => {
45
+ test.each(SKILL_IDS)("renders %s with valid frontmatter", (skillId) => {
46
+ const out = buildSkillFile(skillId);
47
+ const skill = SKILL_DEFINITIONS[skillId];
48
+
49
+ expect(out.startsWith("---\n")).toBe(true);
50
+ expect(out).toContain(`\nname: ${skill.name}\n`);
51
+ expect(out).toContain(`\ndescription: ${skill.description}\n`);
52
+ expect(out).toContain(`\nargument-hint: ${skill.argumentHint}\n`);
53
+ expect(out).toContain("---\n\n");
54
+ });
55
+
56
+ test.each(SKILL_IDS)("appends version marker for %s", (skillId) => {
57
+ const out = buildSkillFile(skillId);
58
+ const match = out.match(VERSION_MARKER_RE);
59
+
60
+ expect(match).not.toBeNull();
61
+ expect(match?.[1]).toBe(SKILLS_VERSION);
62
+ });
63
+
64
+ test.each(SKILL_IDS)("embeds skill content for %s", (skillId) => {
65
+ const out = buildSkillFile(skillId);
66
+ const content = SKILL_DEFINITIONS[skillId].content;
67
+
68
+ expect(out).toContain(content.split("\n")[0]);
69
+ const lastLine = content.trim().split("\n").at(-1) ?? "";
70
+ expect(out).toContain(lastLine);
71
+ });
72
+
73
+ test("is deterministic — same input yields same output", () => {
74
+ const a = buildSkillFile("hmy");
75
+ const b = buildSkillFile("hmy");
76
+ expect(a).toBe(b);
77
+ });
78
+
79
+ test("throws on unknown skill id", () => {
80
+ expect(() => buildSkillFile("does-not-exist")).toThrow(/Unknown skill/);
81
+ });
82
+
83
+ describe("agent-id substitution", () => {
84
+ test("agentId='claude' replaces placeholder phrases in hmy", () => {
85
+ const withAgent = buildSkillFile("hmy", "claude");
86
+ expect(withAgent).not.toContain("Your agent identifier");
87
+ expect(withAgent).not.toContain("Your agent name");
88
+ expect(withAgent).toContain("claude-code");
89
+ expect(withAgent).toContain("Claude Code");
90
+ });
91
+
92
+ test("agentId omitted leaves placeholders untouched", () => {
93
+ const raw = buildSkillFile("hmy");
94
+ const hasPlaceholder =
95
+ raw.includes("Your agent identifier") ||
96
+ raw.includes("Your agent name");
97
+ const skillContent = SKILL_DEFINITIONS.hmy.content;
98
+ const sourceHasPlaceholder =
99
+ skillContent.includes("Your agent identifier") ||
100
+ skillContent.includes("Your agent name");
101
+
102
+ expect(hasPlaceholder).toBe(sourceHasPlaceholder);
103
+ });
104
+
105
+ test("unknown agentId is treated as no-op", () => {
106
+ const raw = buildSkillFile("hmy");
107
+ const unknownAgent = buildSkillFile("hmy", "some-other-agent");
108
+ expect(unknownAgent).toBe(raw);
109
+ });
110
+ });
111
+ });