@syengup/friday-channel-next 0.1.30 → 0.1.36

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 (71) hide show
  1. package/README.md +8 -4
  2. package/dist/src/agent/abort-run.d.ts +12 -1
  3. package/dist/src/agent/abort-run.js +24 -9
  4. package/dist/src/agent/media-bridge.d.ts +8 -1
  5. package/dist/src/agent/media-bridge.js +23 -2
  6. package/dist/src/agent-forward-runtime.d.ts +15 -0
  7. package/dist/src/agent-forward-runtime.js +2 -0
  8. package/dist/src/agent-id.d.ts +8 -0
  9. package/dist/src/agent-id.js +21 -0
  10. package/dist/src/channel-actions.js +45 -14
  11. package/dist/src/channel.js +22 -1
  12. package/dist/src/http/handlers/agent-config.d.ts +27 -0
  13. package/dist/src/http/handlers/agent-config.js +182 -0
  14. package/dist/src/http/handlers/agent-files.d.ts +21 -0
  15. package/dist/src/http/handlers/agent-files.js +137 -0
  16. package/dist/src/http/handlers/agent-tools-catalog.d.ts +10 -0
  17. package/dist/src/http/handlers/agent-tools-catalog.js +33 -0
  18. package/dist/src/http/handlers/agents-list.js +1 -19
  19. package/dist/src/http/handlers/cancel.js +12 -6
  20. package/dist/src/http/handlers/files.d.ts +16 -0
  21. package/dist/src/http/handlers/files.js +80 -12
  22. package/dist/src/http/handlers/messages.js +8 -3
  23. package/dist/src/http/handlers/models-list.d.ts +5 -0
  24. package/dist/src/http/handlers/models-list.js +8 -0
  25. package/dist/src/http/handlers/sessions-settings.js +15 -10
  26. package/dist/src/http/server.js +23 -0
  27. package/dist/src/link-preview/ssrf-guard.js +6 -2
  28. package/dist/src/media-fetch.js +4 -1
  29. package/dist/src/skills-discovery.d.ts +58 -0
  30. package/dist/src/skills-discovery.js +247 -0
  31. package/dist/src/thinking-levels.d.ts +21 -0
  32. package/dist/src/thinking-levels.js +48 -0
  33. package/dist/src/tool-catalog.d.ts +53 -0
  34. package/dist/src/tool-catalog.js +192 -0
  35. package/dist/src/version.js +1 -1
  36. package/package.json +1 -1
  37. package/src/agent/abort-run.ts +24 -8
  38. package/src/agent/media-bridge.test.ts +71 -0
  39. package/src/agent/media-bridge.ts +23 -1
  40. package/src/agent-forward-runtime.ts +11 -0
  41. package/src/agent-id.ts +24 -0
  42. package/src/channel-actions.test.ts +47 -0
  43. package/src/channel-actions.ts +38 -14
  44. package/src/channel.lifecycle.test.ts +41 -0
  45. package/src/channel.ts +23 -1
  46. package/src/http/handlers/agent-config.test.ts +205 -0
  47. package/src/http/handlers/agent-config.ts +218 -0
  48. package/src/http/handlers/agent-files.test.ts +136 -0
  49. package/src/http/handlers/agent-files.ts +149 -0
  50. package/src/http/handlers/agent-tools-catalog.ts +42 -0
  51. package/src/http/handlers/agents-list.ts +1 -22
  52. package/src/http/handlers/cancel.test.ts +12 -2
  53. package/src/http/handlers/cancel.ts +12 -6
  54. package/src/http/handlers/files.test.ts +114 -0
  55. package/src/http/handlers/files.ts +97 -13
  56. package/src/http/handlers/messages.ts +7 -2
  57. package/src/http/handlers/models-list.test.ts +114 -0
  58. package/src/http/handlers/models-list.ts +12 -0
  59. package/src/http/handlers/sessions-settings.ts +16 -11
  60. package/src/http/server.ts +24 -0
  61. package/src/link-preview/ssrf-guard.test.ts +7 -2
  62. package/src/link-preview/ssrf-guard.ts +5 -1
  63. package/src/media-fetch.test.ts +1 -1
  64. package/src/media-fetch.ts +4 -1
  65. package/src/openclaw.d.ts +25 -1
  66. package/src/skills-discovery.test.ts +148 -0
  67. package/src/skills-discovery.ts +248 -0
  68. package/src/thinking-levels.test.ts +143 -0
  69. package/src/thinking-levels.ts +68 -0
  70. package/src/tool-catalog.ts +252 -0
  71. package/src/version.ts +1 -1
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ const saveMediaBuffer = vi.fn();
4
+ vi.mock("openclaw/plugin-sdk/media-store", () => ({
5
+ saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
6
+ }));
7
+
8
+ const IMAGE_MAX = 6 * 1024 * 1024;
9
+ const DOCUMENT_MAX = 100 * 1024 * 1024;
10
+ vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
11
+ mediaKindFromMime: (mime?: string) => (mime?.startsWith("image/") ? "image" : "document"),
12
+ maxBytesForKind: (kind: string) => (kind === "image" ? IMAGE_MAX : DOCUMENT_MAX),
13
+ }));
14
+
15
+ import { saveInboundMediaBuffer } from "./media-bridge.js";
16
+
17
+ describe("saveInboundMediaBuffer", () => {
18
+ beforeEach(() => {
19
+ saveMediaBuffer.mockReset();
20
+ });
21
+
22
+ it("forwards the original filename so core preserves name+extension", async () => {
23
+ // Without the filename, core's media-store saves inbound media as a bare uuid
24
+ // (no extension) and the agent sees `[media attached: file://.../inbound/<uuid>]`
25
+ // with zero file-format signal. Passing originalFilename (5th arg) restores it.
26
+ saveMediaBuffer.mockResolvedValue({
27
+ id: "report---uuid.pdf",
28
+ path: "/m/inbound/report---uuid.pdf",
29
+ });
30
+
31
+ const out = await saveInboundMediaBuffer(Buffer.from("x"), "application/pdf", "report.pdf");
32
+
33
+ expect(saveMediaBuffer).toHaveBeenCalledWith(
34
+ expect.any(Buffer),
35
+ "application/pdf",
36
+ "inbound",
37
+ DOCUMENT_MAX,
38
+ "report.pdf",
39
+ );
40
+ expect(out.path).toContain(".pdf");
41
+ });
42
+
43
+ it("uses openclaw's per-kind byte cap instead of the 5MB save default", async () => {
44
+ saveMediaBuffer.mockResolvedValue({ id: "uuid.png", path: "/m/inbound/uuid.png" });
45
+
46
+ await saveInboundMediaBuffer(Buffer.from("x"), "image/png", "photo.png");
47
+
48
+ expect(saveMediaBuffer).toHaveBeenCalledWith(
49
+ expect.any(Buffer),
50
+ "image/png",
51
+ "inbound",
52
+ IMAGE_MAX,
53
+ "photo.png",
54
+ );
55
+ });
56
+
57
+ it("works without a filename (still applies the per-kind cap)", async () => {
58
+ saveMediaBuffer.mockResolvedValue({ id: "uuid", path: "/m/inbound/uuid" });
59
+
60
+ const out = await saveInboundMediaBuffer(Buffer.from("x"), "image/png");
61
+
62
+ expect(saveMediaBuffer).toHaveBeenCalledWith(
63
+ expect.any(Buffer),
64
+ "image/png",
65
+ "inbound",
66
+ IMAGE_MAX,
67
+ undefined,
68
+ );
69
+ expect(out.id).toBe("uuid");
70
+ });
71
+ });
@@ -3,13 +3,35 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import crypto from "node:crypto";
5
5
 
