@nanobpm/agentic 0.1.0 → 0.4.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 (117) hide show
  1. package/README.md +1 -0
  2. package/dist/demand/model.d.ts +7 -4
  3. package/dist/demand/model.js +22 -4
  4. package/dist/demand/taskdef.d.ts +13 -1
  5. package/dist/demand/taskdef.js +20 -2
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/protocol/conformance/frames.js +32 -4
  9. package/dist/protocol/index.d.ts +1 -1
  10. package/dist/protocol/payloads.d.ts +44 -0
  11. package/dist/protocol/payloads.js +61 -7
  12. package/dist/session/acp/client.d.ts +109 -0
  13. package/dist/session/acp/client.js +254 -0
  14. package/dist/session/acp/index.d.ts +27 -0
  15. package/dist/session/acp/index.js +27 -0
  16. package/dist/session/acp/jsonrpc.d.ts +25 -0
  17. package/dist/session/acp/jsonrpc.js +148 -0
  18. package/dist/session/acp/normalize.d.ts +48 -0
  19. package/dist/session/acp/normalize.js +162 -0
  20. package/dist/session/acp/protocol.d.ts +94 -0
  21. package/dist/session/acp/protocol.js +136 -0
  22. package/dist/session/acp/spawn.d.ts +36 -0
  23. package/dist/session/acp/spawn.js +68 -0
  24. package/dist/session/acp/transport.d.ts +62 -0
  25. package/dist/session/acp/transport.js +126 -0
  26. package/dist/session/adapter.d.ts +135 -0
  27. package/dist/session/adapter.js +24 -0
  28. package/dist/session/backend.d.ts +43 -0
  29. package/dist/session/backend.js +95 -0
  30. package/dist/session/events.d.ts +152 -0
  31. package/dist/session/events.js +192 -0
  32. package/dist/session/index.d.ts +31 -0
  33. package/dist/session/index.js +5 -0
  34. package/dist/session/log.d.ts +107 -0
  35. package/dist/session/log.js +351 -0
  36. package/dist/session/normalizer/claude.d.ts +23 -0
  37. package/dist/session/normalizer/claude.js +138 -0
  38. package/dist/session/normalizer/copilot.d.ts +27 -0
  39. package/dist/session/normalizer/copilot.js +105 -0
  40. package/dist/session/normalizer/deepseek.d.ts +11 -0
  41. package/dist/session/normalizer/deepseek.js +68 -0
  42. package/dist/session/normalizer/index.d.ts +36 -0
  43. package/dist/session/normalizer/index.js +29 -0
  44. package/dist/session/normalizer/kimi.d.ts +10 -0
  45. package/dist/session/normalizer/kimi.js +80 -0
  46. package/dist/session/normalizer/link.d.ts +36 -0
  47. package/dist/session/normalizer/link.js +56 -0
  48. package/dist/session/normalizer/pi.d.ts +13 -0
  49. package/dist/session/normalizer/pi.js +61 -0
  50. package/dist/session/normalizer/qwen.d.ts +11 -0
  51. package/dist/session/normalizer/qwen.js +65 -0
  52. package/dist/session/normalizer/record.d.ts +21 -0
  53. package/dist/session/normalizer/record.js +87 -0
  54. package/dist/session/normalizer/types.d.ts +139 -0
  55. package/dist/session/normalizer/types.js +31 -0
  56. package/dist/session/schema.d.ts +38 -0
  57. package/dist/session/schema.js +74 -0
  58. package/package.json +17 -1
  59. package/src/demand/model.test.ts +82 -4
  60. package/src/demand/model.ts +30 -9
  61. package/src/demand/taskdef.test.ts +51 -6
  62. package/src/demand/taskdef.ts +31 -2
  63. package/src/index.ts +1 -0
  64. package/src/protocol/conformance/frames.ts +32 -4
  65. package/src/protocol/index.ts +4 -0
  66. package/src/protocol/payloads.test.ts +31 -1
  67. package/src/protocol/payloads.ts +110 -7
  68. package/src/session/acp/client.test.ts +222 -0
  69. package/src/session/acp/client.ts +356 -0
  70. package/src/session/acp/fake-agent.ts +71 -0
  71. package/src/session/acp/index.ts +68 -0
  72. package/src/session/acp/integration.test.ts +37 -0
  73. package/src/session/acp/jsonrpc.test.ts +75 -0
  74. package/src/session/acp/jsonrpc.ts +171 -0
  75. package/src/session/acp/normalize.test.ts +150 -0
  76. package/src/session/acp/normalize.ts +204 -0
  77. package/src/session/acp/protocol.ts +178 -0
  78. package/src/session/acp/spawn.test.ts +45 -0
  79. package/src/session/acp/spawn.ts +91 -0
  80. package/src/session/acp/transport.test.ts +82 -0
  81. package/src/session/acp/transport.ts +155 -0
  82. package/src/session/adapter.ts +159 -0
  83. package/src/session/backend.test.ts +198 -0
  84. package/src/session/backend.ts +128 -0
  85. package/src/session/events.test.ts +168 -0
  86. package/src/session/events.ts +347 -0
  87. package/src/session/index.ts +67 -0
  88. package/src/session/log.test.ts +215 -0
  89. package/src/session/log.ts +525 -0
  90. package/src/session/normalizer/backend-integration.test.ts +103 -0
  91. package/src/session/normalizer/claude.test.ts +68 -0
  92. package/src/session/normalizer/claude.ts +136 -0
  93. package/src/session/normalizer/copilot.test.ts +59 -0
  94. package/src/session/normalizer/copilot.ts +133 -0
  95. package/src/session/normalizer/deepseek.ts +80 -0
  96. package/src/session/normalizer/index.ts +61 -0
  97. package/src/session/normalizer/kimi.ts +82 -0
  98. package/src/session/normalizer/link.test.ts +24 -0
  99. package/src/session/normalizer/link.ts +81 -0
  100. package/src/session/normalizer/pi.ts +75 -0
  101. package/src/session/normalizer/probe.test.ts +49 -0
  102. package/src/session/normalizer/qwen.test.ts +20 -0
  103. package/src/session/normalizer/qwen.ts +77 -0
  104. package/src/session/normalizer/record.test.ts +68 -0
  105. package/src/session/normalizer/record.ts +88 -0
  106. package/src/session/normalizer/resume.test.ts +25 -0
  107. package/src/session/normalizer/types.ts +152 -0
  108. package/src/session/normalizer/vectors.test.ts +180 -0
  109. package/src/session/schema.test.ts +84 -0
  110. package/src/session/schema.ts +78 -0
  111. package/src/session/test-db.ts +56 -0
  112. package/dist/blackboard/test-db.d.ts +0 -5
  113. package/dist/blackboard/test-db.js +0 -42
  114. package/dist/presence/test-db.d.ts +0 -5
  115. package/dist/presence/test-db.js +0 -42
  116. package/dist/transcript/test-db.d.ts +0 -5
  117. package/dist/transcript/test-db.js +0 -41
