@syengup/friday-channel-next 0.1.39 → 0.1.40

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.
@@ -25,6 +25,10 @@ type SpawnIntent = {
25
25
  };
26
26
  /** Called by forwardAgentEventRaw on lifecycle start so we can resolve parentRunId. */
27
27
  export declare function registerSessionKeyForRun(sessionKey: string, runId: string): void;
28
+ export declare function parseAnnounceRunId(runId: string): {
29
+ childSessionKey: string;
30
+ bareRunId: string;
31
+ } | null;
28
32
  /** Look up subagent entry by any runId form (announce compound or bare). */
29
33
  export declare function lookupByRunId(runId: string): SubagentEntry | undefined;
30
34
  /** Look up subagent entry by childSessionKey. */
@@ -16,7 +16,7 @@ export function registerSessionKeyForRun(sessionKey, runId) {
16
16
  * → { childSessionKey: "agent:main:subagent:uuid", bareRunId: "runId" }
17
17
  */
18
18
  const ANNOUNCE_RUN_ID_RE = /^announce:v\d+:(agent:.+?):([^:]+)$/;
19
- function parseAnnounceRunId(runId) {
19
+ export function parseAnnounceRunId(runId) {
20
20
  const m = runId.match(ANNOUNCE_RUN_ID_RE);
21
21
  if (!m)
22
22
  return null;
@@ -6,7 +6,7 @@ import { observeAgentEventForActiveRuns } from "./agent/active-runs.js";
6
6
  import { getRunMetadata, ingestAgentEventMetadata } from "./run-metadata.js";
7
7
  import { consumeRunUsage } from "./agent/run-usage-accumulator.js";
8
8
  import { readSessionUsageSnapshotFromStore } from "./session-usage-store.js";
9
- import { lookupByRunId, registerSessionKeyForRun, registerSpawnIntent, consumeSpawnIntent, ensureSubagentFromSpawnTool, registerEnded as registerSubagentEnded, } from "./agent/subagent-registry.js";
9
+ import { lookupByRunId, lookupByChildSessionKey, parseAnnounceRunId, registerSessionKeyForRun, registerSpawnIntent, consumeSpawnIntent, ensureSubagentFromSpawnTool, registerEnded as registerSubagentEnded, } from "./agent/subagent-registry.js";
10
10
  /** Last `data.text` per run for `stream: "thinking"` — OpenClaw core may send cumulative `delta`; we rewrite true increments for the app. */
11
11
  const lastThinkingTextByRun = new Map();
12
12
  function commonPrefixLength(a, b) {
@@ -323,6 +323,10 @@ export function forwardAgentEventRaw(evt) {
323
323
  phase: "spawning",
324
324
  childSessionKey: null,
325
325
  runId: null,
326
+ // A2: the spawn tool-call id is the only stable correlation key before the
327
+ // gateway assigns childSessionKey/runId — the app mints the placeholder window
328
+ // under it, then rekeys to childSessionKey on spawned.
329
+ toolCallId,
326
330
  label: intent.label ?? null,
327
331
  parentRunId: intent.parentRunId,
328
332
  depth: intent.depth,
@@ -357,6 +361,9 @@ export function forwardAgentEventRaw(evt) {
357
361
  phase: "spawned",
358
362
  runId: compoundRunId,
359
363
  childSessionKey: entry.childSessionKey,
364
+ // A2: echo the spawn toolCallId so the app deterministically links this
365
+ // spawned event to the placeholder window it minted at spawning time.
366
+ toolCallId: toolCallId || null,
360
367
  label: entry.label ?? null,
361
368
  parentRunId: entry.parentRunId ?? null,
362
369
  depth: entry.depth,
@@ -365,6 +372,28 @@ export function forwardAgentEventRaw(evt) {
365
372
  }, entry.deviceId);
366
373
  }
367
374
  }
375
+ // Phase 3 (A3): announce-summary delivery to the parent. OpenClaw emits the parent's
376
+ // `lifecycle.start` under the announce compound runId once a subagent's result is being
377
+ // folded back in. We parse the authoritative childSessionKey here (the registry already
378
+ // knows how) and broadcast an explicit `dismissed` subagent event, so the app removes the
379
+ // settled window by childSessionKey instead of re-parsing the announce runId itself.
380
+ if (evt.stream === "lifecycle" && evt.data.phase === "start") {
381
+ const announced = parseAnnounceRunId(evt.runId);
382
+ if (announced) {
383
+ const entry = lookupByChildSessionKey(announced.childSessionKey) ?? lookupByRunId(evt.runId);
384
+ sseEmitter.broadcast({
385
+ type: "subagent",
386
+ data: {
387
+ phase: "dismissed",
388
+ childSessionKey: entry?.childSessionKey ?? announced.childSessionKey,
389
+ runId: entry?.runId ?? announced.bareRunId ?? null,
390
+ parentRunId: entry?.parentRunId ?? null,
391
+ depth: entry?.depth ?? 1,
392
+ deviceId: deviceIdRaw,
393
+ },
394
+ }, deviceIdRaw);
395
+ }
396
+ }
368
397
  const subagentEntry = lookupByRunId(evt.runId);
369
398
  // Only annotate events that originate from the subagent itself
370
399
  // (sessionKey matches childSessionKey). Main-agent delivery events
@@ -375,6 +404,11 @@ export function forwardAgentEventRaw(evt) {
375
404
  label: subagentEntry.label,
376
405
  parentRunId: subagentEntry.parentRunId,
377
406
  depth: subagentEntry.depth,
407
+ // A1: ship the authoritative childSessionKey + lifecycle status on every
408
+ // subagent agent-delta so the app routes/identifies by stable keys instead of
409
+ // guessing from runId.
410
+ childSessionKey: subagentEntry.childSessionKey,
411
+ status: subagentEntry.status,
378
412
  }
379
413
  : undefined;
380
414
  let outgoingData = { ...evt.data };
@@ -82,8 +82,12 @@ function resolveConfiguredAgents() {
82
82
  const agents = cfg?.agents;
83
83
  const list = agents?.list;
84
84
  if (!Array.isArray(list) || list.length === 0) {
85
+ // Implicit `main` agent (no `agents.list`): config carries no name, so fall
86
+ // back to the workspace IDENTITY.md `Name` — the same source ControlUI and
87
+ // the list branch below use — instead of letting the app show the raw id.
88
+ const name = readWorkspaceIdentityName(rt, cfg, DEFAULT_AGENT_ID);
85
89
  return {
86
- agents: [{ id: DEFAULT_AGENT_ID, isDefault: true }],
90
+ agents: [{ id: DEFAULT_AGENT_ID, isDefault: true, ...(name ? { name } : {}) }],
87
91
  defaultAgentId: DEFAULT_AGENT_ID,
88
92
  };
89
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.39",
3
+ "version": "0.1.40",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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] ?? "" };
@@ -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,
@@ -234,7 +236,13 @@ function completeAgentEventForward(params: {
234
236
  deviceIdRaw: string;
235
237
  outgoingData: Record<string, unknown>;
236
238
  isTerminalLifecycle: boolean;
237
- subagentMeta?: { label?: string; parentRunId?: string; depth: number };
239
+ subagentMeta?: {
240
+ label?: string;
241
+ parentRunId?: string;
242
+ depth: number;
243
+ childSessionKey?: string;
244
+ status?: string;
245
+ };
238
246
  }): void {
239
247
  const { evt, sk, deviceIdRaw, outgoingData, isTerminalLifecycle, subagentMeta } = params;
240
248
 
@@ -367,6 +375,10 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
367
375
  phase: "spawning",
368
376
  childSessionKey: null,
369
377
  runId: null,
378
+ // A2: the spawn tool-call id is the only stable correlation key before the
379
+ // gateway assigns childSessionKey/runId — the app mints the placeholder window
380
+ // under it, then rekeys to childSessionKey on spawned.
381
+ toolCallId,
370
382
  label: intent.label ?? null,
371
383
  parentRunId: intent.parentRunId,
372
384
  depth: intent.depth,
@@ -408,6 +420,9 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
408
420
  phase: "spawned",
409
421
  runId: compoundRunId,
410
422
  childSessionKey: entry.childSessionKey,
423
+ // A2: echo the spawn toolCallId so the app deterministically links this
424
+ // spawned event to the placeholder window it minted at spawning time.
425
+ toolCallId: toolCallId || null,
411
426
  label: entry.label ?? null,
412
427
  parentRunId: entry.parentRunId ?? null,
413
428
  depth: entry.depth,
@@ -419,6 +434,33 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
419
434
  }
420
435
  }
421
436
 
437
+ // Phase 3 (A3): announce-summary delivery to the parent. OpenClaw emits the parent's
438
+ // `lifecycle.start` under the announce compound runId once a subagent's result is being
439
+ // folded back in. We parse the authoritative childSessionKey here (the registry already
440
+ // knows how) and broadcast an explicit `dismissed` subagent event, so the app removes the
441
+ // settled window by childSessionKey instead of re-parsing the announce runId itself.
442
+ if (evt.stream === "lifecycle" && evt.data.phase === "start") {
443
+ const announced = parseAnnounceRunId(evt.runId);
444
+ if (announced) {
445
+ const entry =
446
+ lookupByChildSessionKey(announced.childSessionKey) ?? lookupByRunId(evt.runId);
447
+ sseEmitter.broadcast(
448
+ {
449
+ type: "subagent",
450
+ data: {
451
+ phase: "dismissed",
452
+ childSessionKey: entry?.childSessionKey ?? announced.childSessionKey,
453
+ runId: entry?.runId ?? announced.bareRunId ?? null,
454
+ parentRunId: entry?.parentRunId ?? null,
455
+ depth: entry?.depth ?? 1,
456
+ deviceId: deviceIdRaw,
457
+ },
458
+ },
459
+ deviceIdRaw,
460
+ );
461
+ }
462
+ }
463
+
422
464
  const subagentEntry = lookupByRunId(evt.runId);
423
465
  // Only annotate events that originate from the subagent itself
424
466
  // (sessionKey matches childSessionKey). Main-agent delivery events
@@ -429,6 +471,11 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
429
471
  label: subagentEntry.label,
430
472
  parentRunId: subagentEntry.parentRunId,
431
473
  depth: subagentEntry.depth,
474
+ // A1: ship the authoritative childSessionKey + lifecycle status on every
475
+ // subagent agent-delta so the app routes/identifies by stable keys instead of
476
+ // guessing from runId.
477
+ childSessionKey: subagentEntry.childSessionKey,
478
+ status: subagentEntry.status,
432
479
  }
433
480
  : undefined;
434
481
 
@@ -71,6 +71,34 @@ describe("handleAgentsList", () => {
71
71
  expect(body.agents).toEqual([{ id: "main", isDefault: true }]);
72
72
  });
73
73
 
74
+ it("resolves the implicit main name from IDENTITY.md when no agents.list exists", async () => {
75
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "friday-identity-main-"));
76
+ fs.writeFileSync(
77
+ path.join(workspace, "IDENTITY.md"),
78
+ "# IDENTITY.md\n\n- **Name:** F.R.I.D.A.Y\n- **Emoji:** 🌿\n",
79
+ );
80
+ try {
81
+ setFridayAgentForwardRuntime({
82
+ runtime: {
83
+ agent: {
84
+ session: { resolveStorePath: () => "", loadSessionStore: () => ({}) },
85
+ resolveAgentWorkspaceDir: () => workspace,
86
+ },
87
+ config: { current: () => ({ agents: { defaults: {} } }) },
88
+ },
89
+ } as any);
90
+
91
+ const res = new MockRes();
92
+ await handleAgentsList(makeReq(AUTH), res as any);
93
+
94
+ const body = JSON.parse(res.body);
95
+ expect(body.defaultAgentId).toBe("main");
96
+ expect(body.agents).toEqual([{ id: "main", name: "F.R.I.D.A.Y", isDefault: true }]);
97
+ } finally {
98
+ fs.rmSync(workspace, { recursive: true, force: true });
99
+ }
100
+ });
101
+
74
102
  it("lists configured agents with normalized ids and resolved fields", async () => {
75
103
  setConfig({
76
104
  agents: {
@@ -106,8 +106,12 @@ function resolveConfiguredAgents(): ResolvedAgents {
106
106
  const list = agents?.list as Array<Record<string, unknown>> | undefined;
107
107
 
108
108
  if (!Array.isArray(list) || list.length === 0) {
109
+ // Implicit `main` agent (no `agents.list`): config carries no name, so fall
110
+ // back to the workspace IDENTITY.md `Name` — the same source ControlUI and
111
+ // the list branch below use — instead of letting the app show the raw id.
112
+ const name = readWorkspaceIdentityName(rt, cfg, DEFAULT_AGENT_ID);
109
113
  return {
110
- agents: [{ id: DEFAULT_AGENT_ID, isDefault: true }],
114
+ agents: [{ id: DEFAULT_AGENT_ID, isDefault: true, ...(name ? { name } : {}) }],
111
115
  defaultAgentId: DEFAULT_AGENT_ID,
112
116
  };
113
117
  }