@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.
Files changed (44) hide show
  1. package/dist/src/agent-forward-runtime.d.ts +6 -0
  2. package/dist/src/agent-forward-runtime.js +2 -0
  3. package/dist/src/channel-actions.js +9 -1
  4. package/dist/src/channel.js +26 -4
  5. package/dist/src/history/normalize-message.d.ts +67 -0
  6. package/dist/src/history/normalize-message.js +224 -0
  7. package/dist/src/history/read-transcript.d.ts +22 -0
  8. package/dist/src/history/read-transcript.js +136 -0
  9. package/dist/src/http/handlers/files.d.ts +3 -2
  10. package/dist/src/http/handlers/files.js +20 -5
  11. package/dist/src/http/handlers/history-messages.d.ts +13 -0
  12. package/dist/src/http/handlers/history-messages.js +100 -0
  13. package/dist/src/http/handlers/history-sessions.d.ts +23 -0
  14. package/dist/src/http/handlers/history-sessions.js +163 -0
  15. package/dist/src/http/handlers/history-set-title.d.ts +10 -0
  16. package/dist/src/http/handlers/history-set-title.js +77 -0
  17. package/dist/src/http/handlers/messages.js +6 -38
  18. package/dist/src/http/handlers/sessions-settings.js +22 -7
  19. package/dist/src/http/server.js +15 -0
  20. package/dist/src/session/session-manager.d.ts +30 -1
  21. package/dist/src/session/session-manager.js +50 -1
  22. package/package.json +2 -2
  23. package/src/agent-forward-runtime.ts +10 -0
  24. package/src/channel-actions.test.ts +111 -0
  25. package/src/channel-actions.ts +10 -1
  26. package/src/channel.outbound-mirror-suppression.test.ts +36 -0
  27. package/src/channel.outbound.test.ts +137 -0
  28. package/src/channel.ts +33 -6
  29. package/src/history/normalize-message.test.ts +154 -0
  30. package/src/history/normalize-message.ts +292 -0
  31. package/src/history/read-transcript.ts +136 -0
  32. package/src/http/handlers/files.ts +21 -5
  33. package/src/http/handlers/history-messages.test.ts +144 -0
  34. package/src/http/handlers/history-messages.ts +123 -0
  35. package/src/http/handlers/history-sessions.test.ts +146 -0
  36. package/src/http/handlers/history-sessions.ts +184 -0
  37. package/src/http/handlers/history-set-title.test.ts +115 -0
  38. package/src/http/handlers/history-set-title.ts +86 -0
  39. package/src/http/handlers/messages.ts +8 -46
  40. package/src/http/handlers/sessions-settings.ts +23 -6
  41. package/src/http/server.ts +18 -0
  42. package/src/session/session-manager.test.ts +42 -0
  43. package/src/session/session-manager.ts +73 -3
  44. package/src/test-support/mock-runtime.ts +2 -0
