@syengup/friday-channel-next 0.1.13 → 0.1.15
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/dist/src/agent-forward-runtime.d.ts +6 -0
- package/dist/src/agent-forward-runtime.js +2 -0
- package/dist/src/channel-actions.js +9 -1
- package/dist/src/channel.js +26 -4
- package/dist/src/history/normalize-message.d.ts +67 -0
- package/dist/src/history/normalize-message.js +224 -0
- package/dist/src/history/read-transcript.d.ts +22 -0
- package/dist/src/history/read-transcript.js +136 -0
- package/dist/src/http/handlers/files.d.ts +3 -2
- package/dist/src/http/handlers/files.js +20 -5
- package/dist/src/http/handlers/history-messages.d.ts +13 -0
- package/dist/src/http/handlers/history-messages.js +100 -0
- package/dist/src/http/handlers/history-sessions.d.ts +23 -0
- package/dist/src/http/handlers/history-sessions.js +163 -0
- package/dist/src/http/handlers/history-set-title.d.ts +10 -0
- package/dist/src/http/handlers/history-set-title.js +77 -0
- package/dist/src/http/handlers/messages.js +6 -38
- package/dist/src/http/handlers/sessions-settings.js +22 -7
- package/dist/src/http/server.js +15 -0
- package/dist/src/session/session-manager.d.ts +30 -1
- package/dist/src/session/session-manager.js +50 -1
- package/package.json +2 -2
- package/src/agent-forward-runtime.ts +10 -0
- package/src/channel-actions.test.ts +111 -0
- package/src/channel-actions.ts +10 -1
- package/src/channel.outbound-mirror-suppression.test.ts +36 -0
- package/src/channel.outbound.test.ts +137 -0
- package/src/channel.ts +33 -6
- package/src/history/normalize-message.test.ts +154 -0
- package/src/history/normalize-message.ts +292 -0
- package/src/history/read-transcript.ts +136 -0
- package/src/http/handlers/files.ts +21 -5
- package/src/http/handlers/history-messages.test.ts +144 -0
- package/src/http/handlers/history-messages.ts +123 -0
- package/src/http/handlers/history-sessions.test.ts +146 -0
- package/src/http/handlers/history-sessions.ts +184 -0
- package/src/http/handlers/history-set-title.test.ts +115 -0
- package/src/http/handlers/history-set-title.ts +86 -0
- package/src/http/handlers/messages.ts +8 -46
- package/src/http/handlers/sessions-settings.ts +23 -6
- package/src/http/server.ts +18 -0
- package/src/session/session-manager.test.ts +42 -0
- package/src/session/session-manager.ts +73 -3
- package/src/test-support/mock-runtime.ts +2 -0
|
@@ -6,6 +6,14 @@ export type FridayAgentForwardRuntime = {
|
|
|
6
6
|
path: string,
|
|
7
7
|
options?: { skipCache?: boolean; maintenanceConfig?: unknown; clone?: boolean },
|
|
8
8
|
) => Record<string, unknown>;
|
|
9
|
+
/** Cache-owning entry write (syncs the app session name → server `displayName`). */
|
|
10
|
+
updateSessionStoreEntry?: (params: {
|
|
11
|
+
storePath: string;
|
|
12
|
+
sessionKey: string;
|
|
13
|
+
update: (
|
|
14
|
+
entry: Record<string, unknown>,
|
|
15
|
+
) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>;
|
|
16
|
+
}) => Promise<Record<string, unknown> | null>;
|
|
9
17
|
getConfig: () => unknown;
|
|
10
18
|
};
|
|
11
19
|
|
|
@@ -16,6 +24,8 @@ export function setFridayAgentForwardRuntime(api: OpenClawPluginApi): void {
|
|
|
16
24
|
forwardRuntime = {
|
|
17
25
|
resolveStorePath: api.runtime.agent.session.resolveStorePath,
|
|
18
26
|
loadSessionStore: api.runtime.agent.session.loadSessionStore,
|
|
27
|
+
updateSessionStoreEntry: (api.runtime.agent.session as Record<string, unknown>)
|
|
28
|
+
.updateSessionStoreEntry as FridayAgentForwardRuntime["updateSessionStoreEntry"],
|
|
19
29
|
getConfig: () => api.runtime.config.current(),
|
|
20
30
|
};
|
|
21
31
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { handleMessageAction } from "./channel-actions.js";
|
|
6
|
+
import { sseEmitter } from "./sse/emitter.js";
|
|
7
|
+
import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
|
|
8
|
+
import { registerRunRoute } from "./run-metadata.js";
|
|
9
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "./test-support/mock-runtime.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The `message` tool's `action=send` is handled here (NOT via outbound.sendText/sendMedia).
|
|
13
|
+
* `ctx.sessionKey` is the agent's base/main session, so the send must recover the app session that
|
|
14
|
+
* started the device's active run from the run-route registry — otherwise attachments land in a
|
|
15
|
+
* device-level / main session instead of the user's current session.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
class MockRes extends EventEmitter {
|
|
19
|
+
writes: string[] = [];
|
|
20
|
+
write(chunk: string): boolean {
|
|
21
|
+
this.writes.push(chunk);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
end(): void {
|
|
25
|
+
// no-op
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type OutboundFrame = { type: string; data: Record<string, unknown> };
|
|
30
|
+
|
|
31
|
+
function parseOutboundFrames(res: MockRes): OutboundFrame[] {
|
|
32
|
+
const frames: OutboundFrame[] = [];
|
|
33
|
+
for (const block of res.writes.join("").split("\n\n")) {
|
|
34
|
+
if (!block.trim()) continue;
|
|
35
|
+
let type = "";
|
|
36
|
+
let data: Record<string, unknown> | undefined;
|
|
37
|
+
for (const line of block.split("\n")) {
|
|
38
|
+
if (line.startsWith("event: ")) type = line.slice("event: ".length).trim();
|
|
39
|
+
else if (line.startsWith("data: ")) {
|
|
40
|
+
try {
|
|
41
|
+
data = JSON.parse(line.slice("data: ".length));
|
|
42
|
+
} catch {
|
|
43
|
+
// ignore
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (data) frames.push({ type, data });
|
|
48
|
+
}
|
|
49
|
+
return frames;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("channel-actions handleSend sessionKey routing", () => {
|
|
53
|
+
let historyDir = "";
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
sseEmitter.resetForTest();
|
|
57
|
+
historyDir = createTempHistoryDir();
|
|
58
|
+
setOfflineQueueBaseDirForTest(historyDir);
|
|
59
|
+
setMockRuntime({ historyDir, authToken: "test-token" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
setOfflineQueueBaseDirForTest(null);
|
|
64
|
+
removeTempHistoryDir(historyDir);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function connect(deviceId: string): MockRes {
|
|
68
|
+
const res = new MockRes();
|
|
69
|
+
sseEmitter.addConnection(deviceId, res as never);
|
|
70
|
+
return res;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it("send media routes to the active run's app session, not ctx.sessionKey", async () => {
|
|
74
|
+
const deviceId = "DEV-ACT-1";
|
|
75
|
+
const runId = "run-act-1";
|
|
76
|
+
const appSession = "agent:operator:friday:direct:dev-act-1:1780561609";
|
|
77
|
+
const mediaFile = path.join(historyDir, "shot.png");
|
|
78
|
+
fs.writeFileSync(mediaFile, "png-bytes");
|
|
79
|
+
registerRunRoute({ runId, deviceId, sessionKey: appSession });
|
|
80
|
+
sseEmitter.trackDeviceForRun(deviceId, runId);
|
|
81
|
+
const res = connect(deviceId);
|
|
82
|
+
|
|
83
|
+
const result = await handleMessageAction({
|
|
84
|
+
action: "send",
|
|
85
|
+
params: { to: deviceId, message: "桌面截图来了 📸", media: mediaFile },
|
|
86
|
+
sessionKey: "agent:operator:main", // ctx gives the base/main session — must be overridden
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect((result as { ok?: boolean }).ok).toBe(true);
|
|
90
|
+
const frames = parseOutboundFrames(res);
|
|
91
|
+
const media = frames.find((f) => f.type === "outbound" && f.data.op === "media");
|
|
92
|
+
const text = frames.find((f) => f.type === "outbound" && f.data.op === "text");
|
|
93
|
+
expect(media?.data.sessionKey).toBe(appSession);
|
|
94
|
+
expect(text?.data.sessionKey).toBe(appSession);
|
|
95
|
+
expect(media?.data.deviceId).toBe(deviceId);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("falls back to ctx.sessionKey when the device has no active run-route", async () => {
|
|
99
|
+
const deviceId = "DEV-ACT-2";
|
|
100
|
+
const res = connect(deviceId);
|
|
101
|
+
|
|
102
|
+
await handleMessageAction({
|
|
103
|
+
action: "send",
|
|
104
|
+
params: { to: deviceId, message: "hi" },
|
|
105
|
+
sessionKey: "agent:operator:friday:direct:fallback-session",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const text = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
|
|
109
|
+
expect(text?.data.sessionKey).toBe("agent:operator:friday:direct:fallback-session");
|
|
110
|
+
});
|
|
111
|
+
});
|
package/src/channel-actions.ts
CHANGED
|
@@ -2,6 +2,8 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import { sseEmitter } from "./sse/emitter.js";
|
|
4
4
|
import { guessMimeType } from "./http/handlers/files.js";
|
|
5
|
+
import { getRunRoute } from "./run-metadata.js";
|
|
6
|
+
import { resolveHistorySessionKeyForFridayDevice } from "./friday-session.js";
|
|
5
7
|
|
|
6
8
|
type MessageActionCtx = {
|
|
7
9
|
action: string;
|
|
@@ -64,7 +66,14 @@ async function handleSend(ctx: MessageActionCtx): Promise<unknown> {
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
const runId = crypto.randomUUID();
|
|
67
|
-
|
|
69
|
+
// The `message` tool's send runs as a fresh action; `ctx.sessionKey` is the agent's base/main
|
|
70
|
+
// session, not the app session that started the active run on this device. Recover the latter via
|
|
71
|
+
// the device's last tracked run-route so attachments land in the user's current session.
|
|
72
|
+
const activeRunId = sseEmitter.getLastRunIdForDevice(to) ?? undefined;
|
|
73
|
+
const sessionKey =
|
|
74
|
+
(activeRunId ? getRunRoute(activeRunId)?.sessionKey : undefined) ??
|
|
75
|
+
ctx.sessionKey ??
|
|
76
|
+
resolveHistorySessionKeyForFridayDevice(to);
|
|
68
77
|
|
|
69
78
|
// Send text via SSE outbound
|
|
70
79
|
if (text) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { fridayNextChannelPlugin } from "./channel.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* friday-next is a transparent proxy: outbound attachments/text already reach the app
|
|
6
|
+
* live via SSE (sendText/sendMedia/handleSend). OpenClaw core's generic outbound path
|
|
7
|
+
* additionally mirrors message-tool sends into the *recipient's* session transcript
|
|
8
|
+
* (model:"delivery-mirror"). For friday-next that recipient session falls back to
|
|
9
|
+
* `agent:<agentId>:friday-next:direct:<deviceId>` — an orphan session unrelated to the
|
|
10
|
+
* app's real conversation — producing a phantom session + a stray delivery-mirror message.
|
|
11
|
+
*
|
|
12
|
+
* The core consults `messaging.resolveOutboundSessionRoute` FIRST; returning `null`
|
|
13
|
+
* short-circuits route resolution so no orphan session entry and no delivery-mirror are
|
|
14
|
+
* created. This test pins that contract.
|
|
15
|
+
*/
|
|
16
|
+
describe("friday-next channel suppresses core delivery-mirror", () => {
|
|
17
|
+
const messaging = (fridayNextChannelPlugin as { messaging?: Record<string, unknown> }).messaging;
|
|
18
|
+
|
|
19
|
+
it("exposes resolveOutboundSessionRoute on the messaging adapter", () => {
|
|
20
|
+
expect(typeof messaging?.resolveOutboundSessionRoute).toBe("function");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns null for any outbound target so no mirror session is routed", async () => {
|
|
24
|
+
const resolve = messaging?.resolveOutboundSessionRoute as (
|
|
25
|
+
params: Record<string, unknown>,
|
|
26
|
+
) => unknown;
|
|
27
|
+
const route = await resolve({
|
|
28
|
+
cfg: {},
|
|
29
|
+
agentId: "operator",
|
|
30
|
+
channel: "friday-next",
|
|
31
|
+
target: "9cd3d546-b230-40ab-b931-bb2e8305e38c",
|
|
32
|
+
currentSessionKey: "agent:operator:friday-next:9cd3d546",
|
|
33
|
+
});
|
|
34
|
+
expect(route).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { fridayNextChannelPlugin } from "./channel.js";
|
|
6
|
+
import { sseEmitter } from "./sse/emitter.js";
|
|
7
|
+
import { setOfflineQueueBaseDirForTest } from "./sse/offline-queue.js";
|
|
8
|
+
import { registerRunRoute } from "./run-metadata.js";
|
|
9
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "./test-support/mock-runtime.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Outbound (message-tool send) must route to the session that started the run.
|
|
13
|
+
*
|
|
14
|
+
* OpenClaw's `ChannelOutboundContext` does not carry the originating sessionKey, so the channel
|
|
15
|
+
* recovers it from the run-route registry via the device's last tracked runId. Without this the
|
|
16
|
+
* media/text would land in a device-level fallback session, not the user's current session.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
class MockRes extends EventEmitter {
|
|
20
|
+
writes: string[] = [];
|
|
21
|
+
write(chunk: string): boolean {
|
|
22
|
+
this.writes.push(chunk);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
end(): void {
|
|
26
|
+
// no-op
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type OutboundFrame = { type: string; data: Record<string, unknown> };
|
|
31
|
+
|
|
32
|
+
function parseOutboundFrames(res: MockRes): OutboundFrame[] {
|
|
33
|
+
const frames: OutboundFrame[] = [];
|
|
34
|
+
for (const block of res.writes.join("").split("\n\n")) {
|
|
35
|
+
if (!block.trim()) continue;
|
|
36
|
+
let type = "";
|
|
37
|
+
let data: Record<string, unknown> | undefined;
|
|
38
|
+
for (const line of block.split("\n")) {
|
|
39
|
+
if (line.startsWith("event: ")) type = line.slice("event: ".length).trim();
|
|
40
|
+
else if (line.startsWith("data: ")) {
|
|
41
|
+
try {
|
|
42
|
+
data = JSON.parse(line.slice("data: ".length));
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore non-JSON
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (data) frames.push({ type, data });
|
|
49
|
+
}
|
|
50
|
+
return frames;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const outbound = fridayNextChannelPlugin.outbound as {
|
|
54
|
+
sendText: (ctx: Record<string, unknown>) => Promise<unknown>;
|
|
55
|
+
sendMedia: (ctx: Record<string, unknown>) => Promise<unknown>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
describe("friday-next channel outbound sessionKey routing", () => {
|
|
59
|
+
let historyDir = "";
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
sseEmitter.resetForTest();
|
|
63
|
+
historyDir = createTempHistoryDir();
|
|
64
|
+
setOfflineQueueBaseDirForTest(historyDir);
|
|
65
|
+
setMockRuntime({ historyDir, authToken: "test-token" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
setOfflineQueueBaseDirForTest(null);
|
|
70
|
+
removeTempHistoryDir(historyDir);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function connect(deviceId: string): MockRes {
|
|
74
|
+
const res = new MockRes();
|
|
75
|
+
sseEmitter.addConnection(deviceId, res as never);
|
|
76
|
+
return res;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
it("sendText carries the run's sessionKey (recovered via run-route)", async () => {
|
|
80
|
+
const deviceId = "DEV-TEXT-1";
|
|
81
|
+
const runId = "run-text-1";
|
|
82
|
+
const sessionKey = "agent:operator:friday-next:direct:abc-text";
|
|
83
|
+
registerRunRoute({ runId, deviceId, sessionKey });
|
|
84
|
+
sseEmitter.trackDeviceForRun(deviceId, runId);
|
|
85
|
+
const res = connect(deviceId);
|
|
86
|
+
|
|
87
|
+
await outbound.sendText({ to: deviceId, text: "hi" });
|
|
88
|
+
|
|
89
|
+
const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
|
|
90
|
+
expect(evt).toBeDefined();
|
|
91
|
+
expect(evt?.data.sessionKey).toBe(sessionKey);
|
|
92
|
+
expect(evt?.data.deviceId).toBe(deviceId);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("run-route wins over ctx sessionKey (ctx carries the agent's base/main session, not the active app session)", async () => {
|
|
96
|
+
const deviceId = "DEV-TEXT-2";
|
|
97
|
+
const runId = "run-text-2";
|
|
98
|
+
registerRunRoute({ runId, deviceId, sessionKey: "agent:operator:friday-next:direct:route-session" });
|
|
99
|
+
sseEmitter.trackDeviceForRun(deviceId, runId);
|
|
100
|
+
const res = connect(deviceId);
|
|
101
|
+
|
|
102
|
+
await outbound.sendText({ to: deviceId, text: "hi", requesterSessionKey: "agent:operator:main" });
|
|
103
|
+
|
|
104
|
+
const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
|
|
105
|
+
expect(evt?.data.sessionKey).toBe("agent:operator:friday-next:direct:route-session");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("falls back to device-level session when no run-route exists", async () => {
|
|
109
|
+
const deviceId = "DEV-TEXT-3";
|
|
110
|
+
sseEmitter.trackDeviceForRun(deviceId, "run-text-3-untracked");
|
|
111
|
+
const res = connect(deviceId);
|
|
112
|
+
|
|
113
|
+
await outbound.sendText({ to: deviceId, text: "hi" });
|
|
114
|
+
|
|
115
|
+
const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "text");
|
|
116
|
+
// No mapping registered for this device → synthesized device-level fallback.
|
|
117
|
+
expect(evt?.data.sessionKey).toBe(`agent:main:friday-next-${deviceId}`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("sendMedia carries the run's sessionKey (recovered via run-route)", async () => {
|
|
121
|
+
const deviceId = "DEV-MEDIA-1";
|
|
122
|
+
const runId = "run-media-1";
|
|
123
|
+
const sessionKey = "agent:operator:friday-next:direct:abc-media";
|
|
124
|
+
const mediaFile = path.join(historyDir, "shot.png");
|
|
125
|
+
fs.writeFileSync(mediaFile, "png-bytes");
|
|
126
|
+
registerRunRoute({ runId, deviceId, sessionKey });
|
|
127
|
+
sseEmitter.trackDeviceForRun(deviceId, runId);
|
|
128
|
+
const res = connect(deviceId);
|
|
129
|
+
|
|
130
|
+
await outbound.sendMedia({ to: deviceId, text: "caption", mediaUrl: mediaFile });
|
|
131
|
+
|
|
132
|
+
const evt = parseOutboundFrames(res).find((f) => f.type === "outbound" && f.data.op === "media");
|
|
133
|
+
expect(evt).toBeDefined();
|
|
134
|
+
expect(evt?.data.sessionKey).toBe(sessionKey);
|
|
135
|
+
expect(evt?.data.deviceId).toBe(deviceId);
|
|
136
|
+
});
|
|
137
|
+
});
|
package/src/channel.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
resolveFridayDeviceIdForOutbound,
|
|
20
20
|
resolveHistorySessionKeyForFridayDevice,
|
|
21
21
|
} from "./friday-session.js";
|
|
22
|
+
import { getRunRoute } from "./run-metadata.js";
|
|
22
23
|
import { getLastFridayInboundAt } from "./friday-inbound-stats.js";
|
|
23
24
|
|
|
24
25
|
const logger = createFridayNextLogger("channel");
|
|
@@ -32,6 +33,27 @@ function pickFirstString(source: Record<string, unknown>, keys: string[]): strin
|
|
|
32
33
|
return undefined;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the sessionKey for an outbound message-tool send.
|
|
38
|
+
*
|
|
39
|
+
* OpenClaw's `ChannelOutboundContext` does not carry the originating run's sessionKey, so the raw
|
|
40
|
+
* ctx is almost always missing it. We recover the current run's session via the run-route registry
|
|
41
|
+
* (keyed by the device's last tracked runId) so message-tool text/media land in the session that
|
|
42
|
+
* triggered the run — not a device-level fallback. Falls back to the device's latest history session
|
|
43
|
+
* for cron / offline / no-run paths.
|
|
44
|
+
*/
|
|
45
|
+
function resolveOutboundSessionKey(
|
|
46
|
+
deviceId: string,
|
|
47
|
+
runId: string | undefined,
|
|
48
|
+
rawCtx: Record<string, unknown>,
|
|
49
|
+
): string | undefined {
|
|
50
|
+
return (
|
|
51
|
+
(runId ? getRunRoute(runId)?.sessionKey : undefined) ??
|
|
52
|
+
pickFirstString(rawCtx, ["requesterSessionKey", "sessionKey"]) ??
|
|
53
|
+
resolveHistorySessionKeyForFridayDevice(deviceId)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
function resolveLocalMediaPath(mediaUrl: string, localRoots?: string[]): string {
|
|
36
58
|
if (path.isAbsolute(mediaUrl)) return mediaUrl;
|
|
37
59
|
const roots = localRoots ?? [process.cwd(), os.tmpdir()];
|
|
@@ -138,6 +160,15 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
138
160
|
},
|
|
139
161
|
parseExplicitTarget: () => ({ to: "friday-next" }),
|
|
140
162
|
formatTargetDisplay: ({ display }: any) => display || "Friday Next",
|
|
163
|
+
// friday-next is a transparent proxy: outbound text/media already reach the app live
|
|
164
|
+
// via SSE (sendText/sendMedia/handleSend). The OpenClaw core additionally mirrors
|
|
165
|
+
// message-tool sends into the recipient's session transcript (model:"delivery-mirror").
|
|
166
|
+
// For friday-next that recipient session falls back to
|
|
167
|
+
// `agent:<agentId>:friday-next:direct:<deviceId>` — an orphan session unrelated to the
|
|
168
|
+
// app's real conversation — spawning a phantom session + a stray delivery-mirror message.
|
|
169
|
+
// The core checks this hook first; returning null short-circuits route resolution so no
|
|
170
|
+
// orphan session entry and no delivery-mirror are written.
|
|
171
|
+
resolveOutboundSessionRoute: () => null,
|
|
141
172
|
},
|
|
142
173
|
},
|
|
143
174
|
outbound: {
|
|
@@ -153,9 +184,7 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
153
184
|
"runId",
|
|
154
185
|
]);
|
|
155
186
|
const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
|
|
156
|
-
const sessionKey =
|
|
157
|
-
pickFirstString(rawCtx, ["requesterSessionKey", "sessionKey"]) ??
|
|
158
|
-
resolveHistorySessionKeyForFridayDevice(deviceId);
|
|
187
|
+
const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
|
|
159
188
|
|
|
160
189
|
const conn = sseEmitter.getConnection(deviceId);
|
|
161
190
|
const ts = new Date().toISOString();
|
|
@@ -203,9 +232,7 @@ export const fridayNextChannelPlugin = createChatChannelPlugin({
|
|
|
203
232
|
"runId",
|
|
204
233
|
]);
|
|
205
234
|
const runId = runIdFromCtx ?? sseEmitter.getLastRunIdForDevice(deviceId) ?? undefined;
|
|
206
|
-
const sessionKey =
|
|
207
|
-
pickFirstString(rawCtx, ["requesterSessionKey", "sessionKey"]) ??
|
|
208
|
-
resolveHistorySessionKeyForFridayDevice(deviceId);
|
|
235
|
+
const sessionKey = resolveOutboundSessionKey(deviceId, runId, rawCtx);
|
|
209
236
|
const audioAsVoice = ctx.audioAsVoice === true;
|
|
210
237
|
const caption = ctx.text ?? "";
|
|
211
238
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
normalizeHistoryMessage,
|
|
4
|
+
normalizeHistoryMessages,
|
|
5
|
+
} from "./normalize-message.js";
|
|
6
|
+
|
|
7
|
+
function meta(id?: string, seq = 1, extra: Record<string, unknown> = {}) {
|
|
8
|
+
return { __openclaw: { ...(id ? { id } : {}), seq, recordTimestampMs: 1700000000000, ...extra } };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("normalizeHistoryMessage", () => {
|
|
12
|
+
it("returns null for non-object input", () => {
|
|
13
|
+
expect(normalizeHistoryMessage("nope", 0)).toBeNull();
|
|
14
|
+
expect(normalizeHistoryMessage(null, 0)).toBeNull();
|
|
15
|
+
expect(normalizeHistoryMessage([], 0)).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("normalizes a string-content user message and carries the stable id + ts", () => {
|
|
19
|
+
const out = normalizeHistoryMessage(
|
|
20
|
+
{ role: "user", content: "hello", ...meta("entry-1", 3) },
|
|
21
|
+
7,
|
|
22
|
+
);
|
|
23
|
+
expect(out).toMatchObject({
|
|
24
|
+
id: "entry-1",
|
|
25
|
+
seq: 3,
|
|
26
|
+
ts: 1700000000000,
|
|
27
|
+
role: "user",
|
|
28
|
+
text: "hello",
|
|
29
|
+
});
|
|
30
|
+
expect(out?.synthetic).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("synthesizes an id and flags synthetic when upstream omits __openclaw.id", () => {
|
|
34
|
+
const out = normalizeHistoryMessage({ role: "user", content: "hi", __openclaw: { seq: 5 } }, 0);
|
|
35
|
+
expect(out?.id).toBe("seq:5");
|
|
36
|
+
expect(out?.synthetic).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("falls back to batch index for seq when missing", () => {
|
|
40
|
+
const out = normalizeHistoryMessage({ role: "user", content: "hi" }, 4);
|
|
41
|
+
expect(out?.seq).toBe(5);
|
|
42
|
+
expect(out?.id).toBe("seq:5");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("parses assistant text + thinking + toolCall blocks and model/usage", () => {
|
|
46
|
+
const out = normalizeHistoryMessage(
|
|
47
|
+
{
|
|
48
|
+
role: "assistant",
|
|
49
|
+
model: "openai/gpt-4",
|
|
50
|
+
usage: { input: 10, output: 20, totalTokens: 30 },
|
|
51
|
+
content: [
|
|
52
|
+
{ type: "thinking", thinking: "let me think" },
|
|
53
|
+
{ type: "text", text: "the answer" },
|
|
54
|
+
{ type: "toolCall", id: "tc-1", name: "search", arguments: { q: "x" } },
|
|
55
|
+
],
|
|
56
|
+
...meta("entry-2", 2),
|
|
57
|
+
},
|
|
58
|
+
0,
|
|
59
|
+
);
|
|
60
|
+
expect(out?.text).toBe("the answer");
|
|
61
|
+
expect(out?.thinking).toBe("let me think");
|
|
62
|
+
expect(out?.toolCalls).toEqual([{ id: "tc-1", name: "search", arguments: { q: "x" } }]);
|
|
63
|
+
expect(out?.model).toBe("openai/gpt-4");
|
|
64
|
+
expect(out?.usage).toEqual({ input: 10, output: 20, totalTokens: 30 });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("parses inline image content blocks", () => {
|
|
68
|
+
const out = normalizeHistoryMessage(
|
|
69
|
+
{
|
|
70
|
+
role: "user",
|
|
71
|
+
content: [
|
|
72
|
+
{ type: "text", text: "look" },
|
|
73
|
+
{ type: "image", mimeType: "image/png", data: "BASE64" },
|
|
74
|
+
],
|
|
75
|
+
...meta("entry-img", 1),
|
|
76
|
+
},
|
|
77
|
+
0,
|
|
78
|
+
);
|
|
79
|
+
expect(out?.text).toBe("look");
|
|
80
|
+
expect(out?.images).toEqual([{ mimeType: "image/png", data: "BASE64" }]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("extracts [media attached: ...] markers from text into image urls", () => {
|
|
84
|
+
const out = normalizeHistoryMessage(
|
|
85
|
+
{ role: "user", content: "see [media attached: file:///a.jpg]", ...meta("m", 1) },
|
|
86
|
+
0,
|
|
87
|
+
);
|
|
88
|
+
expect(out?.images).toEqual([{ url: "file:///a.jpg" }]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("normalizes a toolResult message", () => {
|
|
92
|
+
const out = normalizeHistoryMessage(
|
|
93
|
+
{
|
|
94
|
+
role: "toolResult",
|
|
95
|
+
toolCallId: "tc-1",
|
|
96
|
+
toolName: "search",
|
|
97
|
+
isError: false,
|
|
98
|
+
content: [{ type: "text", text: "result text" }],
|
|
99
|
+
...meta("entry-tr", 4),
|
|
100
|
+
},
|
|
101
|
+
0,
|
|
102
|
+
);
|
|
103
|
+
expect(out?.role).toBe("toolResult");
|
|
104
|
+
expect(out?.toolResult).toEqual({
|
|
105
|
+
toolCallId: "tc-1",
|
|
106
|
+
toolName: "search",
|
|
107
|
+
text: "result text",
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("strips MEDIA: lines from text into mediaPaths", () => {
|
|
112
|
+
const out = normalizeHistoryMessage(
|
|
113
|
+
{
|
|
114
|
+
role: "assistant",
|
|
115
|
+
content: "Here is the serene landscape 🌅\nMEDIA:/Users/me/.openclaw/media/tool-image-generation/x.png",
|
|
116
|
+
...meta("a1", 1),
|
|
117
|
+
},
|
|
118
|
+
0,
|
|
119
|
+
);
|
|
120
|
+
expect(out?.text).toBe("Here is the serene landscape 🌅");
|
|
121
|
+
expect(out?.mediaPaths).toEqual(["/Users/me/.openclaw/media/tool-image-generation/x.png"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("captures multiple MEDIA: lines and leaves text without markers untouched", () => {
|
|
125
|
+
const out = normalizeHistoryMessage(
|
|
126
|
+
{ role: "assistant", content: "two files\nMEDIA:/a/x.png\nMEDIA:/a/y.mp4", ...meta("a2", 1) },
|
|
127
|
+
0,
|
|
128
|
+
);
|
|
129
|
+
expect(out?.text).toBe("two files");
|
|
130
|
+
expect(out?.mediaPaths).toEqual(["/a/x.png", "/a/y.mp4"]);
|
|
131
|
+
const plain = normalizeHistoryMessage({ role: "user", content: "no media here", ...meta("u", 1) }, 0);
|
|
132
|
+
expect(plain?.mediaPaths).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("flags compaction records via __openclaw.kind", () => {
|
|
136
|
+
const out = normalizeHistoryMessage(
|
|
137
|
+
{ role: "system", content: [{ type: "text", text: "Compaction" }], ...meta("c1", 9, { kind: "compaction" }) },
|
|
138
|
+
0,
|
|
139
|
+
);
|
|
140
|
+
expect(out?.kind).toBe("compaction");
|
|
141
|
+
expect(out?.role).toBe("system");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("normalizeHistoryMessages", () => {
|
|
146
|
+
it("drops unparseable entries and sorts by seq", () => {
|
|
147
|
+
const result = normalizeHistoryMessages([
|
|
148
|
+
{ role: "assistant", content: "b", __openclaw: { id: "b", seq: 2 } },
|
|
149
|
+
"garbage",
|
|
150
|
+
{ role: "user", content: "a", __openclaw: { id: "a", seq: 1 } },
|
|
151
|
+
]);
|
|
152
|
+
expect(result.map((m) => m.id)).toEqual(["a", "b"]);
|
|
153
|
+
});
|
|
154
|
+
});
|