@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 +12 -0
- package/app/agentCompletion.ts +9 -3
- 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 +233 -10
- package/app/agentic/permission-bridge.test.ts +288 -0
- package/app/agentic/permission-bridge.ts +268 -0
- package/app/agentic/transcript-events.test.ts +8 -0
- package/app/agentic/transcript-events.ts +13 -1
- package/app/contracts.ts +8 -0
- package/app/userTasks.ts +11 -1
- package/package.json +1 -1
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
|
package/app/agentCompletion.ts
CHANGED
|
@@ -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
|
|
96
|
-
* `
|
|
97
|
-
*
|
|
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
|
|
@@ -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
|
+
});
|