@stigmer/runner 3.11.0 → 3.12.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 (239) hide show
  1. package/README.md +2 -0
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/discover-mcp-server.d.ts +7 -0
  4. package/dist/activities/discover-mcp-server.js +6 -4
  5. package/dist/activities/discover-mcp-server.js.map +1 -1
  6. package/dist/activities/emit-event.d.ts +14 -2
  7. package/dist/activities/emit-event.js +52 -17
  8. package/dist/activities/emit-event.js.map +1 -1
  9. package/dist/activities/execute-cursor/attachment-resolver.d.ts +11 -0
  10. package/dist/activities/execute-cursor/attachment-resolver.js +8 -0
  11. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  12. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  13. package/dist/activities/execute-cursor/blueprint-resolver.js +0 -1
  14. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  15. package/dist/activities/execute-cursor/error-classifier.d.ts +19 -0
  16. package/dist/activities/execute-cursor/error-classifier.js +34 -3
  17. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  18. package/dist/activities/execute-cursor/index.d.ts +52 -12
  19. package/dist/activities/execute-cursor/index.js +141 -67
  20. package/dist/activities/execute-cursor/index.js.map +1 -1
  21. package/dist/activities/execute-cursor/model-pricing-data.d.ts +2 -0
  22. package/dist/activities/execute-cursor/model-pricing-data.js +13 -3
  23. package/dist/activities/execute-cursor/model-pricing-data.js.map +1 -1
  24. package/dist/activities/execute-cursor/prompt-builder.d.ts +48 -11
  25. package/dist/activities/execute-cursor/prompt-builder.js +57 -10
  26. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  27. package/dist/activities/execute-cursor/turn-recovery.d.ts +52 -0
  28. package/dist/activities/execute-cursor/turn-recovery.js +193 -0
  29. package/dist/activities/execute-cursor/turn-recovery.js.map +1 -0
  30. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +11 -0
  31. package/dist/activities/execute-deep-agent/attachment-injector.js +7 -1
  32. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +0 -15
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/mcp-gate.d.ts +0 -2
  36. package/dist/activities/execute-deep-agent/mcp-gate.js +0 -1
  37. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -8
  39. package/dist/activities/execute-deep-agent/prompt-builder.js +14 -7
  40. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  41. package/dist/activities/execute-deep-agent/setup.d.ts +0 -10
  42. package/dist/activities/execute-deep-agent/setup.js +22 -49
  43. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  44. package/dist/activities/execute-deep-agent/shell-env.d.ts +5 -1
  45. package/dist/activities/execute-deep-agent/shell-env.js +7 -9
  46. package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
  47. package/dist/activities/execute-deep-agent/subagent-transformer.js +7 -1
  48. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  49. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +17 -3
  50. package/dist/activities/execute-deep-agent/subagent-wiring.js +14 -2
  51. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  52. package/dist/activities/hydrate-workflow-execution.js +8 -5
  53. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  54. package/dist/activities/run-command.d.ts +5 -2
  55. package/dist/activities/run-command.js +22 -10
  56. package/dist/activities/run-command.js.map +1 -1
  57. package/dist/activities/run-env.d.ts +40 -0
  58. package/dist/activities/run-env.js +66 -0
  59. package/dist/activities/run-env.js.map +1 -0
  60. package/dist/bootstrap.js +5 -0
  61. package/dist/bootstrap.js.map +1 -1
  62. package/dist/client/stigmer-client.d.ts +83 -26
  63. package/dist/client/stigmer-client.js +88 -24
  64. package/dist/client/stigmer-client.js.map +1 -1
  65. package/dist/config.d.ts +6 -5
  66. package/dist/config.js.map +1 -1
  67. package/dist/encryption/config.d.ts +41 -9
  68. package/dist/encryption/config.js +63 -23
  69. package/dist/encryption/config.js.map +1 -1
  70. package/dist/middleware/index.d.ts +3 -0
  71. package/dist/middleware/index.js +7 -0
  72. package/dist/middleware/index.js.map +1 -1
  73. package/dist/middleware/path-normalization.d.ts +57 -0
  74. package/dist/middleware/path-normalization.js +109 -0
  75. package/dist/middleware/path-normalization.js.map +1 -0
  76. package/dist/middleware/types.d.ts +8 -0
  77. package/dist/payload-codecs.d.ts +2 -1
  78. package/dist/payload-codecs.js +7 -3
  79. package/dist/payload-codecs.js.map +1 -1
  80. package/dist/runner-manager.js +17 -3
  81. package/dist/runner-manager.js.map +1 -1
  82. package/dist/runner.js +7 -3
  83. package/dist/runner.js.map +1 -1
  84. package/dist/shared/artifact-storage.d.ts +34 -1
  85. package/dist/shared/artifact-storage.js +53 -10
  86. package/dist/shared/artifact-storage.js.map +1 -1
  87. package/dist/shared/attachment-download-urls.d.ts +62 -0
  88. package/dist/shared/attachment-download-urls.js +79 -0
  89. package/dist/shared/attachment-download-urls.js.map +1 -0
  90. package/dist/shared/channel-attachment.d.ts +1 -1
  91. package/dist/shared/channel-attachment.js +1 -1
  92. package/dist/shared/checkpointer/http-saver.d.ts +4 -20
  93. package/dist/shared/checkpointer/http-saver.js +15 -57
  94. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  95. package/dist/shared/conversation-attachment.d.ts +2 -3
  96. package/dist/shared/conversation-attachment.js +2 -3
  97. package/dist/shared/conversation-attachment.js.map +1 -1
  98. package/dist/shared/conversation-catchup.d.ts +9 -2
  99. package/dist/shared/conversation-catchup.js +39 -6
  100. package/dist/shared/conversation-catchup.js.map +1 -1
  101. package/dist/shared/grpc-retry.d.ts +5 -1
  102. package/dist/shared/grpc-retry.js +5 -1
  103. package/dist/shared/grpc-retry.js.map +1 -1
  104. package/dist/shared/http-retry.d.ts +51 -9
  105. package/dist/shared/http-retry.js +60 -9
  106. package/dist/shared/http-retry.js.map +1 -1
  107. package/dist/shared/mcp-manager.d.ts +8 -4
  108. package/dist/shared/mcp-manager.js +30 -4
  109. package/dist/shared/mcp-manager.js.map +1 -1
  110. package/dist/shared/mcp-schema-sanitizer.d.ts +70 -0
  111. package/dist/shared/mcp-schema-sanitizer.js +197 -0
  112. package/dist/shared/mcp-schema-sanitizer.js.map +1 -0
  113. package/dist/shared/mcp-transport-guard.d.ts +2 -2
  114. package/dist/shared/mcp-transport-guard.js +2 -2
  115. package/dist/shared/model-client.d.ts +6 -0
  116. package/dist/shared/model-client.js +14 -3
  117. package/dist/shared/model-client.js.map +1 -1
  118. package/dist/shared/model-pricing-data.d.ts +2 -0
  119. package/dist/shared/model-pricing-data.js +13 -3
  120. package/dist/shared/model-pricing-data.js.map +1 -1
  121. package/dist/shared/model-registry.js +3 -2
  122. package/dist/shared/model-registry.js.map +1 -1
  123. package/dist/shared/plan-mode-permissions.d.ts +7 -0
  124. package/dist/shared/plan-mode-permissions.js +7 -0
  125. package/dist/shared/plan-mode-permissions.js.map +1 -1
  126. package/dist/shared/registry-endpoint.d.ts +20 -0
  127. package/dist/shared/registry-endpoint.js +25 -0
  128. package/dist/shared/registry-endpoint.js.map +1 -1
  129. package/dist/shared/runner-credential-keys.d.ts +24 -0
  130. package/dist/shared/runner-credential-keys.js +47 -0
  131. package/dist/shared/runner-credential-keys.js.map +1 -0
  132. package/dist/shared/synthesized-attachment.d.ts +4 -4
  133. package/dist/shared/synthesized-attachment.js +4 -4
  134. package/dist/workflow-engine/resolve.d.ts +19 -1
  135. package/dist/workflow-engine/resolve.js +37 -2
  136. package/dist/workflow-engine/resolve.js.map +1 -1
  137. package/dist/workflows/connect-mcp-server.js +2 -0
  138. package/dist/workflows/connect-mcp-server.js.map +1 -1
  139. package/dist/workflows/types.d.ts +8 -0
  140. package/package.json +3 -4
  141. package/src/__test-utils__/__tests__/vitest-global-setup.test.ts +37 -0
  142. package/src/__test-utils__/fake-artifact-storage.ts +10 -1
  143. package/src/__test-utils__/vitest-global-setup.ts +50 -0
  144. package/src/__tests__/bootstrap.test.ts +43 -0
  145. package/src/__tests__/encryption-codec.test.ts +53 -0
  146. package/src/__tests__/preflight.test.ts +15 -0
  147. package/src/activities/__tests__/discover-mcp-server.test.ts +39 -1
  148. package/src/activities/__tests__/error-classifier.test.ts +94 -2
  149. package/src/activities/__tests__/run-command.test.ts +221 -0
  150. package/src/activities/discover-mcp-server.ts +16 -2
  151. package/src/activities/emit-event.ts +65 -21
  152. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +63 -3
  153. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +173 -31
  154. package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +62 -1
  155. package/src/activities/execute-cursor/__tests__/turn-recovery.test.ts +160 -0
  156. package/src/activities/execute-cursor/attachment-resolver.ts +24 -0
  157. package/src/activities/execute-cursor/blueprint-resolver.ts +1 -10
  158. package/src/activities/execute-cursor/error-classifier.ts +40 -3
  159. package/src/activities/execute-cursor/index.ts +175 -74
  160. package/src/activities/execute-cursor/model-pricing-data.ts +22 -3
  161. package/src/activities/execute-cursor/prompt-builder.ts +89 -22
  162. package/src/activities/execute-cursor/turn-recovery.ts +208 -0
  163. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +105 -0
  164. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +0 -2
  165. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +0 -1
  166. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +0 -1
  167. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +2 -7
  168. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +174 -0
  169. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +71 -0
  170. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +0 -1
  171. package/src/activities/execute-deep-agent/__tests__/shell-env.test.ts +24 -10
  172. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +22 -17
  173. package/src/activities/execute-deep-agent/attachment-injector.ts +20 -1
  174. package/src/activities/execute-deep-agent/index.ts +0 -19
  175. package/src/activities/execute-deep-agent/mcp-gate.ts +0 -3
  176. package/src/activities/execute-deep-agent/prompt-builder.ts +27 -15
  177. package/src/activities/execute-deep-agent/setup.ts +22 -73
  178. package/src/activities/execute-deep-agent/shell-env.ts +8 -9
  179. package/src/activities/execute-deep-agent/subagent-transformer.ts +7 -1
  180. package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -3
  181. package/src/activities/hydrate-workflow-execution.ts +8 -5
  182. package/src/activities/run-command.ts +25 -12
  183. package/src/activities/run-env.ts +79 -0
  184. package/src/bootstrap.ts +5 -0
  185. package/src/client/__tests__/stigmer-client.test.ts +104 -5
  186. package/src/client/stigmer-client.ts +128 -29
  187. package/src/config.ts +6 -5
  188. package/src/encryption/config.ts +87 -23
  189. package/src/middleware/__tests__/path-normalization.test.ts +140 -0
  190. package/src/middleware/index.ts +8 -0
  191. package/src/middleware/path-normalization.ts +125 -0
  192. package/src/middleware/types.ts +11 -0
  193. package/src/payload-codecs.ts +8 -2
  194. package/src/runner-manager.ts +21 -3
  195. package/src/runner.ts +7 -3
  196. package/src/shared/__tests__/artifact-storage.test.ts +154 -2
  197. package/src/shared/__tests__/attachment-download-urls.test.ts +70 -0
  198. package/src/shared/__tests__/channel-attachment.test.ts +2 -2
  199. package/src/shared/__tests__/conversation-catchup.test.ts +24 -0
  200. package/src/shared/__tests__/http-retry.test.ts +5 -4
  201. package/src/shared/__tests__/mcp-manager.test.ts +53 -13
  202. package/src/shared/__tests__/mcp-schema-sanitizer.test.ts +267 -0
  203. package/src/shared/__tests__/model-client.test.ts +46 -0
  204. package/src/shared/__tests__/model-pricing.test.ts +62 -2
  205. package/src/shared/__tests__/model-registry.test.ts +22 -0
  206. package/src/shared/__tests__/synthesized-attachment.test.ts +19 -41
  207. package/src/shared/artifact-storage.ts +86 -10
  208. package/src/shared/attachment-download-urls.ts +98 -0
  209. package/src/shared/channel-attachment.ts +1 -1
  210. package/src/shared/checkpointer/http-saver.ts +15 -60
  211. package/src/shared/conversation-attachment.ts +2 -3
  212. package/src/shared/conversation-catchup.ts +39 -6
  213. package/src/shared/grpc-retry.ts +5 -1
  214. package/src/shared/http-retry.ts +98 -9
  215. package/src/shared/mcp-manager.ts +35 -4
  216. package/src/shared/mcp-schema-sanitizer.ts +224 -0
  217. package/src/shared/mcp-transport-guard.ts +2 -2
  218. package/src/shared/model-client.ts +21 -3
  219. package/src/shared/model-pricing-data.ts +22 -3
  220. package/src/shared/model-registry.ts +7 -2
  221. package/src/shared/plan-mode-permissions.ts +7 -0
  222. package/src/shared/registry-endpoint.ts +27 -0
  223. package/src/shared/runner-credential-keys.ts +46 -0
  224. package/src/shared/synthesized-attachment.ts +4 -4
  225. package/src/workflow-engine/__tests__/tasks/emit-event.test.ts +127 -49
  226. package/src/workflow-engine/resolve.ts +48 -2
  227. package/src/workflows/__tests__/connect-mcp-server.test.ts +25 -0
  228. package/src/workflows/connect-mcp-server.ts +2 -0
  229. package/src/workflows/types.ts +8 -0
  230. package/dist/activities/execute-cursor/mcp-config.d.ts +0 -30
  231. package/dist/activities/execute-cursor/mcp-config.js +0 -39
  232. package/dist/activities/execute-cursor/mcp-config.js.map +0 -1
  233. package/dist/shared/datastore-attachment.d.ts +0 -99
  234. package/dist/shared/datastore-attachment.js +0 -184
  235. package/dist/shared/datastore-attachment.js.map +0 -1
  236. package/src/activities/execute-cursor/mcp-config.ts +0 -66
  237. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +0 -104
  238. package/src/shared/__tests__/datastore-attachment.test.ts +0 -293
  239. package/src/shared/datastore-attachment.ts +0 -209
