@syengup/friday-channel-next 0.1.30 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +8 -4
  2. package/dist/index.js +1 -1
  3. package/dist/src/agent/abort-run.d.ts +12 -1
  4. package/dist/src/agent/abort-run.js +24 -9
  5. package/dist/src/agent/dispatch-bridge.d.ts +1 -1
  6. package/dist/src/agent/media-bridge.d.ts +8 -1
  7. package/dist/src/agent/media-bridge.js +23 -2
  8. package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
  9. package/dist/src/agent/node-pairing-bridge.js +6 -2
  10. package/dist/src/agent/subagent-registry.js +0 -3
  11. package/dist/src/agent-forward-runtime.d.ts +15 -0
  12. package/dist/src/agent-forward-runtime.js +2 -0
  13. package/dist/src/agent-id.d.ts +8 -0
  14. package/dist/src/agent-id.js +21 -0
  15. package/dist/src/channel-actions.js +48 -15
  16. package/dist/src/channel.js +22 -3
  17. package/dist/src/collect-message-media-paths.js +10 -1
  18. package/dist/src/friday-session.js +34 -10
  19. package/dist/src/history/normalize-message.js +22 -8
  20. package/dist/src/http/handlers/agent-config.d.ts +27 -0
  21. package/dist/src/http/handlers/agent-config.js +188 -0
  22. package/dist/src/http/handlers/agent-files.d.ts +21 -0
  23. package/dist/src/http/handlers/agent-files.js +137 -0
  24. package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
  25. package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
  26. package/dist/src/http/handlers/agents-list.js +1 -19
  27. package/dist/src/http/handlers/cancel.js +14 -6
  28. package/dist/src/http/handlers/device-approve.js +3 -1
  29. package/dist/src/http/handlers/files-download.js +6 -8
  30. package/dist/src/http/handlers/files.d.ts +16 -0
  31. package/dist/src/http/handlers/files.js +81 -13
  32. package/dist/src/http/handlers/health.js +18 -4
  33. package/dist/src/http/handlers/history-messages.js +1 -1
  34. package/dist/src/http/handlers/history-sessions.js +5 -3
  35. package/dist/src/http/handlers/messages.js +33 -14
  36. package/dist/src/http/handlers/models-list.d.ts +5 -0
  37. package/dist/src/http/handlers/models-list.js +9 -1
  38. package/dist/src/http/handlers/nodes-approve.js +1 -6
  39. package/dist/src/http/handlers/plugin-info.js +1 -1
  40. package/dist/src/http/handlers/sessions-settings.js +15 -10
  41. package/dist/src/http/server.js +27 -2
  42. package/dist/src/link-preview/og-parse.js +3 -1
  43. package/dist/src/link-preview/ssrf-guard.js +6 -2
  44. package/dist/src/media-fetch.js +4 -1
  45. package/dist/src/plugin-install-info.js +4 -1
  46. package/dist/src/session/session-manager.js +9 -3
  47. package/dist/src/session-usage-store.js +3 -1
  48. package/dist/src/skills-discovery.d.ts +59 -0
  49. package/dist/src/skills-discovery.js +252 -0
  50. package/dist/src/sse/offline-queue.js +4 -1
  51. package/dist/src/thinking-levels.d.ts +21 -0
  52. package/dist/src/thinking-levels.js +48 -0
  53. package/dist/src/tool-catalog.d.ts +53 -0
  54. package/dist/src/tool-catalog.js +191 -0
  55. package/dist/src/upgrade-runtime.d.ts +1 -1
  56. package/dist/src/version.js +4 -2
  57. package/index.ts +43 -35
  58. package/install.js +131 -43
  59. package/package.json +10 -1
  60. package/src/agent/abort-run.ts +23 -8
  61. package/src/agent/dispatch-bridge.ts +2 -1
  62. package/src/agent/media-bridge.test.ts +71 -0
  63. package/src/agent/media-bridge.ts +30 -1
  64. package/src/agent/node-pairing-bridge.ts +29 -15
  65. package/src/agent/run-usage-accumulator.ts +4 -2
  66. package/src/agent/subagent-registry.ts +0 -4
  67. package/src/agent-forward-runtime.ts +11 -0
  68. package/src/agent-id.ts +24 -0
  69. package/src/agent-run-context-bridge.ts +3 -1
  70. package/src/channel-actions.test.ts +57 -4
  71. package/src/channel-actions.ts +41 -15
  72. package/src/channel.lifecycle.test.ts +41 -0
  73. package/src/channel.outbound.test.ts +18 -4
  74. package/src/channel.ts +140 -120
  75. package/src/collect-message-media-paths.ts +15 -6
  76. package/src/config.ts +1 -4
  77. package/src/e2e/agents-list.e2e.test.ts +9 -2
  78. package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
  79. package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
  80. package/src/e2e/auto-approve.integration.test.ts +13 -7
  81. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
  82. package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
  83. package/src/e2e/offline-replay.e2e.test.ts +17 -3
  84. package/src/e2e/send-text.e2e.test.ts +11 -2
  85. package/src/e2e/slash-commands.e2e.test.ts +5 -1
  86. package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
  87. package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
  88. package/src/e2e/subagent.e2e.test.ts +136 -53
  89. package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
  90. package/src/friday-session.forward-agent.test.ts +44 -12
  91. package/src/friday-session.ts +44 -20
  92. package/src/history/normalize-message.test.ts +35 -8
  93. package/src/history/normalize-message.ts +24 -12
  94. package/src/history/read-transcript.ts +1 -4
  95. package/src/http/handlers/agent-config.test.ts +212 -0
  96. package/src/http/handlers/agent-config.ts +232 -0
  97. package/src/http/handlers/agent-files.test.ts +136 -0
  98. package/src/http/handlers/agent-files.ts +149 -0
  99. package/src/http/handlers/agent-tools-catalog.ts +42 -0
  100. package/src/http/handlers/agents-list.test.ts +1 -5
  101. package/src/http/handlers/agents-list.ts +1 -22
  102. package/src/http/handlers/cancel.test.ts +23 -4
  103. package/src/http/handlers/cancel.ts +14 -6
  104. package/src/http/handlers/device-approve.test.ts +12 -3
  105. package/src/http/handlers/device-approve.ts +33 -21
  106. package/src/http/handlers/files-download.ts +17 -13
  107. package/src/http/handlers/files.test.ts +120 -0
  108. package/src/http/handlers/files.ts +115 -17
  109. package/src/http/handlers/health.test.ts +43 -11
  110. package/src/http/handlers/health.ts +22 -6
  111. package/src/http/handlers/history-messages.test.ts +51 -9
  112. package/src/http/handlers/history-messages.ts +4 -1
  113. package/src/http/handlers/history-sessions.test.ts +46 -9
  114. package/src/http/handlers/history-sessions.ts +5 -3
  115. package/src/http/handlers/history-set-title.test.ts +14 -5
  116. package/src/http/handlers/link-preview.test.ts +57 -16
  117. package/src/http/handlers/link-preview.ts +4 -1
  118. package/src/http/handlers/messages.test.ts +12 -8
  119. package/src/http/handlers/messages.ts +64 -21
  120. package/src/http/handlers/models-list.test.ts +114 -0
  121. package/src/http/handlers/models-list.ts +26 -8
  122. package/src/http/handlers/nodes-approve.test.ts +15 -4
  123. package/src/http/handlers/nodes-approve.ts +38 -40
  124. package/src/http/handlers/plugin-info.ts +5 -6
  125. package/src/http/handlers/plugin-upgrade.ts +4 -1
  126. package/src/http/handlers/sessions-settings.ts +16 -11
  127. package/src/http/handlers/sse.ts +3 -1
  128. package/src/http/server.ts +33 -6
  129. package/src/link-preview/og-parse.test.ts +6 -2
  130. package/src/link-preview/og-parse.ts +10 -3
  131. package/src/link-preview/preview-service.ts +4 -1
  132. package/src/link-preview/ssrf-guard.test.ts +78 -16
  133. package/src/link-preview/ssrf-guard.ts +7 -2
  134. package/src/media-fetch.test.ts +8 -3
  135. package/src/media-fetch.ts +5 -3
  136. package/src/openclaw.d.ts +41 -10
  137. package/src/plugin-install-info.ts +20 -9
  138. package/src/run-metadata.ts +2 -1
  139. package/src/session/session-manager.ts +19 -11
  140. package/src/session-usage-snapshot.ts +3 -1
  141. package/src/session-usage-store.ts +3 -1
  142. package/src/skills-discovery.test.ts +152 -0
  143. package/src/skills-discovery.ts +264 -0
  144. package/src/sse/emitter.test.ts +1 -1
  145. package/src/sse/emitter.ts +9 -3
  146. package/src/sse/offline-queue.ts +17 -8
  147. package/src/test-support/app-simulator.ts +17 -3
  148. package/src/test-support/mock-dispatch.ts +17 -4
  149. package/src/thinking-levels.test.ts +143 -0
  150. package/src/thinking-levels.ts +70 -0
  151. package/src/tool-catalog.ts +261 -0
  152. package/src/upgrade-runtime.ts +4 -2
  153. package/src/version.ts +6 -2
  154. package/tsconfig.json +1 -1
