@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,227 @@
1
+ // Friday Next exec/plugin approval capability.
2
+ //
3
+ // Lets the Friday app receive tool-execution approval REQUESTS (e.g. a Codex model wanting to run a
4
+ // shell command that needs confirmation) and submit allow/deny DECISIONS — instead of those
5
+ // approvals only reaching the gateway's built-in ControlUI.
6
+ //
7
+ // Model: unlike Slack (a separate approver list authorized per-account), friday-next uses a
8
+ // device-owner model — the device that owns the originating session is the approver. HTTP requests
9
+ // already carry the channel bearer token, so per-sender authorization happens at the route layer;
10
+ // here we only resolve WHICH device a request belongs to (its session's device) and deliver the
11
+ // prompt there over SSE. The decision round-trips via POST /friday-next/approvals/{id}.
12
+ //
13
+ // We intentionally do NOT set a `delivery.shouldSuppressForwardingFallback` adapter, so enabling
14
+ // this stays additive: ControlUI keeps working as a fallback while the app surface is the primary.
15
+
16
+ import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
17
+ import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
18
+ import { sseEmitter } from "../sse/emitter.js";
19
+ import { resolveFridayDeviceIdForSessionKey } from "../friday-session.js";
20
+ import { createFridayNextLogger } from "../logging.js";
21
+
22
+ const logger = createFridayNextLogger("approval");
23
+
24
+ /** SSE payload the app receives for an approval lifecycle event. `op` is the phase. */
25
+ export interface FridayApprovalPayload {
26
+ op: "request" | "resolved" | "expired";
27
+ approvalId: string;
28
+ kind: "exec" | "plugin";
29
+ title: string;
30
+ description?: string | null;
31
+ // exec
32
+ commandText?: string | null;
33
+ commandPreview?: string | null;
34
+ cwd?: string | null;
35
+ host?: string | null;
36
+ // plugin
37
+ toolName?: string | null;
38
+ severity?: string | null;
39
+ metadata: { label: string; value: string }[];
40
+ actions: { decision: string; label: string; style: string }[];
41
+ expiresAtMs?: number | null;
42
+ decision?: string | null;
43
+ resolvedBy?: string | null;
44
+ sessionKey?: string | null;
45
+ runId?: string | null;
46
+ deviceId: string;
47
+ ts: number;
48
+ }
49
+
50
+ interface PreparedTarget {
51
+ deviceId: string;
52
+ }
53
+ interface PendingEntry {
54
+ deviceId: string;
55
+ approvalId: string;
56
+ }
57
+
58
+ /** Pull the originating sessionKey out of an exec/plugin approval request (`request.request.*`). */
59
+ function sessionKeyOf(request: unknown): string | undefined {
60
+ const inner = (request as { request?: { sessionKey?: unknown } } | undefined)?.request;
61
+ const sk = inner?.sessionKey;
62
+ return typeof sk === "string" && sk.trim() ? sk.trim() : undefined;
63
+ }
64
+
65
+ /** Resolve the friday device that owns this approval's session, if any. */
66
+ function deviceForRequest(request: unknown): string | undefined {
67
+ const sk = sessionKeyOf(request);
68
+ if (!sk) return undefined;
69
+ const dev = resolveFridayDeviceIdForSessionKey(sk);
70
+ return dev ? dev.toUpperCase() : undefined;
71
+ }
72
+
73
+ /** Build the normalized app payload from a pending/resolved/expired approval view. */
74
+ export function buildPayload(params: {
75
+ op: FridayApprovalPayload["op"];
76
+ view: Record<string, unknown>;
77
+ request: unknown;
78
+ deviceId: string;
79
+ }): FridayApprovalPayload {
80
+ const { op, view, request, deviceId } = params;
81
+ const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
82
+ const num = (v: unknown): number | null => (typeof v === "number" ? v : null);
83
+ const actionsRaw = Array.isArray(view.actions) ? (view.actions as Record<string, unknown>[]) : [];
84
+ const metaRaw = Array.isArray(view.metadata) ? (view.metadata as Record<string, unknown>[]) : [];
85
+ return {
86
+ op,
87
+ approvalId: str(view.approvalId) ?? "",
88
+ kind: view.approvalKind === "plugin" ? "plugin" : "exec",
89
+ title: str(view.title) ?? "",
90
+ description: str(view.description),
91
+ commandText: str(view.commandText),
92
+ commandPreview: str(view.commandPreview),
93
+ cwd: str(view.cwd),
94
+ host: str(view.host),
95
+ toolName: str(view.toolName),
96
+ severity: str(view.severity),
97
+ metadata: metaRaw.map((m) => ({ label: str(m.label) ?? "", value: str(m.value) ?? "" })),
98
+ actions: actionsRaw.map((a) => ({
99
+ decision: str(a.decision) ?? "",
100
+ label: str(a.label) ?? "",
101
+ style: str(a.style) ?? "secondary",
102
+ })),
103
+ expiresAtMs: num(view.expiresAtMs),
104
+ decision: str(view.decision),
105
+ resolvedBy: str(view.resolvedBy),
106
+ sessionKey: sessionKeyOf(request) ?? null,
107
+ runId: sseEmitter.getLastRunIdForDevice(deviceId),
108
+ deviceId,
109
+ ts: Date.now(),
110
+ };
111
+ }
112
+
113
+ function emitApproval(deviceId: string, payload: FridayApprovalPayload): void {
114
+ sseEmitter.broadcast({ type: "approval", data: { ...payload } }, deviceId, true);
115
+ }
116
+
117
+ const fridayApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
118
+ FridayApprovalPayload,
119
+ PreparedTarget,
120
+ PendingEntry,
121
+ never,
122
+ FridayApprovalPayload
123
+ >({
124
+ eventKinds: ["exec", "plugin"],
125
+ availability: {
126
+ isConfigured: () => true,
127
+ shouldHandle: ({ request }) => deviceForRequest(request) !== undefined,
128
+ },
129
+ presentation: {
130
+ buildPendingPayload: ({ request, view }) => {
131
+ const deviceId = deviceForRequest(request) ?? "";
132
+ return buildPayload({
133
+ op: "request",
134
+ view: view as unknown as Record<string, unknown>,
135
+ request,
136
+ deviceId,
137
+ });
138
+ },
139
+ buildResolvedResult: ({ request, view }) => {
140
+ const deviceId = deviceForRequest(request) ?? "";
141
+ return {
142
+ kind: "update",
143
+ payload: buildPayload({
144
+ op: "resolved",
145
+ view: view as unknown as Record<string, unknown>,
146
+ request,
147
+ deviceId,
148
+ }),
149
+ };
150
+ },
151
+ buildExpiredResult: ({ request, view }) => {
152
+ const deviceId = deviceForRequest(request) ?? "";
153
+ return {
154
+ kind: "update",
155
+ payload: buildPayload({
156
+ op: "expired",
157
+ view: view as unknown as Record<string, unknown>,
158
+ request,
159
+ deviceId,
160
+ }),
161
+ };
162
+ },
163
+ },
164
+ transport: {
165
+ prepareTarget: ({ plannedTarget, request }) => {
166
+ const planned =
167
+ typeof plannedTarget?.target?.to === "string" && plannedTarget.target.to.trim()
168
+ ? plannedTarget.target.to.trim().toUpperCase()
169
+ : undefined;
170
+ const deviceId = planned ?? deviceForRequest(request);
171
+ if (!deviceId) return null;
172
+ return { dedupeKey: `friday-approval:${deviceId}`, target: { deviceId } };
173
+ },
174
+ deliverPending: ({ preparedTarget, pendingPayload }) => {
175
+ const deviceId = preparedTarget.deviceId;
176
+ logger.info(`deliver approval ${pendingPayload.approvalId} kind=${pendingPayload.kind} -> ${deviceId}`);
177
+ emitApproval(deviceId, { ...pendingPayload, deviceId });
178
+ return { deviceId, approvalId: pendingPayload.approvalId };
179
+ },
180
+ updateEntry: async ({ entry, payload }) => {
181
+ emitApproval(entry.deviceId, { ...payload, deviceId: entry.deviceId });
182
+ },
183
+ deleteEntry: async ({ entry, phase }) => {
184
+ emitApproval(entry.deviceId, {
185
+ op: phase === "resolved" ? "resolved" : "expired",
186
+ approvalId: entry.approvalId,
187
+ kind: "exec",
188
+ title: "",
189
+ metadata: [],
190
+ actions: [],
191
+ deviceId: entry.deviceId,
192
+ ts: Date.now(),
193
+ });
194
+ },
195
+ },
196
+ observe: {
197
+ onDeliveryError: ({ error }) => {
198
+ logger.warn(`approval delivery failed: ${String(error)}`);
199
+ },
200
+ },
201
+ });
202
+
203
+ /**
204
+ * friday-next approval capability. `native` declares delivery to the originating device's session;
205
+ * `nativeRuntime` builds the app payload and ferries it over SSE. No `delivery` suppressor → additive
206
+ * with ControlUI.
207
+ */
208
+ export const fridayApprovalCapability: ChannelApprovalCapability = {
209
+ native: {
210
+ describeDeliveryCapabilities: ({ request }) => {
211
+ const enabled = deviceForRequest(request) !== undefined;
212
+ return {
213
+ enabled,
214
+ preferredSurface: "origin",
215
+ supportsOriginSurface: true,
216
+ supportsApproverDmSurface: false,
217
+ };
218
+ },
219
+ resolveOriginTarget: ({ request }) => {
220
+ const deviceId = deviceForRequest(request);
221
+ return deviceId ? { to: deviceId } : null;
222
+ },
223
+ },
224
+ // Cast widens the parameterized adapter to the field's `unknown`-typed shape (function-param
225
+ // contravariance). Same escape hatch Slack uses for its lazy runtime adapter.
226
+ nativeRuntime: fridayApprovalNativeRuntime as unknown as ChannelApprovalCapability["nativeRuntime"],
227
+ };
package/src/channel.ts CHANGED
@@ -10,6 +10,9 @@ import os from "node:os";
10
10
  import path from "node:path";
