@mcpmesh/sdk 1.4.1 → 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.
- package/dist/__tests__/a2a/a2a-bearer.spec.d.ts +2 -0
- package/dist/__tests__/a2a/a2a-bearer.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/a2a-bearer.spec.js +58 -0
- package/dist/__tests__/a2a/a2a-bearer.spec.js.map +1 -0
- package/dist/__tests__/a2a/a2a-client.spec.d.ts +2 -0
- package/dist/__tests__/a2a/a2a-client.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/a2a-client.spec.js +334 -0
- package/dist/__tests__/a2a/a2a-client.spec.js.map +1 -0
- package/dist/__tests__/a2a/a2a-job.spec.d.ts +2 -0
- package/dist/__tests__/a2a/a2a-job.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/a2a-job.spec.js +255 -0
- package/dist/__tests__/a2a/a2a-job.spec.js.map +1 -0
- package/dist/__tests__/a2a/a2a-stream.spec.d.ts +2 -0
- package/dist/__tests__/a2a/a2a-stream.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/a2a-stream.spec.js +278 -0
- package/dist/__tests__/a2a/a2a-stream.spec.js.map +1 -0
- package/dist/__tests__/a2a/agent-a2a-config.spec.d.ts +2 -0
- package/dist/__tests__/a2a/agent-a2a-config.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/agent-a2a-config.spec.js +262 -0
- package/dist/__tests__/a2a/agent-a2a-config.spec.js.map +1 -0
- package/dist/__tests__/a2a/producer/auth-filter.spec.d.ts +2 -0
- package/dist/__tests__/a2a/producer/auth-filter.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/producer/auth-filter.spec.js +127 -0
- package/dist/__tests__/a2a/producer/auth-filter.spec.js.map +1 -0
- package/dist/__tests__/a2a/producer/card-builder.spec.d.ts +2 -0
- package/dist/__tests__/a2a/producer/card-builder.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/producer/card-builder.spec.js +113 -0
- package/dist/__tests__/a2a/producer/card-builder.spec.js.map +1 -0
- package/dist/__tests__/a2a/producer/dispatcher.spec.d.ts +2 -0
- package/dist/__tests__/a2a/producer/dispatcher.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/producer/dispatcher.spec.js +850 -0
- package/dist/__tests__/a2a/producer/dispatcher.spec.js.map +1 -0
- package/dist/__tests__/a2a/producer/mount-surface-push.spec.d.ts +2 -0
- package/dist/__tests__/a2a/producer/mount-surface-push.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/producer/mount-surface-push.spec.js +164 -0
- package/dist/__tests__/a2a/producer/mount-surface-push.spec.js.map +1 -0
- package/dist/__tests__/a2a/producer/mount.spec.d.ts +2 -0
- package/dist/__tests__/a2a/producer/mount.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/producer/mount.spec.js +433 -0
- package/dist/__tests__/a2a/producer/mount.spec.js.map +1 -0
- package/dist/__tests__/a2a/producer/public-url-cache.spec.d.ts +2 -0
- package/dist/__tests__/a2a/producer/public-url-cache.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/producer/public-url-cache.spec.js +116 -0
- package/dist/__tests__/a2a/producer/public-url-cache.spec.js.map +1 -0
- package/dist/__tests__/a2a/producer/sse-emitter.spec.d.ts +2 -0
- package/dist/__tests__/a2a/producer/sse-emitter.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/producer/sse-emitter.spec.js +754 -0
- package/dist/__tests__/a2a/producer/sse-emitter.spec.js.map +1 -0
- package/dist/__tests__/a2a/producer/state-translator.spec.d.ts +2 -0
- package/dist/__tests__/a2a/producer/state-translator.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/producer/state-translator.spec.js +124 -0
- package/dist/__tests__/a2a/producer/state-translator.spec.js.map +1 -0
- package/dist/__tests__/a2a/producer/task-store.spec.d.ts +2 -0
- package/dist/__tests__/a2a/producer/task-store.spec.d.ts.map +1 -0
- package/dist/__tests__/a2a/producer/task-store.spec.js +180 -0
- package/dist/__tests__/a2a/producer/task-store.spec.js.map +1 -0
- package/dist/__tests__/agent-add-tool.spec.d.ts +2 -0
- package/dist/__tests__/agent-add-tool.spec.d.ts.map +1 -0
- package/dist/__tests__/agent-add-tool.spec.js +483 -0
- package/dist/__tests__/agent-add-tool.spec.js.map +1 -0
- package/dist/__tests__/api-runtime-race.spec.d.ts +2 -0
- package/dist/__tests__/api-runtime-race.spec.d.ts.map +1 -0
- package/dist/__tests__/api-runtime-race.spec.js +193 -0
- package/dist/__tests__/api-runtime-race.spec.js.map +1 -0
- package/dist/__tests__/claim-dispatcher.spec.d.ts +2 -0
- package/dist/__tests__/claim-dispatcher.spec.d.ts.map +1 -0
- package/dist/__tests__/claim-dispatcher.spec.js +408 -0
- package/dist/__tests__/claim-dispatcher.spec.js.map +1 -0
- package/dist/__tests__/inbound-job-dispatch.spec.d.ts +2 -0
- package/dist/__tests__/inbound-job-dispatch.spec.d.ts.map +1 -0
- package/dist/__tests__/inbound-job-dispatch.spec.js +185 -0
- package/dist/__tests__/inbound-job-dispatch.spec.js.map +1 -0
- package/dist/__tests__/job-controller-progress.spec.d.ts +2 -0
- package/dist/__tests__/job-controller-progress.spec.d.ts.map +1 -0
- package/dist/__tests__/job-controller-progress.spec.js +85 -0
- package/dist/__tests__/job-controller-progress.spec.js.map +1 -0
- package/dist/__tests__/jobs-cancel-route.spec.d.ts +2 -0
- package/dist/__tests__/jobs-cancel-route.spec.d.ts.map +1 -0
- package/dist/__tests__/jobs-cancel-route.spec.js +88 -0
- package/dist/__tests__/jobs-cancel-route.spec.js.map +1 -0
- package/dist/__tests__/llm-agent-stream.test.d.ts +14 -0
- package/dist/__tests__/llm-agent-stream.test.d.ts.map +1 -0
- package/dist/__tests__/llm-agent-stream.test.js +341 -0
- package/dist/__tests__/llm-agent-stream.test.js.map +1 -0
- package/dist/__tests__/mesh-job-submitter.spec.d.ts +2 -0
- package/dist/__tests__/mesh-job-submitter.spec.d.ts.map +1 -0
- package/dist/__tests__/mesh-job-submitter.spec.js +110 -0
- package/dist/__tests__/mesh-job-submitter.spec.js.map +1 -0
- package/dist/__tests__/proxy-stream.test.d.ts +9 -0
- package/dist/__tests__/proxy-stream.test.d.ts.map +1 -0
- package/dist/__tests__/proxy-stream.test.js +347 -0
- package/dist/__tests__/proxy-stream.test.js.map +1 -0
- package/dist/__tests__/resolver-meshjob.spec.d.ts +26 -0
- package/dist/__tests__/resolver-meshjob.spec.d.ts.map +1 -0
- package/dist/__tests__/resolver-meshjob.spec.js +201 -0
- package/dist/__tests__/resolver-meshjob.spec.js.map +1 -0
- package/dist/__tests__/schema-verdict-policy.test.d.ts +6 -0
- package/dist/__tests__/schema-verdict-policy.test.d.ts.map +1 -0
- package/dist/__tests__/schema-verdict-policy.test.js +126 -0
- package/dist/__tests__/schema-verdict-policy.test.js.map +1 -0
- package/dist/__tests__/sse-stream.test.d.ts +12 -0
- package/dist/__tests__/sse-stream.test.d.ts.map +1 -0
- package/dist/__tests__/sse-stream.test.js +170 -0
- package/dist/__tests__/sse-stream.test.js.map +1 -0
- package/dist/a2a/a2a-bearer.d.ts +27 -0
- package/dist/a2a/a2a-bearer.d.ts.map +1 -0
- package/dist/a2a/a2a-bearer.js +63 -0
- package/dist/a2a/a2a-bearer.js.map +1 -0
- package/dist/a2a/a2a-client.d.ts +114 -0
- package/dist/a2a/a2a-client.d.ts.map +1 -0
- package/dist/a2a/a2a-client.js +405 -0
- package/dist/a2a/a2a-client.js.map +1 -0
- package/dist/a2a/a2a-event.d.ts +25 -0
- package/dist/a2a/a2a-event.d.ts.map +1 -0
- package/dist/a2a/a2a-event.js +9 -0
- package/dist/a2a/a2a-event.js.map +1 -0
- package/dist/a2a/a2a-job.d.ts +58 -0
- package/dist/a2a/a2a-job.d.ts.map +1 -0
- package/dist/a2a/a2a-job.js +264 -0
- package/dist/a2a/a2a-job.js.map +1 -0
- package/dist/a2a/a2a-stream.d.ts +39 -0
- package/dist/a2a/a2a-stream.d.ts.map +1 -0
- package/dist/a2a/a2a-stream.js +290 -0
- package/dist/a2a/a2a-stream.js.map +1 -0
- package/dist/a2a/errors.d.ts +29 -0
- package/dist/a2a/errors.d.ts.map +1 -0
- package/dist/a2a/errors.js +48 -0
- package/dist/a2a/errors.js.map +1 -0
- package/dist/a2a/index.d.ts +12 -0
- package/dist/a2a/index.d.ts.map +1 -0
- package/dist/a2a/index.js +11 -0
- package/dist/a2a/index.js.map +1 -0
- package/dist/a2a/producer/auth-filter.d.ts +34 -0
- package/dist/a2a/producer/auth-filter.d.ts.map +1 -0
- package/dist/a2a/producer/auth-filter.js +39 -0
- package/dist/a2a/producer/auth-filter.js.map +1 -0
- package/dist/a2a/producer/card-builder.d.ts +59 -0
- package/dist/a2a/producer/card-builder.d.ts.map +1 -0
- package/dist/a2a/producer/card-builder.js +59 -0
- package/dist/a2a/producer/card-builder.js.map +1 -0
- package/dist/a2a/producer/dispatcher.d.ts +276 -0
- package/dist/a2a/producer/dispatcher.d.ts.map +1 -0
- package/dist/a2a/producer/dispatcher.js +896 -0
- package/dist/a2a/producer/dispatcher.js.map +1 -0
- package/dist/a2a/producer/index.d.ts +26 -0
- package/dist/a2a/producer/index.d.ts.map +1 -0
- package/dist/a2a/producer/index.js +23 -0
- package/dist/a2a/producer/index.js.map +1 -0
- package/dist/a2a/producer/mount.d.ts +75 -0
- package/dist/a2a/producer/mount.d.ts.map +1 -0
- package/dist/a2a/producer/mount.js +422 -0
- package/dist/a2a/producer/mount.js.map +1 -0
- package/dist/a2a/producer/public-url-cache.d.ts +73 -0
- package/dist/a2a/producer/public-url-cache.d.ts.map +1 -0
- package/dist/a2a/producer/public-url-cache.js +0 -0
- package/dist/a2a/producer/public-url-cache.js.map +1 -0
- package/dist/a2a/producer/registry.d.ts +138 -0
- package/dist/a2a/producer/registry.d.ts.map +1 -0
- package/dist/a2a/producer/registry.js +117 -0
- package/dist/a2a/producer/registry.js.map +1 -0
- package/dist/a2a/producer/sse-emitter.d.ts +85 -0
- package/dist/a2a/producer/sse-emitter.d.ts.map +1 -0
- package/dist/a2a/producer/sse-emitter.js +405 -0
- package/dist/a2a/producer/sse-emitter.js.map +1 -0
- package/dist/a2a/producer/state-translator.d.ts +63 -0
- package/dist/a2a/producer/state-translator.d.ts.map +1 -0
- package/dist/a2a/producer/state-translator.js +108 -0
- package/dist/a2a/producer/state-translator.js.map +1 -0
- package/dist/a2a/producer/task-store.d.ts +128 -0
- package/dist/a2a/producer/task-store.d.ts.map +1 -0
- package/dist/a2a/producer/task-store.js +128 -0
- package/dist/a2a/producer/task-store.js.map +1 -0
- package/dist/agent.d.ts +72 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +618 -13
- package/dist/agent.js.map +1 -1
- package/dist/api-runtime.d.ts +25 -0
- package/dist/api-runtime.d.ts.map +1 -1
- package/dist/api-runtime.js +75 -2
- package/dist/api-runtime.js.map +1 -1
- package/dist/claim-dispatcher.d.ts +126 -0
- package/dist/claim-dispatcher.d.ts.map +1 -0
- package/dist/claim-dispatcher.js +478 -0
- package/dist/claim-dispatcher.js.map +1 -0
- package/dist/express.d.ts.map +1 -1
- package/dist/express.js +33 -6
- package/dist/express.js.map +1 -1
- package/dist/inbound-job-dispatch.d.ts +105 -0
- package/dist/inbound-job-dispatch.d.ts.map +1 -0
- package/dist/inbound-job-dispatch.js +335 -0
- package/dist/inbound-job-dispatch.js.map +1 -0
- package/dist/index.d.ts +37 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -3
- package/dist/index.js.map +1 -1
- package/dist/job-context.d.ts +107 -0
- package/dist/job-context.d.ts.map +1 -0
- package/dist/job-context.js +95 -0
- package/dist/job-context.js.map +1 -0
- package/dist/jobs-cancel-route.d.ts +36 -0
- package/dist/jobs-cancel-route.d.ts.map +1 -0
- package/dist/jobs-cancel-route.js +60 -0
- package/dist/jobs-cancel-route.js.map +1 -0
- package/dist/jobs-helper-tools.d.ts +48 -0
- package/dist/jobs-helper-tools.d.ts.map +1 -0
- package/dist/jobs-helper-tools.js +133 -0
- package/dist/jobs-helper-tools.js.map +1 -0
- package/dist/llm-agent.d.ts +62 -53
- package/dist/llm-agent.d.ts.map +1 -1
- package/dist/llm-agent.js +211 -292
- package/dist/llm-agent.js.map +1 -1
- package/dist/llm-provider.d.ts +4 -4
- package/dist/llm.d.ts +4 -1
- package/dist/llm.d.ts.map +1 -1
- package/dist/llm.js +7 -17
- package/dist/llm.js.map +1 -1
- package/dist/mesh-job-submitter.d.ts +83 -0
- package/dist/mesh-job-submitter.d.ts.map +1 -0
- package/dist/mesh-job-submitter.js +143 -0
- package/dist/mesh-job-submitter.js.map +1 -0
- package/dist/proxy.d.ts +30 -0
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +351 -1
- package/dist/proxy.js.map +1 -1
- package/dist/resolver-meshjob.d.ts +170 -0
- package/dist/resolver-meshjob.d.ts.map +1 -0
- package/dist/resolver-meshjob.js +159 -0
- package/dist/resolver-meshjob.js.map +1 -0
- package/dist/route.d.ts +4 -0
- package/dist/route.d.ts.map +1 -1
- package/dist/route.js.map +1 -1
- package/dist/schema-normalize.d.ts +62 -0
- package/dist/schema-normalize.d.ts.map +1 -0
- package/dist/schema-normalize.js +128 -0
- package/dist/schema-normalize.js.map +1 -0
- package/dist/sse-stream.d.ts +44 -0
- package/dist/sse-stream.d.ts.map +1 -0
- package/dist/sse-stream.js +173 -0
- package/dist/sse-stream.js.map +1 -0
- package/dist/types.d.ts +351 -9
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -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
|