@syengup/friday-channel-next 0.1.36 → 0.1.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.js +1 -1
- package/dist/src/agent/dispatch-bridge.d.ts +1 -1
- package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
- package/dist/src/agent/node-pairing-bridge.js +6 -2
- package/dist/src/agent/operator-scope.d.ts +19 -0
- package/dist/src/agent/operator-scope.js +54 -0
- package/dist/src/agent/subagent-registry.js +0 -3
- package/dist/src/channel-actions.js +3 -1
- package/dist/src/channel.js +0 -2
- package/dist/src/collect-message-media-paths.js +10 -1
- package/dist/src/friday-session.js +34 -10
- package/dist/src/history/normalize-message.js +22 -8
- package/dist/src/http/handlers/agent-config.js +10 -4
- package/dist/src/http/handlers/cancel.js +4 -2
- package/dist/src/http/handlers/device-approve.js +3 -1
- package/dist/src/http/handlers/files-download.js +6 -8
- package/dist/src/http/handlers/files.js +1 -1
- package/dist/src/http/handlers/health.js +18 -4
- package/dist/src/http/handlers/history-messages.js +1 -1
- package/dist/src/http/handlers/history-sessions.js +5 -3
- package/dist/src/http/handlers/messages.js +34 -11
- package/dist/src/http/handlers/models-list.js +1 -1
- package/dist/src/http/handlers/nodes-approve.js +1 -6
- package/dist/src/http/handlers/plugin-info.js +1 -1
- package/dist/src/http/server.js +4 -2
- package/dist/src/link-preview/og-parse.js +3 -1
- package/dist/src/plugin-install-info.js +4 -1
- package/dist/src/session/session-manager.js +9 -3
- package/dist/src/session-usage-store.js +3 -1
- package/dist/src/skills-discovery.d.ts +5 -4
- package/dist/src/skills-discovery.js +27 -22
- package/dist/src/sse/offline-queue.js +4 -1
- package/dist/src/tool-catalog.js +2 -3
- package/dist/src/upgrade-runtime.d.ts +1 -1
- package/dist/src/version.js +3 -1
- package/index.ts +43 -35
- package/install.js +131 -43
- package/package.json +10 -1
- package/src/agent/abort-run.ts +2 -3
- package/src/agent/dispatch-bridge.ts +2 -1
- package/src/agent/media-bridge.ts +9 -2
- package/src/agent/node-pairing-bridge.ts +29 -15
- package/src/agent/operator-scope.test.ts +66 -0
- package/src/agent/operator-scope.ts +63 -0
- package/src/agent/run-usage-accumulator.ts +4 -2
- package/src/agent/subagent-registry.ts +0 -4
- package/src/agent-run-context-bridge.ts +3 -1
- package/src/channel-actions.test.ts +10 -4
- package/src/channel-actions.ts +3 -1
- package/src/channel.outbound.test.ts +18 -4
- package/src/channel.ts +121 -123
- package/src/collect-message-media-paths.ts +15 -6
- package/src/config.ts +1 -4
- package/src/e2e/agents-list.e2e.test.ts +9 -2
- package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
- package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
- package/src/e2e/auto-approve.integration.test.ts +13 -7
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
- package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
- package/src/e2e/offline-replay.e2e.test.ts +17 -3
- package/src/e2e/send-text.e2e.test.ts +11 -2
- package/src/e2e/slash-commands.e2e.test.ts +5 -1
- package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
- package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
- package/src/e2e/subagent.e2e.test.ts +136 -53
- package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
- package/src/friday-session.forward-agent.test.ts +44 -12
- package/src/friday-session.ts +44 -20
- package/src/history/normalize-message.test.ts +35 -8
- package/src/history/normalize-message.ts +24 -12
- package/src/history/read-transcript.ts +1 -4
- package/src/http/handlers/agent-config.test.ts +10 -3
- package/src/http/handlers/agent-config.ts +22 -8
- package/src/http/handlers/agents-list.test.ts +1 -5
- package/src/http/handlers/cancel.test.ts +12 -3
- package/src/http/handlers/cancel.ts +4 -2
- package/src/http/handlers/device-approve.test.ts +12 -3
- package/src/http/handlers/device-approve.ts +33 -21
- package/src/http/handlers/files-download.ts +17 -13
- package/src/http/handlers/files.test.ts +8 -2
- package/src/http/handlers/files.ts +21 -7
- package/src/http/handlers/health.test.ts +43 -11
- package/src/http/handlers/health.ts +22 -6
- package/src/http/handlers/history-messages.test.ts +51 -9
- package/src/http/handlers/history-messages.ts +4 -1
- package/src/http/handlers/history-sessions.test.ts +46 -9
- package/src/http/handlers/history-sessions.ts +5 -3
- package/src/http/handlers/history-set-title.test.ts +14 -5
- package/src/http/handlers/link-preview.test.ts +57 -16
- package/src/http/handlers/link-preview.ts +4 -1
- package/src/http/handlers/messages.test.ts +12 -8
- package/src/http/handlers/messages.ts +67 -19
- package/src/http/handlers/models-list.ts +14 -8
- package/src/http/handlers/nodes-approve.test.ts +15 -4
- package/src/http/handlers/nodes-approve.ts +38 -40
- package/src/http/handlers/plugin-info.ts +5 -6
- package/src/http/handlers/plugin-upgrade.ts +4 -1
- package/src/http/handlers/sse.ts +3 -1
- package/src/http/server.ts +9 -6
- package/src/link-preview/og-parse.test.ts +6 -2
- package/src/link-preview/og-parse.ts +10 -3
- package/src/link-preview/preview-service.ts +4 -1
- package/src/link-preview/ssrf-guard.test.ts +72 -15
- package/src/link-preview/ssrf-guard.ts +2 -1
- package/src/media-fetch.test.ts +7 -2
- package/src/media-fetch.ts +1 -2
- package/src/openclaw.d.ts +26 -9
- package/src/plugin-install-info.ts +20 -9
- package/src/run-metadata.ts +2 -1
- package/src/session/session-manager.ts +19 -11
- package/src/session-usage-snapshot.ts +3 -1
- package/src/session-usage-store.ts +3 -1
- package/src/skills-discovery.test.ts +14 -10
- package/src/skills-discovery.ts +43 -27
- package/src/sse/emitter.test.ts +1 -1
- package/src/sse/emitter.ts +9 -3
- package/src/sse/offline-queue.ts +17 -8
- package/src/test-support/app-simulator.ts +17 -3
- package/src/test-support/mock-dispatch.ts +17 -4
- package/src/thinking-levels.ts +3 -1
- package/src/tool-catalog.ts +16 -7
- package/src/upgrade-runtime.ts +4 -2
- package/src/version.ts +5 -1
- package/tsconfig.json +1 -1
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
resetThinkingStreamAccumStateForTest,
|
|
11
11
|
} from "./friday-session.js";
|
|
12
12
|
import { resetRunMetadataForTest } from "./run-metadata.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
accumulateRunUsage,
|
|
15
|
+
resetRunUsageAccumulatorForTest,
|
|
16
|
+
} from "./agent/run-usage-accumulator.js";
|
|
14
17
|
import { sseEmitter } from "./sse/emitter.js";
|
|
15
18
|
import { toSessionStoreKey } from "./session/session-manager.js";
|
|
16
19
|
|
|
@@ -56,8 +59,10 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(2);
|
|
59
|
-
const first = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data
|
|
60
|
-
|
|
62
|
+
const first = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data
|
|
63
|
+
.data;
|
|
64
|
+
const second = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[1][1].data
|
|
65
|
+
.data;
|
|
61
66
|
|
|
62
67
|
expect(first.text).toBe(t1);
|
|
63
68
|
expect(first.delta).toBe(t1);
|
|
@@ -94,7 +99,8 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
94
99
|
});
|
|
95
100
|
|
|
96
101
|
expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(3);
|
|
97
|
-
const third = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[2][1].data
|
|
102
|
+
const third = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[2][1].data
|
|
103
|
+
.data;
|
|
98
104
|
expect(third.delta).toBe(t1);
|
|
99
105
|
expect(third.reasoningPrefixChars).toBe(0);
|
|
100
106
|
});
|
|
@@ -123,7 +129,8 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
123
129
|
data: { text: t1, delta: t1 },
|
|
124
130
|
});
|
|
125
131
|
|
|
126
|
-
const third = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[2][1].data
|
|
132
|
+
const third = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[2][1].data
|
|
133
|
+
.data;
|
|
127
134
|
expect(third.reasoningPrefixChars).toBe(0);
|
|
128
135
|
expect(third.delta).toBe(t1);
|
|
129
136
|
});
|
|
@@ -179,7 +186,8 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
179
186
|
});
|
|
180
187
|
|
|
181
188
|
expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(2);
|
|
182
|
-
const endPayload = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[1][1]
|
|
189
|
+
const endPayload = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[1][1]
|
|
190
|
+
.data;
|
|
183
191
|
expect(endPayload.stream).toBe("lifecycle");
|
|
184
192
|
expect((endPayload.data as { phase?: string }).phase).toBe("end");
|
|
185
193
|
expect(endPayload.sessionKey).toBe(sessionKey);
|
|
@@ -214,8 +222,18 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
214
222
|
},
|
|
215
223
|
} as never);
|
|
216
224
|
|
|
217
|
-
accumulateRunUsage(
|
|
218
|
-
|
|
225
|
+
accumulateRunUsage(
|
|
226
|
+
runId,
|
|
227
|
+
{ input: 100, output: 50, cacheRead: 10, total: 150 },
|
|
228
|
+
"my-model",
|
|
229
|
+
"openai",
|
|
230
|
+
);
|
|
231
|
+
accumulateRunUsage(
|
|
232
|
+
runId,
|
|
233
|
+
{ input: 30, output: 10, cacheRead: 0, total: 40 },
|
|
234
|
+
"my-model",
|
|
235
|
+
"openai",
|
|
236
|
+
);
|
|
219
237
|
|
|
220
238
|
forwardAgentEventRaw({
|
|
221
239
|
runId,
|
|
@@ -232,7 +250,10 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
232
250
|
expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
|
|
233
251
|
const forwarded = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data;
|
|
234
252
|
expect(forwarded.stream).toBe("lifecycle");
|
|
235
|
-
const sessionUsage = (forwarded.data as Record<string, unknown>).sessionUsage as Record<
|
|
253
|
+
const sessionUsage = (forwarded.data as Record<string, unknown>).sessionUsage as Record<
|
|
254
|
+
string,
|
|
255
|
+
unknown
|
|
256
|
+
>;
|
|
236
257
|
expect(sessionUsage).toBeDefined();
|
|
237
258
|
// llm_output fallback — per-run totals.
|
|
238
259
|
expect(sessionUsage.modelId).toBe("my-model");
|
|
@@ -272,7 +293,12 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
272
293
|
} as never);
|
|
273
294
|
|
|
274
295
|
// llm_output has fresher model/provider but per-run (smaller) tokens.
|
|
275
|
-
accumulateRunUsage(
|
|
296
|
+
accumulateRunUsage(
|
|
297
|
+
runId,
|
|
298
|
+
{ input: 500, output: 100, cacheRead: 200, total: 800 },
|
|
299
|
+
"llm-model",
|
|
300
|
+
"llm-provider",
|
|
301
|
+
);
|
|
276
302
|
|
|
277
303
|
forwardAgentEventRaw({
|
|
278
304
|
runId,
|
|
@@ -287,7 +313,10 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
287
313
|
|
|
288
314
|
expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
|
|
289
315
|
const forwarded = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data;
|
|
290
|
-
const sessionUsage = (forwarded.data as Record<string, unknown>).sessionUsage as Record<
|
|
316
|
+
const sessionUsage = (forwarded.data as Record<string, unknown>).sessionUsage as Record<
|
|
317
|
+
string,
|
|
318
|
+
unknown
|
|
319
|
+
>;
|
|
291
320
|
expect(sessionUsage).toBeDefined();
|
|
292
321
|
// Store cumulative totals win.
|
|
293
322
|
expect((sessionUsage.tokens as Record<string, unknown>).input).toBe(5000);
|
|
@@ -344,7 +373,10 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
344
373
|
expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
|
|
345
374
|
const forwarded = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data;
|
|
346
375
|
expect(forwarded.stream).toBe("lifecycle");
|
|
347
|
-
const sessionUsage = (forwarded.data as Record<string, unknown>).sessionUsage as Record<
|
|
376
|
+
const sessionUsage = (forwarded.data as Record<string, unknown>).sessionUsage as Record<
|
|
377
|
+
string,
|
|
378
|
+
unknown
|
|
379
|
+
>;
|
|
348
380
|
expect(sessionUsage).toBeDefined();
|
|
349
381
|
expect(sessionUsage.modelId).toBe("store-model");
|
|
350
382
|
expect(sessionUsage.modelProvider).toBe("store-provider");
|
package/src/friday-session.ts
CHANGED
|
@@ -49,7 +49,7 @@ export function deviceIdFromSessionKey(sessionKey: string): string | null {
|
|
|
49
49
|
const m1 = sessionKey.match(/^friday-next-(.+)$/i);
|
|
50
50
|
if (m1) return m1[1] ?? null;
|
|
51
51
|
const m2 = sessionKey.match(/^agent:main:friday-next-(.+)$/i);
|
|
52
|
-
return m2 ? m2[1] ?? null : null;
|
|
52
|
+
return m2 ? (m2[1] ?? null) : null;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
@@ -66,7 +66,8 @@ const deviceIdToLatestHistorySessionKey = new Map<string, string>();
|
|
|
66
66
|
let lastRegisteredFridayDeviceId: string | undefined;
|
|
67
67
|
|
|
68
68
|
function normalizeFridaySessionKeyCase(sk: string): string {
|
|
69
|
-
return /^friday-next-|^agent:main:friday-next-/i.test(sk) ||
|
|
69
|
+
return /^friday-next-|^agent:main:friday-next-/i.test(sk) ||
|
|
70
|
+
/^agent:main:friday-next:direct:/i.test(sk)
|
|
70
71
|
? sk.toLowerCase()
|
|
71
72
|
: sk;
|
|
72
73
|
}
|
|
@@ -157,7 +158,11 @@ function mergeRunMetadataIntoLifecycleEnd(
|
|
|
157
158
|
if (typeof meta.modelName === "string" && meta.modelName.trim()) {
|
|
158
159
|
extra.modelName = meta.modelName.trim();
|
|
159
160
|
}
|
|
160
|
-
if (
|
|
161
|
+
if (
|
|
162
|
+
typeof meta.totalTokens === "number" &&
|
|
163
|
+
Number.isFinite(meta.totalTokens) &&
|
|
164
|
+
meta.totalTokens > 0
|
|
165
|
+
) {
|
|
161
166
|
extra.totalTokens = Math.floor(meta.totalTokens);
|
|
162
167
|
}
|
|
163
168
|
if (
|
|
@@ -260,7 +265,10 @@ function completeAgentEventForward(params: {
|
|
|
260
265
|
/**
|
|
261
266
|
* Resolve the real device UUID for Friday outbound (`sendText` / `sendMedia`).
|
|
262
267
|
*/
|
|
263
|
-
export function resolveFridayDeviceIdForOutbound(
|
|
268
|
+
export function resolveFridayDeviceIdForOutbound(
|
|
269
|
+
to: string | undefined,
|
|
270
|
+
rawCtx?: Record<string, unknown>,
|
|
271
|
+
): string {
|
|
264
272
|
const trimmed = (to ?? "").trim();
|
|
265
273
|
if (trimmed && trimmed.toLowerCase() !== "friday-next") {
|
|
266
274
|
return trimmed;
|
|
@@ -279,6 +287,23 @@ export function resolveFridayDeviceIdForOutbound(to: string | undefined, rawCtx?
|
|
|
279
287
|
return trimmed || "friday-next";
|
|
280
288
|
}
|
|
281
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Stringify a subagent error payload for the wire. `evt.data.error` comes from an
|
|
292
|
+
* in-process SDK callback, so it can be a real Error, a plain object, or a value
|
|
293
|
+
* that JSON.stringify would throw on (circular/BigInt) — keep this total and
|
|
294
|
+
* never-throwing, since it runs on the error path that emits `subagent ended`.
|
|
295
|
+
*/
|
|
296
|
+
function stringifySubagentError(raw: unknown): string {
|
|
297
|
+
if (raw == null) return "unknown";
|
|
298
|
+
if (typeof raw === "string") return raw;
|
|
299
|
+
if (raw instanceof Error) return raw.message || raw.name || "error";
|
|
300
|
+
try {
|
|
301
|
+
return JSON.stringify(raw) ?? "unknown";
|
|
302
|
+
} catch {
|
|
303
|
+
return "unstringifiable error";
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
282
307
|
/**
|
|
283
308
|
* Forward global OpenClaw agent events to the Friday SSE connection (transparent).
|
|
284
309
|
*
|
|
@@ -308,8 +333,7 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
|
|
|
308
333
|
if (!deviceIdRaw) return;
|
|
309
334
|
|
|
310
335
|
if (!sk) {
|
|
311
|
-
sk =
|
|
312
|
-
latestHistorySessionKeyForDeviceId(deviceIdRaw) ?? `friday-next-${deviceIdRaw}`;
|
|
336
|
+
sk = latestHistorySessionKeyForDeviceId(deviceIdRaw) ?? `friday-next-${deviceIdRaw}`;
|
|
313
337
|
}
|
|
314
338
|
|
|
315
339
|
openClawRunIdToDeviceId.set(evt.runId, deviceIdRaw.toUpperCase());
|
|
@@ -325,11 +349,9 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
|
|
|
325
349
|
|
|
326
350
|
// Phase 1: spawning — tool.start with taskName in args
|
|
327
351
|
if (isSpawnTool && evt.data.phase === "start") {
|
|
328
|
-
const toolCallId =
|
|
329
|
-
typeof evt.data.toolCallId === "string" ? evt.data.toolCallId : "";
|
|
352
|
+
const toolCallId = typeof evt.data.toolCallId === "string" ? evt.data.toolCallId : "";
|
|
330
353
|
const args = evt.data.args as Record<string, unknown> | undefined;
|
|
331
|
-
const label =
|
|
332
|
-
typeof args?.taskName === "string" ? args.taskName : undefined;
|
|
354
|
+
const label = typeof args?.taskName === "string" ? args.taskName : undefined;
|
|
333
355
|
if (toolCallId) {
|
|
334
356
|
const intent = registerSpawnIntent({
|
|
335
357
|
toolCallId,
|
|
@@ -362,15 +384,12 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
|
|
|
362
384
|
| { childSessionKey?: string; runId?: string; taskName?: string }
|
|
363
385
|
| undefined;
|
|
364
386
|
if (details?.childSessionKey) {
|
|
365
|
-
const toolCallId =
|
|
366
|
-
typeof evt.data.toolCallId === "string" ? evt.data.toolCallId : "";
|
|
387
|
+
const toolCallId = typeof evt.data.toolCallId === "string" ? evt.data.toolCallId : "";
|
|
367
388
|
const intent = toolCallId ? consumeSpawnIntent(toolCallId) : undefined;
|
|
368
389
|
const label =
|
|
369
390
|
details.taskName ||
|
|
370
391
|
intent?.label ||
|
|
371
|
-
(typeof
|
|
372
|
-
? ((evt.data as Record<string, unknown>).meta as string)
|
|
373
|
-
: undefined);
|
|
392
|
+
(typeof evt.data.meta === "string" ? evt.data.meta : undefined);
|
|
374
393
|
const entry = ensureSubagentFromSpawnTool({
|
|
375
394
|
childSessionKey: details.childSessionKey,
|
|
376
395
|
bareRunId: details.runId,
|
|
@@ -404,10 +423,13 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
|
|
|
404
423
|
// Only annotate events that originate from the subagent itself
|
|
405
424
|
// (sessionKey matches childSessionKey). Main-agent delivery events
|
|
406
425
|
// share the announce runId but have a different sessionKey.
|
|
407
|
-
const isSubagentOwnEvent =
|
|
408
|
-
subagentEntry && sk && subagentEntry.childSessionKey === sk;
|
|
426
|
+
const isSubagentOwnEvent = subagentEntry && sk && subagentEntry.childSessionKey === sk;
|
|
409
427
|
const subagentMeta = isSubagentOwnEvent
|
|
410
|
-
? {
|
|
428
|
+
? {
|
|
429
|
+
label: subagentEntry.label,
|
|
430
|
+
parentRunId: subagentEntry.parentRunId,
|
|
431
|
+
depth: subagentEntry.depth,
|
|
432
|
+
}
|
|
411
433
|
: undefined;
|
|
412
434
|
|
|
413
435
|
let outgoingData: Record<string, unknown> = { ...evt.data };
|
|
@@ -436,12 +458,14 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
|
|
|
436
458
|
|
|
437
459
|
const lifecyclePhase =
|
|
438
460
|
evt.stream === "lifecycle" && typeof evt.data.phase === "string" ? evt.data.phase : "";
|
|
439
|
-
const isTerminalLifecycle =
|
|
461
|
+
const isTerminalLifecycle =
|
|
462
|
+
evt.stream === "lifecycle" && (lifecyclePhase === "end" || lifecyclePhase === "error");
|
|
440
463
|
|
|
441
464
|
// Emit subagent ended SSE when a subagent run terminates
|
|
442
465
|
if (isTerminalLifecycle && isSubagentOwnEvent && subagentEntry.status !== "ended") {
|
|
443
466
|
const outcome = lifecyclePhase === "error" ? "error" : "ok";
|
|
444
|
-
const errorStr =
|
|
467
|
+
const errorStr =
|
|
468
|
+
lifecyclePhase === "error" ? stringifySubagentError(evt.data.error) : undefined;
|
|
445
469
|
const ended = registerSubagentEnded({ runId: evt.runId, outcome, error: errorStr });
|
|
446
470
|
if (ended) {
|
|
447
471
|
sseEmitter.broadcast(
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
normalizeHistoryMessage,
|
|
4
|
-
normalizeHistoryMessages,
|
|
5
|
-
} from "./normalize-message.js";
|
|
2
|
+
import { normalizeHistoryMessage, normalizeHistoryMessages } from "./normalize-message.js";
|
|
6
3
|
|
|
7
4
|
function meta(id?: string, seq = 1, extra: Record<string, unknown> = {}) {
|
|
8
5
|
return { __openclaw: { ...(id ? { id } : {}), seq, recordTimestampMs: 1700000000000, ...extra } };
|
|
@@ -124,7 +121,7 @@ describe("normalizeHistoryMessage", () => {
|
|
|
124
121
|
expect(out?.toolResult?.images).toBeUndefined();
|
|
125
122
|
});
|
|
126
123
|
|
|
127
|
-
it("keeps image blocks on
|
|
124
|
+
it("keeps image blocks on image-producing (image_generation) toolResults", () => {
|
|
128
125
|
const out = normalizeHistoryMessage(
|
|
129
126
|
{
|
|
130
127
|
role: "toolResult",
|
|
@@ -138,11 +135,34 @@ describe("normalizeHistoryMessage", () => {
|
|
|
138
135
|
expect(out?.toolResult?.images).toEqual([{ mimeType: "image/png", data: "REALIMG" }]);
|
|
139
136
|
});
|
|
140
137
|
|
|
138
|
+
it("drops inline image blocks on read toolResults (agent visual input, not an attachment)", () => {
|
|
139
|
+
// The `read` tool returns the file it fed to the model as an inline base64
|
|
140
|
+
// image so the agent can "see" it. That is NOT a user-facing attachment —
|
|
141
|
+
// surfacing it spawned phantom corrupt image bubbles on history rebuild for
|
|
142
|
+
// turns where the agent only LOOKED at a file and sent nothing.
|
|
143
|
+
const out = normalizeHistoryMessage(
|
|
144
|
+
{
|
|
145
|
+
role: "toolResult",
|
|
146
|
+
toolCallId: "tc-read",
|
|
147
|
+
toolName: "read",
|
|
148
|
+
content: [
|
|
149
|
+
{ type: "text", text: "Read image file [image/jpeg]" },
|
|
150
|
+
{ type: "image", mimeType: "image/jpeg", data: "AGENTVISUALINPUT" },
|
|
151
|
+
],
|
|
152
|
+
...meta("entry-read", 7),
|
|
153
|
+
},
|
|
154
|
+
0,
|
|
155
|
+
);
|
|
156
|
+
expect(out?.toolResult?.images).toBeUndefined();
|
|
157
|
+
expect(out?.toolResult?.text).toBe("Read image file [image/jpeg]");
|
|
158
|
+
});
|
|
159
|
+
|
|
141
160
|
it("strips MEDIA: lines from text into mediaPaths", () => {
|
|
142
161
|
const out = normalizeHistoryMessage(
|
|
143
162
|
{
|
|
144
163
|
role: "assistant",
|
|
145
|
-
content:
|
|
164
|
+
content:
|
|
165
|
+
"Here is the serene landscape 🌅\nMEDIA:/Users/me/.openclaw/media/tool-image-generation/x.png",
|
|
146
166
|
...meta("a1", 1),
|
|
147
167
|
},
|
|
148
168
|
0,
|
|
@@ -158,13 +178,20 @@ describe("normalizeHistoryMessage", () => {
|
|
|
158
178
|
);
|
|
159
179
|
expect(out?.text).toBe("two files");
|
|
160
180
|
expect(out?.mediaPaths).toEqual(["/a/x.png", "/a/y.mp4"]);
|
|
161
|
-
const plain = normalizeHistoryMessage(
|
|
181
|
+
const plain = normalizeHistoryMessage(
|
|
182
|
+
{ role: "user", content: "no media here", ...meta("u", 1) },
|
|
183
|
+
0,
|
|
184
|
+
);
|
|
162
185
|
expect(plain?.mediaPaths).toBeUndefined();
|
|
163
186
|
});
|
|
164
187
|
|
|
165
188
|
it("flags compaction records via __openclaw.kind", () => {
|
|
166
189
|
const out = normalizeHistoryMessage(
|
|
167
|
-
{
|
|
190
|
+
{
|
|
191
|
+
role: "system",
|
|
192
|
+
content: [{ type: "text", text: "Compaction" }],
|
|
193
|
+
...meta("c1", 9, { kind: "compaction" }),
|
|
194
|
+
},
|
|
168
195
|
0,
|
|
169
196
|
);
|
|
170
197
|
expect(out?.kind).toBe("compaction");
|
|
@@ -104,6 +104,17 @@ function splitMediaLines(text: string): { text: string; paths: string[] } {
|
|
|
104
104
|
return { text: cleaned, paths };
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Tools whose `toolResult` carries a user-facing PRODUCED image, which stays a
|
|
109
|
+
* chat attachment. Every OTHER tool's inline image block is the agent's visual
|
|
110
|
+
* INPUT — a file the `read` tool fed to the model, a `canvas` snapshot, a
|
|
111
|
+
* browser screenshot — and must NOT surface as an attachment on history rebuild
|
|
112
|
+
* (it spawns phantom, often-corrupt bubbles for turns where the agent never
|
|
113
|
+
* sent a file). Keep this a whitelist so any new image-CONSUMING tool is safe by
|
|
114
|
+
* default; add new image-PRODUCING tools here explicitly.
|
|
115
|
+
*/
|
|
116
|
+
const IMAGE_PRODUCING_TOOLS = new Set(["image_generation"]);
|
|
117
|
+
|
|
107
118
|
const MEDIA_MARKER_RE = /\[media attached:\s*([^\]]+)\]/gi;
|
|
108
119
|
|
|
109
120
|
/** Pull `[media attached: <url>]` markers out of free text into image refs. */
|
|
@@ -219,10 +230,7 @@ function normalizeRole(raw: unknown): FridayHistoryRole {
|
|
|
219
230
|
* Normalize one raw transcript message. `index` is the position in the returned
|
|
220
231
|
* batch, used only to synthesize a stable-ish id when upstream omits one.
|
|
221
232
|
*/
|
|
222
|
-
export function normalizeHistoryMessage(
|
|
223
|
-
raw: unknown,
|
|
224
|
-
index: number,
|
|
225
|
-
): FridayHistoryMessage | null {
|
|
233
|
+
export function normalizeHistoryMessage(raw: unknown, index: number): FridayHistoryMessage | null {
|
|
226
234
|
const record = asRecord(raw);
|
|
227
235
|
if (!record) return null;
|
|
228
236
|
|
|
@@ -254,14 +262,18 @@ export function normalizeHistoryMessage(
|
|
|
254
262
|
if (role === "toolResult") {
|
|
255
263
|
const split = splitMediaLines(parsed.text);
|
|
256
264
|
const toolName = readString(record.toolName);
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
+
// Inline image blocks on a toolResult are almost always the agent's visual
|
|
266
|
+
// INPUT — a file the `read` tool fed to the model, a `canvas` snapshot (so the
|
|
267
|
+
// agent can "see" the rendered page), a browser screenshot — NOT a user-facing
|
|
268
|
+
// attachment. Surfacing them spawns phantom, often-corrupt attachment bubbles on
|
|
269
|
+
// history rebuild for turns where the agent never sent a file. Only tools that
|
|
270
|
+
// PRODUCE a user-facing image keep their blocks. (This was a `canvas`-only
|
|
271
|
+
// blacklist, which still leaked `read`/screenshot images.) The streaming deliver
|
|
272
|
+
// path drops the canvas temp-file form separately (isCanvasSnapshotMediaPath in
|
|
273
|
+
// http/handlers/messages.ts); this is the transcript-rebuild counterpart.
|
|
274
|
+
const keepInlineImages = toolName ? IMAGE_PRODUCING_TOOLS.has(toolName) : false;
|
|
275
|
+
const images = keepInlineImages ? parsed.images : [];
|
|
276
|
+
const mediaPaths = toolName === "canvas" ? [] : split.paths;
|
|
265
277
|
const toolResult: FridayHistoryToolResult = {
|
|
266
278
|
...(readString(record.toolCallId) ? { toolCallId: readString(record.toolCallId) } : {}),
|
|
267
279
|
...(toolName ? { toolName } : {}),
|
|
@@ -40,10 +40,7 @@ function resolveEntry(store: Record<string, unknown>, sessionKey: string): unkno
|
|
|
40
40
|
return undefined;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export function resolveTranscriptPath(
|
|
44
|
-
entry: unknown,
|
|
45
|
-
storePath: string,
|
|
46
|
-
): string | undefined {
|
|
43
|
+
export function resolveTranscriptPath(entry: unknown, storePath: string): string | undefined {
|
|
47
44
|
const sessionFile = entryString(entry, "sessionFile");
|
|
48
45
|
if (sessionFile) {
|
|
49
46
|
return path.isAbsolute(sessionFile)
|
|
@@ -123,7 +123,9 @@ describe("handleAgentConfig", () => {
|
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
it("PUT sets the model on an existing entry", async () => {
|
|
126
|
-
const config: Record<string, unknown> = {
|
|
126
|
+
const config: Record<string, unknown> = {
|
|
127
|
+
agents: { list: [{ id: "main", model: "old/model" }] },
|
|
128
|
+
};
|
|
127
129
|
setRuntimes(config);
|
|
128
130
|
const res = new MockRes();
|
|
129
131
|
await handleAgentConfig(makeReq(AUTH, "PUT", { model: "openai/gpt-5" }), res as any, "main");
|
|
@@ -133,7 +135,9 @@ describe("handleAgentConfig", () => {
|
|
|
133
135
|
});
|
|
134
136
|
|
|
135
137
|
it("PUT model:null deletes the field so it inherits defaults", async () => {
|
|
136
|
-
const config: Record<string, unknown> = {
|
|
138
|
+
const config: Record<string, unknown> = {
|
|
139
|
+
agents: { list: [{ id: "main", model: "old/model" }] },
|
|
140
|
+
};
|
|
137
141
|
setRuntimes(config);
|
|
138
142
|
const res = new MockRes();
|
|
139
143
|
await handleAgentConfig(makeReq(AUTH, "PUT", { model: null }), res as any, "main");
|
|
@@ -197,7 +201,10 @@ describe("handleAgentConfig", () => {
|
|
|
197
201
|
setRuntimes({ agents: { list: [{ id: "main" }] } }, workspace);
|
|
198
202
|
const res = new MockRes();
|
|
199
203
|
await handleAgentConfig(makeReq(AUTH), res as any, "main");
|
|
200
|
-
expect(JSON.parse(res.body).availableSkills.map((s: { id: string }) => s.id)).toEqual([
|
|
204
|
+
expect(JSON.parse(res.body).availableSkills.map((s: { id: string }) => s.id)).toEqual([
|
|
205
|
+
"deep-research",
|
|
206
|
+
"verify",
|
|
207
|
+
]);
|
|
201
208
|
} finally {
|
|
202
209
|
fs.rmSync(workspace, { recursive: true, force: true });
|
|
203
210
|
}
|
|
@@ -60,7 +60,9 @@ function readString(value: unknown): string | undefined {
|
|
|
60
60
|
|
|
61
61
|
function readStringArray(value: unknown): string[] | undefined {
|
|
62
62
|
if (!Array.isArray(value)) return undefined;
|
|
63
|
-
const out = value
|
|
63
|
+
const out = value
|
|
64
|
+
.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
|
|
65
|
+
.map((v) => v.trim());
|
|
64
66
|
return out;
|
|
65
67
|
}
|
|
66
68
|
|
|
@@ -81,7 +83,9 @@ function readToolsConfig(value: unknown): AgentToolsConfig | undefined {
|
|
|
81
83
|
|
|
82
84
|
/** Locate the configured `agents.list[]` entry whose normalized id matches `agentId`. */
|
|
83
85
|
function findAgentEntry(cfg: unknown, agentId: string): Record<string, unknown> | undefined {
|
|
84
|
-
const agents = (cfg as Record<string, unknown> | undefined)?.agents as
|
|
86
|
+
const agents = (cfg as Record<string, unknown> | undefined)?.agents as
|
|
87
|
+
| Record<string, unknown>
|
|
88
|
+
| undefined;
|
|
85
89
|
const list = agents?.list as Array<Record<string, unknown>> | undefined;
|
|
86
90
|
if (!Array.isArray(list)) return undefined;
|
|
87
91
|
return list.find((a) => a && typeof a === "object" && normalizeAgentId(a.id) === agentId);
|
|
@@ -108,7 +112,11 @@ function buildConfigView(agentId: string): AgentConfigView {
|
|
|
108
112
|
/** A field present in the body: `undefined` = not sent (keep), `null` = clear, else new value. */
|
|
109
113
|
type Patch<T> = { sent: boolean; clear: boolean; value?: T };
|
|
110
114
|
|
|
111
|
-
function readPatch<T>(
|
|
115
|
+
function readPatch<T>(
|
|
116
|
+
body: Record<string, unknown>,
|
|
117
|
+
key: string,
|
|
118
|
+
coerce: (raw: unknown) => T | undefined,
|
|
119
|
+
): Patch<T> {
|
|
112
120
|
if (!(key in body)) return { sent: false, clear: false };
|
|
113
121
|
const raw = body[key];
|
|
114
122
|
if (raw === null) return { sent: true, clear: true };
|
|
@@ -117,7 +125,7 @@ function readPatch<T>(body: Record<string, unknown>, key: string, coerce: (raw:
|
|
|
117
125
|
return { sent: true, clear: false, value };
|
|
118
126
|
}
|
|
119
127
|
|
|
120
|
-
function coerceModel(raw: unknown): unknown
|
|
128
|
+
function coerceModel(raw: unknown): unknown {
|
|
121
129
|
if (typeof raw === "string") return raw.trim() || undefined;
|
|
122
130
|
if (raw && typeof raw === "object") {
|
|
123
131
|
const primary = readString((raw as Record<string, unknown>).primary);
|
|
@@ -134,7 +142,7 @@ function coerceTools(raw: unknown): AgentToolsConfig | undefined {
|
|
|
134
142
|
|
|
135
143
|
/** Skills: array (incl. empty = disable all) only; non-arrays are rejected upstream. */
|
|
136
144
|
function coerceSkills(raw: unknown): string[] | undefined {
|
|
137
|
-
return Array.isArray(raw) ? readStringArray(raw) ?? [] : undefined;
|
|
145
|
+
return Array.isArray(raw) ? (readStringArray(raw) ?? []) : undefined;
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
// --- handler -----------------------------------------------------------------
|
|
@@ -167,10 +175,14 @@ export async function handleAgentConfig(
|
|
|
167
175
|
const skills = readPatch(body, "skills", coerceSkills);
|
|
168
176
|
|
|
169
177
|
if ("skills" in body && body.skills !== null && !Array.isArray(body.skills)) {
|
|
170
|
-
return json(res, 400, {
|
|
178
|
+
return json(res, 400, {
|
|
179
|
+
error: "skills must be an array of skill ids, [] to disable all, or null to inherit defaults",
|
|
180
|
+
});
|
|
171
181
|
}
|
|
172
182
|
if (!model.sent && !thinkingDefault.sent && !tools.sent && !skills.sent) {
|
|
173
|
-
return json(res, 400, {
|
|
183
|
+
return json(res, 400, {
|
|
184
|
+
error: "No editable fields provided (model, thinkingDefault, tools, skills)",
|
|
185
|
+
});
|
|
174
186
|
}
|
|
175
187
|
|
|
176
188
|
const upgrade = getUpgradeRuntime();
|
|
@@ -184,7 +196,9 @@ export async function handleAgentConfig(
|
|
|
184
196
|
const draft = draftRaw as Record<string, unknown>;
|
|
185
197
|
const agents = (draft.agents ??= {}) as Record<string, unknown>;
|
|
186
198
|
const list = (agents.list ??= []) as Array<Record<string, unknown>>;
|
|
187
|
-
let entry = list.find(
|
|
199
|
+
let entry = list.find(
|
|
200
|
+
(a) => a && typeof a === "object" && normalizeAgentId(a.id) === agentId,
|
|
201
|
+
);
|
|
188
202
|
if (!entry) {
|
|
189
203
|
// Implicit agent (e.g. "main") with no list entry yet — create a bare one.
|
|
190
204
|
// Never set `default: true`: that would change default-agent resolution.
|
|
@@ -141,11 +141,7 @@ describe("handleAgentsList", () => {
|
|
|
141
141
|
it("defaults to the first entry when none is marked default and dedups ids", async () => {
|
|
142
142
|
setConfig({
|
|
143
143
|
agents: {
|
|
144
|
-
list: [
|
|
145
|
-
{ id: "alpha" },
|
|
146
|
-
{ id: "alpha", name: "dup" },
|
|
147
|
-
{ id: "beta" },
|
|
148
|
-
],
|
|
144
|
+
list: [{ id: "alpha" }, { id: "alpha", name: "dup" }, { id: "beta" }],
|
|
149
145
|
},
|
|
150
146
|
});
|
|
151
147
|
const res = new MockRes();
|
|
@@ -19,8 +19,14 @@ class MockRes extends EventEmitter {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
function mockReq(
|
|
23
|
-
|
|
22
|
+
function mockReq(
|
|
23
|
+
method: string,
|
|
24
|
+
headers: Record<string, string> = {},
|
|
25
|
+
): PassThrough & { method: string; headers: Record<string, string> } {
|
|
26
|
+
const stream = new PassThrough() as unknown as PassThrough & {
|
|
27
|
+
method: string;
|
|
28
|
+
headers: Record<string, string>;
|
|
29
|
+
};
|
|
24
30
|
stream.method = method;
|
|
25
31
|
stream.headers = headers;
|
|
26
32
|
return stream;
|
|
@@ -63,7 +69,10 @@ describe("handleCancel", () => {
|
|
|
63
69
|
req.end(JSON.stringify({ sessionKey: "sk-1" }));
|
|
64
70
|
await p;
|
|
65
71
|
expect((res as unknown as MockRes).statusCode).toBe(200);
|
|
66
|
-
expect(JSON.parse((res as unknown as MockRes).body)).toMatchObject({
|
|
72
|
+
expect(JSON.parse((res as unknown as MockRes).body)).toMatchObject({
|
|
73
|
+
ok: true,
|
|
74
|
+
sessionKey: "sk-1",
|
|
75
|
+
});
|
|
67
76
|
});
|
|
68
77
|
|
|
69
78
|
it("untracks run by runId fallback under Vitest (abort skipped)", async () => {
|
|
@@ -25,14 +25,16 @@ export async function handleCancel(req: IncomingMessage, res: ServerResponse): P
|
|
|
25
25
|
// back-compat fallback for older apps — resolve it to a sessionKey via the run route.
|
|
26
26
|
const sessionKey =
|
|
27
27
|
(typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "") ||
|
|
28
|
-
(runId ? getRunRoute(runId)?.sessionKey?.trim() ?? "" : "");
|
|
28
|
+
(runId ? (getRunRoute(runId)?.sessionKey?.trim() ?? "") : "");
|
|
29
29
|
if (!sessionKey && !runId) {
|
|
30
30
|
res.statusCode = 400;
|
|
31
31
|
res.setHeader("Content-Type", "application/json");
|
|
32
32
|
res.end(JSON.stringify({ error: "Missing sessionKey or runId" }));
|
|
33
33
|
return true;
|
|
34
34
|
}
|
|
35
|
-
const result = sessionKey
|
|
35
|
+
const result = sessionKey
|
|
36
|
+
? await abortRunForSessionKey(sessionKey)
|
|
37
|
+
: { aborted: false, drained: false };
|
|
36
38
|
if (runId) sseEmitter.untrackRun(runId);
|
|
37
39
|
res.statusCode = 200;
|
|
38
40
|
res.setHeader("Content-Type", "application/json");
|
|
@@ -28,8 +28,14 @@ class MockRes extends EventEmitter {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function mockReq(
|
|
32
|
-
|
|
31
|
+
function mockReq(
|
|
32
|
+
method: string,
|
|
33
|
+
headers: Record<string, string> = {},
|
|
34
|
+
): PassThrough & { method: string; headers: Record<string, string> } {
|
|
35
|
+
const stream = new PassThrough() as unknown as PassThrough & {
|
|
36
|
+
method: string;
|
|
37
|
+
headers: Record<string, string>;
|
|
38
|
+
};
|
|
33
39
|
stream.method = method;
|
|
34
40
|
stream.headers = headers;
|
|
35
41
|
return stream;
|
|
@@ -92,7 +98,10 @@ describe("handleDeviceApprove", () => {
|
|
|
92
98
|
});
|
|
93
99
|
|
|
94
100
|
it("returns 404 when listDevicePairing returns data without matching device", async () => {
|
|
95
|
-
mockList.mockResolvedValueOnce({
|
|
101
|
+
mockList.mockResolvedValueOnce({
|
|
102
|
+
pending: [{ requestId: "x", deviceId: "UNMATCHED" }],
|
|
103
|
+
paired: [],
|
|
104
|
+
});
|
|
96
105
|
|
|
97
106
|
const req = mockReq("POST", { authorization: "Bearer test-token" });
|
|
98
107
|
const res = new MockRes() as unknown as ServerResponse;
|