deepdebug-local-agent 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.github/workflows/deploy-gcp.yml +58 -26
  2. package/CHANGELOG.md +6 -0
  3. package/docs/la-protocol-v2.md +102 -0
  4. package/package.json +2 -1
  5. package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
  6. package/src/la-protocol-v2/chunks.js +45 -0
  7. package/src/la-protocol-v2/config.js +27 -0
  8. package/src/la-protocol-v2/content-handlers/audit.js +36 -0
  9. package/src/la-protocol-v2/content-handlers/config.js +48 -0
  10. package/src/la-protocol-v2/content-handlers/egress.js +65 -0
  11. package/src/la-protocol-v2/content-handlers/git-diff.js +92 -0
  12. package/src/la-protocol-v2/content-handlers/handler.js +170 -0
  13. package/src/la-protocol-v2/content-handlers/index.js +6 -0
  14. package/src/la-protocol-v2/content-handlers/install.js +50 -0
  15. package/src/la-protocol-v2/content-handlers/path-guard.js +142 -0
  16. package/src/la-protocol-v2/content-handlers/ref-registry.js +87 -0
  17. package/src/la-protocol-v2/content.js +63 -0
  18. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
  19. package/src/la-protocol-v2/contract/README.md +103 -0
  20. package/src/la-protocol-v2/contract/ack.schema.json +51 -0
  21. package/src/la-protocol-v2/contract/alert.schema.json +62 -0
  22. package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
  23. package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
  24. package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
  25. package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
  26. package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
  27. package/src/la-protocol-v2/contract/defs.schema.json +191 -0
  28. package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
  29. package/src/la-protocol-v2/contract/error.schema.json +59 -0
  30. package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
  31. package/src/la-protocol-v2/contract/hello.schema.json +111 -0
  32. package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
  33. package/src/la-protocol-v2/contract/index.json +166 -0
  34. package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
  35. package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
  36. package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
  37. package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
  38. package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
  39. package/src/la-protocol-v2/contract/ready.schema.json +106 -0
  40. package/src/la-protocol-v2/contract/register.schema.json +92 -0
  41. package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
  42. package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
  43. package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
  44. package/src/la-protocol-v2/contract.js +90 -0
  45. package/src/la-protocol-v2/errors.js +34 -0
  46. package/src/la-protocol-v2/index.js +8 -0
  47. package/src/la-protocol-v2/link.js +93 -0
  48. package/src/la-protocol-v2/runtime.js +81 -0
  49. package/src/la-protocol-v2/session.js +650 -0
  50. package/src/la-protocol-v2/strict-json.js +47 -0
  51. package/src/server.js +25 -4
  52. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
  53. package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
  54. package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
  55. package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
  56. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
  57. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
  58. package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
  59. package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
  60. package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
  61. package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
  62. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
  63. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
  64. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
  65. package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
  66. package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
  67. package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
  68. package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
  69. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
  70. package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
  71. package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
  72. package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
  73. package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
  74. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
  75. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
  76. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
  77. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
  78. package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
  79. package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
  80. package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
  81. package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
  82. package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
  83. package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
  84. package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
  85. package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
  86. package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
  87. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
  88. package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
  89. package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
  90. package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
  91. package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
  92. package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
  93. package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
  94. package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
  95. package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
  96. package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
  97. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
  98. package/test/la-protocol-v2/content-handlers.test.js +633 -0
  99. package/test/la-protocol-v2/content-path-guard.test.js +214 -0
  100. package/test/la-protocol-v2/content-wiring.test.js +225 -0
  101. package/test/la-protocol-v2/contract.test.js +131 -0
  102. package/test/la-protocol-v2/helpers/content-rig.js +94 -0
  103. package/test/la-protocol-v2/helpers/la-rig.js +83 -0
  104. package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
  105. package/test/la-protocol-v2/helpers/spawn-agent.js +36 -0
  106. package/test/la-protocol-v2/link.test.js +141 -0
  107. package/test/la-protocol-v2/session-inbound.test.js +279 -0
  108. package/test/la-protocol-v2/session-outbound.test.js +382 -0
  109. package/test/la-protocol-v2/traces.test.js +270 -0
  110. package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
  111. package/tools/graphify-validation/extras/README.md +19 -0
  112. package/tools/graphify-validation/extras/bymethod.py +35 -0
  113. package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
  114. package/tools/graphify-validation/extras/docker/inner.sh +7 -0
  115. package/tools/graphify-validation/extras/g10.py +59 -0
  116. package/tools/graphify-validation/extras/g3aug.py +49 -0
  117. package/tools/graphify-validation/extras/hybrid.py +34 -0
  118. package/tools/graphify-validation/extras/truth2.py +44 -0
  119. package/tools/interop/LaV2NodeContentInteropTest.java +248 -0
  120. package/tools/interop/LaV2NodeInteropTest.java +266 -0
  121. package/tools/interop/README.md +38 -0
  122. package/tools/interop/la-interop-client.mjs +97 -0
  123. package/tools/interop/la-interop-content-client.mjs +91 -0
