@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.
@@ -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 { type DerivedView, deriveViewFromChunks } from "../transcript-events.ts";
16
+ import {
17
+ type DerivedPermission,
18
+ type DerivedTool,
19
+ type DerivedView,
20
+ deriveViewFromChunks,
21
+ optionKindAllows,
22
+ } from "../transcript-events.ts";
17
23
  import type { TranscriptDataReport } from "./transcript-render.ts";
18
24
 
19
25
  /**
@@ -36,13 +42,230 @@ 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
+ /**
202
+ * Render one permission prompt card from a {@link DerivedPermission}:
203
+ * - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
204
+ * - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
205
+ * - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
206
+ */
207
+ function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: RenderDerivedTranscriptOptions): ElementLike {
208
+ const card = el(doc, "div", "cockpit-transcript-permission");
209
+ card.setAttribute("data-permission", "request");
210
+ card.setAttribute("data-policy", perm.policy);
211
+ card.setAttribute("data-call-id", perm.callId);
212
+ card.setAttribute("data-offset", String(perm.offset));
213
+ if (perm.toolName !== undefined) card.setAttribute("data-tool", perm.toolName);
214
+ if (perm.title !== undefined) card.appendChild(el(doc, "div", "cockpit-transcript-permission-title", perm.title));
215
+ if (perm.reason !== undefined) card.appendChild(el(doc, "div", "cockpit-transcript-permission-reason", perm.reason));
216
+
217
+ if (perm.resolved !== undefined) {
218
+ // Settled: show which option was chosen and no live buttons.
219
+ card.setAttribute("data-status", perm.resolved.allowed ? "allowed" : "denied");
220
+ const chosen = perm.options.find((option) => option.optionId === perm.resolved?.optionId);
221
+ const settled = el(doc, "div", "cockpit-transcript-permission-settled", chosen?.name ?? perm.resolved.optionId);
222
+ settled.setAttribute("data-chosen-option", perm.resolved.optionId);
223
+ if (perm.resolved.by !== undefined) settled.setAttribute("data-by", perm.resolved.by);
224
+ card.appendChild(settled);
225
+ return card;
226
+ }
227
+
228
+ if (perm.policy === "yolo") {
229
+ // Informational: yolo auto-allows and never prompts a human, so no Allow/Deny buttons.
230
+ card.setAttribute("data-status", "auto");
231
+ card.appendChild(el(doc, "div", "cockpit-transcript-permission-note", "Auto-allowed (yolo) — no operator prompt."));
232
+ return card;
233
+ }
234
+
235
+ // Pending escalate: one interactive button per offered option, wired to the resolve seam.
236
+ card.setAttribute("data-status", "pending");
237
+ const actions = el(doc, "div", "cockpit-transcript-permission-actions");
238
+ for (const option of perm.options) {
239
+ const allowed = optionKindAllows(option.kind);
240
+ const button = el(doc, "button", "cockpit-transcript-permission-option", option.name);
241
+ button.setAttribute("type", "button");
242
+ button.setAttribute("data-option-id", option.optionId);
243
+ button.setAttribute("data-option-kind", option.kind);
244
+ button.setAttribute("data-allowed", String(allowed));
245
+ const onPermissionResolve = options.onPermissionResolve;
246
+ if (onPermissionResolve !== undefined) {
247
+ button.addEventListener("click", () => onPermissionResolve({ callId: perm.callId, optionId: option.optionId, allowed }));
248
+ }
249
+ actions.appendChild(button);
250
+ }
251
+ card.appendChild(actions);
252
+ return card;
253
+ }
254
+
39
255
  /**
40
256
  * Render the DERIVED structured view of a fetched transcript into `host`, replacing whatever was there.
41
- * Draws per-turn sections with their derived messages and tool cards, plus a raw-fidelity footer
42
- * (retained bytes/chunks) so the operator sees the byte-replay is preserved alongside the structure.
43
- * Idempotent — call again on each refresh. Everything it shows is a derivation of the one event log.
257
+ * Draws per-turn sections with their derived messages, rich tool/diff cards and permission prompts, plus
258
+ * a raw-fidelity footer (retained bytes/chunks) so the operator sees the byte-replay is preserved
259
+ * alongside the structure. Idempotent — call again on each refresh. Everything it shows is a derivation
260
+ * of the one event log. `options.onPermissionResolve`, when provided, is invoked by a pending
261
+ * escalate-permission prompt's Allow/Deny buttons.
44
262
  */
