@syengup/friday-channel-next 0.1.39 → 1.0.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.
Files changed (43) hide show
  1. package/dist/index.js +59 -1
  2. package/dist/src/agent/subagent-registry.d.ts +4 -0
  3. package/dist/src/agent/subagent-registry.js +1 -1
  4. package/dist/src/approval/friday-approval-capability.d.ts +44 -0
  5. package/dist/src/approval/friday-approval-capability.js +174 -0
  6. package/dist/src/channel.js +22 -0
  7. package/dist/src/codex-reasoning-config.d.ts +11 -0
  8. package/dist/src/codex-reasoning-config.js +83 -0
  9. package/dist/src/friday-session.d.ts +4 -0
  10. package/dist/src/friday-session.js +59 -1
  11. package/dist/src/http/handlers/agents-list.js +5 -1
  12. package/dist/src/http/handlers/approvals.d.ts +9 -0
  13. package/dist/src/http/handlers/approvals.js +54 -0
  14. package/dist/src/http/handlers/messages.js +19 -1
  15. package/dist/src/http/server.js +6 -0
  16. package/dist/src/http 2/middleware/auth.d.ts +13 -0
  17. package/dist/src/http 2/middleware/auth.js +29 -0
  18. package/dist/src/http 2/middleware/body.d.ts +2 -0
  19. package/dist/src/http 2/middleware/body.js +24 -0
  20. package/dist/src/http 2/middleware/cors.d.ts +2 -0
  21. package/dist/src/http 2/middleware/cors.js +11 -0
  22. package/dist/src/sse/emitter.d.ts +1 -1
  23. package/index.ts +61 -0
  24. package/package.json +15 -14
  25. package/src/agent/subagent-registry.ts +3 -1
  26. package/src/approval/friday-approval-capability.test.ts +78 -0
  27. package/src/approval/friday-approval-capability.ts +227 -0
  28. package/src/channel.ts +25 -1
  29. package/src/codex-reasoning-config.test.ts +28 -0
  30. package/src/codex-reasoning-config.ts +82 -0
  31. package/src/e2e/subagent.e2e.test.ts +6 -0
  32. package/src/friday-session.forward-agent.test.ts +127 -0
  33. package/src/friday-session.ts +76 -1
  34. package/src/http/handlers/agents-list.test.ts +28 -0
  35. package/src/http/handlers/agents-list.ts +5 -1
  36. package/src/http/handlers/approvals.ts +61 -0
  37. package/src/http/handlers/messages.ts +23 -1
  38. package/src/http/server.ts +7 -0
  39. package/src/sse/emitter.ts +2 -1
  40. package/dist/src/health/self-health.d.ts +0 -39
  41. package/dist/src/health/self-health.js +0 -174
  42. package/dist/src/http/handlers/sessions-delete.d.ts +0 -2
  43. package/dist/src/http/handlers/sessions-delete.js +0 -49