11
11
  import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
12
12
  import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
13
+ import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
14
+ import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
15
+ import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
13
16
  import { createFridayNextLogger } from "./logging.js";
14
17
  import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
15
18
  import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
@@ -24,6 +27,7 @@ import {
24
27
  } from "./friday-session.js";
25
28
  import { getRunRoute } from "./run-metadata.js";
26
29
  import { getLastFridayInboundAt } from "./friday-inbound-stats.js";
30
+ import { fridayApprovalCapability } from "./approval/friday-approval-capability.js";
27
31
 
28
32
  const logger = createFridayNextLogger("channel");
29
33
  const CHANNEL_ID = "friday-next" as const;
@@ -113,7 +117,22 @@ const fridayLifecycle = {
113
117
  * (reload/shutdown) so the channel stays `running:true` and continuously deliverable.
114
118
  */
115
119
  const fridayGateway = {
116
- startAccount: async (ctx: { abortSignal: AbortSignal }): Promise<void> => {
120
+ startAccount: async (ctx: ChannelGatewayContext): Promise<void> => {
121
+ // Activate exec/plugin approval delivery to the app. The gateway's approval-handler bootstrap
122
+ // only wires up our `approvalCapability` once the channel registers an "approval.native" runtime
123
+ // context (the registration event is the gate — without it approvals silently skip friday-next
124
+ // and only reach ControlUI). friday-next's nativeRuntime needs no per-account state — it resolves
125
+ // the target device from each request's sessionKey via global singletons — so context is empty.
126
+ if (ctx.channelRuntime) {
127
+ registerChannelRuntimeContext({
128
+ channelRuntime: ctx.channelRuntime,
129
+ channelId: CHANNEL_ID,
130
+ accountId: ctx.accountId,
131
+ capability: CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY,
132
+ context: {},
133
+ abortSignal: ctx.abortSignal,
134
+ });
135
+ }
117
136
  await waitUntilAbort(ctx.abortSignal);
118
137
  },
119
138
  };
@@ -342,3 +361,8 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
342
361
  },
343
362
  },
