@nanobpm/nano-workforce 0.146.0 → 0.148.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 CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.148.0](https://github.com/nanobpm/nano-workforce/compare/v0.147.0...v0.148.0) (2026-08-26)
2
+
3
+ ### Features
4
+
5
+ * **agentic:** bridge escalate-policy ACP permission requests to the nwf Tasks inbox ([#562](https://github.com/nanobpm/nano-workforce/issues/562)) ([44e5cfb](https://github.com/nanobpm/nano-workforce/commit/44e5cfb3ff262fa50db17466f690b64eb3405e56)), 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) [#561](https://github.com/nanobpm/nano-workforce/issues/561)
6
+
7
+ ## [0.147.0](https://github.com/nanobpm/nano-workforce/compare/v0.146.0...v0.147.0) (2026-08-26)
8
+
9
+ ### Features
10
+
11
+ * **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)
12
+
1
13
  ## [0.146.0](https://github.com/nanobpm/nano-workforce/compare/v0.145.1...v0.146.0) (2026-08-26)
2
14
 
3
15
  ### Features
@@ -24,6 +24,7 @@ import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
25
25
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
26
26
  import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
27
+ import { ACP_PERMISSION_ELEMENT } from "./userTasks.ts";
27
28
 
28
29
  const now = () => new Date().toISOString();
29
30
 
@@ -92,13 +93,18 @@ export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMEN
92
93
 
93
94
  /** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
94
95
  * `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
95
- * and `conformance-escalation` acknowledgements. The AGENT completer stays scoped to
96
- * `ESCALATION_TASK_ELEMENTS` (neither ack), so widening the human surface never lets an agent retire
97
- * a blocked run or a conformance review. */
96
+ * and `conformance-escalation` acknowledgements, PLUS the advisory ACP permission prompt
97
+ * (`ACP_PERMISSION_ELEMENT`, issue #559) a bridged escalate-policy `session/request_permission` raises.
98
+ * The AGENT completer stays scoped to `ESCALATION_TASK_ELEMENTS` (none of these three), so widening the
99
+ * human surface never lets an agent retire a blocked run, a conformance review, or an agent-permission
100
+ * prompt. The permission element has no static `.form` (it is not a BPMN user task), so
101
+ * `validateEscalationVariables` leaves its Allow/Deny variables unenforced — the permission bridge
102
+ * translates them into a RESOLUTION frame (`app/agentic/permission-bridge.ts`). */
98
103
  export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
99
104
  ...ESCALATION_TASK_ELEMENTS,
100
105
  FEATURE_BLOCKED_TASK_ELEMENT,
101
106
  CONFORMANCE_ESCALATION_TASK_ELEMENT,
107
+ ACP_PERMISSION_ELEMENT,
102
108
  ]);
103
109
 
104
110
  /** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
@@ -36,6 +36,7 @@ export {
36
36
  export {
37
37
  type DerivedTranscriptDom,
38
38
  deriveTranscript,
39
+ type RenderDerivedTranscriptOptions,
39
40
  renderDerivedTranscript,
40
41
  } from "./transcript-derive.ts";
41
42
  export {
@@ -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
+ });