@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.
- package/README.md +1 -0
- package/dist/demand/model.d.ts +7 -4
- package/dist/demand/model.js +22 -4
- package/dist/demand/taskdef.d.ts +13 -1
- package/dist/demand/taskdef.js +20 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/protocol/conformance/frames.js +32 -4
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/payloads.d.ts +44 -0
- package/dist/protocol/payloads.js +61 -7
- package/dist/session/acp/client.d.ts +109 -0
- package/dist/session/acp/client.js +254 -0
- package/dist/session/acp/index.d.ts +27 -0
- package/dist/session/acp/index.js +27 -0
- package/dist/session/acp/jsonrpc.d.ts +25 -0
- package/dist/session/acp/jsonrpc.js +148 -0
- package/dist/session/acp/normalize.d.ts +48 -0
- package/dist/session/acp/normalize.js +162 -0
- package/dist/session/acp/protocol.d.ts +94 -0
- package/dist/session/acp/protocol.js +136 -0
- package/dist/session/acp/spawn.d.ts +36 -0
- package/dist/session/acp/spawn.js +68 -0
- package/dist/session/acp/transport.d.ts +62 -0
- package/dist/session/acp/transport.js +126 -0
- package/dist/session/adapter.d.ts +135 -0
- package/dist/session/adapter.js +24 -0
- package/dist/session/backend.d.ts +43 -0
- package/dist/session/backend.js +95 -0
- package/dist/session/events.d.ts +152 -0
- package/dist/session/events.js +192 -0
- package/dist/session/index.d.ts +31 -0
- package/dist/session/index.js +5 -0
- package/dist/session/log.d.ts +107 -0
- package/dist/session/log.js +351 -0
- package/dist/session/normalizer/claude.d.ts +23 -0
- package/dist/session/normalizer/claude.js +138 -0
- package/dist/session/normalizer/copilot.d.ts +27 -0
- package/dist/session/normalizer/copilot.js +105 -0
- package/dist/session/normalizer/deepseek.d.ts +11 -0
- package/dist/session/normalizer/deepseek.js +68 -0
- package/dist/session/normalizer/index.d.ts +36 -0
- package/dist/session/normalizer/index.js +29 -0
- package/dist/session/normalizer/kimi.d.ts +10 -0
- package/dist/session/normalizer/kimi.js +80 -0
- package/dist/session/normalizer/link.d.ts +36 -0
- package/dist/session/normalizer/link.js +56 -0
- package/dist/session/normalizer/pi.d.ts +13 -0
- package/dist/session/normalizer/pi.js +61 -0
- package/dist/session/normalizer/qwen.d.ts +11 -0
- package/dist/session/normalizer/qwen.js +65 -0
- package/dist/session/normalizer/record.d.ts +21 -0
- package/dist/session/normalizer/record.js +87 -0
- package/dist/session/normalizer/types.d.ts +139 -0
- package/dist/session/normalizer/types.js +31 -0
- package/dist/session/schema.d.ts +38 -0
- package/dist/session/schema.js +74 -0
- package/package.json +17 -1
- package/src/demand/model.test.ts +82 -4
- package/src/demand/model.ts +30 -9
- package/src/demand/taskdef.test.ts +51 -6
- package/src/demand/taskdef.ts +31 -2
- package/src/index.ts +1 -0
- package/src/protocol/conformance/frames.ts +32 -4
- package/src/protocol/index.ts +4 -0
- package/src/protocol/payloads.test.ts +31 -1
- package/src/protocol/payloads.ts +110 -7
- package/src/session/acp/client.test.ts +222 -0
- package/src/session/acp/client.ts +356 -0
- package/src/session/acp/fake-agent.ts +71 -0
- package/src/session/acp/index.ts +68 -0
- package/src/session/acp/integration.test.ts +37 -0
- package/src/session/acp/jsonrpc.test.ts +75 -0
- package/src/session/acp/jsonrpc.ts +171 -0
- package/src/session/acp/normalize.test.ts +150 -0
- package/src/session/acp/normalize.ts +204 -0
- package/src/session/acp/protocol.ts +178 -0
- package/src/session/acp/spawn.test.ts +45 -0
- package/src/session/acp/spawn.ts +91 -0
- package/src/session/acp/transport.test.ts +82 -0
- package/src/session/acp/transport.ts +155 -0
- package/src/session/adapter.ts +159 -0
- package/src/session/backend.test.ts +198 -0
- package/src/session/backend.ts +128 -0
- package/src/session/events.test.ts +168 -0
- package/src/session/events.ts +347 -0
- package/src/session/index.ts +67 -0
- package/src/session/log.test.ts +215 -0
- package/src/session/log.ts +525 -0
- package/src/session/normalizer/backend-integration.test.ts +103 -0
- package/src/session/normalizer/claude.test.ts +68 -0
- package/src/session/normalizer/claude.ts +136 -0
- package/src/session/normalizer/copilot.test.ts +59 -0
- package/src/session/normalizer/copilot.ts +133 -0
- package/src/session/normalizer/deepseek.ts +80 -0
- package/src/session/normalizer/index.ts +61 -0
- package/src/session/normalizer/kimi.ts +82 -0
- package/src/session/normalizer/link.test.ts +24 -0
- package/src/session/normalizer/link.ts +81 -0
- package/src/session/normalizer/pi.ts +75 -0
- package/src/session/normalizer/probe.test.ts +49 -0
- package/src/session/normalizer/qwen.test.ts +20 -0
- package/src/session/normalizer/qwen.ts +77 -0
- package/src/session/normalizer/record.test.ts +68 -0
- package/src/session/normalizer/record.ts +88 -0
- package/src/session/normalizer/resume.test.ts +25 -0
- package/src/session/normalizer/types.ts +152 -0
- package/src/session/normalizer/vectors.test.ts +180 -0
- package/src/session/schema.test.ts +84 -0
- package/src/session/schema.ts +78 -0
- package/src/session/test-db.ts +56 -0
- package/dist/blackboard/test-db.d.ts +0 -5
- package/dist/blackboard/test-db.js +0 -42
- package/dist/presence/test-db.d.ts +0 -5
- package/dist/presence/test-db.js +0 -42
- package/dist/transcript/test-db.d.ts +0 -5
- 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-
|
|
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",
|
|
110
|
+
payload: { op: "produce", stream: "job-1223", incarnation: 1, chunk: "hello world\n" },
|
|
111
111
|
},
|
|
112
|
-
hex: "
|
|
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
|
|
161
|
+
direction: "hub->worker",
|
|
134
162
|
frame: {
|
|
135
163
|
lane: "bulk",
|
|
136
164
|
family: "relay",
|
package/src/protocol/index.ts
CHANGED
|
@@ -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);
|
package/src/protocol/payloads.ts
CHANGED
|
@@ -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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
+
});
|