@syengup/friday-channel-next 0.0.35 → 0.0.38
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.
- package/dist/index.d.ts +4 -0
- package/dist/index.js +182 -0
- package/dist/src/agent/abort-run.d.ts +1 -0
- package/dist/src/agent/abort-run.js +11 -0
- package/dist/src/agent/active-runs.d.ts +9 -0
- package/dist/src/agent/active-runs.js +20 -0
- package/dist/src/agent/dispatch-bridge.d.ts +5 -0
- package/dist/src/agent/dispatch-bridge.js +12 -0
- package/dist/src/agent/media-bridge.d.ts +4 -0
- package/dist/src/agent/media-bridge.js +21 -0
- package/dist/src/agent/subagent-registry.d.ts +68 -0
- package/dist/src/agent/subagent-registry.js +142 -0
- package/dist/src/agent-forward-runtime.d.ts +17 -0
- package/dist/src/agent-forward-runtime.js +16 -0
- package/dist/src/agent-run-context-bridge.d.ts +13 -0
- package/dist/src/agent-run-context-bridge.js +23 -0
- package/dist/src/channel-actions.d.ts +13 -0
- package/dist/src/channel-actions.js +101 -0
- package/dist/src/channel.d.ts +6 -0
- package/dist/src/channel.js +248 -0
- package/dist/src/collect-message-media-paths.d.ts +11 -0
- package/dist/src/collect-message-media-paths.js +143 -0
- package/dist/src/config.d.ts +15 -0
- package/dist/src/config.js +39 -0
- package/dist/src/friday-inbound-stats.d.ts +2 -0
- package/dist/src/friday-inbound-stats.js +8 -0
- package/dist/src/friday-session.d.ts +40 -0
- package/dist/src/friday-session.js +395 -0
- package/dist/src/host-config.d.ts +1 -0
- package/dist/src/host-config.js +15 -0
- package/dist/src/http/handlers/cancel.d.ts +2 -0
- package/dist/src/http/handlers/cancel.js +33 -0
- package/dist/src/http/handlers/device-approve.d.ts +2 -0
- package/dist/src/http/handlers/device-approve.js +125 -0
- package/dist/src/http/handlers/files-download.d.ts +10 -0
- package/dist/src/http/handlers/files-download.js +210 -0
- package/dist/src/http/handlers/files-upload.d.ts +8 -0
- package/dist/src/http/handlers/files-upload.js +136 -0
- package/dist/src/http/handlers/files.d.ts +75 -0
- package/dist/src/http/handlers/files.js +305 -0
- package/dist/src/http/handlers/messages.d.ts +34 -0
- package/dist/src/http/handlers/messages.js +476 -0
- package/dist/src/http/handlers/models-list.d.ts +10 -0
- package/dist/src/http/handlers/models-list.js +113 -0
- package/dist/src/http/handlers/nodes-approve.d.ts +2 -0
- package/dist/src/http/handlers/nodes-approve.js +146 -0
- package/dist/src/http/handlers/sessions-delete.d.ts +2 -0
- package/dist/src/http/handlers/sessions-delete.js +49 -0
- package/dist/src/http/handlers/sessions-settings.d.ts +2 -0
- package/dist/src/http/handlers/sessions-settings.js +71 -0
- package/dist/src/http/handlers/sse.d.ts +2 -0
- package/dist/src/http/handlers/sse.js +70 -0
- package/dist/src/http/handlers/status.d.ts +2 -0
- package/dist/src/http/handlers/status.js +29 -0
- package/dist/src/http/middleware/auth.d.ts +13 -0
- package/dist/src/http/middleware/auth.js +29 -0
- package/dist/src/http/middleware/body.d.ts +2 -0
- package/dist/src/http/middleware/body.js +24 -0
- package/dist/src/http/middleware/cors.d.ts +2 -0
- package/dist/src/http/middleware/cors.js +11 -0
- package/dist/src/http/server.d.ts +19 -0
- package/dist/src/http/server.js +87 -0
- package/dist/src/logging.d.ts +7 -0
- package/dist/src/logging.js +28 -0
- package/dist/src/run-metadata.d.ts +25 -0
- package/dist/src/run-metadata.js +139 -0
- package/dist/src/runtime.d.ts +13 -0
- package/dist/src/runtime.js +5 -0
- package/dist/src/session/session-manager.d.ts +22 -0
- package/dist/src/session/session-manager.js +190 -0
- package/dist/src/session-usage-snapshot.d.ts +23 -0
- package/dist/src/session-usage-snapshot.js +65 -0
- package/dist/src/sse/emitter.d.ts +59 -0
- package/dist/src/sse/emitter.js +219 -0
- package/dist/src/sse/offline-queue.d.ts +26 -0
- package/dist/src/sse/offline-queue.js +134 -0
- package/dist/src/vendor/runtime-store.d.ts +26 -0
- package/dist/src/vendor/runtime-store.js +60 -0
- package/index.ts +10 -4
- package/package.json +11 -10
- package/src/agent/subagent-registry.ts +195 -0
- package/src/channel.ts +6 -4
- package/src/e2e/subagent-smoke.e2e.test.ts +223 -0
- package/src/e2e/subagent.e2e.test.ts +502 -0
- package/src/friday-session.ts +140 -1
- package/src/http/handlers/device-approve.test.ts +0 -1
- package/src/http/handlers/device-approve.ts +0 -2
- package/src/http/handlers/files-download.ts +4 -1
- package/src/http/handlers/files.ts +7 -4
- package/src/http/handlers/messages.ts +54 -4
- package/src/http/handlers/models-list.ts +24 -2
- package/src/http/handlers/nodes-approve.test.ts +288 -0
- package/src/http/handlers/nodes-approve.ts +189 -0
- package/src/http/server.ts +5 -0
- package/src/openclaw.d.ts +5 -0
- package/src/sse/emitter.ts +1 -1
- 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
|
+
});
|
package/src/friday-session.ts
CHANGED
|
@@ -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);
|