344
363
  });
364
+
365
+ // Attach exec/plugin approval delivery to the app. `createChatChannelPlugin` has no config slot for
366
+ // it, so it's set on the returned plugin object; setting it auto-registers the native approval
367
+ // handler via the gateway's approval bootstrap. Additive with ControlUI (no forwarding suppressor).
368
+ fridayNextChannelPlugin.approvalCapability = fridayApprovalCapability;
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hasTopLevelSummaryKey } from "./codex-reasoning-config.js";
3
+
4
+ describe("hasTopLevelSummaryKey", () => {
5
+ it("returns true when the key is a top-level entry", () => {
6
+ expect(hasTopLevelSummaryKey('model_reasoning_summary = "detailed"\n')).toBe(true);
7
+ expect(
8
+ hasTopLevelSummaryKey('model_reasoning_summary = "auto"\n\n[projects."/x"]\ntrust_level = "trusted"\n'),
9
+ ).toBe(true);
10
+ });
11
+
12
+ it("returns false when the file has no key", () => {
13
+ expect(hasTopLevelSummaryKey("")).toBe(false);
14
+ expect(hasTopLevelSummaryKey('[projects."/x"]\ntrust_level = "trusted"\n')).toBe(false);
15
+ });
16
+
17
+ it("treats a key nested under a [section] as NOT top-level (TOML scoping)", () => {
18
+ // This is the trap: appended after a table header the key belongs to that table, so Codex
19
+ // ignores it. Must be reported as absent so the caller prepends a real top-level key.
20
+ const nested = '[projects."/x"]\ntrust_level = "trusted"\nmodel_reasoning_summary = "detailed"\n';
21
+ expect(hasTopLevelSummaryKey(nested)).toBe(false);
22
+ });
23
+
24
+ it("ignores commented or partial matches", () => {
25
+ expect(hasTopLevelSummaryKey('# model_reasoning_summary = "detailed"\n')).toBe(false);
26
+ expect(hasTopLevelSummaryKey("model_reasoning_summary_extra = 1\n")).toBe(false);
27
+ });
28
+ });
@@ -0,0 +1,82 @@
1
+ // Ensures the Codex app-server backend emits reasoning *summary* text so Friday can stream it.
2
+ //
3
+ // Background: OpenAI models authenticated via ChatGPT/OAuth run through OpenClaw's Codex
4
+ // app-server backend. That backend sends `reasoning_effort` per turn but never requests a
5
+ // reasoning summary, and OpenClaw exposes no `openclaw.json` lever for it. Without a summary the
6
+ // model's reasoning stays encrypted (`encrypted_content`) and no reasoning text reaches the
7
+ // channel — so the Friday app shows no streaming "thinking" for Codex models.
8
+ //
9
+ // The only switch that makes Codex return summary text is the Codex CLI's own
10
+ // `model_reasoning_summary` key in `~/.openclaw/agents/<id>/agent/codex-home/config.toml`.
11
+ // We keep the fix on the plugin side by asserting that key on activation (idempotently, for every
12
+ // agent that has a codex-home), so it survives OpenClaw rewrites of that file across restarts.
13
+
14
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ const CONFIG_KEY = "model_reasoning_summary";
19
+ // "detailed" is the value verified end-to-end (reasoning streamed to the app). Codex also accepts
20
+ // "auto"/"concise"; tune here if the summaries feel too verbose.
21
+ const SUMMARY_VALUE = "detailed";
22
+
23
+ function resolveOpenClawHome(): string {
24
+ const env = process.env.OPENCLAW_HOME?.trim();
25
+ return env && env.length > 0 ? env : join(homedir(), ".openclaw");
26
+ }
27
+
28
+ /**
29
+ * True if a top-level `model_reasoning_summary` key already exists. TOML scoping matters: a key is
30
+ * only top-level (and thus honored by Codex) if it appears before the first `[section]` header, so
31
+ * we stop scanning at the first table header.
32
+ */
33
+ export function hasTopLevelSummaryKey(content: string): boolean {
34
+ for (const raw of content.split(/\r?\n/)) {
35
+ const line = raw.trim();
36
+ if (line.startsWith("[")) break;
37
+ if (new RegExp(`^${CONFIG_KEY}\\s*=`).test(line)) return true;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ function ensureKeyInCodexHome(codexHome: string): "added" | "present" | "skip" {
43
+ const configPath = join(codexHome, "config.toml");
44
+ const header = `${CONFIG_KEY} = "${SUMMARY_VALUE}"\n`;
45
+ if (!existsSync(configPath)) {
46
+ writeFileSync(configPath, header, "utf8");
47
+ return "added";
48
+ }
49
+ const content = readFileSync(configPath, "utf8");
50
+ if (hasTopLevelSummaryKey(content)) return "present";
51
+ // Prepend so the key stays top-level even if the file starts with a `[section]` table.
52
+ writeFileSync(configPath, `${header}\n${content}`, "utf8");
53
+ return "added";
54
+ }
55
+
56
+ /**
57
+ * Best-effort: ensure every agent's Codex config requests a reasoning summary. Never throws —
58
+ * activation must not fail because of a config write. `log` receives a one-line summary per change.
59
+ */
60
+ export function ensureCodexReasoningSummary(log: (msg: string) => void): void {
61
+ try {
62
+ const agentsDir = join(resolveOpenClawHome(), "agents");
63
+ if (!existsSync(agentsDir)) return;
64
+ for (const agentId of readdirSync(agentsDir)) {
65
+ const codexHome = join(agentsDir, agentId, "agent", "codex-home");
66
+ // Only touch agents Codex has actually initialized (codex-home exists). New agents are
67
+ // picked up on the next activation/restart.
68
+ if (!existsSync(codexHome)) continue;
69
+ try {
70
+ mkdirSync(codexHome, { recursive: true });
71
+ const result = ensureKeyInCodexHome(codexHome);
72
+ if (result === "added") {
73
+ log(`codex reasoning summary enabled (agent=${agentId})`);
74
+ }
75
+ } catch (err) {
76
+ log(`codex reasoning summary write failed (agent=${agentId}): ${String(err)}`);
77
+ }
78
+ }
79
+ } catch (err) {
80
+ log(`codex reasoning summary ensure failed: ${String(err)}`);
81
+ }
82
+ }
@@ -295,6 +295,9 @@ describe("subagent via sessions_spawn tool", () => {
295
295
  label: "cr",
296
296
  parentRunId: mainRunId,
297
297
  depth: 1,
298
+ // A1: annotation now ships stable identity (childSessionKey) + lifecycle status.
299
+ childSessionKey: childKey,
300
+ status: "running",
298
301
  });
299
302
  });