@@ -9,12 +9,24 @@
9
9
  *
10
10
  * Supported delivery targets:
11
11
  * - webhook: HTTP POST with Content-Type: application/cloudevents+json
12
- * - signal: Temporal signal to another running workflow's listen task
12
+ * - signal: signal to another workflow execution's listen task, routed
13
+ * through the server's SendSignal lane
14
+ *
15
+ * Signal delivery is deliberately server-mediated (oss#517): a direct
16
+ * Temporal client here would bypass the authorization boundary (any
17
+ * workflow could signal any workflow id in the namespace) and is
18
+ * structurally incompatible with payload encryption — emit→listen is
19
+ * the platform's only runner-to-runner channel, and under per-identity
20
+ * runner keys a sender-encrypted signal fails closed at a receiver
21
+ * holding a different key. The server re-produces the payload, so each
22
+ * side's codec passes it through.
13
23
  *
14
24
  * CloudEvents spec: https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md
15
25
  */
16
26
 
17
27
  import { randomUUID } from "node:crypto";
28
+ import type { JsonObject } from "@bufbuild/protobuf";
29
+ import { StigmerClient } from "../client/stigmer-client.js";
18
30
  import { loadConfig } from "../config.js";
19
31
  import { resolveRuntimePlaceholders } from "../workflow-engine/resolve.js";
20
32
 
@@ -24,7 +36,9 @@ export interface WebhookDeliveryTarget {
24
36
  }
25
37
 
26
38
  export interface SignalDeliveryTarget {
27
- readonly workflow_id: string;
39
+ /** Target workflow execution id ("wfx_..."), as returned by run/create. */
40
+ readonly execution_id: string;
41
+ /** Signal name matching the target's listen task event id (verbatim). */
28
42
  readonly signal_name: string;
29
43
  }
30
44
 
@@ -54,6 +68,11 @@ export interface EmitEventResult {
54
68
 
55
69
  const WEBHOOK_TIMEOUT_MS = 30_000;
56
70
 
71
+ // Delivery is best-effort by contract, so no single target may consume the
72
+ // CallFunction activity's whole 5m startToClose budget. Same bound as the
73
+ // webhook arm.
74
+ const SIGNAL_TIMEOUT_MS = 30_000;
75
+
57
76
  function buildEnvelope(
58
77
  config: EmitEventConfig,
59
78
  executionId: string,
@@ -120,33 +139,53 @@ async function deliverWebhook(
120
139
  }
121
140
  }
122
141
 
142
+ function buildClient(): StigmerClient {
143
+ const config = loadConfig();
144
+ return new StigmerClient({
145
+ endpoint: config.stigmerBackendEndpoint,
146
+ token: config.stigmerToken,
147
+ });
148
+ }
149
+
123
150
  async function deliverSignal(
124
151
  envelope: Record<string, unknown>,
125
152
  target: SignalDeliveryTarget,
153
+ client: StigmerClient,
126
154
  ): Promise<DeliveryError | null> {
127
- try {
128
- const { Connection, Client } = await import("@temporalio/client");
129
-
130
- // Resolve Temporal coordinates through the central config layer rather than
131
- // reading env directly: signal delivery must dial the SAME cluster/namespace
132
- // the worker connected with. config.ts is the single source of truth for the
133
- // canonical TEMPORAL_SERVICE_ADDRESS / TEMPORAL_NAMESPACE resolution — reading
134
- // env here would diverge (and previously dialed localhost via a stale name).
135
- const config = loadConfig();
136
- const temporalAddress = config.temporalAddress;
137
- const temporalNamespace = config.temporalNamespace;
138
-
139
- const connection = await Connection.connect({ address: temporalAddress });
140
- const client = new Client({ connection, namespace: temporalNamespace });
141
-
142
- const handle = client.workflow.getHandle(target.workflow_id);
143
- await handle.signal(target.signal_name, envelope);
155
+ // Refuse the pre-oss#517 field by name: direct-addressing raw Temporal
156
+ // workflow ids is exactly the capability server mediation removes.
157
+ const legacyWorkflowId = (target as { workflow_id?: unknown }).workflow_id;
158
+ if (!target.execution_id) {
159
+ const reason = legacyWorkflowId
160
+ ? "signal delivery addresses workflow executions by 'execution_id' (\"wfx_...\"); 'workflow_id' is not supported"
161
+ : "signal delivery requires 'execution_id'";
162
+ return {
163
+ target: `signal:${String(legacyWorkflowId ?? "")}/${target.signal_name ?? ""}`,
164
+ error: reason,
165
+ };
166
+ }
167
+ if (!target.signal_name) {
168
+ return {
169
+ target: `signal:${target.execution_id}/`,
170
+ error: "signal delivery requires 'signal_name'",
171
+ };
172
+ }
144
173
 
174
+ try {
175
+ await client.sendWorkflowSignal(
176
+ target.execution_id,
177
+ target.signal_name,
178
+ envelope as JsonObject,
179
+ { timeoutMs: SIGNAL_TIMEOUT_MS },
180
+ );
145
181
  return null;
146
182
  } catch (err) {
183
+ // Server refusals arrive as ConnectErrors whose messages carry the code
184
+ // (e.g. [not_found], [failed_precondition] for terminal executions) —
185
+ // surfaced verbatim in delivery_errors, same contract as the webhook arm.
147
186
  const message = err instanceof Error ? err.message : String(err);
148
187
  return {
149
- target: `signal:${target.workflow_id}/${target.signal_name}`,
188
+ target: `signal:${target.execution_id}/${target.signal_name}`,
150
189
  error: message,
151
190
  };
152
191
  }
@@ -173,13 +212,18 @@ export async function emitEventAction(
173
212
  const errors: DeliveryError[] = [];
174
213
  const env = runtimeEnv ?? {};
175
214
 
215
+ // One client serves all signal targets of this emit; constructed lazily so
216
+ // webhook-only emits never pay for a gRPC transport.
217
+ let client: StigmerClient | undefined;
218
+
176
219
  for (const target of config.delivery) {
177
220
  let err: DeliveryError | null = null;
178
221
 
179
222
  if ("webhook" in target) {
180
223
  err = await deliverWebhook(envelope, target.webhook, env);
181
224
  } else if ("signal" in target) {
182
- err = await deliverSignal(envelope, target.signal);
225
+ client ??= buildClient();
226
+ err = await deliverSignal(envelope, target.signal, client);
183
227
  }
184
228
 
185
229
  if (err) {
@@ -78,7 +78,11 @@ describe("resolveAttachments", () => {
78
78
  const result = await resolveAttachments([makeAttachment()], options({ storage }));
79
79
 
80
80
  expect(result).toEqual([
81
- { filename: "plan.md", relativePath: ".stigmer/inputs/plan.md" },
81
+ {
82
+ filename: "plan.md",
83
+ relativePath: ".stigmer/inputs/plan.md",
84
+ downloadUrl: "mem://attachments/01ABC/plan.md",
85
+ },
82
86
  ]);
83
87
  expect(readFileSync(join(platformDir, "inputs", "plan.md"), "utf-8")).toBe("# The Plan");
84
88
  });
@@ -130,11 +134,16 @@ describe("resolveAttachments", () => {
130
134
  );
131
135
 
132
136
  expect(result).toEqual([
133
- { filename: "report.pdf", relativePath: ".stigmer/inputs/report.pdf" },
137
+ {
138
+ filename: "report.pdf",
139
+ relativePath: ".stigmer/inputs/report.pdf",
140
+ downloadUrl: "mem://attachments/01AAA/report.pdf",
141
+ },
134
142
  {
135
143
  filename: "report-2.pdf",
136
144
  relativePath: ".stigmer/inputs/report-2.pdf",
137
145
  renamedFrom: "report.pdf",
146
+ downloadUrl: "mem://attachments/01BBB/report.pdf",
138
147
  },
139
148
  ]);
140
149
  expect(readFileSync(join(platformDir, "inputs", "report.pdf"), "utf-8")).toBe("first bytes");
@@ -155,12 +164,15 @@ describe("resolveAttachments", () => {
155
164
  options({ storage }),
156
165
  );
157
166
 
167
+ // The key-less local file lists no URL; the storage twin does — the
168
+ // per-file split the prompt renders.
158
169
  expect(result).toEqual([
159
170
  { filename: "notes.md", relativePath: ".stigmer/inputs/notes.md" },
160
171
  {
161
172
  filename: "notes-2.md",
162
173
  relativePath: ".stigmer/inputs/notes-2.md",
163
174
  renamedFrom: "notes.md",
175
+ downloadUrl: "mem://attachments/01ABC/notes.md",
164
176
  },
165
177
  ]);
166
178
  expect(readFileSync(join(platformDir, "inputs", "notes.md"), "utf-8")).toBe("local copy");
@@ -225,7 +237,11 @@ describe("resolveAttachments", () => {
225
237
  );
226
238
 
227
239
  expect(result).toEqual([
228
- { filename: "evil.md", relativePath: ".stigmer/inputs/evil.md" },
240
+ {
241
+ filename: "evil.md",
242
+ relativePath: ".stigmer/inputs/evil.md",
243
+ downloadUrl: "mem://attachments/01ABC/x",
244
+ },
229
245
  ]);
230
246
  expect(readFileSync(join(platformDir, "inputs", "evil.md"), "utf-8")).toBe("contained");
231
247
  // Nothing escaped two levels up (where `../../evil.md` would have landed).
@@ -247,6 +263,50 @@ describe("resolveAttachments", () => {
247
263
  expect(readFileSync(join(platformDir, "inputs", "evil.txt"), "utf-8")).toBe("local-contained");
248
264
  });
249
265
 
266
+ // ── Download-URL hand-off (issue #532) ───────────────────────────────────
267
+ // The URL is strictly additive (shared/attachment-download-urls.ts): every
268
+ // case below also asserts the file materialized exactly as it would
269
+ // without one.
270
+
271
+ describe("download-URL minting during resolution", () => {
272
+ it("degrades to no URL when the mint fails — file still materialized, turn proceeds", async () => {
273
+ const { storage } = makeInMemoryArtifactStorage();
274
+ await storage.upload("attachments/01ABC/plan.md", Buffer.from("# The Plan"), "text/markdown");
275
+ storage.getDownloadUrl.mockRejectedValueOnce(new Error("presign endpoint unreachable"));
276
+
277
+ const result = await resolveAttachments([makeAttachment()], options({ storage }));
278
+
279
+ expect(result).toEqual([
280
+ { filename: "plan.md", relativePath: ".stigmer/inputs/plan.md" },
281
+ ]);
282
+ expect(readFileSync(join(platformDir, "inputs", "plan.md"), "utf-8")).toBe("# The Plan");
283
+ });
284
+
285
+ it("mints a URL on the localPath fast path when an uploaded copy also exists", async () => {
286
+ // The fast path skips storage for the BYTES; the mint rule is
287
+ // branch-independent, so the uploaded copy still backs the URL.
288
+ const srcPath = join(workspaceDir, "src.csv");
289
+ writeFileSync(srcPath, "a,b,c");
290
+ const { storage } = makeInMemoryArtifactStorage();
291
+
292
+ const result = await resolveAttachments(
293
+ [makeAttachment({ filename: "data.csv", storageKey: "attachments/01ABC/data.csv", localPath: srcPath })],
294
+ options({ storage }),
295
+ );
296
+
297
+ expect(result).toEqual([
298
+ {
299
+ filename: "data.csv",
300
+ relativePath: ".stigmer/inputs/data.csv",
301
+ downloadUrl: "mem://attachments/01ABC/data.csv",
302
+ },
303
+ ]);
304
+ // Bytes came off local disk, not storage — the fast path is intact.
305
+ expect(storage.download).not.toHaveBeenCalled();
306
+ expect(readFileSync(join(platformDir, "inputs", "data.csv"), "utf-8")).toBe("a,b,c");
307
+ });
308
+ });
309
+
250
310
  // ── Vision selection (T04) ────────────────────────────────────────────────
251
311
  // Vision is strictly additive: every case below also asserts the file
252
312
  // materialized exactly as it would without a budget.
@@ -9,12 +9,10 @@
9
9
 
10
10
  import { describe, it, expect } from "vitest";
11
11
  import { create } from "@bufbuild/protobuf";
12
- import { DatastoreUsageSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
13
12
  import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
14
13
  import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
15
- import { ApiResourceReferenceSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
16
14
 
17
- import { buildPrompt, isHitlReinvocation } from "../index.js";
15
+ import { appendStructuredOutputDirective, buildPrompt, isHitlReinvocation, primarySendCarriesImages } from "../index.js";
18
16
  import type { BuildPromptInput } from "../index.js";
19
17
  import { buildReinvocationPrompt, formatInteractionModePrefix, formatImplementPlanSection, formatToolApprovalProtocol, buildToolApprovalRuleFile } from "../prompt-builder.js";
20
18
  import { PLAN_MODE_DIRECTIVE } from "../../../shared/plan-mode-prompt.js";
@@ -89,33 +87,6 @@ describe("buildPrompt", () => {
89
87
  expect(prompt).toContain(USER_MESSAGE);
90
88
  });
91
89
 
92
- it("renders the <available_datastores> section on the first execution (T05)", () => {
93
- const prompt = buildPrompt(
94
- input({
95
- resolution: resolution("local", "created_first_execution"),
96
- datastoreUsages: [
97
- create(DatastoreUsageSchema, {
98
- datastoreRef: create(ApiResourceReferenceSchema, { slug: "clinic" }),
99
- }),
100
- ],
101
- }),
102
- );
103
- expect(prompt).toContain("<available_datastores>");
104
- expect(prompt).toContain("- clinic");
105
- expect(prompt).toContain("describe_datastore");
106
- // The standing failure-disclosure instruction (issue #325) is this
107
- // harness's ONLY outage coverage: the Cursor SDK connects MCP itself,
108
- // so the runner can never reconcile the live roster here.
109
- expect(prompt).toContain("do not answer from memory");
110
- });
111
-
112
- it("omits the datastores section when the agent uses no datastores", () => {
113
- const prompt = buildPrompt(
114
- input({ resolution: resolution("local", "created_first_execution") }),
115
- );
116
- expect(prompt).not.toContain("<available_datastores>");
117
- });
118
-
119
90
  it("carries the rollover context bridge on the first execution (DD-013)", () => {
120
91
  const prompt = buildPrompt(
121
92
  input({
@@ -255,7 +226,7 @@ describe("buildPrompt", () => {
255
226
  });
256
227
  });
257
228
 
258
- describe("isHitlReinvocation (the single discriminator for message-accompanied payloads)", () => {
229
+ describe("isHitlReinvocation (paired with resolution.reason at every consumer — issue #366)", () => {
259
230
  it("is false with no decisions and false with an empty map", () => {
260
231
  expect(isHitlReinvocation(undefined)).toBe(false);
261
232
  expect(isHitlReinvocation(new Map())).toBe(false);
@@ -268,6 +239,115 @@ describe("isHitlReinvocation (the single discriminator for message-accompanied p
268
239
  });
269
240
  });
270
241
 
242
+ describe("HITL recovery — fresh agent mid-HITL (issue #366)", () => {
243
+ const decisions = () =>
244
+ new Map<string, ApprovalAction>([["tool-call-1", ApprovalAction.APPROVE]]);
245
+ const approvals = () => [
246
+ create(PendingApprovalSchema, {
247
+ toolCallId: "tool-call-1",
248
+ toolName: "Write",
249
+ message: "Write file: gated.txt",
250
+ }),
251
+ ];
252
+ const recoveryInput = (overrides: Partial<BuildPromptInput> = {}) =>
253
+ input({
254
+ resolution: resolution("local", "created_after_resume_failure"),
255
+ approvalDecisions: decisions(),
256
+ pendingApprovals: approvals(),
257
+ ...overrides,
258
+ });
259
+
260
+ it("keeps the resumed-HITL prompt byte-identical to the bare reinvocation prompt (regression guard)", () => {
261
+ const prompt = buildPrompt(
262
+ input({
263
+ resolution: resolution("local", "resumed_successfully"),
264
+ approvalDecisions: decisions(),
265
+ pendingApprovals: approvals(),
266
+ }),
267
+ );
268
+ expect(prompt).toBe(buildReinvocationPrompt(approvals(), decisions()));
269
+ });
270
+
271
+ it("rebuilds full context for a fresh agent mid-HITL: instructions, the ORIGINAL user message, the state-loss disclosure, and the decisions", () => {
272
+ const prompt = buildPrompt(recoveryInput());
273
+ // The pre-fix failure mode: bare decisions with no story.
274
+ expect(prompt).not.toBe(buildReinvocationPrompt(approvals(), decisions()));
275
+ expect(prompt).toContain("<agent_instructions>");
276
+ expect(prompt).toContain("<tool_approval_protocol>");
277
+ expect(prompt).toContain(`<user_request>\n${USER_MESSAGE}\n</user_request>`);
278
+ expect(prompt).toContain("<turn_recovery>");
279
+ expect(prompt).toContain("Write file: gated.txt");
280
+ expect(prompt).toContain("APPROVED");
281
+ // The opaque tool-call id must not leak into the prompt (reinvocation rule).
282
+ expect(prompt).not.toContain("tool-call-1");
283
+ });
284
+
285
+ it("orders the recovery chronologically: request, then the recovered work, then the decisions, ending on the continue directive", () => {
286
+ const prompt = buildPrompt(
287
+ recoveryInput({ turnRecoveryDigest: "Tool: Write file: draft.txt — completed" }),
288
+ );
289
+ const requestIdx = prompt.indexOf("<user_request>");
290
+ const recoveryIdx = prompt.indexOf("<turn_recovery>");
291
+ const decisionsIdx = prompt.indexOf("APPROVED");
292
+ expect(requestIdx).toBeGreaterThan(-1);
293
+ expect(recoveryIdx).toBeGreaterThan(requestIdx);
294
+ expect(decisionsIdx).toBeGreaterThan(recoveryIdx);
295
+ expect(prompt).toContain("Tool: Write file: draft.txt — completed");
296
+ expect(prompt.trimEnd().endsWith("do not ask the user for permission in prose.")).toBe(true);
297
+ });
298
+
299
+ it("discloses the state loss even with NO transcript — otherwise the decisions read as reactions to proposals this agent never made", () => {
300
+ const prompt = buildPrompt(recoveryInput({ turnRecoveryDigest: undefined }));
301
+ expect(prompt).toContain("<turn_recovery>");
302
+ expect(prompt).toContain("no transcript of your progress is available");
303
+ });
304
+
305
+ it("preserves already-applied semantics: exact-applied writes are described as done, not as work to redo", () => {
306
+ const prompt = buildPrompt(
307
+ recoveryInput({ appliedToolCallIds: new Set(["tool-call-1"]) }),
308
+ );
309
+ expect(prompt).toContain("ALREADY applied");
310
+ expect(prompt).not.toContain("Carry them out now");
311
+ });
312
+
313
+ it("carries the session-standing context a first turn would get — the replacement agent inherits the whole story, not just the blueprint", () => {
314
+ const prompt = buildPrompt(
315
+ recoveryInput({ contextBridge: "User: earlier thread\nAssistant: earlier reply" }),
316
+ );
317
+ expect(prompt).toContain("<previous_conversation_context>");
318
+ expect(prompt).toContain("User: earlier thread");
319
+ });
320
+
321
+ it("selects the recovery shape for ANY non-resumed reason — decisions-only is safe only when native context is guaranteed", () => {
322
+ // created_first_execution + decisions cannot happen in practice
323
+ // (decisions are reconstructed from a persisted transcript, which
324
+ // implies a prior invocation) — but if it ever did, the bare decisions
325
+ // prompt would reproduce the #366 amnesia. Fail safe.
326
+ const prompt = buildPrompt(
327
+ recoveryInput({ resolution: resolution("local", "created_first_execution") }),
328
+ );
329
+ expect(prompt).toContain("<turn_recovery>");
330
+ expect(prompt).toContain("<agent_instructions>");
331
+ });
332
+
333
+ it("primary send skips images ONLY for a resumed HITL agent (which holds them natively); every fresh agent gets the re-delivery", () => {
334
+ expect(primarySendCarriesImages(decisions(), "resumed_successfully")).toBe(false);
335
+ // The #366 vision corollary: fresh agent mid-HITL after a
336
+ // resolution-time resume failure.
337
+ expect(primarySendCarriesImages(decisions(), "created_after_resume_failure")).toBe(true);
338
+ // Non-HITL turns always carry the message's images, resumed or not.
339
+ expect(primarySendCarriesImages(undefined, "resumed_successfully")).toBe(true);
340
+ expect(primarySendCarriesImages(undefined, "created_first_execution")).toBe(true);
341
+ });
342
+
343
+ it("the structured-output directive is a pure append shared by the primary and recovery sends — absent schema, absent suffix", () => {
344
+ expect(appendStructuredOutputDirective("base", undefined)).toBe("base");
345
+ const withSchema = appendStructuredOutputDirective("base", { type: "object" });
346
+ expect(withSchema.startsWith("base\n\n---\nCRITICAL OUTPUT REQUIREMENT:")).toBe(true);
347
+ expect(withSchema).toContain('"type": "object"');
348
+ });
349
+ });
350
+
271
351
  describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)", () => {
272
352
  const RESUMED = { resolution: resolution("local", "resumed_successfully") };
273
353
 
@@ -345,6 +425,68 @@ describe("attachments on a resumed turn (T04 — the mid-session WhatsApp case)"
345
425
  expect(prompt).toContain("Attached inline and visible to you, in order: 1. a.jpg");
346
426
  });
347
427
 
428
+ it("lists a minted download URL beside its file with the presigned hand-off line (issue #532)", () => {
429
+ const prompt = buildPrompt(
430
+ input({
431
+ ...RESUMED,
432
+ attachments: [
433
+ { path: ".stigmer/inputs/lease.pdf", downloadUrl: "https://r2.example/lease?sig=abc" },
434
+ { path: ".stigmer/inputs/notes.md" },
435
+ ],
436
+ downloadUrlKind: "presigned",
437
+ }),
438
+ );
439
+ expect(prompt).toContain(
440
+ "- `.stigmer/inputs/lease.pdf` — download URL: https://r2.example/lease?sig=abc",
441
+ );
442
+ // The URL-less file keeps a clean entry.
443
+ expect(prompt).toContain("- `.stigmer/inputs/notes.md`\n");
444
+ expect(prompt).toContain("These URLs are time-limited");
445
+ });
446
+
447
+ it("words a local-serve URL honestly — reachable only from this machine", () => {
448
+ const prompt = buildPrompt(
449
+ input({
450
+ ...RESUMED,
451
+ attachments: [
452
+ { path: ".stigmer/inputs/lease.pdf", downloadUrl: "http://localhost:7235/attachments/01A/lease.pdf" },
453
+ ],
454
+ downloadUrlKind: "local-serve",
455
+ }),
456
+ );
457
+ expect(prompt).toContain("download URL: http://localhost:7235/attachments/01A/lease.pdf");
458
+ expect(prompt).toContain("reachable only from this machine");
459
+ expect(prompt).not.toContain("time-limited");
460
+ });
461
+
462
+ it("renders no hand-off line when no listed file carries a URL (kind alone is not enough)", () => {
463
+ const prompt = buildPrompt(
464
+ input({
465
+ ...RESUMED,
466
+ attachments: [{ path: ".stigmer/inputs/local-only.csv" }],
467
+ downloadUrlKind: "presigned",
468
+ }),
469
+ );
470
+ expect(prompt).toContain("<input_files>");
471
+ expect(prompt).not.toContain("download URL");
472
+ expect(prompt).not.toContain("time-limited");
473
+ });
474
+
475
+ it("renders the download URL inside the enhanced first-turn prompt too", () => {
476
+ const prompt = buildPrompt(
477
+ input({
478
+ resolution: resolution("local", "created_first_execution"),
479
+ attachments: [
480
+ { path: ".stigmer/inputs/lease.pdf", downloadUrl: "https://r2.example/lease?sig=abc" },
481
+ ],
482
+ downloadUrlKind: "presigned",
483
+ }),
484
+ );
485
+ expect(prompt).toContain("<input_files>");
486
+ expect(prompt).toContain("download URL: https://r2.example/lease?sig=abc");
487
+ expect(prompt).toContain("These URLs are time-limited");
488
+ });
489
+
348
490
  it("a HITL re-invocation carries no input-files section — its prompt has no user message at all", () => {
349
491
  const prompt = buildPrompt(
350
492
  input({
@@ -1,4 +1,9 @@
1
- import { describe, it, expect, vi, beforeAll } from "vitest";
1
+ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ getPricingTable,
4
+ _resetPricingCache,
5
+ DEFAULT_PRICING,
6
+ } from "../model-pricing-data.js";
2
7
 
3
8
  /**
4
9
  * Verifies the cursor-runner display estimate resolves Cursor speed variants
@@ -110,3 +115,59 @@ describe("getCursorModelPricing — speed variant resolution", () => {
110
115
  expect(p.inputPricePerMillion).toBe(5.0);
111
116
  });
112
117
  });
118
+
119
+ describe("getPricingTable failure caching (#468)", () => {
120
+ beforeEach(() => {
121
+ _resetPricingCache();
122
+ });
123
+
124
+ afterEach(() => {
125
+ _resetPricingCache();
126
+ vi.useRealTimers();
127
+ });
128
+
129
+ it("retries after the short failure TTL instead of pinning DEFAULT_PRICING for an hour", async () => {
130
+ // The model-registry.ts failure-cache policy, applied here: wrong default
131
+ // rates for cost tracking must not persist a full success TTL.
132
+ vi.useFakeTimers();
133
+ const registryResponse = () =>
134
+ new Response(
135
+ JSON.stringify({
136
+ models: [
137
+ {
138
+ id: "composer-2.5",
139
+ displayName: "Composer 2.5",
140
+ provider: "cursor",
141
+ harness: "cursor",
142
+ costTier: "economy",
143
+ pricing: {
144
+ inputPricePerMillion: 0.5,
145
+ outputPricePerMillion: 2.5,
146
+ cacheWritePricePerMillion: 0,
147
+ cacheReadPricePerMillion: 0.2,
148
+ },
149
+ },
150
+ ],
151
+ }),
152
+ { status: 200, headers: { "Content-Type": "application/json" } },
153
+ );
154
+ const fetchSpy = vi
155
+ .fn<typeof fetch>()
156
+ .mockRejectedValueOnce(new Error("network error"))
157
+ .mockResolvedValueOnce(registryResponse());
158
+ vi.stubGlobal("fetch", fetchSpy);
159
+
160
+ // First call fails and degrades to the default table.
161
+ expect((await getPricingTable())[0]).toBe(DEFAULT_PRICING);
162
+
163
+ // Within the failure TTL the fallback stays cached (no refetch).
164
+ vi.advanceTimersByTime(30_000);
165
+ expect((await getPricingTable())[0]).toBe(DEFAULT_PRICING);
166
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
167
+
168
+ // Past the failure TTL the registry is refetched and real rates recover.
169
+ vi.advanceTimersByTime(31_000);
170
+ expect((await getPricingTable())[0]?.model).toBe("composer-2.5");
171
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
172
+ });
173
+ });