@stigmer/runner 3.12.4 → 3.12.6

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 (180) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-agent.js +19 -6
  3. package/dist/activities/call-agent.js.map +1 -1
  4. package/dist/activities/execute-cursor/cursor-mcp-config.d.ts +45 -0
  5. package/dist/activities/execute-cursor/cursor-mcp-config.js +76 -0
  6. package/dist/activities/execute-cursor/cursor-mcp-config.js.map +1 -0
  7. package/dist/activities/execute-cursor/error-classifier.d.ts +9 -0
  8. package/dist/activities/execute-cursor/error-classifier.js +30 -1
  9. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  10. package/dist/activities/execute-cursor/index.d.ts +10 -1
  11. package/dist/activities/execute-cursor/index.js +62 -36
  12. package/dist/activities/execute-cursor/index.js.map +1 -1
  13. package/dist/activities/execute-cursor/prompt-builder.d.ts +13 -2
  14. package/dist/activities/execute-cursor/prompt-builder.js +11 -0
  15. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  16. package/dist/activities/execute-cursor/service-tier.d.ts +38 -29
  17. package/dist/activities/execute-cursor/service-tier.js +92 -63
  18. package/dist/activities/execute-cursor/service-tier.js.map +1 -1
  19. package/dist/activities/execute-cursor/session-lifecycle.d.ts +1 -1
  20. package/dist/activities/execute-cursor/skill-resolver.d.ts +2 -21
  21. package/dist/activities/execute-cursor/skill-resolver.js +5 -108
  22. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  23. package/dist/activities/execute-cursor/usage-accumulator.d.ts +16 -2
  24. package/dist/activities/execute-cursor/usage-accumulator.js +12 -2
  25. package/dist/activities/execute-cursor/usage-accumulator.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/approval-file-change.d.ts +9 -0
  27. package/dist/activities/execute-deep-agent/approval-file-change.js +2 -1
  28. package/dist/activities/execute-deep-agent/approval-file-change.js.map +1 -1
  29. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +18 -1
  30. package/dist/activities/execute-deep-agent/cas-capture-backend.js +20 -3
  31. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  32. package/dist/activities/execute-deep-agent/index.js +57 -5
  33. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  34. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +9 -0
  35. package/dist/activities/execute-deep-agent/prompt-builder.js +10 -0
  36. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  37. package/dist/activities/execute-deep-agent/setup.js +17 -16
  38. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  39. package/dist/activities/execute-deep-agent/subagent-transformer.js +17 -11
  40. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  41. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +3 -3
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.js +1 -1
  44. package/dist/middleware/path-normalization.d.ts +49 -49
  45. package/dist/middleware/path-normalization.js +73 -84
  46. package/dist/middleware/path-normalization.js.map +1 -1
  47. package/dist/middleware/types.d.ts +3 -3
  48. package/dist/runner-manager.d.ts +1 -1
  49. package/dist/runner-manager.js +22 -15
  50. package/dist/runner-manager.js.map +1 -1
  51. package/dist/runner.js +11 -0
  52. package/dist/runner.js.map +1 -1
  53. package/dist/shared/args-preview.d.ts +8 -0
  54. package/dist/shared/args-preview.js +14 -3
  55. package/dist/shared/args-preview.js.map +1 -1
  56. package/dist/shared/artifact-storage.d.ts +11 -1
  57. package/dist/shared/artifact-storage.js +10 -1
  58. package/dist/shared/artifact-storage.js.map +1 -1
  59. package/dist/shared/caller-identity.d.ts +10 -7
  60. package/dist/shared/caller-identity.js +10 -7
  61. package/dist/shared/caller-identity.js.map +1 -1
  62. package/dist/shared/declared-preferences.d.ts +47 -0
  63. package/dist/shared/declared-preferences.js +64 -0
  64. package/dist/shared/declared-preferences.js.map +1 -0
  65. package/dist/shared/mcp-enabled-tools.d.ts +2 -3
  66. package/dist/shared/mcp-enabled-tools.js +2 -3
  67. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  68. package/dist/shared/mcp-resolver.d.ts +20 -15
  69. package/dist/shared/mcp-resolver.js +11 -12
  70. package/dist/shared/mcp-resolver.js.map +1 -1
  71. package/dist/shared/placeholder-resolver.d.ts +9 -2
  72. package/dist/shared/placeholder-resolver.js +9 -2
  73. package/dist/shared/placeholder-resolver.js.map +1 -1
  74. package/dist/shared/plan-mode-permissions.d.ts +26 -46
  75. package/dist/shared/plan-mode-permissions.js +27 -55
  76. package/dist/shared/plan-mode-permissions.js.map +1 -1
  77. package/dist/shared/skill-mount.d.ts +89 -0
  78. package/dist/shared/skill-mount.js +142 -0
  79. package/dist/shared/skill-mount.js.map +1 -0
  80. package/dist/shared/skill-writer.d.ts +28 -26
  81. package/dist/shared/skill-writer.js +79 -102
  82. package/dist/shared/skill-writer.js.map +1 -1
  83. package/dist/shared/thinking-mode.d.ts +35 -0
  84. package/dist/shared/thinking-mode.js +43 -0
  85. package/dist/shared/thinking-mode.js.map +1 -0
  86. package/dist/shared/worker-shutdown.d.ts +67 -0
  87. package/dist/shared/worker-shutdown.js +79 -0
  88. package/dist/shared/worker-shutdown.js.map +1 -0
  89. package/dist/shared/workspace/types.d.ts +3 -2
  90. package/dist/workflow-engine/loader.js +82 -6
  91. package/dist/workflow-engine/loader.js.map +1 -1
  92. package/dist/workflow-engine/tasks/human-input.d.ts +2 -1
  93. package/dist/workflow-engine/tasks/human-input.js +31 -3
  94. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  95. package/dist/workflow-engine/types.d.ts +17 -3
  96. package/dist/workflow-engine/types.js.map +1 -1
  97. package/dist/workflows/human-input-orchestrator.d.ts +4 -0
  98. package/dist/workflows/human-input-orchestrator.js +13 -0
  99. package/dist/workflows/human-input-orchestrator.js.map +1 -1
  100. package/package.json +15 -11
  101. package/src/activities/call-agent.ts +20 -6
  102. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +50 -0
  103. package/src/activities/execute-cursor/__tests__/cursor-mcp-config.test.ts +111 -0
  104. package/src/activities/execute-cursor/__tests__/error-classifier-billing.test.ts +67 -0
  105. package/src/activities/execute-cursor/__tests__/service-tier.test.ts +67 -7
  106. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -94
  107. package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +33 -1
  108. package/src/activities/execute-cursor/cursor-mcp-config.ts +107 -0
  109. package/src/activities/execute-cursor/error-classifier.ts +42 -1
  110. package/src/activities/execute-cursor/index.ts +78 -41
  111. package/src/activities/execute-cursor/prompt-builder.ts +29 -2
  112. package/src/activities/execute-cursor/service-tier.ts +94 -63
  113. package/src/activities/execute-cursor/session-lifecycle.ts +1 -1
  114. package/src/activities/execute-cursor/skill-resolver.ts +10 -137
  115. package/src/activities/execute-cursor/usage-accumulator.ts +11 -1
  116. package/src/activities/execute-deep-agent/__tests__/approval-file-change.test.ts +15 -0
  117. package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +75 -0
  118. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
  119. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  120. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  121. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  122. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +42 -29
  123. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +52 -0
  124. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  125. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +11 -8
  126. package/src/activities/execute-deep-agent/approval-file-change.ts +11 -1
  127. package/src/activities/execute-deep-agent/cas-capture-backend.ts +20 -3
  128. package/src/activities/execute-deep-agent/index.ts +57 -5
  129. package/src/activities/execute-deep-agent/prompt-builder.ts +22 -0
  130. package/src/activities/execute-deep-agent/setup.ts +21 -19
  131. package/src/activities/execute-deep-agent/subagent-transformer.ts +19 -12
  132. package/src/activities/execute-deep-agent/subagent-wiring.ts +3 -3
  133. package/src/index.ts +1 -1
  134. package/src/middleware/__tests__/path-normalization.test.ts +32 -32
  135. package/src/middleware/path-normalization.ts +78 -90
  136. package/src/middleware/types.ts +3 -3
  137. package/src/runner-manager.ts +26 -16
  138. package/src/runner.ts +14 -0
  139. package/src/shared/__tests__/artifact-storage.test.ts +28 -0
  140. package/src/shared/__tests__/bedrock-seam.test.ts +5 -5
  141. package/src/shared/__tests__/declared-preferences.test.ts +109 -0
  142. package/src/shared/__tests__/foundry-seam.test.ts +5 -5
  143. package/src/shared/__tests__/plan-mode-permissions.test.ts +15 -44
  144. package/src/shared/__tests__/skill-mount.test.ts +238 -0
  145. package/src/shared/__tests__/skill-writer.test.ts +151 -128
  146. package/src/shared/__tests__/vertex-adapter.test.ts +30 -3
  147. package/src/shared/__tests__/vertex-seam.test.ts +5 -5
  148. package/src/shared/__tests__/worker-shutdown.test.ts +101 -0
  149. package/src/shared/args-preview.ts +17 -3
  150. package/src/shared/artifact-storage.ts +20 -1
  151. package/src/shared/caller-identity.ts +10 -7
  152. package/src/shared/declared-preferences.ts +84 -0
  153. package/src/shared/mcp-enabled-tools.ts +2 -3
  154. package/src/shared/mcp-resolver.ts +20 -20
  155. package/src/shared/placeholder-resolver.ts +9 -2
  156. package/src/shared/plan-mode-permissions.ts +27 -58
  157. package/src/shared/skill-mount.ts +179 -0
  158. package/src/shared/skill-writer.ts +96 -130
  159. package/src/shared/thinking-mode.ts +52 -0
  160. package/src/shared/worker-shutdown.ts +99 -0
  161. package/src/shared/workspace/types.ts +3 -2
  162. package/src/workflow-engine/__tests__/loader.test.ts +95 -0
  163. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +156 -0
  164. package/src/workflow-engine/loader.ts +97 -6
  165. package/src/workflow-engine/tasks/human-input.ts +41 -6
  166. package/src/workflow-engine/types.ts +18 -3
  167. package/src/workflows/human-input-orchestrator.ts +20 -2
  168. package/dist/activities/execute-cursor/connect-backfill.d.ts +0 -19
  169. package/dist/activities/execute-cursor/connect-backfill.js +0 -27
  170. package/dist/activities/execute-cursor/connect-backfill.js.map +0 -1
  171. package/dist/activities/execute-cursor/mcp-resolver.d.ts +0 -120
  172. package/dist/activities/execute-cursor/mcp-resolver.js +0 -194
  173. package/dist/activities/execute-cursor/mcp-resolver.js.map +0 -1
  174. package/dist/activities/execute-cursor/placeholder-resolver.d.ts +0 -34
  175. package/dist/activities/execute-cursor/placeholder-resolver.js +0 -82
  176. package/dist/activities/execute-cursor/placeholder-resolver.js.map +0 -1
  177. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +0 -125
  178. package/src/activities/execute-cursor/connect-backfill.ts +0 -52
  179. package/src/activities/execute-cursor/mcp-resolver.ts +0 -324
  180. package/src/activities/execute-cursor/placeholder-resolver.ts +0 -109