300
303
 
@@ -524,6 +527,9 @@ describe("subagent via sessions_spawn tool", () => {
524
527
  label: "reviewer",
525
528
  parentRunId: mainRunId,
526
529
  depth: 1,
530
+ // A1: annotation now ships stable identity (childSessionKey) + lifecycle status.
531
+ childSessionKey: childKeyA,
532
+ status: "running",
527
533
  });
528
534
 
529
535
  // sessions_spawn for B (nested from A's tool call — but parentRunId should come from the context)
@@ -16,6 +16,10 @@ import {
16
16
  } from "./agent/run-usage-accumulator.js";
17
17
  import { sseEmitter } from "./sse/emitter.js";
18
18
  import { toSessionStoreKey } from "./session/session-manager.js";
19
+ import {
20
+ ensureSubagentFromSpawnTool,
21
+ resetForTest as resetSubagentRegistryForTest,
22
+ } from "./agent/subagent-registry.js";
19
23
 
20
24
  describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
21
25
  const runId = "run-thinking-test";
@@ -395,3 +399,126 @@ function commonPrefixLen(a: string, b: string): number {
395
399
  while (i < len && a.charCodeAt(i) === b.charCodeAt(i)) i++;
396
400
  return i;
397
401
  }
402
+
403
+ // P1 of the subagent streaming redo (see subagent-streaming-redo-plan.md):
404
+ // the plugin ships authoritative correlation keys so the app stops self-deriving identity.
405
+ describe("forwardAgentEventRaw (subagent stable-identity fields: A1/A2/A3)", () => {
406
+ const sessionKey = "agent:main:friday-session-test";
407
+ const deviceId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE";
408
+ const childKey = "agent:main:subagent:abc";
409
+
410
+ type SubagentBroadcast = { type: string; data: Record<string, unknown> };
411
+
412
+ function subagentBroadcasts(phase: string): Record<string, unknown>[] {
413
+ return (sseEmitter.broadcast as ReturnType<typeof vi.fn>).mock.calls
414
+ .map((c) => c[0] as SubagentBroadcast)
415
+ .filter((m) => m.type === "subagent" && m.data.phase === phase)
416
+ .map((m) => m.data);
417
+ }
418
+
419
+ beforeEach(() => {
420
+ sseEmitter.resetForTest();
421
+ resetThinkingStreamAccumStateForTest();
422
+ resetOpenClawRunDeviceMappingForTest();
423
+ resetSubagentRegistryForTest();
424
+ registerFridaySessionDeviceMapping(sessionKey, deviceId);
425
+ vi.spyOn(sseEmitter, "broadcastToRun").mockImplementation(() => {});
426
+ vi.spyOn(sseEmitter, "broadcast").mockImplementation(() => {});
427
+ });
428
+
429
+ afterEach(() => {
430
+ vi.restoreAllMocks();
431
+ });
432
+
433
+ // A2: spawning carries the spawn tool-call id as a stable correlation key.
434
+ it("spawning event carries the spawn toolCallId", () => {
435
+ forwardAgentEventRaw({
436
+ runId: "parent-run",
437
+ seq: 1,
438
+ stream: "tool",
439
+ sessionKey,
440
+ data: {
441
+ name: "sessions_spawn",
442
+ phase: "start",
443
+ toolCallId: "tc-1",
444
+ args: { taskName: "weather" },
445
+ },
446
+ });
447
+
448
+ const spawning = subagentBroadcasts("spawning");
449
+ expect(spawning).toHaveLength(1);
450
+ expect(spawning[0].toolCallId).toBe("tc-1");
451
+ expect(spawning[0].childSessionKey).toBeNull();
452
+ expect(spawning[0].runId).toBeNull();
453
+ expect(spawning[0].label).toBe("weather");
454
+ });
455
+
456
+ // A1: a subagent's own agent-delta is annotated with childSessionKey + status.
457
+ it("subagent agent-delta annotation includes childSessionKey and status", () => {
458
+ ensureSubagentFromSpawnTool({
459
+ childSessionKey: childKey,
460
+ bareRunId: "sub-bare",
461
+ label: "weather",
462
+ deviceId,
463
+ parentRunId: "parent-run",
464
+ requesterSessionKey: sessionKey,
465
+ });
466
+
467
+ forwardAgentEventRaw({
468
+ runId: "sub-bare",
469
+ seq: 1,
470
+ stream: "thinking",
471
+ sessionKey: childKey, // subagent's own event → sessionKey === childSessionKey
472
+ data: { text: "looking up", delta: "looking up" },
473
+ });
474
+
475
+ expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
476
+ const payload = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data;
477
+ const subagent = payload.subagent as Record<string, unknown>;
478
+ expect(subagent).toBeDefined();
479
+ expect(subagent.childSessionKey).toBe(childKey);
480
+ expect(subagent.status).toBe("running");
481
+ expect(subagent.label).toBe("weather");
482
+ expect(subagent.parentRunId).toBe("parent-run");
483
+ });
484
+
485
+ // A3: the parent's announce-summary lifecycle.start emits an explicit dismiss keyed by
486
+ // childSessionKey — replacing the app's fragile announce-runId string parsing.
487
+ it("announce-summary lifecycle.start emits a dismissed subagent event by childSessionKey", () => {
488
+ ensureSubagentFromSpawnTool({
489
+ childSessionKey: childKey,
490
+ bareRunId: "sub-bare",
491
+ label: "weather",
492
+ deviceId,
493
+ parentRunId: "parent-run",
494
+ requesterSessionKey: sessionKey,
495
+ });
496
+
497
+ const announceRunId = `announce:v1:${childKey}:sub-bare`;
498
+ forwardAgentEventRaw({
499
+ runId: announceRunId,
500
+ seq: 1,
501
+ stream: "lifecycle",
502
+ sessionKey, // parent's sessionKey, not the child's
503
+ data: { phase: "start" },
504
+ });
505
+
506
+ const dismissed = subagentBroadcasts("dismissed");
507
+ expect(dismissed).toHaveLength(1);
508
+ expect(dismissed[0].childSessionKey).toBe(childKey);
509
+ expect(dismissed[0].runId).toBe("sub-bare");
510
+ expect(dismissed[0].parentRunId).toBe("parent-run");
511
+ });
512
+
513
+ // A3 must not fire on ordinary (non-announce) lifecycle.start frames.
514
+ it("does not emit dismissed for a normal lifecycle.start", () => {
515
+ forwardAgentEventRaw({
516
+ runId: "parent-run",
517
+ seq: 1,
518
+ stream: "lifecycle",
519
+ sessionKey,
520
+ data: { phase: "start" },
521
+ });
522
+ expect(subagentBroadcasts("dismissed")).toHaveLength(0);
523
+ });
524
+ });
@@ -9,6 +9,8 @@ import type { FridaySessionUsagePayload } from "./session-usage-snapshot.js";
9
9
  import { readSessionUsageSnapshotFromStore } from "./session-usage-store.js";
