@stigmer/runner 3.10.0 → 3.11.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 (260) hide show
  1. package/README.md +12 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/call-llm.js +9 -10
  4. package/dist/activities/call-llm.js.map +1 -1
  5. package/dist/activities/classify-tool-approvals.d.ts +2 -1
  6. package/dist/activities/classify-tool-approvals.js +28 -2
  7. package/dist/activities/classify-tool-approvals.js.map +1 -1
  8. package/dist/activities/discover-mcp-server.d.ts +32 -0
  9. package/dist/activities/discover-mcp-server.js +162 -27
  10. package/dist/activities/discover-mcp-server.js.map +1 -1
  11. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +8 -0
  12. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  13. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  14. package/dist/activities/execute-cursor/approval-state.d.ts +28 -2
  15. package/dist/activities/execute-cursor/approval-state.js +7 -1
  16. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  17. package/dist/activities/execute-cursor/attachment-resolver.d.ts +14 -0
  18. package/dist/activities/execute-cursor/attachment-resolver.js +18 -4
  19. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  20. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  21. package/dist/activities/execute-cursor/blueprint-resolver.js +6 -22
  22. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  23. package/dist/activities/execute-cursor/env-resolver.js +3 -1
  24. package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
  25. package/dist/activities/execute-cursor/error-classifier.d.ts +40 -3
  26. package/dist/activities/execute-cursor/error-classifier.js +81 -3
  27. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  28. package/dist/activities/execute-cursor/extract-structured-output.d.ts +29 -0
  29. package/dist/activities/execute-cursor/extract-structured-output.js +58 -0
  30. package/dist/activities/execute-cursor/extract-structured-output.js.map +1 -0
  31. package/dist/activities/execute-cursor/hook-script.d.ts +14 -3
  32. package/dist/activities/execute-cursor/hook-script.js +72 -10
  33. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  34. package/dist/activities/execute-cursor/index.d.ts +5 -1
  35. package/dist/activities/execute-cursor/index.js +51 -57
  36. package/dist/activities/execute-cursor/index.js.map +1 -1
  37. package/dist/activities/execute-cursor/mcp-resolver.d.ts +24 -1
  38. package/dist/activities/execute-cursor/mcp-resolver.js +5 -2
  39. package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
  40. package/dist/activities/execute-cursor/prompt-builder.d.ts +18 -4
  41. package/dist/activities/execute-cursor/prompt-builder.js +12 -7
  42. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  43. package/dist/activities/execute-cursor/turn-stream.js +4 -1
  44. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  45. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +18 -1
  46. package/dist/activities/execute-deep-agent/attachment-injector.js +68 -23
  47. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  48. package/dist/activities/execute-deep-agent/environment.js +3 -1
  49. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  50. package/dist/activities/execute-deep-agent/index.js +15 -0
  51. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  52. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -7
  53. package/dist/activities/execute-deep-agent/prompt-builder.js +8 -2
  54. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  55. package/dist/activities/execute-deep-agent/setup.d.ts +10 -0
  56. package/dist/activities/execute-deep-agent/setup.js +55 -23
  57. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  58. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +18 -1
  59. package/dist/activities/execute-deep-agent/subagent-transformer.js +8 -1
  60. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  61. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +11 -4
  62. package/dist/activities/execute-deep-agent/subagent-wiring.js +13 -4
  63. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  64. package/dist/activities/hydrate-workflow-execution.js +3 -1
  65. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  66. package/dist/activities/workflow-event-activities.d.ts +28 -10
  67. package/dist/activities/workflow-event-activities.js +87 -58
  68. package/dist/activities/workflow-event-activities.js.map +1 -1
  69. package/dist/claimcheck/payload-codec.js +21 -1
  70. package/dist/claimcheck/payload-codec.js.map +1 -1
  71. package/dist/client/stigmer-client.d.ts +9 -4
  72. package/dist/client/stigmer-client.js +28 -15
  73. package/dist/client/stigmer-client.js.map +1 -1
  74. package/dist/encryption/config.d.ts +32 -0
  75. package/dist/encryption/config.js +68 -0
  76. package/dist/encryption/config.js.map +1 -0
  77. package/dist/encryption/index.d.ts +3 -0
  78. package/dist/encryption/index.js +3 -0
  79. package/dist/encryption/index.js.map +1 -0
  80. package/dist/encryption/payload-codec.d.ts +41 -0
  81. package/dist/encryption/payload-codec.js +130 -0
  82. package/dist/encryption/payload-codec.js.map +1 -0
  83. package/dist/payload-codecs.d.ts +16 -0
  84. package/dist/payload-codecs.js +38 -0
  85. package/dist/payload-codecs.js.map +1 -0
  86. package/dist/preflight.d.ts +31 -0
  87. package/dist/preflight.js +43 -0
  88. package/dist/preflight.js.map +1 -1
  89. package/dist/runner-manager.js +5 -15
  90. package/dist/runner-manager.js.map +1 -1
  91. package/dist/runner.js +5 -16
  92. package/dist/runner.js.map +1 -1
  93. package/dist/shared/approval-policy.d.ts +9 -3
  94. package/dist/shared/approval-policy.js +15 -6
  95. package/dist/shared/approval-policy.js.map +1 -1
  96. package/dist/shared/attachment-naming.d.ts +53 -0
  97. package/dist/shared/attachment-naming.js +59 -0
  98. package/dist/shared/attachment-naming.js.map +1 -0
  99. package/dist/shared/caller-identity.d.ts +23 -2
  100. package/dist/shared/caller-identity.js +36 -5
  101. package/dist/shared/caller-identity.js.map +1 -1
  102. package/dist/shared/channel-attachment.js +1 -0
  103. package/dist/shared/channel-attachment.js.map +1 -1
  104. package/dist/shared/checkpointer/http-saver.d.ts +42 -1
  105. package/dist/shared/checkpointer/http-saver.js +96 -8
  106. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  107. package/dist/shared/conversation-attachment.js +1 -0
  108. package/dist/shared/conversation-attachment.js.map +1 -1
  109. package/dist/shared/datastore-attachment.d.ts +50 -7
  110. package/dist/shared/datastore-attachment.js +93 -11
  111. package/dist/shared/datastore-attachment.js.map +1 -1
  112. package/dist/shared/http-retry.d.ts +43 -0
  113. package/dist/shared/http-retry.js +50 -0
  114. package/dist/shared/http-retry.js.map +1 -0
  115. package/dist/shared/llm-backend.d.ts +275 -0
  116. package/dist/shared/llm-backend.js +425 -0
  117. package/dist/shared/llm-backend.js.map +1 -0
  118. package/dist/shared/llm-proxy.d.ts +8 -0
  119. package/dist/shared/llm-proxy.js +15 -0
  120. package/dist/shared/llm-proxy.js.map +1 -1
  121. package/dist/shared/mcp-enabled-tools.d.ts +57 -0
  122. package/dist/shared/mcp-enabled-tools.js +86 -0
  123. package/dist/shared/mcp-enabled-tools.js.map +1 -0
  124. package/dist/shared/mcp-manager.d.ts +3 -1
  125. package/dist/shared/mcp-manager.js +17 -4
  126. package/dist/shared/mcp-manager.js.map +1 -1
  127. package/dist/shared/mcp-resolver.d.ts +39 -2
  128. package/dist/shared/mcp-resolver.js +38 -2
  129. package/dist/shared/mcp-resolver.js.map +1 -1
  130. package/dist/shared/model-client.d.ts +12 -5
  131. package/dist/shared/model-client.js +138 -18
  132. package/dist/shared/model-client.js.map +1 -1
  133. package/dist/shared/model-error.js +198 -5
  134. package/dist/shared/model-error.js.map +1 -1
  135. package/dist/shared/plan-mode-permissions.d.ts +26 -0
  136. package/dist/shared/plan-mode-permissions.js +28 -0
  137. package/dist/shared/plan-mode-permissions.js.map +1 -0
  138. package/dist/worker.d.ts +2 -1
  139. package/dist/worker.js +2 -4
  140. package/dist/worker.js.map +1 -1
  141. package/dist/workflow-engine/types.d.ts +18 -0
  142. package/dist/workflow-engine/types.js.map +1 -1
  143. package/dist/workflows/call-agent-orchestrator.d.ts +9 -0
  144. package/dist/workflows/call-agent-orchestrator.js +1 -0
  145. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  146. package/dist/workflows/connect-mcp-server.js +7 -0
  147. package/dist/workflows/connect-mcp-server.js.map +1 -1
  148. package/dist/workflows/engine-core.js +23 -2
  149. package/dist/workflows/engine-core.js.map +1 -1
  150. package/dist/workflows/execute-from-execution.d.ts +1 -1
  151. package/dist/workflows/execute-from-execution.js +11 -1
  152. package/dist/workflows/execute-from-execution.js.map +1 -1
  153. package/package.json +8 -2
  154. package/src/__tests__/claimcheck-codec.test.ts +36 -0
  155. package/src/__tests__/encryption-codec.test.ts +234 -0
  156. package/src/__tests__/fixtures/encrypted-payload-fixture.json +15 -0
  157. package/src/__tests__/history-encryption-e2e.test.ts +243 -0
  158. package/src/__tests__/preflight.test.ts +50 -2
  159. package/src/activities/__tests__/call-llm.test.ts +75 -0
  160. package/src/activities/__tests__/classify-tool-approvals.test.ts +117 -1
  161. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +103 -0
  162. package/src/activities/__tests__/discover-mcp-server.test.ts +203 -0
  163. package/src/activities/__tests__/workflow-event-activities.test.ts +107 -8
  164. package/src/activities/call-llm.ts +9 -16
  165. package/src/activities/classify-tool-approvals.ts +34 -4
  166. package/src/activities/discover-mcp-server.ts +190 -32
  167. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +9 -0
  168. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +14 -0
  169. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +53 -0
  170. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -14
  171. package/src/activities/execute-cursor/__tests__/error-classifier-extraction.test.ts +208 -0
  172. package/src/activities/execute-cursor/__tests__/extract-structured-output.test.ts +120 -0
  173. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +93 -0
  174. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +125 -0
  175. package/src/activities/execute-cursor/__tests__/prompt-builder-delegation.test.ts +1 -1
  176. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +13 -0
  177. package/src/activities/execute-cursor/approval-state.ts +30 -1
  178. package/src/activities/execute-cursor/attachment-resolver.ts +31 -3
  179. package/src/activities/execute-cursor/blueprint-resolver.ts +7 -27
  180. package/src/activities/execute-cursor/env-resolver.ts +3 -1
  181. package/src/activities/execute-cursor/error-classifier.ts +91 -4
  182. package/src/activities/execute-cursor/extract-structured-output.ts +72 -0
  183. package/src/activities/execute-cursor/hook-script.ts +74 -10
  184. package/src/activities/execute-cursor/index.ts +55 -71
  185. package/src/activities/execute-cursor/mcp-resolver.ts +36 -2
  186. package/src/activities/execute-cursor/prompt-builder.ts +34 -9
  187. package/src/activities/execute-cursor/turn-stream.ts +5 -2
  188. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +110 -8
  189. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +104 -0
  190. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +2 -0
  191. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  192. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  193. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +34 -5
  194. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  195. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +173 -0
  196. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +12 -7
  197. package/src/activities/execute-deep-agent/attachment-injector.ts +94 -30
  198. package/src/activities/execute-deep-agent/environment.ts +3 -1
  199. package/src/activities/execute-deep-agent/index.ts +20 -0
  200. package/src/activities/execute-deep-agent/prompt-builder.ts +20 -10
  201. package/src/activities/execute-deep-agent/setup.ts +76 -28
  202. package/src/activities/execute-deep-agent/subagent-transformer.ts +23 -1
  203. package/src/activities/execute-deep-agent/subagent-wiring.ts +14 -4
  204. package/src/activities/hydrate-workflow-execution.ts +3 -1
  205. package/src/activities/workflow-event-activities.ts +96 -69
  206. package/src/claimcheck/payload-codec.ts +33 -1
  207. package/src/client/__tests__/stigmer-client.test.ts +8 -8
  208. package/src/client/stigmer-client.ts +32 -18
  209. package/src/encryption/config.ts +91 -0
  210. package/src/encryption/index.ts +3 -0
  211. package/src/encryption/payload-codec.ts +152 -0
  212. package/src/payload-codecs.ts +56 -0
  213. package/src/preflight.ts +45 -0
  214. package/src/runner-manager.ts +6 -24
  215. package/src/runner.ts +6 -25
  216. package/src/shared/__tests__/approval-policy.test.ts +82 -39
  217. package/src/shared/__tests__/attachment-naming.test.ts +159 -0
  218. package/src/shared/__tests__/bedrock-adapter.test.ts +213 -0
  219. package/src/shared/__tests__/bedrock-seam.test.ts +390 -0
  220. package/src/shared/__tests__/caller-identity.test.ts +25 -0
  221. package/src/shared/__tests__/channel-attachment.test.ts +1 -1
  222. package/src/shared/__tests__/connect-backfill.test.ts +1 -0
  223. package/src/shared/__tests__/conversation-attachment.test.ts +1 -1
  224. package/src/shared/__tests__/datastore-attachment.test.ts +129 -1
  225. package/src/shared/__tests__/foundry-adapter.test.ts +276 -0
  226. package/src/shared/__tests__/foundry-seam.test.ts +482 -0
  227. package/src/shared/__tests__/http-retry.test.ts +67 -0
  228. package/src/shared/__tests__/llm-backend.test.ts +616 -0
  229. package/src/shared/__tests__/mcp-enabled-tools.test.ts +86 -0
  230. package/src/shared/__tests__/mcp-manager.test.ts +84 -2
  231. package/src/shared/__tests__/mcp-resolver.test.ts +146 -3
  232. package/src/shared/__tests__/model-client.test.ts +154 -0
  233. package/src/shared/__tests__/model-error.test.ts +289 -1
  234. package/src/shared/__tests__/synthesized-attachment.test.ts +1 -0
  235. package/src/shared/__tests__/vertex-adapter.test.ts +169 -0
  236. package/src/shared/__tests__/vertex-seam.test.ts +295 -0
  237. package/src/shared/approval-policy.ts +14 -7
  238. package/src/shared/attachment-naming.ts +78 -0
  239. package/src/shared/caller-identity.ts +40 -5
  240. package/src/shared/channel-attachment.ts +1 -0
  241. package/src/shared/checkpointer/__tests__/http-saver.test.ts +196 -1
  242. package/src/shared/checkpointer/http-saver.ts +117 -9
  243. package/src/shared/conversation-attachment.ts +1 -0
  244. package/src/shared/datastore-attachment.ts +106 -11
  245. package/src/shared/http-retry.ts +50 -0
  246. package/src/shared/llm-backend.ts +544 -0
  247. package/src/shared/llm-proxy.ts +15 -0
  248. package/src/shared/mcp-enabled-tools.ts +105 -0
  249. package/src/shared/mcp-manager.ts +21 -4
  250. package/src/shared/mcp-resolver.ts +73 -2
  251. package/src/shared/model-client.ts +161 -19
  252. package/src/shared/model-error.ts +222 -4
  253. package/src/shared/plan-mode-permissions.ts +30 -0
  254. package/src/worker.ts +4 -5
  255. package/src/workflow-engine/types.ts +18 -0
  256. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +68 -2
  257. package/src/workflows/call-agent-orchestrator.ts +10 -0
  258. package/src/workflows/connect-mcp-server.ts +7 -0
  259. package/src/workflows/engine-core.ts +23 -2
  260. package/src/workflows/execute-from-execution.ts +12 -2