@@ -0,0 +1,238 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, readFileSync, existsSync, rmSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { ConnectError, Code } from "@connectrpc/connect";
6
+ import {
7
+ MOUNT_MARKER_FILE,
8
+ mountIsFresh,
9
+ downloadArtifact,
10
+ writeSkillMount,
11
+ } from "../skill-mount.js";
12
+ import { buildZip } from "../../__test-utils__/zip-fixtures.js";
13
+
14
+ // ─── Helpers ─────────────────────────────────────────────────────────────
15
+
16
+ function makeSkillProto(overrides: {
17
+ name?: string;
18
+ skillMd?: string;
19
+ artifactStorageKey?: string;
20
+ versionHash?: string;
21
+ } = {}) {
22
+ return {
23
+ metadata: {
24
+ id: `skill-${overrides.name ?? "test"}`,
25
+ slug: overrides.name ?? "test-skill",
26
+ org: "test-org",
27
+ },
28
+ spec: {
29
+ name: overrides.name ?? "test-skill",
30
+ description: "A test skill",
31
+ skillMd: overrides.skillMd ?? "# Test Skill",
32
+ },
33
+ status: {
34
+ artifactStorageKey: overrides.artifactStorageKey ?? "",
35
+ versionHash: overrides.versionHash ?? "abc",
36
+ },
37
+ } as any;
38
+ }
39
+
40
+ // ─── writeSkillMount + mountIsFresh — marker mechanics ───────────────────
41
+
42
+ describe("writeSkillMount — marker mechanics", () => {
43
+ let skillDir: string;
44
+
45
+ beforeEach(() => {
46
+ skillDir = join(mkdtempSync(join(tmpdir(), "skill-mount-")), "test-skill");
47
+ });
48
+
49
+ afterEach(() => {
50
+ rmSync(join(skillDir, ".."), { recursive: true, force: true });
51
+ });
52
+
53
+ it("stamps a marker recording the hash and a fully mounted artifact", async () => {
54
+ const artifact = buildZip([{ name: "references/guide.md", content: "guide" }]);
55
+ await writeSkillMount(makeSkillProto({ versionHash: "hash-v1" }), skillDir, artifact);
56
+
57
+ const marker = JSON.parse(readFileSync(join(skillDir, MOUNT_MARKER_FILE), "utf-8"));
58
+ expect(marker).toEqual({ versionHash: "hash-v1", artifactMounted: true });
59
+ expect(await mountIsFresh(skillDir, "hash-v1", true)).toBe(true);
60
+ });
61
+
62
+ it("records a degraded SKILL.md-only mount so the next execution retries the artifact", async () => {
63
+ await writeSkillMount(makeSkillProto({ versionHash: "hash-v1" }), skillDir, undefined);
64
+
65
+ const marker = JSON.parse(readFileSync(join(skillDir, MOUNT_MARKER_FILE), "utf-8"));
66
+ expect(marker).toEqual({ versionHash: "hash-v1", artifactMounted: false });
67
+ // Stale for a skill that carries an artifact (retry the download)...
68
+ expect(await mountIsFresh(skillDir, "hash-v1", true)).toBe(false);
69
+ // ...fresh for a skill that never had one.
70
+ expect(await mountIsFresh(skillDir, "hash-v1", false)).toBe(true);
71
+ });
72
+
73
+ it("stamps no marker when the skill has no version hash — such mounts never cache", async () => {
74
+ await writeSkillMount(makeSkillProto({ versionHash: "" }), skillDir, undefined);
75
+
76
+ expect(existsSync(join(skillDir, MOUNT_MARKER_FILE))).toBe(false);
77
+ expect(await mountIsFresh(skillDir, "", false)).toBe(false);
78
+ });
79
+
80
+ it("is stale for a different hash", async () => {
81
+ await writeSkillMount(makeSkillProto({ versionHash: "hash-v1" }), skillDir, undefined);
82
+ expect(await mountIsFresh(skillDir, "hash-v2", false)).toBe(false);
83
+ });
84
+
85
+ it("is stale when the marker is missing or unparseable", async () => {
86
+ expect(await mountIsFresh(skillDir, "hash-v1", false)).toBe(false);
87
+ });
88
+
89
+ it("removes files from a previous mount that the new version no longer carries", async () => {
90
+ const v1 = buildZip([{ name: "references/removed-in-v2.md", content: "old" }]);
91
+ const v2 = buildZip([{ name: "references/new-in-v2.md", content: "new" }]);
92
+
93
+ await writeSkillMount(makeSkillProto({ versionHash: "hash-v1" }), skillDir, v1);
94
+ await writeSkillMount(makeSkillProto({ versionHash: "hash-v2" }), skillDir, v2);
95
+
96
+ expect(existsSync(join(skillDir, "references", "removed-in-v2.md"))).toBe(false);
97
+ expect(readFileSync(join(skillDir, "references", "new-in-v2.md"), "utf-8")).toBe("new");
98
+ });
99
+
100
+ it("owns SKILL.md and the marker name unconditionally — zip copies are ignored", async () => {
101
+ const artifact = buildZip([
102
+ { name: "SKILL.md", content: "# Stale zip copy" },
103
+ { name: MOUNT_MARKER_FILE, content: "{\"versionHash\":\"forged\"}" },
104
+ ]);
105
+ await writeSkillMount(
106
+ makeSkillProto({ skillMd: "# Authoritative", versionHash: "hash-v1" }),
107
+ skillDir,
108
+ artifact,
109
+ );
110
+
111
+ expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toBe("# Authoritative");
112
+ const marker = JSON.parse(readFileSync(join(skillDir, MOUNT_MARKER_FILE), "utf-8"));
113
+ expect(marker.versionHash).toBe("hash-v1");
114
+ });
115
+
116
+ it("rejects zip entries that escape the mount directory", async () => {
117
+ const artifact = buildZip([{ name: "../escape.txt", content: "evil" }]);
118
+
119
+ await expect(
120
+ writeSkillMount(makeSkillProto({ versionHash: "hash-v1" }), skillDir, artifact),
121
+ ).rejects.toThrow(/escapes its mount directory/);
122
+ expect(existsSync(join(skillDir, "..", "escape.txt"))).toBe(false);
123
+ // The interrupted mount must not have been stamped as complete.
124
+ expect(existsSync(join(skillDir, MOUNT_MARKER_FILE))).toBe(false);
125
+ });
126
+
127
+ it("writes script entries with the executable bit set", async () => {
128
+ const artifact = buildZip([
129
+ { name: "scripts/run.py", content: "print('hi')" },
130
+ { name: "references/notes.md", content: "notes" },
131
+ ]);
132
+ await writeSkillMount(makeSkillProto({ versionHash: "hash-v1" }), skillDir, artifact);
133
+
134
+ expect(statSync(join(skillDir, "scripts", "run.py")).mode & 0o111).not.toBe(0);
135
+ expect(statSync(join(skillDir, "references", "notes.md")).mode & 0o111).toBe(0);
136
+ });
137
+ });
138
+
139
+ // ─── downloadArtifact — transfer lane routing (#675) ─────────────────────
140
+
141
+ /** A server that predates the transfer lane (#675) answers the mint RPC
142
+ * with UNIMPLEMENTED — the default posture for these tests, which keeps
143
+ * every pre-existing case pinned to the unary getSkillArtifact fallback. */
144
+ function unimplementedMint() {
145
+ return vi.fn().mockRejectedValue(new ConnectError("unimplemented", Code.Unimplemented));
146
+ }
147
+
148
+ describe("downloadArtifact — transfer lane routing", () => {
149
+ afterEach(() => {
150
+ vi.unstubAllGlobals();
151
+ });
152
+
153
+ function makeClient(overrides: Record<string, any> = {}) {
154
+ return {
155
+ getSkillArtifact: vi.fn().mockResolvedValue({ artifact: new Uint8Array(0) }),
156
+ getSkillArtifactDownloadUrl: unimplementedMint(),
157
+ ...overrides,
158
+ } as any;
159
+ }
160
+
161
+ it("fetches bytes over HTTP when the server mints a download URL", async () => {
162
+ const bytes = new Uint8Array([1, 2, 3, 4, 5]);
163
+ const client = makeClient({
164
+ getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
165
+ url: "http://localhost:7234/v1/skill-artifacts/skills/abc.zip",
166
+ sizeBytes: 5n,
167
+ ttlSeconds: 0,
168
+ }),
169
+ });
170
+ const fetchMock = vi.fn().mockResolvedValue({
171
+ ok: true,
172
+ arrayBuffer: async () => bytes.buffer,
173
+ });
174
+ vi.stubGlobal("fetch", fetchMock);
175
+
176
+ const got = await downloadArtifact(client, "skills/abc.zip");
177
+
178
+ expect(got).toEqual(bytes);
179
+ expect(fetchMock).toHaveBeenCalledWith("http://localhost:7234/v1/skill-artifacts/skills/abc.zip");
180
+ // The unary lane (10MB-capped) must not be touched when the URL lane works.
181
+ expect(client.getSkillArtifact).not.toHaveBeenCalled();
182
+ });
183
+
184
+ it("falls back to the unary RPC when the server predates the lane", async () => {
185
+ const bytes = new Uint8Array([9, 9]);
186
+ const client = makeClient({
187
+ getSkillArtifact: vi.fn().mockResolvedValue({ artifact: bytes }),
188
+ });
189
+ vi.stubGlobal("fetch", vi.fn()); // must never be called
190
+
191
+ const got = await downloadArtifact(client, "skills/abc.zip");
192
+
193
+ expect(got).toEqual(bytes);
194
+ expect(client.getSkillArtifact).toHaveBeenCalledWith("skills/abc.zip");
195
+ expect(fetch).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it("does NOT fall back on non-Unimplemented mint failures", async () => {
199
+ const client = makeClient({
200
+ getSkillArtifactDownloadUrl: vi.fn().mockRejectedValue(
201
+ new ConnectError("boom", Code.Internal),
202
+ ),
203
+ });
204
+
205
+ await expect(downloadArtifact(client, "skills/abc.zip")).rejects.toThrow("boom");
206
+ // Falling back here would mask real server faults behind the capped lane.
207
+ expect(client.getSkillArtifact).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it("rejects truncated fetches via the minted size", async () => {
211
+ const client = makeClient({
212
+ getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
213
+ url: "http://localhost:7234/v1/skill-artifacts/skills/abc.zip",
214
+ sizeBytes: 100n,
215
+ ttlSeconds: 0,
216
+ }),
217
+ });
218
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
219
+ ok: true,
220
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
221
+ }));
222
+
223
+ await expect(downloadArtifact(client, "skills/abc.zip")).rejects.toThrow(/truncated/);
224
+ });
225
+
226
+ it("surfaces HTTP failures with the status code", async () => {
227
+ const client = makeClient({
228
+ getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
229
+ url: "http://localhost:7234/v1/skill-artifacts/skills/gone.zip",
230
+ sizeBytes: 0n,
231
+ ttlSeconds: 0,
232
+ }),
233
+ });
234
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 }));
235
+
236
+ await expect(downloadArtifact(client, "skills/gone.zip")).rejects.toThrow(/HTTP 404/);
237
+ });
238
+ });
@@ -1,17 +1,18 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, readFileSync, existsSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { ConnectError, Code } from "@connectrpc/connect";
2
6
  import {
3
7
  mergeSkillRefs,
4
8
  fetchSkillsByRefs,
5
- writeSkills,
6
- computeSkillPaths,
7
- checkSkillIntegrity,
9
+ mountSkills,
8
10
  generatePromptSection,
9
11
  generateAlsoAvailableSection,
10
- fetchSkillArtifacts,
11
12
  } from "../skill-writer.js";