6
+ /**
7
+ * openclaw's own per-kind byte cap for a mime type (image 6MB / audio·video 16MB /
8
+ * document 100MB). Unknown mimes fall back to the most permissive ("document") cap.
9
+ * Returns undefined if the media runtime isn't importable (tests / stripped runtime),
10
+ * letting `saveMediaBuffer` apply its built-in default.
11
+ */
12
+ export async function resolveMediaMaxBytes(mimeType: string): Promise<number | undefined> {
13
+ try {
14
+ const { maxBytesForKind, mediaKindFromMime } = await import("openclaw/plugin-sdk/media-runtime");
15
+ return maxBytesForKind(mediaKindFromMime(mimeType) ?? "document");
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }
20
+
6
21
  export async function saveInboundMediaBuffer(
7
22
  buffer: Buffer,
8
23
  mimeType: string,
24
+ originalFilename?: string,
9
25
  ): Promise<{ id: string; path: string }> {
10
26
  try {
11
27
  const sdk = await import("openclaw/plugin-sdk/media-store");
12
- const saved = await sdk.saveMediaBuffer(buffer, mimeType, "inbound");
28
+ // Accept whatever openclaw itself supports for this media kind instead of
29
+ // saveMediaBuffer's conservative 5MB default.
30
+ const maxBytes = await resolveMediaMaxBytes(mimeType);
31
+ // Pass the original filename (5th arg) so core's media-store preserves the
32
+ // name+extension instead of saving a bare uuid. Otherwise the agent receives
33
+ // `[media attached: file://.../inbound/<uuid>]` with no file-format signal.
34
+ const saved = await sdk.saveMediaBuffer(buffer, mimeType, "inbound", maxBytes, originalFilename);
13
35
  if (saved?.id && saved?.path) return { id: saved.id, path: saved.path };
14
36
  } catch {
15
37
  // fallback for tests or stripped runtime
@@ -16,6 +16,15 @@ export type FridayAgentForwardRuntime = {
16
16
  }) => Promise<Record<string, unknown> | null>;
17
17
  /** Resolves an agent's workspace dir — used to read IDENTITY.md for the name fallback. */
18
18
  resolveAgentWorkspaceDir?: (cfg: unknown, agentId: string) => string;
19
+ /**
20
+ * Resolves the thinking-level options + default for a provider/model pair, driven by the running
21
+ * gateway's provider plugins + model catalog (so the option set varies per model). Optional: older
22
+ * gateways don't expose it, in which case callers fall back to the base five levels.
23
+ */
24
+ resolveThinkingPolicy?: (params: { provider?: string | null; model?: string | null }) => {
25
+ levels: Array<{ id: string; label: string }>;
26
+ defaultLevel?: string | null;
27
+ };
19
28
  getConfig: () => unknown;
20
29
  };
21
30
 
@@ -30,6 +39,8 @@ export function setFridayAgentForwardRuntime(api: OpenClawPluginApi): void {
30
39
  .updateSessionStoreEntry as FridayAgentForwardRuntime["updateSessionStoreEntry"],
31
40
  resolveAgentWorkspaceDir: (api.runtime.agent as Record<string, unknown>)
32
41
  .resolveAgentWorkspaceDir as FridayAgentForwardRuntime["resolveAgentWorkspaceDir"],
42
+ resolveThinkingPolicy: (api.runtime.agent as Record<string, unknown>)
43
+ .resolveThinkingPolicy as FridayAgentForwardRuntime["resolveThinkingPolicy"],
33
44
  getConfig: () => api.runtime.config.current(),
34
45
  };
35
46
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Agent id normalization shared across handlers.
3
+ *
4
+ * Mirror of OpenClaw's `normalizeAgentId` (src/routing/session-key.ts): trim,
5
+ * lowercase, keep path/shell-safe. Empty → "main".
6
+ */
7
+
8
+ export const DEFAULT_AGENT_ID = "main";
9
+
10
+ /** Agent ids already in path/shell-safe form skip the slug rewrite below. */
11
+ const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9_-]*$/;
12
+
13
+ export function normalizeAgentId(value: unknown): string {
14
+ const trimmed = typeof value === "string" ? value.trim() : "";
15
+ if (!trimmed) return DEFAULT_AGENT_ID;
16
+ const lowered = trimmed.toLowerCase();
17
+ if (SAFE_AGENT_ID.test(lowered)) return lowered;
18
+ return (
19
+ lowered
20
+ .replace(/[^a-z0-9_-]+/g, "-")
21
+ .replace(/^-+|-+$/g, "")
22
+ .slice(0, 64) || DEFAULT_AGENT_ID
23
+ );
24
+ }
@@ -163,6 +163,53 @@ describe("channel-actions handleSend sessionKey routing", () => {
163
163
  expect(media?.data.sessionKey).toBe(appSession);
164
164
  });