@@ -600,24 +600,97 @@ describe("injectAttachments", () => {
600
600
  expect(Buffer.compare(writtenBuffer, binaryContent)).toBe(0);
601
601
  });
602
602
 
603
- it("detects mount path collision before any downloads", async () => {
603
+ it("uniquifies duplicate default-derived filenames instead of failing (issue #364)", async () => {
604
+ const contentA = Buffer.from("first");
605
+ const contentB = Buffer.from("second");
606
+ const storage = makeMockStorage();
607
+ await storage.upload("attachments/a/data.csv", contentA);
608
+ await storage.upload("attachments/b/data.csv", contentB);
609
+
610
+ const backend = mockWorkspaceBackend();
611
+
612
+ const result = await injectAttachments({
613
+ backend,
614
+ attachments: [
615
+ makeAttachment({ filename: "data.csv", storageKey: "attachments/a/data.csv" }),
616
+ makeAttachment({ filename: "data.csv", storageKey: "attachments/b/data.csv" }),
617
+ ],
618
+ storage,
619
+ isLocalMode: false,
620
+ });
621
+
622
+ // Both files land, both downloads run — the execution is never burned
623
+ // over a mechanically resolvable name.
624
+ expect(result).toHaveLength(2);
625
+ expect(result[0]).toMatchObject({
626
+ filename: "data.csv",
627
+ path: ".stigmer/inputs/data.csv",
628
+ });
629
+ expect(result[0].renamedFrom).toBeUndefined();
630
+ // The rename follows the platform's stem-2.ext semantics and carries the
631
+ // disclosure payload for the prompt.
632
+ expect(result[1]).toMatchObject({
633
+ filename: "data-2.csv",
634
+ path: ".stigmer/inputs/data-2.csv",
635
+ renamedFrom: "data.csv",
636
+ });
637
+ expect(backend.writeFileBuffer).toHaveBeenCalledWith(
638
+ ".stigmer/inputs/data.csv", contentA,
639
+ );
640
+ expect(backend.writeFileBuffer).toHaveBeenCalledWith(
641
+ ".stigmer/inputs/data-2.csv", contentB,
642
+ );
643
+ });
644
+
645
+ it("rejects two attachments explicitly pinning the same mountPath (a user contradiction)", async () => {
604
646
  const backend = mockWorkspaceBackend();
605
647
  const storage = makeMockStorage();
606
648
 
607
649
  await expect(injectAttachments({
608
650
  backend,
609
651
  attachments: [
610
- makeAttachment({ filename: "data.csv", storageKey: "key1" }),
611
- makeAttachment({ filename: "data.csv", storageKey: "key2" }),
652
+ makeAttachment({ filename: "a.csv", storageKey: "key1", mountPath: "inputs/data.csv" }),
653
+ makeAttachment({ filename: "b.csv", storageKey: "key2", mountPath: "inputs/data.csv" }),
612
654
  ],
613
655
  storage,
614
656
  isLocalMode: false,
615
657
  })).rejects.toThrow(/collides with/);
616
658
 
617
- // Verify no downloads were attempted
659
+ // The contradiction is detected before any downloads
618
660
  expect(storage.download).not.toHaveBeenCalled();
619
661
  });
620
662
 
663
+ it("default-derived names dodge an explicit mountPath already inside the inputs prefix", async () => {
664
+ const storage = makeMockStorage();
665
+ await storage.upload("attachments/a/pinned", Buffer.from("pinned"));
666
+ await storage.upload("attachments/b/report.pdf", Buffer.from("derived"));
667
+
668
+ const backend = mockWorkspaceBackend();
669
+
670
+ const result = await injectAttachments({
671
+ backend,
672
+ attachments: [
673
+ // Listed AFTER the default-derived attachment: explicit paths claim
674
+ // first regardless of order, so the default still dodges.
675
+ makeAttachment({ filename: "report.pdf", storageKey: "attachments/b/report.pdf" }),
676
+ makeAttachment({
677
+ filename: "report.pdf",
678
+ storageKey: "attachments/a/pinned",
679
+ mountPath: ".stigmer/inputs/report.pdf",
680
+ }),
681
+ ],
682
+ storage,
683
+ isLocalMode: false,
684
+ });
685
+
686
+ const byPath = new Map(result.map((f) => [f.path, f]));
687
+ expect(byPath.get(".stigmer/inputs/report.pdf")?.renamedFrom).toBeUndefined();
688
+ expect(byPath.get(".stigmer/inputs/report-2.pdf")).toMatchObject({
689
+ filename: "report-2.pdf",
690
+ renamedFrom: "report.pdf",
691
+ });
692
+ });
693
+
621
694
  it("derives filename from storageKey when filename is empty", async () => {
622
695
  const content = Buffer.from("fallback");
623
696
  const localFile = join(tempDir, "fallback.txt");
@@ -662,7 +735,7 @@ describe("injectAttachments", () => {
662
735
  expect(result[0].path).toBe("workspace/input.csv");
663
736
  });
664
737
 
665
- it("error message includes attachment filename and actionable suggestion", async () => {
738
+ it("explicit-collision error names both attachments and stays actionable", async () => {
666
739
  const backend = mockWorkspaceBackend();
667
740
  const storage = makeMockStorage();
668
741
 
@@ -670,8 +743,8 @@ describe("injectAttachments", () => {
670
743
  await injectAttachments({
671
744
  backend,
672
745
  attachments: [
673
- makeAttachment({ filename: "first.csv" }),
674
- makeAttachment({ filename: "first.csv", storageKey: "key2" }),
746
+ makeAttachment({ filename: "first.csv", mountPath: "inputs/shared.csv" }),
747
+ makeAttachment({ filename: "second.csv", storageKey: "key2", mountPath: "inputs/shared.csv" }),
675
748
  ],
676
749
  storage,
677
750
  isLocalMode: false,
@@ -680,7 +753,7 @@ describe("injectAttachments", () => {
680
753
  } catch (err) {
681
754
  expect(err).toBeInstanceOf(AttachmentInjectionError);
682
755
  const injErr = err as AttachmentInjectionError;
683
- expect(injErr.attachmentFilename).toBe("first.csv");
756
+ expect(injErr.attachmentFilename).toBe("second.csv");
684
757
  expect(injErr.message).toContain("first.csv");
685
758
  expect(injErr.message).toContain("mountPath");
686
759
  }
@@ -860,6 +933,35 @@ describe("injectAttachments — vision selection", () => {
860
933
  expect(injected.visionDegraded).toBe("type_mismatch");
861
934
  });
862
935
 
936
+ it("labels a duplicate-renamed image's vision payload with the on-disk name", async () => {
937
+ // Coherence pin: the prompt lists the renamed path and the vision
938
+ // disclosure lists image filenames — if the offer used the original
939
+ // attachment.filename, the agent would see two images with one
940
+ // indistinguishable name.
941
+ const backend = mockWorkspaceBackend();
942
+ const storage = makeMockStorage();
943
+ await storage.upload("attachments/a/photo.png", PNG_BYTES);
944
+ await storage.upload("attachments/b/photo.png", PNG_BYTES);
945
+
946
+ const injected = await injectAttachments({
947
+ backend,
948
+ attachments: [
949
+ makeAttachment({ filename: "photo.png", storageKey: "attachments/a/photo.png", contentType: "image/png" }),
950
+ makeAttachment({ filename: "photo.png", storageKey: "attachments/b/photo.png", contentType: "image/png" }),
951
+ ],
952
+ storage,
953
+ isLocalMode: false,
954
+ visionBudget: new VisionBudget(DEEP_AGENT_VISION_PROFILE),
955
+ });
956
+
957
+ expect(injected[0].vision?.filename).toBe("photo.png");
958
+ expect(injected[1]).toMatchObject({
959
+ filename: "photo-2.png",
960
+ renamedFrom: "photo.png",
961
+ });
962
+ expect(injected[1].vision?.filename).toBe("photo-2.png");
963
+ });
964
+
863
965
  it("never offers an extract archive to the budget — extracted files carry no vision fields", async () => {
864
966
  // A PNG inside a ZIP has no attachment-level bytes; the archive rides the
865
967
  // normal extraction story with no vision involvement or disclosure.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Datastore degradation disclosure, activity half (issue #325).
3
+ *
4
+ * The activity appends the operator-facing MESSAGE_SYSTEM notice to
5
+ * `initialStatus.messages` BEFORE streaming (index.ts) — deliberately not
6
+ * through a status builder, because the v2 and v3 stream paths each
7
+ * construct their own builder around that same proto. These tests pin the
8
+ * property that push site depends on: BOTH builders preserve pre-seeded
9
+ * messages (ExecutionState only appends; index rebuilds touch runtime
10
+ * tool-call state, and a MESSAGE_SYSTEM row carries no tool calls), so
11
+ * the notice survives whichever stream version runs and rides every
12
+ * persist. The prompt half (section selection) is pinned in
13
+ * shared/__tests__/datastore-attachment.test.ts.
14
+ */
15
+
16
+ import { describe, it, expect, beforeEach } from "vitest";
17
+ import { create } from "@bufbuild/protobuf";
18
+ import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
19
+ import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
20
+ import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
21
+ import { MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
22
+ import {
23
+ EXPECTED_RECORD_TOOLS,
24
+ formatDatastoreDegradationNotice,
25
+ } from "../../../shared/datastore-attachment.js";
26
+ import { StatusBuilder } from "../status-builder.js";
27
+ import { V3StatusBuilder } from "../v3-status-builder.js";
28
+ import { normalize } from "../v3-protocol-normalizer.js";
29
+ import {
30
+ resetSeq,
31
+ makeMessageStart,
32
+ makeMessageFinish,
33
+ makeTextDelta,
34
+ } from "../__test-utils__/v3-event-fixtures.js";
35
+
36
+ beforeEach(() => resetSeq());
37
+
38
+ /** initialStatus as index.ts shapes it when setup reports missing tools. */
39
+ function statusWithDegradationNotice(): AgentExecutionStatus {
40
+ const status = create(AgentExecutionStatusSchema, {});
41
+ status.messages.push(create(AgentMessageSchema, {
42
+ type: MessageType.MESSAGE_SYSTEM,
43
+ content: formatDatastoreDegradationNotice(1, [...EXPECTED_RECORD_TOOLS]),
44
+ timestamp: "2026-08-11T00:00:00.000Z",
45
+ }));
46
+ return status;
47
+ }
48
+
49
+ describe("datastore degradation notice survives streaming (issue #325)", () => {
50
+ it("v2 StatusBuilder preserves the pre-seeded notice through a streamed turn", () => {
51
+ const initialStatus = statusWithDegradationNotice();
52
+ const sb = new StatusBuilder("exec-1", initialStatus);
53
+
54
+ sb.processEvent({
55
+ event: "on_chat_model_stream",
56
+ run_id: "run-1",
57
+ data: { chunk: { content: "I cannot reach the datastore right now." } },
58
+ });
59
+ sb.processEvent({ event: "on_chat_model_end", run_id: "run-1", data: {} });
60
+
61
+ const messages = sb.currentStatus.messages;
62
+ expect(messages.length).toBeGreaterThanOrEqual(2);
63
+ expect(messages[0].type).toBe(MessageType.MESSAGE_SYSTEM);
64
+ expect(messages[0].content).toContain("0/5 record tools connected");
65
+ expect(messages[0].content).toContain("declared 1 datastore(s)");
66
+ });
67
+
68
+ it("v3 V3StatusBuilder preserves the pre-seeded notice through a streamed turn", () => {
69
+ const initialStatus = statusWithDegradationNotice();
70
+ const sb = new V3StatusBuilder("exec-1", initialStatus);
71
+
72
+ const protocolEvents = [
73
+ makeMessageStart("run-1"),
74
+ makeTextDelta("run-1", "I cannot reach the datastore right now."),
75
+ makeMessageFinish("run-1"),
76
+ ];
77
+ for (const raw of protocolEvents) {
78
+ for (const e of normalize(raw)) {
79
+ sb.processEvent(e);
80
+ }
81
+ }
82
+
83
+ const messages = sb.currentStatus.messages;
84
+ expect(messages.length).toBeGreaterThanOrEqual(2);
85
+ expect(messages[0].type).toBe(MessageType.MESSAGE_SYSTEM);
86
+ expect(messages[0].content).toContain("0/5 record tools connected");
87
+ });
88
+
89
+ it("both builders share the initialStatus proto, so the append needs no builder", () => {
90
+ // The push-site contract itself: mutating initialStatus.messages is
91
+ // visible through whichever builder wraps it.
92
+ const initialStatus = create(AgentExecutionStatusSchema, {});
93
+ const sb = new StatusBuilder("exec-1", initialStatus);
94
+ initialStatus.messages.push(create(AgentMessageSchema, {
95
+ type: MessageType.MESSAGE_SYSTEM,
96
+ content: formatDatastoreDegradationNotice(2, ["delete_record"]),
97
+ timestamp: "2026-08-11T00:00:00.000Z",
98
+ }));
99
+ expect(sb.currentStatus.messages).toHaveLength(1);
100
+ expect(sb.currentStatus.messages[0].content).toContain(
101
+ "4/5 record tools connected",
102
+ );
103
+ });
104
+ });
@@ -184,6 +184,7 @@ function fakeHttpRejectSetup(): SetupResult {
184
184
  session: { spec: { workspaceEntries: [] } },
185
185
  workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-reject-http" },
186
186
  mcpConnection: null,
187
+ datastoreToolsMissing: [],
187
188
  mergedEnvVars: {},
188
189
  secretKeys: new Set<string>(),
189
190
  modelName: "claude-sonnet",
@@ -219,6 +220,7 @@ function fakeMemoryRejectSetup(): SetupResult {
219
220
  session: { spec: { workspaceEntries: [] } },
220
221
  workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-reject-mem" },
221
222
  mcpConnection: null,
223
+ datastoreToolsMissing: [],
222
224
  mergedEnvVars: {},
223
225
  secretKeys: new Set<string>(),
224
226
  modelName: "claude-sonnet",
@@ -264,6 +264,7 @@ function fakeDurableResumeSetup(): SetupResult {
264
264
  // path shared across files would serialize parallel test workers for real.
265
265
  workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-hitl-approve-all" },
266
266
  mcpConnection: null,
267
+ datastoreToolsMissing: [],
267
268
  mergedEnvVars: {},
268
269
  secretKeys: new Set<string>(),
269
270
  modelName: "claude-sonnet",
@@ -219,6 +219,7 @@ function fakeDurableResumeSetup(): SetupResult {
219
219
  // path shared across files would serialize parallel test workers for real.
220
220
  workspaceBackend: { rootDir: "/tmp/stigmer-test-ws-hitl-history" },
221
221
  mcpConnection: null,
222
+ datastoreToolsMissing: [],
222
223
  mergedEnvVars: {},
223
224
  secretKeys: new Set<string>(),
224
225
  modelName: "claude-sonnet",
@@ -142,6 +142,10 @@ describe("buildEnhancedSystemPrompt", () => {
142
142
  });
143
143
 
144
144
  it("includes injected files section with size info", () => {
145
+ // The section renders the injector's own result type (sizeBytes). A local
146
+ // structural twin with a `size` field used to live in the prompt builder,
147
+ // and production wiring silently dropped the size — this pin holds the
148
+ // real field name so that drift class cannot return.
145
149
  const prompt = buildEnhancedSystemPrompt({
146
150
  instructions: "Test",
147
151
  provisionResults: [],
@@ -150,15 +154,40 @@ describe("buildEnhancedSystemPrompt", () => {
150
154
  workspaceFileRefs: [],
151
155
  workspaceRoot: "/workspace",
152
156
  injectedFiles: [
153
- { filename: "data.csv", path: ".stigmer/inputs/data.csv", size: 1024 },
154
- { filename: "notes.md", path: ".stigmer/inputs/notes.md", size: null },
157
+ { filename: "data.csv", path: ".stigmer/inputs/data.csv", sizeBytes: 1024 },
158
+ { filename: "notes.md", path: ".stigmer/inputs/notes.md", sizeBytes: 12 },
155
159
  ],
156
160
  });
157
161
 
158
162
  expect(prompt).toContain("## Input Files");
159
163
  expect(prompt).toContain("`.stigmer/inputs/data.csv` (1024 bytes)");
160
- expect(prompt).toContain("`.stigmer/inputs/notes.md`");
161
- expect(prompt).not.toContain("null");
164
+ expect(prompt).toContain("`.stigmer/inputs/notes.md` (12 bytes)");
165
+ });
166
+
167
+ it("discloses a duplicate-renamed injected file's original name", () => {
168
+ const prompt = buildEnhancedSystemPrompt({
169
+ instructions: "Test",
170
+ provisionResults: [],
171
+ containerRoot: "",
172
+ skillsPromptSection: "",
173
+ workspaceFileRefs: [],
174
+ workspaceRoot: "/workspace",
175
+ injectedFiles: [
176
+ { filename: "report.pdf", path: ".stigmer/inputs/report.pdf", sizeBytes: 10 },
177
+ {
178
+ filename: "report-2.pdf",
179
+ path: ".stigmer/inputs/report-2.pdf",
180
+ sizeBytes: 20,
181
+ renamedFrom: "report.pdf",
182
+ },
183
+ ],
184
+ });
185
+
186
+ expect(prompt).toContain(
187
+ "`.stigmer/inputs/report-2.pdf` (20 bytes) (renamed from duplicate 'report.pdf')",
188
+ );
189
+ // The first file keeps a clean entry — no disclosure noise.
190
+ expect(prompt).toContain("`.stigmer/inputs/report.pdf` (10 bytes)\n");
162
191
  });
163
192
 
164
193
  it("produces a git repo description for git workspace entries", () => {
@@ -361,7 +390,7 @@ describe("buildEnhancedSystemPrompt", () => {
361
390
  const planFile = {
362
391
  filename: "plan.md",
363
392
  path: ".stigmer/inputs/plan.md",
364
- size: 1024,
393
+ sizeBytes: 1024,
365
394
  };
366
395
 
367
396
  it("appends the implement-plan directive pointing at the injected plan", () => {
@@ -196,6 +196,7 @@ function fakeMemoryGateSetup(opts: {
196
196
  readFile: vi.fn(async () => ""),
197
197
  },
198
198
  mcpConnection: null,
199
+ datastoreToolsMissing: [],
199
200
  mergedEnvVars: {},
200
201
  secretKeys: new Set<string>(),
201
202
  modelName: "claude-sonnet",
@@ -0,0 +1,173 @@
1
+ /**
2
+ * End-to-end proof (real deepagents + LangGraph runtime, no LLM/network) that a
3
+ * plan-mode SUB-AGENT is read-only BY CONSTRUCTION (issue #255).
4
+ *
5
+ * The parent graph receives PLAN_MODE_PERMISSIONS in setup.ts, but deepagents'
6
+ * parent-permission inheritance covers only spec-style sub-agents — our
7
+ * pre-built CompiledSubAgents bypass `normalizeSubagentSpec` entirely, so
8
+ * `compileSubagents` must bake the rules into each sub-agent graph itself.
9
+ * These tests exercise that exact wiring with NO approval gate installed (the
10
+ * auto-approve-all shape): any denial must come from the permission rules
11
+ * alone, proving plan mode's "read-only by construction, not by gate"
12
+ * contract at the sub-agent level. A contrast case pins that without the
13
+ * rules (act mode) the same write flows — this change is a plan-mode-only
14
+ * delta.
15
+ *
16
+ * The rule under test is the PRODUCTION constant from
17
+ * shared/plan-mode-permissions.ts (the same one setup.ts applies to both
18
+ * graph sites), not a test copy, so a change to the plan-mode permission set
19
+ * re-proves sub-agent coverage automatically.
20
+ */
21
+
22
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
23
+ import { mkdtemp, mkdir, rm, readFile, writeFile, access } from "node:fs/promises";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { HumanMessage, ToolMessage, type BaseMessage } from "@langchain/core/messages";
27
+
28
+ import { PLAN_MODE_PERMISSIONS } from "../../../shared/plan-mode-permissions.js";
29
+ import { CasCaptureObserver } from "../cas-capture-observer.js";
30
+ import { compileSubagents } from "../subagent-transformer.js";
31
+ import { ScriptedModel, type ScriptSelector } from "../__test-utils__/scripted-model.js";
32
+
33
+ const SEEDED_CONTENT = "PLAN_MODE_README_TOKEN: hello from the seeded file";
34
+
35
+ describe("plan-mode sub-agent filesystem permissions (issue #255)", () => {
36
+ let root: string;
37
+ let observer: CasCaptureObserver;
38
+
39
+ beforeEach(async () => {
40
+ root = await mkdtemp(join(tmpdir(), "sa-plan-"));
41
+ await mkdir(join(root, "dist"), { recursive: true });
42
+ await writeFile(join(root, "notes.md"), SEEDED_CONTENT);
43
+ // Everything is gitignored from the observer's view — the strictest case:
44
+ // every write would be CAS-observed if it ever reached the backend.
45
+ observer = new CasCaptureObserver({ rootDir: root, isIgnored: async () => true });
46
+ });
47
+
48
+ afterEach(async () => {
49
+ await rm(root, { recursive: true, force: true });
50
+ });
51
+
52
+ /**
53
+ * Compile a worker through the production path with NO approval gate, so the
54
+ * permission rules are the only thing standing between the model and the
55
+ * filesystem. `permissions` present = the plan-mode wiring under test;
56
+ * absent = the act-mode contrast.
57
+ */
58
+ async function compileWorker(script: ScriptSelector, planMode: boolean) {
59
+ const compiled = await compileSubagents(
60
+ [{ name: "worker", description: "test worker", systemPrompt: "do the work", tools: [] }],
61
+ {
62
+ approvalGate: null,
63
+ parentModelName: "test-model",
64
+ workspaceRootDir: root,
65
+ casObserver: observer,
66
+ modelFactory: async () => new ScriptedModel(script),
67
+ ...(planMode ? { permissions: PLAN_MODE_PERMISSIONS } : {}),
68
+ },
69
+ );
70
+ expect(compiled).toHaveLength(1);
71
+ return compiled[0];
72
+ }
73
+
74
+ function toolResultById(messages: BaseMessage[], toolCallId: string): string {
75
+ const match = messages.find(
76
+ (m): m is ToolMessage => m instanceof ToolMessage && m.tool_call_id === toolCallId,
77
+ );
78
+ expect(match, `expected a tool result for call '${toolCallId}'`).toBeDefined();
79
+ return typeof match!.content === "string" ? match!.content : JSON.stringify(match!.content);
80
+ }
81
+
82
+ it("denies write_file and edit_file at the tool level, while read_file still works", async () => {
83
+ // One turn, three tool calls: both mutation tools must be denied by the
84
+ // permission rules; the read must pass through untouched (the deny rule
85
+ // covers the `write` operation class only).
86
+ // Real absolute workspace paths, as production models use (the prompt's
87
+ // file tree is absolute, and the tools' contract says absolute). With
88
+ // permission rules present this is also the only shape that reaches the
89
+ // rules at all: enforcePermission's canonicalization refuses relative
90
+ // paths with a validation error before any rule or backend runs — a
91
+ // different denial, but still nothing written.
92
+ const script: ScriptSelector = () => ({
93
+ toolCalls: [
94
+ { name: "write_file", args: { file_path: join(root, "dist/out.txt"), content: "new file" }, id: "c_write" },
95
+ { name: "edit_file", args: { file_path: join(root, "notes.md"), old_string: "hello", new_string: "HACKED" }, id: "c_edit" },
96
+ { name: "read_file", args: { file_path: join(root, "notes.md") }, id: "c_read" },
97
+ ],
98
+ done: "worker done",
99
+ });
100
+
101
+ const worker = await compileWorker(script, true);
102
+ const result = (await worker.runnable.invoke(
103
+ { messages: [new HumanMessage({ content: "go" })] },
104
+ { configurable: { thread_id: "t1" }, recursionLimit: 50 },
105
+ )) as { messages: BaseMessage[] };
106
+
107
+ // Both mutations denied with deepagents' permission error, and nothing on disk:
108
+ // the new file was never created and the seeded file is byte-identical.
109
+ expect(toolResultById(result.messages, "c_write")).toMatch(/permission denied for write/i);
110
+ expect(toolResultById(result.messages, "c_edit")).toMatch(/permission denied for write/i);
111
+ await expect(access(join(root, "dist/out.txt"))).rejects.toThrow();
112
+ expect(await readFile(join(root, "notes.md"), "utf8")).toBe(SEEDED_CONTENT);
113
+
114
+ // The read flowed: its result carries the seeded content, not a denial.
115
+ const readResult = toolResultById(result.messages, "c_read");
116
+ expect(readResult).toContain("PLAN_MODE_README_TOKEN");
117
+ expect(readResult).not.toMatch(/permission denied/i);
118
+
119
+ // The denial fired INSIDE the tool handler, before any backend was reached:
120
+ // the CAS observer (which records pre-write bytes on the backend's write/edit
121
+ // seams) saw neither mutation.
122
+ expect(observer.before.size).toBe(0);
123
+ });
124
+
125
+ it("act-mode contrast: without the permissions option the same write lands (zero-delta outside plan mode)", async () => {
126
+ const script: ScriptSelector = () => ({
127
+ toolCalls: [
128
+ { name: "write_file", args: { file_path: join(root, "dist/out.txt"), content: "new file" }, id: "c_write" },
129
+ ],
130
+ done: "worker done",
131
+ });
132
+
133
+ const worker = await compileWorker(script, false);
134
+ const result = (await worker.runnable.invoke(
135
+ { messages: [new HumanMessage({ content: "go" })] },
136
+ { configurable: { thread_id: "t2" }, recursionLimit: 50 },
137
+ )) as { messages: BaseMessage[] };
138
+
139
+ expect(toolResultById(result.messages, "c_write")).not.toMatch(/permission denied/i);
140
+ expect(await readFile(join(root, "dist/out.txt"), "utf8")).toBe("new file");
141
+ // And the backend WAS reached this time — the observer recorded a write.
142
+ // (Key shape is DD-19 territory, deliberately not pinned here.)
143
+ expect(observer.before.size).toBe(1);
144
+ });
145
+
146
+ it("relative-path mutations are refused too — by validation, before the rules", async () => {
147
+ // The native prompt steers models to workspace-relative paths, but
148
+ // deepagents' permission canonicalization accepts only absolute ones, so
149
+ // with rules present a relative-path call fails validation before any
150
+ // rule (or backend) runs. Read-only still holds — nothing is written —
151
+ // but the model sees "path must be absolute", not "permission denied",
152
+ // and prompt-compliant relative READS degrade the same way. That
153
+ // relative-vs-absolute friction is a pre-existing parent-plan-mode
154
+ // behavior, tracked as issue #429; this pins that the sub-agent's
155
+ // read-only contract survives it.
156
+ const script: ScriptSelector = () => ({
157
+ toolCalls: [
158
+ { name: "write_file", args: { file_path: "dist/out.txt", content: "new file" }, id: "c_write_rel" },
159
+ ],
160
+ done: "worker done",
161
+ });
162
+
163
+ const worker = await compileWorker(script, true);
164
+ const result = (await worker.runnable.invoke(
165
+ { messages: [new HumanMessage({ content: "go" })] },
166
+ { configurable: { thread_id: "t3" }, recursionLimit: 50 },
167
+ )) as { messages: BaseMessage[] };
168
+
169
+ expect(toolResultById(result.messages, "c_write_rel")).toMatch(/path must be absolute/i);
170
+ await expect(access(join(root, "dist/out.txt"))).rejects.toThrow();
171
+ expect(observer.before.size).toBe(0);
172
+ });
173
+ });
@@ -7,10 +7,11 @@ describe("buildSubAgentMiddleware", () => {
7
7
  it("returns the correct middleware order without cost cap", () => {
8
8
  const stack = buildSubAgentMiddleware();
9
9
 
10
- expect(stack).toHaveLength(3);
10
+ expect(stack).toHaveLength(4);
11
11
  expect(stack[0].name).toBe("LoopDetectionMiddleware");
12
12
  expect(stack[1].name).toBe("ExecutionBudgetMiddleware");
13
13
  expect(stack[2].name).toBe("ToolTruncationMiddleware");
14
+ expect(stack[3].name).toBe("ErrorHintsMiddleware");
14
15
  });
15
16
 
16
17
  it("includes cost cap view when parent cost cap is provided", () => {
@@ -24,8 +25,9 @@ describe("buildSubAgentMiddleware", () => {
24
25
 
25
26
  const stack = buildSubAgentMiddleware({ costCap: parentCostCap });
26
27
 
27
- expect(stack).toHaveLength(4);
28
+ expect(stack).toHaveLength(5);
28
29
  expect(stack[3].name).toBe("CostCapSubAgentView");
30
+ expect(stack[4].name).toBe("ErrorHintsMiddleware");
29
31
  });
30
32
 
31
33
  it("sub-agent cost cap view shares parent state", () => {
@@ -75,8 +77,8 @@ describe("buildSubAgentMiddleware", () => {
75
77
  approvalGate: { policies: new Map(), toolServerMap: new Map() },
76
78
  });
77
79
 
78
- // loop, budget, truncation, approval gate
79
- expect(stack).toHaveLength(4);
80
+ // loop, budget, truncation, approval gate, error hints
81
+ expect(stack).toHaveLength(5);
80
82
  expect(stack[3].name).toBe("ApprovalGateMiddleware");
81
83
  expect(stack[3].wrapToolCall).toBeDefined();
82
84
  });
@@ -84,7 +86,7 @@ describe("buildSubAgentMiddleware", () => {
84
86
  it("omits the approval gate when approvalGate is null (auto-approve-all parity)", () => {
85
87
  const stack = buildSubAgentMiddleware({ approvalGate: null });
86
88
 
87
- expect(stack).toHaveLength(3);
89
+ expect(stack).toHaveLength(4);
88
90
  expect(stack.some((m) => m.name === "ApprovalGateMiddleware")).toBe(false);
89
91
  });
90
92
 
@@ -102,10 +104,13 @@ describe("buildSubAgentMiddleware", () => {
102
104
  approvalGate: { policies: new Map(), toolServerMap: new Map() },
103
105
  });
104
106
 
105
- // loop, budget, truncation, approval gate, cost cap view
106
- expect(stack).toHaveLength(5);
107
+ // loop, budget, truncation, approval gate, cost cap view, error hints.
108
+ // Hints AFTER the gate matches the parent nesting: the gate's HITL
109
+ // interrupt stays outside the hints' try/catch (issue #255).
110
+ expect(stack).toHaveLength(6);
107
111
  expect(stack[3].name).toBe("ApprovalGateMiddleware");
108
112
  expect(stack[4].name).toBe("CostCapSubAgentView");
113
+ expect(stack[5].name).toBe("ErrorHintsMiddleware");
109
114
  });
110
115
 
111
116
  // captureIgnored is the structural coupling that makes sub-agent gitignored