@@ -0,0 +1,382 @@
1
+ // F3-05b: what the Local Agent sends, and what it does about acks, loss, backpressure and resume.
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import crypto from "node:crypto";
5
+ import { ReferenceCp, ReferenceCpServer, TENANT, AGENT, uuid, waitFor } from "./helpers/reference-cp.js";
6
+ import { inMemory, startRig } from "./helpers/la-rig.js";
7
+ import { sendContent, sendContentFailure } from "../../src/la-protocol-v2/content.js";
8
+ import { loadContract } from "../../src/la-protocol-v2/contract.js";
9
+
10
+ const JOB = "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f";
11
+ const REQ = "d4e5f6a7-b8c9-4d0e-9f1a-3b4c5d6e7f80";
12
+ const ev = (state = "running", extra = {}) => ({ jobId: JOB, state, ...extra });
13
+ const contract = loadContract();
14
+
15
+ async function ready(opts = {}) {
16
+ const cp = new ReferenceCp(opts.cp);
17
+ const env = inMemory(cp, opts.la).start();
18
+ await waitFor(() => env.session.state === "ready");
19
+ return { cp, ...env };
20
+ }
21
+
22
+ // ------------------------------------------------------------------------------------------ envelope and numbering
23
+
24
+ test("outbound: every frame is closed, schema-valid, carries the configured tenant/agent and consecutive seq", async () => {
25
+ const env = await ready();
26
+ for (let i = 0; i < 5; i++) await env.session.send("job.event", ev("running", { iteration: i }));
27
+ await env.session.send("alert", { code: "disk_low", severity: "warn" }).catch(() => {});
28
+ const frames = env.wire.toCp.map((t) => JSON.parse(t));
29
+ for (const f of frames) {
30
+ assert.deepEqual(contract.validateFrame(f), { ok: true }, f.type);
31
+ assert.equal(f.tenantId, TENANT);
32
+ assert.equal(f.agentId, AGENT);
33
+ assert.equal(f.schemaVersion, 2);
34
+ assert.match(f.msgId, /^[0-9a-f-]{36}$/);
35
+ assert.equal("sig" in f || "paramsRaw" in f, false, "LA frames are never signed");
36
+ }
37
+ assert.deepEqual(frames.filter((f) => f.type === "register" || f.type === "hello").map((f) => f.seq), [0, 0]);
38
+ assert.deepEqual(env.cp.received.filter((f) => f.type === "job.event").map((f) => f.seq), [1, 2, 3, 4, 5]);
39
+ assert.equal(new Set(env.cp.received.map((f) => f.msgId)).size, env.cp.received.length, "msgId is unique per frame");
40
+ env.session.close();
41
+ });
42
+
43
+ test("outbound: our frames piggyback ack = highest contiguous CP seq received", async () => {
44
+ const env = await ready();
45
+ env.session.setHandler("job.cancel", () => ({ accept: true }));
46
+ env.cp.sendData("job.cancel", { jobId: JOB, reason: "user" });
47
+ env.cp.sendData("job.cancel", { jobId: JOB, reason: "user" });
48
+ await waitFor(() => env.session.lastRecvSeq === 2);
49
+ await env.session.send("job.event", ev());
50
+ assert.equal(env.cp.received.at(-1).ack, 2);
51
+ env.session.close();
52
+ });
53
+
54
+ test("outbound: heartbeat frames go out on their own, with mode/clock/disk/queue (no paths, no content)", async () => {
55
+ const env = await ready({ la: { options: { heartbeatMs: 40 } } });
56
+ await waitFor(() => env.cp.received.filter((f) => f.type === "heartbeat").length >= 2);
57
+ const hb = env.cp.received.find((f) => f.type === "heartbeat");
58
+ assert.deepEqual(Object.keys(hb.payload).sort(), ["clockEpochMs", "diskFreeBytes", "inflight", "queueDepth", "signatureMode"]);
59
+ assert.equal(hb.payload.diskFreeBytes, 1234);
60
+ assert.equal(hb.payload.signatureMode, "enforce");
61
+ env.session.close();
62
+ });
63
+
64
+ // ------------------------------------------------------------------------------------------ refusals before a byte is written
65
+
66
+ const REFUSED = [
67
+ ["a type outside the allowlist", "shell.exec", {}, "unknown_type"],
68
+ ["a v1 command name", "mcp.execute-command", {}, "unknown_type"],
69
+ ["a cp->la type", "job.assign", {}, "wrong_direction"],
70
+ ["a session type (register)", "register", {}, "wrong_direction"],
71
+ ["a field the schema does not know", "job.event", ev("running", { stack: "x" }), "schema_violation"],
72
+ ["a state outside the enum", "job.event", ev("exploded"), "schema_violation"],
73
+ ["a string above its maxLength", "job.result", { jobId: JOB, outcome: "pr_opened", prUrl: "https://example.com/" + "a".repeat(600) }, "payload_too_large"],
74
+ ];
75
+ for (const [name, type, payload, code] of REFUSED) {
76
+ test(`outbound refusal: ${name} -> ${code}, nothing is written`, async () => {
77
+ const env = await ready();
78
+ const before = env.wire.toCp.length;
79
+ await assert.rejects(env.session.enqueue(type, payload), (e) => e.code === code);
80
+ assert.equal(env.wire.toCp.length, before);
81
+ assert.equal(env.session.sendSeq, 0, "a refused frame does not consume a seq");
82
+ env.session.close();
83
+ });
84
+ }
85
+
86
+ test("outbound refusal: before the first ready nothing can be sent (not_ready)", async () => {
87
+ const cp = new ReferenceCp();
88
+ const env = inMemory(cp);
89
+ await assert.rejects(env.session.enqueue("job.event", ev()), (e) => e.code === "not_ready");
90
+ env.session.close();
91
+ });
92
+
93
+ test("outbound refusal: the send queue is bounded (rate_limited)", async () => {
94
+ const env = await ready({ cp: { }, la: { options: { maxInFlight: 1, maxQueued: 3 } } });
95
+ env.cp.silent = true;
96
+ const ps = [];
97
+ for (let i = 0; i < 6; i++) ps.push(env.session.enqueue("job.event", ev()).then(() => "ok", (e) => e.code));
98
+ const res = await Promise.race([Promise.all(ps), new Promise((r) => setTimeout(() => r(null), 100))]);
99
+ assert.equal(res, null, "waiting frames keep waiting");
100
+ const refused = await Promise.all(ps.slice(-2));
101
+ assert.deepEqual(refused, ["rate_limited", "rate_limited"]);
102
+ env.session.close();
103
+ });
104
+
105
+ // ------------------------------------------------------------------------------------------ acks, loss, backpressure
106
+
107
+ test("backpressure: at most maxInFlight frames unacked, the rest wait; when acks resume everything arrives once and in order", async () => {
108
+ const env = await ready({ cp: { limits: { ackTimeoutMs: 150, maxRetries: 3 } }, la: { options: { maxInFlight: 3 } } });
109
+ env.cp.silent = true; // acks lost
110
+ const all = [];
111
+ for (let i = 0; i < 10; i++) all.push(env.session.send("job.event", ev("running", { iteration: i })));
112
+ await waitFor(() => env.cp.received.length === 3);
113
+ await new Promise((r) => setTimeout(r, 40));
114
+ assert.equal(env.cp.received.length, 3, "never more than maxInFlight on the wire");
115
+ assert.equal(env.session.snapshot.waiting, 7);
116
+ assert.equal(env.session.snapshot.unacked, 3);
117
+ env.cp.silent = false; // the retransmissions of 1..3 are now acked (as duplicates)
118
+ await Promise.all(all);
119
+ assert.deepEqual(env.cp.received.map((f) => f.payload.iteration), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
120
+ assert.deepEqual(env.cp.received.map((f) => f.seq), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
121
+ assert.ok(env.session.stats.retransmits >= 1, "the lost acks forced a retransmission (cumulative acks settle the rest)");
122
+ env.session.close();
123
+ });
124
+
125
+ test("ack lost: the SAME bytes (same seq and msgId) are sent again, the CP sees a duplicate, the caller's ack resolves", async () => {
126
+ const env = await ready({ cp: { limits: { ackTimeoutMs: 100 } } });
127
+ env.cp.dropAcks = 1;
128
+ await env.session.send("job.event", ev());
129
+ const copies = env.wire.toCp.filter((t) => t.includes('"job.event"'));
130
+ assert.equal(copies.length, 2);
131
+ assert.equal(copies[0], copies[1], "a retransmission is byte-identical");
132
+ assert.deepEqual(env.cp.duplicates, [1]);
133
+ assert.equal(env.cp.received.length, 1);
134
+ assert.equal(env.session.stats.retransmits, 1);
135
+ env.session.close();
136
+ });
137
+
138
+ test("retries exhausted: the caller gets timeout, the connection is closed with 4420, the frame is kept for a resume", async () => {
139
+ const env = await ready({ cp: { limits: { ackTimeoutMs: 100, maxRetries: 2 } } });
140
+ env.cp.silent = true;
141
+ const h = await env.session.enqueue("job.event", ev());
142
+ await assert.rejects(h.acked, (e) => e.code === "timeout");
143
+ assert.deepEqual(env.wire.closed[0], [4420, "ack_timeout"]);
144
+ assert.equal(env.session.stats.retransmits, 2);
145
+ assert.equal(env.session.state, "detached");
146
+ assert.equal(env.session.unacked.size, 1, "kept for the resume");
147
+ env.session.close();
148
+ });
149
+
150
+ // ------------------------------------------------------------------------------------------ content (chunking)
151
+
152
+ const bytesOf = (n) => crypto.randomBytes(n);
153
+
154
+ test("chunking: 100 KiB goes out as 4 ordered chunks, each <= 32 KiB raw, each frame <= 48 KiB, and the CP rebuilds it exactly", async () => {
155
+ const env = await ready();
156
+ const data = bytesOf(100 * 1024);
157
+ const r = await sendContent(env.session, { requestId: REQ, source: data, contentType: "application/octet-stream" });
158
+ assert.deepEqual(r, { chunks: 4, bytes: data.length });
159
+ const frames = env.cp.received.filter((f) => f.type === "content.response");
160
+ assert.deepEqual(frames.map((f) => f.chunk), [0, 1, 2, 3].map((index) => ({ index, total: 4, last: index === 3 })));
161
+ assert.deepEqual(frames.map((f) => f.payload.byteLength), [32768, 32768, 32768, 4096]);
162
+ assert.ok(env.cp.rawIn.every((t) => Buffer.byteLength(t) <= 49152));
163
+ assert.ok(Math.max(...env.cp.rawIn.map((t) => Buffer.byteLength(t))) > 43692, "a full chunk really is that big");
164
+ assert.equal(frames[0].requestId, REQ);
165
+ assert.ok(frames.every((f) => f.payload.totalBytes === data.length && f.payload.encoding === "base64"));
166
+ const c = env.cp.contentRequests.get(REQ);
167
+ assert.equal(c.done, true);
168
+ assert.ok(Buffer.concat(c.chunks).equals(data));
169
+ assert.deepEqual(env.cp.errorsSent, []);
170
+ env.session.close();
171
+ });
172
+
173
+ test("chunking: exactly 2 MiB = 64 chunks is accepted, 2 MiB + 1 is refused before a single chunk is sent", async () => {
174
+ const env = await ready();
175
+ const max = bytesOf(2 * 1024 * 1024);
176
+ const r = await sendContent(env.session, { requestId: REQ, source: max });
177
+ assert.equal(r.chunks, 64);
178
+ assert.ok(Buffer.concat(env.cp.contentRequests.get(REQ).chunks).equals(max));
179
+ const before = env.wire.toCp.length;
180
+ await assert.rejects(sendContent(env.session, { requestId: uuid(), source: bytesOf(2 * 1024 * 1024 + 1) }), (e) => e.code === "payload_too_large");
181
+ assert.equal(env.wire.toCp.length, before);
182
+ env.session.close();
183
+ });
184
+
185
+ test("chunking: empty content is one empty chunk; a failure answer is one empty chunk with the status", async () => {
186
+ const env = await ready();
187
+ await sendContent(env.session, { requestId: REQ, source: Buffer.alloc(0), contentType: "text/plain" });
188
+ const rid2 = uuid();
189
+ await sendContentFailure(env.session, { requestId: rid2, status: "not_found", errorCode: "internal" });
190
+ const [a, b] = env.cp.received;
191
+ assert.deepEqual(a.chunk, { index: 0, total: 1, last: true });
192
+ assert.equal(a.payload.data, "");
193
+ assert.equal(b.payload.status, "not_found");
194
+ assert.deepEqual(b.chunk, { index: 0, total: 1, last: true });
195
+ env.session.close();
196
+ });
197
+
198
+ test("chunking + backpressure: with a slow CP never more than maxInFlight chunks are unacked", async () => {
199
+ const env = await ready({ cp: { }, la: { options: { maxInFlight: 3 } } });
200
+ env.cp.ackDelayMs = 15;
201
+ let maxUnacked = 0;
202
+ env.cp.onReceived = () => { maxUnacked = Math.max(maxUnacked, env.session.unacked.size); };
203
+ const data = bytesOf(40 * 32768);
204
+ await sendContent(env.session, { requestId: REQ, source: data });
205
+ assert.ok(maxUnacked <= 3 && maxUnacked >= 2, `max unacked ${maxUnacked}`);
206
+ assert.ok(Buffer.concat(env.cp.contentRequests.get(REQ).chunks).equals(data));
207
+ assert.equal(env.session.stats.retransmits, 0);
208
+ env.session.close();
209
+ });
210
+
211
+ test("chunking is lazy: the next chunk is not read until a slot opens (bounded memory)", async () => {
212
+ const env = await ready({ la: { options: { maxInFlight: 2 } } });
213
+ env.cp.silent = true;
214
+ let reads = 0;
215
+ const source = { size: 10 * 32768, async read(offset, length) { reads++; return Buffer.alloc(length, 1); } };
216
+ const p = sendContent(env.session, { requestId: REQ, source });
217
+ p.catch(() => {});
218
+ await waitFor(() => env.cp.received.length === 2);
219
+ await new Promise((r) => setTimeout(r, 60));
220
+ assert.ok(reads <= 3, `read ${reads} chunks while only 2 can be in flight`);
221
+ env.session.close();
222
+ await assert.rejects(p);
223
+ });
224
+
225
+ test("chunking: cancelling (AbortSignal) stops reading and drops the chunks still waiting", async () => {
226
+ const env = await ready({ la: { options: { maxInFlight: 2 } } });
227
+ env.cp.ackDelayMs = 30;
228
+ const ac = new AbortController();
229
+ let reads = 0;
230
+ const source = { size: 20 * 32768, async read(o, l) { reads++; if (reads === 4) ac.abort(); return Buffer.alloc(l, 2); } };
231
+ await assert.rejects(sendContent(env.session, { requestId: REQ, source, signal: ac.signal }), (e) => e.code === "agent_offline");
232
+ assert.ok(reads <= 5);
233
+ assert.equal(env.session.snapshot.waiting, 0);
234
+ env.session.close();
235
+ });
236
+
237
+ // the chunk rules of the contract applied to what we send (the invalid-chunk-* traces, from the sender's side)
238
+ const CHUNK = (data = Buffer.alloc(26, 1)) => ({ status: "ok", contentType: "text/plain", encoding: "base64", data: data.toString("base64"), byteLength: data.length });
239
+ const BAD_CHUNKS = [
240
+ ["out of order (0 then 2)", [[0, 3, false], [2, 3, true]], "chunk_out_of_order"],
241
+ ["after last", [[0, 1, true], [1, 2, true]], "chunk_out_of_order"],
242
+ ["total changes mid-stream", [[0, 3, false], [1, 4, false]], "chunk_inconsistent"],
243
+ ["last on the wrong index", [[0, 3, false], [1, 3, true]], "chunk_inconsistent"],
244
+ ["last missing on the last index", [[0, 1, false]], "chunk_inconsistent"],
245
+ ["a stream that does not start at 0", [[1, 3, false]], "chunk_out_of_order"],
246
+ ];
247
+ for (const [name, steps, code] of BAD_CHUNKS) {
248
+ test(`chunk rules on send: ${name} -> ${code}`, async () => {
249
+ const env = await ready();
250
+ let got = null;
251
+ for (const [index, total, last] of steps) {
252
+ try { await env.session.send("content.response", CHUNK(), { requestId: uuid() === "" ? "" : REQ, chunk: { index, total, last } }); }
253
+ catch (e) { got = e.code; break; }
254
+ }
255
+ assert.equal(got, code);
256
+ env.session.close();
257
+ });
258
+ }
259
+
260
+ test("chunk rules on send: byteLength must equal the decoded size; data above 32 KiB raw is refused", async () => {
261
+ const env = await ready();
262
+ const p = CHUNK();
263
+ p.byteLength = 27;
264
+ await assert.rejects(env.session.send("content.response", p, { requestId: REQ, chunk: { index: 0, total: 1, last: true } }), (e) => e.code === "chunk_inconsistent");
265
+ const big = bytesOf(32769);
266
+ await assert.rejects(env.session.send("content.response", { ...CHUNK(big) }, { requestId: uuid(), chunk: { index: 0, total: 1, last: true } }),
267
+ (e) => ["payload_too_large", "schema_violation", "chunk_inconsistent"].includes(e.code));
268
+ assert.equal(env.cp.received.length, 0);
269
+ env.session.close();
270
+ });
271
+
272
+ // ------------------------------------------------------------------------------------------ resume (real sockets)
273
+
274
+ // every ws test runs inside withRig: sockets and servers are closed even when an assertion throws
275
+ async function withRig(opts, body) {
276
+ const srv = await new ReferenceCpServer({ requireEnforce: true, ...(opts.cp || {}) }).start();
277
+ const rig = startRig(srv, opts.rig);
278
+ try {
279
+ await rig.connect();
280
+ await waitFor(() => rig.session.state === "ready");
281
+ await body({ srv, rig });
282
+ } finally {
283
+ await rig.stop();
284
+ await srv.stop();
285
+ }
286
+ }
287
+
288
+ test("resume: after a dropped socket the LA offers {sessionId,lastRecvSeq}, continues numbering, and the CP gets one copy of everything", async () => {
289
+ await withRig({}, async ({ srv, rig }) => {
290
+ rig.session.setHandler("job.cancel", () => ({ accept: true }));
291
+ await rig.session.send("job.event", ev("running", { iteration: 1 }));
292
+ await rig.session.send("job.event", ev("running", { iteration: 2 }));
293
+ srv.cp.sendData("job.cancel", { jobId: JOB, reason: "user" });
294
+ await waitFor(() => rig.session.lastRecvSeq === 1);
295
+ const sid = rig.session.sessionId;
296
+ rig.drop();
297
+ await waitFor(() => rig.session.state === "detached");
298
+ const queued = rig.session.send("job.event", ev("running", { iteration: 3 })); // queued while detached
299
+ await rig.connect();
300
+ await queued;
301
+ const reg = srv.cp.registers.at(-1);
302
+ assert.deepEqual(reg.resume, { sessionId: sid, lastRecvSeq: 1 });
303
+ assert.equal(rig.session.sessionId, sid);
304
+ assert.deepEqual(srv.cp.received.filter((f) => f.type === "job.event").map((f) => [f.seq, f.payload.iteration]), [[1, 1], [2, 2], [3, 3]]);
305
+ assert.equal(srv.cp.hellos.length, 2);
306
+ });});
307
+
308
+ test("resume: frames the CP never saw are sent again (same seq and msgId) and are acked after the reconnect", async () => {
309
+ await withRig({ cp: { limits: { ackTimeoutMs: 5000 } } }, async ({ srv, rig }) => {
310
+ srv.cp.silent = true;
311
+ const h = await rig.session.enqueue("job.event", ev("running", { iteration: 1 }));
312
+ await waitFor(() => srv.cp.received.length === 1);
313
+ rig.drop();
314
+ await waitFor(() => rig.session.state === "detached");
315
+ srv.cp.silent = false;
316
+ await rig.connect();
317
+ await h.acked; // ready{peerLastSeq:1} settles it
318
+ assert.equal(srv.cp.received.length, 1, "no second copy");
319
+ assert.equal(rig.session.unacked.size, 0);
320
+ });});
321
+
322
+ test("resume: a frame lost with the socket is delivered after the reconnect (retransmitted once, not twice)", async () => {
323
+ await withRig({}, async ({ srv, rig }) => {
324
+ // write the frame, then lose the connection before the CP reads it
325
+ srv.ws.pause?.();
326
+ rig.ws.terminate();
327
+ await waitFor(() => rig.session.state === "detached");
328
+ const p = rig.session.send("job.event", ev("running", { iteration: 9 }));
329
+ await rig.connect();
330
+ await p;
331
+ assert.deepEqual(srv.cp.received.map((f) => f.payload.iteration), [9]);
332
+ });});
333
+
334
+ test("resumed:false (the CP lost the session): numbering restarts at 1 and frames that were in flight fail with agent_offline", async () => {
335
+ await withRig({}, async ({ srv, rig }) => {
336
+ srv.cp.silent = true;
337
+ const a = await rig.session.enqueue("job.event", ev("running", { iteration: 1 }));
338
+ const b = await rig.session.enqueue("job.event", ev("running", { iteration: 2 }));
339
+ await waitFor(() => srv.cp.received.length === 2);
340
+ const old = rig.session.sessionId;
341
+ rig.drop();
342
+ await waitFor(() => rig.session.state === "detached");
343
+ srv.cp.sessionId = uuid(); // the CP has no memory of this session
344
+ srv.cp.silent = false;
345
+ await rig.connect();
346
+ await assert.rejects(a.acked, (e) => e.code === "agent_offline");
347
+ await assert.rejects(b.acked, (e) => e.code === "agent_offline");
348
+ assert.notEqual(rig.session.sessionId, old);
349
+ await rig.session.send("job.event", ev("running", { iteration: 3 }));
350
+ assert.equal(srv.cp.received.at(-1).seq, 1, "both sides restart at 1");
351
+ });});
352
+
353
+ test("resume window: after resumeWindowMs a dropped session is forgotten (no resume offered, in-flight fail)", async () => {
354
+ await withRig({ rig: { options: { resumeWindowMs: 80 } } }, async ({ srv, rig }) => {
355
+ srv.cp.silent = true;
356
+ const h = await rig.session.enqueue("job.event", ev());
357
+ await waitFor(() => srv.cp.received.length === 1);
358
+ rig.drop();
359
+ await waitFor(() => rig.session.state === "idle");
360
+ await assert.rejects(h.acked, (e) => e.code === "agent_offline");
361
+ srv.cp.silent = false;
362
+ await rig.connect();
363
+ await waitFor(() => rig.session.state === "ready");
364
+ assert.equal(srv.cp.registers.at(-1).resume, undefined);
365
+ });});
366
+
367
+ test("resume: a content stream does not survive a reset (its chunks would reach a CP that never saw chunk 0)", async () => {
368
+ await withRig({ rig: { options: { maxInFlight: 1 } } }, async ({ srv, rig }) => {
369
+ srv.cp.silent = true;
370
+ const p = sendContent(rig.session, { requestId: REQ, source: bytesOf(5 * 32768) });
371
+ p.catch(() => {});
372
+ await waitFor(() => srv.cp.received.length === 1);
373
+ rig.drop();
374
+ await waitFor(() => rig.session.state === "detached");
375
+ srv.cp.sessionId = uuid();
376
+ srv.cp.silent = false;
377
+ await rig.connect();
378
+ await assert.rejects(p, (e) => e.code === "agent_offline");
379
+ await new Promise((r) => setTimeout(r, 50));
380
+ assert.equal(srv.cp.received.filter((f) => f.type === "content.response").length, 1);
381
+ assert.deepEqual(srv.cp.errorsSent, []);
382
+ });});
@@ -0,0 +1,270 @@
1
+ // F3-05b: the 24 whole-session traces of the contract (F3-22), reproduced in Node. "Traces are language
2
+ // neutral; the LA must reproduce each expect.error." Three things are shown for them:
3
+ // 1. the reference CP (test helper) reproduces every trace: same error at the same frame (so the model the
4
+ // other tests use is the model of the contract);
5
+ // 2. the two traces whose error is in a CP->LA frame (params_mismatch, tenant_mismatch of the signature)
6
+ // are reproduced by the REAL LA session, same error at the same frame;
7
+ // 3. for the others (an LA frame that the CP refuses) the LA's own send path refuses to produce the same
8
+ // frame, with the same code; the valid traces are replayed with the LA as the LA.
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { ReferenceCp, uuid, waitFor } from "./helpers/reference-cp.js";
15
+ import { inMemory, signingFor } from "./helpers/la-rig.js";
16
+ import { V2Session } from "../../src/la-protocol-v2/session.js";
17
+
18
+ const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "../contract/la-protocol-v2/vectors/traces");
19
+ const traces = fs.readdirSync(DIR).filter((f) => f.endsWith(".json")).sort().map((f) => ({ file: f, name: f.replace(".json", ""), ...JSON.parse(fs.readFileSync(path.join(DIR, f), "utf8")) }));
20
+
21
+ test("the suite has all 24 traces (20 invalid, 4 valid)", () => {
22
+ assert.equal(traces.length, 24);
23
+ assert.equal(traces.filter((t) => t.expect.ok).length, 4);
24
+ });
25
+
26
+ const textOf = (fr) => {
27
+ let text = JSON.stringify(fr.frame);
28
+ if (fr.inflateBytesTo) text += " ".repeat(Math.max(0, fr.inflateBytesTo - Buffer.byteLength(text)));
29
+ return text;
30
+ };
31
+
32
+ /** The reference CP in the state the trace starts from (a resumed session when the trace says so). */
33
+ function cpFor(trace) {
34
+ const s = trace.session;
35
+ const cp = new ReferenceCp({ tenantId: s.tenantId, agentId: s.agentId, requireEnforce: s.requireEnforce, now: () => Date.parse(s.now) });
36
+ const ready = trace.frames.map((f) => f.frame).find((f) => f.type === "ready");
37
+ const reg = trace.frames.map((f) => f.frame).find((f) => f.type === "register");
38
+ if (ready && ready.payload.resumed) {
39
+ cp.sessionStarted = true;
40
+ cp.sessionId = ready.payload.sessionId;
41
+ cp.laSeq = ready.payload.peerLastSeq;
42
+ cp.cpSeq = reg.payload.resume.lastRecvSeq;
43
+ }
44
+ cp.attach(() => {}, () => {});
45
+ return cp;
46
+ }
47
+
48
+ // ------------------------------------------------------------------------------------------ 1. the reference CP
49
+
50
+ for (const t of traces) {
51
+ const cpSideError = t.expect.error && t.frames[t.expect.atFrame].dir === "cp->la";
52
+ test(`reference CP reproduces ${t.name}${cpSideError ? " (n/a: the error is in a CP frame, see 2)" : ""}`, { skip: cpSideError }, () => {
53
+ const cp = cpFor(t);
54
+ let failed = null;
55
+ for (let i = 0; i < t.frames.length && !failed; i++) {
56
+ const fr = t.frames[i];
57
+ if (fr.dir === "cp->la") { cp.cpSeq = Math.max(cp.cpSeq, fr.frame.seq); continue; }
58
+ const r = cp.feed(textOf(fr));
59
+ if (r) failed = { error: r.code, atFrame: i };
60
+ }
61
+ if (t.expect.ok) {
62
+ assert.equal(failed, null, JSON.stringify(failed));
63
+ if (t.expect.duplicateFrames) assert.equal(cp.duplicates.length, t.expect.duplicateFrames.length);
64
+ } else {
65
+ assert.deepEqual(failed, { error: t.expect.error, atFrame: t.expect.atFrame });
66
+ }
67
+ });
68
+ }
69
+
70
+ // ------------------------------------------------------------------------------------------ 2. errors in CP frames, by the LA
71
+
72
+ for (const name of ["invalid-params-raw-differs-from-payload", "invalid-sig-tenant-mismatch"]) {
73
+ test(`the LA reproduces ${name}: ${traces.find((t) => t.name === name).expect.error} at frame ${traces.find((t) => t.name === name).expect.atFrame}`, async () => {
74
+ const t = traces.find((x) => x.name === name);
75
+ const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId });
76
+ // the trace's signatures are placeholders: mode off skips the cryptography, the structural checks still run
77
+ const env = inMemory(cp, { mode: "off", tenantId: t.session.tenantId, agentId: t.session.agentId });
78
+ env.session.attach(env.transport);
79
+ await waitFor(() => env.session.state === "ready");
80
+ const bad = t.frames[t.expect.atFrame].frame;
81
+ env.session.setHandler(bad.type, () => ({ accept: true }));
82
+ env.session.handleText(JSON.stringify(bad), env.transport);
83
+ await waitFor(() => cp.peerErrors.length > 0);
84
+ assert.deepEqual(cp.peerErrors, [t.expect.error]);
85
+ assert.equal(env.session.stats.dataOut, 0);
86
+ env.session.close();
87
+ });
88
+ }
89
+
90
+ // ------------------------------------------------------------------------------------------ 3. the LA never produces them
91
+
92
+ const LA_FRAMES = (t) => t.frames.map((f, i) => ({ ...f, i })).filter((f) => f.dir === "la->cp");
93
+
94
+ async function readyLa() {
95
+ const cp = new ReferenceCp();
96
+ const env = inMemory(cp).start();
97
+ await waitFor(() => env.session.state === "ready");
98
+ return { cp, ...env };
99
+ }
100
+
101
+ for (const name of ["invalid-chunk-after-last", "invalid-chunk-byte-length-mismatch", "invalid-chunk-last-flag", "invalid-chunk-out-of-order", "invalid-chunk-total-changes", "invalid-payload-above-limit"]) {
102
+ test(`the LA refuses to send the chunks of ${name}: same code at the same frame`, async () => {
103
+ const t = traces.find((x) => x.name === name);
104
+ const { cp, session } = await readyLa();
105
+ let got = null;
106
+ for (const fr of LA_FRAMES(t)) {
107
+ if (fr.frame.type !== "content.response") continue;
108
+ try {
109
+ await session.enqueue("content.response", fr.frame.payload, { requestId: fr.frame.requestId, chunk: fr.frame.chunk }).then((h) => h.acked);
110
+ } catch (e) { got = { error: e.code, atFrame: fr.i }; break; }
111
+ }
112
+ // the oversized trace chunk is refused by the schema (payload maxLength), which the CP reports as payload_too_large
113
+ assert.deepEqual(got, { error: t.expect.error, atFrame: t.expect.atFrame });
114
+ assert.deepEqual(cp.errorsSent, [], "nothing invalid reached the CP");
115
+ session.close();
116
+ });
117
+ }
118
+
119
+ test("the LA never produces invalid-event-before-ready / invalid-first-frame-not-register: register and hello first, no data before ready", async () => {
120
+ const cp = new ReferenceCp();
121
+ const sent = [];
122
+ const env = inMemory(cp);
123
+ const swallow = { send: (t) => sent.push(JSON.parse(t)), close() {}, bufferedAmount: () => 0 };
124
+ env.session.attach(swallow);
125
+ assert.deepEqual(sent.map((f) => f.type), ["register", "hello"], "the first frame is register, then hello");
126
+ await assert.rejects(env.session.enqueue("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" }), (e) => e.code === "not_ready");
127
+ assert.equal(sent.length, 2);
128
+ env.session.close();
129
+ });
130
+
131
+ test("the LA never produces invalid-clock-skew: register.ts is the clock of the LA, hello carries clockEpochMs", async () => {
132
+ const cp = new ReferenceCp();
133
+ const sent = [];
134
+ const env = inMemory(cp);
135
+ env.session.attach({ send: (t) => sent.push(JSON.parse(t)), close() {}, bufferedAmount: () => 0 });
136
+ assert.ok(Math.abs(Date.parse(sent[0].ts) - Date.now()) < 2000);
137
+ assert.ok(Math.abs(sent[1].payload.clockEpochMs - Date.now()) < 2000);
138
+ env.session.close();
139
+ });
140
+
141
+ test("the LA never produces invalid-signature-warn-with-enforce-required: hello.signatureMode is the real mode, and a CP that requires enforce says so", async () => {
142
+ const cp = new ReferenceCp({ requireEnforce: true });
143
+ const env = inMemory(cp, { mode: "warn" }).start();
144
+ await waitFor(() => cp.errorsSent.length > 0);
145
+ assert.deepEqual(cp.errorsSent, ["signature_mode_insufficient"]);
146
+ assert.equal(env.session.state, "await_ready"); // not ready, no data possible
147
+ assert.equal(cp.hellos.length, 0);
148
+ env.session.close();
149
+ });
150
+
151
+ test("the LA never produces invalid-agent-mismatch / invalid-tenant-mismatch: every frame carries the configured ids, never one from a payload", async () => {
152
+ const { cp, session } = await readyLa();
153
+ await session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" });
154
+ for (const f of cp.received) {
155
+ assert.equal(f.tenantId, cp.tenantId);
156
+ assert.equal(f.agentId, cp.agentId);
157
+ }
158
+ // there is no way to pass an identity to enqueue(); a payload that tries is refused by the closed schema
159
+ await assert.rejects(session.enqueue("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", tenantId: "99999999-5a4d-4e7b-9c21-0a1b2c3d4e5f" }), (e) => e.code === "schema_violation");
160
+ session.close();
161
+ });
162
+
163
+ test("the LA never produces invalid-la-sends-job-assign / invalid-type-outside-allowlist", async () => {
164
+ const { cp, session } = await readyLa();
165
+ await assert.rejects(session.enqueue("job.assign", {}), (e) => e.code === "wrong_direction");
166
+ await assert.rejects(session.enqueue("shell.exec", {}), (e) => e.code === "unknown_type");
167
+ assert.deepEqual(cp.errorsSent, []);
168
+ session.close();
169
+ });
170
+
171
+ test("the LA never produces invalid-seq-gap / invalid-seq-repeated / invalid-resume-restarts-seq: consecutive seq, one msgId per seq, a resume continues", async () => {
172
+ const { cp, session } = await readyLa();
173
+ for (let i = 0; i < 4; i++) await session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", iteration: i });
174
+ assert.deepEqual(cp.received.map((f) => f.seq), [1, 2, 3, 4]);
175
+ assert.equal(new Set(cp.received.map((f) => f.msgId)).size, 4);
176
+ assert.deepEqual(cp.errorsSent, []);
177
+ session.close();
178
+ });
179
+
180
+ test("the LA never produces invalid-frame-over-48kib: frames are compact JSON and the size is checked before sending", async () => {
181
+ const { cp, session } = await readyLa();
182
+ await assert.rejects(session.enqueue("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", phase: "x".repeat(60_000) }), (e) => ["frame_too_large", "payload_too_large", "schema_violation"].includes(e.code));
183
+ // and the same frame as the trace has it (padded to 50000 bytes) is refused on the way IN as frame_too_large
184
+ const t = traces.find((x) => x.name === "invalid-frame-over-48kib");
185
+ const fr = t.frames[t.expect.atFrame];
186
+ const { transport } = { transport: session.transport };
187
+ session.handleText(textOf(fr), transport);
188
+ await waitFor(() => cp.peerErrors.length > 0);
189
+ assert.deepEqual(cp.peerErrors, ["frame_too_large"]);
190
+ session.close();
191
+ });
192
+
193
+ // ------------------------------------------------------------------------------------------ valid traces, LA as the LA
194
+
195
+ test("valid-handshake-and-job: CP job.assign -> LA acks it (ackSeq 1) and then reports job.event x2 + job.result with seq 1..3", async () => {
196
+ const t = traces.find((x) => x.name === "valid-handshake-and-job");
197
+ const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId, requireEnforce: true });
198
+ const env = inMemory(cp, { tenantId: t.session.tenantId, agentId: t.session.agentId }).start();
199
+ await waitFor(() => env.session.state === "ready");
200
+ const assign = t.frames.find((f) => f.frame.type === "job.assign").frame;
201
+ env.session.setHandler("job.assign", (ctx) => { assert.deepEqual(ctx.params, assign.payload); return { accept: true }; });
202
+ cp.sendData("job.assign", assign.payload, { workspaceId: assign.workspaceId, requestId: assign.requestId });
203
+ await waitFor(() => cp.acksFromLa.length === 1);
204
+ assert.deepEqual(cp.acksFromLa[0], { ackSeq: 1, status: "accepted", errorCode: undefined });
205
+ for (const fr of LA_FRAMES(t).filter((f) => ["job.event", "job.result"].includes(f.frame.type))) {
206
+ await env.session.send(fr.frame.type, fr.frame.payload, { workspaceId: fr.frame.workspaceId });
207
+ }
208
+ assert.deepEqual(cp.received.map((f) => [f.type, f.seq]), [["job.event", 1], ["job.event", 2], ["job.result", 3]]);
209
+ assert.deepEqual(cp.errorsSent, []);
210
+ env.session.close();
211
+ });
212
+
213
+ test("valid-content-chunks-in-order: the three chunks of the trace go out in order and the CP accepts them", async () => {
214
+ const t = traces.find((x) => x.name === "valid-content-chunks-in-order");
215
+ const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId });
216
+ const env = inMemory(cp, { tenantId: t.session.tenantId, agentId: t.session.agentId }).start();
217
+ await waitFor(() => env.session.state === "ready");
218
+ for (const fr of LA_FRAMES(t).filter((f) => f.frame.type === "content.response")) {
219
+ await env.session.send("content.response", fr.frame.payload, { requestId: fr.frame.requestId, chunk: fr.frame.chunk, workspaceId: fr.frame.workspaceId });
220
+ }
221
+ assert.deepEqual(cp.received.map((f) => [f.seq, f.chunk.index]), [[1, 0], [2, 1], [3, 2]]);
222
+ assert.deepEqual(cp.errorsSent, []);
223
+ env.session.close();
224
+ });
225
+
226
+ test("valid-retransmit-is-idempotent: an ack lost -> the same frame again -> the CP re-acks and counts one event", async () => {
227
+ const cp = new ReferenceCp({ limits: { ackTimeoutMs: 100 } });
228
+ const env = inMemory(cp).start();
229
+ await waitFor(() => env.session.state === "ready");
230
+ cp.dropAcks = 1;
231
+ await env.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running" });
232
+ await env.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "succeeded" });
233
+ assert.deepEqual(cp.duplicates, [1]);
234
+ assert.deepEqual(cp.received.map((f) => f.seq), [1, 2]);
235
+ assert.deepEqual(cp.errorsSent, []);
236
+ env.session.close();
237
+ });
238
+
239
+ test("valid-resume-continues-seq: after a resumed ready the LA continues at peerLastSeq+1", async () => {
240
+ const t = traces.find((x) => x.name === "valid-resume-continues-seq");
241
+ const cp = new ReferenceCp({ tenantId: t.session.tenantId, agentId: t.session.agentId });
242
+ const env = inMemory(cp, { tenantId: t.session.tenantId, agentId: t.session.agentId });
243
+ // first life: 5 events delivered
244
+ env.session.attach(env.transport);
245
+ await waitFor(() => env.session.state === "ready");
246
+ for (let i = 0; i < 5; i++) await env.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", iteration: i });
247
+ // the CP also sent 4 frames
248
+ env.session.setHandler("job.cancel", () => ({ accept: true }));
249
+ for (let i = 0; i < 4; i++) cp.sendData("job.cancel", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", reason: "user" });
250
+ await waitFor(() => env.session.lastRecvSeq === 4);
251
+ // second life: same session object (the LA process kept it), new connection
252
+ const t2 = { send: (x) => { env.wire.toCp.push(x); cp.feed(x); }, close() {}, bufferedAmount: () => 0 };
253
+ env.session.detach(env.transport);
254
+ cp.attach((x) => queueMicrotask(() => env.session.handleText(x, t2)), () => {});
255
+ env.session.attach(t2);
256
+ await waitFor(() => env.session.state === "ready");
257
+ assert.deepEqual(cp.registers.at(-1).resume, { sessionId: cp.sessionId, lastRecvSeq: 4 });
258
+ await env.session.send("job.event", { jobId: "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", state: "running", iteration: 5 });
259
+ assert.equal(cp.received.at(-1).seq, 6, "continues at peerLastSeq + 1");
260
+ assert.deepEqual(cp.errorsSent, []);
261
+ env.session.close();
262
+ });
263
+
264
+ test("V2Session is exported and constructible without side effects (no timers left running)", () => {
265
+ const cp = new ReferenceCp();
266
+ const s = new V2Session({ tenantId: cp.tenantId, agentId: cp.agentId, signing: signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }), laVersion: "1.3.0" });
267
+ assert.equal(s.state, "idle");
268
+ s.close();
269
+ assert.equal(uuid().length, 36);
270
+ });