165
165
 
166
+ it("send with a multi-file mediaUrls[] emits one op:media per file (same runId)", async () => {
167
+ // The agent's `message` tool call with a structured `attachments[]` array is flattened by the
168
+ // OpenClaw core into `params.mediaUrls` (with `media` set to the first entry for back-compat).
169
+ // handleSend must emit one outbound op:media per file, not just the first.
170
+ const deviceId = "DEV-ACT-MULTI";
171
+ const runId = "run-act-multi";
172
+ const appSession = "agent:operator:friday:direct:dev-act-multi:1780561609";
173
+ registerRunRoute({ runId, deviceId, sessionKey: appSession });
174
+ sseEmitter.trackDeviceForRun(deviceId, runId);
175
+ const res = connect(deviceId);
176
+
177
+ const files = ["A.swift", "B.swift", "C.swift"].map((name) => {
178
+ const p = path.join(historyDir, name);
179
+ fs.writeFileSync(p, `// ${name}`);
180
+ return p;
181
+ });
182
+
183
+ const result = await handleMessageAction({
184
+ action: "send",
185
+ params: {
186
+ to: deviceId,
187
+ message: "三个文件发给你",
188
+ media: files[0], // core sets `media` to the first entry
189
+ mediaUrls: files, // ...and the full list here
190
+ },
191
+ sessionKey: "agent:operator:main",
192
+ });
193
+
194
+ expect((result as { ok?: boolean }).ok).toBe(true);
195
+ const mediaFrames = parseOutboundFrames(res).filter(
196
+ (f) => f.type === "outbound" && f.data.op === "media",
197
+ );
198
+ expect(mediaFrames).toHaveLength(3);
199
+ // All media events share the send's runId so the app groups them into one assistant message.
200
+ const runIds = new Set(mediaFrames.map((f) => f.data.runId));
201
+ expect(runIds.size).toBe(1);
202
+ // Original filenames preserved (one per file, no duplicates, no drops).
203
+ const names = mediaFrames
204
+ .map((f) => (f.data.ctx as { originalMediaUrl?: string })?.originalMediaUrl)
205
+ .map((p) => (p ? path.basename(p) : ""));
206
+ expect(new Set(names)).toEqual(new Set(["A.swift", "B.swift", "C.swift"]));
207
+ for (const f of mediaFrames) {
208
+ expect(String(f.data.mediaUrl)).toMatch(/^\/friday-next\/files\//);
209
+ expect(f.data.sessionKey).toBe(appSession);
210
+ }
211
+ });
212
+
166
213
  it("falls back to ctx.sessionKey when the device has no active run-route", async () => {
167
214
  const deviceId = "DEV-ACT-2";
168
215
  const res = connect(deviceId);
@@ -36,6 +36,21 @@ function pickString(params: Record<string, unknown>, keys: string[]): string {
36
36
  return "";
37
37
  }
38
38
 
39
+ function pickStringArray(params: Record<string, unknown>, key: string): string[] {
40
+ const v = params[key];
41
+ if (!Array.isArray(v)) return [];
42
+ const out: string[] = [];
43
+ const seen = new Set<string>();
44
+ for (const entry of v) {
45
+ if (typeof entry !== "string") continue;
46
+ const trimmed = entry.trim();
47
+ if (!trimmed || seen.has(trimmed)) continue;
48
+ seen.add(trimmed);
49
+ out.push(trimmed);
50
+ }
51
+ return out;
52
+ }
53
+
39
54
  async function readMediaFile(
40
55
  mediaPath: string,
41
56
  ctx: MessageActionCtx,
@@ -101,26 +116,35 @@ async function handleSend(ctx: MessageActionCtx): Promise<unknown> {
101
116
  );
102
117
  }
103
118
 
104
- // Resolve media from an inline base64 buffer or a path/url reference. (`attachments[]` arrays are
105
- // normalized by the OpenClaw core and arrive via outbound.sendMedia, so they're not handled here.)
106
- let media: { buffer: Buffer; mimeType: string } | null = null;
107
- let originalMediaUrl = "";
108
- if (inlineBase64) {
109
- media = decodeBase64Media(
119
+ // Resolve the media to send. A `message` tool call with a structured `attachments[]` array is
120
+ // flattened by the OpenClaw core into `params.mediaUrls` (with `media` set to the first entry for
121
+ // back-compat), so prefer the full list; fall back to a single inline base64 buffer or a
122
+ // path/url reference for the single-attachment / direct-link / buffer cases.
123
+ const mediaSources: { buffer: Buffer; mimeType: string; originalMediaUrl: string }[] = [];
124
+ const mediaUrls = pickStringArray(ctx.params, "mediaUrls");
125
+ if (mediaUrls.length > 0) {
126
+ for (const ref of mediaUrls) {
127
+ const loaded = await readMediaFile(ref, ctx);
128
+ if (loaded) mediaSources.push({ ...loaded, originalMediaUrl: ref });
129
+ }
130
+ } else if (inlineBase64) {
131
+ const loaded = decodeBase64Media(
110
132
  inlineBase64,
111
133
  mediaMimeHint || (filename ? guessMimeType(filename) : ""),
112
134
  );
113
- originalMediaUrl = filename || "inline-buffer";
135
+ if (loaded) mediaSources.push({ ...loaded, originalMediaUrl: filename || "inline-buffer" });
114
136
  } else if (mediaPath) {
115
- media = await readMediaFile(mediaPath, ctx);
116
- originalMediaUrl = mediaPath;
137
+ const loaded = await readMediaFile(mediaPath, ctx);
138
+ if (loaded) mediaSources.push({ ...loaded, originalMediaUrl: mediaPath });
117
139
  }
118
140
 
119
- // Send media via SSE outbound
120
- if (media) {
141
+ // Send each media via its own SSE outbound event. They all share this send's `runId` so the app
142
+ // groups them into a single assistant message (first → attachment, rest → extra attachments).
143
+ if (mediaSources.length > 0) {
121
144
  const { saveMediaBuffer } = await import("openclaw/plugin-sdk/media-store");
122
- const saved = await saveMediaBuffer(media.buffer, media.mimeType, "inbound");
123
- if (saved.id) {
145
+ for (const source of mediaSources) {
146
+ const saved = await saveMediaBuffer(source.buffer, source.mimeType, "inbound");
147
+ if (!saved.id) continue;
124
148
  const publicUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
125
149
  sseEmitter.broadcast(
126
150
  {
@@ -134,7 +158,7 @@ async function handleSend(ctx: MessageActionCtx): Promise<unknown> {
134
158
  audioAsVoice: false,
135
159
  caption: caption || text,
136
160
  mediaUrl: publicUrl,
137
- ctx: { to, text: caption || text, originalMediaUrl },
161
+ ctx: { to, text: caption || text, originalMediaUrl: source.originalMediaUrl },
138
162
  },
139
163
  },
140
164
  to,
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fridayNextChannelPlugin } from "./channel.js";
3
+
4
+ /**
5
+ * friday-next is a passive HTTP+SSE channel whose routes live on the shared gateway server.
6
+ * It still MUST keep its account lifecycle pending (running:true) so the core health-monitor
7
+ * does not poll it as "stopped" and restart it every few minutes. A stopped account drops out
8
+ * of the deliverable-channel registry, so an agent `message` send landing in that window fails
9
+ * with `Unknown channel: friday-next`. See gateway.startAccount keep-alive in channel.ts.
10
+ */
11
+ describe("friday-next channel gateway lifecycle", () => {
12
+ it("exposes a startAccount keep-alive that stays pending until abort", async () => {
13
+ const gateway = (fridayNextChannelPlugin as { gateway?: { startAccount?: unknown } }).gateway;
14
+ expect(gateway?.startAccount).toBeTypeOf("function");
15
+
16
+ const startAccount = gateway!.startAccount as (ctx: {
17
+ accountId: string;
18
+ abortSignal: AbortSignal;
19
+ }) => Promise<unknown>;
20
+
21
+ const controller = new AbortController();
22
+ const started = startAccount({ accountId: "default", abortSignal: controller.signal });
23
+
24
+ // Must stay pending while the account is alive (so the core keeps running:true).
25
+ let settled = false;
26
+ void started.then(
27
+ () => {
28
+ settled = true;
29
+ },
30
+ () => {
31
+ settled = true;
32
+ },
33
+ );
34
+ await new Promise((resolve) => setTimeout(resolve, 20));
35
+ expect(settled).toBe(false);
36
+
37
+ // Aborting (reload / shutdown) resolves it cleanly.
38
+ controller.abort();
39
+ await expect(started).resolves.toBeUndefined();
40
+ });
41
+ });
package/src/channel.ts CHANGED
@@ -9,6 +9,7 @@ import fs from "node:fs";
9
9
  import os from "node:os";
10
10
  import path from "node:path";
11
11
  import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
12
+ import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
12
13
  import { createFridayNextLogger } from "./logging.js";
13
14
  import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
14
15
  import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
@@ -16,6 +17,7 @@ import { sseEmitter } from "./sse/emitter.js";
16
17
  import { describeMessageActions, handleMessageAction } from "./channel-actions.js";
17
18
  import { guessMimeType, resolveMediaAttachment } from "./http/handlers/files.js";
18
19
  import { downloadRemoteMedia, isHttpUrl } from "./media-fetch.js";
20
+ import { resolveMediaMaxBytes } from "./agent/media-bridge.js";
19
21
  import {
20
22
  resolveFridayDeviceIdForOutbound,
21
23
  resolveHistorySessionKeyForFridayDevice,
@@ -100,6 +102,22 @@ const fridayLifecycle = {
100
102
  },
101
103
  };
102
104
 
105
+ /**
106
+ * friday-next is a passive HTTP+SSE channel: its routes live on the shared gateway server and
107
+ * SSE clients connect on demand, so there is no per-account socket or polling loop to maintain.
108
+ * But the core health-monitor reads the account's lifecycle `running` flag — which the framework
109
+ * flips to `false` the moment `startAccount` resolves/rejects. Without a long-lived startAccount
110
+ * the account is permanently seen as "stopped" and restarted every health poll (~5 min). A stopped
111
+ * account drops out of the deliverable-channel registry, so an agent `message` send landing in that
112
+ * window fails with `Unknown channel: friday-next`. Hold the account lifecycle open until abort
113
+ * (reload/shutdown) so the channel stays `running:true` and continuously deliverable.
114
+ */
115
+ const fridayGateway = {
116
+ startAccount: async (ctx: { abortSignal: AbortSignal }): Promise<void> => {
117
+ await waitUntilAbort(ctx.abortSignal);
118
+ },
119
+ };
120
+
103
121
  const fridayStatus = {
104
122
  buildAccountSnapshot: async (params: {
105
123
  account: { accountId?: string; name?: string; enabled?: boolean };
@@ -139,6 +157,7 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
139
157
  },
140
158
  config: fridayConfigAdapter,
141
159
  lifecycle: fridayLifecycle,
160
+ gateway: fridayGateway,
142
161
  status: fridayStatus,
143
162
  bindings: {
144
163
  compileConfiguredBinding: () => null,
@@ -275,7 +294,10 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
275
294
 
276
295
  if (buffer) {
277
296
  const mimeType = downloadedMimeType ?? guessMimeType(mediaUrl);
278
- const saved = await saveMediaBuffer(buffer, mimeType, "inbound");
297
+ // Match what openclaw itself supports for this media kind rather than
298
+ // saveMediaBuffer's 5MB default.
299
+ const maxBytes = await resolveMediaMaxBytes(mimeType);
300
+ const saved = await saveMediaBuffer(buffer, mimeType, "inbound", maxBytes);
279
301
  if (saved.id) {
280
302
  const fileUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
281
303
  const resolved = resolveMediaAttachment(fileUrl);
@@ -0,0 +1,205 @@
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> = { agents: { list: [{ id: "main", model: "old/model" }] } };
127
+ setRuntimes(config);
128
+ const res = new MockRes();
129
+ await handleAgentConfig(makeReq(AUTH, "PUT", { model: "openai/gpt-5" }), res as any, "main");
130
+ expect(res.statusCode).toBe(200);
131
+ const entry = (config.agents as any).list[0];
132
+ expect(entry.model).toBe("openai/gpt-5");
133
+ });
134
+
135
+ 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" }] } };
137
+ setRuntimes(config);
138
+ const res = new MockRes();
139
+ await handleAgentConfig(makeReq(AUTH, "PUT", { model: null }), res as any, "main");
140
+ expect(res.statusCode).toBe(200);
141
+ expect("model" in (config.agents as any).list[0]).toBe(false);
142
+ });
143
+
144
+ it("PUT skills:[] disables all; skills:null clears the field", async () => {
145
+ const config: Record<string, unknown> = { agents: { list: [{ id: "main", skills: ["a"] }] } };
146
+ setRuntimes(config);
147
+
148
+ let res = new MockRes();
149
+ await handleAgentConfig(makeReq(AUTH, "PUT", { skills: [] }), res as any, "main");
150
+ expect((config.agents as any).list[0].skills).toEqual([]);
151
+
152
+ res = new MockRes();
153
+ await handleAgentConfig(makeReq(AUTH, "PUT", { skills: null }), res as any, "main");
154
+ expect("skills" in (config.agents as any).list[0]).toBe(false);
155
+ });
156
+
157
+ it("PUT creates a bare list entry for an implicit agent, never marking it default", async () => {
158
+ const config: Record<string, unknown> = { agents: { defaults: {} } };
159
+ setRuntimes(config);
160
+ const res = new MockRes();
161
+ await handleAgentConfig(
162
+ makeReq(AUTH, "PUT", { tools: { profile: "restricted" } }),
163
+ res as any,
164
+ "main",
165
+ );
166
+ expect(res.statusCode).toBe(200);
167
+ const list = (config.agents as any).list;
168
+ expect(list).toHaveLength(1);
169
+ expect(list[0].id).toBe("main");
170
+ expect(list[0].tools).toEqual({ profile: "restricted" });
171
+ expect("default" in list[0]).toBe(false);
172
+ });
173
+
174
+ it("PUT rejects a body with no editable fields", async () => {
175
+ setRuntimes({ agents: { list: [{ id: "main" }] } });
176
+ const res = new MockRes();
177
+ await handleAgentConfig(makeReq(AUTH, "PUT", { unrelated: 1 }), res as any, "main");
178
+ expect(res.statusCode).toBe(400);
179
+ });
180
+
181
+ it("PUT rejects a non-array, non-null skills value", async () => {
182
+ setRuntimes({ agents: { list: [{ id: "main" }] } });
183
+ const res = new MockRes();
184
+ await handleAgentConfig(makeReq(AUTH, "PUT", { skills: "deep-research" }), res as any, "main");
185
+ expect(res.statusCode).toBe(400);
186
+ });
187
+
188
+ it("GET lists skills discovered in the workspace skills/ dir (SKILL.md dirs only)", async () => {
189
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "friday-skills-"));
190
+ for (const id of ["deep-research", "verify"]) {
191
+ fs.mkdirSync(path.join(workspace, "skills", id), { recursive: true });
192
+ fs.writeFileSync(path.join(workspace, "skills", id, "SKILL.md"), "# " + id);
193
+ }
194
+ // No SKILL.md → not a skill; must be ignored.
195
+ fs.mkdirSync(path.join(workspace, "skills", "not-a-skill"), { recursive: true });
196
+ try {
197
+ setRuntimes({ agents: { list: [{ id: "main" }] } }, workspace);
198
+ const res = new MockRes();
199
+ 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"]);
201
+ } finally {
202
+ fs.rmSync(workspace, { recursive: true, force: true });
203
+ }
204
+ });
205
+ });