@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
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
|
2
2
|
import { delimiter, dirname, join } from "node:path";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Results come from the untyped OpenClaw dist module, so the resolved shapes are
|
|
5
|
+
// `any` at this host boundary — callers read dynamic fields (.pending, .status, …).
|
|
6
|
+
type ListNodePairingFn = () => Promise<any>;
|
|
7
|
+
type ApproveNodePairingFn = (
|
|
8
|
+
requestId: string,
|
|
9
|
+
options: { callerScopes?: unknown },
|
|
10
|
+
) => Promise<any>;
|
|
11
|
+
type NodePairingModule = {
|
|
12
|
+
listNodePairing: ListNodePairingFn;
|
|
13
|
+
approveNodePairing: ApproveNodePairingFn;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let cache: NodePairingModule | null = null;
|
|
5
17
|
|
|
6
18
|
function resolveOpenClawDistFromPath(): string | null {
|
|
7
19
|
// Walk PATH looking for the openclaw binary, then resolve its real
|
|
@@ -16,7 +28,9 @@ function resolveOpenClawDistFromPath(): string | null {
|
|
|
16
28
|
const dist = join(dirname(real), "dist");
|
|
17
29
|
readdirSync(dist);
|
|
18
30
|
return dist;
|
|
19
|
-
} catch {
|
|
31
|
+
} catch {
|
|
32
|
+
// Not a real dist dir — keep walking PATH.
|
|
33
|
+
}
|
|
20
34
|
}
|
|
21
35
|
return null;
|
|
22
36
|
}
|
|
@@ -43,15 +57,17 @@ function resolveOpenClawDist(): string {
|
|
|
43
57
|
].filter((v): v is string => typeof v === "string" && v.length > 0);
|
|
44
58
|
|
|
45
59
|
for (const root of candidates) {
|
|
46
|
-
try {
|
|
60
|
+
try {
|
|
61
|
+
readdirSync(root);
|
|
62
|
+
return root;
|
|
63
|
+
} catch {
|
|
64
|
+
// Candidate dir doesn't exist — try the next one.
|
|
65
|
+
}
|
|
47
66
|
}
|
|
48
67
|
throw new Error("OpenClaw dist directory not found. Set OPENCLAW_DIST env var.");
|
|
49
68
|
}
|
|
50
69
|
|
|
51
|
-
export async function loadNodePairingModule(): Promise<{
|
|
52
|
-
listNodePairing: Function;
|
|
53
|
-
approveNodePairing: Function;
|
|
54
|
-
}> {
|
|
70
|
+
export async function loadNodePairingModule(): Promise<NodePairingModule> {
|
|
55
71
|
if (cache) return cache;
|
|
56
72
|
const dist = resolveOpenClawDist();
|
|
57
73
|
const file = readdirSync(dist).find(
|
|
@@ -63,12 +79,13 @@ export async function loadNodePairingModule(): Promise<{
|
|
|
63
79
|
// bundled module uses `export { listNodePairing as r, … }`. Resolve the
|
|
64
80
|
// correct functions by Function.name, which preserves the original name.
|
|
65
81
|
const mod = await import(join(dist, file));
|
|
66
|
-
let listNodePairing:
|
|
67
|
-
let approveNodePairing:
|
|
82
|
+
let listNodePairing: ListNodePairingFn | undefined;
|
|
83
|
+
let approveNodePairing: ApproveNodePairingFn | undefined;
|
|
68
84
|
for (const value of Object.values(mod)) {
|
|
69
85
|
if (typeof value === "function") {
|
|
70
|
-
if (value.name === "listNodePairing") listNodePairing = value;
|
|
71
|
-
else if (value.name === "approveNodePairing")
|
|
86
|
+
if (value.name === "listNodePairing") listNodePairing = value as ListNodePairingFn;
|
|
87
|
+
else if (value.name === "approveNodePairing")
|
|
88
|
+
approveNodePairing = value as ApproveNodePairingFn;
|
|
72
89
|
}
|
|
73
90
|
}
|
|
74
91
|
if (!listNodePairing || !approveNodePairing) {
|
|
@@ -79,9 +96,6 @@ export async function loadNodePairingModule(): Promise<{
|
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
/** Vitest-only: inject mock pairing functions. */
|
|
82
|
-
export function __setMockNodePairingForTests(mock: {
|
|
83
|
-
listNodePairing: Function;
|
|
84
|
-
approveNodePairing: Function;
|
|
85
|
-
}): void {
|
|
99
|
+
export function __setMockNodePairingForTests(mock: NodePairingModule): void {
|
|
86
100
|
cache = mock;
|
|
87
101
|
}
|
|
@@ -39,8 +39,10 @@ export function accumulateRunUsage(
|
|
|
39
39
|
const entry = ensure(runId);
|
|
40
40
|
if (typeof usage.input === "number" && usage.input > 0) entry.input += usage.input;
|
|
41
41
|
if (typeof usage.output === "number" && usage.output > 0) entry.output += usage.output;
|
|
42
|
-
if (typeof usage.cacheRead === "number" && usage.cacheRead > 0)
|
|
43
|
-
|
|
42
|
+
if (typeof usage.cacheRead === "number" && usage.cacheRead > 0)
|
|
43
|
+
entry.cacheRead += usage.cacheRead;
|
|
44
|
+
if (typeof usage.cacheWrite === "number" && usage.cacheWrite > 0)
|
|
45
|
+
entry.cacheWrite += usage.cacheWrite;
|
|
44
46
|
if (typeof usage.total === "number" && usage.total > 0) entry.total += usage.total;
|
|
45
47
|
if (model && model.trim()) entry.model = model.trim();
|
|
46
48
|
if (provider && provider.trim()) entry.provider = provider.trim();
|
|
@@ -39,10 +39,6 @@ export function registerSessionKeyForRun(sessionKey: string, runId: string): voi
|
|
|
39
39
|
sessionKeyToRunId.set(sessionKey, runId);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function resolveRunIdForSessionKey(sessionKey: string): string | undefined {
|
|
43
|
-
return sessionKeyToRunId.get(sessionKey);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
42
|
/**
|
|
47
43
|
* Parse OpenClaw announce compound runId:
|
|
48
44
|
* announce:v<version>:<sessionKey>:<bareRunId>
|
|
@@ -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
|
+
}
|
|
@@ -26,7 +26,9 @@ function getAgentEventState(): AgentEventStateLike | undefined {
|
|
|
26
26
|
return { runContextById };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function getOpenClawAgentRunContext(
|
|
29
|
+
export function getOpenClawAgentRunContext(
|
|
30
|
+
runId: string,
|
|
31
|
+
): OpenClawAgentRunContextBridge | undefined {
|
|
30
32
|
if (!runId) return undefined;
|
|
31
33
|
return getAgentEventState()?.runContextById.get(runId);
|
|
32
34
|
}
|
|
@@ -6,7 +6,11 @@ import { handleMessageAction } from "./channel-actions.js";
|
|
|
6
6
|
import { sseEmitter } from "./sse/emitter.js";
|
|
7
7
|
import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
|
|
8
8
|
import { registerRunRoute } from "./run-metadata.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createTempHistoryDir,
|
|
11
|
+
removeTempHistoryDir,
|
|
12
|
+
setMockRuntime,
|
|
13
|
+
} from "./test-support/mock-runtime.js";
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* The `message` tool's `action=send` is handled here (NOT via outbound.sendText/sendMedia).
|
|
@@ -107,8 +111,8 @@ describe("channel-actions handleSend sessionKey routing", () => {
|
|
|
107
111
|
// 8-byte PNG magic header so saveMediaBuffer's magic-byte detection recognizes an image.
|
|
108
112
|
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]);
|
|
109
113
|
const directLink = "https://picsum.photos/600/400";
|
|
110
|
-
const fetchMock = vi.fn(
|
|
111
|
-
new Response(pngBytes, { status: 200, headers: { "content-type": "image/png" } }),
|
|
114
|
+
const fetchMock = vi.fn(
|
|
115
|
+
async () => new Response(pngBytes, { status: 200, headers: { "content-type": "image/png" } }),
|
|
112
116
|
);
|
|
113
117
|
vi.stubGlobal("fetch", fetchMock);
|
|
114
118
|
|
|
@@ -163,6 +167,53 @@ describe("channel-actions handleSend sessionKey routing", () => {
|
|
|
163
167
|
expect(media?.data.sessionKey).toBe(appSession);
|
|
164
168
|
});
|
|
165
169
|
|
|
170
|
+
it("send with a multi-file mediaUrls[] emits one op:media per file (same runId)", async () => {
|
|
171
|
+
// The agent's `message` tool call with a structured `attachments[]` array is flattened by the
|
|
172
|
+
// OpenClaw core into `params.mediaUrls` (with `media` set to the first entry for back-compat).
|
|
173
|
+
// handleSend must emit one outbound op:media per file, not just the first.
|
|
174
|
+
const deviceId = "DEV-ACT-MULTI";
|
|
175
|
+
const runId = "run-act-multi";
|
|
176
|
+
const appSession = "agent:operator:friday:direct:dev-act-multi:1780561609";
|
|
177
|
+
registerRunRoute({ runId, deviceId, sessionKey: appSession });
|
|
178
|
+
sseEmitter.trackDeviceForRun(deviceId, runId);
|
|
179
|
+
const res = connect(deviceId);
|
|
180
|
+
|
|
181
|
+
const files = ["A.swift", "B.swift", "C.swift"].map((name) => {
|
|
182
|
+
const p = path.join(historyDir, name);
|
|
183
|
+
fs.writeFileSync(p, `// ${name}`);
|
|
184
|
+
return p;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const result = await handleMessageAction({
|
|
188
|
+
action: "send",
|
|
189
|
+
params: {
|
|
190
|
+
to: deviceId,
|
|
191
|
+
message: "三个文件发给你",
|
|
192
|
+
media: files[0], // core sets `media` to the first entry
|
|
193
|
+
mediaUrls: files, // ...and the full list here
|
|
194
|
+
},
|
|
195
|
+
sessionKey: "agent:operator:main",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect((result as { ok?: boolean }).ok).toBe(true);
|
|
199
|
+
const mediaFrames = parseOutboundFrames(res).filter(
|
|
200
|
+
(f) => f.type === "outbound" && f.data.op === "media",
|
|
201
|
+
);
|
|
202
|
+
expect(mediaFrames).toHaveLength(3);
|
|
203
|
+
// All media events share the send's runId so the app groups them into one assistant message.
|
|
204
|
+
const runIds = new Set(mediaFrames.map((f) => f.data.runId));
|
|
205
|
+
expect(runIds.size).toBe(1);
|
|
206
|
+
// Original filenames preserved (one per file, no duplicates, no drops).
|
|
207
|
+
const names = mediaFrames
|
|
208
|
+
.map((f) => (f.data.ctx as { originalMediaUrl?: string })?.originalMediaUrl)
|
|
209
|
+
.map((p) => (p ? path.basename(p) : ""));
|
|
210
|
+
expect(new Set(names)).toEqual(new Set(["A.swift", "B.swift", "C.swift"]));
|
|
211
|
+
for (const f of mediaFrames) {
|
|
212
|
+
expect(String(f.data.mediaUrl)).toMatch(/^\/friday-next\/files\//);
|
|
213
|
+
expect(f.data.sessionKey).toBe(appSession);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
166
217
|
it("falls back to ctx.sessionKey when the device has no active run-route", async () => {
|
|
167
218
|
const deviceId = "DEV-ACT-2";
|
|
168
219
|
const res = connect(deviceId);
|
|
@@ -173,7 +224,9 @@ describe("channel-actions handleSend sessionKey routing", () => {
|
|
|
173
224
|
sessionKey: "agent:operator:friday:direct:fallback-session",
|
|
174
225
|
});
|
|
175
226
|
|
|
176
|
-
const text = parseOutboundFrames(res).find(
|
|
227
|
+
const text = parseOutboundFrames(res).find(
|
|
228
|
+
(f) => f.type === "outbound" && f.data.op === "text",
|
|
229
|
+
);
|
|
177
230
|
expect(text?.data.sessionKey).toBe("agent:operator:friday:direct:fallback-session");
|
|
178
231
|
});
|
|
179
232
|
});
|
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,
|
|
@@ -49,7 +64,9 @@ async function readMediaFile(
|
|
|
49
64
|
if (buffer?.length) {
|
|
50
65
|
return { buffer, mimeType: guessMimeType(mediaPath) };
|
|
51
66
|
}
|
|
52
|
-
} catch {
|
|
67
|
+
} catch {
|
|
68
|
+
/* fall through */
|
|
69
|
+
}
|
|
53
70
|
}
|
|
54
71
|
try {
|
|
55
72
|
const buffer = fs.readFileSync(mediaPath);
|
|
@@ -101,26 +118,35 @@ async function handleSend(ctx: MessageActionCtx): Promise<unknown> {
|
|
|
101
118
|
);
|
|
102
119
|
}
|
|
103
120
|
|
|
104
|
-
// Resolve media
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
// Resolve the media to send. A `message` tool call with a structured `attachments[]` array is
|
|
122
|
+
// flattened by the OpenClaw core into `params.mediaUrls` (with `media` set to the first entry for
|
|
123
|
+
// back-compat), so prefer the full list; fall back to a single inline base64 buffer or a
|
|
124
|
+
// path/url reference for the single-attachment / direct-link / buffer cases.
|
|
125
|
+
const mediaSources: { buffer: Buffer; mimeType: string; originalMediaUrl: string }[] = [];
|
|
126
|
+
const mediaUrls = pickStringArray(ctx.params, "mediaUrls");
|
|
127
|
+
if (mediaUrls.length > 0) {
|
|
128
|
+
for (const ref of mediaUrls) {
|
|
129
|
+
const loaded = await readMediaFile(ref, ctx);
|
|
130
|
+
if (loaded) mediaSources.push({ ...loaded, originalMediaUrl: ref });
|
|
131
|
+
}
|
|
132
|
+
} else if (inlineBase64) {
|
|
133
|
+
const loaded = decodeBase64Media(
|
|
110
134
|
inlineBase64,
|
|
111
135
|
mediaMimeHint || (filename ? guessMimeType(filename) : ""),
|
|
112
136
|
);
|
|
113
|
-
originalMediaUrl
|
|
137
|
+
if (loaded) mediaSources.push({ ...loaded, originalMediaUrl: filename || "inline-buffer" });
|
|
114
138
|
} else if (mediaPath) {
|
|
115
|
-
|
|
116
|
-
originalMediaUrl
|
|
139
|
+
const loaded = await readMediaFile(mediaPath, ctx);
|
|
140
|
+
if (loaded) mediaSources.push({ ...loaded, originalMediaUrl: mediaPath });
|
|
117
141
|
}
|
|
118
142
|
|
|
119
|
-
// Send media via SSE outbound
|
|
120
|
-
|
|
143
|
+
// Send each media via its own SSE outbound event. They all share this send's `runId` so the app
|
|
144
|
+
// groups them into a single assistant message (first → attachment, rest → extra attachments).
|
|
145
|
+
if (mediaSources.length > 0) {
|
|
121
146
|
const { saveMediaBuffer } = await import("openclaw/plugin-sdk/media-store");
|
|
122
|
-
const
|
|
123
|
-
|
|
147
|
+
for (const source of mediaSources) {
|
|
148
|
+
const saved = await saveMediaBuffer(source.buffer, source.mimeType, "inbound");
|
|
149
|
+
if (!saved.id) continue;
|
|
124
150
|
const publicUrl = `/friday-next/files/${encodeURIComponent(saved.id)}`;
|
|
125
151
|
sseEmitter.broadcast(
|
|
126
152
|
{
|
|
@@ -134,7 +160,7 @@ async function handleSend(ctx: MessageActionCtx): Promise<unknown> {
|
|
|
134
160
|
audioAsVoice: false,
|
|
135
161
|
caption: caption || text,
|
|
136
162
|
mediaUrl: publicUrl,
|
|
137
|
-
ctx: { to, text: caption || text, originalMediaUrl },
|
|
163
|
+
ctx: { to, text: caption || text, originalMediaUrl: source.originalMediaUrl },
|
|
138
164
|
},
|
|
139
165
|
},
|
|
140
166
|
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
|
+
});
|
|
@@ -6,7 +6,11 @@ import { fridayNextChannelPlugin } from "./channel.js";
|
|
|
6
6
|
import { sseEmitter } from "./sse/emitter.js";
|
|
7
7
|
import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
|
|
8
8
|
import { registerRunRoute } from "./run-metadata.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createTempHistoryDir,
|
|
11
|
+
removeTempHistoryDir,
|
|
12
|
+
setMockRuntime,
|
|
13
|
+
} from "./test-support/mock-runtime.js";
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* Outbound (message-tool send) must route to the session that started the run.
|
|
@@ -95,11 +99,19 @@ describe("friday-next channel outbound sessionKey routing", () => {
|
|
|
95
99
|
it("run-route wins over ctx sessionKey (ctx carries the agent's base/main session, not the active app session)", async () => {
|
|
96
100
|
const deviceId = "DEV-TEXT-2";
|
|
97
101
|
const runId = "run-text-2";
|
|
98
|
-
registerRunRoute({
|
|
102
|
+
registerRunRoute({
|
|
103
|
+
runId,
|
|
104
|
+
deviceId,
|
|
105
|
+
sessionKey: "agent:operator:friday-next:direct:route-session",
|
|
106
|
+
});
|
|
99
107
|
sseEmitter.trackDeviceForRun(deviceId, runId);
|
|
100
108
|
const res = connect(deviceId);
|
|
101
109
|
|
|
102
|
-
await outbound.sendText({
|
|
110
|
+
await outbound.sendText({
|
|
111
|
+
to: deviceId,
|
|
112
|
+
text: "hi",
|
|
113
|
+
requesterSessionKey: "agent:operator:main",
|
|
114
|
+
});
|
|
103
115
|
|
|
104
116
|
const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
|
|
105
117
|
expect(evt?.data.sessionKey).toBe("agent:operator:friday-next:direct:route-session");
|
|
@@ -129,7 +141,9 @@ describe("friday-next channel outbound sessionKey routing", () => {
|
|
|
129
141
|
|
|
130
142
|
await outbound.sendMedia({ to: deviceId, text: "caption", mediaUrl: mediaFile });
|
|
131
143
|
|
|
132
|
-
const evt = parseOutboundFrames(res).find(
|
|
144
|
+
const evt = parseOutboundFrames(res).find(
|
|
145
|
+
(f) => f.type === "outbound" && f.data.op === "media",
|
|
146
|
+
);
|
|
133
147
|
expect(evt).toBeDefined();
|
|
134
148
|
expect(evt?.data.sessionKey).toBe(sessionKey);
|
|
135
149
|
expect(evt?.data.deviceId).toBe(deviceId);
|