@syengup/friday-channel-next 0.1.13 → 0.1.14
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 +17 -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/server.js +15 -0
- package/package.json +10 -11
- 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.test.ts +137 -0
- package/src/channel.ts +24 -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/server.ts +18 -0
- package/src/test-support/mock-runtime.ts +2 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a session's transcript directly from disk via the forward runtime's
|
|
3
|
+
* session store (`sessions.json` → entry.sessionFile → the `.jsonl` transcript).
|
|
4
|
+
*
|
|
5
|
+
* We do NOT use `runtime.subagent.getSessionMessages` here: that dispatches the
|
|
6
|
+
* gateway `sessions.get` method which is only valid inside a gateway request
|
|
7
|
+
* scope and returns empty when called from a plugin HTTP route. Reading the
|
|
8
|
+
* transcript file mirrors how `history-sessions.ts` reads `sessions.json`.
|
|
9
|
+
*
|
|
10
|
+
* Each transcript line is `{type, id, parentId, timestamp, message:{role,content,...}}`.
|
|
11
|
+
* We surface message records (in file order) with an `__openclaw` envelope
|
|
12
|
+
* matching the gateway's own `sessions.get` output, so `normalize-message.ts`
|
|
13
|
+
* can consume either source identically.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { getFridayAgentForwardRuntime } from "../agent-forward-runtime.js";
|
|
19
|
+
import { agentIdFromSessionKey, toSessionStoreKey } from "../session/session-manager.js";
|
|
20
|
+
|
|
21
|
+
function entryString(entry: unknown, key: string): string | undefined {
|
|
22
|
+
if (!entry || typeof entry !== "object") return undefined;
|
|
23
|
+
const v = (entry as Record<string, unknown>)[key];
|
|
24
|
+
return typeof v === "string" && v.trim() ? v : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolves the store entry for a session key, tolerating case differences
|
|
29
|
+
* (the app's key carries an upper-case deviceId; `sessions.json` stores it
|
|
30
|
+
* lower-cased).
|
|
31
|
+
*/
|
|
32
|
+
function resolveEntry(store: Record<string, unknown>, sessionKey: string): unknown {
|
|
33
|
+
if (store[sessionKey]) return store[sessionKey];
|
|
34
|
+
const canonical = toSessionStoreKey(sessionKey);
|
|
35
|
+
if (store[canonical]) return store[canonical];
|
|
36
|
+
const target = canonical.toLowerCase();
|
|
37
|
+
for (const [k, v] of Object.entries(store)) {
|
|
38
|
+
if (k.toLowerCase() === target) return v;
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveTranscriptPath(
|
|
44
|
+
entry: unknown,
|
|
45
|
+
storePath: string,
|
|
46
|
+
): string | undefined {
|
|
47
|
+
const sessionFile = entryString(entry, "sessionFile");
|
|
48
|
+
if (sessionFile) {
|
|
49
|
+
return path.isAbsolute(sessionFile)
|
|
50
|
+
? sessionFile
|
|
51
|
+
: path.join(path.dirname(storePath), sessionFile);
|
|
52
|
+
}
|
|
53
|
+
const sessionId = entryString(entry, "sessionId");
|
|
54
|
+
if (sessionId) {
|
|
55
|
+
return path.join(path.dirname(storePath), `${sessionId}.jsonl`);
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Resolves the real server-side session id for a session key, or undefined. */
|
|
61
|
+
export function resolveSessionId(sessionKey: string): string | undefined {
|
|
62
|
+
const rt = getFridayAgentForwardRuntime();
|
|
63
|
+
if (!rt) return undefined;
|
|
64
|
+
const agentId = agentIdFromSessionKey(sessionKey);
|
|
65
|
+
try {
|
|
66
|
+
const store = rt.loadSessionStore(rt.resolveStorePath(undefined, { agentId })) ?? {};
|
|
67
|
+
return entryString(resolveEntry(store, sessionKey), "sessionId");
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns raw transcript message objects (newest tail up to `limit`), each with
|
|
75
|
+
* an `__openclaw: { id, seq, recordTimestampMs }` envelope. Empty on any failure.
|
|
76
|
+
*/
|
|
77
|
+
export function readSessionTranscriptRawMessages(sessionKey: string, limit: number): unknown[] {
|
|
78
|
+
const rt = getFridayAgentForwardRuntime();
|
|
79
|
+
if (!rt) return [];
|
|
80
|
+
|
|
81
|
+
const agentId = agentIdFromSessionKey(sessionKey);
|
|
82
|
+
let storePath: string;
|
|
83
|
+
let store: Record<string, unknown>;
|
|
84
|
+
try {
|
|
85
|
+
storePath = rt.resolveStorePath(undefined, { agentId });
|
|
86
|
+
store = rt.loadSessionStore(storePath) ?? {};
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const entry = resolveEntry(store, sessionKey);
|
|
92
|
+
if (!entry) return [];
|
|
93
|
+
const filePath = resolveTranscriptPath(entry, storePath);
|
|
94
|
+
if (!filePath) return [];
|
|
95
|
+
|
|
96
|
+
let content: string;
|
|
97
|
+
try {
|
|
98
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const raw: unknown[] = [];
|
|
104
|
+
let seq = 0;
|
|
105
|
+
for (const line of content.split("\n")) {
|
|
106
|
+
const trimmed = line.trim();
|
|
107
|
+
if (!trimmed) continue;
|
|
108
|
+
let rec: Record<string, unknown>;
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
111
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
112
|
+
rec = parsed as Record<string, unknown>;
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (rec.type === "session" || !rec.message || typeof rec.message !== "object") continue;
|
|
117
|
+
seq += 1;
|
|
118
|
+
const tsRaw = rec.timestamp;
|
|
119
|
+
const ts =
|
|
120
|
+
typeof tsRaw === "string"
|
|
121
|
+
? Date.parse(tsRaw)
|
|
122
|
+
: typeof tsRaw === "number"
|
|
123
|
+
? tsRaw
|
|
124
|
+
: Number.NaN;
|
|
125
|
+
raw.push({
|
|
126
|
+
...(rec.message as Record<string, unknown>),
|
|
127
|
+
__openclaw: {
|
|
128
|
+
...(typeof rec.id === "string" ? { id: rec.id } : {}),
|
|
129
|
+
seq,
|
|
130
|
+
...(Number.isFinite(ts) ? { recordTimestampMs: ts } : {}),
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return limit > 0 && raw.length > limit ? raw.slice(raw.length - limit) : raw;
|
|
136
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File manager for Friday Next channel attachments.
|
|
3
3
|
*
|
|
4
|
-
* Files are copied under
|
|
4
|
+
* Files are copied under `~/.openclaw/friday-next/attachments/` and served at
|
|
5
5
|
* GET /friday-next/files/{token} so the app can use stable gateway URLs after restarts.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -11,14 +11,30 @@ import os from "node:os";
|
|
|
11
11
|
import { createFridayNextLogger } from "../../logging.js";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { resolveFridayNextConfig } from "../../config.js";
|
|
15
|
+
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
16
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
/** Test-only override for the attachments base directory. */
|
|
19
|
+
let testAttachmentsDir: string | null = null;
|
|
20
|
+
|
|
21
|
+
export function setAttachmentsDirForTest(dir: string | null): void {
|
|
22
|
+
testAttachmentsDir = dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Resolve `<historyDir>/../attachments`, mirroring the offline-queue layout. */
|
|
26
|
+
function resolveAttachmentsDir(): string {
|
|
27
|
+
try {
|
|
28
|
+
const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(getFridayNextRuntime().config));
|
|
29
|
+
return path.join(path.dirname(cfg.historyDir), "attachments");
|
|
30
|
+
} catch {
|
|
31
|
+
return path.join(os.homedir(), ".openclaw", "friday-next", "attachments");
|
|
32
|
+
}
|
|
17
33
|
}
|
|
18
34
|
|
|
19
|
-
/**
|
|
35
|
+
/** `~/.openclaw/friday-next/attachments/` directory; created on first use. */
|
|
20
36
|
export function getAttachmentsDir(): string {
|
|
21
|
-
const dir =
|
|
37
|
+
const dir = testAttachmentsDir ?? resolveAttachmentsDir();
|
|
22
38
|
try {
|
|
23
39
|
fs.mkdirSync(dir, { recursive: true });
|
|
24
40
|
} catch {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { handleHistoryMessages } from "./history-messages.js";
|
|
7
|
+
import { setFridayNextRuntime } from "../../runtime.js";
|
|
8
|
+
import {
|
|
9
|
+
setFridayAgentForwardRuntime,
|
|
10
|
+
resetFridayAgentForwardRuntimeForTest,
|
|
11
|
+
} from "../../agent-forward-runtime.js";
|
|
12
|
+
|
|
13
|
+
class MockRes extends EventEmitter {
|
|
14
|
+
statusCode = 0;
|
|
15
|
+
headers: Record<string, string> = {};
|
|
16
|
+
body = "";
|
|
17
|
+
setHeader(name: string, value: string): void {
|
|
18
|
+
this.headers[name.toLowerCase()] = value;
|
|
19
|
+
}
|
|
20
|
+
end(body?: string): void {
|
|
21
|
+
if (body) this.body += body;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeReq(path: string, headers: Record<string, string> = {}, method = "GET"): any {
|
|
26
|
+
return { method, url: path, headers };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const AUTH = { authorization: "Bearer test-token" };
|
|
30
|
+
const CFG = {
|
|
31
|
+
channels: { "friday-next": { authToken: "test-token", pathPrefix: "/friday-next" } },
|
|
32
|
+
gateway: { auth: { token: "test-token" } },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let tmpDir = "";
|
|
36
|
+
|
|
37
|
+
/** Auth config + optional subagent fallback. */
|
|
38
|
+
function setRuntime(getSessionMessages?: (params: { sessionKey: string; limit?: number }) => Promise<{ messages?: unknown[] }>): void {
|
|
39
|
+
setFridayNextRuntime({
|
|
40
|
+
config: { loadConfig: () => CFG },
|
|
41
|
+
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
|
42
|
+
...(getSessionMessages ? { subagent: { getSessionMessages } } : {}),
|
|
43
|
+
} as never);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Forward runtime: store keyed by full session key → entry with a sessionFile. */
|
|
47
|
+
function setForward(store: Record<string, unknown>): void {
|
|
48
|
+
setFridayAgentForwardRuntime({
|
|
49
|
+
runtime: {
|
|
50
|
+
agent: {
|
|
51
|
+
session: {
|
|
52
|
+
resolveStorePath: (_s?: string, opts?: { agentId?: string }) =>
|
|
53
|
+
path.join(tmpDir, `${opts?.agentId ?? "main"}-sessions.json`),
|
|
54
|
+
loadSessionStore: () => store,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
config: { current: () => CFG },
|
|
58
|
+
},
|
|
59
|
+
} as any);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeTranscript(name: string, lines: unknown[]): string {
|
|
63
|
+
const file = path.join(tmpDir, name);
|
|
64
|
+
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n", "utf-8");
|
|
65
|
+
return file;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("handleHistoryMessages", () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "friday-hist-"));
|
|
71
|
+
setRuntime();
|
|
72
|
+
});
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
resetFridayAgentForwardRuntimeForTest();
|
|
75
|
+
try {
|
|
76
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
77
|
+
} catch {
|
|
78
|
+
/* ignore */
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("rejects non-GET with 405", async () => {
|
|
83
|
+
const res = new MockRes();
|
|
84
|
+
await handleHistoryMessages(makeReq("/friday-next/history/messages", AUTH, "POST"), res as any);
|
|
85
|
+
expect(res.statusCode).toBe(405);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rejects missing token with 401", async () => {
|
|
89
|
+
const res = new MockRes();
|
|
90
|
+
await handleHistoryMessages(makeReq("/friday-next/history/messages"), res as any);
|
|
91
|
+
expect(res.statusCode).toBe(401);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("400s when sessionKey is missing", async () => {
|
|
95
|
+
const res = new MockRes();
|
|
96
|
+
await handleHistoryMessages(makeReq("/friday-next/history/messages", AUTH), res as any);
|
|
97
|
+
expect(res.statusCode).toBe(400);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("reads the transcript file from disk including user + assistant messages", async () => {
|
|
101
|
+
const file = writeTranscript("sess.jsonl", [
|
|
102
|
+
{ type: "session", version: 1, sessionId: "s" },
|
|
103
|
+
{ type: "message", id: "u1", timestamp: "2026-01-01T00:00:00.000Z", message: { role: "user", content: "hi there" } },
|
|
104
|
+
{ type: "message", id: "a1", timestamp: "2026-01-01T00:00:01.000Z", message: { role: "assistant", content: [{ type: "text", text: "hello" }], model: "openai/gpt-4" } },
|
|
105
|
+
]);
|
|
106
|
+
setForward({ "agent:main:main": { sessionId: "s", sessionFile: file } });
|
|
107
|
+
|
|
108
|
+
const res = new MockRes();
|
|
109
|
+
await handleHistoryMessages(makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH), res as any);
|
|
110
|
+
expect(res.statusCode).toBe(200);
|
|
111
|
+
const body = JSON.parse(res.body);
|
|
112
|
+
expect(body.messages.map((m: any) => m.role)).toEqual(["user", "assistant"]);
|
|
113
|
+
expect(body.messages[0].text).toBe("hi there");
|
|
114
|
+
expect(body.messages[1].text).toBe("hello");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("resolves the entry case-insensitively (app upper-cases deviceId)", async () => {
|
|
118
|
+
const file = writeTranscript("fd.jsonl", [
|
|
119
|
+
{ type: "message", id: "u1", message: { role: "user", content: "from app" } },
|
|
120
|
+
]);
|
|
121
|
+
// Store keyed lower-case (as sessions.json persists it).
|
|
122
|
+
setForward({ "agent:main:friday:direct:abcd-1234:9": { sessionId: "x", sessionFile: file } });
|
|
123
|
+
|
|
124
|
+
const res = new MockRes();
|
|
125
|
+
await handleHistoryMessages(
|
|
126
|
+
makeReq("/friday-next/history/messages?sessionKey=agent:main:friday:direct:ABCD-1234:9", AUTH),
|
|
127
|
+
res as any,
|
|
128
|
+
);
|
|
129
|
+
const body = JSON.parse(res.body);
|
|
130
|
+
expect(body.messages.map((m: any) => m.role)).toEqual(["user"]);
|
|
131
|
+
expect(body.messages[0].text).toBe("from app");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("falls back to getSessionMessages when the transcript is not on disk", async () => {
|
|
135
|
+
setForward({}); // no entry → disk read yields nothing
|
|
136
|
+
setRuntime(async () => ({
|
|
137
|
+
messages: [{ role: "assistant", content: "fallback", __openclaw: { id: "a1", seq: 1 } }],
|
|
138
|
+
}));
|
|
139
|
+
const res = new MockRes();
|
|
140
|
+
await handleHistoryMessages(makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH), res as any);
|
|
141
|
+
const body = JSON.parse(res.body);
|
|
142
|
+
expect(body.messages.map((m: any) => m.id)).toEqual(["a1"]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /friday-next/history/messages?sessionKey=&agentId=&limit=
|
|
3
|
+
*
|
|
4
|
+
* Returns a session's transcript history as a flat, normalized message stream.
|
|
5
|
+
* The Friday app groups these into rounds itself (by role transitions) and uses
|
|
6
|
+
* each message's stable `id` (the upstream transcript entry id) as its sync key.
|
|
7
|
+
*
|
|
8
|
+
* Reads via the gateway `sessions.get` method (exposed to plugins as
|
|
9
|
+
* `runtime.subagent.getSessionMessages`), which already resolves the active
|
|
10
|
+
* branch and compaction, then normalizes each raw message into a stable DTO.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
14
|
+
import { getFridayNextRuntime } from "../../runtime.js";
|
|
15
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
16
|
+
import { normalizeHistoryMessages } from "../../history/normalize-message.js";
|
|
17
|
+
import { readSessionTranscriptRawMessages, resolveSessionId } from "../../history/read-transcript.js";
|
|
18
|
+
import { resolveMediaAttachment } from "./files.js";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LIMIT = 200;
|
|
21
|
+
const MAX_LIMIT = 1000;
|
|
22
|
+
|
|
23
|
+
type SubagentSessionApi = {
|
|
24
|
+
getSessionMessages?: (params: {
|
|
25
|
+
sessionKey: string;
|
|
26
|
+
limit?: number;
|
|
27
|
+
}) => Promise<{ messages?: unknown[] }>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function resolveSubagentApi(): SubagentSessionApi | undefined {
|
|
31
|
+
try {
|
|
32
|
+
const runtime = getFridayNextRuntime();
|
|
33
|
+
return (runtime as unknown as { subagent?: SubagentSessionApi }).subagent;
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function handleHistoryMessages(
|
|
40
|
+
req: IncomingMessage,
|
|
41
|
+
res: ServerResponse,
|
|
42
|
+
): Promise<boolean> {
|
|
43
|
+
if (req.method !== "GET") {
|
|
44
|
+
res.statusCode = 405;
|
|
45
|
+
res.setHeader("Content-Type", "application/json");
|
|
46
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const token = extractBearerToken(req);
|
|
51
|
+
if (!token) {
|
|
52
|
+
res.statusCode = 401;
|
|
53
|
+
res.setHeader("Content-Type", "application/json");
|
|
54
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
59
|
+
const sessionKey = url.searchParams.get("sessionKey")?.trim();
|
|
60
|
+
if (!sessionKey) {
|
|
61
|
+
res.statusCode = 400;
|
|
62
|
+
res.setHeader("Content-Type", "application/json");
|
|
63
|
+
res.end(JSON.stringify({ error: "Missing required query param: sessionKey" }));
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
const agentId = url.searchParams.get("agentId")?.trim() || undefined;
|
|
67
|
+
const limitParam = Number(url.searchParams.get("limit"));
|
|
68
|
+
const limit =
|
|
69
|
+
Number.isFinite(limitParam) && limitParam > 0
|
|
70
|
+
? Math.min(Math.floor(limitParam), MAX_LIMIT)
|
|
71
|
+
: DEFAULT_LIMIT;
|
|
72
|
+
|
|
73
|
+
// Primary path: read the transcript file directly (works from an HTTP route).
|
|
74
|
+
let rawMessages: unknown[] = readSessionTranscriptRawMessages(sessionKey, limit);
|
|
75
|
+
|
|
76
|
+
// Fallback: the request-scoped gateway method (only works in some contexts).
|
|
77
|
+
if (rawMessages.length === 0) {
|
|
78
|
+
const sessionApi = resolveSubagentApi();
|
|
79
|
+
if (sessionApi?.getSessionMessages) {
|
|
80
|
+
try {
|
|
81
|
+
const response = await sessionApi.getSessionMessages({ sessionKey, limit });
|
|
82
|
+
rawMessages = Array.isArray(response?.messages) ? response.messages : [];
|
|
83
|
+
} catch {
|
|
84
|
+
// Best-effort: an unreadable/unknown session yields an empty history
|
|
85
|
+
// rather than an error, so the app degrades gracefully.
|
|
86
|
+
rawMessages = [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const messages = normalizeHistoryMessages(rawMessages);
|
|
92
|
+
|
|
93
|
+
// Resolve `MEDIA:<server-path>` references into downloadable attachment URLs
|
|
94
|
+
// (copies the file into the plugin's attachments/ dir — the same mechanism the
|
|
95
|
+
// live deliver path uses), then drop the raw paths from the wire.
|
|
96
|
+
for (const message of messages) {
|
|
97
|
+
if (!message.mediaPaths?.length) continue;
|
|
98
|
+
const resolved = message.mediaPaths
|
|
99
|
+
.map((p) => resolveMediaAttachment(p))
|
|
100
|
+
.filter((r): r is NonNullable<typeof r> => Boolean(r))
|
|
101
|
+
.map((r) => ({ url: r.url, filename: r.fileName }));
|
|
102
|
+
if (resolved.length) {
|
|
103
|
+
message.images = [...(message.images ?? []), ...resolved];
|
|
104
|
+
}
|
|
105
|
+
delete message.mediaPaths;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const sessionId = resolveSessionId(sessionKey);
|
|
109
|
+
|
|
110
|
+
res.statusCode = 200;
|
|
111
|
+
res.setHeader("Content-Type", "application/json");
|
|
112
|
+
res.end(
|
|
113
|
+
JSON.stringify({
|
|
114
|
+
ok: true,
|
|
115
|
+
sessionKey,
|
|
116
|
+
...(agentId ? { agentId } : {}),
|
|
117
|
+
...(sessionId ? { sessionId } : {}),
|
|
118
|
+
totalMessages: messages.length,
|
|
119
|
+
messages,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { handleHistorySessions } from "./history-sessions.js";
|
|
7
|
+
import { setMockRuntime } from "../../test-support/mock-runtime.js";
|
|
8
|
+
import {
|
|
9
|
+
setFridayAgentForwardRuntime,
|
|
10
|
+
resetFridayAgentForwardRuntimeForTest,
|
|
11
|
+
} from "../../agent-forward-runtime.js";
|
|
12
|
+
|
|
13
|
+
class MockRes extends EventEmitter {
|
|
14
|
+
statusCode = 0;
|
|
15
|
+
headers: Record<string, string> = {};
|
|
16
|
+
body = "";
|
|
17
|
+
setHeader(name: string, value: string): void {
|
|
18
|
+
this.headers[name.toLowerCase()] = value;
|
|
19
|
+
}
|
|
20
|
+
end(body?: string): void {
|
|
21
|
+
if (body) this.body += body;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeReq(headers: Record<string, string> = {}, method = "GET"): any {
|
|
26
|
+
return { method, url: "/friday-next/history/sessions", headers };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const AUTH = { authorization: "Bearer test-token" };
|
|
30
|
+
|
|
31
|
+
let tmpDir = "";
|
|
32
|
+
|
|
33
|
+
/** Write a non-empty transcript file and return its absolute path. */
|
|
34
|
+
function transcript(name: string): string {
|
|
35
|
+
const file = path.join(tmpDir, name);
|
|
36
|
+
fs.writeFileSync(file, `${JSON.stringify({ type: "message", id: "m", message: { role: "user", content: "hi" } })}\n`, "utf-8");
|
|
37
|
+
return file;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setForward(config: unknown, storesByAgent: Record<string, Record<string, unknown>>): void {
|
|
41
|
+
setFridayAgentForwardRuntime({
|
|
42
|
+
runtime: {
|
|
43
|
+
agent: {
|
|
44
|
+
session: {
|
|
45
|
+
resolveStorePath: (_s?: string, opts?: { agentId?: string }) =>
|
|
46
|
+
path.join(tmpDir, `${opts?.agentId ?? "main"}.json`),
|
|
47
|
+
loadSessionStore: (p: string) => {
|
|
48
|
+
const agentId = path.basename(p, ".json");
|
|
49
|
+
return storesByAgent[agentId] ?? {};
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
config: { current: () => config },
|
|
54
|
+
},
|
|
55
|
+
} as any);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("handleHistorySessions", () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "friday-hs-"));
|
|
61
|
+
setMockRuntime();
|
|
62
|
+
});
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
resetFridayAgentForwardRuntimeForTest();
|
|
65
|
+
try {
|
|
66
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
67
|
+
} catch {
|
|
68
|
+
/* ignore */
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects non-GET with 405", async () => {
|
|
73
|
+
const res = new MockRes();
|
|
74
|
+
await handleHistorySessions(makeReq(AUTH, "POST"), res as any);
|
|
75
|
+
expect(res.statusCode).toBe(405);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("rejects missing token with 401", async () => {
|
|
79
|
+
const res = new MockRes();
|
|
80
|
+
await handleHistorySessions(makeReq(), res as any);
|
|
81
|
+
expect(res.statusCode).toBe(401);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lists live sessions with sessionId + server title, sorted by updatedAt", async () => {
|
|
85
|
+
setForward(
|
|
86
|
+
{ agents: { list: [{ id: "main" }] } },
|
|
87
|
+
{
|
|
88
|
+
main: {
|
|
89
|
+
"agent:main:main": { sessionId: "s-main", updatedAt: 100, sessionFile: transcript("main.jsonl") },
|
|
90
|
+
"agent:main:friday:direct:dev:1": {
|
|
91
|
+
sessionId: "s-fd",
|
|
92
|
+
updatedAt: 300,
|
|
93
|
+
displayName: "我的会话",
|
|
94
|
+
sessionFile: transcript("fd.jsonl"),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
const res = new MockRes();
|
|
100
|
+
await handleHistorySessions(makeReq(AUTH), res as any);
|
|
101
|
+
const body = JSON.parse(res.body);
|
|
102
|
+
expect(body.sessions.map((s: any) => s.sessionKey)).toEqual([
|
|
103
|
+
"agent:main:friday:direct:dev:1",
|
|
104
|
+
"agent:main:main",
|
|
105
|
+
]);
|
|
106
|
+
const fd = body.sessions[0];
|
|
107
|
+
expect(fd).toMatchObject({ sessionId: "s-fd", title: "我的会话" });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("filters out archived sessions (transcript file missing)", async () => {
|
|
111
|
+
setForward(
|
|
112
|
+
{ agents: { list: [{ id: "main" }] } },
|
|
113
|
+
{
|
|
114
|
+
main: {
|
|
115
|
+
"agent:main:live": { sessionId: "a", updatedAt: 1, sessionFile: transcript("live.jsonl") },
|
|
116
|
+
"agent:main:archived": { sessionId: "b", updatedAt: 2, sessionFile: path.join(tmpDir, "gone.jsonl") },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
const res = new MockRes();
|
|
121
|
+
await handleHistorySessions(makeReq(AUTH), res as any);
|
|
122
|
+
const keys = JSON.parse(res.body).sessions.map((s: any) => s.sessionKey);
|
|
123
|
+
expect(keys).toEqual(["agent:main:live"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("filters out internal/system + subagent sessions", async () => {
|
|
127
|
+
setForward(
|
|
128
|
+
{ agents: { list: [{ id: "main" }] } },
|
|
129
|
+
{
|
|
130
|
+
main: {
|
|
131
|
+
"agent:main:main": { sessionId: "ok", updatedAt: 5, sessionFile: transcript("ok.jsonl") },
|
|
132
|
+
"agent:main:main:heartbeat": { sessionId: "hb", updatedAt: 4, sessionFile: transcript("hb.jsonl") },
|
|
133
|
+
"agent:main:cron:abc": { sessionId: "c", updatedAt: 3, sessionFile: transcript("c.jsonl") },
|
|
134
|
+
"agent:main:subagent:xyz": { sessionId: "sa", updatedAt: 2, sessionFile: transcript("sa.jsonl") },
|
|
135
|
+
"agent:main:dreaming-narrative-rem-1": { sessionId: "d", updatedAt: 1, sessionFile: transcript("d.jsonl") },
|
|
136
|
+
"agent:main:child": { sessionId: "ch", updatedAt: 6, spawnedBy: "agent:main:main", sessionFile: transcript("ch.jsonl") },
|
|
137
|
+
global: { sessionId: "g", updatedAt: 7, sessionFile: transcript("g.jsonl") },
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
const res = new MockRes();
|
|
142
|
+
await handleHistorySessions(makeReq(AUTH), res as any);
|
|
143
|
+
const keys = JSON.parse(res.body).sessions.map((s: any) => s.sessionKey);
|
|
144
|
+
expect(keys).toEqual(["agent:main:main"]);
|
|
145
|
+
});
|
|
146
|
+
});
|