45
- export function renderDerivedTranscript(host: ElementLike, doc: DocumentLike, data: TranscriptDataReport): DerivedTranscriptDom {
263
+ export function renderDerivedTranscript(
264
+ host: ElementLike,
265
+ doc: DocumentLike,
266
+ data: TranscriptDataReport,
267
+ options: RenderDerivedTranscriptOptions = {},
268
+ ): DerivedTranscriptDom {
46
269
  const view = deriveTranscript(data);
47
270
  host.replaceChildren();
48
271
  const root = el(doc, "div", "cockpit-transcript-derived");
@@ -51,6 +274,7 @@ export function renderDerivedTranscript(host: ElementLike, doc: DocumentLike, da
51
274
  root.setAttribute("data-turn-count", String(view.turns.length));
52
275
  root.setAttribute("data-message-count", String(view.messages.length));
53
276
  root.setAttribute("data-tool-count", String(view.tools.length));
277
+ root.setAttribute("data-permission-count", String(view.permissions.length));
54
278
 
55
279
  if (view.turns.length === 0) {
56
280
  const empty = el(doc, "div", "cockpit-transcript-empty", "No structured events derived — raw replay only.");
@@ -70,11 +294,10 @@ export function renderDerivedTranscript(host: ElementLike, doc: DocumentLike, da
70
294
  section.appendChild(row);
71
295
  }
72
296
  for (const tool of turn.tools) {
73
- const card = el(doc, "div", "cockpit-transcript-tool", tool.name);
74
- card.setAttribute("data-tool", tool.name);
75
- card.setAttribute("data-offset", String(tool.offset));
76
- card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error");
77
- section.appendChild(card);
297
+ section.appendChild(renderTool(doc, tool));
298
+ }
299
+ for (const perm of turn.permissions) {
300
+ section.appendChild(renderPermission(doc, perm, options));
78
301
  }
79
302
  root.appendChild(section);
80
303
  }
@@ -0,0 +1,288 @@
1
+ // The escalation-bridge acceptance tests (issue #559, ADR 0056). They drive the REAL in-repo operator
2
+ // path end to end with in-memory fakes:
3
+ // escalate-policy permission REQUEST → a Tasks-inbox row raised (the new ACP_PERMISSION_ELEMENT kind) →
4
+ // an operator Allow/Deny answered through the canonical `completeEscalationAsHuman` door →
5
+ // a permission RESOLUTION frame sent down the relay CONTROL lane with the matching callId/optionId/allowed.
6
+ // A focused unit test proves the exported `onPermissionResolve` adapter converges on the SAME frame, and
7
+ // a yolo/opt-out test proves the bridge is bypassed by default.
8
+ import { test } from "node:test";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import {
11
+ buildPermissionResolutionFrame,
12
+ completePermissionEscalationAsHuman,
13
+ createOnPermissionResolve,
14
+ optionKindAllows,
15
+ type PermissionBridgeDeps,
16
+ permissionEscalationEnabled,
17
+ permissionOptionAllows,
18
+ permissionQuestion,
19
+ permissionUserTaskRow,
20
+ } from "./permission-bridge.ts";
21
+ import { jobStream } from "./correlation.ts";
22
+ import {
23
+ type DerivedPermission,
24
+ parseTranscriptEvent,
25
+ type PermissionResolutionEvent,
26
+ } from "./transcript-events.ts";
27
+
28
+ // ── In-memory fakes (mirroring the shared patterns in app/agentCompletion.test.ts) ────────────────
29
+
30
+ /** A minimal in-memory `Table<T>` with AUTOINCREMENT ids on insert and structural find/get/delete. */
31
+ function memTable(rows: any[], key: string) {
32
+ let seq = rows.reduce((m, r) => Math.max(m, Number(r[key]) || 0), 0);
33
+ return {
34
+ insert: (row: any) => {
35
+ const id = ++seq;
36
+ const stored = key === "id" ? { ...row, id } : { ...row };
37
+ rows.push(stored);
38
+ return Promise.resolve(key === "id" ? id : stored[key]);
39
+ },
40
+ get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k)),
41
+ find: (q: any = {}) => Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
42
+ findOne: (q: any = {}) => Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
43
+ update: (k: any, patch: any) => {
44
+ const r = rows.find((x) => x[key] === k);
45
+ if (r) Object.assign(r, patch);
46
+ return Promise.resolve(r ? 1 : 0);
47
+ },
48
+ delete: (k: any) => {
49
+ const i = rows.findIndex((r) => r[key] === k);
50
+ if (i >= 0) rows.splice(i, 1);
51
+ return Promise.resolve(i >= 0 ? 1 : 0);
52
+ },
53
+ count: (q: any = {}) => Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
54
+ all: () => Promise.resolve(rows.slice()),
55
+ };
56
+ }
57
+
58
+ function memData(stores: Record<string, { rows: any[]; key: string }>) {
59
+ return {
60
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
61
+ } as any;
62
+ }
63
+
64
+ /** A stub engine that records every `completeUserTask` against a seeded set of open user tasks (the
65
+ * completer resolves via `openUserTasks`, CREATED only). */
66
+ function fakeEngine(openTasks: Array<{ userTaskKey: string; elementId?: string }>) {
67
+ const completed: Array<{ userTaskKey: string; variables?: Record<string, unknown> }> = [];
68
+ const engine = {
69
+ openUserTasks: () => Promise.resolve(openTasks),
70
+ searchUserTasks: () => Promise.reject(new Error("completer must resolve via openUserTasks")),
71
+ completeUserTask: (userTaskKey: string, variables?: Record<string, unknown>) => {
72
+ completed.push({ userTaskKey, variables });
73
+ return Promise.resolve();
74
+ },
75
+ } as any;
76
+ return { engine, completed };
77
+ }
78
+
79
+ /** A relay-send spy: records every frame the bridge emits. */
80
+ function sendSpy() {
81
+ const frames: any[] = [];
82
+ const deps: PermissionBridgeDeps = { send: (frame) => frames.push(frame) };
83
+ return { deps, frames };
84
+ }
85
+
86
+ /** Decode a produced resolution chunk back through the ONE parser (never re-parsing bytes by hand). */
87
+ function decodeResolution(frame: any): PermissionResolutionEvent {
88
+ const chunk = frame.payload.chunk as string;
89
+ const event = parseTranscriptEvent({ offset: 0, chunk });
90
+ assert(event.kind === "permission" && event.phase === "resolution", "expected a permission RESOLUTION");
91
+ return event;
92
+ }
93
+
94
+ /** An escalate-policy permission REQUEST offering Allow (allow-once) and Deny (reject-once). */
95
+ function escalateRequest(callId = "job-1"): DerivedPermission {
96
+ return {
97
+ callId,
98
+ policy: "escalate",
99
+ options: [
100
+ { optionId: "allow", name: "Allow", kind: "allow-once" },
101
+ { optionId: "deny", name: "Deny", kind: "reject-once" },
102
+ ],
103
+ toolName: "write_file",
104
+ title: "Write /etc/hosts",
105
+ reason: "The agent wants to modify a protected file.",
106
+ offset: 3,
107
+ };
108
+ }
109
+
110
+ // ── Acceptance: the full real-operator path, Allow and Deny ────────────────────────────────────────
111
+
112
+ test("escalate REQUEST → Tasks-inbox row → operator ALLOW via the completion door → RESOLUTION down the control lane", async () => {
113
+ const request = escalateRequest("job-allow");
114
+ const stream = jobStream(request.callId);
115
+
116
+ // 1. The request surfaces as an answerable Tasks-inbox row of the new permission kind.
117
+ const row = permissionUserTaskRow(request, { userTaskKey: "ut-perm-1", subjectKey: "hire-42" }, { enabled: true });
118
+ assert(row !== null, "an escalate-policy request must raise a row");
119
+ assertEquals(row.element_id, "acp-permission");
120
+ assertEquals(row.kind_label, "Agent permission");
121
+ assertEquals(row.subject_type, "agent");
122
+ assertEquals(row.user_task_key, "ut-perm-1");
123
+ assert(row.question?.includes("Write /etc/hosts"), "the row carries the request's title/reason as the question");
124
+ assert(row.question?.includes("modify a protected file"), "the row carries the request's reason");
125
+
126
+ // 2. The operator answers Allow through the ONE canonical completion door.
127
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
128
+ const data = memData(stores);
129
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-perm-1", elementId: "acp-permission" }]);
130
+ const { deps, frames } = sendSpy();
131
+
132
+ const result = await completePermissionEscalationAsHuman(data, engine, deps, {
133
+ permission: request,
134
+ userTaskKey: "ut-perm-1",
135
+ optionId: "allow",
136
+ operatorId: "op:ada",
137
+ });
138
+
139
+ // The completion took through the canonical door (recorded attribution + engine completion).
140
+ assertEquals(result.completion.ok, true);
141
+ assertEquals(result.completion.elementId, "acp-permission");
142
+ assertEquals(completed.length, 1);
143
+ assertEquals(completed[0].userTaskKey, "ut-perm-1");
144
+ assertEquals(completed[0].variables, { optionId: "allow", allowed: true });
145
+ assertEquals(stores.task_completions.rows.length, 1);
146
+ assertEquals(stores.task_completions.rows[0].actor_kind, "human");
147
+
148
+ // 3. A RESOLUTION frame flowed back down the relay CONTROL lane, releasing the blocked request.
149
+ assertEquals(frames.length, 1);
150
+ assertEquals(frames[0].lane, "control");
151
+ assertEquals(frames[0].family, "relay");
152
+ assertEquals(frames[0].payload.op, "produce");
153
+ assertEquals(frames[0].payload.stream, stream);
154
+ const resolution = decodeResolution(frames[0]);
155
+ assertEquals(resolution.callId, "job-allow");
156
+ assertEquals(resolution.optionId, "allow");
157
+ assertEquals(resolution.allowed, true);
158
+ assertEquals(resolution.by, "operator");
159
+ });
160
+
161
+ test("escalate REQUEST → operator DENY via the completion door → RESOLUTION allowed=false down the control lane", async () => {
162
+ const request = escalateRequest("job-deny");
163
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
164
+ const data = memData(stores);
165
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-perm-2", elementId: "acp-permission" }]);
166
+ const { deps, frames } = sendSpy();
167
+
168
+ const result = await completePermissionEscalationAsHuman(data, engine, deps, {
169
+ permission: request,
170
+ userTaskKey: "ut-perm-2",
171
+ optionId: "deny",
172
+ operatorId: "op:ada",
173
+ });
174
+
175
+ assertEquals(result.completion.ok, true);
176
+ assertEquals(completed[0].variables, { optionId: "deny", allowed: false });
177
+ assertEquals(frames.length, 1);
178
+ const resolution = decodeResolution(frames[0]);
179
+ assertEquals(resolution.callId, "job-deny");
180
+ assertEquals(resolution.optionId, "deny");
181
+ assertEquals(resolution.allowed, false);
182
+ assertEquals(resolution.by, "operator");
183
+ });
184
+
185
+ test("a failed completion (no open task) NEVER sends a resolution — the block is only released on a real answer", async () => {
186
+ const request = escalateRequest("job-missing");
187
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
188
+ const data = memData(stores);
189
+ const { engine } = fakeEngine([]); // no open task with this key
190
+ const { deps, frames } = sendSpy();
191
+
192
+ const result = await completePermissionEscalationAsHuman(data, engine, deps, {
193
+ permission: request,
194
+ userTaskKey: "ut-nope",
195
+ optionId: "allow",
196
+ operatorId: "op:ada",
197
+ });
198
+
199
+ assertEquals(result.completion.ok, false);
200
+ assertEquals(result.resolution, undefined);
201
+ assertEquals(frames.length, 0);
202
+ });
203
+
204
+ // ── Convergence: the onPermissionResolve adapter and the completion door build the SAME frame ───────
205
+
206
+ test("the exported onPermissionResolve adapter produces the SAME relay RESOLUTION as the completion door", async () => {
207
+ const request = escalateRequest("job-converge");
208
+ const stream = jobStream(request.callId);
209
+
210
+ // Path A: the completion-door path.
211
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
212
+ const data = memData(stores);
213
+ const { engine } = fakeEngine([{ userTaskKey: "ut-perm-3", elementId: "acp-permission" }]);
214
+ const doorSpy = sendSpy();
215
+ await completePermissionEscalationAsHuman(data, engine, doorSpy.deps, {
216
+ permission: request,
217
+ userTaskKey: "ut-perm-3",
218
+ optionId: "allow",
219
+ operatorId: "op:ada",
220
+ });
221
+
222
+ // Path B: the exported cockpit seam adapter, invoked with the cockpit-derived {callId, optionId, allowed}.
223
+ const seamSpy = sendSpy();
224
+ const onPermissionResolve = createOnPermissionResolve(seamSpy.deps);
225
+ onPermissionResolve({ callId: request.callId, optionId: "allow", allowed: true });
226
+
227
+ assertEquals(doorSpy.frames.length, 1);
228
+ assertEquals(seamSpy.frames.length, 1);
229
+ // Byte-identical frames: same lane, family, seq, and produce payload (stream + encoded chunk).
230
+ assertEquals(seamSpy.frames[0], doorSpy.frames[0]);
231
+ assertEquals(seamSpy.frames[0].payload.stream, stream);
232
+ // And they equal the pure builder's frame for the same decision + stream.
233
+ const pure = buildPermissionResolutionFrame(stream, { callId: request.callId, optionId: "allow", allowed: true, by: "operator" });
234
+ assertEquals(doorSpy.frames[0], pure.frame);
235
+ });
236
+
237
+ // ── yolo / opt-out: the bridge is bypassed by default ──────────────────────────────────────────────
238
+
239
+ test("a yolo-policy request NEVER raises a user task and never emits a bridge resolution", () => {
240
+ const yolo: DerivedPermission = {
241
+ callId: "job-yolo",
242
+ policy: "yolo",
243
+ options: [{ optionId: "allow", name: "Allow", kind: "allow-once" }],
244
+ offset: 1,
245
+ };
246
+ // No row for yolo, even when the bridge is enabled (yolo auto-allows elsewhere; the bridge is inert).
247
+ assertEquals(permissionUserTaskRow(yolo, { userTaskKey: "ut-y", subjectKey: "hire-1" }, { enabled: true }), null);
248
+ // No completion door is invoked for yolo, so no send edge fires — nothing to assert beyond the null row:
249
+ // the bridge only ever emits from `completePermissionEscalationAsHuman`/`createOnPermissionResolve`.
250
+ });
251
+
252
+ test("the bridge is opt-in: a disabled bridge raises no row even for an escalate-policy request", () => {
253
+ const request = escalateRequest("job-off");
254
+ assertEquals(permissionUserTaskRow(request, { userTaskKey: "ut-off", subjectKey: "hire-1" }, { enabled: false }), null);
255
+ });
256
+
257
+ test("permissionEscalationEnabled defaults OFF and honours the truthy forms of the master switch", () => {
258
+ assertEquals(permissionEscalationEnabled({}), false);
259
+ assertEquals(permissionEscalationEnabled({ NANO_WORKFORCE_PERMISSION_ESCALATION: "off" }), false);
260
+ assertEquals(permissionEscalationEnabled({ NANO_WORKFORCE_PERMISSION_ESCALATION: "false" }), false);
261
+ for (const on of ["1", "true", "on", "yes", "TRUE", "On"]) {
262
+ assertEquals(permissionEscalationEnabled({ NANO_WORKFORCE_PERMISSION_ESCALATION: on }), true, `expected ${on} to enable`);
263
+ }
264
+ });
265
+
266
+ // ── Pure derivations ────────────────────────────────────────────────────────────────────────────
267
+
268
+ test("optionKindAllows / permissionOptionAllows derive allow vs reject from the option kind (fail-closed)", () => {
269
+ assertEquals(optionKindAllows("allow-once"), true);
270
+ assertEquals(optionKindAllows("allow-always"), true);
271
+ assertEquals(optionKindAllows("reject-once"), false);
272
+ assertEquals(optionKindAllows("reject-always"), false);
273
+ const request = escalateRequest();
274
+ assertEquals(permissionOptionAllows(request, "allow"), true);
275
+ assertEquals(permissionOptionAllows(request, "deny"), false);
276
+ assertEquals(permissionOptionAllows(request, "unknown-option"), false); // fail-closed
277
+ });
278
+
279
+ test("permissionQuestion folds title + reason, falling back to the tool name", () => {
280
+ assertEquals(
281
+ permissionQuestion(escalateRequest()),
282
+ "Write /etc/hosts — The agent wants to modify a protected file.",
283
+ );
284
+ assertEquals(
285
+ permissionQuestion({ callId: "c", policy: "escalate", options: [], toolName: "run_shell", offset: 0 }),
286
+ "Permission requested for run_shell",
287
+ );
288
+ });