@@ -101,15 +101,43 @@ export const GOLDEN_FRAMES: readonly GoldenFrame[] = [
101
101
  hex: "4e4101010600000065000000187b226f70223a2272656164222c2273696e6365223a31327d",
102
102
  },
103
103
  {
104
- name: "relay-chunk-bulk",
104
+ name: "relay-produce-bulk",
105
105
  direction: "worker->hub",
106
106
  frame: {
107
107
  lane: "bulk",
108
108
  family: "relay",
109
109
  seq: 5,
110
- payload: { stream: "job-1223", offset: 2048, chunk: "hello world\n" },
110
+ payload: { op: "produce", stream: "job-1223", incarnation: 1, chunk: "hello world\n" },
111
111
  },
112
- hex: "4e41010207000000050000003b7b2273747265616d223a226a6f622d31323233222c226f6666736574223a323034382c226368756e6b223a2268656c6c6f20776f726c645c6e227d",
112
+ hex: "4e41010207000000050000004c7b226f70223a2270726f64756365222c2273747265616d223a226a6f622d31323233222c22696e6361726e6174696f6e223a312c226368756e6b223a2268656c6c6f20776f726c645c6e227d",
113
+ },
114
+ {
115
+ name: "relay-subscribe-control",
116
+ direction: "worker->hub",
117
+ frame: {
118
+ lane: "control",
119
+ family: "relay",
120
+ seq: 6,
121
+ payload: { op: "subscribe", stream: "job-1223", from: 0, credit: 1024 },
122
+ },
123
+ hex: "4e41010007000000060000003d7b226f70223a22737562736372696265222c2273747265616d223a226a6f622d31323233222c2266726f6d223a302c22637265646974223a313032347d",
124
+ },
125
+ {
126
+ name: "relay-credit-control",
127
+ direction: "worker->hub",
128
+ frame: { lane: "control", family: "relay", seq: 7, payload: { op: "credit", credit: 512 } },
129
+ hex: "4e41010007000000070000001c7b226f70223a22637265646974222c22637265646974223a3531327d",
130
+ },
131
+ {
132
+ name: "relay-subscribed-ack-control",
133
+ direction: "hub->worker",
134
+ frame: {
135
+ lane: "control",
136
+ family: "relay",
137
+ seq: 8,
138
+ payload: { op: "subscribed", stream: "job-1223", gap: false, nextOffset: 2048 },
139
+ },
140
+ hex: "4e4101000700000008000000457b226f70223a2273756273637269626564222c2273747265616d223a226a6f622d31323233222c22676170223a66616c73652c226e6578744f6666736574223a323034387d",
113
141
  },
