@syengup/friday-channel-next 0.0.34 → 0.0.37

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 (186) hide show
  1. package/dist/attachments/0768c9b1-53b0-44df-83e8-be15c4ea188f.jpg +0 -0
  2. package/dist/attachments/0a379d01-116b-4da1-bf15-77cb2cbb0093.jpg +0 -0
  3. package/dist/attachments/181caab2-64a7-4004-a057-225a144f949e.mp3 +0 -0
  4. package/dist/attachments/19662331-e527-47d2-bc0e-0e19a7a91419.jpg +0 -0
  5. package/dist/attachments/26a23b2b-52df-4572-a5e1-15b34fb87e44.jpg +0 -0
  6. package/dist/attachments/2f9282c5-8db4-4c4a-a060-e65104f6f9ff.jpg +0 -0
  7. package/dist/attachments/3929ec3d-ea15-4de6-96bc-97e8b0b658a7.jpg +0 -0
  8. package/dist/attachments/403c0cbc-4e3c-4146-a3be-ff3746ee7cda.jpg +0 -0
  9. package/dist/attachments/441977f5-0f7b-4aa2-841a-1d63e787ea53.jpg +0 -0
  10. package/dist/attachments/453e8aa2-76e3-498d-8d6f-d7b96d6bf45b.jpg +0 -0
  11. package/dist/attachments/538cde71-d26e-4d3d-b901-e8dd905e668c.mp3 +0 -0
  12. package/dist/attachments/55c7f628-4ba2-4252-aa4b-4f3eb6045a8a.mp3 +0 -0
  13. package/dist/attachments/5f7683f5-8194-4698-b077-31d209525379.jpg +0 -0
  14. package/dist/attachments/60614a35-8f44-4197-b783-2f58f5a72ac8.jpeg +0 -0
  15. package/dist/attachments/62830489-8814-48b1-851c-3845e514f35e.mp3 +0 -0
  16. package/dist/attachments/66f4a62d-1531-4f38-a531-7456f9edf221.png +0 -0
  17. package/dist/attachments/6735d749-769e-483a-9b84-43b9338a720b.png +0 -0
  18. package/dist/attachments/6d1766b1-05e4-4b04-b3c8-1c25e9d182a1.png +0 -0
  19. package/dist/attachments/782b077b-06e3-484b-baf5-33e7160234ed.png +0 -0
  20. package/dist/attachments/7ad638b2-1f56-4d93-9ad8-b40346e0650f.jpg +0 -0
  21. package/dist/attachments/89f6fb15-e652-4111-a60c-baa414659052.png +0 -0
  22. package/dist/attachments/8a88b14f-442f-45fb-b01d-e51bab8f800d.mp3 +0 -0
  23. package/dist/attachments/92292034-9cf6-4f26-8d77-fddca3deb638.png +0 -0
  24. package/dist/attachments/92c2b414-d33d-4d93-bcb6-013da7bec9a4.jpg +0 -0
  25. package/dist/attachments/9664f69e-3c05-45ca-9a52-f2d0b9f9bf7e.jpg +0 -0
  26. package/dist/attachments/977d28c1-43c0-40e0-95e3-defe0f41afe8.jpg +0 -0
  27. package/dist/attachments/9df40f1a-c6e1-4177-8a03-06757a30b19e.png +0 -0
  28. package/dist/attachments/a68e6815-6163-4421-a70f-34493aa9a217.jpg +0 -0
  29. package/dist/attachments/aab32fea-6d99-47ec-ab1f-2340f31312eb.jpg +0 -0
  30. package/dist/attachments/ab403224-2fb1-49c1-8738-ea194ab65d44.png +0 -0
  31. package/dist/attachments/ac3da190-d6ee-4038-a673-8b893035a687.png +0 -0
  32. package/dist/attachments/af02be9c-87f7-4c5a-9969-7db32039bb58.png +0 -0
  33. package/dist/attachments/b011d42a-00e5-4f77-86bc-08da6112e6e1.mp3 +0 -0
  34. package/dist/attachments/b7d7df40-c627-4b1f-9b09-167b88545c25.mp3 +0 -0
  35. package/dist/attachments/c5e9bf09-a718-422c-bcb3-94c173e3755b.mp3 +0 -0
  36. package/dist/attachments/d5449e13-1995-44ba-9392-ecbfe5f9876f.jpg +0 -0
  37. package/dist/attachments/ea0069f5-01cf-4ea1-985e-3a1e426399c3.png +0 -0
  38. package/dist/attachments/f3989ff2-7b70-4a80-a896-74a6b197f7d8.png +0 -0
  39. package/dist/attachments/f64a4a14-e3aa-4eed-a8d9-1603f04baa5b.jpg +0 -0
  40. package/dist/index.d.ts +4 -0
  41. package/dist/index.js +176 -0
  42. package/dist/src/agent/abort-run.d.ts +1 -0
  43. package/dist/src/agent/abort-run.js +11 -0
  44. package/dist/src/agent/active-runs.d.ts +9 -0
  45. package/dist/src/agent/active-runs.js +20 -0
  46. package/dist/src/agent/dispatch-bridge.d.ts +5 -0
  47. package/dist/src/agent/dispatch-bridge.js +12 -0
  48. package/dist/src/agent/media-bridge.d.ts +4 -0
  49. package/dist/src/agent/media-bridge.js +21 -0
  50. package/dist/src/agent/subagent-registry.d.ts +68 -0
  51. package/dist/src/agent/subagent-registry.js +142 -0
  52. package/dist/src/agent-forward-runtime.d.ts +17 -0
  53. package/dist/src/agent-forward-runtime.js +16 -0
  54. package/dist/src/agent-run-context-bridge.d.ts +13 -0
  55. package/dist/src/agent-run-context-bridge.js +23 -0
  56. package/dist/src/channel-actions.d.ts +13 -0
  57. package/dist/src/channel-actions.js +101 -0
  58. package/dist/src/channel.d.ts +6 -0
  59. package/dist/src/channel.js +248 -0
  60. package/dist/src/collect-message-media-paths.d.ts +11 -0
  61. package/dist/src/collect-message-media-paths.js +143 -0
  62. package/dist/src/config.d.ts +15 -0
  63. package/dist/src/config.js +39 -0
  64. package/dist/src/friday-inbound-stats.d.ts +2 -0
  65. package/dist/src/friday-inbound-stats.js +8 -0
  66. package/dist/src/friday-session.d.ts +40 -0
  67. package/dist/src/friday-session.js +395 -0
  68. package/dist/src/host-config.d.ts +1 -0
  69. package/dist/src/host-config.js +15 -0
  70. package/dist/src/http/handlers/cancel.d.ts +2 -0
  71. package/dist/src/http/handlers/cancel.js +33 -0
  72. package/dist/src/http/handlers/device-approve.d.ts +2 -0
  73. package/dist/src/http/handlers/device-approve.js +125 -0
  74. package/dist/src/http/handlers/device-token.d.ts +2 -0
  75. package/dist/src/http/handlers/device-token.js +43 -0
  76. package/dist/src/http/handlers/files-download.d.ts +10 -0
  77. package/dist/src/http/handlers/files-download.js +210 -0
  78. package/dist/src/http/handlers/files-upload.d.ts +8 -0
  79. package/dist/src/http/handlers/files-upload.js +136 -0
  80. package/dist/src/http/handlers/files.d.ts +75 -0
  81. package/dist/src/http/handlers/files.js +305 -0
  82. package/dist/src/http/handlers/import.d.ts +7 -0
  83. package/dist/src/http/handlers/import.js +69 -0
  84. package/dist/src/http/handlers/info.d.ts +2 -0
  85. package/dist/src/http/handlers/info.js +13 -0
  86. package/dist/src/http/handlers/messages-list.d.ts +7 -0
  87. package/dist/src/http/handlers/messages-list.js +44 -0
  88. package/dist/src/http/handlers/messages.d.ts +34 -0
  89. package/dist/src/http/handlers/messages.js +476 -0
  90. package/dist/src/http/handlers/models-list.d.ts +10 -0
  91. package/dist/src/http/handlers/models-list.js +113 -0
  92. package/dist/src/http/handlers/nodes-approve.d.ts +2 -0
  93. package/dist/src/http/handlers/nodes-approve.js +146 -0
  94. package/dist/src/http/handlers/pair.d.ts +2 -0
  95. package/dist/src/http/handlers/pair.js +39 -0
  96. package/dist/src/http/handlers/sessions-delete.d.ts +2 -0
  97. package/dist/src/http/handlers/sessions-delete.js +49 -0
  98. package/dist/src/http/handlers/sessions-list.d.ts +8 -0
  99. package/dist/src/http/handlers/sessions-list.js +24 -0
  100. package/dist/src/http/handlers/sessions-messages-get.d.ts +2 -0
  101. package/dist/src/http/handlers/sessions-messages-get.js +55 -0
  102. package/dist/src/http/handlers/sessions-messages-post.d.ts +2 -0
  103. package/dist/src/http/handlers/sessions-messages-post.js +92 -0
  104. package/dist/src/http/handlers/sessions-messages.d.ts +2 -0
  105. package/dist/src/http/handlers/sessions-messages.js +135 -0
  106. package/dist/src/http/handlers/sessions-settings.d.ts +2 -0
  107. package/dist/src/http/handlers/sessions-settings.js +71 -0
  108. package/dist/src/http/handlers/sse.d.ts +2 -0
  109. package/dist/src/http/handlers/sse.js +70 -0
  110. package/dist/src/http/handlers/status.d.ts +2 -0
  111. package/dist/src/http/handlers/status.js +29 -0
  112. package/dist/src/http/handlers/sync.d.ts +7 -0
  113. package/dist/src/http/handlers/sync.js +56 -0
  114. package/dist/src/http/middleware/auth.d.ts +13 -0
  115. package/dist/src/http/middleware/auth.js +29 -0
  116. package/dist/src/http/middleware/body.d.ts +2 -0
  117. package/dist/src/http/middleware/body.js +24 -0
  118. package/dist/src/http/middleware/cors.d.ts +2 -0
  119. package/dist/src/http/middleware/cors.js +11 -0
  120. package/dist/src/http/server.d.ts +19 -0
  121. package/dist/src/http/server.js +87 -0
  122. package/dist/src/logging.d.ts +7 -0
  123. package/dist/src/logging.js +28 -0
  124. package/dist/src/push/apns.d.ts +15 -0
  125. package/dist/src/push/apns.js +56 -0
  126. package/dist/src/push/device-tokens.d.ts +3 -0
  127. package/dist/src/push/device-tokens.js +39 -0
  128. package/dist/src/run-metadata.d.ts +25 -0
  129. package/dist/src/run-metadata.js +139 -0
  130. package/dist/src/runtime.d.ts +13 -0
  131. package/dist/src/runtime.js +5 -0
  132. package/dist/src/session/session-manager.d.ts +22 -0
  133. package/dist/src/session/session-manager.js +190 -0
  134. package/dist/src/session-usage-snapshot.d.ts +23 -0
  135. package/dist/src/session-usage-snapshot.js +65 -0
  136. package/dist/src/sse/emitter.d.ts +59 -0
  137. package/dist/src/sse/emitter.js +219 -0
  138. package/dist/src/sse/offline-queue.d.ts +26 -0
  139. package/dist/src/sse/offline-queue.js +134 -0
  140. package/dist/src/sync/account-identity.d.ts +14 -0
  141. package/dist/src/sync/account-identity.js +101 -0
  142. package/dist/src/sync/archive.d.ts +9 -0
  143. package/dist/src/sync/archive.js +25 -0
  144. package/dist/src/sync/database.d.ts +66 -0
  145. package/dist/src/sync/database.js +364 -0
  146. package/dist/src/sync/init.d.ts +3 -0
  147. package/dist/src/sync/init.js +14 -0
  148. package/dist/src/sync/installation-id.d.ts +1 -0
  149. package/dist/src/sync/installation-id.js +41 -0
  150. package/dist/src/sync/message-accumulator.d.ts +29 -0
  151. package/dist/src/sync/message-accumulator.js +188 -0
  152. package/dist/src/sync/message-store.d.ts +68 -0
  153. package/dist/src/sync/message-store.js +262 -0
  154. package/dist/src/sync/push-store.d.ts +5 -0
  155. package/dist/src/sync/push-store.js +54 -0
  156. package/dist/src/sync/session-key.d.ts +12 -0
  157. package/dist/src/sync/session-key.js +47 -0
  158. package/dist/src/sync/sync-state.d.ts +5 -0
  159. package/dist/src/sync/sync-state.js +54 -0
  160. package/dist/src/sync/transcript-archive.d.ts +13 -0
  161. package/dist/src/sync/transcript-archive.js +37 -0
  162. package/dist/src/sync/transcript-store.d.ts +35 -0
  163. package/dist/src/sync/transcript-store.js +221 -0
  164. package/dist/src/sync/translate.d.ts +42 -0
  165. package/dist/src/sync/translate.js +171 -0
  166. package/dist/src/vendor/runtime-store.d.ts +26 -0
  167. package/dist/src/vendor/runtime-store.js +60 -0
  168. package/install.js +2 -2
  169. package/package.json +11 -10
  170. package/src/agent/subagent-registry.ts +195 -0
  171. package/src/channel.ts +6 -4
  172. package/src/e2e/subagent-smoke.e2e.test.ts +223 -0
  173. package/src/e2e/subagent.e2e.test.ts +502 -0
  174. package/src/friday-session.ts +140 -1
  175. package/src/http/handlers/device-approve.test.ts +0 -1
  176. package/src/http/handlers/device-approve.ts +0 -2
  177. package/src/http/handlers/files-download.ts +4 -1
  178. package/src/http/handlers/files.ts +7 -4
  179. package/src/http/handlers/messages.ts +54 -4
  180. package/src/http/handlers/models-list.ts +24 -2
  181. package/src/http/handlers/nodes-approve.test.ts +288 -0
  182. package/src/http/handlers/nodes-approve.ts +189 -0
  183. package/src/http/server.ts +5 -0
  184. package/src/openclaw.d.ts +5 -0
  185. package/src/sse/emitter.ts +1 -1
  186. package/src/test-support/mock-runtime.ts +2 -0
