@nanobpm/agentic 0.5.0 → 0.7.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 +58 -1
- package/dist/protocol/conformance/control.d.ts +32 -0
- package/dist/protocol/conformance/control.js +113 -0
- package/dist/protocol/conformance/index.d.ts +1 -0
- package/dist/protocol/conformance/index.js +1 -0
- package/dist/protocol/control.d.ts +110 -0
- package/dist/protocol/control.js +191 -0
- package/dist/protocol/index.d.ts +1 -0
- package/dist/protocol/index.js +1 -0
- package/dist/transcript/events.d.ts +213 -0
- package/dist/transcript/events.js +322 -0
- package/dist/transcript/index.d.ts +8 -0
- package/dist/transcript/index.js +7 -0
- package/package.json +1 -1
- package/src/protocol/conformance/control.ts +152 -0
- package/src/protocol/conformance/corpus.test.ts +76 -0
- package/src/protocol/conformance/index.ts +6 -0
- package/src/protocol/control.test.ts +131 -0
- package/src/protocol/control.ts +258 -0
- package/src/protocol/index.ts +17 -0
- package/src/transcript/events.drift.test.ts +68 -0
- package/src/transcript/events.test.ts +308 -0
- package/src/transcript/events.ts +490 -0
- package/src/transcript/index.ts +38 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTROL_FRAME_MARKER,
|
|
3
|
+
CONTROL_FRAME_VERSION,
|
|
4
|
+
type InboundControlErrorCode,
|
|
5
|
+
type InboundControlFrame,
|
|
6
|
+
} from "../control.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared corpus for the INBOUND control vocabulary (steer-in). These vectors are
|
|
10
|
+
* exported so cross-repo consumers (the c8ctl harness) decode the SAME frames.
|
|
11
|
+
*
|
|
12
|
+
* `chunk` is the raw inbound steer string a peer sends; `frame` is the typed
|
|
13
|
+
* {@link InboundControlFrame} it must decode to; `structured` records whether it
|
|
14
|
+
* is a recognised control envelope (`true`) or the legacy bare-string-as-prompt
|
|
15
|
+
* fall-back (`false`). A `roundTrips: false` vector decodes to `frame` but does
|
|
16
|
+
* NOT re-encode to the same `chunk` (a legacy bare string, or an envelope with
|
|
17
|
+
* extra tolerated fields), so the round-trip test skips re-encoding it.
|
|
18
|
+
*/
|
|
19
|
+
export interface ValidControlFrame {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly chunk: string;
|
|
22
|
+
readonly frame: InboundControlFrame;
|
|
23
|
+
readonly structured: boolean;
|
|
24
|
+
readonly roundTrips: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MARKER = { [CONTROL_FRAME_MARKER]: CONTROL_FRAME_VERSION };
|
|
28
|
+
|
|
29
|
+
export const VALID_CONTROL_FRAMES: readonly ValidControlFrame[] = [
|
|
30
|
+
{
|
|
31
|
+
name: "prompt-structured",
|
|
32
|
+
chunk: JSON.stringify({ ...MARKER, kind: "prompt", text: "run the tests" }),
|
|
33
|
+
frame: { kind: "prompt", text: "run the tests" },
|
|
34
|
+
structured: true,
|
|
35
|
+
roundTrips: true,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "prompt-structured-empty-text",
|
|
39
|
+
chunk: JSON.stringify({ ...MARKER, kind: "prompt", text: "" }),
|
|
40
|
+
frame: { kind: "prompt", text: "" },
|
|
41
|
+
structured: true,
|
|
42
|
+
roundTrips: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "cancel-structured",
|
|
46
|
+
chunk: JSON.stringify({ ...MARKER, kind: "cancel" }),
|
|
47
|
+
frame: { kind: "cancel" },
|
|
48
|
+
structured: true,
|
|
49
|
+
roundTrips: true,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "cancel-structured-with-reason",
|
|
53
|
+
chunk: JSON.stringify({ ...MARKER, kind: "cancel", reason: "operator interrupt" }),
|
|
54
|
+
frame: { kind: "cancel", reason: "operator interrupt" },
|
|
55
|
+
structured: true,
|
|
56
|
+
roundTrips: true,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "permission-granted",
|
|
60
|
+
chunk: JSON.stringify({ ...MARKER, kind: "permission", requestId: "req-7", outcome: "granted" }),
|
|
61
|
+
frame: { kind: "permission", requestId: "req-7", outcome: "granted" },
|
|
62
|
+
structured: true,
|
|
63
|
+
roundTrips: true,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "permission-denied",
|
|
67
|
+
chunk: JSON.stringify({ ...MARKER, kind: "permission", requestId: "req-8", outcome: "denied" }),
|
|
68
|
+
frame: { kind: "permission", requestId: "req-8", outcome: "denied" },
|
|
69
|
+
structured: true,
|
|
70
|
+
roundTrips: true,
|
|
71
|
+
},
|
|
72
|
+
// Legacy raw-byte steer: a bare keystroke/line is NOT a control envelope and
|
|
73
|
+
// must decode as a prompt carrying the chunk verbatim — the no-regression path.
|
|
74
|
+
{
|
|
75
|
+
name: "legacy-keystroke-line",
|
|
76
|
+
chunk: "ls -la\n",
|
|
77
|
+
frame: { kind: "prompt", text: "ls -la\n" },
|
|
78
|
+
structured: false,
|
|
79
|
+
roundTrips: false,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "legacy-control-c",
|
|
83
|
+
chunk: "\u0003",
|
|
84
|
+
frame: { kind: "prompt", text: "\u0003" },
|
|
85
|
+
structured: false,
|
|
86
|
+
roundTrips: false,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "legacy-json-number-is-not-a-frame",
|
|
90
|
+
chunk: "42",
|
|
91
|
+
frame: { kind: "prompt", text: "42" },
|
|
92
|
+
structured: false,
|
|
93
|
+
roundTrips: false,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "legacy-untagged-json-object",
|
|
97
|
+
chunk: JSON.stringify({ kind: "prompt", text: "not tagged" }),
|
|
98
|
+
frame: { kind: "prompt", text: JSON.stringify({ kind: "prompt", text: "not tagged" }) },
|
|
99
|
+
structured: false,
|
|
100
|
+
roundTrips: false,
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Adversarial inbound control vectors: a chunk TAGGED as a control envelope but
|
|
106
|
+
* malformed. Each MUST be rejected with the exact {@link InboundControlErrorCode}.
|
|
107
|
+
* A missing tag is NOT here — an untagged chunk is a valid legacy prompt (see
|
|
108
|
+
* {@link VALID_CONTROL_FRAMES}), never an error.
|
|
109
|
+
*/
|
|
110
|
+
export interface MalformedControlFrame {
|
|
111
|
+
readonly name: string;
|
|
112
|
+
readonly chunk: string;
|
|
113
|
+
readonly expected: InboundControlErrorCode;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const MALFORMED_CONTROL_FRAMES: readonly MalformedControlFrame[] = [
|
|
117
|
+
{
|
|
118
|
+
name: "tagged-missing-kind",
|
|
119
|
+
chunk: JSON.stringify({ ...MARKER, text: "no kind" }),
|
|
120
|
+
expected: "bad-kind",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "tagged-unknown-kind",
|
|
124
|
+
chunk: JSON.stringify({ ...MARKER, kind: "explode" }),
|
|
125
|
+
expected: "bad-kind",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: "prompt-missing-text",
|
|
129
|
+
chunk: JSON.stringify({ ...MARKER, kind: "prompt" }),
|
|
130
|
+
expected: "bad-prompt-text",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "prompt-non-string-text",
|
|
134
|
+
chunk: JSON.stringify({ ...MARKER, kind: "prompt", text: 123 }),
|
|
135
|
+
expected: "bad-prompt-text",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "cancel-non-string-reason",
|
|
139
|
+
chunk: JSON.stringify({ ...MARKER, kind: "cancel", reason: 5 }),
|
|
140
|
+
expected: "bad-cancel-reason",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "permission-missing-request-id",
|
|
144
|
+
chunk: JSON.stringify({ ...MARKER, kind: "permission", outcome: "granted" }),
|
|
145
|
+
expected: "bad-permission-request-id",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "permission-bad-outcome",
|
|
149
|
+
chunk: JSON.stringify({ ...MARKER, kind: "permission", requestId: "req-9", outcome: "maybe" }),
|
|
150
|
+
expected: "bad-permission-outcome",
|
|
151
|
+
},
|
|
152
|
+
];
|
|
@@ -6,6 +6,13 @@ import { GOLDEN_FRAMES } from "./frames.ts";
|
|
|
6
6
|
import type { FrameDirection } from "./frames.ts";
|
|
7
7
|
import { MALFORMED_FRAMES } from "./malformed.ts";
|
|
8
8
|
import type { FrameDecodeErrorCode } from "../frame.ts";
|
|
9
|
+
import {
|
|
10
|
+
parseInboundRelayChunk,
|
|
11
|
+
encodeInboundControlFrame,
|
|
12
|
+
type InboundControlErrorCode,
|
|
13
|
+
type InboundControlKind,
|
|
14
|
+
} from "../control.ts";
|
|
15
|
+
import { VALID_CONTROL_FRAMES, MALFORMED_CONTROL_FRAMES } from "./control.ts";
|
|
9
16
|
|
|
10
17
|
// The corpus is only a defence against drift if it is exhaustive. These tests
|
|
11
18
|
// fail if a new family, lane, or decode-error code is added without a covering
|
|
@@ -64,3 +71,72 @@ test("golden frame names are unique", () => {
|
|
|
64
71
|
const names = GOLDEN_FRAMES.map((g) => g.name);
|
|
65
72
|
assert.equal(new Set(names).size, names.length);
|
|
66
73
|
});
|
|
74
|
+
|
|
75
|
+
// --- Inbound control vocabulary (steer-in) -------------------------------
|
|
76
|
+
|
|
77
|
+
test("valid control corpus decodes to its declared frame", () => {
|
|
78
|
+
for (const v of VALID_CONTROL_FRAMES) {
|
|
79
|
+
const result = parseInboundRelayChunk(v.chunk);
|
|
80
|
+
assert.ok(result.ok, `${v.name}: ${result.ok ? "" : JSON.stringify(result.errors)}`);
|
|
81
|
+
assert.deepEqual(result.frame, v.frame, v.name);
|
|
82
|
+
assert.equal(result.structured, v.structured, `${v.name}: structured flag`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("structured control frames round-trip through the encoder", () => {
|
|
87
|
+
for (const v of VALID_CONTROL_FRAMES) {
|
|
88
|
+
if (!v.roundTrips) continue;
|
|
89
|
+
const encoded = encodeInboundControlFrame(v.frame);
|
|
90
|
+
const back = parseInboundRelayChunk(encoded);
|
|
91
|
+
assert.ok(back.ok, `${v.name}: re-decode failed`);
|
|
92
|
+
assert.deepEqual(back.frame, v.frame, `${v.name}: round-trip frame`);
|
|
93
|
+
assert.equal(back.structured, true, `${v.name}: round-trip is structured`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("valid control corpus covers every control kind", () => {
|
|
98
|
+
const ALL_KINDS: Record<InboundControlKind, true> = {
|
|
99
|
+
prompt: true,
|
|
100
|
+
cancel: true,
|
|
101
|
+
permission: true,
|
|
102
|
+
};
|
|
103
|
+
const covered = new Set<string>(VALID_CONTROL_FRAMES.map((v) => v.frame.kind));
|
|
104
|
+
for (const kind of Object.keys(ALL_KINDS)) {
|
|
105
|
+
assert.ok(covered.has(kind), `no valid control vector covers kind: ${kind}`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("legacy bare-string steer still decodes as a prompt (no regression)", () => {
|
|
110
|
+
const legacy = VALID_CONTROL_FRAMES.filter((v) => !v.structured);
|
|
111
|
+
assert.ok(legacy.length > 0, "corpus must retain legacy bare-string vectors");
|
|
112
|
+
for (const v of legacy) {
|
|
113
|
+
const result = parseInboundRelayChunk(v.chunk);
|
|
114
|
+
assert.ok(result.ok && result.frame.kind === "prompt", v.name);
|
|
115
|
+
assert.equal(result.ok && result.frame.kind === "prompt" && result.frame.text, v.chunk, v.name);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("malformed control corpus is rejected with the expected code", () => {
|
|
120
|
+
for (const m of MALFORMED_CONTROL_FRAMES) {
|
|
121
|
+
const result = parseInboundRelayChunk(m.chunk);
|
|
122
|
+
assert.ok(!result.ok, `${m.name}: expected rejection`);
|
|
123
|
+
assert.ok(
|
|
124
|
+
!result.ok && result.errors.some((e) => e.code === m.expected),
|
|
125
|
+
`${m.name}: expected code ${m.expected}, got ${result.ok ? "ok" : JSON.stringify(result.errors)}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("malformed control corpus covers every control error code", () => {
|
|
131
|
+
const ALL_CODES: Record<InboundControlErrorCode, true> = {
|
|
132
|
+
"bad-kind": true,
|
|
133
|
+
"bad-prompt-text": true,
|
|
134
|
+
"bad-cancel-reason": true,
|
|
135
|
+
"bad-permission-request-id": true,
|
|
136
|
+
"bad-permission-outcome": true,
|
|
137
|
+
};
|
|
138
|
+
const covered = new Set<string>(MALFORMED_CONTROL_FRAMES.map((m) => m.expected));
|
|
139
|
+
for (const code of Object.keys(ALL_CODES)) {
|
|
140
|
+
assert.ok(covered.has(code), `no malformed control vector covers code: ${code}`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
CONTROL_FRAME_MARKER,
|
|
5
|
+
CONTROL_FRAME_VERSION,
|
|
6
|
+
isInboundControlEnvelope,
|
|
7
|
+
parseInboundRelayChunk,
|
|
8
|
+
encodeInboundControlFrame,
|
|
9
|
+
type InboundControlFrame,
|
|
10
|
+
} from "./control.ts";
|
|
11
|
+
|
|
12
|
+
test("marker + version are the canonical tag", () => {
|
|
13
|
+
assert.equal(CONTROL_FRAME_MARKER, "nanoControlFrame");
|
|
14
|
+
assert.equal(CONTROL_FRAME_VERSION, 1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("isInboundControlEnvelope only accepts the tagged object at the current version", () => {
|
|
18
|
+
assert.ok(isInboundControlEnvelope({ [CONTROL_FRAME_MARKER]: 1, kind: "cancel" }));
|
|
19
|
+
assert.ok(!isInboundControlEnvelope({ [CONTROL_FRAME_MARKER]: 2, kind: "cancel" }));
|
|
20
|
+
assert.ok(!isInboundControlEnvelope({ kind: "cancel" }));
|
|
21
|
+
assert.ok(!isInboundControlEnvelope("cancel"));
|
|
22
|
+
assert.ok(!isInboundControlEnvelope(null));
|
|
23
|
+
assert.ok(!isInboundControlEnvelope([{ [CONTROL_FRAME_MARKER]: 1 }]));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("prompt frame decodes with its text", () => {
|
|
27
|
+
const chunk = encodeInboundControlFrame({ kind: "prompt", text: "hello" });
|
|
28
|
+
const result = parseInboundRelayChunk(chunk);
|
|
29
|
+
assert.ok(result.ok);
|
|
30
|
+
assert.equal(result.structured, true);
|
|
31
|
+
assert.deepEqual(result.frame, { kind: "prompt", text: "hello" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("cancel frame decodes with and without a reason", () => {
|
|
35
|
+
const bare = parseInboundRelayChunk(encodeInboundControlFrame({ kind: "cancel" }));
|
|
36
|
+
assert.ok(bare.ok && bare.frame.kind === "cancel");
|
|
37
|
+
assert.equal(bare.ok && "reason" in bare.frame, false);
|
|
38
|
+
|
|
39
|
+
const withReason = parseInboundRelayChunk(
|
|
40
|
+
encodeInboundControlFrame({ kind: "cancel", reason: "stop" }),
|
|
41
|
+
);
|
|
42
|
+
assert.ok(withReason.ok);
|
|
43
|
+
assert.deepEqual(withReason.frame, { kind: "cancel", reason: "stop" });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("permission frame carries the request id and outcome", () => {
|
|
47
|
+
for (const outcome of ["granted", "denied"] as const) {
|
|
48
|
+
const chunk = encodeInboundControlFrame({ kind: "permission", requestId: "r1", outcome });
|
|
49
|
+
const result = parseInboundRelayChunk(chunk);
|
|
50
|
+
assert.ok(result.ok);
|
|
51
|
+
assert.deepEqual(result.frame, { kind: "permission", requestId: "r1", outcome });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a bare keystroke string decodes as a prompt verbatim (legacy raw-byte steer)", () => {
|
|
56
|
+
for (const raw of ["ls -la\n", "\u0003", "y", "{not json", "42", "true"]) {
|
|
57
|
+
const result = parseInboundRelayChunk(raw);
|
|
58
|
+
assert.ok(result.ok, raw);
|
|
59
|
+
assert.equal(result.structured, false, raw);
|
|
60
|
+
assert.deepEqual(result.frame, { kind: "prompt", text: raw }, raw);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("an untagged JSON object is a legacy prompt, not a control frame", () => {
|
|
65
|
+
const chunk = JSON.stringify({ kind: "cancel" });
|
|
66
|
+
const result = parseInboundRelayChunk(chunk);
|
|
67
|
+
assert.ok(result.ok);
|
|
68
|
+
assert.equal(result.structured, false);
|
|
69
|
+
assert.deepEqual(result.frame, { kind: "prompt", text: chunk });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a tagged-but-malformed envelope is an error, never a silent prompt", () => {
|
|
73
|
+
const cases: Array<{ chunk: string; code: string }> = [
|
|
74
|
+
{ chunk: JSON.stringify({ [CONTROL_FRAME_MARKER]: 1 }), code: "bad-kind" },
|
|
75
|
+
{ chunk: JSON.stringify({ [CONTROL_FRAME_MARKER]: 1, kind: "nope" }), code: "bad-kind" },
|
|
76
|
+
{ chunk: JSON.stringify({ [CONTROL_FRAME_MARKER]: 1, kind: "prompt" }), code: "bad-prompt-text" },
|
|
77
|
+
{
|
|
78
|
+
chunk: JSON.stringify({ [CONTROL_FRAME_MARKER]: 1, kind: "permission", outcome: "granted" }),
|
|
79
|
+
code: "bad-permission-request-id",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
chunk: JSON.stringify({
|
|
83
|
+
[CONTROL_FRAME_MARKER]: 1,
|
|
84
|
+
kind: "permission",
|
|
85
|
+
requestId: "r",
|
|
86
|
+
outcome: "meh",
|
|
87
|
+
}),
|
|
88
|
+
code: "bad-permission-outcome",
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
for (const c of cases) {
|
|
92
|
+
const result = parseInboundRelayChunk(c.chunk);
|
|
93
|
+
assert.ok(!result.ok, c.chunk);
|
|
94
|
+
assert.ok(!result.ok && result.errors.some((e) => e.code === c.code), c.chunk);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a control envelope with leading whitespace still decodes structured (fast-path scans past whitespace)", () => {
|
|
99
|
+
const envelope = encodeInboundControlFrame({ kind: "cancel", reason: "stop" });
|
|
100
|
+
for (const prefix of [" ", "\t", "\n", "\r\n", " \t\n "]) {
|
|
101
|
+
const result = parseInboundRelayChunk(prefix + envelope);
|
|
102
|
+
assert.ok(result.ok, prefix);
|
|
103
|
+
assert.equal(result.structured, true, prefix);
|
|
104
|
+
assert.deepEqual(result.frame, { kind: "cancel", reason: "stop" }, prefix);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("chunks whose first non-whitespace char is not { stay legacy prompts verbatim", () => {
|
|
109
|
+
// Includes valid non-object JSON (number/array/string) that must NOT be
|
|
110
|
+
// mistaken for a control envelope by the JSON.parse fast-path gate.
|
|
111
|
+
for (const raw of [" ls\n", "\t\ty", "42", " 3.14", "[1,2,3]", '"quoted"', "", " "]) {
|
|
112
|
+
const result = parseInboundRelayChunk(raw);
|
|
113
|
+
assert.ok(result.ok, raw);
|
|
114
|
+
assert.equal(result.structured, false, raw);
|
|
115
|
+
assert.deepEqual(result.frame, { kind: "prompt", text: raw }, raw);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("every frame kind round-trips through encode/parse", () => {
|
|
120
|
+
const frames: InboundControlFrame[] = [
|
|
121
|
+
{ kind: "prompt", text: "多 bytes ✓" },
|
|
122
|
+
{ kind: "cancel" },
|
|
123
|
+
{ kind: "cancel", reason: "why" },
|
|
124
|
+
{ kind: "permission", requestId: "req-42", outcome: "denied" },
|
|
125
|
+
];
|
|
126
|
+
for (const frame of frames) {
|
|
127
|
+
const back = parseInboundRelayChunk(encodeInboundControlFrame(frame));
|
|
128
|
+
assert.ok(back.ok);
|
|
129
|
+
assert.deepEqual(back.frame, frame);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed INBOUND control vocabulary for the `relay` family (ADR 0056, ACP steer).
|
|
3
|
+
*
|
|
4
|
+
* The relay data frame ({@link RelayPayload} `{ stream, offset, chunk }`) is the
|
|
5
|
+
* OUTBOUND (hub → consumer) byte lane and is left untouched. This module adds an
|
|
6
|
+
* ADDITIVE vocabulary for the INBOUND (consumer → agent) steer lane, where a
|
|
7
|
+
* peer historically sent a bare `chunk` string that a PTY role fed straight to
|
|
8
|
+
* the terminal as keystrokes. ACP steering must now distinguish three intents:
|
|
9
|
+
*
|
|
10
|
+
* - `prompt` — start a new turn (carries the prompt text);
|
|
11
|
+
* - `cancel` — interrupt the current turn (optionally with a reason);
|
|
12
|
+
* - `permission` — answer/release a blocked `session/request_permission`
|
|
13
|
+
* (carries the request id being answered + the outcome).
|
|
14
|
+
*
|
|
15
|
+
* ## Backward compatibility (raw-byte steer)
|
|
16
|
+
*
|
|
17
|
+
* The vocabulary is a STRICT superset of the legacy raw-byte path. A structured
|
|
18
|
+
* frame is a JSON envelope TAGGED with {@link CONTROL_FRAME_MARKER}; a bare
|
|
19
|
+
* inbound string that is NOT such a tagged envelope (a keystroke, a shell line,
|
|
20
|
+
* unrelated JSON) continues to decode as a `prompt` whose `text` is the original
|
|
21
|
+
* chunk verbatim. Existing raw-byte producers and consumers need not change.
|
|
22
|
+
*
|
|
23
|
+
* ## Decode order (see {@link parseInboundRelayChunk})
|
|
24
|
+
*
|
|
25
|
+
* 1. If the chunk parses as a control envelope (a JSON object tagged with the
|
|
26
|
+
* marker at the current version) → validate it and return the typed frame;
|
|
27
|
+
* a tagged-but-malformed envelope is an ERROR, never a silent prompt.
|
|
28
|
+
* 2. Otherwise → legacy fall-back: `{ kind: "prompt", text: <chunk> }`.
|
|
29
|
+
*
|
|
30
|
+
* This is purely the inbound control vocabulary at the protocol seam: it changes
|
|
31
|
+
* neither the relay transport (ring, QoS, offsets, stream routing) nor the
|
|
32
|
+
* outbound `{ stream, offset, chunk }` data-frame shape.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The marker key that tags a chunk as a structured inbound control envelope.
|
|
37
|
+
* Its value is the {@link CONTROL_FRAME_VERSION}. A chunk without this key is a
|
|
38
|
+
* legacy bare-string steer and decodes as a `prompt`.
|
|
39
|
+
*/
|
|
40
|
+
export const CONTROL_FRAME_MARKER = "nanoControlFrame" as const;
|
|
41
|
+
|
|
42
|
+
/** The schema version carried by the {@link CONTROL_FRAME_MARKER}. */
|
|
43
|
+
export const CONTROL_FRAME_VERSION = 1 as const;
|
|
44
|
+
|
|
45
|
+
/** The three inbound steer intents. */
|
|
46
|
+
export type InboundControlKind = "prompt" | "cancel" | "permission";
|
|
47
|
+
|
|
48
|
+
/** How a blocked `session/request_permission` was answered. */
|
|
49
|
+
export type PermissionOutcome = "granted" | "denied";
|
|
50
|
+
|
|
51
|
+
/** Start a new turn with the given prompt text. */
|
|
52
|
+
export interface PromptControlFrame {
|
|
53
|
+
readonly kind: "prompt";
|
|
54
|
+
readonly text: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Interrupt the current turn. `reason` is advisory, for telemetry/audit. */
|
|
58
|
+
export interface CancelControlFrame {
|
|
59
|
+
readonly kind: "cancel";
|
|
60
|
+
readonly reason?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Answer a blocked `session/request_permission` identified by `requestId`. */
|
|
64
|
+
export interface PermissionControlFrame {
|
|
65
|
+
readonly kind: "permission";
|
|
66
|
+
readonly requestId: string;
|
|
67
|
+
readonly outcome: PermissionOutcome;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The typed inbound control vocabulary — a discriminated union on `kind`. */
|
|
71
|
+
export type InboundControlFrame =
|
|
72
|
+
| PromptControlFrame
|
|
73
|
+
| CancelControlFrame
|
|
74
|
+
| PermissionControlFrame;
|
|
75
|
+
|
|
76
|
+
export interface InboundControlError {
|
|
77
|
+
readonly code: InboundControlErrorCode;
|
|
78
|
+
readonly message: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The closed set of validation-error codes a tagged-but-malformed control
|
|
83
|
+
* envelope can raise. The conformance corpus derives its coverage from this
|
|
84
|
+
* union, so a new code cannot be added without a covering malformed vector.
|
|
85
|
+
*/
|
|
86
|
+
export type InboundControlErrorCode =
|
|
87
|
+
| "bad-kind"
|
|
88
|
+
| "bad-prompt-text"
|
|
89
|
+
| "bad-cancel-reason"
|
|
90
|
+
| "bad-permission-request-id"
|
|
91
|
+
| "bad-permission-outcome";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The result of decoding an inbound steer chunk. `structured` distinguishes a
|
|
95
|
+
* recognised control envelope from the legacy bare-string-as-prompt fall-back,
|
|
96
|
+
* so a consumer can tell an explicit `prompt` frame from a raw keystroke.
|
|
97
|
+
*/
|
|
98
|
+
export type InboundControlDecodeResult =
|
|
99
|
+
| { readonly ok: true; readonly frame: InboundControlFrame; readonly structured: boolean }
|
|
100
|
+
| { readonly ok: false; readonly errors: readonly InboundControlError[] };
|
|
101
|
+
|
|
102
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
103
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Is `value` a structured inbound control envelope (a plain object tagged with
|
|
108
|
+
* the marker at the current version)? This is the discriminator the decoder uses
|
|
109
|
+
* to choose the structured path over the legacy bare-string fall-back — it does
|
|
110
|
+
* NOT assert the envelope's `kind`-specific fields are well-formed.
|
|
111
|
+
*/
|
|
112
|
+
export function isInboundControlEnvelope(value: unknown): value is Record<string, unknown> {
|
|
113
|
+
return isPlainObject(value) && value[CONTROL_FRAME_MARKER] === CONTROL_FRAME_VERSION;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateEnvelope(env: Record<string, unknown>): InboundControlDecodeResult {
|
|
117
|
+
const errors: InboundControlError[] = [];
|
|
118
|
+
const kind = env.kind;
|
|
119
|
+
switch (kind) {
|
|
120
|
+
case "prompt": {
|
|
121
|
+
const text = env.text;
|
|
122
|
+
if (typeof text !== "string") {
|
|
123
|
+
errors.push({ code: "bad-prompt-text", message: "control.prompt.text must be a string" });
|
|
124
|
+
return { ok: false, errors };
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, structured: true, frame: { kind: "prompt", text } };
|
|
127
|
+
}
|
|
128
|
+
case "cancel": {
|
|
129
|
+
if ("reason" in env && typeof env.reason !== "string") {
|
|
130
|
+
errors.push({ code: "bad-cancel-reason", message: "control.cancel.reason must be a string when present" });
|
|
131
|
+
}
|
|
132
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
133
|
+
const frame: CancelControlFrame =
|
|
134
|
+
typeof env.reason === "string" ? { kind: "cancel", reason: env.reason } : { kind: "cancel" };
|
|
135
|
+
return { ok: true, structured: true, frame };
|
|
136
|
+
}
|
|
137
|
+
case "permission": {
|
|
138
|
+
const requestId = env.requestId;
|
|
139
|
+
const outcome = env.outcome;
|
|
140
|
+
const validRequestId = typeof requestId === "string" && requestId.length > 0 ? requestId : undefined;
|
|
141
|
+
if (validRequestId === undefined) {
|
|
142
|
+
errors.push({
|
|
143
|
+
code: "bad-permission-request-id",
|
|
144
|
+
message: "control.permission.requestId must be a non-empty string",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const validOutcome: PermissionOutcome | undefined =
|
|
148
|
+
outcome === "granted" || outcome === "denied" ? outcome : undefined;
|
|
149
|
+
if (validOutcome === undefined) {
|
|
150
|
+
errors.push({
|
|
151
|
+
code: "bad-permission-outcome",
|
|
152
|
+
message: "control.permission.outcome must be 'granted' or 'denied'",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (errors.length > 0 || validRequestId === undefined || validOutcome === undefined) {
|
|
156
|
+
return { ok: false, errors };
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
structured: true,
|
|
161
|
+
frame: {
|
|
162
|
+
kind: "permission",
|
|
163
|
+
requestId: validRequestId,
|
|
164
|
+
outcome: validOutcome,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
default: {
|
|
169
|
+
const seen = kind === undefined ? "<missing>" : JSON.stringify(kind);
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
errors: [
|
|
173
|
+
{
|
|
174
|
+
code: "bad-kind",
|
|
175
|
+
message: `control.kind must be one of prompt|cancel|permission, got ${seen}`,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether `chunk` begins with a JSON object — i.e. its first character that is
|
|
185
|
+
* not JSON-insignificant whitespace (space, tab, LF, CR) is `{`. Used as a
|
|
186
|
+
* cheap, allocation-free gate before attempting a full `JSON.parse`: only a
|
|
187
|
+
* chunk that starts a JSON object can possibly be a tagged control envelope.
|
|
188
|
+
*/
|
|
189
|
+
function startsWithJsonObject(chunk: string): boolean {
|
|
190
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
191
|
+
const code = chunk.charCodeAt(i);
|
|
192
|
+
// JSON insignificant whitespace: space (0x20), tab (0x09), LF (0x0a), CR (0x0d).
|
|
193
|
+
if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
return code === 0x7b; // '{'
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Decode a raw inbound steer chunk into a typed {@link InboundControlFrame}.
|
|
203
|
+
*
|
|
204
|
+
* A chunk tagged as a control envelope is validated and returned as its typed
|
|
205
|
+
* frame (`structured: true`); a tagged-but-malformed envelope (bad/missing
|
|
206
|
+
* discriminant, missing/ill-typed field) is a validation ERROR. Any other chunk
|
|
207
|
+
* — a bare keystroke, a shell line, unrelated JSON — is treated as the legacy
|
|
208
|
+
* raw-byte steer and returned as a `prompt` whose `text` is the chunk verbatim
|
|
209
|
+
* (`structured: false`). See the module header for the full decode order.
|
|
210
|
+
*/
|
|
211
|
+
export function parseInboundRelayChunk(chunk: string): InboundControlDecodeResult {
|
|
212
|
+
// Fast path for the common per-keystroke steer: a control envelope is always a
|
|
213
|
+
// JSON *object*, so it must begin with `{` after any insignificant leading
|
|
214
|
+
// whitespace. Any chunk that does not is (and always was) a legacy bare-string
|
|
215
|
+
// prompt carrying the chunk verbatim, so skip the JSON.parse attempt entirely.
|
|
216
|
+
if (!startsWithJsonObject(chunk)) {
|
|
217
|
+
return { ok: true, structured: false, frame: { kind: "prompt", text: chunk } };
|
|
218
|
+
}
|
|
219
|
+
let parsed: unknown;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(chunk);
|
|
222
|
+
} catch {
|
|
223
|
+
// Started with `{` but is not valid JSON. Legacy bare-string prompt.
|
|
224
|
+
return { ok: true, structured: false, frame: { kind: "prompt", text: chunk } };
|
|
225
|
+
}
|
|
226
|
+
if (!isInboundControlEnvelope(parsed)) {
|
|
227
|
+
// Valid JSON but not a control envelope (unrelated JSON, or a plain string
|
|
228
|
+
// like "ls\n"): still a legacy bare-string prompt carrying the chunk as-is.
|
|
229
|
+
return { ok: true, structured: false, frame: { kind: "prompt", text: chunk } };
|
|
230
|
+
}
|
|
231
|
+
return validateEnvelope(parsed);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Encode a typed {@link InboundControlFrame} as its canonical wire chunk — a
|
|
236
|
+
* JSON envelope tagged with {@link CONTROL_FRAME_MARKER}. The result round-trips
|
|
237
|
+
* back through {@link parseInboundRelayChunk} to an equal frame.
|
|
238
|
+
*/
|
|
239
|
+
export function encodeInboundControlFrame(frame: InboundControlFrame): string {
|
|
240
|
+
const marker = { [CONTROL_FRAME_MARKER]: CONTROL_FRAME_VERSION } as const;
|
|
241
|
+
switch (frame.kind) {
|
|
242
|
+
case "prompt":
|
|
243
|
+
return JSON.stringify({ ...marker, kind: "prompt", text: frame.text });
|
|
244
|
+
case "cancel":
|
|
245
|
+
return JSON.stringify(
|
|
246
|
+
frame.reason === undefined
|
|
247
|
+
? { ...marker, kind: "cancel" }
|
|
248
|
+
: { ...marker, kind: "cancel", reason: frame.reason },
|
|
249
|
+
);
|
|
250
|
+
case "permission":
|
|
251
|
+
return JSON.stringify({
|
|
252
|
+
...marker,
|
|
253
|
+
kind: "permission",
|
|
254
|
+
requestId: frame.requestId,
|
|
255
|
+
outcome: frame.outcome,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
package/src/protocol/index.ts
CHANGED
|
@@ -85,4 +85,21 @@ export {
|
|
|
85
85
|
type PayloadValidationResult,
|
|
86
86
|
} from "./payloads.ts";
|
|
87
87
|
|
|
88
|
+
export {
|
|
89
|
+
CONTROL_FRAME_MARKER,
|
|
90
|
+
CONTROL_FRAME_VERSION,
|
|
91
|
+
isInboundControlEnvelope,
|
|
92
|
+
parseInboundRelayChunk,
|
|
93
|
+
encodeInboundControlFrame,
|
|
94
|
+
type InboundControlKind,
|
|
95
|
+
type PermissionOutcome,
|
|
96
|
+
type PromptControlFrame,
|
|
97
|
+
type CancelControlFrame,
|
|
98
|
+
type PermissionControlFrame,
|
|
99
|
+
type InboundControlFrame,
|
|
100
|
+
type InboundControlError,
|
|
101
|
+
type InboundControlErrorCode,
|
|
102
|
+
type InboundControlDecodeResult,
|
|
103
|
+
} from "./control.ts";
|
|
104
|
+
|
|
88
105
|
export { bytesToHex, hexToBytes } from "./hex.ts";
|