@mcpmesh/sdk 1.3.4 → 2.0.0-beta.1

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 (262) hide show
  1. package/dist/__tests__/a2a/a2a-bearer.spec.d.ts +2 -0
  2. package/dist/__tests__/a2a/a2a-bearer.spec.d.ts.map +1 -0
  3. package/dist/__tests__/a2a/a2a-bearer.spec.js +58 -0
  4. package/dist/__tests__/a2a/a2a-bearer.spec.js.map +1 -0
  5. package/dist/__tests__/a2a/a2a-client.spec.d.ts +2 -0
  6. package/dist/__tests__/a2a/a2a-client.spec.d.ts.map +1 -0
  7. package/dist/__tests__/a2a/a2a-client.spec.js +334 -0
  8. package/dist/__tests__/a2a/a2a-client.spec.js.map +1 -0
  9. package/dist/__tests__/a2a/a2a-job.spec.d.ts +2 -0
  10. package/dist/__tests__/a2a/a2a-job.spec.d.ts.map +1 -0
  11. package/dist/__tests__/a2a/a2a-job.spec.js +255 -0
  12. package/dist/__tests__/a2a/a2a-job.spec.js.map +1 -0
  13. package/dist/__tests__/a2a/a2a-stream.spec.d.ts +2 -0
  14. package/dist/__tests__/a2a/a2a-stream.spec.d.ts.map +1 -0
  15. package/dist/__tests__/a2a/a2a-stream.spec.js +278 -0
  16. package/dist/__tests__/a2a/a2a-stream.spec.js.map +1 -0
  17. package/dist/__tests__/a2a/agent-a2a-config.spec.d.ts +2 -0
  18. package/dist/__tests__/a2a/agent-a2a-config.spec.d.ts.map +1 -0
  19. package/dist/__tests__/a2a/agent-a2a-config.spec.js +262 -0
  20. package/dist/__tests__/a2a/agent-a2a-config.spec.js.map +1 -0
  21. package/dist/__tests__/a2a/producer/auth-filter.spec.d.ts +2 -0
  22. package/dist/__tests__/a2a/producer/auth-filter.spec.d.ts.map +1 -0
  23. package/dist/__tests__/a2a/producer/auth-filter.spec.js +127 -0
  24. package/dist/__tests__/a2a/producer/auth-filter.spec.js.map +1 -0
  25. package/dist/__tests__/a2a/producer/card-builder.spec.d.ts +2 -0
  26. package/dist/__tests__/a2a/producer/card-builder.spec.d.ts.map +1 -0
  27. package/dist/__tests__/a2a/producer/card-builder.spec.js +113 -0
  28. package/dist/__tests__/a2a/producer/card-builder.spec.js.map +1 -0
  29. package/dist/__tests__/a2a/producer/dispatcher.spec.d.ts +2 -0
  30. package/dist/__tests__/a2a/producer/dispatcher.spec.d.ts.map +1 -0
  31. package/dist/__tests__/a2a/producer/dispatcher.spec.js +850 -0
  32. package/dist/__tests__/a2a/producer/dispatcher.spec.js.map +1 -0
  33. package/dist/__tests__/a2a/producer/mount-surface-push.spec.d.ts +2 -0
  34. package/dist/__tests__/a2a/producer/mount-surface-push.spec.d.ts.map +1 -0
  35. package/dist/__tests__/a2a/producer/mount-surface-push.spec.js +164 -0
  36. package/dist/__tests__/a2a/producer/mount-surface-push.spec.js.map +1 -0
  37. package/dist/__tests__/a2a/producer/mount.spec.d.ts +2 -0
  38. package/dist/__tests__/a2a/producer/mount.spec.d.ts.map +1 -0
  39. package/dist/__tests__/a2a/producer/mount.spec.js +433 -0
  40. package/dist/__tests__/a2a/producer/mount.spec.js.map +1 -0
  41. package/dist/__tests__/a2a/producer/public-url-cache.spec.d.ts +2 -0
  42. package/dist/__tests__/a2a/producer/public-url-cache.spec.d.ts.map +1 -0
  43. package/dist/__tests__/a2a/producer/public-url-cache.spec.js +116 -0
  44. package/dist/__tests__/a2a/producer/public-url-cache.spec.js.map +1 -0
  45. package/dist/__tests__/a2a/producer/sse-emitter.spec.d.ts +2 -0
  46. package/dist/__tests__/a2a/producer/sse-emitter.spec.d.ts.map +1 -0
  47. package/dist/__tests__/a2a/producer/sse-emitter.spec.js +754 -0
  48. package/dist/__tests__/a2a/producer/sse-emitter.spec.js.map +1 -0
  49. package/dist/__tests__/a2a/producer/state-translator.spec.d.ts +2 -0
  50. package/dist/__tests__/a2a/producer/state-translator.spec.d.ts.map +1 -0
  51. package/dist/__tests__/a2a/producer/state-translator.spec.js +124 -0
  52. package/dist/__tests__/a2a/producer/state-translator.spec.js.map +1 -0
  53. package/dist/__tests__/a2a/producer/task-store.spec.d.ts +2 -0
  54. package/dist/__tests__/a2a/producer/task-store.spec.d.ts.map +1 -0
  55. package/dist/__tests__/a2a/producer/task-store.spec.js +180 -0
  56. package/dist/__tests__/a2a/producer/task-store.spec.js.map +1 -0
  57. package/dist/__tests__/agent-add-tool.spec.d.ts +2 -0
  58. package/dist/__tests__/agent-add-tool.spec.d.ts.map +1 -0
  59. package/dist/__tests__/agent-add-tool.spec.js +483 -0
  60. package/dist/__tests__/agent-add-tool.spec.js.map +1 -0
  61. package/dist/__tests__/api-runtime-race.spec.d.ts +2 -0
  62. package/dist/__tests__/api-runtime-race.spec.d.ts.map +1 -0
  63. package/dist/__tests__/api-runtime-race.spec.js +193 -0
  64. package/dist/__tests__/api-runtime-race.spec.js.map +1 -0
  65. package/dist/__tests__/claim-dispatcher.spec.d.ts +2 -0
  66. package/dist/__tests__/claim-dispatcher.spec.d.ts.map +1 -0
  67. package/dist/__tests__/claim-dispatcher.spec.js +408 -0
  68. package/dist/__tests__/claim-dispatcher.spec.js.map +1 -0
  69. package/dist/__tests__/inbound-job-dispatch.spec.d.ts +2 -0
  70. package/dist/__tests__/inbound-job-dispatch.spec.d.ts.map +1 -0
  71. package/dist/__tests__/inbound-job-dispatch.spec.js +185 -0
  72. package/dist/__tests__/inbound-job-dispatch.spec.js.map +1 -0
  73. package/dist/__tests__/job-controller-progress.spec.d.ts +2 -0
  74. package/dist/__tests__/job-controller-progress.spec.d.ts.map +1 -0
  75. package/dist/__tests__/job-controller-progress.spec.js +85 -0
  76. package/dist/__tests__/job-controller-progress.spec.js.map +1 -0
  77. package/dist/__tests__/jobs-cancel-route.spec.d.ts +2 -0
  78. package/dist/__tests__/jobs-cancel-route.spec.d.ts.map +1 -0
  79. package/dist/__tests__/jobs-cancel-route.spec.js +88 -0
  80. package/dist/__tests__/jobs-cancel-route.spec.js.map +1 -0
  81. package/dist/__tests__/llm-agent-stream.test.d.ts +14 -0
  82. package/dist/__tests__/llm-agent-stream.test.d.ts.map +1 -0
  83. package/dist/__tests__/llm-agent-stream.test.js +341 -0
  84. package/dist/__tests__/llm-agent-stream.test.js.map +1 -0
  85. package/dist/__tests__/llm-provider.test.js +22 -1
  86. package/dist/__tests__/llm-provider.test.js.map +1 -1
  87. package/dist/__tests__/media-resolver.test.js +40 -0
  88. package/dist/__tests__/media-resolver.test.js.map +1 -1
  89. package/dist/__tests__/mesh-job-submitter.spec.d.ts +2 -0
  90. package/dist/__tests__/mesh-job-submitter.spec.d.ts.map +1 -0
  91. package/dist/__tests__/mesh-job-submitter.spec.js +110 -0
  92. package/dist/__tests__/mesh-job-submitter.spec.js.map +1 -0
  93. package/dist/__tests__/proxy-stream.test.d.ts +9 -0
  94. package/dist/__tests__/proxy-stream.test.d.ts.map +1 -0
  95. package/dist/__tests__/proxy-stream.test.js +347 -0
  96. package/dist/__tests__/proxy-stream.test.js.map +1 -0
  97. package/dist/__tests__/resolver-meshjob.spec.d.ts +26 -0
  98. package/dist/__tests__/resolver-meshjob.spec.d.ts.map +1 -0
  99. package/dist/__tests__/resolver-meshjob.spec.js +201 -0
  100. package/dist/__tests__/resolver-meshjob.spec.js.map +1 -0
  101. package/dist/__tests__/schema-verdict-policy.test.d.ts +6 -0
  102. package/dist/__tests__/schema-verdict-policy.test.d.ts.map +1 -0
  103. package/dist/__tests__/schema-verdict-policy.test.js +126 -0
  104. package/dist/__tests__/schema-verdict-policy.test.js.map +1 -0
  105. package/dist/__tests__/sse-stream.test.d.ts +12 -0
  106. package/dist/__tests__/sse-stream.test.d.ts.map +1 -0
  107. package/dist/__tests__/sse-stream.test.js +170 -0
  108. package/dist/__tests__/sse-stream.test.js.map +1 -0
  109. package/dist/a2a/a2a-bearer.d.ts +27 -0
  110. package/dist/a2a/a2a-bearer.d.ts.map +1 -0
  111. package/dist/a2a/a2a-bearer.js +63 -0
  112. package/dist/a2a/a2a-bearer.js.map +1 -0
  113. package/dist/a2a/a2a-client.d.ts +114 -0
  114. package/dist/a2a/a2a-client.d.ts.map +1 -0
  115. package/dist/a2a/a2a-client.js +405 -0
  116. package/dist/a2a/a2a-client.js.map +1 -0
  117. package/dist/a2a/a2a-event.d.ts +25 -0
  118. package/dist/a2a/a2a-event.d.ts.map +1 -0
  119. package/dist/a2a/a2a-event.js +9 -0
  120. package/dist/a2a/a2a-event.js.map +1 -0
  121. package/dist/a2a/a2a-job.d.ts +58 -0
  122. package/dist/a2a/a2a-job.d.ts.map +1 -0
  123. package/dist/a2a/a2a-job.js +264 -0
  124. package/dist/a2a/a2a-job.js.map +1 -0
  125. package/dist/a2a/a2a-stream.d.ts +39 -0
  126. package/dist/a2a/a2a-stream.d.ts.map +1 -0
  127. package/dist/a2a/a2a-stream.js +290 -0
  128. package/dist/a2a/a2a-stream.js.map +1 -0
  129. package/dist/a2a/errors.d.ts +29 -0
  130. package/dist/a2a/errors.d.ts.map +1 -0
  131. package/dist/a2a/errors.js +48 -0
  132. package/dist/a2a/errors.js.map +1 -0
  133. package/dist/a2a/index.d.ts +12 -0
  134. package/dist/a2a/index.d.ts.map +1 -0
  135. package/dist/a2a/index.js +11 -0
  136. package/dist/a2a/index.js.map +1 -0
  137. package/dist/a2a/producer/auth-filter.d.ts +34 -0
  138. package/dist/a2a/producer/auth-filter.d.ts.map +1 -0
  139. package/dist/a2a/producer/auth-filter.js +39 -0
  140. package/dist/a2a/producer/auth-filter.js.map +1 -0
  141. package/dist/a2a/producer/card-builder.d.ts +59 -0
  142. package/dist/a2a/producer/card-builder.d.ts.map +1 -0
  143. package/dist/a2a/producer/card-builder.js +59 -0
  144. package/dist/a2a/producer/card-builder.js.map +1 -0
  145. package/dist/a2a/producer/dispatcher.d.ts +276 -0
  146. package/dist/a2a/producer/dispatcher.d.ts.map +1 -0
  147. package/dist/a2a/producer/dispatcher.js +896 -0
  148. package/dist/a2a/producer/dispatcher.js.map +1 -0
  149. package/dist/a2a/producer/index.d.ts +26 -0
  150. package/dist/a2a/producer/index.d.ts.map +1 -0
  151. package/dist/a2a/producer/index.js +23 -0
  152. package/dist/a2a/producer/index.js.map +1 -0
  153. package/dist/a2a/producer/mount.d.ts +75 -0
  154. package/dist/a2a/producer/mount.d.ts.map +1 -0
  155. package/dist/a2a/producer/mount.js +422 -0
  156. package/dist/a2a/producer/mount.js.map +1 -0
  157. package/dist/a2a/producer/public-url-cache.d.ts +73 -0
  158. package/dist/a2a/producer/public-url-cache.d.ts.map +1 -0
  159. package/dist/a2a/producer/public-url-cache.js +0 -0
  160. package/dist/a2a/producer/public-url-cache.js.map +1 -0
  161. package/dist/a2a/producer/registry.d.ts +138 -0
  162. package/dist/a2a/producer/registry.d.ts.map +1 -0
  163. package/dist/a2a/producer/registry.js +117 -0
  164. package/dist/a2a/producer/registry.js.map +1 -0
  165. package/dist/a2a/producer/sse-emitter.d.ts +85 -0
  166. package/dist/a2a/producer/sse-emitter.d.ts.map +1 -0
  167. package/dist/a2a/producer/sse-emitter.js +405 -0
  168. package/dist/a2a/producer/sse-emitter.js.map +1 -0
  169. package/dist/a2a/producer/state-translator.d.ts +63 -0
  170. package/dist/a2a/producer/state-translator.d.ts.map +1 -0
  171. package/dist/a2a/producer/state-translator.js +108 -0
  172. package/dist/a2a/producer/state-translator.js.map +1 -0
  173. package/dist/a2a/producer/task-store.d.ts +128 -0
  174. package/dist/a2a/producer/task-store.d.ts.map +1 -0
  175. package/dist/a2a/producer/task-store.js +128 -0
  176. package/dist/a2a/producer/task-store.js.map +1 -0
  177. package/dist/agent.d.ts +99 -0
  178. package/dist/agent.d.ts.map +1 -1
  179. package/dist/agent.js +754 -19
  180. package/dist/agent.js.map +1 -1
  181. package/dist/api-runtime.d.ts +25 -0
  182. package/dist/api-runtime.d.ts.map +1 -1
  183. package/dist/api-runtime.js +75 -2
  184. package/dist/api-runtime.js.map +1 -1
  185. package/dist/claim-dispatcher.d.ts +126 -0
  186. package/dist/claim-dispatcher.d.ts.map +1 -0
  187. package/dist/claim-dispatcher.js +478 -0
  188. package/dist/claim-dispatcher.js.map +1 -0
  189. package/dist/express.d.ts.map +1 -1
  190. package/dist/express.js +33 -6
  191. package/dist/express.js.map +1 -1
  192. package/dist/inbound-job-dispatch.d.ts +105 -0
  193. package/dist/inbound-job-dispatch.d.ts.map +1 -0
  194. package/dist/inbound-job-dispatch.js +335 -0
  195. package/dist/inbound-job-dispatch.js.map +1 -0
  196. package/dist/index.d.ts +40 -4
  197. package/dist/index.d.ts.map +1 -1
  198. package/dist/index.js +40 -3
  199. package/dist/index.js.map +1 -1
  200. package/dist/job-context.d.ts +107 -0
  201. package/dist/job-context.d.ts.map +1 -0
  202. package/dist/job-context.js +95 -0
  203. package/dist/job-context.js.map +1 -0
  204. package/dist/jobs-cancel-route.d.ts +36 -0
  205. package/dist/jobs-cancel-route.d.ts.map +1 -0
  206. package/dist/jobs-cancel-route.js +60 -0
  207. package/dist/jobs-cancel-route.js.map +1 -0
  208. package/dist/jobs-helper-tools.d.ts +48 -0
  209. package/dist/jobs-helper-tools.d.ts.map +1 -0
  210. package/dist/jobs-helper-tools.js +133 -0
  211. package/dist/jobs-helper-tools.js.map +1 -0
  212. package/dist/llm-agent.d.ts +62 -53
  213. package/dist/llm-agent.d.ts.map +1 -1
  214. package/dist/llm-agent.js +211 -292
  215. package/dist/llm-agent.js.map +1 -1
  216. package/dist/llm-provider.d.ts +11 -4
  217. package/dist/llm-provider.d.ts.map +1 -1
  218. package/dist/llm-provider.js +57 -4
  219. package/dist/llm-provider.js.map +1 -1
  220. package/dist/llm.d.ts +4 -1
  221. package/dist/llm.d.ts.map +1 -1
  222. package/dist/llm.js +7 -17
  223. package/dist/llm.js.map +1 -1
  224. package/dist/media/resolver.d.ts.map +1 -1
  225. package/dist/media/resolver.js +3 -2
  226. package/dist/media/resolver.js.map +1 -1
  227. package/dist/mesh-job-submitter.d.ts +83 -0
  228. package/dist/mesh-job-submitter.d.ts.map +1 -0
  229. package/dist/mesh-job-submitter.js +143 -0
  230. package/dist/mesh-job-submitter.js.map +1 -0
  231. package/dist/provider-handlers/gemini-handler.js +5 -0
  232. package/dist/provider-handlers/gemini-handler.js.map +1 -1
  233. package/dist/proxy.d.ts +40 -0
  234. package/dist/proxy.d.ts.map +1 -1
  235. package/dist/proxy.js +375 -2
  236. package/dist/proxy.js.map +1 -1
  237. package/dist/resolver-meshjob.d.ts +170 -0
  238. package/dist/resolver-meshjob.d.ts.map +1 -0
  239. package/dist/resolver-meshjob.js +159 -0
  240. package/dist/resolver-meshjob.js.map +1 -0
  241. package/dist/route.d.ts +4 -0
  242. package/dist/route.d.ts.map +1 -1
  243. package/dist/route.js.map +1 -1
  244. package/dist/schema-normalize.d.ts +62 -0
  245. package/dist/schema-normalize.d.ts.map +1 -0
  246. package/dist/schema-normalize.js +128 -0
  247. package/dist/schema-normalize.js.map +1 -0
  248. package/dist/sse-stream.d.ts +44 -0
  249. package/dist/sse-stream.d.ts.map +1 -0
  250. package/dist/sse-stream.js +173 -0
  251. package/dist/sse-stream.js.map +1 -0
  252. package/dist/tool-worker-entry.d.ts +21 -0
  253. package/dist/tool-worker-entry.d.ts.map +1 -0
  254. package/dist/tool-worker-entry.js +162 -0
  255. package/dist/tool-worker-entry.js.map +1 -0
  256. package/dist/tool-worker-pool.d.ts +49 -0
  257. package/dist/tool-worker-pool.d.ts.map +1 -0
  258. package/dist/tool-worker-pool.js +272 -0
  259. package/dist/tool-worker-pool.js.map +1 -0
  260. package/dist/types.d.ts +351 -9
  261. package/dist/types.d.ts.map +1 -1
  262. package/package.json +5 -3