@@ -0,0 +1,54 @@
1
+ import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
2
+ import { readJsonBody } from "../middleware/body.js";
3
+ import { extractBearerToken } from "../middleware/auth.js";
4
+ import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
5
+ import { getFridayNextRuntime } from "../../runtime.js";
6
+ import { createFridayNextLogger } from "../../logging.js";
7
+ const VALID_DECISIONS = new Set(["allow-once", "allow-always", "deny"]);
8
+ /**
9
+ * POST /friday-next/approvals/{approvalId}
10
+ * Body: { decision: "allow-once" | "allow-always" | "deny", deviceId?: string }
11
+ *
12
+ * Submits the app user's decision for a pending exec/plugin approval back to the gateway. The bearer
13
+ * token gates auth (the device owner is the approver); the gateway then resumes / aborts the run.
14
+ */
15
+ export async function handleApprovalDecision(req, res, approvalId) {
16
+ const log = createFridayNextLogger("approvals");
17
+ const json = (status, body) => {
18
+ res.statusCode = status;
19
+ res.setHeader("Content-Type", "application/json");
20
+ res.end(JSON.stringify(body));
21
+ return true;
22
+ };
23
+ if (req.method !== "POST")
24
+ return json(405, { error: "Method Not Allowed" });
25
+ if (!extractBearerToken(req))
26
+ return json(401, { error: "Unauthorized: bearer token mismatch" });
27
+ if (!approvalId.trim())
28
+ return json(400, { error: "Missing approvalId" });
29
+ const body = await readJsonBody(req);
30
+ if (!body)
31
+ return json(400, { error: "Invalid JSON body" });
32
+ const decision = typeof body.decision === "string" ? body.decision.trim() : "";
33
+ if (!VALID_DECISIONS.has(decision)) {
34
+ return json(400, { error: "decision must be allow-once | allow-always | deny" });
35
+ }
36
+ const deviceId = typeof body.deviceId === "string" ? body.deviceId.trim().toUpperCase() : "";
37
+ const cfg = getHostOpenClawConfigSnapshot(getFridayNextRuntime().config);
38
+ try {
39
+ await resolveApprovalOverGateway({
40
+ cfg: cfg,
41
+ approvalId: approvalId.trim(),
42
+ decision: decision,
43
+ senderId: deviceId || null,
44
+ allowPluginFallback: true,
45
+ clientDisplayName: deviceId ? `Friday Next (${deviceId})` : "Friday Next",
46
+ });
47
+ }
48
+ catch (err) {
49
+ log.error(`resolveApprovalOverGateway failed: ${err instanceof Error ? err.message : String(err)}`);
50
+ return json(502, { error: "Approval resolution failed", detail: String(err) });
51
+ }
52
+ log.info(`approval ${approvalId} resolved decision=${decision} device=${deviceId || "(none)"}`);
53
+ return json(200, { ok: true, approvalId: approvalId.trim(), decision });
54
+ }
@@ -17,7 +17,7 @@ import { resolveAgentDefaults, setSessionSettings, splitModelRef, toSessionStore
17
17
  import { sseEmitter } from "../../sse/emitter.js";
18
18
  import { extractBearerToken } from "../middleware/auth.js";
19
19
  import { readJsonBody } from "../middleware/body.js";
20
- import { registerFridaySessionDeviceMapping } from "../../friday-session.js";
20
+ import { forwardAgentEventRaw, isCodexRun, registerFridaySessionDeviceMapping, } from "../../friday-session.js";
21
21
  import { touchFridayInbound } from "../../friday-inbound-stats.js";
22
22
  import { fridayAttachmentLookupKey, fridayFilesPublicUrl, readFile, rememberInboundMediaName, resolveMediaAttachment, resolveMediaUrl, } from "./files.js";
23
23
  import { runFridayDispatch } from "../../agent/dispatch-bridge.js";
@@ -467,6 +467,12 @@ export async function handleMessages(req, res) {
467
467
  runId,
468
468
  suppressTyping: true,
469
469
  disableBlockStreaming: true,
470
+ // A1: feed the chosen thinking level into the run as a one-shot override so the model
471
+ // request asks for a reasoning summary. The session-stored `thinkingLevel` alone is NOT
472
+ // honored by the reply dispatch; `thinkingLevelOverride` has top priority in OpenClaw's
473
+ // resolution chain (get-reply-directives). Required for Codex (openai-chatgpt-responses)
474
+ // to emit any reasoning at all.
475
+ ...(thinkingLevel ? { thinkingLevelOverride: thinkingLevel } : {}),
470
476
  onModelSelected: (sel) => {
471
477
  const name = typeof sel.model === "string" ? sel.model.trim() : "";
472
478
  if (name) {
@@ -481,6 +487,18 @@ export async function handleMessages(req, res) {
481
487
  : undefined;
482
488
  const text = typeof rawText === "string" ? rawText : "";
483
489
  log("REASONING_STREAM", normalizedDeviceId, runId, `textLen=${text.length}`);
490
+ // A2: the embedded runner already emits `stream: "thinking"` on the agent-event bus, so
491
+ // forwarding here would double it. The Codex app-server backend does NOT — reasoning text
492
+ // only arrives via this callback (cumulative snapshot). Forward it as a thinking event
493
+ // (reusing forwardAgentEventRaw's cumulative→delta rewrite) ONLY for Codex runs.
494
+ if (text && isCodexRun(runId)) {
495
+ forwardAgentEventRaw({
496
+ runId,
497
+ stream: "thinking",
498
+ data: { text },
499
+ sessionKey: baseSessionKey,
500
+ });
501
+ }
484
502
  },
485
503
  onReasoningEnd: async () => {
486
504
  log("REASONING_STREAM_END", normalizedDeviceId, runId);
@@ -11,6 +11,7 @@ import { handleFilesDownload } from "./handlers/files-download.js";
11
11
  import { handleCancel } from "./handlers/cancel.js";
12
12
  import { handleDeviceApprove } from "./handlers/device-approve.js";
13
13
  import { handleNodesApprove } from "./handlers/nodes-approve.js";
14
+ import { handleApprovalDecision } from "./handlers/approvals.js";
14
15
  import { handleSessionsSettings } from "./handlers/sessions-settings.js";
15
16
  import { handleModelsList } from "./handlers/models-list.js";
16
17
  import { handleAgentsList } from "./handlers/agents-list.js";
@@ -65,6 +66,11 @@ async function handleFridayNextRoute(req, res) {
65
66
  if (req.method === "POST" && pathname === "/friday-next/nodes-approve") {
66
67
  return await handleNodesApprove(req, res);
67
68
  }
69
+ // Route: POST /friday-next/approvals/{approvalId} (submit exec/plugin approval decision)
70
+ if (req.method === "POST" && pathname.startsWith("/friday-next/approvals/")) {
71
+ const approvalId = decodeURIComponent(pathname.slice("/friday-next/approvals/".length));
72
+ return await handleApprovalDecision(req, res, approvalId);
73
+ }
68
74
  if ((req.method === "PUT" || req.method === "GET") &&
69
75
  pathname === "/friday-next/sessions/settings") {
70
76
  return await handleSessionsSettings(req, res);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Bearer token authentication middleware for Friday HTTP routes.
3
+ *
4
+ * Validates that the bearer token matches the gateway's configured auth token.
5
+ * This ensures plugin HTTP endpoints use the same token as gateway WS connections.
6
+ */
7
+ import type { IncomingMessage } from "node:http";
8
+ /**
9
+ * Extract and validate bearer token from Authorization header.
10
+ * Returns the token only if it matches the gateway's configured auth token.
11
+ * Returns null if token is missing, malformed, or doesn't match.
12
+ */
13
+ export declare function extractBearerToken(req: IncomingMessage): string | null;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Bearer token authentication middleware for Friday HTTP routes.
3
+ *
4
+ * Validates that the bearer token matches the gateway's configured auth token.
5
+ * This ensures plugin HTTP endpoints use the same token as gateway WS connections.
6
+ */
7
+ import { resolveFridayNextConfig } from "../../config.js";
8
+ import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
9
+ import { getFridayNextRuntime } from "../../runtime.js";
10
+ /**
11
+ * Extract and validate bearer token from Authorization header.
12
+ * Returns the token only if it matches the gateway's configured auth token.
13
+ * Returns null if token is missing, malformed, or doesn't match.
14
+ */
15
+ export function extractBearerToken(req) {
16
+ const auth = req.headers.authorization;
17
+ if (!auth || typeof auth !== "string")
18
+ return null;
19
+ const parts = auth.trim().split(/\s+/);
20
+ if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer")
21
+ return null;
22
+ const token = parts[1];
23
+ // Validate token matches the gateway's configured auth token.
24
+ const cfg = getHostOpenClawConfigSnapshot(getFridayNextRuntime().config);
25
+ const runtimeConfig = resolveFridayNextConfig(cfg);
26
+ if (!runtimeConfig.authToken || token !== runtimeConfig.authToken)
27
+ return null;
28
+ return token;
29
+ }
@@ -0,0 +1,2 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ export declare function readJsonBody(req: IncomingMessage, maxBytes?: number): Promise<Record<string, unknown> | null>;
@@ -0,0 +1,24 @@
1
+ export async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
2
+ return await new Promise((resolve) => {
3
+ const chunks = [];
4
+ let total = 0;
5
+ req.on("data", (chunk) => {
6
+ total += chunk.length;
7
+ if (total > maxBytes) {
8
+ resolve(null);
9
+ req.destroy();
10
+ return;
11
+ }
12
+ chunks.push(chunk);
13
+ });
14
+ req.on("end", () => {
15
+ try {
16
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8")));
17
+ }
18
+ catch {
19
+ resolve(null);
20
+ }
21
+ });
22
+ req.on("error", () => resolve(null));
23
+ });
24
+ }
@@ -0,0 +1,2 @@
1
+ import type { ServerResponse } from "node:http";
2
+ export declare function applyCorsHeaders(res: ServerResponse): void;
@@ -0,0 +1,11 @@
1
+ import { resolveFridayNextConfig } from "../../config.js";
2
+ import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
3
+ import { getFridayNextRuntime } from "../../runtime.js";
4
+ export function applyCorsHeaders(res) {
5
+ const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
6
+ if (!cfg.corsEnabled)
7
+ return;
8
+ res.setHeader("Access-Control-Allow-Origin", cfg.corsAllowOrigin || "*");
9
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Last-Event-ID");
10
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
11
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ServerResponse } from "node:http";
2
- export type SseEventType = "connected" | "agent" | "deliver" | "tool-hook" | "outbound" | "ping" | "subagent";
2
+ export type SseEventType = "connected" | "agent" | "deliver" | "tool-hook" | "outbound" | "ping" | "subagent" | "approval";
3
3
  export interface SseEvent {
4
4
  type: SseEventType;
5
5
  data: Record<string, unknown>;
package/index.ts CHANGED
@@ -15,6 +15,7 @@ import { sseEmitter } from "./src/sse/emitter.js";
15
15
  import {
16
16
  forwardAgentEventRaw,
17
17
  getLastRegisteredFridayDeviceId,
18
+ isCodexRun,
18
19
  resolveFridayDeviceIdForSessionKey,
19
20
  } from "./src/friday-session.js";
20
21
  import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
@@ -22,6 +23,7 @@ import { setUpgradeRuntime } from "./src/upgrade-runtime.js";
22
23
  import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
23
24
  import { accumulateRunUsage } from "./src/agent/run-usage-accumulator.js";
24
25
  import { createFridayNextLogger } from "./src/logging.js";
26
+ import { ensureCodexReasoningSummary } from "./src/codex-reasoning-config.js";
25
27
 
26
28
  const hookLogger = createFridayNextLogger("hook");
27
29
 
@@ -63,6 +65,38 @@ function isFridaySessionKey(sk: string): boolean {
63
65
  return /^friday-next-/i.test(sk) || /^agent:main:friday-next-/i.test(sk);
64
66
  }
65
67
 
68
+ /** Shell/exec-style tools whose stdout the app renders as a `command_output` row (A3). */
69
+ const COMMAND_TOOL_NAMES = new Set([
70
+ "exec",
71
+ "bash",
72
+ "shell",
73
+ "local_shell",
74
+ "command",
75
+ "process",
76
+ "run_terminal_cmd",
77
+ ]);
78
+
79
+ function isCommandTool(toolName: unknown): boolean {
80
+ return typeof toolName === "string" && COMMAND_TOOL_NAMES.has(toolName.trim().toLowerCase());
81
+ }
82
+
83
+ /** Best-effort flatten of an after-hook tool result into the stdout string the app expects. */
84
+ function coerceCommandOutput(result: unknown): string {
85
+ if (typeof result === "string") return result;
86
+ if (result && typeof result === "object") {
87
+ const r = result as Record<string, unknown>;
88
+ for (const key of ["output", "stdout", "text"]) {
89
+ if (typeof r[key] === "string") return r[key] as string;
90
+ }
91
+ try {
92
+ return JSON.stringify(result);
93
+ } catch {
94
+ return "";
95
+ }
96
+ }
97
+ return result == null ? "" : String(result);
98
+ }
99
+
66
100
  function shouldForwardToolEventToFriday(ctx: PluginHookToolContext): boolean {
67
101
  if (ctx.runId) {
68
102
  if (sseEmitter.getDeviceIdByRunId(ctx.runId)) return true;
@@ -134,6 +168,10 @@ export default defineChannelPluginEntry({
134
168
  }
135
169
  fridayNextToolHooksRegistered = true;
136
170
 
171
+ // Make Codex (ChatGPT/OAuth) models emit reasoning summary text so the app can stream
172
+ // "thinking". OpenClaw never sets this; we assert it on the plugin side. Best-effort.
173
+ ensureCodexReasoningSummary((msg) => hookLogger.info(msg));
174
+
137
175
  api.on("subagent_delivery_target", (event: any) => {
138
176
  if (!event.expectsCompletionMessage) return;
139
177
  const ch = event.requesterOrigin?.channel?.trim().toLowerCase();
@@ -219,6 +257,29 @@ export default defineChannelPluginEntry({
219
257
  ts: Date.now(),
220
258
  },
221
259
  });
260
+
261
+ // A3: the Codex app-server backend never puts exec stdout on the `command_output` stream
262
+ // (its tool result carries only exitCode/duration), so the app shows the command row with no
263
+ // output. The after-hook DOES carry the full stdout — synthesize a `command_output` end event
264
+ // keyed by toolCallId (== the forwarded `item kind:command` itemId) so the app attaches it.
265
+ // Codex-only: embedded runs already stream `command_output` on the bus.
266
+ if (isCodexRun(runId) && event.toolCallId && isCommandTool(event.toolName)) {
267
+ const output = coerceCommandOutput(event.result);
268
+ if (output) {
269
+ forwardAgentEventRaw({
270
+ runId,
271
+ stream: "command_output",
272
+ data: {
273
+ itemId: event.toolCallId,
274
+ phase: "end",
275
+ output,
276
+ status: event.error ? "failed" : "completed",
277
+ durationMs: event.durationMs ?? null,
278
+ },
279
+ sessionKey: ctx.sessionKey,
280
+ });
281
+ }
282
+ }
222
283
  });
223
284
  },
224
285
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.39",
3
+ "version": "1.0.0",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,19 @@
12
12
  "tsconfig.json",
13
13
  "openclaw.plugin.json"
14
14
  ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "lint": "eslint .",
18
+ "lint:fix": "eslint . --fix",
19
+ "format": "prettier --write .",
20
+ "format:check": "prettier --check .",
21
+ "prepublishOnly": "pnpm build && rm -rf dist/attachments",
22
+ "test": "npm run test:unit && npm run test:e2e",
23
+ "test:unit": "vitest run",
24
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
25
+ "test:smoke": "node scripts/e2e-smoke.mjs",
26
+ "test:msg-live": "node scripts/message-roundtrip-live.mjs"
27
+ },
15
28
  "bin": {
16
29
  "friday-channel-next": "install.js"
17
30
  },
@@ -62,17 +75,5 @@
62
75
  "typescript-eslint": "^8.61.1",
63
76
  "vitest": "^4.1.5",
64
77
  "zod": "^4.3.6"
65
- },
66
- "scripts": {
67
- "build": "tsc -p tsconfig.json",
68
- "lint": "eslint .",
69
- "lint:fix": "eslint . --fix",
70
- "format": "prettier --write .",
71
- "format:check": "prettier --check .",
72
- "test": "npm run test:unit && npm run test:e2e",
73
- "test:unit": "vitest run",
74
- "test:e2e": "vitest run --config vitest.e2e.config.ts",
75
- "test:smoke": "node scripts/e2e-smoke.mjs",
76
- "test:msg-live": "node scripts/message-roundtrip-live.mjs"
77
78
  }
78
- }
79
+ }
@@ -48,7 +48,9 @@ export function registerSessionKeyForRun(sessionKey: string, runId: string): voi
48
48
  */
49
49
  const ANNOUNCE_RUN_ID_RE = /^announce:v\d+:(agent:.+?):([^:]+)$/;
50
50
 
51
- function parseAnnounceRunId(runId: string): { childSessionKey: string; bareRunId: string } | null {
51
+ export function parseAnnounceRunId(
52
+ runId: string,
53
+ ): { childSessionKey: string; bareRunId: string } | null {
52
54
  const m = runId.match(ANNOUNCE_RUN_ID_RE);
53
55
  if (!m) return null;
54
56
  return { childSessionKey: m[1] ?? "", bareRunId: m[2] ?? "" };
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildPayload } from "./friday-approval-capability.js";
3
+
4
+ const execView = {
5
+ approvalId: "exec:abc",
6
+ approvalKind: "exec",
7
+ title: "Codex command approval",
8
+ commandText: "curl -sS https://example.com",
9
+ commandPreview: "curl ...",
10
+ cwd: "/ws",
11
+ host: "sandbox",
12
+ metadata: [{ label: "Severity", value: "Warning" }],
13
+ actions: [
14
+ { decision: "allow-once", label: "Allow Once", style: "primary" },
15
+ { decision: "deny", label: "Deny", style: "danger" },
16
+ ],
17
+ expiresAtMs: 123,
18
+ };
19
+
20
+ const pluginView = {
21
+ approvalId: "plugin:xyz",
22
+ approvalKind: "plugin",
23
+ title: "Codex app-server command approval",
24
+ description: "Command: curl ...",
25
+ toolName: "codex_command_approval",
26
+ severity: "warning",
27
+ metadata: [{ label: "Tool", value: "codex_command_approval" }],
28
+ actions: [{ decision: "allow-always", label: "Allow Always", style: "secondary" }],
29
+ expiresAtMs: 456,
30
+ };
31
+
32
+ const reqWith = (sessionKey: string) => ({ request: { sessionKey } });
33
+
34
+ describe("buildPayload", () => {
35
+ it("maps an exec approval view (command/cwd/host + actions)", () => {
36
+ const p = buildPayload({
37
+ op: "request",
38
+ view: execView,
39
+ request: reqWith("agent:main:fridaynext:s1"),
40
+ deviceId: "DEV1",
41
+ });
42
+ expect(p.op).toBe("request");
43
+ expect(p.kind).toBe("exec");
44
+ expect(p.approvalId).toBe("exec:abc");
45
+ expect(p.commandText).toBe("curl -sS https://example.com");
46
+ expect(p.cwd).toBe("/ws");
47
+ expect(p.host).toBe("sandbox");
48
+ expect(p.actions.map((a) => a.decision)).toEqual(["allow-once", "deny"]);
49
+ expect(p.sessionKey).toBe("agent:main:fridaynext:s1");
50
+ expect(p.deviceId).toBe("DEV1");
51
+ expect(p.expiresAtMs).toBe(123);
52
+ });
53
+
54
+ it("maps a plugin approval view (toolName/severity/description)", () => {
55
+ const p = buildPayload({
56
+ op: "request",
57
+ view: pluginView,
58
+ request: reqWith("agent:main:fridaynext:s2"),
59
+ deviceId: "DEV2",
60
+ });
61
+ expect(p.kind).toBe("plugin");
62
+ expect(p.toolName).toBe("codex_command_approval");
63
+ expect(p.severity).toBe("warning");
64
+ expect(p.description).toBe("Command: curl ...");
65
+ expect(p.actions[0].decision).toBe("allow-always");
66
+ expect(p.commandText).toBeNull();
67
+ });
68
+
69
+ it("defaults missing fields without throwing", () => {
70
+ const p = buildPayload({ op: "expired", view: {}, request: {}, deviceId: "D" });
71
+ expect(p.op).toBe("expired");
72
+ expect(p.kind).toBe("exec");
73
+ expect(p.approvalId).toBe("");
74
+ expect(p.actions).toEqual([]);
75
+ expect(p.metadata).toEqual([]);
76
+ expect(p.sessionKey).toBeNull();
77
+ });
78
+ });