@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.
Files changed (124) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/src/agent/dispatch-bridge.d.ts +1 -1
  3. package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
  4. package/dist/src/agent/node-pairing-bridge.js +6 -2
  5. package/dist/src/agent/operator-scope.d.ts +19 -0
  6. package/dist/src/agent/operator-scope.js +54 -0
  7. package/dist/src/agent/subagent-registry.js +0 -3
  8. package/dist/src/channel-actions.js +3 -1
  9. package/dist/src/channel.js +0 -2
  10. package/dist/src/collect-message-media-paths.js +10 -1
  11. package/dist/src/friday-session.js +34 -10
  12. package/dist/src/history/normalize-message.js +22 -8
  13. package/dist/src/http/handlers/agent-config.js +10 -4
  14. package/dist/src/http/handlers/cancel.js +4 -2
  15. package/dist/src/http/handlers/device-approve.js +3 -1
  16. package/dist/src/http/handlers/files-download.js +6 -8
  17. package/dist/src/http/handlers/files.js +1 -1
  18. package/dist/src/http/handlers/health.js +18 -4
  19. package/dist/src/http/handlers/history-messages.js +1 -1
  20. package/dist/src/http/handlers/history-sessions.js +5 -3
  21. package/dist/src/http/handlers/messages.js +34 -11
  22. package/dist/src/http/handlers/models-list.js +1 -1
  23. package/dist/src/http/handlers/nodes-approve.js +1 -6
  24. package/dist/src/http/handlers/plugin-info.js +1 -1
  25. package/dist/src/http/server.js +4 -2
  26. package/dist/src/link-preview/og-parse.js +3 -1
  27. package/dist/src/plugin-install-info.js +4 -1
  28. package/dist/src/session/session-manager.js +9 -3
  29. package/dist/src/session-usage-store.js +3 -1
  30. package/dist/src/skills-discovery.d.ts +5 -4
  31. package/dist/src/skills-discovery.js +27 -22
  32. package/dist/src/sse/offline-queue.js +4 -1
  33. package/dist/src/tool-catalog.js +2 -3
  34. package/dist/src/upgrade-runtime.d.ts +1 -1
  35. package/dist/src/version.js +3 -1
  36. package/index.ts +43 -35
  37. package/install.js +131 -43
  38. package/package.json +10 -1
  39. package/src/agent/abort-run.ts +2 -3
  40. package/src/agent/dispatch-bridge.ts +2 -1
  41. package/src/agent/media-bridge.ts +9 -2
  42. package/src/agent/node-pairing-bridge.ts +29 -15
  43. package/src/agent/operator-scope.test.ts +66 -0
  44. package/src/agent/operator-scope.ts +63 -0
  45. package/src/agent/run-usage-accumulator.ts +4 -2
  46. package/src/agent/subagent-registry.ts +0 -4
  47. package/src/agent-run-context-bridge.ts +3 -1
  48. package/src/channel-actions.test.ts +10 -4
  49. package/src/channel-actions.ts +3 -1
  50. package/src/channel.outbound.test.ts +18 -4
  51. package/src/channel.ts +121 -123
  52. package/src/collect-message-media-paths.ts +15 -6
  53. package/src/config.ts +1 -4
  54. package/src/e2e/agents-list.e2e.test.ts +9 -2
  55. package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
  56. package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
  57. package/src/e2e/auto-approve.integration.test.ts +13 -7
  58. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
  59. package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
  60. package/src/e2e/offline-replay.e2e.test.ts +17 -3
  61. package/src/e2e/send-text.e2e.test.ts +11 -2
  62. package/src/e2e/slash-commands.e2e.test.ts +5 -1
  63. package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
  64. package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
  65. package/src/e2e/subagent.e2e.test.ts +136 -53
  66. package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
  67. package/src/friday-session.forward-agent.test.ts +44 -12
  68. package/src/friday-session.ts +44 -20
  69. package/src/history/normalize-message.test.ts +35 -8
  70. package/src/history/normalize-message.ts +24 -12
  71. package/src/history/read-transcript.ts +1 -4
  72. package/src/http/handlers/agent-config.test.ts +10 -3
  73. package/src/http/handlers/agent-config.ts +22 -8
  74. package/src/http/handlers/agents-list.test.ts +1 -5
  75. package/src/http/handlers/cancel.test.ts +12 -3
  76. package/src/http/handlers/cancel.ts +4 -2
  77. package/src/http/handlers/device-approve.test.ts +12 -3
  78. package/src/http/handlers/device-approve.ts +33 -21
  79. package/src/http/handlers/files-download.ts +17 -13
  80. package/src/http/handlers/files.test.ts +8 -2
  81. package/src/http/handlers/files.ts +21 -7
  82. package/src/http/handlers/health.test.ts +43 -11
  83. package/src/http/handlers/health.ts +22 -6
  84. package/src/http/handlers/history-messages.test.ts +51 -9
  85. package/src/http/handlers/history-messages.ts +4 -1
  86. package/src/http/handlers/history-sessions.test.ts +46 -9
  87. package/src/http/handlers/history-sessions.ts +5 -3
  88. package/src/http/handlers/history-set-title.test.ts +14 -5
  89. package/src/http/handlers/link-preview.test.ts +57 -16
  90. package/src/http/handlers/link-preview.ts +4 -1
  91. package/src/http/handlers/messages.test.ts +12 -8
  92. package/src/http/handlers/messages.ts +67 -19
  93. package/src/http/handlers/models-list.ts +14 -8
  94. package/src/http/handlers/nodes-approve.test.ts +15 -4
  95. package/src/http/handlers/nodes-approve.ts +38 -40
  96. package/src/http/handlers/plugin-info.ts +5 -6
  97. package/src/http/handlers/plugin-upgrade.ts +4 -1
  98. package/src/http/handlers/sse.ts +3 -1
  99. package/src/http/server.ts +9 -6
  100. package/src/link-preview/og-parse.test.ts +6 -2
  101. package/src/link-preview/og-parse.ts +10 -3
  102. package/src/link-preview/preview-service.ts +4 -1
  103. package/src/link-preview/ssrf-guard.test.ts +72 -15
  104. package/src/link-preview/ssrf-guard.ts +2 -1
  105. package/src/media-fetch.test.ts +7 -2
  106. package/src/media-fetch.ts +1 -2
  107. package/src/openclaw.d.ts +26 -9
  108. package/src/plugin-install-info.ts +20 -9
  109. package/src/run-metadata.ts +2 -1
  110. package/src/session/session-manager.ts +19 -11
  111. package/src/session-usage-snapshot.ts +3 -1
  112. package/src/session-usage-store.ts +3 -1
  113. package/src/skills-discovery.test.ts +14 -10
  114. package/src/skills-discovery.ts +43 -27
  115. package/src/sse/emitter.test.ts +1 -1
  116. package/src/sse/emitter.ts +9 -3
  117. package/src/sse/offline-queue.ts +17 -8
  118. package/src/test-support/app-simulator.ts +17 -3
  119. package/src/test-support/mock-dispatch.ts +17 -4
  120. package/src/thinking-levels.ts +3 -1
  121. package/src/tool-catalog.ts +16 -7
  122. package/src/upgrade-runtime.ts +4 -2
  123. package/src/version.ts +5 -1
  124. 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 { accumulateRunUsage, resetRunUsageAccumulatorForTest } from "./agent/run-usage-accumulator.js";
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.data;
60
- const second = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[1][1].data.data;
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.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.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].data;
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(runId, { input: 100, output: 50, cacheRead: 10, total: 150 }, "my-model", "openai");
218
- accumulateRunUsage(runId, { input: 30, output: 10, cacheRead: 0, total: 40 }, "my-model", "openai");
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<string, unknown>;
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(runId, { input: 500, output: 100, cacheRead: 200, total: 800 }, "llm-model", "llm-provider");
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<string, unknown>;
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<string, unknown>;
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");
@@ -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) || /^agent:main:friday-next:direct:/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 (typeof meta.totalTokens === "number" && Number.isFinite(meta.totalTokens) && meta.totalTokens > 0) {
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(to: string | undefined, rawCtx?: Record<string, unknown>): string {
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 (evt.data as Record<string, unknown>).meta === "string"
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
- ? { label: subagentEntry.label, parentRunId: subagentEntry.parentRunId, depth: subagentEntry.depth }
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 = evt.stream === "lifecycle" && (lifecyclePhase === "end" || lifecyclePhase === "error");
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 = lifecyclePhase === "error" ? String(evt.data.error ?? "unknown") : undefined;
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 non-canvas toolResults", () => {
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: "Here is the serene landscape 🌅\nMEDIA:/Users/me/.openclaw/media/tool-image-generation/x.png",
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({ role: "user", content: "no media here", ...meta("u", 1) }, 0);
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
- { role: "system", content: [{ type: "text", text: "Compaction" }], ...meta("c1", 9, { kind: "compaction" }) },
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
- // Canvas snapshots come back as base64 image blocks on the `canvas` tool result so the *agent*
258
- // can "see" the rendered page they must never surface as chat attachments on history rebuild.
259
- // The streaming deliver path already drops the temp-file form (see isCanvasSnapshotMediaPath in
260
- // http/handlers/messages.ts); this is the transcript-rebuild counterpart. The canvas tool has no
261
- // other image-returning action, so all images on a canvas result are snapshots.
262
- const isCanvasResult = toolName === "canvas";
263
- const images = isCanvasResult ? [] : parsed.images;
264
- const mediaPaths = isCanvasResult ? [] : split.paths;
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> = { agents: { list: [{ id: "main", model: "old/model" }] } };
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> = { agents: { list: [{ id: "main", model: "old/model" }] } };
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(["deep-research", "verify"]);
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.filter((v): v is string => typeof v === "string" && v.trim().length > 0).map((v) => v.trim());
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 Record<string, unknown> | undefined;
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>(body: Record<string, unknown>, key: string, coerce: (raw: unknown) => T | undefined): Patch<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 | undefined {
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, { error: "skills must be an array of skill ids, [] to disable all, or null to inherit defaults" });
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, { error: "No editable fields provided (model, thinkingDefault, tools, skills)" });
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((a) => a && typeof a === "object" && normalizeAgentId(a.id) === agentId);
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(method: string, headers: Record<string, string> = {}): PassThrough & { method: string; headers: Record<string, string> } {
23
- const stream = new PassThrough() as unknown as PassThrough & { method: string; headers: Record<string, string> };
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({ ok: true, sessionKey: "sk-1" });
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 ? await abortRunForSessionKey(sessionKey) : { aborted: false, drained: false };
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(method: string, headers: Record<string, string> = {}): PassThrough & { method: string; headers: Record<string, string> } {
32
- const stream = new PassThrough() as unknown as PassThrough & { method: string; headers: Record<string, string> };
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({ pending: [{ requestId: "x", deviceId: "UNMATCHED" }], paired: [] });
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;