10
10
  import {
11
11
  lookupByRunId,
12
+ lookupByChildSessionKey,
13
+ parseAnnounceRunId,
12
14
  registerSessionKeyForRun,
13
15
  registerSpawnIntent,
14
16
  consumeSpawnIntent,
@@ -31,6 +33,27 @@ export function resetThinkingStreamAccumStateForTest(): void {
31
33
  lastThinkingTextByRun.clear();
32
34
  }
33
35
 
36
+ /**
37
+ * Runs backed by the OpenClaw Codex app-server backend (model api `openai-chatgpt-responses`).
38
+ * They emit their activity under a `codex_app_server.*` stream namespace and — unlike the embedded
39
+ * runner — do NOT put reasoning text on the agent-event bus (`stream: "thinking"`); that text only
40
+ * arrives via the dispatch `onReasoningStream` callback. Likewise exec stdout never reaches the
41
+ * `command_output` stream. We mark a run as Codex the first time we see any `codex_app_server.*`
42
+ * frame so the message handler / tool hooks know to synthesize the missing `thinking` /
43
+ * `command_output` events for it (and ONLY for it — embedded runs already get both via the bus).
44
+ */
45
+ const codexRunIds = new Set<string>();
46
+
47
+ /** True once a `codex_app_server.*` frame has been seen for this run. */
48
+ export function isCodexRun(runId: string): boolean {
49
+ return codexRunIds.has(runId);
50
+ }
51
+
52
+ /** Vitest-only */
53
+ export function resetCodexRunTrackingForTest(): void {
54
+ codexRunIds.clear();
55
+ }
56
+
34
57
  /**
35
58
  * OpenClaw `runId` → device UUID (uppercase).
36
59
  * When `lifecycle.end` / `error` is emitted, the gateway may call `clearAgentRunContext` before this extension's
@@ -234,7 +257,13 @@ function completeAgentEventForward(params: {
234
257
  deviceIdRaw: string;
235
258
  outgoingData: Record<string, unknown>;
236
259
  isTerminalLifecycle: boolean;
237
- subagentMeta?: { label?: string; parentRunId?: string; depth: number };
260
+ subagentMeta?: {
261
+ label?: string;
262
+ parentRunId?: string;
263
+ depth: number;
264
+ childSessionKey?: string;
265
+ status?: string;
266
+ };
238
267
  }): void {
239
268
  const { evt, sk, deviceIdRaw, outgoingData, isTerminalLifecycle, subagentMeta } = params;
240
269
 
@@ -338,6 +367,12 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
338
367
 
339
368
  openClawRunIdToDeviceId.set(evt.runId, deviceIdRaw.toUpperCase());
340
369
 
370
+ // Flag Codex app-server runs so the message handler / tool hooks synthesize the `thinking` /
371
+ // `command_output` events that this backend never emits on the bus (see `isCodexRun`).
372
+ if (typeof evt.stream === "string" && evt.stream.startsWith("codex_app_server")) {
373
+ codexRunIds.add(evt.runId);
374
+ }
375
+
341
376
  // Register sessionKey → runId so we can resolve parentRunId
342
377
  if (sk && evt.stream === "lifecycle" && evt.data.phase === "start") {
343
378
  registerSessionKeyForRun(sk, evt.runId);
@@ -367,6 +402,10 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
367
402
  phase: "spawning",
368
403
  childSessionKey: null,
369
404
  runId: null,
405
+ // A2: the spawn tool-call id is the only stable correlation key before the
406
+ // gateway assigns childSessionKey/runId — the app mints the placeholder window
407
+ // under it, then rekeys to childSessionKey on spawned.
408
+ toolCallId,
370
409
  label: intent.label ?? null,
371
410
  parentRunId: intent.parentRunId,
372
411
  depth: intent.depth,
@@ -408,6 +447,9 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
408
447
  phase: "spawned",
409
448
  runId: compoundRunId,
410
449
  childSessionKey: entry.childSessionKey,
450
+ // A2: echo the spawn toolCallId so the app deterministically links this
451
+ // spawned event to the placeholder window it minted at spawning time.
452
+ toolCallId: toolCallId || null,
411
453
  label: entry.label ?? null,
412
454
  parentRunId: entry.parentRunId ?? null,
413
455
  depth: entry.depth,
@@ -419,6 +461,33 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
419
461
  }
420
462
  }
421
463
 
464
+ // Phase 3 (A3): announce-summary delivery to the parent. OpenClaw emits the parent's
465
+ // `lifecycle.start` under the announce compound runId once a subagent's result is being
466
+ // folded back in. We parse the authoritative childSessionKey here (the registry already
467
+ // knows how) and broadcast an explicit `dismissed` subagent event, so the app removes the
468
+ // settled window by childSessionKey instead of re-parsing the announce runId itself.
469
+ if (evt.stream === "lifecycle" && evt.data.phase === "start") {
470
+ const announced = parseAnnounceRunId(evt.runId);
471
+ if (announced) {
472
+ const entry =
473
+ lookupByChildSessionKey(announced.childSessionKey) ?? lookupByRunId(evt.runId);
474
+ sseEmitter.broadcast(
475
+ {
476
+ type: "subagent",
477
+ data: {
478
+ phase: "dismissed",
479
+ childSessionKey: entry?.childSessionKey ?? announced.childSessionKey,
480
+ runId: entry?.runId ?? announced.bareRunId ?? null,
481
+ parentRunId: entry?.parentRunId ?? null,
482
+ depth: entry?.depth ?? 1,
483
+ deviceId: deviceIdRaw,
484
+ },
485
+ },
486
+ deviceIdRaw,
487
+ );
488
+ }
489
+ }
490
+
422
491
  const subagentEntry = lookupByRunId(evt.runId);
423
492
  // Only annotate events that originate from the subagent itself
424
493
  // (sessionKey matches childSessionKey). Main-agent delivery events
@@ -429,6 +498,11 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
429
498
  label: subagentEntry.label,
430
499
  parentRunId: subagentEntry.parentRunId,
431
500
  depth: subagentEntry.depth,
501
+ // A1: ship the authoritative childSessionKey + lifecycle status on every
502
+ // subagent agent-delta so the app routes/identifies by stable keys instead of
503
+ // guessing from runId.
504
+ childSessionKey: subagentEntry.childSessionKey,
505
+ status: subagentEntry.status,
432
506
  }
433
507
  : undefined;
434
508
 
@@ -453,6 +527,7 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
453
527
  }
454
528
  if (phase === "end" || phase === "error") {
455
529
  lastThinkingTextByRun.delete(evt.runId);
530
+ codexRunIds.delete(evt.runId);
456
531
  }
457
532
  }
458
533