@@ -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
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * GET /friday-next/history/sessions
3
+ *
4
+ * Lists every session across every configured agent (lightweight metadata only).
5
+ * The Friday app uses this to surface sessions created on other platforms /
6
+ * channels in its sidebar before lazily fetching each session's history.
7
+ *
8
+ * Session message bodies are intentionally NOT read here — that is the job of the
9
+ * per-session history endpoint. We only read each agent's `sessions.json` via the
10
+ * forward runtime (`loadSessionStore`), matching the read path already used for
11
+ * terminal lifecycle forwards.
12
+ */
13
+
14
+ import type { IncomingMessage, ServerResponse } from "node:http";
15
+ import fs from "node:fs";
16
+ import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
17
+ import { extractBearerToken } from "../middleware/auth.js";
18
+ import { resolveTranscriptPath } from "../../history/read-transcript.js";
19
+
20
+ const DEFAULT_AGENT_ID = "main";
21
+ const SAFE_AGENT_ID = /^[a-z0-9][a-z0-9_-]*$/;
22
+
23
+ export interface FridayHistorySessionSummary {
24
+ /** Canonical app session key, e.g. "agent:main:main". */
25
+ sessionKey: string;
26
+ agentId: string;
27
+ sessionId?: string;
28
+ updatedAt?: number;
29
+ model?: string;
30
+ title?: string;
31
+ }
32
+
33
+ /** Mirror of OpenClaw's `normalizeAgentId` (also used in agents-list.ts). */
34
+ function normalizeAgentId(value: unknown): string {
35
+ const trimmed = typeof value === "string" ? value.trim() : "";
36
+ if (!trimmed) return DEFAULT_AGENT_ID;
37
+ const lowered = trimmed.toLowerCase();
38
+ if (SAFE_AGENT_ID.test(lowered)) return lowered;
39
+ return (
40
+ lowered
41
+ .replace(/[^a-z0-9_-]+/g, "-")
42
+ .replace(/^-+|-+$/g, "")
43
+ .slice(0, 64) || DEFAULT_AGENT_ID
44
+ );
45
+ }
46
+
47
+ function readString(value: unknown): string | undefined {
48
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
49
+ }
50
+
51
+ function readNumber(value: unknown): number | undefined {
52
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
53
+ }
54
+
55
+ /** Configured agent ids (deduped). Falls back to the implicit "main" agent. */
56
+ function resolveConfiguredAgentIds(config: Record<string, unknown> | undefined): string[] {
57
+ const agents = config?.agents as Record<string, unknown> | undefined;
58
+ const list = agents?.list as Array<Record<string, unknown>> | undefined;
59
+ if (!Array.isArray(list) || list.length === 0) return [DEFAULT_AGENT_ID];
60
+ const ids = new Set<string>();
61
+ for (const agent of list) {
62
+ if (agent && typeof agent === "object") ids.add(normalizeAgentId(agent.id));
63
+ }
64
+ if (ids.size === 0) ids.add(DEFAULT_AGENT_ID);
65
+ return [...ids];
66
+ }
67
+
68
+ /** Build the canonical app session key from an agent + a raw sessions.json key. */
69
+ function toCanonicalSessionKey(agentId: string, storeKey: string): string {
70
+ const key = storeKey.trim();
71
+ if (key.toLowerCase().startsWith("agent:")) return key;
72
+ return `agent:${agentId}:${key}`;
73
+ }
74
+
75
+ /** Session-id portion (everything after `agent:<id>:`). */
76
+ function sessionRest(canonicalKey: string): string {
77
+ const m = canonicalKey.match(/^agent:[^:]+:(.+)$/);
78
+ return (m?.[1] ?? canonicalKey).toLowerCase();
79
+ }
80
+
81
+ /**
82
+ * Internal/system sessions that aren't user-facing conversations — mirrors what
83
+ * OpenClaw's own `sessions.list` filters (cron/global/unknown/phantom) plus
84
+ * heartbeat / subagent / memory-dreaming runs.
85
+ */
86
+ function isInternalSessionKey(canonicalKey: string, storeKey: string): boolean {
87
+ const k = storeKey.toLowerCase();
88
+ if (k === "global" || k === "unknown") return true;
89
+ const rest = sessionRest(canonicalKey);
90
+ return (
91
+ rest === "sessions" || // phantom agent-store entry
92
+ rest.startsWith("subagent:") ||
93
+ rest.startsWith("cron:") ||
94
+ rest.startsWith("dreaming-narrative") ||
95
+ rest === "heartbeat" ||
96
+ rest.endsWith(":heartbeat")
97
+ );
98
+ }
99
+
100
+ /** A session counts as archived/empty when its transcript file is gone or empty. */
101
+ function hasLiveTranscript(entry: Record<string, unknown>, storePath: string): boolean {
102
+ const filePath = resolveTranscriptPath(entry, storePath);
103
+ if (!filePath) return false;
104
+ try {
105
+ return fs.statSync(filePath).size > 0;
106
+ } catch {
107
+ return false; // archived (moved to .deleted/.bak) or never written
108
+ }
109
+ }
110
+
111
+ function readAgentSessions(agentId: string): FridayHistorySessionSummary[] {
112
+ const rt = getFridayAgentForwardRuntime();
113
+ if (!rt) return [];
114
+ let storePath: string;
115
+ let store: Record<string, unknown>;
116
+ try {
117
+ storePath = rt.resolveStorePath(undefined, { agentId });
118
+ store = rt.loadSessionStore(storePath) ?? {};
119
+ } catch {
120
+ return [];
121
+ }
122
+ const summaries: FridayHistorySessionSummary[] = [];
123
+ for (const [storeKey, rawEntry] of Object.entries(store)) {
124
+ if (!rawEntry || typeof rawEntry !== "object") continue;
125
+ const entry = rawEntry as Record<string, unknown>;
126
+ const canonicalKey = toCanonicalSessionKey(agentId, storeKey);
127
+
128
+ // Drop internal/system sessions and subagent links.
129
+ if (isInternalSessionKey(canonicalKey, storeKey)) continue;
130
+ if (entry.spawnedBy || entry.subagentRole || entry.parentSessionKey) continue;
131
+ // Drop archived/empty sessions (transcript moved away or never written).
132
+ if (!hasLiveTranscript(entry, storePath)) continue;
133
+
134
+ summaries.push({
135
+ sessionKey: canonicalKey,
136
+ agentId,
137
+ ...(readString(entry.sessionId) ? { sessionId: readString(entry.sessionId) } : {}),
138
+ ...(readNumber(entry.updatedAt) !== undefined ? { updatedAt: readNumber(entry.updatedAt) } : {}),
139
+ ...(readString(entry.model) ?? readString(entry.modelOverride)
140
+ ? { model: readString(entry.model) ?? readString(entry.modelOverride) }
141
+ : {}),
142
+ // Server-side session display name (matches OpenClaw's resolution order).
143
+ ...(readString(entry.displayName) ?? readString(entry.label)
144
+ ? { title: readString(entry.displayName) ?? readString(entry.label) }
145
+ : {}),
146
+ });
147
+ }
148
+ return summaries;
149
+ }
150
+
151
+ export async function handleHistorySessions(
152
+ req: IncomingMessage,
153
+ res: ServerResponse,
154
+ ): Promise<boolean> {
155
+ if (req.method !== "GET") {
156
+ res.statusCode = 405;
157
+ res.setHeader("Content-Type", "application/json");
158
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
159
+ return true;
160
+ }
161
+
162
+ const token = extractBearerToken(req);
163
+ if (!token) {
164
+ res.statusCode = 401;
165
+ res.setHeader("Content-Type", "application/json");
166
+ res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
167
+ return true;
168
+ }
169
+
170
+ const rt = getFridayAgentForwardRuntime();
171
+ const config = rt?.getConfig() as Record<string, unknown> | undefined;
172
+ const agentIds = resolveConfiguredAgentIds(config);
173
+
174
+ const sessions: FridayHistorySessionSummary[] = [];
175
+ for (const agentId of agentIds) {
176
+ sessions.push(...readAgentSessions(agentId));
177
+ }
178
+ sessions.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
179
+
180
+ res.statusCode = 200;
181
+ res.setHeader("Content-Type", "application/json");
182
+ res.end(JSON.stringify({ ok: true, sessions }));
183
+ return true;
184
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { Readable } from "node:stream";
4
+ import { handleHistorySetTitle } from "./history-set-title.js";
5
+ import { setFridayNextRuntime } from "../../runtime.js";
6
+ import {
7
+ setFridayAgentForwardRuntime,
8
+ resetFridayAgentForwardRuntimeForTest,
9
+ } from "../../agent-forward-runtime.js";
10
+
11
+ class MockRes extends EventEmitter {
12
+ statusCode = 0;
13
+ headers: Record<string, string> = {};
14
+ body = "";
15
+ setHeader(name: string, value: string): void {
16
+ this.headers[name.toLowerCase()] = value;
17
+ }
18
+ end(body?: string): void {
19
+ if (body) this.body += body;
20
+ }
21
+ }
22
+
23
+ function makeReq(bodyObj: unknown, headers: Record<string, string> = {}, method = "PUT"): any {
24
+ const req = Readable.from([Buffer.from(JSON.stringify(bodyObj))]) as any;
25
+ req.method = method;
26
+ req.url = "/friday-next/sessions/title";
27
+ req.headers = headers;
28
+ return req;
29
+ }
30
+
31
+ const AUTH = { authorization: "Bearer test-token" };
32
+ const CFG = {
33
+ channels: { "friday-next": { authToken: "test-token", pathPrefix: "/friday-next" } },
34
+ gateway: { auth: { token: "test-token" } },
35
+ };
36
+
37
+ let captured: { storePath: string; sessionKey: string; patch: unknown } | null = null;
38
+
39
+ function setForward(store: Record<string, unknown>, withWriter = true): void {
40
+ captured = null;
41
+ setFridayAgentForwardRuntime({
42
+ runtime: {
43
+ agent: {
44
+ session: {
45
+ resolveStorePath: (_s?: string, opts?: { agentId?: string }) => `/store/${opts?.agentId ?? "main"}.json`,
46
+ loadSessionStore: () => store,
47
+ ...(withWriter
48
+ ? {
49
+ updateSessionStoreEntry: async (params: any) => {
50
+ const patch = await params.update({ sessionId: "sess-1" });
51
+ captured = { storePath: params.storePath, sessionKey: params.sessionKey, patch };
52
+ return { sessionId: "sess-1", ...patch };
53
+ },
54
+ }
55
+ : {}),
56
+ },
57
+ },
58
+ config: { current: () => CFG },
59
+ },
60
+ } as any);
61
+ }
62
+
63
+ describe("handleHistorySetTitle", () => {
64
+ beforeEach(() => setFridayNextRuntime({ config: { loadConfig: () => CFG }, logger: {} } as never));
65
+ afterEach(() => resetFridayAgentForwardRuntimeForTest());
66
+
67
+ it("rejects GET with 405", async () => {
68
+ setForward({});
69
+ const res = new MockRes();
70
+ await handleHistorySetTitle(makeReq({}, AUTH, "GET"), res as any);
71
+ expect(res.statusCode).toBe(405);
72
+ });
73
+
74
+ it("rejects missing token with 401", async () => {
75
+ setForward({});
76
+ const res = new MockRes();
77
+ await handleHistorySetTitle(makeReq({ sessionKey: "agent:main:main", title: "x" }), res as any);
78
+ expect(res.statusCode).toBe(401);
79
+ });
80
+
81
+ it("400s without sessionKey", async () => {
82
+ setForward({});
83
+ const res = new MockRes();
84
+ await handleHistorySetTitle(makeReq({ title: "x" }, AUTH), res as any);
85
+ expect(res.statusCode).toBe(400);
86
+ });
87
+
88
+ it("writes displayName for the resolved (case-insensitive) store key", async () => {
89
+ setForward({ "agent:main:friday:direct:abcd:9": { sessionId: "sess-1" } });
90
+ const res = new MockRes();
91
+ await handleHistorySetTitle(
92
+ makeReq({ sessionKey: "agent:main:friday:direct:ABCD:9", title: "My Chat" }, AUTH),
93
+ res as any,
94
+ );
95
+ expect(res.statusCode).toBe(200);
96
+ expect(captured?.sessionKey).toBe("agent:main:friday:direct:abcd:9");
97
+ expect(captured?.patch).toEqual({ displayName: "My Chat" });
98
+ const body = JSON.parse(res.body);
99
+ expect(body).toMatchObject({ ok: true, sessionId: "sess-1", title: "My Chat" });
100
+ });
101
+
102
+ it("404s when the session is unknown", async () => {
103
+ setForward({});
104
+ const res = new MockRes();
105
+ await handleHistorySetTitle(makeReq({ sessionKey: "agent:main:nope", title: "x" }, AUTH), res as any);
106
+ expect(res.statusCode).toBe(404);
107
+ });
108
+
109
+ it("503s when the store writer is unavailable", async () => {
110
+ setForward({ "agent:main:main": { sessionId: "s" } }, false);
111
+ const res = new MockRes();
112
+ await handleHistorySetTitle(makeReq({ sessionKey: "agent:main:main", title: "x" }, AUTH), res as any);
113
+ expect(res.statusCode).toBe(503);
114
+ });
115
+ })
@@ -0,0 +1,86 @@
1
+ /**
2
+ * PUT /friday-next/sessions/title body: { sessionKey, title }
3
+ *
4
+ * Syncs the app-set session name to the server session's `displayName` (the
5
+ * field OpenClaw resolves first for a session's display title, ahead of `label`).
6
+ * Writes via `updateSessionStoreEntry` (cache-owning, not request-scoped), so the
7
+ * change is also visible to webui and other clients.
8
+ */
9
+
10
+ import type { IncomingMessage, ServerResponse } from "node:http";
11
+ import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
12
+ import { extractBearerToken } from "../middleware/auth.js";
13
+ import { readJsonBody } from "../middleware/body.js";
14
+ import { agentIdFromSessionKey, toSessionStoreKey } from "../../session/session-manager.js";
15
+
16
+ function json(res: ServerResponse, status: number, body: unknown): true {
17
+ res.statusCode = status;
18
+ res.setHeader("Content-Type", "application/json");
19
+ res.end(JSON.stringify(body));
20
+ return true;
21
+ }
22
+
23
+ /** Real store key matching `sessionKey`, tolerating deviceId case differences. */
24
+ function resolveStoreKey(store: Record<string, unknown>, sessionKey: string): string | undefined {
25
+ if (store[sessionKey]) return sessionKey;
26
+ const canonical = toSessionStoreKey(sessionKey);
27
+ if (store[canonical]) return canonical;
28
+ const target = canonical.toLowerCase();
29
+ for (const k of Object.keys(store)) {
30
+ if (k.toLowerCase() === target) return k;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ export async function handleHistorySetTitle(
36
+ req: IncomingMessage,
37
+ res: ServerResponse,
38
+ ): Promise<boolean> {
39
+ if (req.method !== "PUT" && req.method !== "POST") {
40
+ return json(res, 405, { error: "Method Not Allowed" });
41
+ }
42
+ if (!extractBearerToken(req)) {
43
+ return json(res, 401, { error: "Unauthorized: bearer token mismatch" });
44
+ }
45
+
46
+ const body = (await readJsonBody(req)) as { sessionKey?: unknown; title?: unknown } | null;
47
+ const sessionKey = typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "";
48
+ const title = typeof body?.title === "string" ? body.title.trim() : "";
49
+ if (!sessionKey) {
50
+ return json(res, 400, { error: "Missing required field: sessionKey" });
51
+ }
52
+
53
+ const rt = getFridayAgentForwardRuntime();
54
+ if (!rt?.updateSessionStoreEntry) {
55
+ return json(res, 503, { error: "Session store write not available" });
56
+ }
57
+
58
+ const agentId = agentIdFromSessionKey(sessionKey);
59
+ let storePath: string;
60
+ let store: Record<string, unknown>;
61
+ try {
62
+ storePath = rt.resolveStorePath(undefined, { agentId });
63
+ store = rt.loadSessionStore(storePath) ?? {};
64
+ } catch {
65
+ return json(res, 500, { error: "Failed to load session store" });
66
+ }
67
+
68
+ const storeKey = resolveStoreKey(store, sessionKey);
69
+ if (!storeKey) {
70
+ return json(res, 404, { error: `Session not found: ${sessionKey}` });
71
+ }
72
+
73
+ try {
74
+ const updated = await rt.updateSessionStoreEntry({
75
+ storePath,
76
+ sessionKey: storeKey,
77
+ // Empty title clears the override so the server can derive its own again.
78
+ update: () => ({ displayName: title || undefined }),
79
+ });
80
+ const sessionId =
81
+ updated && typeof updated.sessionId === "string" ? updated.sessionId : undefined;
82
+ return json(res, 200, { ok: true, sessionKey, ...(sessionId ? { sessionId } : {}), title });
83
+ } catch {
84
+ return json(res, 500, { error: "Failed to update session title" });
85
+ }
86
+ }