@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.
- package/README.md +8 -4
- package/dist/index.js +1 -1
- package/dist/src/agent/abort-run.d.ts +12 -1
- package/dist/src/agent/abort-run.js +24 -9
- package/dist/src/agent/dispatch-bridge.d.ts +1 -1
- package/dist/src/agent/media-bridge.d.ts +8 -1
- package/dist/src/agent/media-bridge.js +23 -2
- 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/subagent-registry.js +0 -3
- package/dist/src/agent-forward-runtime.d.ts +15 -0
- package/dist/src/agent-forward-runtime.js +2 -0
- package/dist/src/agent-id.d.ts +8 -0
- package/dist/src/agent-id.js +21 -0
- package/dist/src/channel-actions.js +48 -15
- package/dist/src/channel.js +22 -3
- 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.d.ts +27 -0
- package/dist/src/http/handlers/agent-config.js +188 -0
- package/dist/src/http/handlers/agent-files.d.ts +21 -0
- package/dist/src/http/handlers/agent-files.js +137 -0
- package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
- package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
- package/dist/src/http/handlers/agents-list.js +1 -19
- package/dist/src/http/handlers/cancel.js +14 -6
- 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.d.ts +16 -0
- package/dist/src/http/handlers/files.js +81 -13
- 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 +33 -14
- package/dist/src/http/handlers/models-list.d.ts +5 -0
- package/dist/src/http/handlers/models-list.js +9 -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/handlers/sessions-settings.js +15 -10
- package/dist/src/http/server.js +27 -2
- package/dist/src/link-preview/og-parse.js +3 -1
- package/dist/src/link-preview/ssrf-guard.js +6 -2
- package/dist/src/media-fetch.js +4 -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 +59 -0
- package/dist/src/skills-discovery.js +252 -0
- package/dist/src/sse/offline-queue.js +4 -1
- package/dist/src/thinking-levels.d.ts +21 -0
- package/dist/src/thinking-levels.js +48 -0
- package/dist/src/tool-catalog.d.ts +53 -0
- package/dist/src/tool-catalog.js +191 -0
- package/dist/src/upgrade-runtime.d.ts +1 -1
- package/dist/src/version.js +4 -2
- package/index.ts +43 -35
- package/install.js +131 -43
- package/package.json +10 -1
- package/src/agent/abort-run.ts +23 -8
- package/src/agent/dispatch-bridge.ts +2 -1
- package/src/agent/media-bridge.test.ts +71 -0
- package/src/agent/media-bridge.ts +30 -1
- package/src/agent/node-pairing-bridge.ts +29 -15
- package/src/agent/run-usage-accumulator.ts +4 -2
- package/src/agent/subagent-registry.ts +0 -4
- package/src/agent-forward-runtime.ts +11 -0
- package/src/agent-id.ts +24 -0
- package/src/agent-run-context-bridge.ts +3 -1
- package/src/channel-actions.test.ts +57 -4
- package/src/channel-actions.ts +41 -15
- package/src/channel.lifecycle.test.ts +41 -0
- package/src/channel.outbound.test.ts +18 -4
- package/src/channel.ts +140 -120
- 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 +212 -0
- package/src/http/handlers/agent-config.ts +232 -0
- package/src/http/handlers/agent-files.test.ts +136 -0
- package/src/http/handlers/agent-files.ts +149 -0
- package/src/http/handlers/agent-tools-catalog.ts +42 -0
- package/src/http/handlers/agents-list.test.ts +1 -5
- package/src/http/handlers/agents-list.ts +1 -22
- package/src/http/handlers/cancel.test.ts +23 -4
- package/src/http/handlers/cancel.ts +14 -6
- 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 +120 -0
- package/src/http/handlers/files.ts +115 -17
- 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 +64 -21
- package/src/http/handlers/models-list.test.ts +114 -0
- package/src/http/handlers/models-list.ts +26 -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/sessions-settings.ts +16 -11
- package/src/http/handlers/sse.ts +3 -1
- package/src/http/server.ts +33 -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 +78 -16
- package/src/link-preview/ssrf-guard.ts +7 -2
- package/src/media-fetch.test.ts +8 -3
- package/src/media-fetch.ts +5 -3
- package/src/openclaw.d.ts +41 -10
- 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 +152 -0
- package/src/skills-discovery.ts +264 -0
- 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.test.ts +143 -0
- package/src/thinking-levels.ts +70 -0
- package/src/tool-catalog.ts +261 -0
- package/src/upgrade-runtime.ts +4 -2
- package/src/version.ts +6 -2
- package/tsconfig.json +1 -1
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)
|
|
@@ -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
|
+
});
|