@@ -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)
@@ -0,0 +1,212 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { Readable } from "node:stream";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { handleAgentConfig } from "./agent-config.js";
8
+ import { setMockRuntime } from "../../test-support/mock-runtime.js";
9
+ import {
10
+ setFridayAgentForwardRuntime,
11
+ resetFridayAgentForwardRuntimeForTest,
12
+ } from "../../agent-forward-runtime.js";
13
+ import { setUpgradeRuntime, resetUpgradeRuntimeForTest } from "../../upgrade-runtime.js";
14
+
15
+ class MockRes extends EventEmitter {
16
+ statusCode = 0;
17
+ headers: Record<string, string> = {};
18
+ body = "";
19
+ setHeader(name: string, value: string): void {
20
+ this.headers[name.toLowerCase()] = value;
21
+ }
22
+ end(body?: string): void {
23
+ if (body) this.body += body;
24
+ }
25
+ }
26
+
27
+ const AUTH = { authorization: "Bearer test-token" };
28
+
29
+ function makeReq(headers: Record<string, string> = {}, method = "GET", body?: unknown): any {
30
+ const stream = Readable.from(body === undefined ? [] : [Buffer.from(JSON.stringify(body))]);
31
+ return Object.assign(stream, { method, url: "/friday-next/agents/main/config", headers });
32
+ }
33
+
34
+ /** Wire both runtimes around a single mutable `config`; mutateConfigFile edits it in place. */
35
+ function setRuntimes(config: Record<string, unknown>, workspace?: string): void {
36
+ setFridayAgentForwardRuntime({
37
+ runtime: {
38
+ agent: {
39
+ session: { resolveStorePath: () => "", loadSessionStore: () => ({}) },
40
+ ...(workspace ? { resolveAgentWorkspaceDir: () => workspace } : {}),
41
+ },
42
+ config: { current: () => config },
43
+ },
44
+ } as any);
45
+ setUpgradeRuntime({
46
+ runtime: {
47
+ system: {},
48
+ config: {
49
+ current: () => config,
50
+ mutateConfigFile: async ({ mutate }: { mutate: (draft: unknown) => unknown | void }) => {
51
+ mutate(config);
52
+ return config;
53
+ },
54
+ },
55
+ },
56
+ source: "/dev/path",
57
+ } as any);
58
+ }
59
+
60
+ describe("handleAgentConfig", () => {
61
+ beforeEach(() => setMockRuntime());
62
+ afterEach(() => {
63
+ resetFridayAgentForwardRuntimeForTest();
64
+ resetUpgradeRuntimeForTest();
65
+ });
66
+
67
+ it("rejects unsupported methods with 405", async () => {
68
+ setRuntimes({});
69
+ const res = new MockRes();
70
+ await handleAgentConfig(makeReq(AUTH, "POST"), res as any, "main");
71
+ expect(res.statusCode).toBe(405);
72
+ });
73
+
74
+ it("rejects missing token with 401", async () => {
75
+ setRuntimes({});
76
+ const res = new MockRes();
77
+ await handleAgentConfig(makeReq({}, "GET"), res as any, "main");
78
+ expect(res.statusCode).toBe(401);
79
+ });
80
+
81
+ it("GET returns the configured agent's editable fields", async () => {
82
+ setRuntimes({
83
+ agents: {
84
+ list: [
85
+ {
86
+ id: "Research Bot",
87
+ model: { primary: "anthropic/claude", fallbacks: ["openai/gpt-4"] },
88
+ thinkingDefault: "high",
89
+ tools: { profile: "default", deny: ["bash"], allow: ["read", "edit"] },
90
+ skills: ["deep-research", "verify"],
91
+ },
92
+ ],
93
+ },
94
+ });
95
+ const res = new MockRes();
96
+ await handleAgentConfig(makeReq(AUTH), res as any, "research-bot");
97
+ expect(res.statusCode).toBe(200);
98
+ const body = JSON.parse(res.body);
99
+ expect(body.ok).toBe(true);
100
+ expect(body.exists).toBe(true);
101
+ expect(body.model).toEqual({ primary: "anthropic/claude", fallbacks: ["openai/gpt-4"] });
102
+ expect(body.thinkingDefault).toBe("high");
103
+ expect(body.tools).toEqual({ profile: "default", allow: ["read", "edit"], deny: ["bash"] });
104
+ expect(body.skills).toEqual(["deep-research", "verify"]);
105
+ });
106
+
107
+ it("GET reports exists:false and inherited (undefined) fields for an implicit agent", async () => {
108
+ setRuntimes({ agents: { defaults: {} } });
109
+ const res = new MockRes();
110
+ await handleAgentConfig(makeReq(AUTH), res as any, "main");
111
+ const body = JSON.parse(res.body);
112
+ expect(body.exists).toBe(false);
113
+ expect(body.model).toBeUndefined();
114
+ expect(body.skills).toBeUndefined();
115
+ expect(body.availableSkills).toEqual([]);
116
+ });
117
+
118
+ it("GET distinguishes [] (all skills disabled) from absent (inherit)", async () => {
119
+ setRuntimes({ agents: { list: [{ id: "main", skills: [] }] } });
120
+ const res = new MockRes();
121
+ await handleAgentConfig(makeReq(AUTH), res as any, "main");
122
+ expect(JSON.parse(res.body).skills).toEqual([]);
123
+ });
124
+
125
+ it("PUT sets the model on an existing entry", async () => {
126
+ const config: Record<string, unknown> = {
127
+ agents: { list: [{ id: "main", model: "old/model" }] },
128
+ };
129
+ setRuntimes(config);
130
+ const res = new MockRes();
131
+ await handleAgentConfig(makeReq(AUTH, "PUT", { model: "openai/gpt-5" }), res as any, "main");
132
+ expect(res.statusCode).toBe(200);
133
+ const entry = (config.agents as any).list[0];
134
+ expect(entry.model).toBe("openai/gpt-5");
135
+ });
136
+
137
+ it("PUT model:null deletes the field so it inherits defaults", async () => {
138
+ const config: Record<string, unknown> = {
139
+ agents: { list: [{ id: "main", model: "old/model" }] },
140
+ };
141
+ setRuntimes(config);
142
+ const res = new MockRes();
143
+ await handleAgentConfig(makeReq(AUTH, "PUT", { model: null }), res as any, "main");
144
+ expect(res.statusCode).toBe(200);
145
+ expect("model" in (config.agents as any).list[0]).toBe(false);
146
+ });
147
+
148
+ it("PUT skills:[] disables all; skills:null clears the field", async () => {
149
+ const config: Record<string, unknown> = { agents: { list: [{ id: "main", skills: ["a"] }] } };
150
+ setRuntimes(config);
151
+
152
+ let res = new MockRes();
153
+ await handleAgentConfig(makeReq(AUTH, "PUT", { skills: [] }), res as any, "main");
154
+ expect((config.agents as any).list[0].skills).toEqual([]);
155
+
156
+ res = new MockRes();
157
+ await handleAgentConfig(makeReq(AUTH, "PUT", { skills: null }), res as any, "main");
158
+ expect("skills" in (config.agents as any).list[0]).toBe(false);
159
+ });
160
+
161
+ it("PUT creates a bare list entry for an implicit agent, never marking it default", async () => {
162
+ const config: Record<string, unknown> = { agents: { defaults: {} } };
163
+ setRuntimes(config);
164
+ const res = new MockRes();
165
+ await handleAgentConfig(
166
+ makeReq(AUTH, "PUT", { tools: { profile: "restricted" } }),
167
+ res as any,
168
+ "main",
169
+ );
170
+ expect(res.statusCode).toBe(200);
171
+ const list = (config.agents as any).list;
172
+ expect(list).toHaveLength(1);
173
+ expect(list[0].id).toBe("main");
174
+ expect(list[0].tools).toEqual({ profile: "restricted" });
175
+ expect("default" in list[0]).toBe(false);
176
+ });
177
+
178
+ it("PUT rejects a body with no editable fields", async () => {
179
+ setRuntimes({ agents: { list: [{ id: "main" }] } });
180
+ const res = new MockRes();
181
+ await handleAgentConfig(makeReq(AUTH, "PUT", { unrelated: 1 }), res as any, "main");
182
+ expect(res.statusCode).toBe(400);
183
+ });
184
+
185
+ it("PUT rejects a non-array, non-null skills value", async () => {
186
+ setRuntimes({ agents: { list: [{ id: "main" }] } });
187
+ const res = new MockRes();
188
+ await handleAgentConfig(makeReq(AUTH, "PUT", { skills: "deep-research" }), res as any, "main");
189
+ expect(res.statusCode).toBe(400);
190
+ });
191
+
192
+ it("GET lists skills discovered in the workspace skills/ dir (SKILL.md dirs only)", async () => {
193
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "friday-skills-"));
194
+ for (const id of ["deep-research", "verify"]) {
195
+ fs.mkdirSync(path.join(workspace, "skills", id), { recursive: true });
196
+ fs.writeFileSync(path.join(workspace, "skills", id, "SKILL.md"), "# " + id);
197
+ }
198
+ // No SKILL.md → not a skill; must be ignored.
199
+ fs.mkdirSync(path.join(workspace, "skills", "not-a-skill"), { recursive: true });
200
+ try {
201
+ setRuntimes({ agents: { list: [{ id: "main" }] } }, workspace);
202
+ const res = new MockRes();
203
+ await handleAgentConfig(makeReq(AUTH), res as any, "main");
204
+ expect(JSON.parse(res.body).availableSkills.map((s: { id: string }) => s.id)).toEqual([
205
+ "deep-research",
206
+ "verify",
207
+ ]);
208
+ } finally {
209
+ fs.rmSync(workspace, { recursive: true, force: true });
210
+ }
211
+ });
212
+ });