@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.
- 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__/llm-provider.test.js +22 -1
- package/dist/__tests__/llm-provider.test.js.map +1 -1
- package/dist/__tests__/media-resolver.test.js +40 -0
- package/dist/__tests__/media-resolver.test.js.map +1 -1
- 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 +99 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +754 -19
- 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 +40 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -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 +11 -4
- package/dist/llm-provider.d.ts.map +1 -1
- package/dist/llm-provider.js +57 -4
- package/dist/llm-provider.js.map +1 -1
- 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/media/resolver.d.ts.map +1 -1
- package/dist/media/resolver.js +3 -2
- package/dist/media/resolver.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/provider-handlers/gemini-handler.js +5 -0
- package/dist/provider-handlers/gemini-handler.js.map +1 -1
- package/dist/proxy.d.ts +40 -0
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +375 -2
- 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/tool-worker-entry.d.ts +21 -0
- package/dist/tool-worker-entry.d.ts.map +1 -0
- package/dist/tool-worker-entry.js +162 -0
- package/dist/tool-worker-entry.js.map +1 -0
- package/dist/tool-worker-pool.d.ts +49 -0
- package/dist/tool-worker-pool.d.ts.map +1 -0
- package/dist/tool-worker-pool.js +272 -0
- package/dist/tool-worker-pool.js.map +1 -0
- package/dist/types.d.ts +351 -9
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `dispatcher.ts` (spec §4).
|
|
3
|
+
*
|
|
4
|
+
* Coverage — JSON-RPC tasks/send / tasks/get / tasks/cancel verbs +
|
|
5
|
+
* error semantics. Asserts JSON shape (not string-grep) per Appendix A.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors Java's `MeshA2ADispatcherTest`.
|
|
8
|
+
*
|
|
9
|
+
* Mocking strategy:
|
|
10
|
+
* - Use the duck-typed `isJobProxy` fallback path with a plain object that
|
|
11
|
+
* exposes `{jobId, status, wait, cancel}` — avoids binding the real
|
|
12
|
+
* napi `JobProxy` class.
|
|
13
|
+
* - Fake Express Request/Response: a tiny capturing stub matching the
|
|
14
|
+
* methods the dispatcher actually calls (`status`, `type`, `send`).
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
17
|
+
import { RouteRegistry } from "../../../route.js";
|
|
18
|
+
import { A2ATaskStore } from "../../../a2a/producer/task-store.js";
|
|
19
|
+
import { buildDispatcherMiddleware, JSONRPC_PARSE_ERROR, JSONRPC_INVALID_REQUEST, JSONRPC_METHOD_NOT_FOUND, JSONRPC_INVALID_PARAMS, } from "../../../a2a/producer/dispatcher.js";
|
|
20
|
+
function makeRes(captured) {
|
|
21
|
+
return {
|
|
22
|
+
status(code) {
|
|
23
|
+
captured.status = code;
|
|
24
|
+
return this;
|
|
25
|
+
},
|
|
26
|
+
type(t) {
|
|
27
|
+
captured.contentType = t;
|
|
28
|
+
return this;
|
|
29
|
+
},
|
|
30
|
+
send(body) {
|
|
31
|
+
captured.body = body;
|
|
32
|
+
return this;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function makeReq(body) {
|
|
37
|
+
return { body, headers: {} };
|
|
38
|
+
}
|
|
39
|
+
function parseBody(captured) {
|
|
40
|
+
expect(captured.body).toBeDefined();
|
|
41
|
+
return JSON.parse(captured.body);
|
|
42
|
+
}
|
|
43
|
+
function fakeProxy(opts = {}) {
|
|
44
|
+
return {
|
|
45
|
+
jobId: opts.jobId ?? "job-fake",
|
|
46
|
+
status: vi.fn(opts.status ?? (async () => ({ status: "running" }))),
|
|
47
|
+
wait: vi.fn(opts.wait ?? (async () => null)),
|
|
48
|
+
cancel: vi.fn(opts.cancel ?? (async () => undefined)),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function makeSurface(overrides = {}) {
|
|
52
|
+
return {
|
|
53
|
+
path: "/agents/test",
|
|
54
|
+
skillId: "test-skill",
|
|
55
|
+
skillName: "Test Skill",
|
|
56
|
+
description: "test",
|
|
57
|
+
tags: [],
|
|
58
|
+
dependencies: [],
|
|
59
|
+
auth: "",
|
|
60
|
+
routeId: "route_0_A2A:/agents/test",
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function makeDeps(handler, taskStore) {
|
|
65
|
+
const surface = makeSurface();
|
|
66
|
+
const routeRegistry = RouteRegistry.getInstance();
|
|
67
|
+
// Register the surface's synthetic route so getDependenciesForRoute()
|
|
68
|
+
// returns a fresh empty dict.
|
|
69
|
+
const routeId = routeRegistry.registerRoute("A2A", surface.path, []);
|
|
70
|
+
return {
|
|
71
|
+
surface: { ...surface, routeId },
|
|
72
|
+
handler,
|
|
73
|
+
taskStore,
|
|
74
|
+
routeRegistry,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function dispatch(deps, body) {
|
|
78
|
+
const middleware = buildDispatcherMiddleware(deps);
|
|
79
|
+
const captured = {};
|
|
80
|
+
await middleware(makeReq(body), makeRes(captured), () => {
|
|
81
|
+
/* the JSON-RPC middleware never calls next */
|
|
82
|
+
});
|
|
83
|
+
return captured;
|
|
84
|
+
}
|
|
85
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
86
|
+
// tasks/send
|
|
87
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
88
|
+
describe("dispatcher: tasks/send (spec §4.3)", () => {
|
|
89
|
+
let store;
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
RouteRegistry.reset();
|
|
92
|
+
store = new A2ATaskStore();
|
|
93
|
+
});
|
|
94
|
+
/**
|
|
95
|
+
* Spec §4.3 sync branch: handler returns a value → state=completed.
|
|
96
|
+
* Appendix A: parts[0].type === "text" + result JSON-stringified.
|
|
97
|
+
*/
|
|
98
|
+
it("sync handler return -> state=completed with text part (Appendix A)", async () => {
|
|
99
|
+
const handler = async () => ({ greeting: "hello" });
|
|
100
|
+
const deps = makeDeps(handler, store);
|
|
101
|
+
const captured = await dispatch(deps, {
|
|
102
|
+
jsonrpc: "2.0",
|
|
103
|
+
id: 1,
|
|
104
|
+
method: "tasks/send",
|
|
105
|
+
params: { id: "task-1", message: { role: "user", parts: [] } },
|
|
106
|
+
});
|
|
107
|
+
expect(captured.status).toBe(200);
|
|
108
|
+
const body = parseBody(captured);
|
|
109
|
+
expect(body.jsonrpc).toBe("2.0");
|
|
110
|
+
expect(body.id).toBe(1);
|
|
111
|
+
const result = body.result;
|
|
112
|
+
expect(result.id).toBe("task-1");
|
|
113
|
+
const status = result.status;
|
|
114
|
+
expect(status.state).toBe("completed");
|
|
115
|
+
const artifacts = result.artifacts;
|
|
116
|
+
expect(artifacts).toHaveLength(1);
|
|
117
|
+
const parts = artifacts[0].parts;
|
|
118
|
+
// Appendix A: parts[0].type MUST be the literal "text".
|
|
119
|
+
expect(parts[0].type).toBe("text");
|
|
120
|
+
expect(parts[0].text).toBe('{"greeting":"hello"}');
|
|
121
|
+
});
|
|
122
|
+
/** Appendix B item 7: sessionId defaults to taskId when not supplied. */
|
|
123
|
+
it("sessionId defaults to taskId per spec Appendix B item 7", async () => {
|
|
124
|
+
const handler = async () => "ok";
|
|
125
|
+
const deps = makeDeps(handler, store);
|
|
126
|
+
const captured = await dispatch(deps, {
|
|
127
|
+
jsonrpc: "2.0",
|
|
128
|
+
method: "tasks/send",
|
|
129
|
+
params: { id: "task-no-session" },
|
|
130
|
+
});
|
|
131
|
+
const result = parseBody(captured).result;
|
|
132
|
+
expect(result.sessionId).toBe("task-no-session");
|
|
133
|
+
});
|
|
134
|
+
/** Spec §4.3 long-running: handler returns JobProxy → state=working. */
|
|
135
|
+
it("JobProxy return -> state=working; task parked with proxy ref", async () => {
|
|
136
|
+
const proxy = fakeProxy({ jobId: "job-long" });
|
|
137
|
+
const handler = async () => proxy;
|
|
138
|
+
const deps = makeDeps(handler, store);
|
|
139
|
+
const captured = await dispatch(deps, {
|
|
140
|
+
jsonrpc: "2.0",
|
|
141
|
+
id: "abc",
|
|
142
|
+
method: "tasks/send",
|
|
143
|
+
params: { id: "task-lr" },
|
|
144
|
+
});
|
|
145
|
+
const result = parseBody(captured).result;
|
|
146
|
+
expect(result.status.state).toBe("working");
|
|
147
|
+
// artifacts MAY be present-but-empty per spec §4.3.
|
|
148
|
+
expect(result.artifacts).toEqual([]);
|
|
149
|
+
// Task store: parked with JobProxy reference preserved.
|
|
150
|
+
const parked = store.get("task-lr");
|
|
151
|
+
expect(parked).toBeDefined();
|
|
152
|
+
expect(parked.jobProxy).toBe(proxy);
|
|
153
|
+
expect(parked.terminalAt).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
/** Spec §4.3 "handler raised": exception → state=failed (NOT JSON-RPC error). */
|
|
156
|
+
it("handler exception -> state=failed (NOT JSON-RPC error)", async () => {
|
|
157
|
+
const handler = async () => {
|
|
158
|
+
throw new Error("boom");
|
|
159
|
+
};
|
|
160
|
+
const deps = makeDeps(handler, store);
|
|
161
|
+
const captured = await dispatch(deps, {
|
|
162
|
+
jsonrpc: "2.0",
|
|
163
|
+
id: 7,
|
|
164
|
+
method: "tasks/send",
|
|
165
|
+
params: { id: "task-fail" },
|
|
166
|
+
});
|
|
167
|
+
const body = parseBody(captured);
|
|
168
|
+
expect(body.error).toBeUndefined();
|
|
169
|
+
const result = body.result;
|
|
170
|
+
const status = result.status;
|
|
171
|
+
expect(status.state).toBe("failed");
|
|
172
|
+
const msg = status.message;
|
|
173
|
+
const parts = msg.parts;
|
|
174
|
+
expect(parts[0].type).toBe("text");
|
|
175
|
+
expect(parts[0].text).toBe("boom");
|
|
176
|
+
expect(result.artifacts).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
/** Spec §4.3: duplicate in-flight task_id -> -32602. */
|
|
179
|
+
it("duplicate task id -> -32602", async () => {
|
|
180
|
+
const handler = async () => "ok";
|
|
181
|
+
const deps = makeDeps(handler, store);
|
|
182
|
+
await dispatch(deps, {
|
|
183
|
+
jsonrpc: "2.0",
|
|
184
|
+
method: "tasks/send",
|
|
185
|
+
params: { id: "dup-1" },
|
|
186
|
+
});
|
|
187
|
+
const second = await dispatch(deps, {
|
|
188
|
+
jsonrpc: "2.0",
|
|
189
|
+
method: "tasks/send",
|
|
190
|
+
params: { id: "dup-1" },
|
|
191
|
+
});
|
|
192
|
+
const body = parseBody(second);
|
|
193
|
+
const err = body.error;
|
|
194
|
+
expect(err.code).toBe(JSONRPC_INVALID_PARAMS);
|
|
195
|
+
expect(err.message.toLowerCase()).toContain("already in use");
|
|
196
|
+
});
|
|
197
|
+
/**
|
|
198
|
+
* Regression: two concurrent tasks/send with the same id must NOT both
|
|
199
|
+
* slip through. Atomic reserveTask() in the task store closes the race
|
|
200
|
+
* window between a non-atomic `contains()` pre-check and the post-await
|
|
201
|
+
* `put()` (the `await handler(...)` between them yields control to the
|
|
202
|
+
* event loop, so a separate request can pass the same pre-check).
|
|
203
|
+
*
|
|
204
|
+
* Acceptance: exactly one request returns state=completed, the other
|
|
205
|
+
* returns JSON-RPC -32602 "already in use".
|
|
206
|
+
*/
|
|
207
|
+
it("concurrent tasks/send with same id -> exactly one succeeds, other -32602", async () => {
|
|
208
|
+
// Handler that yields to the event loop a couple of times before
|
|
209
|
+
// returning — simulates the original race window between the
|
|
210
|
+
// pre-check and the post-await put().
|
|
211
|
+
const handler = async () => {
|
|
212
|
+
await Promise.resolve();
|
|
213
|
+
await Promise.resolve();
|
|
214
|
+
return "ok";
|
|
215
|
+
};
|
|
216
|
+
const deps = makeDeps(handler, store);
|
|
217
|
+
const body = {
|
|
218
|
+
jsonrpc: "2.0",
|
|
219
|
+
method: "tasks/send",
|
|
220
|
+
params: { id: "concurrent-1" },
|
|
221
|
+
};
|
|
222
|
+
const [first, second] = await Promise.all([
|
|
223
|
+
dispatch(deps, body),
|
|
224
|
+
dispatch(deps, body),
|
|
225
|
+
]);
|
|
226
|
+
const firstBody = parseBody(first);
|
|
227
|
+
const secondBody = parseBody(second);
|
|
228
|
+
// Exactly one is a JSON-RPC error -32602, the other is a success.
|
|
229
|
+
const errorBodies = [firstBody, secondBody].filter((b) => "error" in b);
|
|
230
|
+
const successBodies = [firstBody, secondBody].filter((b) => "result" in b);
|
|
231
|
+
expect(errorBodies).toHaveLength(1);
|
|
232
|
+
expect(successBodies).toHaveLength(1);
|
|
233
|
+
const err = errorBodies[0].error;
|
|
234
|
+
expect(err.code).toBe(JSONRPC_INVALID_PARAMS);
|
|
235
|
+
expect(err.message.toLowerCase()).toContain("already in use");
|
|
236
|
+
const result = successBodies[0].result;
|
|
237
|
+
const status = result.status;
|
|
238
|
+
expect(status.state).toBe("completed");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
242
|
+
// tasks/get
|
|
243
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
244
|
+
describe("dispatcher: tasks/get (spec §4.4)", () => {
|
|
245
|
+
let store;
|
|
246
|
+
beforeEach(() => {
|
|
247
|
+
RouteRegistry.reset();
|
|
248
|
+
store = new A2ATaskStore();
|
|
249
|
+
});
|
|
250
|
+
/** Spec §4.4: terminal record returns cached envelope. */
|
|
251
|
+
it("terminal record returns cached envelope", async () => {
|
|
252
|
+
const env = {
|
|
253
|
+
id: "t1",
|
|
254
|
+
sessionId: "t1",
|
|
255
|
+
status: { state: "completed", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
256
|
+
artifacts: [],
|
|
257
|
+
history: [],
|
|
258
|
+
};
|
|
259
|
+
store.put("t1", {
|
|
260
|
+
sessionId: "t1",
|
|
261
|
+
terminalEnvelope: env,
|
|
262
|
+
terminalAt: Date.now(),
|
|
263
|
+
jobProxy: null,
|
|
264
|
+
});
|
|
265
|
+
const deps = makeDeps((async () => "ok"), store);
|
|
266
|
+
const captured = await dispatch(deps, {
|
|
267
|
+
jsonrpc: "2.0",
|
|
268
|
+
id: 1,
|
|
269
|
+
method: "tasks/get",
|
|
270
|
+
params: { id: "t1" },
|
|
271
|
+
});
|
|
272
|
+
const body = parseBody(captured);
|
|
273
|
+
expect(body.result).toEqual(env);
|
|
274
|
+
});
|
|
275
|
+
/** Spec §4.4: non-terminal + proxy.status() working → state=working. */
|
|
276
|
+
it("non-terminal record + working proxy -> state=working (no artifact)", async () => {
|
|
277
|
+
const proxy = fakeProxy({ status: async () => ({ status: "running" }) });
|
|
278
|
+
store.put("t2", { sessionId: "t2", jobProxy: proxy });
|
|
279
|
+
const deps = makeDeps((async () => null), store);
|
|
280
|
+
const captured = await dispatch(deps, {
|
|
281
|
+
jsonrpc: "2.0",
|
|
282
|
+
id: 1,
|
|
283
|
+
method: "tasks/get",
|
|
284
|
+
params: { id: "t2" },
|
|
285
|
+
});
|
|
286
|
+
const result = parseBody(captured).result;
|
|
287
|
+
expect(result.status.state).toBe("working");
|
|
288
|
+
expect(result.artifacts).toEqual([]);
|
|
289
|
+
expect(proxy.wait).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
/** Spec §4.4: non-terminal + status=completed → calls proxy.wait(1.0) for artifact. */
|
|
292
|
+
it("non-terminal + completed status calls proxy.wait(1.0) for artifact", async () => {
|
|
293
|
+
const proxy = fakeProxy({
|
|
294
|
+
status: async () => ({ status: "completed" }),
|
|
295
|
+
wait: async () => "final-result",
|
|
296
|
+
});
|
|
297
|
+
store.put("t3", { sessionId: "t3", jobProxy: proxy });
|
|
298
|
+
const deps = makeDeps((async () => null), store);
|
|
299
|
+
const captured = await dispatch(deps, {
|
|
300
|
+
jsonrpc: "2.0",
|
|
301
|
+
id: 1,
|
|
302
|
+
method: "tasks/get",
|
|
303
|
+
params: { id: "t3" },
|
|
304
|
+
});
|
|
305
|
+
const result = parseBody(captured).result;
|
|
306
|
+
expect(result.status.state).toBe("completed");
|
|
307
|
+
const artifacts = result.artifacts;
|
|
308
|
+
expect(artifacts).toHaveLength(1);
|
|
309
|
+
const parts = artifacts[0].parts;
|
|
310
|
+
expect(parts[0].type).toBe("text");
|
|
311
|
+
expect(parts[0].text).toBe("final-result");
|
|
312
|
+
expect(proxy.wait).toHaveBeenCalledWith(1.0);
|
|
313
|
+
});
|
|
314
|
+
/** Spec §4.4: non-terminal + status=failed → state=failed; do NOT call wait(). */
|
|
315
|
+
it("non-terminal + failed status -> state=failed; wait() not called", async () => {
|
|
316
|
+
const proxy = fakeProxy({
|
|
317
|
+
status: async () => ({ status: "failed", error: "kaboom" }),
|
|
318
|
+
});
|
|
319
|
+
store.put("t4", { sessionId: "t4", jobProxy: proxy });
|
|
320
|
+
const deps = makeDeps((async () => null), store);
|
|
321
|
+
const captured = await dispatch(deps, {
|
|
322
|
+
jsonrpc: "2.0",
|
|
323
|
+
id: 1,
|
|
324
|
+
method: "tasks/get",
|
|
325
|
+
params: { id: "t4" },
|
|
326
|
+
});
|
|
327
|
+
const result = parseBody(captured).result;
|
|
328
|
+
const status = result.status;
|
|
329
|
+
expect(status.state).toBe("failed");
|
|
330
|
+
const msg = status.message;
|
|
331
|
+
const parts = msg.parts;
|
|
332
|
+
expect(parts[0].text).toBe("kaboom");
|
|
333
|
+
expect(proxy.wait).not.toHaveBeenCalled();
|
|
334
|
+
});
|
|
335
|
+
/** Spec §7.2 UK→US: non-terminal + status=cancelled → state=canceled. */
|
|
336
|
+
it("non-terminal + cancelled mesh status -> A2A state=canceled (US spelling)", async () => {
|
|
337
|
+
const proxy = fakeProxy({ status: async () => ({ status: "cancelled" }) });
|
|
338
|
+
store.put("t5", { sessionId: "t5", jobProxy: proxy });
|
|
339
|
+
const deps = makeDeps((async () => null), store);
|
|
340
|
+
const captured = await dispatch(deps, {
|
|
341
|
+
jsonrpc: "2.0",
|
|
342
|
+
id: 1,
|
|
343
|
+
method: "tasks/get",
|
|
344
|
+
params: { id: "t5" },
|
|
345
|
+
});
|
|
346
|
+
const result = parseBody(captured).result;
|
|
347
|
+
expect(result.status.state).toBe("canceled");
|
|
348
|
+
expect(result.status.state).not.toBe("cancelled");
|
|
349
|
+
});
|
|
350
|
+
/** Spec §4.4: status() throws → state=working with error text (NOT -32602). */
|
|
351
|
+
it("proxy.status() throw -> state=working with 'status unavailable' (spec §4.4)", async () => {
|
|
352
|
+
const proxy = fakeProxy({
|
|
353
|
+
status: async () => {
|
|
354
|
+
throw new Error("registry unreachable");
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
store.put("t6", { sessionId: "t6", jobProxy: proxy });
|
|
358
|
+
const deps = makeDeps((async () => null), store);
|
|
359
|
+
const captured = await dispatch(deps, {
|
|
360
|
+
jsonrpc: "2.0",
|
|
361
|
+
id: 1,
|
|
362
|
+
method: "tasks/get",
|
|
363
|
+
params: { id: "t6" },
|
|
364
|
+
});
|
|
365
|
+
const body = parseBody(captured);
|
|
366
|
+
expect(body.error).toBeUndefined();
|
|
367
|
+
const result = body.result;
|
|
368
|
+
const status = result.status;
|
|
369
|
+
expect(status.state).toBe("working");
|
|
370
|
+
const msg = status.message;
|
|
371
|
+
const parts = msg.parts;
|
|
372
|
+
expect(parts[0].text).toMatch(/status unavailable:/);
|
|
373
|
+
expect(parts[0].text).toMatch(/registry unreachable/);
|
|
374
|
+
});
|
|
375
|
+
/** Spec §4.4 errors: unknown task id -> -32602. */
|
|
376
|
+
it("unknown task id -> -32602", async () => {
|
|
377
|
+
const deps = makeDeps((async () => null), store);
|
|
378
|
+
const captured = await dispatch(deps, {
|
|
379
|
+
jsonrpc: "2.0",
|
|
380
|
+
id: 1,
|
|
381
|
+
method: "tasks/get",
|
|
382
|
+
params: { id: "ghost" },
|
|
383
|
+
});
|
|
384
|
+
const err = parseBody(captured).error;
|
|
385
|
+
expect(err.code).toBe(JSONRPC_INVALID_PARAMS);
|
|
386
|
+
expect(err.message.toLowerCase()).toContain("unknown task id");
|
|
387
|
+
});
|
|
388
|
+
/** Appendix A: progress emitted as JSON number, not string. */
|
|
389
|
+
it("Appendix A: metadata.progress is a JSON number (not stringified)", async () => {
|
|
390
|
+
const proxy = fakeProxy({
|
|
391
|
+
status: async () => ({ status: "running", progress: 0.42 }),
|
|
392
|
+
});
|
|
393
|
+
store.put("t-prog", { sessionId: "t-prog", jobProxy: proxy });
|
|
394
|
+
const deps = makeDeps((async () => null), store);
|
|
395
|
+
const captured = await dispatch(deps, {
|
|
396
|
+
jsonrpc: "2.0",
|
|
397
|
+
id: 1,
|
|
398
|
+
method: "tasks/get",
|
|
399
|
+
params: { id: "t-prog" },
|
|
400
|
+
});
|
|
401
|
+
const result = parseBody(captured).result;
|
|
402
|
+
const meta = result.metadata;
|
|
403
|
+
expect(typeof meta.progress).toBe("number");
|
|
404
|
+
expect(meta.progress).toBe(0.42);
|
|
405
|
+
});
|
|
406
|
+
/**
|
|
407
|
+
* Appendix A defensive guard (W1): if `proxy.status()` returns
|
|
408
|
+
* `progress` as a non-number (e.g. a string `"50%"`), the dispatcher
|
|
409
|
+
* MUST NOT emit it verbatim — the SSE emitter already coerces to
|
|
410
|
+
* `typeof progress === "number" ? progress : null` so the
|
|
411
|
+
* `tasks/get` path needs to match. Omit `metadata` entirely when the
|
|
412
|
+
* raw value isn't a number.
|
|
413
|
+
*/
|
|
414
|
+
it("Appendix A defensive: non-number progress -> metadata omitted (W1)", async () => {
|
|
415
|
+
const proxy = fakeProxy({
|
|
416
|
+
status: async () => ({ status: "running", progress: "50%" }),
|
|
417
|
+
});
|
|
418
|
+
store.put("t-prog-str", { sessionId: "t-prog-str", jobProxy: proxy });
|
|
419
|
+
const deps = makeDeps((async () => null), store);
|
|
420
|
+
const captured = await dispatch(deps, {
|
|
421
|
+
jsonrpc: "2.0",
|
|
422
|
+
id: 1,
|
|
423
|
+
method: "tasks/get",
|
|
424
|
+
params: { id: "t-prog-str" },
|
|
425
|
+
});
|
|
426
|
+
const result = parseBody(captured).result;
|
|
427
|
+
// metadata must be absent (no spec-violating string-typed progress).
|
|
428
|
+
expect(result.metadata).toBeUndefined();
|
|
429
|
+
});
|
|
430
|
+
/**
|
|
431
|
+
* Regression: after a live-status poll returns a terminal state
|
|
432
|
+
* (completed / failed / canceled), the envelope MUST be persisted via
|
|
433
|
+
* markTerminal() so subsequent tasks/get calls hit the cache and don't
|
|
434
|
+
* re-poll the JobProxy. Without this, every tasks/get after the task
|
|
435
|
+
* finishes makes another expensive status() + wait() round-trip.
|
|
436
|
+
*/
|
|
437
|
+
it("terminal live-status -> persisted; subsequent tasks/get hits cache (no re-poll)", async () => {
|
|
438
|
+
const proxy = fakeProxy({
|
|
439
|
+
status: async () => ({ status: "completed" }),
|
|
440
|
+
wait: async () => "final-payload",
|
|
441
|
+
});
|
|
442
|
+
store.put("t-cache", { sessionId: "t-cache", jobProxy: proxy });
|
|
443
|
+
const deps = makeDeps((async () => null), store);
|
|
444
|
+
// First tasks/get: polls the proxy and observes terminal state.
|
|
445
|
+
const first = await dispatch(deps, {
|
|
446
|
+
jsonrpc: "2.0",
|
|
447
|
+
id: 1,
|
|
448
|
+
method: "tasks/get",
|
|
449
|
+
params: { id: "t-cache" },
|
|
450
|
+
});
|
|
451
|
+
const firstResult = parseBody(first).result;
|
|
452
|
+
expect(firstResult.status.state).toBe("completed");
|
|
453
|
+
expect(proxy.status).toHaveBeenCalledTimes(1);
|
|
454
|
+
expect(proxy.wait).toHaveBeenCalledTimes(1);
|
|
455
|
+
// The record is now terminal (markTerminal stamped it).
|
|
456
|
+
const parked = store.get("t-cache");
|
|
457
|
+
expect(parked.terminalEnvelope).toBeDefined();
|
|
458
|
+
expect(parked.terminalAt).toBeDefined();
|
|
459
|
+
// Second tasks/get: must hit the cached terminal envelope — no
|
|
460
|
+
// additional proxy.status() / proxy.wait() invocations.
|
|
461
|
+
const second = await dispatch(deps, {
|
|
462
|
+
jsonrpc: "2.0",
|
|
463
|
+
id: 2,
|
|
464
|
+
method: "tasks/get",
|
|
465
|
+
params: { id: "t-cache" },
|
|
466
|
+
});
|
|
467
|
+
const secondResult = parseBody(second).result;
|
|
468
|
+
expect(secondResult.status.state).toBe("completed");
|
|
469
|
+
expect(proxy.status).toHaveBeenCalledTimes(1);
|
|
470
|
+
expect(proxy.wait).toHaveBeenCalledTimes(1);
|
|
471
|
+
});
|
|
472
|
+
/**
|
|
473
|
+
* Negative case: working (non-terminal) live-status MUST NOT mark the
|
|
474
|
+
* record terminal. tasks/get should re-poll on the next call so the
|
|
475
|
+
* client sees fresh progress.
|
|
476
|
+
*/
|
|
477
|
+
it("working live-status -> NOT persisted; next tasks/get re-polls", async () => {
|
|
478
|
+
const proxy = fakeProxy({
|
|
479
|
+
status: async () => ({ status: "running", progress: 0.3 }),
|
|
480
|
+
});
|
|
481
|
+
store.put("t-working", { sessionId: "t-working", jobProxy: proxy });
|
|
482
|
+
const deps = makeDeps((async () => null), store);
|
|
483
|
+
await dispatch(deps, {
|
|
484
|
+
jsonrpc: "2.0",
|
|
485
|
+
id: 1,
|
|
486
|
+
method: "tasks/get",
|
|
487
|
+
params: { id: "t-working" },
|
|
488
|
+
});
|
|
489
|
+
await dispatch(deps, {
|
|
490
|
+
jsonrpc: "2.0",
|
|
491
|
+
id: 2,
|
|
492
|
+
method: "tasks/get",
|
|
493
|
+
params: { id: "t-working" },
|
|
494
|
+
});
|
|
495
|
+
// Each call must re-poll — no cached envelope for non-terminal records.
|
|
496
|
+
expect(proxy.status).toHaveBeenCalledTimes(2);
|
|
497
|
+
const parked = store.get("t-working");
|
|
498
|
+
expect(parked.terminalEnvelope).toBeUndefined();
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
502
|
+
// tasks/cancel
|
|
503
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
504
|
+
describe("dispatcher: tasks/cancel (spec §4.5)", () => {
|
|
505
|
+
let store;
|
|
506
|
+
beforeEach(() => {
|
|
507
|
+
RouteRegistry.reset();
|
|
508
|
+
store = new A2ATaskStore();
|
|
509
|
+
});
|
|
510
|
+
/** Spec §4.5: missing id -> -32602. */
|
|
511
|
+
it("missing id -> -32602", async () => {
|
|
512
|
+
const deps = makeDeps((async () => null), store);
|
|
513
|
+
const captured = await dispatch(deps, {
|
|
514
|
+
jsonrpc: "2.0",
|
|
515
|
+
id: 1,
|
|
516
|
+
method: "tasks/cancel",
|
|
517
|
+
params: {},
|
|
518
|
+
});
|
|
519
|
+
const err = parseBody(captured).error;
|
|
520
|
+
expect(err.code).toBe(JSONRPC_INVALID_PARAMS);
|
|
521
|
+
});
|
|
522
|
+
/** Unknown task id -> -32602. */
|
|
523
|
+
it("unknown task id -> -32602", async () => {
|
|
524
|
+
const deps = makeDeps((async () => null), store);
|
|
525
|
+
const captured = await dispatch(deps, {
|
|
526
|
+
jsonrpc: "2.0",
|
|
527
|
+
id: 1,
|
|
528
|
+
method: "tasks/cancel",
|
|
529
|
+
params: { id: "ghost" },
|
|
530
|
+
});
|
|
531
|
+
const err = parseBody(captured).error;
|
|
532
|
+
expect(err.code).toBe(JSONRPC_INVALID_PARAMS);
|
|
533
|
+
});
|
|
534
|
+
/** Spec §4.5 idempotent ack: already-terminal -> echo cached envelope. */
|
|
535
|
+
it("already-terminal task -> echo cached envelope (idempotent)", async () => {
|
|
536
|
+
const env = {
|
|
537
|
+
id: "t1",
|
|
538
|
+
sessionId: "t1",
|
|
539
|
+
status: { state: "canceled" },
|
|
540
|
+
artifacts: [],
|
|
541
|
+
history: [],
|
|
542
|
+
};
|
|
543
|
+
store.put("t1", {
|
|
544
|
+
sessionId: "t1",
|
|
545
|
+
terminalEnvelope: env,
|
|
546
|
+
terminalAt: Date.now(),
|
|
547
|
+
jobProxy: null,
|
|
548
|
+
});
|
|
549
|
+
const deps = makeDeps((async () => null), store);
|
|
550
|
+
const captured = await dispatch(deps, {
|
|
551
|
+
jsonrpc: "2.0",
|
|
552
|
+
id: 1,
|
|
553
|
+
method: "tasks/cancel",
|
|
554
|
+
params: { id: "t1" },
|
|
555
|
+
});
|
|
556
|
+
const body = parseBody(captured);
|
|
557
|
+
expect(body.result).toEqual(env);
|
|
558
|
+
});
|
|
559
|
+
/** Non-terminal: cancel succeeds and post-cancel status terminal → cached. */
|
|
560
|
+
it("cancel + status terminal -> caches terminal envelope", async () => {
|
|
561
|
+
const proxy = fakeProxy({
|
|
562
|
+
status: async () => ({ status: "cancelled" }),
|
|
563
|
+
});
|
|
564
|
+
store.put("t2", { sessionId: "t2", jobProxy: proxy });
|
|
565
|
+
const deps = makeDeps((async () => null), store);
|
|
566
|
+
const captured = await dispatch(deps, {
|
|
567
|
+
jsonrpc: "2.0",
|
|
568
|
+
id: 1,
|
|
569
|
+
method: "tasks/cancel",
|
|
570
|
+
params: { id: "t2", reason: "user-cancel" },
|
|
571
|
+
});
|
|
572
|
+
const result = parseBody(captured).result;
|
|
573
|
+
expect(result.status.state).toBe("canceled");
|
|
574
|
+
expect(proxy.cancel).toHaveBeenCalledWith("user-cancel");
|
|
575
|
+
// Cached terminal envelope so a follow-up tasks/get is idempotent.
|
|
576
|
+
const parked = store.get("t2");
|
|
577
|
+
expect(parked.terminalEnvelope).toBeDefined();
|
|
578
|
+
});
|
|
579
|
+
/** cancel() throws but status() succeeds → uses status envelope. */
|
|
580
|
+
it("cancel() throws + status() succeeds -> uses status envelope", async () => {
|
|
581
|
+
const proxy = fakeProxy({
|
|
582
|
+
cancel: async () => {
|
|
583
|
+
throw new Error("cancel failed");
|
|
584
|
+
},
|
|
585
|
+
status: async () => ({ status: "completed" }),
|
|
586
|
+
wait: async () => "result",
|
|
587
|
+
});
|
|
588
|
+
store.put("t3", { sessionId: "t3", jobProxy: proxy });
|
|
589
|
+
const deps = makeDeps((async () => null), store);
|
|
590
|
+
const captured = await dispatch(deps, {
|
|
591
|
+
jsonrpc: "2.0",
|
|
592
|
+
id: 1,
|
|
593
|
+
method: "tasks/cancel",
|
|
594
|
+
params: { id: "t3" },
|
|
595
|
+
});
|
|
596
|
+
// status() succeeded → returns the (live) projected envelope. State
|
|
597
|
+
// will be "completed" since that's what status() reported, but since
|
|
598
|
+
// status was terminal cancel marks terminal (whatever state status
|
|
599
|
+
// reported gets cached).
|
|
600
|
+
const result = parseBody(captured).result;
|
|
601
|
+
// Status came back terminal → envelope reflects that (not synthesized).
|
|
602
|
+
expect(result.status.state).toBe("completed");
|
|
603
|
+
});
|
|
604
|
+
/**
|
|
605
|
+
* Java BLOCKER fix from #934: cancel() AND status() both throw →
|
|
606
|
+
* synthesized state=canceled envelope (no exception propagation).
|
|
607
|
+
*/
|
|
608
|
+
it("cancel() + status() both throw -> synthesized state=canceled (#934)", async () => {
|
|
609
|
+
const proxy = fakeProxy({
|
|
610
|
+
cancel: async () => {
|
|
611
|
+
throw new Error("cancel failed");
|
|
612
|
+
},
|
|
613
|
+
status: async () => {
|
|
614
|
+
throw new Error("status failed");
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
store.put("t4", { sessionId: "t4", jobProxy: proxy });
|
|
618
|
+
const deps = makeDeps((async () => null), store);
|
|
619
|
+
const captured = await dispatch(deps, {
|
|
620
|
+
jsonrpc: "2.0",
|
|
621
|
+
id: 1,
|
|
622
|
+
method: "tasks/cancel",
|
|
623
|
+
params: { id: "t4", reason: "double-fail" },
|
|
624
|
+
});
|
|
625
|
+
const body = parseBody(captured);
|
|
626
|
+
expect(body.error).toBeUndefined();
|
|
627
|
+
const result = body.result;
|
|
628
|
+
expect(result.status.state).toBe("canceled");
|
|
629
|
+
// Subsequent tasks/get returns the synthesized envelope (cached).
|
|
630
|
+
const parked = store.get("t4");
|
|
631
|
+
expect(parked.terminalEnvelope).toBeDefined();
|
|
632
|
+
const cached = parked.terminalEnvelope;
|
|
633
|
+
expect(cached.status.state).toBe("canceled");
|
|
634
|
+
});
|
|
635
|
+
/**
|
|
636
|
+
* Defensive: lost JobProxy on a non-terminal record → synthesized
|
|
637
|
+
* state=canceled rather than -32602 error.
|
|
638
|
+
*/
|
|
639
|
+
it("non-terminal record without JobProxy -> synthesized state=canceled", async () => {
|
|
640
|
+
store.put("t5", { sessionId: "t5", jobProxy: null });
|
|
641
|
+
const deps = makeDeps((async () => null), store);
|
|
642
|
+
const captured = await dispatch(deps, {
|
|
643
|
+
jsonrpc: "2.0",
|
|
644
|
+
id: 1,
|
|
645
|
+
method: "tasks/cancel",
|
|
646
|
+
params: { id: "t5" },
|
|
647
|
+
});
|
|
648
|
+
const body = parseBody(captured);
|
|
649
|
+
expect(body.error).toBeUndefined();
|
|
650
|
+
const result = body.result;
|
|
651
|
+
expect(result.status.state).toBe("canceled");
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
655
|
+
// JSON-RPC error semantics (spec §4.1)
|
|
656
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
657
|
+
describe("dispatcher: JSON-RPC error semantics (spec §4.1)", () => {
|
|
658
|
+
let store;
|
|
659
|
+
beforeEach(() => {
|
|
660
|
+
RouteRegistry.reset();
|
|
661
|
+
store = new A2ATaskStore();
|
|
662
|
+
});
|
|
663
|
+
/** Spec §4.1: empty / null body -> HTTP 400 + -32700. */
|
|
664
|
+
it("empty body -> HTTP 400 + -32700", async () => {
|
|
665
|
+
const deps = makeDeps((async () => null), store);
|
|
666
|
+
const captured = {};
|
|
667
|
+
const middleware = buildDispatcherMiddleware(deps);
|
|
668
|
+
await middleware({ body: null, headers: {} }, makeRes(captured), () => { });
|
|
669
|
+
expect(captured.status).toBe(400);
|
|
670
|
+
const err = parseBody(captured).error;
|
|
671
|
+
expect(err.code).toBe(JSONRPC_PARSE_ERROR);
|
|
672
|
+
});
|
|
673
|
+
/** Spec §4.1: array body -> -32700 (not a JSON-RPC object). */
|
|
674
|
+
it("array body -> HTTP 400 + -32700", async () => {
|
|
675
|
+
const deps = makeDeps((async () => null), store);
|
|
676
|
+
const captured = {};
|
|
677
|
+
const middleware = buildDispatcherMiddleware(deps);
|
|
678
|
+
await middleware({ body: [], headers: {} }, makeRes(captured), () => { });
|
|
679
|
+
expect(captured.status).toBe(400);
|
|
680
|
+
const err = parseBody(captured).error;
|
|
681
|
+
expect(err.code).toBe(JSONRPC_PARSE_ERROR);
|
|
682
|
+
});
|
|
683
|
+
/**
|
|
684
|
+
* Spec §4.1 + #934 BLOCKER: missing `method` field -> -32600 Invalid Request.
|
|
685
|
+
* NOT -32601 with "Method not implemented: 'null'".
|
|
686
|
+
*/
|
|
687
|
+
it("missing method field -> -32600 Invalid Request (NOT -32601 'null')", async () => {
|
|
688
|
+
const deps = makeDeps((async () => null), store);
|
|
689
|
+
const captured = await dispatch(deps, { jsonrpc: "2.0", id: 1 });
|
|
690
|
+
const err = parseBody(captured).error;
|
|
691
|
+
expect(err.code).toBe(JSONRPC_INVALID_REQUEST);
|
|
692
|
+
expect(err.code).not.toBe(JSONRPC_METHOD_NOT_FOUND);
|
|
693
|
+
// The error message MUST NOT contain "null" — that was the #934 bug.
|
|
694
|
+
expect(err.message.toLowerCase()).not.toContain("'null'");
|
|
695
|
+
});
|
|
696
|
+
/**
|
|
697
|
+
* Spec §4.1: unknown method -> -32601 with the actual method name.
|
|
698
|
+
* #934 explicit guard.
|
|
699
|
+
*/
|
|
700
|
+
it("unknown method -> -32601 with actual method name (NOT 'null')", async () => {
|
|
701
|
+
const deps = makeDeps((async () => null), store);
|
|
702
|
+
const captured = await dispatch(deps, {
|
|
703
|
+
jsonrpc: "2.0",
|
|
704
|
+
id: 1,
|
|
705
|
+
method: "tasks/madeUp",
|
|
706
|
+
params: {},
|
|
707
|
+
});
|
|
708
|
+
const err = parseBody(captured).error;
|
|
709
|
+
expect(err.code).toBe(JSONRPC_METHOD_NOT_FOUND);
|
|
710
|
+
expect(err.message).toContain("tasks/madeUp");
|
|
711
|
+
expect(err.message.toLowerCase()).not.toContain("'null'");
|
|
712
|
+
});
|
|
713
|
+
/** Request id is echoed verbatim (including 0 and null). */
|
|
714
|
+
it("echoes id verbatim including 0", async () => {
|
|
715
|
+
const handler = async () => "ok";
|
|
716
|
+
const deps = makeDeps(handler, store);
|
|
717
|
+
const captured = await dispatch(deps, {
|
|
718
|
+
jsonrpc: "2.0",
|
|
719
|
+
id: 0,
|
|
720
|
+
method: "tasks/send",
|
|
721
|
+
params: { id: "t-id-zero" },
|
|
722
|
+
});
|
|
723
|
+
const body = parseBody(captured);
|
|
724
|
+
expect(body.id).toBe(0);
|
|
725
|
+
});
|
|
726
|
+
it("echoes id verbatim including null", async () => {
|
|
727
|
+
const handler = async () => "ok";
|
|
728
|
+
const deps = makeDeps(handler, store);
|
|
729
|
+
const captured = await dispatch(deps, {
|
|
730
|
+
jsonrpc: "2.0",
|
|
731
|
+
id: null,
|
|
732
|
+
method: "tasks/send",
|
|
733
|
+
params: { id: "t-id-null" },
|
|
734
|
+
});
|
|
735
|
+
const body = parseBody(captured);
|
|
736
|
+
expect(body.id).toBeNull();
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
740
|
+
// jobSubmitter injection (issue #936)
|
|
741
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
742
|
+
describe("dispatcher: handler jobSubmitter arg (issue #936)", () => {
|
|
743
|
+
let store;
|
|
744
|
+
beforeEach(() => {
|
|
745
|
+
RouteRegistry.reset();
|
|
746
|
+
store = new A2ATaskStore();
|
|
747
|
+
});
|
|
748
|
+
/**
|
|
749
|
+
* The framework auto-injects a MeshJobSubmitter on the third positional
|
|
750
|
+
* arg so long-running handlers don't have to hand-construct one from
|
|
751
|
+
* getApiRuntime().getServiceId() + MCP_MESH_REGISTRY_URL. Mirrors Java's
|
|
752
|
+
* MeshA2ADispatcher behavior.
|
|
753
|
+
*/
|
|
754
|
+
it("passes provided submitter through to the handler as the third arg", async () => {
|
|
755
|
+
let captured3rdArg = "<not-called>";
|
|
756
|
+
const handler = async (_deps, _payload, jobSubmitter) => {
|
|
757
|
+
captured3rdArg = jobSubmitter;
|
|
758
|
+
return "ok";
|
|
759
|
+
};
|
|
760
|
+
const baseDeps = makeDeps(handler, store);
|
|
761
|
+
const fakeSubmitter = { __fake: "submitter" };
|
|
762
|
+
const deps = {
|
|
763
|
+
...baseDeps,
|
|
764
|
+
jobSubmitterProvider: () => fakeSubmitter,
|
|
765
|
+
};
|
|
766
|
+
await dispatch(deps, {
|
|
767
|
+
jsonrpc: "2.0",
|
|
768
|
+
method: "tasks/send",
|
|
769
|
+
params: { id: "t-with-submitter" },
|
|
770
|
+
});
|
|
771
|
+
expect(captured3rdArg).toBe(fakeSubmitter);
|
|
772
|
+
});
|
|
773
|
+
/**
|
|
774
|
+
* Without a `jobSubmitterProvider` (e.g. test fixtures, advanced
|
|
775
|
+
* wiring) the dispatcher passes `null` — handler can check and
|
|
776
|
+
* surface a clear error rather than blow up.
|
|
777
|
+
*/
|
|
778
|
+
it("passes null when no jobSubmitterProvider is configured", async () => {
|
|
779
|
+
let captured3rdArg = "<not-called>";
|
|
780
|
+
const handler = async (_deps, _payload, jobSubmitter) => {
|
|
781
|
+
captured3rdArg = jobSubmitter;
|
|
782
|
+
return "ok";
|
|
783
|
+
};
|
|
784
|
+
const deps = makeDeps(handler, store);
|
|
785
|
+
await dispatch(deps, {
|
|
786
|
+
jsonrpc: "2.0",
|
|
787
|
+
method: "tasks/send",
|
|
788
|
+
params: { id: "t-no-provider" },
|
|
789
|
+
});
|
|
790
|
+
expect(captured3rdArg).toBeNull();
|
|
791
|
+
});
|
|
792
|
+
/**
|
|
793
|
+
* The provider is called per-request — the dispatcher does NOT memoize
|
|
794
|
+
* inside itself. (Caching is the provider's responsibility — `mount.ts`
|
|
795
|
+
* memoizes after the first successful construction.) This matters for
|
|
796
|
+
* the early-startup case where the provider returns null on the first
|
|
797
|
+
* call but a real instance on the second.
|
|
798
|
+
*/
|
|
799
|
+
it("calls the provider on every dispatch (no internal memoization)", async () => {
|
|
800
|
+
const handler = async () => "ok";
|
|
801
|
+
const baseDeps = makeDeps(handler, store);
|
|
802
|
+
const providerCalls = [];
|
|
803
|
+
let callIndex = 0;
|
|
804
|
+
const deps = {
|
|
805
|
+
...baseDeps,
|
|
806
|
+
jobSubmitterProvider: () => {
|
|
807
|
+
callIndex += 1;
|
|
808
|
+
providerCalls.push(callIndex);
|
|
809
|
+
return null;
|
|
810
|
+
},
|
|
811
|
+
};
|
|
812
|
+
await dispatch(deps, {
|
|
813
|
+
jsonrpc: "2.0",
|
|
814
|
+
method: "tasks/send",
|
|
815
|
+
params: { id: "t-call-1" },
|
|
816
|
+
});
|
|
817
|
+
await dispatch(deps, {
|
|
818
|
+
jsonrpc: "2.0",
|
|
819
|
+
method: "tasks/send",
|
|
820
|
+
params: { id: "t-call-2" },
|
|
821
|
+
});
|
|
822
|
+
expect(providerCalls).toEqual([1, 2]);
|
|
823
|
+
});
|
|
824
|
+
/**
|
|
825
|
+
* The handler's third arg must be passed on the SSE
|
|
826
|
+
* (`tasks/sendSubscribe`) path too — not just JSON-RPC. Java's
|
|
827
|
+
* dispatcher exercises both call sites; the TS equivalent must
|
|
828
|
+
* match.
|
|
829
|
+
*/
|
|
830
|
+
it("threads jobSubmitter through to tasks/sendSubscribe handler", async () => {
|
|
831
|
+
let captured3rdArg = "<not-called>";
|
|
832
|
+
const fakeProxyResult = fakeProxy({ jobId: "job-stream" });
|
|
833
|
+
const handler = async (_deps, _payload, jobSubmitter) => {
|
|
834
|
+
captured3rdArg = jobSubmitter;
|
|
835
|
+
return fakeProxyResult;
|
|
836
|
+
};
|
|
837
|
+
const baseDeps = makeDeps(handler, store);
|
|
838
|
+
const fakeSubmitter = { __fake: "submitter" };
|
|
839
|
+
const deps = {
|
|
840
|
+
...baseDeps,
|
|
841
|
+
jobSubmitterProvider: () => fakeSubmitter,
|
|
842
|
+
};
|
|
843
|
+
// Call buildSendSubscribeStream directly — the dispatcher's
|
|
844
|
+
// tasks/sendSubscribe entry point.
|
|
845
|
+
const { buildSendSubscribeStream } = await import("../../../a2a/producer/dispatcher.js");
|
|
846
|
+
await buildSendSubscribeStream("req-1", { id: "t-sse-1" }, deps);
|
|
847
|
+
expect(captured3rdArg).toBe(fakeSubmitter);
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
//# sourceMappingURL=dispatcher.spec.js.map
|