@@ -0,0 +1,502 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ ensureSubagentFromSpawnTool,
4
+ registerEnded,
5
+ lookupByRunId,
6
+ lookupByChildSessionKey,
7
+ resetForTest as resetSubagentRegistryForTest,
8
+ registerSessionKeyForRun,
9
+ } from "../agent/subagent-registry.js";
10
+ import {
11
+ forwardAgentEventRaw,
12
+ registerFridaySessionDeviceMapping,
13
+ resetOpenClawRunDeviceMappingForTest,
14
+ resetThinkingStreamAccumStateForTest,
15
+ } from "../friday-session.js";
16
+ import { resetFridayAgentForwardRuntimeForTest } from "../agent-forward-runtime.js";
17
+ import { sseEmitter } from "../sse/emitter.js";
18
+ import { resetRunMetadataForTest } from "../run-metadata.js";
19
+ import { resetActiveRunsForTest } from "../agent/active-runs.js";
20
+
21
+ const deviceId = "DEVICE-AAAA-BBBB";
22
+ const mainSessionKey = "agent:main:main";
23
+ const mainRunId = "main-run-001";
24
+
25
+ function captureBroadcastToRunCalls() {
26
+ const calls: Array<[string, { type: string; data: Record<string, unknown> }]> = [];
27
+ vi.spyOn(sseEmitter, "broadcastToRun").mockImplementation((runId, event) => {
28
+ calls.push([runId, event]);
29
+ });
30
+ return calls;
31
+ }
32
+
33
+ function captureBroadcastCalls() {
34
+ const calls: Array<[string | undefined, { type: string; data: Record<string, unknown> }]> = [];
35
+ vi.spyOn(sseEmitter, "broadcast").mockImplementation((event, deviceId) => {
36
+ calls.push([deviceId, event]);
37
+ });
38
+ return calls;
39
+ }
40
+
41
+ function makeSpawnToolResult(params: {
42
+ childSessionKey: string;
43
+ runId?: string;
44
+ taskName?: string;
45
+ meta?: string;
46
+ }) {
47
+ return {
48
+ childSessionKey: params.childSessionKey,
49
+ runId: params.runId ?? "bare-run-001",
50
+ taskName: params.taskName ?? "task",
51
+ };
52
+ }
53
+
54
+ describe("subagent via sessions_spawn tool", () => {
55
+ beforeEach(() => {
56
+ sseEmitter.resetForTest();
57
+ resetSubagentRegistryForTest();
58
+ resetThinkingStreamAccumStateForTest();
59
+ resetOpenClawRunDeviceMappingForTest();
60
+ resetRunMetadataForTest();
61
+ resetActiveRunsForTest();
62
+ resetFridayAgentForwardRuntimeForTest();
63
+ registerFridaySessionDeviceMapping(mainSessionKey, deviceId);
64
+ });
65
+
66
+ afterEach(() => {
67
+ vi.restoreAllMocks();
68
+ });
69
+
70
+ describe("registry (tool-driven API)", () => {
71
+ it("ensureSubagentFromSpawnTool creates entry with parentRunId", () => {
72
+ registerSessionKeyForRun(mainSessionKey, mainRunId);
73
+
74
+ const entry = ensureSubagentFromSpawnTool({
75
+ childSessionKey: "agent:main:subagent:cr",
76
+ bareRunId: "run-cr",
77
+ label: "code-reviewer",
78
+ deviceId,
79
+ parentRunId: mainRunId,
80
+ requesterSessionKey: mainSessionKey,
81
+ });
82
+
83
+ expect(entry.status).toBe("running");
84
+ expect(entry.depth).toBe(1);
85
+ expect(entry.parentRunId).toBe(mainRunId);
86
+ expect(entry.label).toBe("code-reviewer");
87
+ expect(entry.runId).toBe("run-cr");
88
+ expect(entry.deviceId).toBe(deviceId);
89
+ });
90
+
91
+ it("reuses existing entry on duplicate childSessionKey", () => {
92
+ const e1 = ensureSubagentFromSpawnTool({
93
+ childSessionKey: "agent:main:subagent:cr",
94
+ bareRunId: "run-1",
95
+ deviceId,
96
+ parentRunId: mainRunId,
97
+ });
98
+ const e2 = ensureSubagentFromSpawnTool({
99
+ childSessionKey: "agent:main:subagent:cr",
100
+ bareRunId: "run-2",
101
+ deviceId,
102
+ parentRunId: mainRunId,
103
+ });
104
+ expect(e2).toBe(e1);
105
+ expect(e2.runId).toBe("run-1"); // original runId preserved
106
+ });
107
+
108
+ it("registerEnded marks entry as ended", () => {
109
+ const entry = ensureSubagentFromSpawnTool({
110
+ childSessionKey: "agent:main:subagent:cr",
111
+ bareRunId: "run-cr",
112
+ deviceId,
113
+ parentRunId: mainRunId,
114
+ });
115
+ const ended = registerEnded({ runId: "run-cr", outcome: "error", error: "timeout" });
116
+ expect(ended?.status).toBe("ended");
117
+ expect(ended?.outcome).toBe("error");
118
+ expect(ended?.error).toBe("timeout");
119
+ });
120
+
121
+ it("registerEnded finds entry by childSessionKey", () => {
122
+ ensureSubagentFromSpawnTool({
123
+ childSessionKey: "agent:main:subagent:cr",
124
+ bareRunId: "run-cr",
125
+ deviceId,
126
+ parentRunId: mainRunId,
127
+ });
128
+ const ended = registerEnded({ childSessionKey: "agent:main:subagent:cr", outcome: "ok" });
129
+ expect(ended?.status).toBe("ended");
130
+ });
131
+
132
+ it("registerEnded returns undefined for unknown keys", () => {
133
+ expect(registerEnded({ runId: "nonexistent" })).toBeUndefined();
134
+ });
135
+ });
136
+
137
+ describe("announce runId parsing", () => {
138
+ it("lookupByRunId resolves compound announce runIds", () => {
139
+ ensureSubagentFromSpawnTool({
140
+ childSessionKey: "agent:main:subagent:abc-123",
141
+ bareRunId: "bare-id",
142
+ deviceId,
143
+ parentRunId: mainRunId,
144
+ });
145
+ const compound = "announce:v1:agent:main:subagent:abc-123:bare-id";
146
+ const entry = lookupByRunId(compound);
147
+ expect(entry).toBeTruthy();
148
+ expect(entry?.childSessionKey).toBe("agent:main:subagent:abc-123");
149
+ });
150
+
151
+ it("lookupByRunId resolves bare runIds", () => {
152
+ ensureSubagentFromSpawnTool({
153
+ childSessionKey: "agent:main:subagent:xyz",
154
+ bareRunId: "bare-xyz",
155
+ deviceId,
156
+ parentRunId: mainRunId,
157
+ });
158
+ const entry = lookupByRunId("bare-xyz");
159
+ expect(entry).toBeTruthy();
160
+ expect(entry?.childSessionKey).toBe("agent:main:subagent:xyz");
161
+ });
162
+
163
+ it("lookupByRunId returns undefined for unknown runIds", () => {
164
+ expect(lookupByRunId("unknown-run")).toBeUndefined();
165
+ });
166
+ });
167
+
168
+ describe("nested subagents", () => {
169
+ it("resolves depth=2 for nested subagent via requesterSessionKey chain", () => {
170
+ const childKeyA = "agent:main:subagent:reviewer";
171
+ const childRunA = "run-reviewer";
172
+
173
+ registerSessionKeyForRun(mainSessionKey, mainRunId);
174
+
175
+ ensureSubagentFromSpawnTool({
176
+ childSessionKey: childKeyA,
177
+ bareRunId: childRunA,
178
+ deviceId,
179
+ label: "reviewer",
180
+ parentRunId: mainRunId,
181
+ requesterSessionKey: mainSessionKey,
182
+ });
183
+
184
+ // Nested subagent B spawns with requesterSessionKey = A's childSessionKey
185
+ const entryB = ensureSubagentFromSpawnTool({
186
+ childSessionKey: "agent:main:subagent:lint",
187
+ bareRunId: "run-lint",
188
+ deviceId,
189
+ label: "lint",
190
+ parentRunId: childRunA,
191
+ requesterSessionKey: childKeyA,
192
+ });
193
+
194
+ expect(entryB.depth).toBe(2);
195
+ expect(entryB.parentRunId).toBe(childRunA);
196
+ });
197
+ });
198
+
199
+ describe("forwardAgentEventRaw integration", () => {
200
+ it("sessions_spawn tool result triggers subagent SSE emission", () => {
201
+ const childKey = "agent:main:subagent:cr";
202
+ const childRun = "bare-run-cr";
203
+
204
+ // Main run start
205
+ forwardAgentEventRaw({
206
+ runId: mainRunId, seq: 1, stream: "lifecycle",
207
+ sessionKey: mainSessionKey, data: { phase: "start" },
208
+ });
209
+
210
+ const broadcastCalls = captureBroadcastCalls();
211
+
212
+ // Simulate sessions_spawn tool result
213
+ forwardAgentEventRaw({
214
+ runId: mainRunId, seq: 2, stream: "tool",
215
+ sessionKey: mainSessionKey,
216
+ data: {
217
+ phase: "result",
218
+ name: "sessions_spawn",
219
+ toolCallId: "call_1",
220
+ result: {
221
+ details: makeSpawnToolResult({
222
+ childSessionKey: childKey,
223
+ runId: childRun,
224
+ taskName: "code-reviewer",
225
+ }),
226
+ },
227
+ },
228
+ });
229
+
230
+ // Should emit subagent SSE with phase=spawned
231
+ const subagentCalls = broadcastCalls.filter(([, e]) => e.type === "subagent");
232
+ expect(subagentCalls).toHaveLength(1);
233
+ expect(subagentCalls[0]![1].data.phase).toBe("spawned");
234
+ expect(subagentCalls[0]![1].data.childSessionKey).toBe(childKey);
235
+ expect(subagentCalls[0]![1].data.label).toBe("code-reviewer");
236
+ expect(subagentCalls[0]![1].data.parentRunId).toBe(mainRunId);
237
+ expect(subagentCalls[0]![1].data.depth).toBe(1);
238
+ });
239
+
240
+ it("agent events for subagent runId are annotated via announce runId", () => {
241
+ const childKey = "agent:main:subagent:cr";
242
+ const bareRunId = "bare-cr";
243
+
244
+ // Main run
245
+ forwardAgentEventRaw({
246
+ runId: mainRunId, seq: 1, stream: "lifecycle",
247
+ sessionKey: mainSessionKey, data: { phase: "start" },
248
+ });
249
+
250
+ // Spawn subagent
251
+ forwardAgentEventRaw({
252
+ runId: mainRunId, seq: 2, stream: "tool",
253
+ sessionKey: mainSessionKey,
254
+ data: {
255
+ phase: "result", name: "sessions_spawn", toolCallId: "c1",
256
+ result: { details: makeSpawnToolResult({ childSessionKey: childKey, runId: bareRunId, taskName: "cr" }) },
257
+ },
258
+ });
259
+
260
+ const compoundRunId = `announce:v1:${childKey}:${bareRunId}`;
261
+ sseEmitter.trackDeviceForRun(deviceId, compoundRunId);
262
+
263
+ const calls = captureBroadcastToRunCalls();
264
+
265
+ // Agent event for subagent (announce runId, subagent's own sessionKey)
266
+ forwardAgentEventRaw({
267
+ runId: compoundRunId, seq: 1, stream: "thinking",
268
+ data: { text: "reviewing..." },
269
+ sessionKey: childKey,
270
+ });
271
+
272
+ const agentCall = calls.find(
273
+ ([, e]) => e.type === "agent" && e.data.stream === "thinking",
274
+ );
275
+ expect(agentCall).toBeTruthy();
276
+ expect(agentCall![1].data.subagent).toEqual({
277
+ label: "cr",
278
+ parentRunId: mainRunId,
279
+ depth: 1,
280
+ });
281
+ });
282
+
283
+ it("main agent events are NOT annotated", () => {
284
+ forwardAgentEventRaw({
285
+ runId: mainRunId, seq: 1, stream: "lifecycle",
286
+ sessionKey: mainSessionKey, data: { phase: "start" },
287
+ });
288
+
289
+ const calls = captureBroadcastToRunCalls();
290
+
291
+ forwardAgentEventRaw({
292
+ runId: mainRunId, seq: 2, stream: "assistant",
293
+ sessionKey: mainSessionKey,
294
+ data: { text: "main reply" },
295
+ });
296
+
297
+ const agentCall = calls.find(
298
+ ([, e]) => e.type === "agent" && e.data.stream === "assistant",
299
+ );
300
+ expect(agentCall![1].data.subagent).toBeUndefined();
301
+ });
302
+
303
+ it("lifecycle end for subagent emits ended SSE", () => {
304
+ const childKey = "agent:main:subagent:cr";
305
+ const bareRunId = "bare-cr";
306
+
307
+ forwardAgentEventRaw({
308
+ runId: mainRunId, seq: 1, stream: "lifecycle",
309
+ sessionKey: mainSessionKey, data: { phase: "start" },
310
+ });
311
+
312
+ forwardAgentEventRaw({
313
+ runId: mainRunId, seq: 2, stream: "tool",
314
+ sessionKey: mainSessionKey,
315
+ data: {
316
+ phase: "result", name: "sessions_spawn", toolCallId: "c1",
317
+ result: { details: makeSpawnToolResult({ childSessionKey: childKey, runId: bareRunId, taskName: "cr" }) },
318
+ },
319
+ });
320
+
321
+ const compoundRunId = `announce:v1:${childKey}:${bareRunId}`;
322
+ sseEmitter.trackDeviceForRun(deviceId, compoundRunId);
323
+
324
+ const broadcastCalls = captureBroadcastCalls();
325
+
326
+ // Lifecycle end for subagent (subagent's own sessionKey)
327
+ forwardAgentEventRaw({
328
+ runId: compoundRunId, seq: 5, stream: "lifecycle",
329
+ data: { phase: "end" },
330
+ sessionKey: childKey,
331
+ });
332
+
333
+ const endedCall = broadcastCalls.find(
334
+ ([, e]) => e.type === "subagent" && e.data.phase === "ended",
335
+ );
336
+ expect(endedCall).toBeTruthy();
337
+ expect(endedCall![1].data.outcome).toBe("ok");
338
+ });
339
+
340
+ it("lifecycle error for subagent emits ended SSE with error outcome", () => {
341
+ const childKey = "agent:main:subagent:cr";
342
+ const bareRunId = "bare-cr";
343
+
344
+ forwardAgentEventRaw({
345
+ runId: mainRunId, seq: 1, stream: "lifecycle",
346
+ sessionKey: mainSessionKey, data: { phase: "start" },
347
+ });
348
+
349
+ forwardAgentEventRaw({
350
+ runId: mainRunId, seq: 2, stream: "tool",
351
+ sessionKey: mainSessionKey,
352
+ data: {
353
+ phase: "result", name: "sessions_spawn", toolCallId: "c1",
354
+ result: { details: makeSpawnToolResult({ childSessionKey: childKey, runId: bareRunId }) },
355
+ },
356
+ });
357
+
358
+ const compoundRunId = `announce:v1:${childKey}:${bareRunId}`;
359
+ sseEmitter.trackDeviceForRun(deviceId, compoundRunId);
360
+
361
+ const broadcastCalls = captureBroadcastCalls();
362
+
363
+ forwardAgentEventRaw({
364
+ runId: compoundRunId, seq: 5, stream: "lifecycle",
365
+ data: { phase: "error", error: "timeout" },
366
+ sessionKey: childKey,
367
+ });
368
+
369
+ const endedCall = broadcastCalls.find(
370
+ ([, e]) => e.type === "subagent" && e.data.phase === "ended",
371
+ );
372
+ expect(endedCall).toBeTruthy();
373
+ expect(endedCall![1].data.outcome).toBe("error");
374
+ expect(endedCall![1].data.error).toBe("timeout");
375
+ });
376
+
377
+ it("duplicate lifecycle end does not emit a second ended SSE", () => {
378
+ const childKey = "agent:main:subagent:cr";
379
+ const bareRunId = "bare-cr";
380
+
381
+ forwardAgentEventRaw({
382
+ runId: mainRunId, seq: 1, stream: "lifecycle",
383
+ sessionKey: mainSessionKey, data: { phase: "start" },
384
+ });
385
+
386
+ forwardAgentEventRaw({
387
+ runId: mainRunId, seq: 2, stream: "tool",
388
+ sessionKey: mainSessionKey,
389
+ data: {
390
+ phase: "result", name: "sessions_spawn", toolCallId: "c1",
391
+ result: { details: makeSpawnToolResult({ childSessionKey: childKey, runId: bareRunId }) },
392
+ },
393
+ });
394
+
395
+ const compoundRunId = `announce:v1:${childKey}:${bareRunId}`;
396
+ sseEmitter.trackDeviceForRun(deviceId, compoundRunId);
397
+
398
+ const broadcastCalls = captureBroadcastCalls();
399
+
400
+ forwardAgentEventRaw({
401
+ runId: compoundRunId, seq: 5, stream: "lifecycle",
402
+ data: { phase: "end" },
403
+ sessionKey: childKey,
404
+ });
405
+ forwardAgentEventRaw({
406
+ runId: compoundRunId, seq: 6, stream: "lifecycle",
407
+ data: { phase: "end" },
408
+ sessionKey: childKey,
409
+ });
410
+
411
+ const endedCalls = broadcastCalls.filter(
412
+ ([, e]) => e.type === "subagent" && e.data.phase === "ended",
413
+ );
414
+ expect(endedCalls).toHaveLength(1); // only first end counts
415
+ });
416
+ });
417
+
418
+ describe("complete tool-driven lifecycle", () => {
419
+ it("two-level nested subagent via sessions_spawn events", () => {
420
+ const childKeyA = "agent:main:subagent:reviewer";
421
+ const bareA = "bare-reviewer";
422
+ const compoundA = `announce:v1:${childKeyA}:${bareA}`;
423
+ const childKeyB = "agent:main:subagent:lint";
424
+ const bareB = "bare-lint";
425
+ const compoundB = `announce:v1:${childKeyB}:${bareB}`;
426
+
427
+ // Main run start
428
+ forwardAgentEventRaw({
429
+ runId: mainRunId, seq: 1, stream: "lifecycle",
430
+ sessionKey: mainSessionKey, data: { phase: "start" },
431
+ });
432
+
433
+ // sessions_spawn for A
434
+ forwardAgentEventRaw({
435
+ runId: mainRunId, seq: 2, stream: "tool",
436
+ sessionKey: mainSessionKey,
437
+ data: {
438
+ phase: "result", name: "sessions_spawn", toolCallId: "c1",
439
+ result: { details: makeSpawnToolResult({ childSessionKey: childKeyA, runId: bareA, taskName: "reviewer" }) },
440
+ },
441
+ });
442
+ sseEmitter.trackDeviceForRun(deviceId, compoundA);
443
+
444
+ // Subagent A thinking (subagent's own sessionKey)
445
+ const calls = captureBroadcastToRunCalls();
446
+ forwardAgentEventRaw({
447
+ runId: compoundA, seq: 1, stream: "thinking",
448
+ data: { text: "reviewing code..." },
449
+ sessionKey: childKeyA,
450
+ });
451
+ const thinkingA = calls.find(
452
+ ([, e]) => e.type === "agent" && e.data.stream === "thinking",
453
+ );
454
+ expect(thinkingA![1].data.subagent).toEqual({
455
+ label: "reviewer",
456
+ parentRunId: mainRunId,
457
+ depth: 1,
458
+ });
459
+
460
+ // sessions_spawn for B (nested from A's tool call — but parentRunId should come from the context)
461
+ // In reality, B is spawned from a tool call inside A's run, but here we simulate it from the main run
462
+ // The registry handles nesting via requesterSessionKey chain
463
+ forwardAgentEventRaw({
464
+ runId: compoundA, seq: 2, stream: "tool",
465
+ sessionKey: mainSessionKey,
466
+ data: {
467
+ phase: "result", name: "sessions_spawn", toolCallId: "c2",
468
+ result: { details: makeSpawnToolResult({ childSessionKey: childKeyB, runId: bareB, taskName: "lint" }) },
469
+ },
470
+ });
471
+ sseEmitter.trackDeviceForRun(deviceId, compoundB);
472
+
473
+ // Subagent B thinking (subagent's own sessionKey)
474
+ forwardAgentEventRaw({
475
+ runId: compoundB, seq: 1, stream: "assistant",
476
+ data: { text: "no lint errors" },
477
+ sessionKey: childKeyB,
478
+ });
479
+ const assistantB = calls.find(
480
+ ([, e]) => e.type === "agent" && e.data.stream === "assistant",
481
+ );
482
+ expect(assistantB![1].data.subagent).toBeDefined();
483
+
484
+ // B ends (subagent's own sessionKey)
485
+ forwardAgentEventRaw({
486
+ runId: compoundB, seq: 5, stream: "lifecycle",
487
+ data: { phase: "end" },
488
+ sessionKey: childKeyB,
489
+ });
490
+
491
+ // A ends (subagent's own sessionKey)
492
+ forwardAgentEventRaw({
493
+ runId: compoundA, seq: 6, stream: "lifecycle",
494
+ data: { phase: "end" },
495
+ sessionKey: childKeyA,
496
+ });
497
+
498
+ expect(lookupByChildSessionKey(childKeyA)?.status).toBe("ended");
499
+ expect(lookupByChildSessionKey(childKeyB)?.status).toBe("ended");
500
+ });
501
+ });
502
+ });
@@ -5,6 +5,14 @@ import { getOpenClawAgentRunContext } from "./agent-run-context-bridge.js";
5
5
  import { observeAgentEventForActiveRuns } from "./agent/active-runs.js";