12
13
  import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
13
14
  import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
14
- import type { WorkspaceBackend } from "../workspace/types.js";
15
+ import { buildZip } from "../../__test-utils__/zip-fixtures.js";
15
16
 
16
17
  // ─── Helpers ─────────────────────────────────────────────────────────────
17
18
 
@@ -19,16 +20,18 @@ function makeSkill(overrides: {
19
20
  id?: string;
20
21
  name?: string;
21
22
  slug?: string;
23
+ org?: string;
22
24
  description?: string;
23
25
  skillMd?: string;
24
26
  artifactStorageKey?: string;
27
+ versionHash?: string;
25
28
  } = {}): Skill {
26
29
  return {
27
30
  metadata: {
28
31
  id: overrides.id ?? "skill-id-1",
29
32
  name: overrides.name ?? overrides.slug ?? "test-skill",
30
33
  slug: overrides.slug ?? "test-skill",
31
- org: "test-org",
34
+ org: overrides.org ?? "test-org",
32
35
  },
33
36
  spec: {
34
37
  name: overrides.name ?? "test-skill",
@@ -38,7 +41,7 @@ function makeSkill(overrides: {
38
41
  },
39
42
  status: {
40
43
  artifactStorageKey: overrides.artifactStorageKey ?? "",
41
- versionHash: "abc123",
44
+ versionHash: overrides.versionHash ?? "abc123",
42
45
  state: "active",
43
46
  },
44
47
  } as any;
@@ -48,48 +51,21 @@ function makeRef(slug: string, org = "test-org"): ApiResourceReference {
48
51
  return { slug, org, kind: 43 } as any;
49
52
  }
50
53
 
54
+ /** A server that predates the transfer lane (#675) answers the mint RPC
55
+ * with UNIMPLEMENTED, pinning these tests to the unary fallback. */
51
56
  function makeMockClient(overrides: Record<string, unknown> = {}) {
52
57
  return {
53
58
  getSkillByReference: vi.fn().mockImplementation((ref: any) =>
54
59
  Promise.resolve(makeSkill({ slug: ref.slug, name: ref.slug })),
55
60
  ),
56
61
  getSkillArtifact: vi.fn().mockResolvedValue({ artifact: new Uint8Array(0) }),
62
+ getSkillArtifactDownloadUrl: vi.fn().mockRejectedValue(
63
+ new ConnectError("unimplemented", Code.Unimplemented),
64
+ ),
57
65
  ...overrides,
58
66
  } as any;
59
67
  }
60
68
 
61
- function makeMockBackend(): WorkspaceBackend & {
62
- writtenFiles: Map<string, string>;
63
- executedCommands: string[];
64
- existingFiles: Set<string>;
65
- } {
66
- const writtenFiles = new Map<string, string>();
67
- const executedCommands: string[] = [];
68
- const existingFiles = new Set<string>();
69
- return {
70
- rootDir: "/workspace",
71
- writtenFiles,
72
- executedCommands,
73
- existingFiles,
74
- async writeFile(path: string, content: string) {
75
- writtenFiles.set(path, content);
76
- },
77
- async writeFileBuffer(path: string, content: Buffer) {
78
- writtenFiles.set(path, content.toString("utf-8"));
79
- },
80
- async readFile(path: string) {
81
- return writtenFiles.get(path) ?? "";
82
- },
83
- async exists(path: string) {
84
- return writtenFiles.has(path) || existingFiles.has(path);
85
- },
86
- async execute(command: string) {
87
- executedCommands.push(command);
88
- return "";
89
- },
90
- };
91
- }
92
-
93
69
  // ─── mergeSkillRefs ──────────────────────────────────────────────────────
94
70
 
95
71
  describe("mergeSkillRefs", () => {
@@ -151,86 +127,171 @@ describe("fetchSkillsByRefs", () => {
151
127
  });
152
128
  });
153
129
 
154
- // ─── writeSkills ─────────────────────────────────────────────────────────
130
+ // ─── mountSkills ─────────────────────────────────────────────────────────
131
+
132
+ describe("mountSkills", () => {
133
+ let platformDir: string;
134
+
135
+ beforeEach(() => {
136
+ platformDir = mkdtempSync(join(tmpdir(), "skill-writer-platform-"));
137
+ });
155
138
 
156
- describe("writeSkills", () => {
157
- it("writes SKILL.md from spec when no artifact", async () => {
158
- const backend = makeMockBackend();
139
+ afterEach(() => {
140
+ rmSync(platformDir, { recursive: true, force: true });
141
+ });
142
+
143
+ it("writes SKILL.md and returns agent-visible paths keyed by skill id", async () => {
144
+ const client = makeMockClient();
159
145
  const skills = [makeSkill({ name: "calculator", skillMd: "# Calculator\n\nAdds numbers." })];
160
- const result = await writeSkills(skills, backend, new Map());
146
+ const result = await mountSkills(client, skills, platformDir);
161
147
 
162
148
  expect(result.paths.get("skill-id-1")).toBe(".stigmer/skills/calculator");
163
- expect(backend.writtenFiles.has(".stigmer/skills/calculator/SKILL.md")).toBe(true);
164
- expect(backend.writtenFiles.get(".stigmer/skills/calculator/SKILL.md"))
149
+ expect(readFileSync(join(platformDir, "skills", "calculator", "SKILL.md"), "utf-8"))
165
150
  .toBe("# Calculator\n\nAdds numbers.");
166
151
  });
167
152
 
168
- it("runs chmod on script extensions", async () => {
169
- const backend = makeMockBackend();
170
- const skills = [makeSkill({ name: "runner" })];
171
- await writeSkills(skills, backend, new Map());
172
- expect(backend.executedCommands.length).toBeGreaterThan(0);
173
- expect(backend.executedCommands[0]).toContain("chmod");
153
+ it("extracts artifact files alongside SKILL.md", async () => {
154
+ const artifact = buildZip([
155
+ { name: "SKILL.md", content: "# Stale zip copy" },
156
+ { name: "references/schema.md", content: "schema" },
157
+ ]);
158
+ const client = makeMockClient({
159
+ getSkillArtifact: vi.fn().mockResolvedValue({ artifact }),
160
+ });
161
+ const skills = [makeSkill({
162
+ name: "db-skill",
163
+ skillMd: "# Authoritative",
164
+ artifactStorageKey: "artifacts/db.zip",
165
+ })];
166
+ await mountSkills(client, skills, platformDir);
167
+
168
+ const skillDir = join(platformDir, "skills", "db-skill");
169
+ expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toBe("# Authoritative");
170
+ expect(readFileSync(join(skillDir, "references", "schema.md"), "utf-8")).toBe("schema");
174
171
  });
175
172
 
176
173
  it("handles multiple skills", async () => {
177
- const backend = makeMockBackend();
174
+ const client = makeMockClient();
178
175
  const skills = [
179
176
  makeSkill({ id: "id-1", name: "skill-a", skillMd: "# A" }),
180
177
  makeSkill({ id: "id-2", name: "skill-b", skillMd: "# B" }),
181
178
  ];
182
- const result = await writeSkills(skills, backend, new Map());
179
+ const result = await mountSkills(client, skills, platformDir);
183
180
  expect(result.paths.size).toBe(2);
184
181
  expect(result.paths.get("id-1")).toBe(".stigmer/skills/skill-a");
185
182
  expect(result.paths.get("id-2")).toBe(".stigmer/skills/skill-b");
183
+ expect(existsSync(join(platformDir, "skills", "skill-a", "SKILL.md"))).toBe(true);
184
+ expect(existsSync(join(platformDir, "skills", "skill-b", "SKILL.md"))).toBe(true);
186
185
  });
187
186
 
188
- it("skips write when skill has no skillMd and no artifact", async () => {
189
- const backend = makeMockBackend();
190
- const skills = [makeSkill({ skillMd: "" })];
191
- (skills[0] as any).spec.skillMd = "";
192
- await writeSkills(skills, backend, new Map());
193
- expect(backend.writtenFiles.size).toBe(0);
194
- });
195
- });
187
+ it("skips the artifact download when the mounted hash matches (cache hit)", async () => {
188
+ const artifact = buildZip([{ name: "references/guide.md", content: "guide" }]);
189
+ const client = makeMockClient({
190
+ getSkillArtifact: vi.fn().mockResolvedValue({ artifact }),
191
+ });
192
+ const skill = makeSkill({
193
+ name: "cached-skill",
194
+ artifactStorageKey: "artifacts/cached.zip",
195
+ versionHash: "hash-v1",
196
+ });
196
197
 
197
- // ─── computeSkillPaths ───────────────────────────────────────────────────
198
+ await mountSkills(client, [skill], platformDir);
199
+ expect(client.getSkillArtifact).toHaveBeenCalledTimes(1);
198
200
 
199
- describe("computeSkillPaths", () => {
200
- it("computes paths without side effects", () => {
201
- const skills = [
202
- makeSkill({ id: "id-1", name: "calculator" }),
203
- makeSkill({ id: "id-2", name: "web-scraper" }),
204
- ];
205
- const paths = computeSkillPaths(skills);
206
- expect(paths.get("id-1")).toBe(".stigmer/skills/calculator");
207
- expect(paths.get("id-2")).toBe(".stigmer/skills/web-scraper");
201
+ // Second execution of the same session: same hash, no transfer.
202
+ const second = await mountSkills(client, [skill], platformDir);
203
+ expect(client.getSkillArtifact).toHaveBeenCalledTimes(1);
204
+ expect(second.paths.get("skill-id-1")).toBe(".stigmer/skills/cached-skill");
205
+ expect(readFileSync(
206
+ join(platformDir, "skills", "cached-skill", "references", "guide.md"), "utf-8",
207
+ )).toBe("guide");
208
208
  });
209
209
 
210
- it("returns empty map for empty skills", () => {
211
- expect(computeSkillPaths([])).toEqual(new Map());
210
+ it("remounts on version change and clears stale files from the old mount", async () => {
211
+ const artifactV1 = buildZip([{ name: "references/removed-in-v2.md", content: "old" }]);
212
+ const artifactV2 = buildZip([{ name: "references/new-in-v2.md", content: "new" }]);
213
+ const client = makeMockClient({
214
+ getSkillArtifact: vi.fn()
215
+ .mockResolvedValueOnce({ artifact: artifactV1 })
216
+ .mockResolvedValueOnce({ artifact: artifactV2 }),
217
+ });
218
+
219
+ await mountSkills(client, [makeSkill({
220
+ name: "evolving-skill",
221
+ skillMd: "# V1",
222
+ artifactStorageKey: "artifacts/v1.zip",
223
+ versionHash: "hash-v1",
224
+ })], platformDir);
225
+
226
+ await mountSkills(client, [makeSkill({
227
+ name: "evolving-skill",
228
+ skillMd: "# V2",
229
+ artifactStorageKey: "artifacts/v2.zip",
230
+ versionHash: "hash-v2",
231
+ })], platformDir);
232
+
233
+ const skillDir = join(platformDir, "skills", "evolving-skill");
234
+ expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
235
+ expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toBe("# V2");
236
+ expect(readFileSync(join(skillDir, "references", "new-in-v2.md"), "utf-8")).toBe("new");
237
+ // The v1-only file must not linger in the v2 mount (the stale-file leak).
238
+ expect(existsSync(join(skillDir, "references", "removed-in-v2.md"))).toBe(false);
212
239
  });
213
- });
214
240
 
215
- // ─── checkSkillIntegrity ─────────────────────────────────────────────────
241
+ it("does not cache a degraded SKILL.md-only mount — the next execution retries", async () => {
242
+ const artifact = buildZip([{ name: "references/late.md", content: "finally" }]);
243
+ const client = makeMockClient({
244
+ getSkillArtifact: vi.fn()
245
+ .mockRejectedValueOnce(new Error("Network timeout"))
246
+ .mockResolvedValueOnce({ artifact }),
247
+ });
248
+ const skill = makeSkill({
249
+ name: "retry-skill",
250
+ artifactStorageKey: "artifacts/retry.zip",
251
+ versionHash: "hash-v1",
252
+ });
216
253
 
217
- describe("checkSkillIntegrity", () => {
218
- it("returns true when sentinel exists", async () => {
219
- const backend = makeMockBackend();
220
- backend.existingFiles.add(".stigmer/skills/calculator/SKILL.md");
221
- const skills = [makeSkill({ id: "id-1", name: "calculator" })];
222
- expect(await checkSkillIntegrity(skills, backend)).toBe(true);
254
+ // First pass degrades to SKILL.md only — never throws.
255
+ const first = await mountSkills(client, [skill], platformDir);
256
+ const skillDir = join(platformDir, "skills", "retry-skill");
257
+ expect(first.paths.get("skill-id-1")).toBe(".stigmer/skills/retry-skill");
258
+ expect(existsSync(join(skillDir, "SKILL.md"))).toBe(true);
259
+ expect(existsSync(join(skillDir, "references"))).toBe(false);
260
+
261
+ // Second pass retries and completes the mount.
262
+ await mountSkills(client, [skill], platformDir);
263
+ expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
264
+ expect(readFileSync(join(skillDir, "references", "late.md"), "utf-8")).toBe("finally");
265
+
266
+ // Third pass is a cache hit.
267
+ await mountSkills(client, [skill], platformDir);
268
+ expect(client.getSkillArtifact).toHaveBeenCalledTimes(2);
223
269
  });
224
270
 
225
- it("returns false when sentinel is missing", async () => {
226
- const backend = makeMockBackend();
227
- const skills = [makeSkill({ id: "id-1", name: "calculator" })];
228
- expect(await checkSkillIntegrity(skills, backend)).toBe(false);
271
+ it("mounts only the first claimant when two skills resolve to the same directory", async () => {
272
+ const client = makeMockClient();
273
+ const skills = [
274
+ makeSkill({ id: "id-1", org: "org-a", slug: "shared-a", name: "shared-name", skillMd: "# First" }),
275
+ makeSkill({ id: "id-2", org: "org-b", slug: "shared-b", name: "shared-name", skillMd: "# Second" }),
276
+ ];
277
+ const result = await mountSkills(client, skills, platformDir);
278
+
279
+ // Both ids resolve to the (single) directory; the first claimant's content wins.
280
+ expect(result.paths.get("id-1")).toBe(".stigmer/skills/shared-name");
281
+ expect(result.paths.get("id-2")).toBe(".stigmer/skills/shared-name");
282
+ expect(readFileSync(join(platformDir, "skills", "shared-name", "SKILL.md"), "utf-8"))
283
+ .toBe("# First");
229
284
  });
230
285
 
231
- it("returns true for empty skills list", async () => {
232
- const backend = makeMockBackend();
233
- expect(await checkSkillIntegrity([], backend)).toBe(true);
286
+ it("skips skills with no skillMd content", async () => {
287
+ const client = makeMockClient();
288
+ const skill = makeSkill({ name: "broken", skillMd: "" });
289
+ (skill as any).spec.skillMd = "";
290
+ const result = await mountSkills(client, [skill], platformDir);
291
+
292
+ // The path entry exists (naming is total) but nothing was mounted.
293
+ expect(result.paths.get("skill-id-1")).toBe(".stigmer/skills/broken");
294
+ expect(existsSync(join(platformDir, "skills", "broken"))).toBe(false);
234
295
  });
235
296
  });
236
297
 
@@ -309,41 +370,3 @@ describe("generateAlsoAvailableSection", () => {
309
370
  expect(section).toContain("relevant to your task");
310
371
  });
311
372
  });
312
-
313
- // ─── fetchSkillArtifacts ─────────────────────────────────────────────────
314
-
315
- describe("fetchSkillArtifacts", () => {
316
- it("returns empty map when no skills have artifacts", async () => {
317
- const client = makeMockClient();
318
- const skills = [makeSkill({ artifactStorageKey: "" })];
319
- const result = await fetchSkillArtifacts(client, skills);
320
- expect(result.size).toBe(0);
321
- expect(client.getSkillArtifact).not.toHaveBeenCalled();
322
- });
323
-
324
- it("fetches artifacts for skills with storage keys", async () => {
325
- const artifactData = new Uint8Array([80, 75, 3, 4]); // ZIP magic bytes
326
- const client = makeMockClient({
327
- getSkillArtifact: vi.fn().mockResolvedValue({ artifact: artifactData }),
328
- });
329
- const skills = [makeSkill({ id: "id-1", artifactStorageKey: "artifacts/key.zip" })];
330
- const result = await fetchSkillArtifacts(client, skills);
331
- expect(result.size).toBe(1);
332
- expect(result.get("id-1")).toEqual(artifactData);
333
- });
334
-
335
- it("handles partial failures gracefully", async () => {
336
- const client = makeMockClient({
337
- getSkillArtifact: vi.fn()
338
- .mockResolvedValueOnce({ artifact: new Uint8Array([1, 2, 3]) })
339
- .mockRejectedValueOnce(new Error("Network error")),
340
- });
341
- const skills = [
342
- makeSkill({ id: "id-1", name: "good", artifactStorageKey: "key1" }),
343
- makeSkill({ id: "id-2", name: "bad", artifactStorageKey: "key2" }),
344
- ];
345
- const result = await fetchSkillArtifacts(client, skills);
346
- expect(result.size).toBe(1);
347
- expect(result.has("id-1")).toBe(true);
348
- });
349
- });
@@ -123,9 +123,9 @@ describe("buildChatModel vertex adapter", () => {
123
123
  // default applies. That default prefix-matches the model string — and the
124
124
  // vertex branch hands ChatAnthropic the TRANSLATED string. This pins the
125
125
  // equality the zero-behavior-change criterion rests on (verified 16384 ==
126
- // 16384 for 4.5-generation ids at @langchain/anthropic 1.4.0); a future
127
- // bump that diverges the two forms fails here instead of silently capping
128
- // vertex deployments differently from public ones.
126
+ // 16384 for 4.5-generation ids at @langchain/anthropic 1.4.0, re-verified
127
+ // at 1.5.5); a future bump that diverges the two forms fails here instead
128
+ // of silently capping vertex deployments differently from public ones.
129
129
  for (const canonical of [
130
130
  "claude-sonnet-4-5-20250929",
131
131
  "claude-haiku-4-5-20251001",
@@ -137,6 +137,33 @@ describe("buildChatModel vertex adapter", () => {
137
137
  }
138
138
  });
139
139
 
140
+ it("model.profile goes empty exactly when translation rewrites the id", () => {
141
+ // ChatAnthropic.profile resolves per model name, and — unlike the
142
+ // maxTokens default above — its lookup does NOT prefix-match: a dated
143
+ // id rewritten to Vertex's `@` form resolves {} (verified at
144
+ // @langchain/anthropic 1.4.0 and again at 1.5.5), while undated ids
145
+ // pass through toVertexModelId unchanged and keep their full profile.
146
+ // Nothing in the runner consumes profile directly, but deepagents'
147
+ // SummarizationMiddleware reads profile.maxInputTokens when present, so
148
+ // vertex deployments pinned to DATED ids summarize on its fallback
149
+ // budget rather than the model's real window. This pins both halves of
150
+ // that contract: if a bump starts resolving `@`-form ids (a welcome
151
+ // improvement — those deployments' summarization budgets would change),
152
+ // or stops resolving canonical ids, this fails and the behavior gets
153
+ // re-decided consciously instead of shifting silently.
154
+ for (const dated of ["claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001"]) {
155
+ const publicModel = new ChatAnthropic({ model: dated, apiKey: "probe" });
156
+ const vertexModel = new ChatAnthropic({ model: toVertexModelId(dated), apiKey: "probe" });
157
+ expect(publicModel.profile.maxInputTokens, dated).toBeGreaterThan(0);
158
+ expect(vertexModel.profile, dated).toEqual({});
159
+ }
160
+ const undated = "claude-sonnet-4-6";
161
+ expect(toVertexModelId(undated)).toBe(undated);
162
+ expect(
163
+ new ChatAnthropic({ model: undated, apiKey: "probe" }).profile.maxInputTokens,
164
+ ).toBeGreaterThan(0);
165
+ });
166
+
140
167
  it("forwards the request timeout to the SDK client (STIGMER_LLM_REQUEST_TIMEOUT_MS path)", async () => {
141
168
  // The timeout rides clientOptions -> factory options -> SDK constructor.
142
169
  // A factory that drops it silently unbounds the operator's timeout on