@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.
- package/README.md +8 -4
- package/dist/src/agent/abort-run.d.ts +12 -1
- package/dist/src/agent/abort-run.js +24 -9
- package/dist/src/agent/media-bridge.d.ts +8 -1
- package/dist/src/agent/media-bridge.js +23 -2
- 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 +45 -14
- package/dist/src/channel.js +22 -1
- package/dist/src/http/handlers/agent-config.d.ts +27 -0
- package/dist/src/http/handlers/agent-config.js +182 -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 +12 -6
- package/dist/src/http/handlers/files.d.ts +16 -0
- package/dist/src/http/handlers/files.js +80 -12
- package/dist/src/http/handlers/messages.js +8 -3
- package/dist/src/http/handlers/models-list.d.ts +5 -0
- package/dist/src/http/handlers/models-list.js +8 -0
- package/dist/src/http/handlers/sessions-settings.js +15 -10
- package/dist/src/http/server.js +23 -0
- package/dist/src/link-preview/ssrf-guard.js +6 -2
- package/dist/src/media-fetch.js +4 -1
- package/dist/src/skills-discovery.d.ts +58 -0
- package/dist/src/skills-discovery.js +247 -0
- 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 +192 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/src/agent/abort-run.ts +24 -8
- package/src/agent/media-bridge.test.ts +71 -0
- package/src/agent/media-bridge.ts +23 -1
- package/src/agent-forward-runtime.ts +11 -0
- package/src/agent-id.ts +24 -0
- package/src/channel-actions.test.ts +47 -0
- package/src/channel-actions.ts +38 -14
- package/src/channel.lifecycle.test.ts +41 -0
- package/src/channel.ts +23 -1
- package/src/http/handlers/agent-config.test.ts +205 -0
- package/src/http/handlers/agent-config.ts +218 -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.ts +1 -22
- package/src/http/handlers/cancel.test.ts +12 -2
- package/src/http/handlers/cancel.ts +12 -6
- package/src/http/handlers/files.test.ts +114 -0
- package/src/http/handlers/files.ts +97 -13
- package/src/http/handlers/messages.ts +7 -2
- package/src/http/handlers/models-list.test.ts +114 -0
- package/src/http/handlers/models-list.ts +12 -0
- package/src/http/handlers/sessions-settings.ts +16 -11
- package/src/http/server.ts +24 -0
- package/src/link-preview/ssrf-guard.test.ts +7 -2
- package/src/link-preview/ssrf-guard.ts +5 -1
- package/src/media-fetch.test.ts +1 -1
- package/src/media-fetch.ts +4 -1
- package/src/openclaw.d.ts +25 -1
- package/src/skills-discovery.test.ts +148 -0
- package/src/skills-discovery.ts +248 -0
- package/src/thinking-levels.test.ts +143 -0
- package/src/thinking-levels.ts +68 -0
- package/src/tool-catalog.ts +252 -0
- 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
|
-
|
|
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
|
}
|
package/src/agent-id.ts
ADDED
|
@@ -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);
|
package/src/channel-actions.ts
CHANGED
|
@@ -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
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
135
|
+
if (loaded) mediaSources.push({ ...loaded, originalMediaUrl: filename || "inline-buffer" });
|
|
114
136
|
} else if (mediaPath) {
|
|
115
|
-
|
|
116
|
-
originalMediaUrl
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
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
|
-
|
|
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
|
+
});
|