114
142
  {
115
143
  name: "relay-seq-max-bulk",
@@ -130,7 +158,7 @@ export const GOLDEN_FRAMES: readonly GoldenFrame[] = [
130
158
  },
131
159
  {
132
160
  name: "unicode-payload-relay",
133
- direction: "worker->hub",
161
+ direction: "hub->worker",
134
162
  frame: {
135
163
  lane: "bulk",
136
164
  family: "relay",
@@ -77,6 +77,10 @@ export {
77
77
  type BlackboardPayload,
78
78
  type BlackboardOp,
79
79
  type RelayPayload,
80
+ type RelayProducePayload,
81
+ type RelaySubscribePayload,
82
+ type RelayCreditPayload,
83
+ type RelaySubscribedPayload,
80
84
  type PayloadError,
81
85
  type PayloadValidationResult,
82
86
  } from "./payloads.ts";
@@ -69,13 +69,43 @@ test("blackboard files must be an array of only strings when present", () => {
69
69
  if (!mixed.ok) assert.ok(mixed.errors.some((e) => e.code === "bad-files"));
70
70
  });
71
71
 
72
- test("relay requires stream, non-negative integer offset, and string chunk", () => {
72
+ test("relay delivery chunk (no op) requires stream, non-negative integer offset, and string chunk", () => {
73
73
  assert.ok(validatePayload("relay", { stream: "s", offset: 0, chunk: "" }).ok);
74
74
  assert.ok(!validatePayload("relay", { stream: "s", offset: -1, chunk: "" }).ok);
75
75
  assert.ok(!validatePayload("relay", { stream: "s", offset: 1.5, chunk: "" }).ok);
76
76
  assert.ok(!validatePayload("relay", { stream: "", offset: 0, chunk: "" }).ok);
77
77
  });
78
78
 
79
+ test("relay produce control frame requires stream, non-negative integer incarnation, and string chunk", () => {
80
+ assert.ok(validatePayload("relay", { op: "produce", stream: "s", incarnation: 0, chunk: "hi" }).ok);
81
+ assert.ok(validatePayload("relay", { op: "produce", stream: "s", incarnation: 1786690000000, chunk: "" }).ok);
82
+ const badInc = validatePayload("relay", { op: "produce", stream: "s", incarnation: -1, chunk: "" });
83
+ assert.ok(!badInc.ok);
84
+ if (!badInc.ok) assert.ok(badInc.errors.some((e) => e.code === "bad-incarnation"));
85
+ assert.ok(!validatePayload("relay", { op: "produce", stream: "s", incarnation: 1.5, chunk: "" }).ok);
86
+ // The legacy bug: a produce frame missing `incarnation` (the old { stream, offset, chunk } shape) is rejected.
87
+ assert.ok(!validatePayload("relay", { op: "produce", stream: "s", offset: 0, chunk: "" }).ok);
88
+ assert.ok(!validatePayload("relay", { op: "produce", stream: "", incarnation: 0, chunk: "" }).ok);
89
+ });
90
+
91
+ test("relay subscribe/credit/subscribed control frames validate their fields", () => {
92
+ assert.ok(validatePayload("relay", { op: "subscribe", stream: "s" }).ok);
93
+ assert.ok(validatePayload("relay", { op: "subscribe", stream: "s", from: 2, credit: 1024 }).ok);
94
+ assert.ok(!validatePayload("relay", { op: "subscribe", stream: "s", from: -1 }).ok);
95
+ assert.ok(!validatePayload("relay", { op: "subscribe", stream: "s", credit: 1.5 }).ok);
96
+ assert.ok(validatePayload("relay", { op: "credit", credit: 512 }).ok);
97
+ assert.ok(!validatePayload("relay", { op: "credit", credit: -1 }).ok);
98
+ assert.ok(validatePayload("relay", { op: "subscribed", stream: "s", gap: false, nextOffset: 8 }).ok);
99
+ assert.ok(!validatePayload("relay", { op: "subscribed", stream: "s", gap: "no", nextOffset: 8 }).ok);
100
+ assert.ok(!validatePayload("relay", { op: "subscribed", stream: "s", gap: true, nextOffset: -1 }).ok);
101
+ });
102
+
103
+ test("relay rejects an unknown op", () => {
104
+ const bad = validatePayload("relay", { op: "explode", stream: "s" });
105
+ assert.ok(!bad.ok);
106
+ if (!bad.ok) assert.ok(bad.errors.some((e) => e.code === "bad-op"));
107
+ });
108
+
79
109
  test("deregister accepts optional string reason and rejects non-string reason", () => {
80
110
  assert.ok(validatePayload("deregister", { instance: "w" }).ok);
81
111
  assert.ok(validatePayload("deregister", { instance: "w", reason: "shutdown" }).ok);
@@ -53,12 +53,60 @@ export interface BlackboardPayload {
53
53
  readonly since?: number;
54
54
  }
55
55
 
56
+ /**
57
+ * The `relay` family multiplexes two roles on one message family:
58
+ *
59
+ * - CONTROL frames a peer sends the hub: a producer's {@link RelayProducePayload}
60
+ * (`op: "produce"`), and a consumer's {@link RelaySubscribePayload} /
61
+ * {@link RelayCreditPayload}.
62
+ * - DELIVERY frames the hub sends a consumer: a data chunk ({@link RelayPayload},
63
+ * no `op`) and a resume ack ({@link RelaySubscribedPayload}, `op: "subscribed"`).
64
+ *
65
+ * {@link RelayPayload} is the DELIVERY data chunk specifically (`{ stream, offset,
66
+ * chunk }`, no `op`) — the hub assigns the authoritative `offset` from its ring.
67
+ * A producer must NOT send this shape; it sends {@link RelayProducePayload}.
68
+ */
56
69
  export interface RelayPayload {
57
70
  readonly stream: string;
58
71
  readonly offset: number;
59
72
  readonly chunk: string;
60
73
  }
61
74
 
75
+ /** A producer appends bytes to a stream. `incarnation` is the producer's
76
+ * generation, stamped so the hub can fence a stale predecessor (a retried job on
77
+ * a fresh runner takes over with a strictly higher incarnation). The hub assigns
78
+ * the offset — a producer never carries one. */
79
+ export interface RelayProducePayload {
80
+ readonly op: "produce";
81
+ readonly stream: string;
82
+ readonly incarnation: number;
83
+ readonly chunk: string;
84
+ }
85
+
86
+ /** A consumer subscribes to a stream, optionally resuming from `from` with an
87
+ * initial `credit` budget. */
88
+ export interface RelaySubscribePayload {
89
+ readonly op: "subscribe";
90
+ readonly stream: string;
91
+ readonly from?: number;
92
+ readonly credit?: number;
93
+ }
94
+
95
+ /** A consumer replenishes its flow-control budget. */
96
+ export interface RelayCreditPayload {
97
+ readonly op: "credit";
98
+ readonly credit: number;
99
+ }
100
+
101
+ /** The hub's ack to a subscribe: `gap` is true when `from` predated the retained
102
+ * ring (bytes were missed), `nextOffset` is where delivery resumes. */
103
+ export interface RelaySubscribedPayload {
104
+ readonly op: "subscribed";
105
+ readonly stream: string;
106
+ readonly gap: boolean;
107
+ readonly nextOffset: number;
108
+ }
109
+
62
110
  export interface PayloadError {
63
111
  readonly code: string;
64
112
  readonly message: string;
@@ -76,6 +124,10 @@ function nonEmptyString(value: unknown): value is string {
76
124
  return typeof value === "string" && value.length > 0;
77
125
  }
78
126
 
127
+ function nonNegInt(value: unknown): value is number {
128
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
129
+ }
130
+
79
131
  function validateRegister(p: Record<string, unknown>, errors: PayloadError[]): void {
80
132
  if (!nonEmptyString(p.instance)) {
81
133
  errors.push({ code: "bad-instance", message: "register.instance must be a non-empty string" });
@@ -150,14 +202,65 @@ function validateBlackboard(p: Record<string, unknown>, errors: PayloadError[]):
150
202
  }
151
203
 
152
204
  function validateRelay(p: Record<string, unknown>, errors: PayloadError[]): void {
153
- if (!nonEmptyString(p.stream)) {
154
- errors.push({ code: "bad-stream", message: "relay.stream must be a non-empty string" });
155
- }
156
- if (typeof p.offset !== "number" || !Number.isInteger(p.offset) || p.offset < 0) {
157
- errors.push({ code: "bad-offset", message: "relay.offset must be a non-negative integer" });
205
+ const op = p.op;
206
+ // No `op`: a DELIVERY data chunk (hub -> consumer) — `{ stream, offset, chunk }`.
207
+ if (op === undefined) {
208
+ if (!nonEmptyString(p.stream)) {
209
+ errors.push({ code: "bad-stream", message: "relay.stream must be a non-empty string" });
210
+ }
211
+ if (!nonNegInt(p.offset)) {
212
+ errors.push({ code: "bad-offset", message: "relay.offset must be a non-negative integer" });
213
+ }
214
+ if (typeof p.chunk !== "string") {
215
+ errors.push({ code: "bad-chunk", message: "relay.chunk must be a string" });
216
+ }
217
+ return;
158
218
  }
159
- if (typeof p.chunk !== "string") {
160
- errors.push({ code: "bad-chunk", message: "relay.chunk must be a string" });
219
+ // Otherwise an op-tagged CONTROL/ack frame.
220
+ switch (op) {
221
+ case "produce":
222
+ if (!nonEmptyString(p.stream)) {
223
+ errors.push({ code: "bad-stream", message: "relay.produce.stream must be a non-empty string" });
224
+ }
225
+ if (!nonNegInt(p.incarnation)) {
226
+ errors.push({ code: "bad-incarnation", message: "relay.produce.incarnation must be a non-negative integer" });
227
+ }
228
+ if (typeof p.chunk !== "string") {
229
+ errors.push({ code: "bad-chunk", message: "relay.produce.chunk must be a string" });
230
+ }
231
+ return;
232
+ case "subscribe":
233
+ if (!nonEmptyString(p.stream)) {
234
+ errors.push({ code: "bad-stream", message: "relay.subscribe.stream must be a non-empty string" });
235
+ }
236
+ if ("from" in p && !nonNegInt(p.from)) {
237
+ errors.push({ code: "bad-from", message: "relay.subscribe.from must be a non-negative integer when present" });
238
+ }
239
+ if ("credit" in p && !nonNegInt(p.credit)) {
240
+ errors.push({ code: "bad-credit", message: "relay.subscribe.credit must be a non-negative integer when present" });
241
+ }
242
+ return;
243
+ case "credit":
244
+ if (!nonNegInt(p.credit)) {
245
+ errors.push({ code: "bad-credit", message: "relay.credit.credit must be a non-negative integer" });
246
+ }
247
+ return;
248
+ case "subscribed":
249
+ if (!nonEmptyString(p.stream)) {
250
+ errors.push({ code: "bad-stream", message: "relay.subscribed.stream must be a non-empty string" });
251
+ }
252
+ if (typeof p.gap !== "boolean") {
253
+ errors.push({ code: "bad-gap", message: "relay.subscribed.gap must be a boolean" });
254
+ }
255
+ if (!nonNegInt(p.nextOffset)) {
256
+ errors.push({ code: "bad-next-offset", message: "relay.subscribed.nextOffset must be a non-negative integer" });
257
+ }
258
+ return;
259
+ default:
260
+ errors.push({
261
+ code: "bad-op",
262
+ message: `relay.op must be one of produce|subscribe|credit|subscribed, got ${String(op)}`,
263
+ });
161
264
  }
162
265
  }
163
266
 
@@ -0,0 +1,222 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import type { ActivationKey } from "../adapter.ts";
4
+ import { openInMemorySession } from "../backend.ts";
5
+ import type { SessionEvent } from "../events.ts";
6
+ import { AcpSessionClient, type SessionEventSink } from "./client.ts";
7
+ import { startFakeAcpAgent } from "./fake-agent.ts";
8
+ import { AcpConnection } from "./jsonrpc.ts";
9
+ import { inMemoryTransportPair } from "./transport.ts";
10
+
11
+ const KEY: ActivationKey = { processInstanceKey: "pik-1", elementId: "implement-task" };
12
+ const CWD = "/work/repo";
13
+
14
+ function seqIds(prefix: string): () => string {
15
+ let n = 0;
16
+ return () => `${prefix}-${n++}`;
17
+ }
18
+
19
+ function collector(): { sink: SessionEventSink; events: SessionEvent[] } {
20
+ const events: SessionEvent[] = [];
21
+ return { events, sink: { emit: (event) => events.push(event) } };
22
+ }
23
+
24
+ function connectClient(
25
+ script: Parameters<typeof startFakeAcpAgent>[1],
26
+ sink: SessionEventSink,
27
+ ): { client: AcpSessionClient; closeAgent: () => void } {
28
+ const { client: clientTransport, agent: agentTransport } = inMemoryTransportPair();
29
+ const agent = startFakeAcpAgent(agentTransport, script);
30
+ const client = new AcpSessionClient(new AcpConnection(clientTransport), sink, { newEventId: seqIds("e") });
31
+ return { client, closeAgent: () => agent.close() };
32
+ }
33
+
34
+ function connectClientWithAgent(
35
+ script: Parameters<typeof startFakeAcpAgent>[1],
36
+ sink: SessionEventSink,
37
+ ): { client: AcpSessionClient; agent: AcpConnection; closeAgent: () => void } {
38
+ const { client: clientTransport, agent: agentTransport } = inMemoryTransportPair();
39
+ const agent = startFakeAcpAgent(agentTransport, script);
40
+ const client = new AcpSessionClient(new AcpConnection(clientTransport), sink, { newEventId: seqIds("e") });
41
+ return { client, agent, closeAgent: () => agent.close() };
42
+ }
43
+
44
+ test("initialize surfaces durable-resume:true when the agent advertises loadSession", async () => {
45
+ const { sink } = collector();
46
+ const { client } = connectClient({ loadSession: true }, sink);
47
+ const probe = await client.initialize();
48
+ assert.equal(probe.loadSession, true);
49
+ assert.equal(probe.durableResume, true);
50
+ assert.equal(probe.protocolVersion, 1);
51
+ });
52
+
53
+ test("initialize surfaces durable-resume:false when the agent lacks loadSession", async () => {
54
+ const { sink } = collector();
55
+ const { client } = connectClient({ loadSession: false }, sink);
56
+ const probe = await client.initialize();
57
+ assert.equal(probe.loadSession, false);
58
+ assert.equal(probe.durableResume, false);
59
+ });
60
+
61
+ test("a driven session normalises the update stream into canonical events on the authoritative log", async () => {
62
+ const { backend, log } = openInMemorySession(KEY, 1);
63
+ const { client: clientTransport, agent: agentTransport } = inMemoryTransportPair();
64
+ startFakeAcpAgent(agentTransport, {
65
+ loadSession: true,
66
+ promptUpdates: [
67
+ { sessionUpdate: "agent_message_chunk", messageId: "m1", content: { type: "text", text: "Hello " } },
68
+ { sessionUpdate: "agent_message_chunk", messageId: "m1", content: { type: "text", text: "world" } },
69
+ { sessionUpdate: "tool_call", toolCallId: "t1", title: "grep", status: "pending", rawInput: { q: "foo" } },
70
+ { sessionUpdate: "tool_call_update", toolCallId: "t1", status: "completed", rawOutput: { hits: 3 } },
71
+ { sessionUpdate: "agent_message_chunk", messageId: "m2", content: { type: "text", text: "Done" } },
72
+ ],
73
+ promptStopReason: "end_turn",
74
+ });
75
+ const client = new AcpSessionClient(new AcpConnection(clientTransport), backend, { newEventId: seqIds("e") });
76
+
77
+ await client.initialize();
78
+ await client.newSession({ cwd: CWD });
79
+ const result = await client.prompt("please search");
80
+
81
+ assert.equal(result.stopReason, "end_turn");
82
+ const logged = log.replay(KEY, 0);
83
+ assert.deepEqual(
84
+ logged.map((e) => ({ type: e.type, offset: e.offset })),
85
+ [
86
+ { type: "assistant", offset: 0 },
87
+ { type: "tool-call", offset: 1 },
88
+ { type: "tool-result", offset: 2 },
89
+ { type: "assistant", offset: 3 },
90
+ ],
91
+ "chunks coalesce into whole messages; the tool lifecycle becomes tool-call + tool-result",
92
+ );
93
+
94
+ const [assistant, toolCall, toolResult, done] = logged;
95
+ assert.equal(assistant.type === "assistant" && assistant.text, "Hello world");
96
+ assert.equal(toolCall.type === "tool-call" && toolCall.name, "grep");
97
+ assert.deepEqual(toolCall.type === "tool-call" ? toolCall.args : null, { q: "foo" });
98
+ assert.equal(toolResult.type === "tool-result" && toolResult.ok, true);
99
+ assert.deepEqual(toolResult.type === "tool-result" ? toolResult.result : null, { hits: 3 });
100
+ assert.equal(done.type === "assistant" && done.text, "Done");
101
+
102
+ // The producer owns a single unbroken causal chain across the whole stream.
103
+ assert.equal(assistant.parentId, null);
104
+ assert.equal(toolCall.parentId, assistant.id);
105
+ assert.equal(toolResult.parentId, toolCall.id);
106
+ assert.equal(done.parentId, toolResult.id);
107
+ });
108
+
109
+ test("restore replays prior history via session/load, then the agent continues", async () => {
110
+ const { backend, log } = openInMemorySession(KEY, 1);
111
+ const { client: clientTransport, agent: agentTransport } = inMemoryTransportPair();
112
+ startFakeAcpAgent(agentTransport, {
113
+ loadSession: true,
114
+ loadUpdates: [
115
+ { sessionUpdate: "user_message_chunk", content: { type: "text", text: "prior question" } },
116
+ { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "prior answer" } },
117
+ ],
118
+ promptUpdates: [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "continued" } }],
119
+ });
120
+ const client = new AcpSessionClient(new AcpConnection(clientTransport), backend, { newEventId: seqIds("e") });
121
+
122
+ const probe = await client.initialize();
123
+ assert.equal(probe.durableResume, true);
124
+
125
+ const restored = await client.restore("sess-existing", { cwd: CWD });
126
+ assert.deepEqual(
127
+ restored.map((e) => e.type),
128
+ ["user", "assistant"],
129
+ "session/load replays the prior turn as normalised events",
130
+ );
131
+ assert.equal(client.sessionId, "sess-existing");
132
+
133
+ await client.prompt("carry on");
134
+ const logged = log.replay(KEY, 0);
135
+ assert.deepEqual(logged.map((e) => e.type), ["user", "assistant", "assistant"]);
136
+ const last = logged[2];
137
+ assert.equal(last.type === "assistant" && last.text, "continued");
138
+ // The resumed turn continues the same causal chain as the restored history.
139
+ assert.equal(last.parentId, logged[1].id);
140
+ });
141
+
142
+ test("consecutive same-message chunks coalesce; a new messageId starts a new event", async () => {
143
+ const { sink, events } = collector();
144
+ const { client: clientTransport, agent: agentTransport } = inMemoryTransportPair();
145
+ startFakeAcpAgent(agentTransport, {
146
+ promptUpdates: [
147
+ { sessionUpdate: "agent_message_chunk", messageId: "a", content: { type: "text", text: "one " } },
148
+ { sessionUpdate: "agent_message_chunk", messageId: "a", content: { type: "text", text: "two" } },
149
+ { sessionUpdate: "agent_message_chunk", messageId: "b", content: { type: "text", text: "three" } },
150
+ ],
151
+ });
152
+ const client = new AcpSessionClient(new AcpConnection(clientTransport), sink, { newEventId: seqIds("e") });
153
+ await client.initialize();
154
+ await client.newSession({ cwd: CWD });
155
+ await client.prompt("go");
156
+
157
+ assert.deepEqual(
158
+ events.map((e) => (e.type === "assistant" ? e.text : e.type)),
159
+ ["one two", "three"],
160
+ );
161
+ });
162
+
163
+ test("session/update addressed to a different session id is ignored", async () => {
164
+ const { sink, events } = collector();
165
+ const { client, agent } = connectClientWithAgent(
166
+ {
167
+ sessionId: "sess-mine",
168
+ promptUpdates: [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "mine" } }],
169
+ promptStopReason: "end_turn",
170
+ },
171
+ sink,
172
+ );
173
+ await client.initialize();
174
+ await client.newSession({ cwd: CWD });
175
+ // A stray update for a *different* session must not enter this session's stream.
176
+ agent.notify("session/update", {
177
+ sessionId: "sess-other",
178
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "leak" } },
179
+ });
180
+ await client.prompt("hi");
181
+
182
+ assert.deepEqual(
183
+ events.map((e) => (e.type === "assistant" ? e.text : e.type)),
184
+ ["mine"],
185
+ "only the update addressed to sess-mine is ingested; the sess-other leak is dropped",
186
+ );
187
+ });
188
+
189
+ test("prompt before a session is established throws", async () => {
190
+ const { sink } = collector();
191
+ const { client } = connectClient({}, sink);
192
+ await client.initialize();
193
+ await assert.rejects(() => client.prompt("hi"), /active session/);
194
+ });
195
+
196
+ test("default permission handler selects the allow-flavoured option", async () => {
197
+ const { sink } = collector();
198
+ const { client, agent } = connectClientWithAgent({}, sink);
199
+ await client.initialize();
200
+ const outcome = await agent.request("session/request_permission", {
201
+ options: [
202
+ { optionId: "reject", kind: "reject_once" },
203
+ { optionId: "ok", kind: "allow_once" },
204
+ ],
205
+ });
206
+ assert.deepEqual(outcome, { outcome: { outcome: "selected", optionId: "ok" } });
207
+ });
208
+
209
+ test("default permission handler cancels when no allow-flavoured option is offered", async () => {
210
+ const { sink } = collector();
211
+ const { client, agent } = connectClientWithAgent({}, sink);
212
+ await client.initialize();
213
+ // No option's kind starts with "allow" — the handler must cancel, not select the
214
+ // first (possibly deny) option by ordering.
215
+ const outcome = await agent.request("session/request_permission", {
216
+ options: [
217
+ { optionId: "reject", kind: "reject_once" },
218
+ { optionId: "reject-all", kind: "reject_always" },
219
+ ],
220
+ });
221
+ assert.deepEqual(outcome, { outcome: { outcome: "cancelled" } });
222
+ });