@nanobpm/nano-workforce 0.145.1 → 0.147.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/CHANGELOG.md +12 -0
- package/app/agentic/cockpit/index.ts +1 -0
- package/app/agentic/cockpit/supply-boot.test.ts +94 -0
- package/app/agentic/cockpit/supply-boot.ts +36 -0
- package/app/agentic/cockpit/transcript-derive.test.ts +182 -1
- package/app/agentic/cockpit/transcript-derive.ts +238 -10
- package/app/agentic/transcript-events.test.ts +224 -0
- package/app/agentic/transcript-events.ts +247 -5
- package/app/contracts.ts +17 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.147.0](https://github.com/nanobpm/nano-workforce/compare/v0.146.0...v0.147.0) (2026-08-26)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **cockpit:** rich tool/diff cards + permission prompt mounted into the typed replay path ([#561](https://github.com/nanobpm/nano-workforce/issues/561)) ([4bd04a0](https://github.com/nanobpm/nano-workforce/commit/4bd04a06f12569e418566cb47b8a7bbf053cdb20)), closes [#559](https://github.com/nanobpm/nano-workforce/issues/559) [#setMode](https://github.com/nanobpm/nano-workforce/issues/setMode) [#structuredRegion](https://github.com/nanobpm/nano-workforce/issues/structuredRegion)
|
|
6
|
+
|
|
7
|
+
## [0.146.0](https://github.com/nanobpm/nano-workforce/compare/v0.145.1...v0.146.0) (2026-08-26)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **agentic:** add a permission transcript-event kind (request + resolution) ([#560](https://github.com/nanobpm/nano-workforce/issues/560)) ([722bb96](https://github.com/nanobpm/nano-workforce/commit/722bb962b24c85faf585e598b905bf67a43040d4)), closes [#559](https://github.com/nanobpm/nano-workforce/issues/559)
|
|
12
|
+
|
|
1
13
|
## [0.145.1](https://github.com/nanobpm/nano-workforce/compare/v0.145.0...v0.145.1) (2026-08-26)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
@@ -7,8 +7,10 @@ import assert from "node:assert/strict";
|
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
|
|
9
9
|
import { FakeDocument, FakeElement, FakeSocket } from "../../../test/agentic-cockpit-doubles.ts";
|
|
10
|
+
import { TRANSCRIPT_EVENT_MARKER, TRANSCRIPT_EVENT_VERSION } from "../transcript-events.ts";
|
|
10
11
|
import { bootSupplyCockpit, type SupplyCockpitEnv } from "./supply-boot.ts";
|
|
11
12
|
import type { SupplyReport } from "./supply-view.ts";
|
|
13
|
+
import type { TranscriptDataReport } from "./transcript-render.ts";
|
|
12
14
|
|
|
13
15
|
const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
14
16
|
|
|
@@ -358,3 +360,95 @@ test("a drill whose terminal build throws resets the panel to idle instead of le
|
|
|
358
360
|
assert.equal(cockpit.currentMode, "live");
|
|
359
361
|
assert.equal(cockpit.currentStream, "wk-a");
|
|
360
362
|
});
|
|
363
|
+
|
|
364
|
+
// A transcript envelope (marker + kind + fields), matching the one event grammar the fold parses.
|
|
365
|
+
function env(kind: string, extra: Record<string, unknown> = {}): string {
|
|
366
|
+
return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// A captured transcript carrying a pending ESCALATE permission request, for the structured replay path.
|
|
370
|
+
function permissionTranscript(stream: string): TranscriptDataReport {
|
|
371
|
+
const chunks = [
|
|
372
|
+
env("turn", { index: 0 }),
|
|
373
|
+
env("message", { role: "assistant", text: "I need to run a command" }),
|
|
374
|
+
env("permission", {
|
|
375
|
+
phase: "request",
|
|
376
|
+
callId: "perm-1",
|
|
377
|
+
policy: "escalate",
|
|
378
|
+
title: "Run `rm -rf build`?",
|
|
379
|
+
options: [
|
|
380
|
+
{ optionId: "allow", name: "Allow", kind: "allow-once" },
|
|
381
|
+
{ optionId: "deny", name: "Deny", kind: "reject-once" },
|
|
382
|
+
],
|
|
383
|
+
}),
|
|
384
|
+
];
|
|
385
|
+
return { stream, from: 0, gap: false, nextOffset: chunks.length, entries: chunks.map((chunk, offset) => ({ offset, chunk })) };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// A rig whose replay endpoints (list + per-session bytes) are wired, plus an optional permission hook.
|
|
389
|
+
function replayRig(onPermissionResolve?: SupplyCockpitEnv["onPermissionResolve"]): {
|
|
390
|
+
readonly env: SupplyCockpitEnv;
|
|
391
|
+
readonly host: FakeElement;
|
|
392
|
+
readonly data: TranscriptDataReport;
|
|
393
|
+
} {
|
|
394
|
+
const r = rig();
|
|
395
|
+
const data = permissionTranscript("job:done");
|
|
396
|
+
const env: SupplyCockpitEnv = {
|
|
397
|
+
...r.env,
|
|
398
|
+
fetchTranscripts: () => Promise.resolve({ count: 0, transcripts: [] }),
|
|
399
|
+
fetchTranscript: () => Promise.resolve(data),
|
|
400
|
+
...(onPermissionResolve !== undefined ? { onPermissionResolve } : {}),
|
|
401
|
+
};
|
|
402
|
+
return { env, host: r.host, data };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
test("replay MOUNTS the structured derived view beside the byte replay (typed boot/replay path)", async () => {
|
|
406
|
+
const { env, host } = replayRig();
|
|
407
|
+
const cockpit = bootSupplyCockpit(env);
|
|
408
|
+
await cockpit.replay("job:done");
|
|
409
|
+
|
|
410
|
+
// The dedicated structured region exists and now holds the derived view (the byte terminal replay,
|
|
411
|
+
// asserted elsewhere, is untouched — this is additive beside it).
|
|
412
|
+
const region = host.byData("structured", "region")[0];
|
|
413
|
+
assert.ok(region !== undefined, "the structured region is present");
|
|
414
|
+
assert.equal(region?.byClass("cockpit-transcript-derived").length, 1, "the derived structured view is mounted");
|
|
415
|
+
assert.equal(host.byData("permission", "request").length, 1, "the permission prompt is rendered in the structured view");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("a SupplyCockpitEnv.onPermissionResolve hook is threaded into the structured render so a click reaches it", async () => {
|
|
419
|
+
const resolutions: Array<{ callId: string; optionId: string; allowed: boolean }> = [];
|
|
420
|
+
const { env, host } = replayRig((resolution) => resolutions.push(resolution));
|
|
421
|
+
const cockpit = bootSupplyCockpit(env);
|
|
422
|
+
await cockpit.replay("job:done");
|
|
423
|
+
|
|
424
|
+
const allow = host.byClass("cockpit-transcript-permission-option").find((b) => b.getAttribute("data-option-id") === "allow");
|
|
425
|
+
assert.ok(allow !== undefined, "the Allow button rendered");
|
|
426
|
+
allow?.dispatch("click");
|
|
427
|
+
assert.deepEqual(resolutions, [{ callId: "perm-1", optionId: "allow", allowed: true }]);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("the structured view renders even without an onPermissionResolve hook (buttons are inert, no throw)", async () => {
|
|
431
|
+
const { env, host } = replayRig();
|
|
432
|
+
const cockpit = bootSupplyCockpit(env);
|
|
433
|
+
await cockpit.replay("job:done");
|
|
434
|
+
const allow = host.byClass("cockpit-transcript-permission-option").find((b) => b.getAttribute("data-option-id") === "allow");
|
|
435
|
+
assert.ok(allow !== undefined, "the Allow button rendered without a hook");
|
|
436
|
+
allow?.dispatch("click"); // no handler wired — must not throw
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("switching from replay to a LIVE drill CLEARS the stale structured derived view (no wrong-callId clicks)", async () => {
|
|
440
|
+
const { env, host } = replayRig();
|
|
441
|
+
const cockpit = bootSupplyCockpit(env);
|
|
442
|
+
await cockpit.replay("job:done");
|
|
443
|
+
// The structured region is populated with the derived view + pending permission prompt.
|
|
444
|
+
assert.equal(host.byData("permission", "request").length, 1, "the permission prompt is mounted on replay");
|
|
445
|
+
|
|
446
|
+
// Drilling into a live stream must tear the stale structured view down — leaving a clickable permission
|
|
447
|
+
// prompt over a live terminal risks an operator action against the wrong callId.
|
|
448
|
+
cockpit.drill("wk-a");
|
|
449
|
+
assert.equal(cockpit.currentMode, "live");
|
|
450
|
+
const region = host.byData("structured", "region")[0];
|
|
451
|
+
assert.ok(region !== undefined, "the structured region element persists (it is cleared, not removed)");
|
|
452
|
+
assert.equal(region?.byClass("cockpit-transcript-derived").length, 0, "the derived structured view is cleared on live drill");
|
|
453
|
+
assert.equal(host.byData("permission", "request").length, 0, "the stale permission prompt is gone in live mode");
|
|
454
|
+
});
|
|
@@ -32,6 +32,7 @@ import type { CockpitRoute } from "./cockpit-route.ts";
|
|
|
32
32
|
import { renderSupply } from "./supply-render.ts";
|
|
33
33
|
import type { SupplyReport, SupplyView } from "./supply-view.ts";
|
|
34
34
|
import { supplyView } from "./supply-view.ts";
|
|
35
|
+
import { type RenderDerivedTranscriptOptions, renderDerivedTranscript } from "./transcript-derive.ts";
|
|
35
36
|
import { renderTranscripts, replayTranscript, type TranscriptDataReport } from "./transcript-render.ts";
|
|
36
37
|
import type { TranscriptListReport } from "./transcript-view.ts";
|
|
37
38
|
import { transcriptsView } from "./transcript-view.ts";
|
|
@@ -88,6 +89,16 @@ export interface SupplyCockpitEnv {
|
|
|
88
89
|
readonly pastFetchTimeoutMs?: number;
|
|
89
90
|
/** Notified of a fetch/render/relay error (the poll keeps going). */
|
|
90
91
|
readonly onError?: (err: unknown) => void;
|
|
92
|
+
/**
|
|
93
|
+
* The seam the wave-2 escalation bridge attaches to: threaded into the STRUCTURED replay view's
|
|
94
|
+
* {@link RenderDerivedTranscriptOptions.onPermissionResolve}, so a pending escalate-permission
|
|
95
|
+
* prompt's Allow/Deny click reaches the bridge. Optional (default `undefined`): the structured view
|
|
96
|
+
* still renders, its prompt buttons just have no handler. NOTE (runtime-layer truth): this is the
|
|
97
|
+
* TYPED boot/replay path exercised by unit tests + the published package, NOT the operator's live
|
|
98
|
+
* browser cockpit (`pages/cockpit/mount.js`, a hand-maintained drift twin) — surfacing the prompt
|
|
99
|
+
* there is a separate follow-up.
|
|
100
|
+
*/
|
|
101
|
+
readonly onPermissionResolve?: RenderDerivedTranscriptOptions["onPermissionResolve"];
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
/** The running supply cockpit; dispose to stop polling and tear down the terminal. */
|
|
@@ -132,6 +143,10 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
132
143
|
readonly #env: SupplyCockpitEnv;
|
|
133
144
|
readonly #listRegion: ElementLike;
|
|
134
145
|
readonly #pastRegion: ElementLike | undefined;
|
|
146
|
+
// A dedicated volatile region the STRUCTURED derived view (messages, rich tool/diff cards, permission
|
|
147
|
+
// prompts) is mounted into on a replay — beside, and additive to, the byte-level terminal replay
|
|
148
|
+
// (which is left untouched). Present only when the transcript read endpoints are wired.
|
|
149
|
+
readonly #structuredRegion: ElementLike | undefined;
|
|
135
150
|
readonly #terminalHost: ElementLike;
|
|
136
151
|
readonly #terminalTitle: ElementLike;
|
|
137
152
|
readonly #terminalNote: ElementLike;
|
|
@@ -247,6 +262,14 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
247
262
|
this.#terminalHost.className = "cockpit-terminal-host";
|
|
248
263
|
this.#terminalHost.setAttribute("data-terminal", "host");
|
|
249
264
|
this.#terminalPanel.appendChild(this.#terminalHost);
|
|
265
|
+
// The STRUCTURED derived view is mounted here on a replay, additive beside the byte terminal above.
|
|
266
|
+
// Only exists when the transcript read endpoints are wired (same pairing as the past-sessions list).
|
|
267
|
+
if (env.fetchTranscripts !== undefined) {
|
|
268
|
+
this.#structuredRegion = env.doc.createElement("div");
|
|
269
|
+
this.#structuredRegion.className = "cockpit-structured-region";
|
|
270
|
+
this.#structuredRegion.setAttribute("data-structured", "region");
|
|
271
|
+
this.#terminalPanel.appendChild(this.#structuredRegion);
|
|
272
|
+
}
|
|
250
273
|
// A status note under the terminal, shown while a LIVE drill has connected but no output has
|
|
251
274
|
// arrived yet (a quiet job between frames): without it the panel is an indistinguishable blank
|
|
252
275
|
// black rectangle, so the operator can't tell "connected, waiting" from "broken". Cleared the
|
|
@@ -284,6 +307,11 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
284
307
|
// Any mode change replaces what's behind the panel, so the prior "waiting for output" note is
|
|
285
308
|
// stale — clear it. A live drill re-arms it (below) once its fresh terminal is mounted.
|
|
286
309
|
this.#setNote(undefined);
|
|
310
|
+
// The STRUCTURED derived view is only valid alongside a replay. Any non-replay mode (a live drill
|
|
311
|
+
// or idle) must clear it, or a stale derived transcript / pending permission prompt would linger —
|
|
312
|
+
// visible and CLICKABLE — over the live terminal, risking an operator action against the wrong
|
|
313
|
+
// callId. A replay re-mounts it (in replay(), after this #setMode("replay", …)).
|
|
314
|
+
if (mode !== "replay") this.#structuredRegion?.replaceChildren();
|
|
287
315
|
}
|
|
288
316
|
|
|
289
317
|
/** Show (or clear) the terminal status note — the "connected, waiting for output" affordance. */
|
|
@@ -577,6 +605,14 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
577
605
|
const session = new TerminalSession({ stream, sink, send: () => {}, from: data.from });
|
|
578
606
|
replayTranscript(session, data);
|
|
579
607
|
this.#setMode("replay", stream);
|
|
608
|
+
// Additively mount the STRUCTURED derived view beside the byte replay above (the byte replay is
|
|
609
|
+
// left untouched). The env's onPermissionResolve seam is threaded into the render so a pending
|
|
610
|
+
// escalate-permission prompt's Allow/Deny click reaches whatever the bridge wires there.
|
|
611
|
+
if (this.#structuredRegion !== undefined) {
|
|
612
|
+
renderDerivedTranscript(this.#structuredRegion, this.#env.doc, data, {
|
|
613
|
+
onPermissionResolve: this.#env.onPermissionResolve,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
580
616
|
// Re-render the past list so the just-selected session shows as active (best-effort).
|
|
581
617
|
void this.#refreshPast(this.#route.kind === "worker" ? this.#route.instance : undefined);
|
|
582
618
|
} catch (err) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// cards, per-turn boundaries), and that raw chunks are preserved in the fidelity footer — the byte
|
|
5
5
|
// replay is not lost. It renders into the in-memory DOM double, no browser.
|
|
6
6
|
import { test } from "node:test";
|
|
7
|
-
import { assertEquals } from "#test-assert";
|
|
7
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
8
8
|
import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
|
|
9
9
|
import { TRANSCRIPT_EVENT_MARKER, TRANSCRIPT_EVENT_VERSION } from "../transcript-events.ts";
|
|
10
10
|
import { deriveTranscript, renderDerivedTranscript } from "./transcript-derive.ts";
|
|
@@ -76,3 +76,184 @@ test("renderDerivedTranscript shows an empty state for an all-raw (unstructured)
|
|
|
76
76
|
assertEquals(host.byData("empty", "true").length, 1);
|
|
77
77
|
assertEquals(host.byData("turn-count", "0").length, 1);
|
|
78
78
|
});
|
|
79
|
+
|
|
80
|
+
// A minimal single-page report of the given entries (offsets assigned in order).
|
|
81
|
+
function page(chunks: readonly string[], stream = "job:x"): TranscriptDataReport {
|
|
82
|
+
return { stream, from: 0, gap: false, nextOffset: chunks.length, entries: chunks.map((chunk, offset) => ({ offset, chunk })) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test("a tool card renders its args and result content", () => {
|
|
86
|
+
const host = new FakeElement("div");
|
|
87
|
+
renderDerivedTranscript(
|
|
88
|
+
host,
|
|
89
|
+
doc,
|
|
90
|
+
page([
|
|
91
|
+
env("tool-call", { name: "read", callId: "c1", args: { path: "README.md" } }),
|
|
92
|
+
env("tool-result", { callId: "c1", ok: true, content: "file contents here" }),
|
|
93
|
+
]),
|
|
94
|
+
);
|
|
95
|
+
const card = host.byData("tool", "read")[0];
|
|
96
|
+
assertEquals(card?.getAttribute("data-status"), "ok");
|
|
97
|
+
assertEquals(card?.getAttribute("data-tool-kind"), undefined); // not a diff
|
|
98
|
+
const argsEl = host.byData("tool-args", "true")[0];
|
|
99
|
+
assertStringIncludes(argsEl?.textContent ?? "", "README.md");
|
|
100
|
+
const resultEl = host.byData("tool-result", "true")[0];
|
|
101
|
+
assertEquals(resultEl?.textContent, "file contents here");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a diff tool renders a cockpit-transcript-diff block with per-line add/del/ctx markers", () => {
|
|
105
|
+
const host = new FakeElement("div");
|
|
106
|
+
const unified = "--- a/foo.txt\n+++ b/foo.txt\n@@ -1,2 +1,2 @@\n-old line\n+new line\n unchanged\n";
|
|
107
|
+
renderDerivedTranscript(
|
|
108
|
+
host,
|
|
109
|
+
doc,
|
|
110
|
+
page([
|
|
111
|
+
env("tool-call", { name: "edit", callId: "d1" }),
|
|
112
|
+
env("tool-result", { callId: "d1", ok: true, content: unified }),
|
|
113
|
+
]),
|
|
114
|
+
);
|
|
115
|
+
const card = host.byData("tool", "edit")[0];
|
|
116
|
+
assertEquals(card?.getAttribute("data-tool-kind"), "diff");
|
|
117
|
+
assertEquals(host.byClass("cockpit-transcript-diff").length, 1);
|
|
118
|
+
assertEquals(host.byData("diff-line", "add").length, 1);
|
|
119
|
+
assertEquals(host.byData("diff-line", "del").length, 1);
|
|
120
|
+
assert(host.byData("diff-line", "ctx").length >= 1, "the hunk/header/context lines are marked ctx");
|
|
121
|
+
// The diff replaces the plain result rendering — no raw result <pre> for a diff tool.
|
|
122
|
+
assertEquals(host.byData("tool-result", "true").length, 0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("structured edit args (path + old/new text) render as a synthesized diff", () => {
|
|
126
|
+
const host = new FakeElement("div");
|
|
127
|
+
renderDerivedTranscript(
|
|
128
|
+
host,
|
|
129
|
+
doc,
|
|
130
|
+
page([env("tool-call", { name: "write", callId: "s1", args: { path: "a.txt", oldText: "one\ntwo", newText: "one\nTWO" } })]),
|
|
131
|
+
);
|
|
132
|
+
const card = host.byData("tool", "write")[0];
|
|
133
|
+
assertEquals(card?.getAttribute("data-tool-kind"), "diff");
|
|
134
|
+
assertEquals(host.byData("diff-line", "del").length, 2);
|
|
135
|
+
assertEquals(host.byData("diff-line", "add").length, 2);
|
|
136
|
+
// The diff was synthesized FROM args, so the raw args <pre> is not ALSO rendered.
|
|
137
|
+
assertEquals(host.byData("tool-args", "true").length, 0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("structured edit args ending in a trailing newline do not synthesize a spurious empty diff line", () => {
|
|
141
|
+
const host = new FakeElement("div");
|
|
142
|
+
renderDerivedTranscript(
|
|
143
|
+
host,
|
|
144
|
+
doc,
|
|
145
|
+
page([env("tool-call", { name: "write", callId: "s2", args: { path: "a.txt", oldText: "one\ntwo\n", newText: "one\nTWO\n" } })]),
|
|
146
|
+
);
|
|
147
|
+
// "one\ntwo\n" would naively split into 3 segments (…, "two", "") — the trailing empty is dropped.
|
|
148
|
+
assertEquals(host.byData("diff-line", "del").length, 2);
|
|
149
|
+
assertEquals(host.byData("diff-line", "add").length, 2);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("an args-synthesized diff still renders non-diff result content (args+result, diff special render)", () => {
|
|
153
|
+
const host = new FakeElement("div");
|
|
154
|
+
renderDerivedTranscript(
|
|
155
|
+
host,
|
|
156
|
+
doc,
|
|
157
|
+
page([
|
|
158
|
+
env("tool-call", { name: "write", callId: "s3", args: { path: "a.txt", oldText: "one", newText: "ONE" } }),
|
|
159
|
+
env("tool-result", { callId: "s3", ok: true, content: "wrote 1 file" }),
|
|
160
|
+
]),
|
|
161
|
+
);
|
|
162
|
+
const card = host.byData("tool", "write")[0];
|
|
163
|
+
assertEquals(card?.getAttribute("data-tool-kind"), "diff");
|
|
164
|
+
// The diff came from args, so the result content is not the diff source — it is still shown.
|
|
165
|
+
const resultEl = host.byData("tool-result", "true")[0];
|
|
166
|
+
assertEquals(resultEl?.textContent, "wrote 1 file");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a pending escalate permission renders Allow/Deny buttons that invoke onPermissionResolve", () => {
|
|
170
|
+
const host = new FakeElement("div");
|
|
171
|
+
const calls: Array<{ callId: string; optionId: string; allowed: boolean }> = [];
|
|
172
|
+
renderDerivedTranscript(
|
|
173
|
+
host,
|
|
174
|
+
doc,
|
|
175
|
+
page([
|
|
176
|
+
env("permission", {
|
|
177
|
+
phase: "request",
|
|
178
|
+
callId: "p1",
|
|
179
|
+
policy: "escalate",
|
|
180
|
+
title: "Run a shell command?",
|
|
181
|
+
options: [
|
|
182
|
+
{ optionId: "allow", name: "Allow", kind: "allow-once" },
|
|
183
|
+
{ optionId: "deny", name: "Deny", kind: "reject-once" },
|
|
184
|
+
],
|
|
185
|
+
}),
|
|
186
|
+
]),
|
|
187
|
+
{ onPermissionResolve: (resolution) => calls.push(resolution) },
|
|
188
|
+
);
|
|
189
|
+
const card = host.byData("permission", "request")[0];
|
|
190
|
+
assertEquals(card?.getAttribute("data-policy"), "escalate");
|
|
191
|
+
assertEquals(card?.getAttribute("data-status"), "pending");
|
|
192
|
+
assertEquals(card?.getAttribute("data-call-id"), "p1");
|
|
193
|
+
const buttons = host.byClass("cockpit-transcript-permission-option");
|
|
194
|
+
assertEquals(buttons.length, 2);
|
|
195
|
+
|
|
196
|
+
buttons.find((b) => b.getAttribute("data-option-id") === "allow")?.dispatch("click");
|
|
197
|
+
buttons.find((b) => b.getAttribute("data-option-id") === "deny")?.dispatch("click");
|
|
198
|
+
assertEquals(calls, [
|
|
199
|
+
{ callId: "p1", optionId: "allow", allowed: true },
|
|
200
|
+
{ callId: "p1", optionId: "deny", allowed: false },
|
|
201
|
+
]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("a yolo permission request renders informational only — no Allow/Deny buttons", () => {
|
|
205
|
+
const host = new FakeElement("div");
|
|
206
|
+
const calls: unknown[] = [];
|
|
207
|
+
renderDerivedTranscript(
|
|
208
|
+
host,
|
|
209
|
+
doc,
|
|
210
|
+
page([
|
|
211
|
+
env("permission", {
|
|
212
|
+
phase: "request",
|
|
213
|
+
callId: "y1",
|
|
214
|
+
policy: "yolo",
|
|
215
|
+
options: [{ optionId: "allow", name: "Allow", kind: "allow-always" }],
|
|
216
|
+
}),
|
|
217
|
+
]),
|
|
218
|
+
{ onPermissionResolve: (resolution) => calls.push(resolution) },
|
|
219
|
+
);
|
|
220
|
+
const card = host.byData("permission", "request")[0];
|
|
221
|
+
assertEquals(card?.getAttribute("data-policy"), "yolo");
|
|
222
|
+
assertEquals(card?.getAttribute("data-status"), "auto");
|
|
223
|
+
assertEquals(host.byClass("cockpit-transcript-permission-option").length, 0);
|
|
224
|
+
assertEquals(calls.length, 0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a resolved permission renders settled (allowed/denied) with the chosen option and no live buttons", () => {
|
|
228
|
+
const host = new FakeElement("div");
|
|
229
|
+
renderDerivedTranscript(
|
|
230
|
+
host,
|
|
231
|
+
doc,
|
|
232
|
+
page([
|
|
233
|
+
env("permission", {
|
|
234
|
+
phase: "request",
|
|
235
|
+
callId: "r1",
|
|
236
|
+
policy: "escalate",
|
|
237
|
+
title: "Delete the file?",
|
|
238
|
+
options: [
|
|
239
|
+
{ optionId: "allow", name: "Allow once", kind: "allow-once" },
|
|
240
|
+
{ optionId: "deny", name: "Deny", kind: "reject-once" },
|
|
241
|
+
],
|
|
242
|
+
}),
|
|
243
|
+
env("permission", { phase: "resolution", callId: "r1", optionId: "deny", allowed: false, by: "operator" }),
|
|
244
|
+
]),
|
|
245
|
+
);
|
|
246
|
+
const card = host.byData("permission", "request")[0];
|
|
247
|
+
assertEquals(card?.getAttribute("data-status"), "denied");
|
|
248
|
+
const settled = host.byClass("cockpit-transcript-permission-settled")[0];
|
|
249
|
+
assertEquals(settled?.getAttribute("data-chosen-option"), "deny");
|
|
250
|
+
assertEquals(settled?.textContent, "Deny");
|
|
251
|
+
assertEquals(host.byClass("cockpit-transcript-permission-option").length, 0);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("the existing 3-arg renderDerivedTranscript(host, doc, data) call still works (options optional)", () => {
|
|
255
|
+
const host = new FakeElement("div");
|
|
256
|
+
renderDerivedTranscript(host, doc, report());
|
|
257
|
+
assertEquals(host.byData("turn-count", "1").length, 1);
|
|
258
|
+
assertEquals(host.byData("permission-count", "0").length, 1);
|
|
259
|
+
});
|
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
// the same {@link DerivedView}, and the renderer draws into the injected {@link DocumentLike} subset so
|
|
14
14
|
// a real DOM satisfies it at runtime and an in-memory fake satisfies it for DOM-free Node tests.
|
|
15
15
|
import type { DocumentLike, ElementLike } from "@nanobpm/agentic/cockpit";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
type DerivedPermission,
|
|
18
|
+
type DerivedTool,
|
|
19
|
+
type DerivedView,
|
|
20
|
+
deriveViewFromChunks,
|
|
21
|
+
type PermissionOptionKind,
|
|
22
|
+
} from "../transcript-events.ts";
|
|
17
23
|
import type { TranscriptDataReport } from "./transcript-render.ts";
|
|
18
24
|
|
|
19
25
|
/**
|
|
@@ -36,13 +42,235 @@ export interface DerivedTranscriptDom {
|
|
|
36
42
|
readonly root: ElementLike;
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Options for {@link renderDerivedTranscript}. This is the SHARED SEAM the wave-2 escalation bridge
|
|
47
|
+
* attaches its handler to: an escalate-policy permission prompt's Allow/Deny buttons invoke
|
|
48
|
+
* {@link onPermissionResolve} on click (mirroring how `transcript-render.ts` wires `onReplay`). The
|
|
49
|
+
* render itself only *invokes* the callback — the relay round-trip that actually releases the blocked
|
|
50
|
+
* agent lives in the bridge, not here. Optional/defaulted so the 3-arg call sites keep working.
|
|
51
|
+
*/
|
|
52
|
+
export interface RenderDerivedTranscriptOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Called when the operator picks an Allow/Deny option on a pending `escalate` permission prompt. The
|
|
55
|
+
* resolution shape is the minimal `{ callId, optionId, allowed }` the bridge folds into a
|
|
56
|
+
* `permission` RESOLUTION frame — `allowed` is derived from the chosen option's kind (allow-* ⇒ true,
|
|
57
|
+
* reject-* ⇒ false). Yolo requests never prompt, so this never fires for a yolo policy.
|
|
58
|
+
*/
|
|
59
|
+
readonly onPermissionResolve?: (resolution: { callId: string; optionId: string; allowed: boolean }) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A single classified line of a rendered diff block. */
|
|
63
|
+
type DiffLineKind = "add" | "del" | "ctx";
|
|
64
|
+
interface DiffLine {
|
|
65
|
+
readonly kind: DiffLineKind;
|
|
66
|
+
readonly text: string;
|
|
67
|
+
}
|
|
68
|
+
interface DetectedDiff {
|
|
69
|
+
readonly lines: readonly DiffLine[];
|
|
70
|
+
/** Where the diff came from — so the raw `args`/`result` content isn't ALSO rendered redundantly. */
|
|
71
|
+
readonly source: "args" | "result";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Render an arbitrary derived value (tool args/result) as displayable text without re-parsing the log. */
|
|
75
|
+
function toText(value: unknown): string {
|
|
76
|
+
if (typeof value === "string") return value;
|
|
77
|
+
if (value === undefined) return "";
|
|
78
|
+
return JSON.stringify(value, null, 2);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read the first string-valued field among `keys` off an object, without an `as` cast. */
|
|
82
|
+
function pickString(obj: object, keys: readonly string[]): string | undefined {
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
const value = Reflect.get(obj, key);
|
|
85
|
+
if (typeof value === "string") return value;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Classify one line of a unified diff (file/hunk headers are context, not add/del). */
|
|
91
|
+
function classifyUnifiedLine(line: string): DiffLineKind {
|
|
92
|
+
if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@") || line.startsWith("diff ")) return "ctx";
|
|
93
|
+
if (line.startsWith("+")) return "add";
|
|
94
|
+
if (line.startsWith("-")) return "del";
|
|
95
|
+
return "ctx";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Heuristic: does this string look like a unified diff (a hunk header, or paired +/- content lines)? */
|
|
99
|
+
function looksLikeUnifiedDiff(text: string): boolean {
|
|
100
|
+
if (text.length === 0) return false;
|
|
101
|
+
let add = false;
|
|
102
|
+
let del = false;
|
|
103
|
+
let hunk = false;
|
|
104
|
+
for (const line of text.split("\n")) {
|
|
105
|
+
if (line.startsWith("@@") || line.startsWith("diff --git")) hunk = true;
|
|
106
|
+
else if (line.startsWith("+++") || line.startsWith("---")) continue;
|
|
107
|
+
else if (line.startsWith("+")) add = true;
|
|
108
|
+
else if (line.startsWith("-")) del = true;
|
|
109
|
+
}
|
|
110
|
+
return hunk || (add && del);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Split a unified-diff string into classified lines (dropping a single trailing empty line). */
|
|
114
|
+
function parseUnifiedDiff(text: string): DiffLine[] {
|
|
115
|
+
const lines = text.split("\n");
|
|
116
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
117
|
+
return lines.map((line) => ({ kind: classifyUnifiedLine(line), text: line }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Split a block of text into lines, dropping a single trailing empty segment (text ending in "\n"). */
|
|
121
|
+
function splitTextLines(text: string): string[] {
|
|
122
|
+
const lines = text.split("\n");
|
|
123
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Synthesize a diff from structured edit args (`{ path?, oldText/old_string, newText/new_string }`). */
|
|
128
|
+
function structuredDiff(args: unknown): DiffLine[] | undefined {
|
|
129
|
+
if (typeof args !== "object" || args === null) return undefined;
|
|
130
|
+
const oldText = pickString(args, ["oldText", "old_string", "oldStr", "old", "before"]);
|
|
131
|
+
const newText = pickString(args, ["newText", "new_string", "newStr", "new", "after"]);
|
|
132
|
+
if (oldText === undefined && newText === undefined) return undefined;
|
|
133
|
+
const lines: DiffLine[] = [];
|
|
134
|
+
const path = pickString(args, ["path", "file", "filePath", "fileName"]);
|
|
135
|
+
if (path !== undefined) lines.push({ kind: "ctx", text: `diff --git a/${path} b/${path}` });
|
|
136
|
+
if (oldText !== undefined && oldText.length > 0) {
|
|
137
|
+
for (const line of splitTextLines(oldText)) lines.push({ kind: "del", text: `-${line}` });
|
|
138
|
+
}
|
|
139
|
+
if (newText !== undefined && newText.length > 0) {
|
|
140
|
+
for (const line of splitTextLines(newText)) lines.push({ kind: "add", text: `+${line}` });
|
|
141
|
+
}
|
|
142
|
+
return lines.length > 0 ? lines : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Detect diff-shaped content on a tool call/result — a unified-diff string or structured edit args. */
|
|
146
|
+
function detectDiff(tool: DerivedTool): DetectedDiff | undefined {
|
|
147
|
+
const content = tool.result?.content;
|
|
148
|
+
if (typeof content === "string" && looksLikeUnifiedDiff(content)) {
|
|
149
|
+
return { lines: parseUnifiedDiff(content), source: "result" };
|
|
150
|
+
}
|
|
151
|
+
if (typeof tool.args === "string" && looksLikeUnifiedDiff(tool.args)) {
|
|
152
|
+
return { lines: parseUnifiedDiff(tool.args), source: "args" };
|
|
153
|
+
}
|
|
154
|
+
const structured = structuredDiff(tool.args);
|
|
155
|
+
if (structured !== undefined) return { lines: structured, source: "args" };
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
|
|
160
|
+
function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
|
|
161
|
+
const card = el(doc, "div", "cockpit-transcript-tool");
|
|
162
|
+
card.setAttribute("data-tool", tool.name);
|
|
163
|
+
card.setAttribute("data-offset", String(tool.offset));
|
|
164
|
+
card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
|
|
165
|
+
card.appendChild(el(doc, "div", "cockpit-transcript-tool-name", tool.name));
|
|
166
|
+
|
|
167
|
+
const diff = detectDiff(tool);
|
|
168
|
+
if (diff !== undefined) card.setAttribute("data-tool-kind", "diff");
|
|
169
|
+
|
|
170
|
+
// Show the raw args unless the diff was synthesized FROM the args (then the diff block replaces it).
|
|
171
|
+
if (tool.args !== undefined && !(diff !== undefined && diff.source === "args")) {
|
|
172
|
+
const argsEl = el(doc, "pre", "cockpit-transcript-tool-args", toText(tool.args));
|
|
173
|
+
argsEl.setAttribute("data-tool-args", "true");
|
|
174
|
+
card.appendChild(argsEl);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (diff !== undefined) {
|
|
178
|
+
const pre = el(doc, "pre", "cockpit-transcript-diff");
|
|
179
|
+
pre.setAttribute("data-diff", "true");
|
|
180
|
+
for (const line of diff.lines) {
|
|
181
|
+
// A <pre> may only contain phrasing content, so each diff line is a phrasing <span>
|
|
182
|
+
// (not a block <div>, which would be invalid markup) carrying a trailing "\n". The
|
|
183
|
+
// enclosing <pre> preserves that newline, so lines break onto their own line without
|
|
184
|
+
// depending on host CSS forcing display:block.
|
|
185
|
+
const row = el(doc, "span", "cockpit-transcript-diff-line", `${line.text}\n`);
|
|
186
|
+
row.setAttribute("data-diff-line", line.kind);
|
|
187
|
+
pre.appendChild(row);
|
|
188
|
+
}
|
|
189
|
+
card.appendChild(pre);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Render the result content unless it was itself consumed as the diff source (source === "result").
|
|
193
|
+
if (typeof tool.result?.content === "string" && !(diff !== undefined && diff.source === "result")) {
|
|
194
|
+
const resEl = el(doc, "pre", "cockpit-transcript-tool-result", tool.result.content);
|
|
195
|
+
resEl.setAttribute("data-tool-result", "true");
|
|
196
|
+
card.appendChild(resEl);
|
|
197
|
+
}
|
|
198
|
+
return card;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Does a permission option kind allow (true) or reject (false) the proposed action? */
|
|
202
|
+
function optionAllows(kind: PermissionOptionKind): boolean {
|
|
203
|
+
return kind === "allow-once" || kind === "allow-always";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Render one permission prompt card from a {@link DerivedPermission}:
|
|
208
|
+
* - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
|
|
209
|
+
* - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
|
|
210
|
+
* - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
|
|
211
|
+
*/
|
|
212
|
+
function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: RenderDerivedTranscriptOptions): ElementLike {
|
|
213
|
+
const card = el(doc, "div", "cockpit-transcript-permission");
|
|
214
|
+
card.setAttribute("data-permission", "request");
|
|
215
|
+
card.setAttribute("data-policy", perm.policy);
|
|
216
|
+
card.setAttribute("data-call-id", perm.callId);
|
|
217
|
+
card.setAttribute("data-offset", String(perm.offset));
|
|
218
|
+
if (perm.toolName !== undefined) card.setAttribute("data-tool", perm.toolName);
|
|
219
|
+
if (perm.title !== undefined) card.appendChild(el(doc, "div", "cockpit-transcript-permission-title", perm.title));
|
|
220
|
+
if (perm.reason !== undefined) card.appendChild(el(doc, "div", "cockpit-transcript-permission-reason", perm.reason));
|
|
221
|
+
|
|
222
|
+
if (perm.resolved !== undefined) {
|
|
223
|
+
// Settled: show which option was chosen and no live buttons.
|
|
224
|
+
card.setAttribute("data-status", perm.resolved.allowed ? "allowed" : "denied");
|
|
225
|
+
const chosen = perm.options.find((option) => option.optionId === perm.resolved?.optionId);
|
|
226
|
+
const settled = el(doc, "div", "cockpit-transcript-permission-settled", chosen?.name ?? perm.resolved.optionId);
|
|
227
|
+
settled.setAttribute("data-chosen-option", perm.resolved.optionId);
|
|
228
|
+
if (perm.resolved.by !== undefined) settled.setAttribute("data-by", perm.resolved.by);
|
|
229
|
+
card.appendChild(settled);
|
|
230
|
+
return card;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (perm.policy === "yolo") {
|
|
234
|
+
// Informational: yolo auto-allows and never prompts a human, so no Allow/Deny buttons.
|
|
235
|
+
card.setAttribute("data-status", "auto");
|
|
236
|
+
card.appendChild(el(doc, "div", "cockpit-transcript-permission-note", "Auto-allowed (yolo) — no operator prompt."));
|
|
237
|
+
return card;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Pending escalate: one interactive button per offered option, wired to the resolve seam.
|
|
241
|
+
card.setAttribute("data-status", "pending");
|
|
242
|
+
const actions = el(doc, "div", "cockpit-transcript-permission-actions");
|
|
243
|
+
for (const option of perm.options) {
|
|
244
|
+
const allowed = optionAllows(option.kind);
|
|
245
|
+
const button = el(doc, "button", "cockpit-transcript-permission-option", option.name);
|
|
246
|
+
button.setAttribute("type", "button");
|
|
247
|
+
button.setAttribute("data-option-id", option.optionId);
|
|
248
|
+
button.setAttribute("data-option-kind", option.kind);
|
|
249
|
+
button.setAttribute("data-allowed", String(allowed));
|
|
250
|
+
const onPermissionResolve = options.onPermissionResolve;
|
|
251
|
+
if (onPermissionResolve !== undefined) {
|
|
252
|
+
button.addEventListener("click", () => onPermissionResolve({ callId: perm.callId, optionId: option.optionId, allowed }));
|
|
253
|
+
}
|
|
254
|
+
actions.appendChild(button);
|
|
255
|
+
}
|
|
256
|
+
card.appendChild(actions);
|
|
257
|
+
return card;
|
|
258
|
+
}
|
|
259
|
+
|
|
39
260
|
/**
|
|
40
261
|
* Render the DERIVED structured view of a fetched transcript into `host`, replacing whatever was there.
|
|
41
|
-
* Draws per-turn sections with their derived messages
|
|
42
|
-
* (retained bytes/chunks) so the operator sees the byte-replay is preserved
|
|
43
|
-
* Idempotent — call again on each refresh. Everything it shows is a derivation
|
|
262
|
+
* Draws per-turn sections with their derived messages, rich tool/diff cards and permission prompts, plus
|
|
263
|
+
* a raw-fidelity footer (retained bytes/chunks) so the operator sees the byte-replay is preserved
|
|
264
|
+
* alongside the structure. Idempotent — call again on each refresh. Everything it shows is a derivation
|
|
265
|
+
* of the one event log. `options.onPermissionResolve`, when provided, is invoked by a pending
|
|
266
|
+
* escalate-permission prompt's Allow/Deny buttons.
|
|
44
267
|
*/
|
|
45
|
-
export function renderDerivedTranscript(
|
|
268
|
+
export function renderDerivedTranscript(
|
|
269
|
+
host: ElementLike,
|
|
270
|
+
doc: DocumentLike,
|
|
271
|
+
data: TranscriptDataReport,
|
|
272
|
+
options: RenderDerivedTranscriptOptions = {},
|
|
273
|
+
): DerivedTranscriptDom {
|
|
46
274
|
const view = deriveTranscript(data);
|
|
47
275
|
host.replaceChildren();
|
|
48
276
|
const root = el(doc, "div", "cockpit-transcript-derived");
|
|
@@ -51,6 +279,7 @@ export function renderDerivedTranscript(host: ElementLike, doc: DocumentLike, da
|
|
|
51
279
|
root.setAttribute("data-turn-count", String(view.turns.length));
|
|
52
280
|
root.setAttribute("data-message-count", String(view.messages.length));
|
|
53
281
|
root.setAttribute("data-tool-count", String(view.tools.length));
|
|
282
|
+
root.setAttribute("data-permission-count", String(view.permissions.length));
|
|
54
283
|
|
|
55
284
|
if (view.turns.length === 0) {
|
|
56
285
|
const empty = el(doc, "div", "cockpit-transcript-empty", "No structured events derived — raw replay only.");
|
|
@@ -70,11 +299,10 @@ export function renderDerivedTranscript(host: ElementLike, doc: DocumentLike, da
|
|
|
70
299
|
section.appendChild(row);
|
|
71
300
|
}
|
|
72
301
|
for (const tool of turn.tools) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
section.appendChild(card);
|
|
302
|
+
section.appendChild(renderTool(doc, tool));
|
|
303
|
+
}
|
|
304
|
+
for (const perm of turn.permissions) {
|
|
305
|
+
section.appendChild(renderPermission(doc, perm, options));
|
|
78
306
|
}
|
|
79
307
|
root.appendChild(section);
|
|
80
308
|
}
|
|
@@ -184,3 +184,227 @@ test("deriveViewFromChunks: a mixed log derives typed structure while retaining
|
|
|
184
184
|
assertEquals(view.messages.map((m) => m.text), ["hi"]);
|
|
185
185
|
assertEquals(view.rawChunkCount, 1);
|
|
186
186
|
});
|
|
187
|
+
|
|
188
|
+
// --- permission kind (issue #559) -----------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
const REQUEST_OPTIONS = [
|
|
191
|
+
{ optionId: "allow", name: "Allow", kind: "allow-once" },
|
|
192
|
+
{ optionId: "deny", name: "Deny", kind: "reject-once" },
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
test("core vocab decodes a permission REQUEST envelope", () => {
|
|
196
|
+
const chunk = env("permission", {
|
|
197
|
+
phase: "request",
|
|
198
|
+
callId: "p1",
|
|
199
|
+
policy: "escalate",
|
|
200
|
+
options: REQUEST_OPTIONS,
|
|
201
|
+
toolName: "write_file",
|
|
202
|
+
title: "Write to /etc/hosts?",
|
|
203
|
+
reason: "The agent wants to modify a system file",
|
|
204
|
+
});
|
|
205
|
+
assertEquals(parseTranscriptEvent({ offset: 1, chunk }), {
|
|
206
|
+
kind: "permission",
|
|
207
|
+
phase: "request",
|
|
208
|
+
offset: 1,
|
|
209
|
+
callId: "p1",
|
|
210
|
+
policy: "escalate",
|
|
211
|
+
options: REQUEST_OPTIONS,
|
|
212
|
+
toolName: "write_file",
|
|
213
|
+
title: "Write to /etc/hosts?",
|
|
214
|
+
reason: "The agent wants to modify a system file",
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("core vocab decodes a permission REQUEST with only required fields (optional fields omitted)", () => {
|
|
219
|
+
const chunk = env("permission", { phase: "request", callId: "p2", policy: "yolo", options: REQUEST_OPTIONS });
|
|
220
|
+
assertEquals(parseTranscriptEvent({ offset: 0, chunk }), {
|
|
221
|
+
kind: "permission",
|
|
222
|
+
phase: "request",
|
|
223
|
+
offset: 0,
|
|
224
|
+
callId: "p2",
|
|
225
|
+
policy: "yolo",
|
|
226
|
+
options: REQUEST_OPTIONS,
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("core vocab decodes a permission RESOLUTION envelope", () => {
|
|
231
|
+
const chunk = env("permission", { phase: "resolution", callId: "p1", optionId: "allow", allowed: true, by: "operator" });
|
|
232
|
+
assertEquals(parseTranscriptEvent({ offset: 2, chunk }), {
|
|
233
|
+
kind: "permission",
|
|
234
|
+
phase: "resolution",
|
|
235
|
+
offset: 2,
|
|
236
|
+
callId: "p1",
|
|
237
|
+
optionId: "allow",
|
|
238
|
+
allowed: true,
|
|
239
|
+
by: "operator",
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("core vocab: malformed permission envelopes fall back to raw stream-chunk", () => {
|
|
244
|
+
// request missing options
|
|
245
|
+
assertEquals(
|
|
246
|
+
parseTranscriptEvent({ offset: 0, chunk: env("permission", { phase: "request", callId: "p1", policy: "escalate" }) }).kind,
|
|
247
|
+
"stream-chunk",
|
|
248
|
+
);
|
|
249
|
+
// request with a bad policy
|
|
250
|
+
assertEquals(
|
|
251
|
+
parseTranscriptEvent({
|
|
252
|
+
offset: 0,
|
|
253
|
+
chunk: env("permission", { phase: "request", callId: "p1", policy: "maybe", options: REQUEST_OPTIONS }),
|
|
254
|
+
}).kind,
|
|
255
|
+
"stream-chunk",
|
|
256
|
+
);
|
|
257
|
+
// request with a malformed option (bad kind)
|
|
258
|
+
assertEquals(
|
|
259
|
+
parseTranscriptEvent({
|
|
260
|
+
offset: 0,
|
|
261
|
+
chunk: env("permission", { phase: "request", callId: "p1", policy: "escalate", options: [{ optionId: "a", name: "A", kind: "nope" }] }),
|
|
262
|
+
}).kind,
|
|
263
|
+
"stream-chunk",
|
|
264
|
+
);
|
|
265
|
+
// resolution missing allowed
|
|
266
|
+
assertEquals(
|
|
267
|
+
parseTranscriptEvent({ offset: 0, chunk: env("permission", { phase: "resolution", callId: "p1", optionId: "allow" }) }).kind,
|
|
268
|
+
"stream-chunk",
|
|
269
|
+
);
|
|
270
|
+
// resolution with a present-but-unknown `by` provenance (rejected, not silently dropped)
|
|
271
|
+
assertEquals(
|
|
272
|
+
parseTranscriptEvent({
|
|
273
|
+
offset: 0,
|
|
274
|
+
chunk: env("permission", { phase: "resolution", callId: "p1", optionId: "allow", allowed: true, by: "robot" }),
|
|
275
|
+
}).kind,
|
|
276
|
+
"stream-chunk",
|
|
277
|
+
);
|
|
278
|
+
// resolution with a present-but-NON-STRING `by` (e.g. 123) — rejected, not accepted with `by` dropped
|
|
279
|
+
assertEquals(
|
|
280
|
+
parseTranscriptEvent({
|
|
281
|
+
offset: 0,
|
|
282
|
+
chunk: env("permission", { phase: "resolution", callId: "p1", optionId: "allow", allowed: true, by: 123 }),
|
|
283
|
+
}).kind,
|
|
284
|
+
"stream-chunk",
|
|
285
|
+
);
|
|
286
|
+
// missing callId
|
|
287
|
+
assertEquals(
|
|
288
|
+
parseTranscriptEvent({ offset: 0, chunk: env("permission", { phase: "request", policy: "escalate", options: REQUEST_OPTIONS }) }).kind,
|
|
289
|
+
"stream-chunk",
|
|
290
|
+
);
|
|
291
|
+
// unknown phase
|
|
292
|
+
assertEquals(
|
|
293
|
+
parseTranscriptEvent({ offset: 0, chunk: env("permission", { phase: "wat", callId: "p1" }) }).kind,
|
|
294
|
+
"stream-chunk",
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("mergeTranscriptVocab: the permission kind is additive — provable via a merged vocab too", () => {
|
|
299
|
+
// Registering an unrelated kind via merge must not disturb the core `permission` decoder.
|
|
300
|
+
const vocab = mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, {
|
|
301
|
+
reasoning: (body, offset) => ({ kind: "message", offset, role: "system", text: String(body.text ?? "") }),
|
|
302
|
+
});
|
|
303
|
+
const chunk = env("permission", { phase: "request", callId: "p9", policy: "escalate", options: REQUEST_OPTIONS });
|
|
304
|
+
assertEquals(parseTranscriptEvent({ offset: 5, chunk }, vocab), {
|
|
305
|
+
kind: "permission",
|
|
306
|
+
phase: "request",
|
|
307
|
+
offset: 5,
|
|
308
|
+
callId: "p9",
|
|
309
|
+
policy: "escalate",
|
|
310
|
+
options: REQUEST_OPTIONS,
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("encodeTranscriptEvent round-trips the permission kind through the one parser", () => {
|
|
315
|
+
const events: TranscriptEvent[] = [
|
|
316
|
+
{
|
|
317
|
+
kind: "permission",
|
|
318
|
+
phase: "request",
|
|
319
|
+
offset: 10,
|
|
320
|
+
callId: "p1",
|
|
321
|
+
policy: "escalate",
|
|
322
|
+
options: REQUEST_OPTIONS.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind === "allow-once" ? "allow-once" : "reject-once" })),
|
|
323
|
+
toolName: "rm",
|
|
324
|
+
title: "Delete?",
|
|
325
|
+
reason: "why",
|
|
326
|
+
},
|
|
327
|
+
{ kind: "permission", phase: "resolution", offset: 11, callId: "p1", optionId: "deny", allowed: false, by: "auto" },
|
|
328
|
+
];
|
|
329
|
+
for (const original of events) {
|
|
330
|
+
const chunk = encodeTranscriptEvent(original);
|
|
331
|
+
assertEquals(parseTranscriptEvent({ offset: original.offset, chunk }), original);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("deriveView: pairs a permission request with its resolution by callId, carrying policy + options", () => {
|
|
336
|
+
const view = deriveView([
|
|
337
|
+
{ kind: "turn", offset: 0, index: 0 },
|
|
338
|
+
{
|
|
339
|
+
kind: "permission",
|
|
340
|
+
phase: "request",
|
|
341
|
+
offset: 1,
|
|
342
|
+
callId: "p1",
|
|
343
|
+
policy: "escalate",
|
|
344
|
+
options: [
|
|
345
|
+
{ optionId: "allow", name: "Allow", kind: "allow-once" },
|
|
346
|
+
{ optionId: "deny", name: "Deny", kind: "reject-once" },
|
|
347
|
+
],
|
|
348
|
+
title: "Write file?",
|
|
349
|
+
},
|
|
350
|
+
{ kind: "permission", phase: "resolution", offset: 2, callId: "p1", optionId: "allow", allowed: true, by: "operator" },
|
|
351
|
+
]);
|
|
352
|
+
assertEquals(view.permissions.length, 1);
|
|
353
|
+
const permission = view.permissions[0];
|
|
354
|
+
assertEquals(permission?.policy, "escalate");
|
|
355
|
+
assertEquals(permission?.title, "Write file?");
|
|
356
|
+
assertEquals(permission?.options.length, 2);
|
|
357
|
+
assertEquals(permission?.resolved, { allowed: true, optionId: "allow", offset: 2, by: "operator" });
|
|
358
|
+
// Also attached to its enclosing turn.
|
|
359
|
+
assertEquals(view.turns[0]?.permissions.length, 1);
|
|
360
|
+
assertEquals(view.turns[0]?.permissions[0]?.resolved?.optionId, "allow");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("deriveView: an unresolved permission request stays pending (no resolution)", () => {
|
|
364
|
+
const view = deriveView([
|
|
365
|
+
{
|
|
366
|
+
kind: "permission",
|
|
367
|
+
phase: "request",
|
|
368
|
+
offset: 0,
|
|
369
|
+
callId: "p1",
|
|
370
|
+
policy: "escalate",
|
|
371
|
+
options: [{ optionId: "allow", name: "Allow", kind: "allow-once" }],
|
|
372
|
+
},
|
|
373
|
+
]);
|
|
374
|
+
assertEquals(view.permissions.length, 1);
|
|
375
|
+
assertEquals(view.permissions[0]?.resolved, undefined);
|
|
376
|
+
assertEquals(view.permissions[0]?.policy, "escalate");
|
|
377
|
+
// Content before any turn event opens an implicit turn 0 (consistent with messages/tools).
|
|
378
|
+
assertEquals(view.turns.length, 1);
|
|
379
|
+
assertEquals(view.turns[0]?.permissions.length, 1);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("deriveView: the policy field survives derivation for a yolo request", () => {
|
|
383
|
+
const view = deriveView([
|
|
384
|
+
{
|
|
385
|
+
kind: "permission",
|
|
386
|
+
phase: "request",
|
|
387
|
+
offset: 0,
|
|
388
|
+
callId: "p1",
|
|
389
|
+
policy: "yolo",
|
|
390
|
+
options: [{ optionId: "allow", name: "Allow", kind: "allow-always" }],
|
|
391
|
+
},
|
|
392
|
+
{ kind: "permission", phase: "resolution", offset: 1, callId: "p1", optionId: "allow", allowed: true, by: "auto" },
|
|
393
|
+
]);
|
|
394
|
+
assertEquals(view.permissions[0]?.policy, "yolo");
|
|
395
|
+
assertEquals(view.permissions[0]?.resolved?.by, "auto");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("ACP plan updates map onto the existing step/turn vocabulary (no new kind)", () => {
|
|
399
|
+
// An ACP plan entry becomes a `step` (its label the entry title); a plan/turn boundary a `turn`.
|
|
400
|
+
const view = deriveViewFromChunks([
|
|
401
|
+
{ offset: 0, chunk: env("turn", { index: 0 }) },
|
|
402
|
+
{ offset: 1, chunk: env("step", { label: "Investigate the failing test", index: 0 }) },
|
|
403
|
+
{ offset: 2, chunk: env("step", { label: "Fix the bug", index: 1 }) },
|
|
404
|
+
{ offset: 3, chunk: env("turn", { index: 1 }) },
|
|
405
|
+
{ offset: 4, chunk: env("step", { label: "Write a regression test" }) },
|
|
406
|
+
]);
|
|
407
|
+
assertEquals(view.turns.length, 2);
|
|
408
|
+
assertEquals(view.turns[0]?.steps, 2);
|
|
409
|
+
assertEquals(view.turns[1]?.steps, 1);
|
|
410
|
+
});
|
|
@@ -64,7 +64,8 @@ export type TranscriptEventKind =
|
|
|
64
64
|
| "tool-result"
|
|
65
65
|
| "turn"
|
|
66
66
|
| "step"
|
|
67
|
-
| "lifecycle"
|
|
67
|
+
| "lifecycle"
|
|
68
|
+
| "permission";
|
|
68
69
|
|
|
69
70
|
/** The message roles the derived history distinguishes (assistant is authoritative for derivation). */
|
|
70
71
|
export type TranscriptRole = "assistant" | "user" | "system" | "tool";
|
|
@@ -124,6 +125,70 @@ export interface LifecycleEvent extends TranscriptEventBase {
|
|
|
124
125
|
readonly phase: "open" | "completed" | "exited";
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
// --- Permission (ACP `session/request_permission`) — SHARED CONTRACT (issue #559) ------------------
|
|
129
|
+
// A `permission` event models ACP's `session/request_permission`: the agent asks the operator to
|
|
130
|
+
// allow/deny a proposed action (usually a tool call), and the operator (or an auto policy) resolves it.
|
|
131
|
+
// It is decoded here (the ONE parser) and folded here (the ONE fold) into a paired {@link DerivedPermission}.
|
|
132
|
+
// These exported types are the SINGLE SOURCE OF TRUTH the sibling slices (cockpit render, escalation
|
|
133
|
+
// bridge) consume — they must import these, never reinvent a divergent permission shape. See the durable
|
|
134
|
+
// declaration in `app/contracts.ts` (`type:PermissionPolicy`, `wire:transcript.permission`).
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The role's permission policy the PRODUCER tags a request with. `"escalate"` means a human must be
|
|
138
|
+
* asked (the cockpit renders an Allow/Deny prompt, the escalation bridge raises a user task);
|
|
139
|
+
* `"yolo"` means the action is auto-allowed and never prompts a human. Cockpit + bridge branch on this.
|
|
140
|
+
*/
|
|
141
|
+
export type PermissionPolicy = "escalate" | "yolo";
|
|
142
|
+
|
|
143
|
+
/** The kind of a permission option — mirrors ACP's option kinds (allow/reject × once/always). */
|
|
144
|
+
export type PermissionOptionKind = "allow-once" | "allow-always" | "reject-once" | "reject-always";
|
|
145
|
+
|
|
146
|
+
/** One offered permission option (ACP `options[]` member): a stable id, a label, and its kind. */
|
|
147
|
+
export interface PermissionOption {
|
|
148
|
+
readonly optionId: string;
|
|
149
|
+
readonly name: string;
|
|
150
|
+
readonly kind: PermissionOptionKind;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A permission REQUEST: the agent asks the operator to allow/deny a proposed action. The `callId`
|
|
155
|
+
* pairs the eventual {@link PermissionResolutionEvent} back to this request (mirroring how
|
|
156
|
+
* `tool-call`/`tool-result` pair by `callId`).
|
|
157
|
+
*/
|
|
158
|
+
export interface PermissionRequestEvent extends TranscriptEventBase {
|
|
159
|
+
readonly kind: "permission";
|
|
160
|
+
readonly phase: "request";
|
|
161
|
+
/** Stable id pairing this request to its resolution. */
|
|
162
|
+
readonly callId: string;
|
|
163
|
+
/** The producer-tagged policy the cockpit + bridge branch on. */
|
|
164
|
+
readonly policy: PermissionPolicy;
|
|
165
|
+
/** The offered options — always at least one (the decoder rejects an empty list). */
|
|
166
|
+
readonly options: readonly PermissionOption[];
|
|
167
|
+
/** The tool the proposed action would invoke, when known. */
|
|
168
|
+
readonly toolName?: string;
|
|
169
|
+
/** A short human-readable title for the prompt. */
|
|
170
|
+
readonly title?: string;
|
|
171
|
+
/** A longer human-readable reason for the prompt. */
|
|
172
|
+
readonly reason?: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A permission RESOLUTION: the operator's (or an auto policy's) decision, carrying the same `callId`,
|
|
177
|
+
* the chosen `optionId`, and whether the action was `allowed`.
|
|
178
|
+
*/
|
|
179
|
+
export interface PermissionResolutionEvent extends TranscriptEventBase {
|
|
180
|
+
readonly kind: "permission";
|
|
181
|
+
readonly phase: "resolution";
|
|
182
|
+
/** The `callId` of the {@link PermissionRequestEvent} this resolves. */
|
|
183
|
+
readonly callId: string;
|
|
184
|
+
/** The chosen option's id. */
|
|
185
|
+
readonly optionId: string;
|
|
186
|
+
/** True = allowed, false = denied. */
|
|
187
|
+
readonly allowed: boolean;
|
|
188
|
+
/** Provenance of the decision, when supplied. */
|
|
189
|
+
readonly by?: "operator" | "auto";
|
|
190
|
+
}
|
|
191
|
+
|
|
127
192
|
/** The core typed transcript-event union (merge-extensible: authors add kinds via the vocab). */
|
|
128
193
|
export type TranscriptEvent =
|
|
129
194
|
| StreamChunkEvent
|
|
@@ -132,7 +197,9 @@ export type TranscriptEvent =
|
|
|
132
197
|
| ToolResultEvent
|
|
133
198
|
| TurnEvent
|
|
134
199
|
| StepEvent
|
|
135
|
-
| LifecycleEvent
|
|
200
|
+
| LifecycleEvent
|
|
201
|
+
| PermissionRequestEvent
|
|
202
|
+
| PermissionResolutionEvent;
|
|
136
203
|
|
|
137
204
|
/** A stored chunk as the store/read path exposes it (mirrors `TranscriptChunk`). */
|
|
138
205
|
export interface StoredChunk {
|
|
@@ -172,6 +239,32 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
172
239
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
173
240
|
}
|
|
174
241
|
|
|
242
|
+
const PERMISSION_OPTION_KINDS: readonly PermissionOptionKind[] = [
|
|
243
|
+
"allow-once",
|
|
244
|
+
"allow-always",
|
|
245
|
+
"reject-once",
|
|
246
|
+
"reject-always",
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Decode ACP's `options[]` into typed {@link PermissionOption}s, or `undefined` if the array is
|
|
251
|
+
* missing/empty or any member is malformed (so the whole request envelope is rejected → `stream-chunk`).
|
|
252
|
+
*/
|
|
253
|
+
function decodePermissionOptions(value: unknown): PermissionOption[] | undefined {
|
|
254
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
255
|
+
const options: PermissionOption[] = [];
|
|
256
|
+
for (const raw of value) {
|
|
257
|
+
if (!isRecord(raw)) return undefined;
|
|
258
|
+
const optionId = str(raw, "optionId");
|
|
259
|
+
const name = str(raw, "name");
|
|
260
|
+
const kindRaw = str(raw, "kind");
|
|
261
|
+
const kind = PERMISSION_OPTION_KINDS.find((k) => k === kindRaw);
|
|
262
|
+
if (optionId === undefined || name === undefined || kind === undefined) return undefined;
|
|
263
|
+
options.push({ optionId, name, kind });
|
|
264
|
+
}
|
|
265
|
+
return options;
|
|
266
|
+
}
|
|
267
|
+
|
|
175
268
|
/**
|
|
176
269
|
* The opinionated core vocabulary — the built-in event kinds every consumer understands out of the
|
|
177
270
|
* box. Authors extend it in the SAME schema via {@link mergeTranscriptVocab}; they never fork the
|
|
@@ -207,6 +300,11 @@ export const CORE_TRANSCRIPT_VOCAB: TranscriptVocab = Object.freeze({
|
|
|
207
300
|
...(content !== undefined ? { content } : {}),
|
|
208
301
|
};
|
|
209
302
|
},
|
|
303
|
+
// ACP `plan` mapping: ACP `session/update` plan updates map onto the EXISTING `step`/`turn`
|
|
304
|
+
// vocabulary rather than a new kind — an ACP plan ENTRY becomes a `step` (its `label` is the plan
|
|
305
|
+
// entry's title; the entry ordinal is not preserved, as `StepEvent` carries only a `label`), and a
|
|
306
|
+
// plan/turn BOUNDARY becomes a `turn` (its `index` the ACP turn/plan ordinal). The decoders below
|
|
307
|
+
// already cope with an ACP-shaped `label` (`step`) / `index` (`turn`), so no new kind is needed.
|
|
210
308
|
turn: (body, offset) => {
|
|
211
309
|
const index = num(body, "index");
|
|
212
310
|
return index !== undefined ? { kind: "turn", offset, index } : { kind: "turn", offset };
|
|
@@ -220,6 +318,51 @@ export const CORE_TRANSCRIPT_VOCAB: TranscriptVocab = Object.freeze({
|
|
|
220
318
|
if (phase !== "open" && phase !== "completed" && phase !== "exited") return undefined;
|
|
221
319
|
return { kind: "lifecycle", offset, phase };
|
|
222
320
|
},
|
|
321
|
+
// A single `permission` decoder handles BOTH shapes (never a parser fork), branching on `phase`.
|
|
322
|
+
// Malformed envelopes return `undefined` and fall back to `stream-chunk`, like the other decoders.
|
|
323
|
+
permission: (body, offset) => {
|
|
324
|
+
const callId = str(body, "callId");
|
|
325
|
+
if (callId === undefined) return undefined;
|
|
326
|
+
const phase = str(body, "phase");
|
|
327
|
+
if (phase === "request") {
|
|
328
|
+
const policy = str(body, "policy");
|
|
329
|
+
if (policy !== "escalate" && policy !== "yolo") return undefined;
|
|
330
|
+
const options = decodePermissionOptions(body.options);
|
|
331
|
+
if (options === undefined) return undefined;
|
|
332
|
+
const toolName = str(body, "toolName");
|
|
333
|
+
const title = str(body, "title");
|
|
334
|
+
const reason = str(body, "reason");
|
|
335
|
+
const event: PermissionRequestEvent = { kind: "permission", phase: "request", offset, callId, policy, options };
|
|
336
|
+
return {
|
|
337
|
+
...event,
|
|
338
|
+
...(toolName !== undefined ? { toolName } : {}),
|
|
339
|
+
...(title !== undefined ? { title } : {}),
|
|
340
|
+
...(reason !== undefined ? { reason } : {}),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (phase === "resolution") {
|
|
344
|
+
const optionId = str(body, "optionId");
|
|
345
|
+
if (optionId === undefined) return undefined;
|
|
346
|
+
if (typeof body.allowed !== "boolean") return undefined;
|
|
347
|
+
const by = str(body, "by");
|
|
348
|
+
// Reject a malformed `by` rather than silently dropping it: a present-but-unknown provenance is a
|
|
349
|
+
// producer bug, and swallowing it would make the typed event diverge from the on-wire JSON. This
|
|
350
|
+
// covers BOTH a present-but-non-string `by` (e.g. `by: 123`, where str() coerces to undefined) and
|
|
351
|
+
// a string that isn't a known provenance — either way the on-wire `by` is present but invalid.
|
|
352
|
+
if (body.by !== undefined && by === undefined) return undefined;
|
|
353
|
+
if (by !== undefined && by !== "operator" && by !== "auto") return undefined;
|
|
354
|
+
const event: PermissionResolutionEvent = {
|
|
355
|
+
kind: "permission",
|
|
356
|
+
phase: "resolution",
|
|
357
|
+
offset,
|
|
358
|
+
callId,
|
|
359
|
+
optionId,
|
|
360
|
+
allowed: body.allowed,
|
|
361
|
+
};
|
|
362
|
+
return { ...event, ...(by !== undefined ? { by } : {}) };
|
|
363
|
+
}
|
|
364
|
+
return undefined;
|
|
365
|
+
},
|
|
223
366
|
});
|
|
224
367
|
|
|
225
368
|
/**
|
|
@@ -302,12 +445,35 @@ export interface DerivedMessage {
|
|
|
302
445
|
readonly offset: number;
|
|
303
446
|
}
|
|
304
447
|
|
|
448
|
+
/**
|
|
449
|
+
* A derived permission: a permission REQUEST paired with its RESOLUTION by `callId` (resolution absent
|
|
450
|
+
* while the request is still pending), mirroring how {@link DerivedTool} pairs a call with its result.
|
|
451
|
+
* The cockpit and the escalation bridge read THIS — they never re-parse the log.
|
|
452
|
+
*/
|
|
453
|
+
export interface DerivedPermission {
|
|
454
|
+
readonly callId: string;
|
|
455
|
+
readonly policy: PermissionPolicy;
|
|
456
|
+
readonly options: readonly PermissionOption[];
|
|
457
|
+
readonly toolName?: string;
|
|
458
|
+
readonly title?: string;
|
|
459
|
+
readonly reason?: string;
|
|
460
|
+
readonly offset: number;
|
|
461
|
+
/** The resolution, once present (pending request → `undefined`). */
|
|
462
|
+
readonly resolved?: {
|
|
463
|
+
readonly allowed: boolean;
|
|
464
|
+
readonly optionId: string;
|
|
465
|
+
readonly by?: "operator" | "auto";
|
|
466
|
+
readonly offset: number;
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
305
470
|
/** A derived turn: the messages, tool cards and step count folded within one turn boundary. */
|
|
306
471
|
export interface DerivedTurn {
|
|
307
472
|
readonly index: number;
|
|
308
473
|
readonly startOffset: number;
|
|
309
474
|
readonly messages: readonly DerivedMessage[];
|
|
310
475
|
readonly tools: readonly DerivedTool[];
|
|
476
|
+
readonly permissions: readonly DerivedPermission[];
|
|
311
477
|
readonly steps: number;
|
|
312
478
|
}
|
|
313
479
|
|
|
@@ -319,6 +485,8 @@ export interface DerivedView {
|
|
|
319
485
|
readonly messages: readonly DerivedMessage[];
|
|
320
486
|
/** Every tool card across all turns, in offset order. */
|
|
321
487
|
readonly tools: readonly DerivedTool[];
|
|
488
|
+
/** Every permission across all turns, in offset order (each request paired to its resolution by `callId`). */
|
|
489
|
+
readonly permissions: readonly DerivedPermission[];
|
|
322
490
|
/** Total retained raw bytes (UTF-8) across `stream-chunk` events — the byte-replay fidelity accounting. */
|
|
323
491
|
readonly rawByteLength: number;
|
|
324
492
|
/** Number of retained raw chunks. */
|
|
@@ -334,6 +502,7 @@ interface MutableTurn {
|
|
|
334
502
|
startOffset: number;
|
|
335
503
|
messages: DerivedMessage[];
|
|
336
504
|
tools: DerivedTool[];
|
|
505
|
+
permissions: DerivedPermission[];
|
|
337
506
|
steps: number;
|
|
338
507
|
}
|
|
339
508
|
|
|
@@ -351,8 +520,10 @@ export function deriveView(events: Iterable<TranscriptEvent>): DerivedView {
|
|
|
351
520
|
const turns: MutableTurn[] = [];
|
|
352
521
|
const messages: DerivedMessage[] = [];
|
|
353
522
|
const tools: DerivedTool[] = [];
|
|
523
|
+
const permissions: DerivedPermission[] = [];
|
|
354
524
|
const openTools = new Map<string, DerivedTool>();
|
|
355
525
|
let anonymousTool: DerivedTool | undefined;
|
|
526
|
+
const openPermissions = new Map<string, DerivedPermission>();
|
|
356
527
|
let rawByteLength = 0;
|
|
357
528
|
let rawChunkCount = 0;
|
|
358
529
|
let lifecycle: "open" | "completed" | "exited" = "open";
|
|
@@ -361,7 +532,7 @@ export function deriveView(events: Iterable<TranscriptEvent>): DerivedView {
|
|
|
361
532
|
|
|
362
533
|
const ensureTurn = (offset: number): MutableTurn => {
|
|
363
534
|
if (current === undefined) {
|
|
364
|
-
current = { index: turns.length, startOffset: offset, messages: [], tools: [], steps: 0 };
|
|
535
|
+
current = { index: turns.length, startOffset: offset, messages: [], tools: [], permissions: [], steps: 0 };
|
|
365
536
|
turns.push(current);
|
|
366
537
|
}
|
|
367
538
|
return current;
|
|
@@ -371,7 +542,14 @@ export function deriveView(events: Iterable<TranscriptEvent>): DerivedView {
|
|
|
371
542
|
eventCount++;
|
|
372
543
|
switch (event.kind) {
|
|
373
544
|
case "turn": {
|
|
374
|
-
current = {
|
|
545
|
+
current = {
|
|
546
|
+
index: event.index ?? turns.length,
|
|
547
|
+
startOffset: event.offset,
|
|
548
|
+
messages: [],
|
|
549
|
+
tools: [],
|
|
550
|
+
permissions: [],
|
|
551
|
+
steps: 0,
|
|
552
|
+
};
|
|
375
553
|
turns.push(current);
|
|
376
554
|
break;
|
|
377
555
|
}
|
|
@@ -408,6 +586,33 @@ export function deriveView(events: Iterable<TranscriptEvent>): DerivedView {
|
|
|
408
586
|
}
|
|
409
587
|
break;
|
|
410
588
|
}
|
|
589
|
+
case "permission": {
|
|
590
|
+
// A `permission` event is one of two phases (same discriminant `kind`); branch on `phase`. A
|
|
591
|
+
// REQUEST opens a pending DerivedPermission (paired to its turn); a RESOLUTION folds back into
|
|
592
|
+
// the open request by `callId` — mirroring the tool-call/tool-result open-map pairing above.
|
|
593
|
+
if (event.phase === "request") {
|
|
594
|
+
const permission: DerivedPermission = {
|
|
595
|
+
policy: event.policy,
|
|
596
|
+
options: event.options,
|
|
597
|
+
offset: event.offset,
|
|
598
|
+
callId: event.callId,
|
|
599
|
+
...(event.toolName !== undefined ? { toolName: event.toolName } : {}),
|
|
600
|
+
...(event.title !== undefined ? { title: event.title } : {}),
|
|
601
|
+
...(event.reason !== undefined ? { reason: event.reason } : {}),
|
|
602
|
+
};
|
|
603
|
+
permissions.push(permission);
|
|
604
|
+
ensureTurn(event.offset).permissions.push(permission);
|
|
605
|
+
openPermissions.set(event.callId, permission);
|
|
606
|
+
} else {
|
|
607
|
+
const target = openPermissions.get(event.callId);
|
|
608
|
+
if (target !== undefined) {
|
|
609
|
+
pairResolution(permissions, target, event);
|
|
610
|
+
pairResolutionInTurns(turns, target, event);
|
|
611
|
+
openPermissions.delete(event.callId);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
411
616
|
case "lifecycle": {
|
|
412
617
|
lifecycle = event.phase;
|
|
413
618
|
break;
|
|
@@ -421,9 +626,17 @@ export function deriveView(events: Iterable<TranscriptEvent>): DerivedView {
|
|
|
421
626
|
}
|
|
422
627
|
|
|
423
628
|
return {
|
|
424
|
-
turns: turns.map((t) => ({
|
|
629
|
+
turns: turns.map((t) => ({
|
|
630
|
+
index: t.index,
|
|
631
|
+
startOffset: t.startOffset,
|
|
632
|
+
messages: t.messages,
|
|
633
|
+
tools: t.tools,
|
|
634
|
+
permissions: t.permissions,
|
|
635
|
+
steps: t.steps,
|
|
636
|
+
})),
|
|
425
637
|
messages,
|
|
426
638
|
tools,
|
|
639
|
+
permissions,
|
|
427
640
|
rawByteLength,
|
|
428
641
|
rawChunkCount,
|
|
429
642
|
lifecycle,
|
|
@@ -457,6 +670,35 @@ function withResult(tool: DerivedTool, result: ToolResultEvent): DerivedTool {
|
|
|
457
670
|
};
|
|
458
671
|
}
|
|
459
672
|
|
|
673
|
+
/** Replace a pending permission with its resolution in the flat list (by identity — see {@link pairResult}). */
|
|
674
|
+
function pairResolution(list: DerivedPermission[], target: DerivedPermission, resolution: PermissionResolutionEvent): void {
|
|
675
|
+
const idx = list.indexOf(target);
|
|
676
|
+
if (idx >= 0) list[idx] = withResolution(target, resolution);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Replace a pending permission with its resolution inside whichever turn holds it. */
|
|
680
|
+
function pairResolutionInTurns(turns: MutableTurn[], target: DerivedPermission, resolution: PermissionResolutionEvent): void {
|
|
681
|
+
for (const turn of turns) {
|
|
682
|
+
const idx = turn.permissions.indexOf(target);
|
|
683
|
+
if (idx >= 0) {
|
|
684
|
+
turn.permissions[idx] = withResolution(target, resolution);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function withResolution(permission: DerivedPermission, resolution: PermissionResolutionEvent): DerivedPermission {
|
|
691
|
+
return {
|
|
692
|
+
...permission,
|
|
693
|
+
resolved: {
|
|
694
|
+
allowed: resolution.allowed,
|
|
695
|
+
optionId: resolution.optionId,
|
|
696
|
+
offset: resolution.offset,
|
|
697
|
+
...(resolution.by !== undefined ? { by: resolution.by } : {}),
|
|
698
|
+
},
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
460
702
|
/**
|
|
461
703
|
* Convenience: parse a run of stored chunks into typed events through {@link parseTranscriptEvent} (the
|
|
462
704
|
* one parser) and fold them with {@link deriveView} in a single call — the entry point a consumer uses
|
package/app/contracts.ts
CHANGED
|
@@ -407,9 +407,26 @@ export const WIRE_CONTRACTS = {
|
|
|
407
407
|
"Filesystem-import request body POSTed to /actions/delivery-graph/library/import (issue #524, epic #519 S5). Declared in openapi.yaml as `ImportToLibrarySubmit`; the compose App-View's `<input type=file accept=.json>` reads the picked file's text client-side and POSTs it here as the raw `graphJson` string. The door validates + compiles it through the SAME `parseAndCompileText` pipeline preview/stage/save use, then persists `source: imported` — an uncompilable graph is a clean 400 and NOTHING is written. Its `name` defaults to the imported graph's own `name`; an explicit `name` overrides it (an unnamed graph with no override is a clean 400 — the library id is name-derived). Related to but DISTINCT from `SaveToLibrarySubmit` (which is graphJson-OR-digest and needs no required file text); consume this ONE shape across the openapi edge, the door, and the compose mount — do not re-declare a synonym.",
|
|
408
408
|
shape: "{ graphJson: string, name?: string, description?: string }",
|
|
409
409
|
},
|
|
410
|
+
"transcript.permission": {
|
|
411
|
+
category: "wire",
|
|
412
|
+
name: "transcript.permission",
|
|
413
|
+
owner: "app/agentic/transcript-events.ts",
|
|
414
|
+
semantics:
|
|
415
|
+
"The `permission` transcript-event envelope (issue #559) modelling ACP's `session/request_permission`, decoded by the ONE parser (`parseTranscriptEvent`) and folded by the ONE fold (`deriveView`) in app/agentic/transcript-events.ts. Two phases share the `kind:\"permission\"` discriminant, distinguished by `phase`. A REQUEST carries a stable `callId` (pairs the resolution back, like tool-call/tool-result), the producer-tagged `policy` (\"escalate\" = must ask a human, \"yolo\" = auto-allowed), the offered `options` (a NON-EMPTY array of ACP `{optionId,name,kind}` where kind is allow-once/allow-always/reject-once/reject-always — the decoder rejects a missing or empty `options`), and optional `toolName`/`title`/`reason`. A RESOLUTION carries the same `callId`, the chosen `optionId`, a boolean `allowed`, and optional `by` provenance (operator/auto). deriveView surfaces these as `DerivedPermission` (paired by callId) on `DerivedView.permissions` and `DerivedTurn.permissions`. The cockpit-render and escalation-bridge slices CONSUME this exact wire shape — do not re-declare a synonym.",
|
|
416
|
+
shape:
|
|
417
|
+
'{ nwfTranscriptEvent: 1, kind: "permission", phase: "request", callId: string, policy: "escalate"|"yolo", options: [{ optionId: string, name: string, kind: "allow-once"|"allow-always"|"reject-once"|"reject-always" }, ...Array<{ optionId: string, name: string, kind: "allow-once"|"allow-always"|"reject-once"|"reject-always" }>], toolName?: string, title?: string, reason?: string } | { nwfTranscriptEvent: 1, kind: "permission", phase: "resolution", callId: string, optionId: string, allowed: boolean, by?: "operator"|"auto" }',
|
|
418
|
+
},
|
|
410
419
|
} as const satisfies Record<string, WireContract>;
|
|
411
420
|
|
|
412
421
|
export const TYPE_CONTRACTS = {
|
|
422
|
+
PermissionPolicy: {
|
|
423
|
+
category: "type",
|
|
424
|
+
name: "PermissionPolicy",
|
|
425
|
+
owner: "app/agentic/transcript-events.ts",
|
|
426
|
+
semantics:
|
|
427
|
+
"The role's permission policy a `permission` transcript-event REQUEST is tagged with (issue #559): `\"escalate\"` (a human must be asked — cockpit renders an Allow/Deny prompt, the escalation bridge raises a user task) vs `\"yolo\"` (auto-allowed, never prompts). Exported from app/agentic/transcript-events.ts alongside the shared permission contract types (`PermissionOption`, `PermissionOptionKind`, `PermissionRequestEvent`, `PermissionResolutionEvent`, and the derived `DerivedPermission` surface on `DerivedView`/`DerivedTurn`). The cockpit-render and escalation-bridge siblings IMPORT these — they must not reinvent a divergent permission shape or a synonym policy enum.",
|
|
428
|
+
module: "app/agentic/transcript-events.ts",
|
|
429
|
+
},
|
|
413
430
|
BlackboardEntry: {
|
|
414
431
|
category: "type",
|
|
415
432
|
name: "BlackboardEntry",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.147.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|