6
6
  import { getRunMetadata, ingestAgentEventMetadata } from "./run-metadata.js";
7
7
  import { buildSessionUsageSnapshot } from "./session-usage-snapshot.js";
8
+ import {
9
+ lookupByRunId,
10
+ registerSessionKeyForRun,
11
+ registerSpawnIntent,
12
+ consumeSpawnIntent,
13
+ ensureSubagentFromSpawnTool,
14
+ registerEnded as registerSubagentEnded,
15
+ } from "./agent/subagent-registry.js";
8
16
 
9
17
  /** Last `data.text` per run for `stream: "thinking"` — OpenClaw core may send cumulative `delta`; we rewrite true increments for the app. */
10
18
  const lastThinkingTextByRun = new Map<string, string>();
@@ -193,8 +201,9 @@ function completeAgentEventForward(params: {
193
201
  deviceIdRaw: string;
194
202
  outgoingData: Record<string, unknown>;
195
203
  isTerminalLifecycle: boolean;
204
+ subagentMeta?: { label?: string; parentRunId?: string; depth: number };
196
205
  }): void {
197
- const { evt, sk, deviceIdRaw, outgoingData, isTerminalLifecycle } = params;
206
+ const { evt, sk, deviceIdRaw, outgoingData, isTerminalLifecycle, subagentMeta } = params;
198
207
 
199
208
  observeAgentEventForActiveRuns({ stream: evt.stream, runId: evt.runId, data: outgoingData });
200
209
 
@@ -211,6 +220,7 @@ function completeAgentEventForward(params: {
211
220
  sessionKey: evt.sessionKey ?? sk,
212
221
  };
213
222
  if (directToDevice) payload.deviceId = deviceId;
223
+ if (subagentMeta) payload.subagent = subagentMeta;
214
224
 
215
225
  sseEmitter.broadcastToRun(targetRunId, { type: "agent", data: payload });
216
226
 
@@ -262,6 +272,11 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
262
272
  const mapped = openClawRunIdToDeviceId.get(evt.runId);
263
273
  if (mapped) deviceIdRaw = mapped;
264
274
  }
275
+ // Subagent runs have their deviceId in the subagent registry
276
+ if (!deviceIdRaw) {
277
+ const sub = lookupByRunId(evt.runId);
278
+ if (sub) deviceIdRaw = sub.deviceId;
279
+ }
265
280
  if (!deviceIdRaw) return;
266
281
 
267
282
  if (!sk) {
@@ -271,6 +286,102 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
271
286
 
272
287
  openClawRunIdToDeviceId.set(evt.runId, deviceIdRaw.toUpperCase());
273
288
 
289
+ // Register sessionKey → runId so we can resolve parentRunId
290
+ if (sk && evt.stream === "lifecycle" && evt.data.phase === "start") {
291
+ registerSessionKeyForRun(sk, evt.runId);
292
+ }
293
+
294
+ // ── sessions_spawn tool → subagent lifecycle (replaces hooks) ──
295
+ const isSpawnTool =
296
+ evt.stream === "tool" && (evt.data.name === "sessions_spawn" || evt.data.name === "task");
297
+
298
+ // Phase 1: spawning — tool.start with taskName in args
299
+ if (isSpawnTool && evt.data.phase === "start") {
300
+ const toolCallId =
301
+ typeof evt.data.toolCallId === "string" ? evt.data.toolCallId : "";
302
+ const args = evt.data.args as Record<string, unknown> | undefined;
303
+ const label =
304
+ typeof args?.taskName === "string" ? args.taskName : undefined;
305
+ if (toolCallId) {
306
+ const intent = registerSpawnIntent({
307
+ toolCallId,
308
+ label,
309
+ deviceId: deviceIdRaw,
310
+ parentRunId: evt.runId,
311
+ requesterSessionKey: sk || undefined,
312
+ });
313
+ sseEmitter.broadcast(
314
+ {
315
+ type: "subagent",
316
+ data: {
317
+ phase: "spawning",
318
+ childSessionKey: null,
319
+ runId: null,
320
+ label: intent.label ?? null,
321
+ parentRunId: intent.parentRunId,
322
+ depth: intent.depth,
323
+ deviceId: intent.deviceId,
324
+ },
325
+ },
326
+ intent.deviceId,
327
+ );
328
+ }
329
+ }
330
+
331
+ // Phase 2: spawned — tool.result with childSessionKey + runId
332
+ if (isSpawnTool && evt.data.phase === "result") {
333
+ const details = (evt.data.result as Record<string, unknown> | undefined)?.details as
334
+ | { childSessionKey?: string; runId?: string; taskName?: string }
335
+ | undefined;
336
+ if (details?.childSessionKey) {
337
+ const toolCallId =
338
+ typeof evt.data.toolCallId === "string" ? evt.data.toolCallId : "";
339
+ const intent = toolCallId ? consumeSpawnIntent(toolCallId) : undefined;
340
+ const label =
341
+ details.taskName ||
342
+ intent?.label ||
343
+ (typeof (evt.data as Record<string, unknown>).meta === "string"
344
+ ? ((evt.data as Record<string, unknown>).meta as string)
345
+ : undefined);
346
+ const entry = ensureSubagentFromSpawnTool({
347
+ childSessionKey: details.childSessionKey,
348
+ bareRunId: details.runId,
349
+ label,
350
+ deviceId: deviceIdRaw,
351
+ parentRunId: intent?.parentRunId ?? evt.runId,
352
+ requesterSessionKey: sk,
353
+ depth: intent?.depth,
354
+ });
355
+ const compoundRunId = entry.runId ?? evt.runId;
356
+ sseEmitter.trackDeviceForRun(entry.deviceId, compoundRunId);
357
+ sseEmitter.broadcast(
358
+ {
359
+ type: "subagent",
360
+ data: {
361
+ phase: "spawned",
362
+ runId: compoundRunId,
363
+ childSessionKey: entry.childSessionKey,
364
+ label: entry.label ?? null,
365
+ parentRunId: entry.parentRunId ?? null,
366
+ depth: entry.depth,
367
+ deviceId: entry.deviceId,
368
+ },
369
+ },
370
+ entry.deviceId,
371
+ );
372
+ }
373
+ }
374
+
375
+ const subagentEntry = lookupByRunId(evt.runId);
376
+ // Only annotate events that originate from the subagent itself
377
+ // (sessionKey matches childSessionKey). Main-agent delivery events
378
+ // share the announce runId but have a different sessionKey.
379
+ const isSubagentOwnEvent =
380
+ subagentEntry && sk && subagentEntry.childSessionKey === sk;
381
+ const subagentMeta = isSubagentOwnEvent
382
+ ? { label: subagentEntry.label, parentRunId: subagentEntry.parentRunId, depth: subagentEntry.depth }
383
+ : undefined;
384
+
274
385
  let outgoingData: Record<string, unknown> = { ...evt.data };
275
386
 
276
387
  if (evt.stream === "thinking") {
@@ -299,6 +410,32 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
299
410
  evt.stream === "lifecycle" && typeof evt.data.phase === "string" ? evt.data.phase : "";
300
411
  const isTerminalLifecycle = evt.stream === "lifecycle" && (lifecyclePhase === "end" || lifecyclePhase === "error");
301
412
 
413
+ // Emit subagent ended SSE when a subagent run terminates
414
+ if (isTerminalLifecycle && isSubagentOwnEvent && subagentEntry.status !== "ended") {
415
+ const outcome = lifecyclePhase === "error" ? "error" : "ok";
416
+ const errorStr = lifecyclePhase === "error" ? String(evt.data.error ?? "unknown") : undefined;
417
+ const ended = registerSubagentEnded({ runId: evt.runId, outcome, error: errorStr });
418
+ if (ended) {
419
+ sseEmitter.broadcast(
420
+ {
421
+ type: "subagent",
422
+ data: {
423
+ phase: "ended",
424
+ runId: ended.runId ?? evt.runId ?? null,
425
+ childSessionKey: ended.childSessionKey,
426
+ label: ended.label ?? null,
427
+ parentRunId: ended.parentRunId ?? null,
428
+ depth: ended.depth,
429
+ deviceId: ended.deviceId,
430
+ outcome: ended.outcome ?? null,
431
+ error: ended.error ?? null,
432
+ },
433
+ },
434
+ ended.deviceId,
435
+ );
436
+ }
437
+ }
438
+
302
439
  if (isTerminalLifecycle && getFridayAgentForwardRuntime()) {
303
440
  setImmediate(() => {
304
441
  let data = outgoingData;
@@ -312,6 +449,7 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
312
449
  deviceIdRaw,
313
450
  outgoingData: data,
314
451
  isTerminalLifecycle: true,
452
+ subagentMeta,
315
453
  });
316
454
  });
317
455
  return;
@@ -323,5 +461,6 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
323
461
  deviceIdRaw,
324
462
  outgoingData,
325
463
  isTerminalLifecycle,
464
+ subagentMeta,
326
465
  });
327
466
  }
@@ -163,7 +163,6 @@ describe("handleDeviceApprove", () => {
163
163
  pending: [{ requestId: REQUEST_ID, deviceId: DEVICE_ID }],
164
164
  paired: [],
165
165
  }));
166
- // second call (approve) fails
167
166
  const approveErr = new Error("Command failed") as Error & { stderr: string };
168
167
  approveErr.stderr = "unknown requestId";
169
168
  mockExecErrorWithStderr(approveErr);