@@ -0,0 +1,754 @@
1
+ /**
2
+ * Unit tests for `sse-emitter.ts` (spec §4.6 / §4.7 / §5).
3
+ *
4
+ * Coverage:
5
+ * - single-frame plan: artifact + terminal frames + headers
6
+ * - sync-completed plan: artifact frame BEFORE terminal status frame
7
+ * - long-running plan:
8
+ * - initial state=working, final=false
9
+ * - progress-changed frame
10
+ * - progress-unchanged → suppressed
11
+ * - keepalive comment after KEEPALIVE_MILLIS
12
+ * - terminal completed: artifact frame BEFORE terminal status frame
13
+ * - terminal failed / canceled: terminal status frame only (no artifact)
14
+ * - status() throws transiently → state=working frame, NOT terminal
15
+ * (Java BLOCKER #934 fix)
16
+ * - 5 consecutive status() failures → stream closes WITHOUT marking
17
+ * terminal (Java BLOCKER #934 fix)
18
+ * - MAX_STREAM_MILLIS cap → state=working, final=false frame, NOT
19
+ * final=true (Java BLOCKER #934 W3 fix)
20
+ * - Client disconnect mid-stream → loop exits without calling
21
+ * proxy.cancel() (spec §7.3)
22
+ * - tasks/resubscribe:
23
+ * - Unknown id → JSON-RPC -32602 (NOT SSE)
24
+ * - Terminal task → one SSE frame with final=true
25
+ * - Lost JobProxy → single state=failed terminal frame
26
+ * (Java BLOCKER #934 fix)
27
+ * - Appendix A golden-frame assertions:
28
+ * - typeof final === "boolean"
29
+ * - typeof metadata.progress === "number"
30
+ * - parts[0].type === "text"
31
+ *
32
+ * Mocking strategy:
33
+ * - Fake Express Request/Response with capturing `write()`, `end()`,
34
+ * `setHeader()`, and `on()` so we can drive `close` events without
35
+ * binding a real http server.
36
+ * - Fake JobProxy via the dispatcher's duck-typed path.
37
+ * - vitest fake timers for keepalive + MAX_STREAM_MILLIS.
38
+ *
39
+ * Mirrors Java's `MeshA2ASseEmitterTest`.
40
+ */
41
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
42
+ import { EventEmitter } from "node:events";
43
+ import { A2ATaskStore } from "../../../a2a/producer/task-store.js";
44
+ import { RouteRegistry } from "../../../route.js";
45
+ import { buildSseDispatcherMiddleware, renderSsePlan, POLL_INTERVAL_MILLIS, KEEPALIVE_MILLIS, MAX_STREAM_MILLIS, MAX_CONSECUTIVE_STATUS_FAILURES, } from "../../../a2a/producer/sse-emitter.js";
46
+ import { buildResubscribeStream, buildStatusUpdateFrame, JSONRPC_INVALID_PARAMS, } from "../../../a2a/producer/dispatcher.js";
47
+ function makeRes() {
48
+ const ee = new EventEmitter();
49
+ // Build the object first with placeholders so vi.fn closures can
50
+ // reference the final res via lexical scope (no `this` binding needed —
51
+ // .bind() strips the spy metadata so toHaveBeenCalled fails).
52
+ const res = {
53
+ writableEnded: false,
54
+ destroyed: false,
55
+ _written: [],
56
+ };
57
+ res.status = vi.fn((code) => {
58
+ res._statusCode = code;
59
+ return res;
60
+ });
61
+ res.setHeader = vi.fn();
62
+ res.type = vi.fn((t) => {
63
+ res._sentType = t;
64
+ return res;
65
+ });
66
+ res.send = vi.fn((body) => {
67
+ res._sentBody = body;
68
+ return res;
69
+ });
70
+ res.write = vi.fn((data) => {
71
+ res._written.push(data);
72
+ return true;
73
+ });
74
+ res.end = vi.fn(() => {
75
+ res.writableEnded = true;
76
+ return res;
77
+ });
78
+ res.flushHeaders = vi.fn();
79
+ res.on = (e, l) => {
80
+ ee.on(e, l);
81
+ };
82
+ res.removeListener = (e, l) => {
83
+ ee.removeListener(e, l);
84
+ };
85
+ res.emit = (e) => ee.emit(e);
86
+ return res;
87
+ }
88
+ function makeReq(body = {}) {
89
+ const ee = new EventEmitter();
90
+ return {
91
+ body,
92
+ headers: {},
93
+ on: (e, l) => {
94
+ ee.on(e, l);
95
+ },
96
+ removeListener: (e, l) => {
97
+ ee.removeListener(e, l);
98
+ },
99
+ emit: (e) => ee.emit(e),
100
+ _ee: ee,
101
+ };
102
+ }
103
+ /** Parse the JSON envelope from one `data: ...\n\n` frame. */
104
+ function parseFrame(raw) {
105
+ expect(raw.startsWith("data: ")).toBe(true);
106
+ const json = raw.slice("data: ".length).replace(/\n\n$/, "");
107
+ return JSON.parse(json);
108
+ }
109
+ function fakeProxy(opts = {}) {
110
+ return {
111
+ jobId: opts.jobId ?? "job-x",
112
+ status: vi.fn(opts.status ?? (async () => ({ status: "running" }))),
113
+ wait: vi.fn(opts.wait ?? (async () => null)),
114
+ cancel: vi.fn(opts.cancel ?? (async () => undefined)),
115
+ };
116
+ }
117
+ function makeSurface() {
118
+ return {
119
+ path: "/agents/sse",
120
+ skillId: "sse",
121
+ skillName: "sse",
122
+ description: "",
123
+ tags: [],
124
+ dependencies: [],
125
+ auth: "",
126
+ routeId: "rid",
127
+ };
128
+ }
129
+ function makeDeps(handler, taskStore) {
130
+ const surface = makeSurface();
131
+ const routeRegistry = RouteRegistry.getInstance();
132
+ const routeId = routeRegistry.registerRoute("A2A", surface.path, []);
133
+ return {
134
+ surface: { ...surface, routeId },
135
+ handler,
136
+ taskStore,
137
+ routeRegistry,
138
+ };
139
+ }
140
+ // ────────────────────────────────────────────────────────────────────────
141
+ // renderSsePlan — single-frame + sync-completed shapes
142
+ // ────────────────────────────────────────────────────────────────────────
143
+ describe("renderSsePlan: single-frame (spec §5)", () => {
144
+ it("writes one data frame with the spec-mandated headers", async () => {
145
+ const res = makeRes();
146
+ const req = makeReq();
147
+ const store = new A2ATaskStore();
148
+ const frame = buildStatusUpdateFrame(1, "task-1", "completed", null, true, null);
149
+ const plan = { kind: "single-frame", frame };
150
+ await renderSsePlan(req, res, plan, store);
151
+ // Headers per spec §5.1
152
+ const headerMap = Object.fromEntries(res.setHeader.mock.calls);
153
+ expect(headerMap["Content-Type"]).toBe("text/event-stream");
154
+ expect(headerMap["Cache-Control"]).toBe("no-cache");
155
+ expect(headerMap["Connection"]).toBe("keep-alive");
156
+ expect(headerMap["X-Accel-Buffering"]).toBe("no");
157
+ expect(res.flushHeaders).toHaveBeenCalled();
158
+ // One data frame + end()
159
+ expect(res._written).toHaveLength(1);
160
+ expect(res._written[0]).toMatch(/^data: /);
161
+ const parsed = parseFrame(res._written[0]);
162
+ expect(parsed.jsonrpc).toBe("2.0");
163
+ expect(res.end).toHaveBeenCalled();
164
+ });
165
+ });
166
+ describe("renderSsePlan: sync-completed (spec §5.3)", () => {
167
+ it("emits artifact frame BEFORE terminal status frame, then closes", async () => {
168
+ const res = makeRes();
169
+ const req = makeReq();
170
+ const store = new A2ATaskStore();
171
+ const plan = {
172
+ kind: "sync-completed",
173
+ reqId: 1,
174
+ taskId: "task-2",
175
+ artifactFrame: {
176
+ jsonrpc: "2.0",
177
+ id: 1,
178
+ result: {
179
+ id: "task-2",
180
+ artifact: { parts: [{ type: "text", text: "ok" }] },
181
+ },
182
+ },
183
+ terminalFrame: buildStatusUpdateFrame(1, "task-2", "completed", null, true, null),
184
+ };
185
+ await renderSsePlan(req, res, plan, store);
186
+ expect(res._written).toHaveLength(2);
187
+ const f1 = parseFrame(res._written[0]);
188
+ const f2 = parseFrame(res._written[1]);
189
+ // Artifact first, then terminal status (spec §5.3 ordering).
190
+ expect(f1.result.artifact).toBeDefined();
191
+ expect(f2.result.status
192
+ .state).toBe("completed");
193
+ // Appendix A: final on terminal frame is a real boolean.
194
+ expect(f2.result.final).toBe(true);
195
+ expect(typeof f2.result.final).toBe("boolean");
196
+ expect(res.end).toHaveBeenCalled();
197
+ });
198
+ });
199
+ // ────────────────────────────────────────────────────────────────────────
200
+ // renderSsePlan: error plan
201
+ // ────────────────────────────────────────────────────────────────────────
202
+ describe("renderSsePlan: error plan -> JSON response (NOT SSE)", () => {
203
+ it("returns the supplied JSON-RPC error body with the supplied HTTP status", async () => {
204
+ const res = makeRes();
205
+ const req = makeReq();
206
+ const store = new A2ATaskStore();
207
+ const plan = {
208
+ kind: "error",
209
+ errorBody: {
210
+ jsonrpc: "2.0",
211
+ error: { code: JSONRPC_INVALID_PARAMS, message: "Unknown task id: ghost" },
212
+ id: null,
213
+ },
214
+ httpStatus: 200,
215
+ };
216
+ await renderSsePlan(req, res, plan, store);
217
+ expect(res._statusCode).toBe(200);
218
+ expect(res._sentType).toBe("application/json");
219
+ const body = JSON.parse(res._sentBody);
220
+ expect(body.error.code).toBe(JSONRPC_INVALID_PARAMS);
221
+ // No SSE frames written.
222
+ expect(res._written).toHaveLength(0);
223
+ });
224
+ });
225
+ // ────────────────────────────────────────────────────────────────────────
226
+ // renderSsePlan: long-running poll loop
227
+ // ────────────────────────────────────────────────────────────────────────
228
+ describe("renderSsePlan: long-running poll loop (spec §5.3)", () => {
229
+ beforeEach(() => {
230
+ vi.useFakeTimers();
231
+ });
232
+ afterEach(() => {
233
+ vi.useRealTimers();
234
+ });
235
+ /**
236
+ * Helper: drive a long-running plan to completion under fake timers.
237
+ * Advances the timer in `pollCount` steps so the poll loop iterates.
238
+ */
239
+ async function runLongRunning(proxy, taskId, polls) {
240
+ const res = makeRes();
241
+ const req = makeReq();
242
+ const store = new A2ATaskStore();
243
+ store.put(taskId, { sessionId: taskId, jobProxy: proxy });
244
+ const plan = {
245
+ kind: "long-running",
246
+ reqId: 1,
247
+ taskId,
248
+ proxy: proxy,
249
+ };
250
+ const promise = renderSsePlan(req, res, plan, store);
251
+ // Iteratively flush microtasks + advance the poll timer.
252
+ for (let i = 0; i < polls; i++) {
253
+ await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MILLIS);
254
+ }
255
+ // One extra microtask flush so any final awaits resolve.
256
+ await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MILLIS);
257
+ await promise;
258
+ return { res, req, store };
259
+ }
260
+ /**
261
+ * Initial frame: state=working, final=false.
262
+ * Terminal frame on completed: artifact BEFORE terminal status.
263
+ */
264
+ it("emits initial working frame, then artifact + terminal status on completed", async () => {
265
+ let callCount = 0;
266
+ const proxy = fakeProxy({
267
+ status: async () => {
268
+ callCount += 1;
269
+ if (callCount >= 2)
270
+ return { status: "completed" };
271
+ return { status: "running" };
272
+ },
273
+ wait: async () => "final-payload",
274
+ });
275
+ const { res, store } = await runLongRunning(proxy, "task-lr-1", 3);
276
+ // Expected order: initial working, then artifact, then terminal.
277
+ expect(res._written.length).toBeGreaterThanOrEqual(3);
278
+ const frames = res._written.filter((w) => w.startsWith("data: ")).map(parseFrame);
279
+ const initial = frames[0].result;
280
+ expect(initial.status.state).toBe("working");
281
+ expect(initial.final).toBe(false);
282
+ expect(typeof initial.final).toBe("boolean");
283
+ // Find artifact frame + terminal status frame.
284
+ const artifactFrame = frames.find((f) => f.result.artifact !== undefined);
285
+ expect(artifactFrame).toBeDefined();
286
+ const terminalFrame = frames.find((f) => f.result.final === true &&
287
+ f.result.status
288
+ ?.state === "completed");
289
+ expect(terminalFrame).toBeDefined();
290
+ // Task store: marked terminal with the projected envelope.
291
+ const cached = store.get("task-lr-1");
292
+ expect(cached.terminalEnvelope).toBeDefined();
293
+ });
294
+ /** Terminal failed → single final=true status frame, NO artifact frame. */
295
+ it("terminal=failed -> single final status frame, no artifact", async () => {
296
+ let n = 0;
297
+ const proxy = fakeProxy({
298
+ status: async () => {
299
+ n += 1;
300
+ if (n >= 2)
301
+ return { status: "failed", error: "boom" };
302
+ return { status: "running" };
303
+ },
304
+ });
305
+ const { res } = await runLongRunning(proxy, "task-lr-fail", 3);
306
+ const frames = res._written.filter((w) => w.startsWith("data: ")).map(parseFrame);
307
+ expect(frames.some((f) => f.result.artifact)).toBe(false);
308
+ const terminal = frames.find((f) => f.result.final === true &&
309
+ f.result.status
310
+ ?.state === "failed");
311
+ expect(terminal).toBeDefined();
312
+ const status = terminal.result
313
+ .status;
314
+ const msg = status.message;
315
+ const parts = msg.parts;
316
+ // Appendix A: type === "text".
317
+ expect(parts[0].type).toBe("text");
318
+ expect(parts[0].text).toBe("boom");
319
+ expect(proxy.wait).not.toHaveBeenCalled();
320
+ });
321
+ /** Terminal canceled → single final=true status frame; spelling US. */
322
+ it("terminal=canceled (US spelling) -> single final status frame", async () => {
323
+ let n = 0;
324
+ const proxy = fakeProxy({
325
+ status: async () => {
326
+ n += 1;
327
+ if (n >= 2)
328
+ return { status: "cancelled" }; // mesh emits UK
329
+ return { status: "running" };
330
+ },
331
+ });
332
+ const { res } = await runLongRunning(proxy, "task-lr-cancel", 3);
333
+ const frames = res._written.filter((w) => w.startsWith("data: ")).map(parseFrame);
334
+ const terminal = frames.find((f) => f.result.final === true);
335
+ expect(terminal).toBeDefined();
336
+ const state = terminal.result.status.state;
337
+ expect(state).toBe("canceled"); // US spelling per spec §7.2
338
+ expect(state).not.toBe("cancelled");
339
+ });
340
+ /**
341
+ * Progress-changed → frame emitted.
342
+ * Progress-unchanged on subsequent polls → suppression.
343
+ */
344
+ it("progress-changed -> frame; progress-unchanged -> suppressed", async () => {
345
+ const responses = [
346
+ { status: "running", progress: 0.1 }, // 1st poll: change -> emit
347
+ { status: "running", progress: 0.1 }, // 2nd poll: same -> suppress
348
+ { status: "running", progress: 0.5 }, // 3rd poll: change -> emit
349
+ { status: "completed" }, // 4th poll: terminal
350
+ ];
351
+ let i = 0;
352
+ const proxy = fakeProxy({
353
+ status: async () => responses[Math.min(i++, responses.length - 1)],
354
+ wait: async () => "done",
355
+ });
356
+ const { res } = await runLongRunning(proxy, "task-prog", 5);
357
+ const frames = res._written.filter((w) => w.startsWith("data: ")).map(parseFrame);
358
+ // Count working frames with metadata.progress.
359
+ const workingProgressFrames = frames.filter((f) => {
360
+ const r = f.result;
361
+ const meta = r.metadata;
362
+ const state = r.status?.state;
363
+ return state === "working" && meta && typeof meta.progress === "number";
364
+ });
365
+ // Should be 2 distinct progress frames (0.1 and 0.5), NOT 3.
366
+ expect(workingProgressFrames.length).toBe(2);
367
+ expect(workingProgressFrames[0].result
368
+ .metadata).toEqual({ progress: 0.1 });
369
+ expect(workingProgressFrames[1].result
370
+ .metadata).toEqual({ progress: 0.5 });
371
+ // Appendix A: progress is a real JSON number.
372
+ expect(typeof workingProgressFrames[0].result
373
+ .metadata.progress).toBe("number");
374
+ });
375
+ /**
376
+ * Keepalive: after KEEPALIVE_MILLIS of inactivity (no progress change),
377
+ * a `: keepalive\n\n` comment line is emitted.
378
+ */
379
+ it("keepalive emitted after KEEPALIVE_MILLIS of inactivity", async () => {
380
+ const proxy = fakeProxy({ status: async () => ({ status: "running" }) });
381
+ const res = makeRes();
382
+ const req = makeReq();
383
+ const store = new A2ATaskStore();
384
+ store.put("task-ka", { sessionId: "task-ka", jobProxy: proxy });
385
+ const plan = {
386
+ kind: "long-running",
387
+ reqId: 1,
388
+ taskId: "task-ka",
389
+ proxy: proxy,
390
+ };
391
+ const renderPromise = renderSsePlan(req, res, plan, store);
392
+ // Advance well past KEEPALIVE_MILLIS — should see one keepalive comment.
393
+ const iters = Math.ceil((KEEPALIVE_MILLIS * 2) / POLL_INTERVAL_MILLIS);
394
+ for (let i = 0; i < iters; i++) {
395
+ await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MILLIS);
396
+ }
397
+ // Force disconnect to terminate the loop cleanly.
398
+ res.writableEnded = true;
399
+ res.emit("close");
400
+ await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MILLIS);
401
+ await renderPromise;
402
+ const keepalives = res._written.filter((s) => s === ": keepalive\n\n");
403
+ expect(keepalives.length).toBeGreaterThanOrEqual(1);
404
+ });
405
+ /**
406
+ * W2 regression: a transient-failure frame counts as activity, so
407
+ * `lastEventTime` MUST be refreshed when one is emitted. Otherwise
408
+ * the keepalive branch can trip KEEPALIVE_MILLIS after the LAST
409
+ * successful frame even though a transient data frame went out in
410
+ * between. Cosmetic — but a deviation from the progress-changed
411
+ * branch and the keepalive branch which BOTH update
412
+ * `lastEventTime`.
413
+ *
414
+ * Scenario: drive the loop with continuous transient failures
415
+ * starting at t=0. The cap (MAX_CONSECUTIVE_STATUS_FAILURES=5)
416
+ * closes the stream within ~5 seconds — well under
417
+ * KEEPALIVE_MILLIS=15s — so a legitimate keepalive should never
418
+ * fire. We assert exactly MAX_CONSECUTIVE_STATUS_FAILURES
419
+ * "status unavailable" data frames and ZERO keepalive comment
420
+ * lines. With the bug, the test still passes because the bug
421
+ * manifests on a SUBSEQUENT suppressed-progress poll, not within
422
+ * the transient loop itself — so we additionally insert one
423
+ * suppressed-progress poll between transients to expose the
424
+ * stale-`lastEventTime` path: the bug emits a keepalive between
425
+ * the transient frames within KEEPALIVE_MILLIS; the fix does not.
426
+ */
427
+ it("transient frames refresh lastEventTime — no spurious keepalive within KEEPALIVE_MILLIS (W2)", async () => {
428
+ let n = 0;
429
+ const proxy = fakeProxy({
430
+ status: async () => {
431
+ n += 1;
432
+ // First call: success frame to anchor `lastEventTime`.
433
+ if (n === 1)
434
+ return { status: "running" };
435
+ // Throw on every subsequent call so we hit the transient
436
+ // branch repeatedly. The cap (5 consecutive) closes the
437
+ // stream — but we want to span > KEEPALIVE_MILLIS, so use a
438
+ // higher consecutive count via interleaved successes.
439
+ // Strategy: alternate throw / success-with-same-status so
440
+ // `consecutiveStatusFailures` resets, but no progress change
441
+ // → keepalive check is the only thing that can refresh
442
+ // `lastEventTime` (in the bug path). With the fix, transient
443
+ // frames refresh it. We drive 18 polls (well past
444
+ // KEEPALIVE_MILLIS) and assert that with the fix at most 1
445
+ // keepalive fires, vs. ≥ 2 without the fix.
446
+ if (n % 2 === 0)
447
+ throw new Error("transient");
448
+ return { status: "running" };
449
+ },
450
+ });
451
+ const res = makeRes();
452
+ const req = makeReq();
453
+ const store = new A2ATaskStore();
454
+ store.put("task-w2", { sessionId: "task-w2", jobProxy: proxy });
455
+ const plan = {
456
+ kind: "long-running",
457
+ reqId: 1,
458
+ taskId: "task-w2",
459
+ proxy: proxy,
460
+ };
461
+ const renderPromise = renderSsePlan(req, res, plan, store);
462
+ // Drive 2 × KEEPALIVE_MILLIS worth of polls so two keepalives
463
+ // could in principle fire. With the fix, transient frames keep
464
+ // `lastEventTime` fresh — keepalives only fire when `now -
465
+ // lastEventTime > KEEPALIVE_MILLIS` AND we hit the
466
+ // suppressed-progress branch with no transient in between. Per
467
+ // our alternating pattern, transient frames are emitted on
468
+ // every even poll → `lastEventTime` is refreshed at most 1s
469
+ // apart, suppressing every spurious keepalive.
470
+ const iters = Math.ceil((2 * KEEPALIVE_MILLIS) / POLL_INTERVAL_MILLIS) + 2;
471
+ for (let i = 0; i < iters; i++) {
472
+ await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MILLIS);
473
+ }
474
+ res.writableEnded = true;
475
+ res.emit("close");
476
+ await renderPromise;
477
+ const transients = res._written.filter((w) => w.startsWith("data: ") && w.includes("status unavailable:"));
478
+ const keepalives = res._written.filter((s) => s === ": keepalive\n\n");
479
+ // Several transient frames were emitted (one per even poll up
480
+ // until disconnect).
481
+ expect(transients.length).toBeGreaterThanOrEqual(2);
482
+ // With the fix, every transient frame refreshes `lastEventTime`
483
+ // so the gap to the next suppressed-progress poll is at most 1s
484
+ // — well under KEEPALIVE_MILLIS=15s. ZERO keepalives are
485
+ // expected. Without the fix, suppressed polls at t≈16, 17, ...
486
+ // emit redundant keepalives even though a transient frame just
487
+ // went out a poll earlier.
488
+ expect(keepalives.length).toBe(0);
489
+ });
490
+ /**
491
+ * #934 BLOCKER fix: status() throws transiently → emit state=working
492
+ * frame (NOT terminal failed) and continue polling.
493
+ */
494
+ it("status() throw -> state=working frame, NOT terminal failed (#934)", async () => {
495
+ let n = 0;
496
+ const proxy = fakeProxy({
497
+ status: async () => {
498
+ n += 1;
499
+ if (n === 1)
500
+ return { status: "running" }; // initial poll OK
501
+ if (n <= 3)
502
+ throw new Error("transient");
503
+ return { status: "running" };
504
+ },
505
+ });
506
+ const res = makeRes();
507
+ const req = makeReq();
508
+ const store = new A2ATaskStore();
509
+ store.put("task-trans", { sessionId: "task-trans", jobProxy: proxy });
510
+ const plan = {
511
+ kind: "long-running",
512
+ reqId: 1,
513
+ taskId: "task-trans",
514
+ proxy: proxy,
515
+ };
516
+ const renderPromise = renderSsePlan(req, res, plan, store);
517
+ // Drive through several polls.
518
+ for (let i = 0; i < 5; i++) {
519
+ await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MILLIS);
520
+ }
521
+ // Force disconnect to terminate cleanly.
522
+ res.writableEnded = true;
523
+ res.emit("close");
524
+ await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MILLIS);
525
+ await renderPromise;
526
+ const frames = res._written.filter((w) => w.startsWith("data: ")).map(parseFrame);
527
+ // No terminal=failed frame should have been emitted.
528
+ const terminalFailed = frames.find((f) => f.result.final === true &&
529
+ f.result.status
530
+ ?.state === "failed");
531
+ expect(terminalFailed).toBeUndefined();
532
+ // At least one working frame should carry "status unavailable:" message.
533
+ const transientFrame = frames.find((f) => {
534
+ const r = f.result;
535
+ const status = r.status;
536
+ const msg = status?.message;
537
+ const parts = msg?.parts;
538
+ const text = parts?.[0]?.text;
539
+ return text?.startsWith("status unavailable:");
540
+ });
541
+ expect(transientFrame).toBeDefined();
542
+ // Task store record is NOT marked terminal.
543
+ expect(store.get("task-trans")?.terminalEnvelope).toBeUndefined();
544
+ });
545
+ /**
546
+ * #934 BLOCKER fix: MAX_CONSECUTIVE_STATUS_FAILURES consecutive throws
547
+ * close the stream WITHOUT marking the task store record terminal.
548
+ */
549
+ it("5 consecutive status() failures -> stream closes WITHOUT marking terminal (#934)", async () => {
550
+ const proxy = fakeProxy({
551
+ status: async () => {
552
+ throw new Error("registry down");
553
+ },
554
+ });
555
+ const res = makeRes();
556
+ const req = makeReq();
557
+ const store = new A2ATaskStore();
558
+ store.put("task-cap", { sessionId: "task-cap", jobProxy: proxy });
559
+ const plan = {
560
+ kind: "long-running",
561
+ reqId: 1,
562
+ taskId: "task-cap",
563
+ proxy: proxy,
564
+ };
565
+ const renderPromise = renderSsePlan(req, res, plan, store);
566
+ // Drive enough iterations to exceed the cap.
567
+ for (let i = 0; i < MAX_CONSECUTIVE_STATUS_FAILURES + 2; i++) {
568
+ await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MILLIS);
569
+ }
570
+ await renderPromise;
571
+ // res.end was called (stream closed).
572
+ expect(res.end).toHaveBeenCalled();
573
+ // Task store record was NOT marked terminal — client can resume.
574
+ expect(store.get("task-cap")?.terminalEnvelope).toBeUndefined();
575
+ expect(store.get("task-cap")?.jobProxy).toBe(proxy);
576
+ });
577
+ /**
578
+ * #934 BLOCKER W3 fix: MAX_STREAM_MILLIS cap emits state=working,
579
+ * final=false (NOT final=true) so clients know to resubscribe.
580
+ */
581
+ it("MAX_STREAM_MILLIS cap emits state=working, final=false (#934 W3)", async () => {
582
+ const proxy = fakeProxy({ status: async () => ({ status: "running" }) });
583
+ const res = makeRes();
584
+ const req = makeReq();
585
+ const store = new A2ATaskStore();
586
+ store.put("task-cap2", { sessionId: "task-cap2", jobProxy: proxy });
587
+ const plan = {
588
+ kind: "long-running",
589
+ reqId: 1,
590
+ taskId: "task-cap2",
591
+ proxy: proxy,
592
+ };
593
+ const renderPromise = renderSsePlan(req, res, plan, store);
594
+ // Advance past the stream cap (1h).
595
+ await vi.advanceTimersByTimeAsync(MAX_STREAM_MILLIS + POLL_INTERVAL_MILLIS);
596
+ await renderPromise;
597
+ // Filter out keepalive comment lines — only parse data frames.
598
+ const dataFrames = res._written.filter((w) => w.startsWith("data: "));
599
+ const frames = dataFrames.map(parseFrame);
600
+ // Find the cap frame — last working frame with explanatory message.
601
+ const capFrame = frames.find((f) => {
602
+ const r = f.result;
603
+ const status = r.status;
604
+ const msg = status?.message;
605
+ const parts = msg?.parts;
606
+ const text = parts?.[0]?.text;
607
+ return text?.includes("producer-side cap");
608
+ });
609
+ expect(capFrame).toBeDefined();
610
+ const r = capFrame.result;
611
+ expect(r.status.state).toBe("working");
612
+ // CRITICAL invariant: final=false, NOT true.
613
+ expect(r.final).toBe(false);
614
+ expect(typeof r.final).toBe("boolean");
615
+ // Task store NOT marked terminal — client can resubscribe.
616
+ expect(store.get("task-cap2")?.terminalEnvelope).toBeUndefined();
617
+ });
618
+ /**
619
+ * Spec §7.3: client SSE disconnect MUST NOT cancel the underlying job.
620
+ */
621
+ it("client disconnect mid-stream -> loop exits WITHOUT proxy.cancel()", async () => {
622
+ const proxy = fakeProxy({ status: async () => ({ status: "running" }) });
623
+ const res = makeRes();
624
+ const req = makeReq();
625
+ const store = new A2ATaskStore();
626
+ store.put("task-disc", { sessionId: "task-disc", jobProxy: proxy });
627
+ const plan = {
628
+ kind: "long-running",
629
+ reqId: 1,
630
+ taskId: "task-disc",
631
+ proxy: proxy,
632
+ };
633
+ const renderPromise = renderSsePlan(req, res, plan, store);
634
+ // Tick once so the loop is mid-sleep.
635
+ await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MILLIS);
636
+ // Simulate disconnect.
637
+ res.writableEnded = true;
638
+ res.emit("close");
639
+ await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MILLIS);
640
+ await renderPromise;
641
+ // Spec §7.3: cancel() MUST NOT have been called.
642
+ expect(proxy.cancel).not.toHaveBeenCalled();
643
+ // Task store preserved (not marked terminal).
644
+ expect(store.get("task-disc")?.terminalEnvelope).toBeUndefined();
645
+ expect(store.get("task-disc")?.jobProxy).toBe(proxy);
646
+ });
647
+ });
648
+ // ────────────────────────────────────────────────────────────────────────
649
+ // tasks/resubscribe
650
+ // ────────────────────────────────────────────────────────────────────────
651
+ describe("buildResubscribeStream (spec §4.7)", () => {
652
+ let store;
653
+ beforeEach(() => {
654
+ RouteRegistry.reset();
655
+ store = new A2ATaskStore();
656
+ });
657
+ /** Spec §4.7 errors: unknown id → JSON-RPC -32602 (NOT SSE). */
658
+ it("unknown task id -> JSON-RPC -32602 error plan (NOT SSE)", () => {
659
+ const plan = buildResubscribeStream(1, { id: "ghost" }, store);
660
+ expect(plan.kind).toBe("error");
661
+ if (plan.kind === "error") {
662
+ const err = plan.errorBody.error;
663
+ expect(err.code).toBe(JSONRPC_INVALID_PARAMS);
664
+ expect(err.message.toLowerCase()).toContain("unknown task id");
665
+ }
666
+ });
667
+ /** Missing id → JSON-RPC -32602. */
668
+ it("missing id -> JSON-RPC -32602 error plan", () => {
669
+ const plan = buildResubscribeStream(1, {}, store);
670
+ expect(plan.kind).toBe("error");
671
+ if (plan.kind === "error") {
672
+ expect(plan.errorBody.error.code).toBe(JSONRPC_INVALID_PARAMS);
673
+ }
674
+ });
675
+ /** Terminal record → single SSE frame with final=true. */
676
+ it("terminal task -> single SSE frame with final=true", async () => {
677
+ const env = {
678
+ id: "t1",
679
+ sessionId: "t1",
680
+ status: { state: "completed", timestamp: "x" },
681
+ artifacts: [],
682
+ history: [],
683
+ };
684
+ store.put("t1", {
685
+ sessionId: "t1",
686
+ terminalEnvelope: env,
687
+ terminalAt: Date.now(),
688
+ jobProxy: null,
689
+ });
690
+ const plan = buildResubscribeStream(1, { id: "t1" }, store);
691
+ expect(plan.kind).toBe("single-frame");
692
+ if (plan.kind === "single-frame") {
693
+ const result = plan.frame.result;
694
+ expect(result.final).toBe(true);
695
+ expect(typeof result.final).toBe("boolean");
696
+ expect(result.status.state).toBe("completed");
697
+ }
698
+ });
699
+ /** Non-terminal record + JobProxy → long-running plan. */
700
+ it("non-terminal + JobProxy -> long-running plan", () => {
701
+ const proxy = fakeProxy({ jobId: "j-resub" });
702
+ store.put("t2", { sessionId: "t2", jobProxy: proxy });
703
+ const plan = buildResubscribeStream(1, { id: "t2" }, store);
704
+ expect(plan.kind).toBe("long-running");
705
+ if (plan.kind === "long-running") {
706
+ expect(plan.taskId).toBe("t2");
707
+ expect(plan.proxy).toBe(proxy);
708
+ }
709
+ });
710
+ /**
711
+ * #934 BLOCKER fix: lost JobProxy on non-terminal record → single
712
+ * SSE frame with state=failed + final=true so the client doesn't hang.
713
+ */
714
+ it("lost JobProxy non-terminal -> single state=failed terminal frame (#934)", () => {
715
+ store.put("t3", { sessionId: "t3", jobProxy: null });
716
+ const plan = buildResubscribeStream(1, { id: "t3" }, store);
717
+ expect(plan.kind).toBe("single-frame");
718
+ if (plan.kind === "single-frame") {
719
+ const result = plan.frame.result;
720
+ expect(result.status.state).toBe("failed");
721
+ expect(result.final).toBe(true);
722
+ }
723
+ });
724
+ });
725
+ // ────────────────────────────────────────────────────────────────────────
726
+ // SSE dispatcher middleware fall-through
727
+ // ────────────────────────────────────────────────────────────────────────
728
+ describe("buildSseDispatcherMiddleware: routing", () => {
729
+ let store;
730
+ beforeEach(() => {
731
+ RouteRegistry.reset();
732
+ store = new A2ATaskStore();
733
+ });
734
+ /** Non-SSE methods fall through to next(). */
735
+ it("calls next() for non-SSE methods", async () => {
736
+ const deps = makeDeps((async () => "ok"), store);
737
+ const mw = buildSseDispatcherMiddleware(deps);
738
+ const next = vi.fn();
739
+ const res = makeRes();
740
+ await mw(makeReq({ jsonrpc: "2.0", method: "tasks/send", params: {} }), res, next);
741
+ expect(next).toHaveBeenCalledTimes(1);
742
+ expect(res._written).toHaveLength(0);
743
+ });
744
+ /** Non-object body falls through to next() (canonical -32700 from JSON-RPC dispatcher). */
745
+ it("calls next() on null body", async () => {
746
+ const deps = makeDeps((async () => "ok"), store);
747
+ const mw = buildSseDispatcherMiddleware(deps);
748
+ const next = vi.fn();
749
+ const res = makeRes();
750
+ await mw(makeReq(null), res, next);
751
+ expect(next).toHaveBeenCalledTimes(1);
752
+ });
753
+ });
754
+ //# sourceMappingURL=sse-emitter.spec.js.map