@syengup/friday-channel-next 0.1.24 → 0.1.26
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/http/handlers/history-messages.js +50 -9
- package/dist/src/http/handlers/history-sessions.js +6 -2
- package/package.json +1 -1
- package/src/http/handlers/history-messages.test.ts +33 -0
- package/src/http/handlers/history-messages.ts +46 -8
- package/src/http/handlers/history-sessions.test.ts +30 -0
- package/src/http/handlers/history-sessions.ts +6 -2
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* `runtime.subagent.getSessionMessages`), which already resolves the active
|
|
10
10
|
* branch and compaction, then normalizes each raw message into a stable DTO.
|
|
11
11
|
*/
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
13
14
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
14
15
|
import { normalizeHistoryMessages } from "../../history/normalize-message.js";
|
|
@@ -17,6 +18,28 @@ import { resolveMediaAttachment } from "./files.js";
|
|
|
17
18
|
import { readSessionUsageSnapshotFromStore } from "../../session-usage-store.js";
|
|
18
19
|
const DEFAULT_LIMIT = 200;
|
|
19
20
|
const MAX_LIMIT = 1000;
|
|
21
|
+
/**
|
|
22
|
+
* For an `images[].url` produced from a `[media attached: …]` marker: returns the
|
|
23
|
+
* server-local filesystem path to resolve (`file://…` or a bare absolute path), or
|
|
24
|
+
* null to leave the image untouched. Already-served `/friday-next/files/…` URLs and
|
|
25
|
+
* remote `http(s)://` / `data:` URLs are NOT local paths — never feed them to
|
|
26
|
+
* `resolveMediaAttachment` (it would mis-treat them as paths and break valid URLs).
|
|
27
|
+
*/
|
|
28
|
+
function serverLocalPathForImageUrl(url) {
|
|
29
|
+
if (url.startsWith("file://")) {
|
|
30
|
+
try {
|
|
31
|
+
return fileURLToPath(url);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return url.slice("file://".length);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (url.startsWith("/friday-next/files/"))
|
|
38
|
+
return null;
|
|
39
|
+
if (url.startsWith("/"))
|
|
40
|
+
return url;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
20
43
|
function resolveSubagentApi() {
|
|
21
44
|
try {
|
|
22
45
|
const runtime = getFridayNextRuntime();
|
|
@@ -75,16 +98,34 @@ export async function handleHistoryMessages(req, res) {
|
|
|
75
98
|
// (copies the file into the plugin's attachments/ dir — the same mechanism the
|
|
76
99
|
// live deliver path uses), then drop the raw paths from the wire.
|
|
77
100
|
for (const message of messages) {
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
if (message.mediaPaths?.length) {
|
|
102
|
+
const resolved = message.mediaPaths
|
|
103
|
+
.map((p) => resolveMediaAttachment(p))
|
|
104
|
+
.filter((r) => Boolean(r))
|
|
105
|
+
.map((r) => ({ url: r.url, filename: r.fileName }));
|
|
106
|
+
if (resolved.length) {
|
|
107
|
+
message.images = [...(message.images ?? []), ...resolved];
|
|
108
|
+
}
|
|
109
|
+
delete message.mediaPaths;
|
|
110
|
+
}
|
|
111
|
+
// User attachments arrive as `[media attached: file://<server-path>]` markers,
|
|
112
|
+
// which normalize-message extracts into images[].url as a RAW server-local path.
|
|
113
|
+
// Unlike MEDIA: paths (resolved above), these were never copied into the file
|
|
114
|
+
// store, so the app would try to load a path that only exists on the gateway host
|
|
115
|
+
// and the attachment bubble is lost on history sync. Resolve them the same way.
|
|
116
|
+
if (message.images?.length) {
|
|
117
|
+
message.images = message.images.map((img) => {
|
|
118
|
+
if (!img.url || img.data)
|
|
119
|
+
return img;
|
|
120
|
+
const local = serverLocalPathForImageUrl(img.url);
|
|
121
|
+
if (!local)
|
|
122
|
+
return img;
|
|
123
|
+
const resolved = resolveMediaAttachment(local);
|
|
124
|
+
if (!resolved)
|
|
125
|
+
return img;
|
|
126
|
+
return { ...img, url: resolved.url, filename: img.filename ?? resolved.fileName };
|
|
127
|
+
});
|
|
86
128
|
}
|
|
87
|
-
delete message.mediaPaths;
|
|
88
129
|
}
|
|
89
130
|
const sessionId = resolveSessionId(sessionKey);
|
|
90
131
|
// Cumulative session-usage snapshot (model + context window/used) read from the
|
|
@@ -110,10 +110,14 @@ function readAgentSessions(agentId) {
|
|
|
110
110
|
continue;
|
|
111
111
|
const entry = rawEntry;
|
|
112
112
|
const canonicalKey = toCanonicalSessionKey(agentId, storeKey);
|
|
113
|
-
// Drop internal/system sessions and subagent links.
|
|
113
|
+
// Drop internal/system sessions and subagent links. We key only on
|
|
114
|
+
// `spawnedBy`/`subagentRole` (plus the `subagent:` prefix in
|
|
115
|
+
// isInternalSessionKey) — NOT on `parentSessionKey`, which real user
|
|
116
|
+
// conversations (e.g. webchat sessions branched off another session) also
|
|
117
|
+
// carry and which would otherwise be wrongly excluded from the sidebar.
|
|
114
118
|
if (isInternalSessionKey(canonicalKey, storeKey))
|
|
115
119
|
continue;
|
|
116
|
-
if (entry.spawnedBy || entry.subagentRole
|
|
120
|
+
if (entry.spawnedBy || entry.subagentRole)
|
|
117
121
|
continue;
|
|
118
122
|
// Drop archived/empty sessions (transcript moved away or never written).
|
|
119
123
|
if (!hasLiveTranscript(entry, storePath))
|
package/package.json
CHANGED
|
@@ -170,6 +170,39 @@ describe("handleHistoryMessages", () => {
|
|
|
170
170
|
expect(body.messages[0].text).toBe("from app");
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
+
it("resolves user [media attached: file://] markers into downloadable /friday-next/files URLs", async () => {
|
|
174
|
+
// The server-local source the marker points at (only exists on the gateway host).
|
|
175
|
+
const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), "friday-inbound-"));
|
|
176
|
+
const srcFile = path.join(srcDir, "ce1ff405-28ad-48b9-b4a7-4f2228d77649.jpg");
|
|
177
|
+
fs.writeFileSync(srcFile, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]));
|
|
178
|
+
const file = writeTranscript("media.jsonl", [
|
|
179
|
+
{
|
|
180
|
+
type: "message",
|
|
181
|
+
id: "u1",
|
|
182
|
+
message: {
|
|
183
|
+
role: "user",
|
|
184
|
+
content: `你见过这个可乐吗?\n\n[media attached: file://${srcFile}]`,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
]);
|
|
188
|
+
setForward({ "agent:main:main": { sessionId: "s", sessionFile: file } });
|
|
189
|
+
|
|
190
|
+
const res = new MockRes();
|
|
191
|
+
await handleHistoryMessages(
|
|
192
|
+
makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH),
|
|
193
|
+
res as any,
|
|
194
|
+
);
|
|
195
|
+
expect(res.statusCode).toBe(200);
|
|
196
|
+
const body = JSON.parse(res.body);
|
|
197
|
+
const userMsg = body.messages.find((m: any) => m.role === "user");
|
|
198
|
+
expect(userMsg.images?.length).toBe(1);
|
|
199
|
+
// The raw server-local file:// path must be resolved to a gateway-served URL the
|
|
200
|
+
// app can actually download — otherwise the attachment bubble is lost on history sync.
|
|
201
|
+
expect(userMsg.images[0].url.startsWith("file://")).toBe(false);
|
|
202
|
+
expect(userMsg.images[0].url.startsWith("/friday-next/files/")).toBe(true);
|
|
203
|
+
fs.rmSync(srcDir, { recursive: true, force: true });
|
|
204
|
+
});
|
|
205
|
+
|
|
173
206
|
it("falls back to getSessionMessages when the transcript is not on disk", async () => {
|
|
174
207
|
setForward({}); // no entry → disk read yields nothing
|
|
175
208
|
setRuntime(async () => ({
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
14
15
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
15
16
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
16
17
|
import { normalizeHistoryMessages } from "../../history/normalize-message.js";
|
|
@@ -28,6 +29,26 @@ type SubagentSessionApi = {
|
|
|
28
29
|
}) => Promise<{ messages?: unknown[] }>;
|
|
29
30
|
};
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* For an `images[].url` produced from a `[media attached: …]` marker: returns the
|
|
34
|
+
* server-local filesystem path to resolve (`file://…` or a bare absolute path), or
|
|
35
|
+
* null to leave the image untouched. Already-served `/friday-next/files/…` URLs and
|
|
36
|
+
* remote `http(s)://` / `data:` URLs are NOT local paths — never feed them to
|
|
37
|
+
* `resolveMediaAttachment` (it would mis-treat them as paths and break valid URLs).
|
|
38
|
+
*/
|
|
39
|
+
function serverLocalPathForImageUrl(url: string): string | null {
|
|
40
|
+
if (url.startsWith("file://")) {
|
|
41
|
+
try {
|
|
42
|
+
return fileURLToPath(url);
|
|
43
|
+
} catch {
|
|
44
|
+
return url.slice("file://".length);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (url.startsWith("/friday-next/files/")) return null;
|
|
48
|
+
if (url.startsWith("/")) return url;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
function resolveSubagentApi(): SubagentSessionApi | undefined {
|
|
32
53
|
try {
|
|
33
54
|
const runtime = getFridayNextRuntime();
|
|
@@ -95,15 +116,32 @@ export async function handleHistoryMessages(
|
|
|
95
116
|
// (copies the file into the plugin's attachments/ dir — the same mechanism the
|
|
96
117
|
// live deliver path uses), then drop the raw paths from the wire.
|
|
97
118
|
for (const message of messages) {
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
119
|
+
if (message.mediaPaths?.length) {
|
|
120
|
+
const resolved = message.mediaPaths
|
|
121
|
+
.map((p) => resolveMediaAttachment(p))
|
|
122
|
+
.filter((r): r is NonNullable<typeof r> => Boolean(r))
|
|
123
|
+
.map((r) => ({ url: r.url, filename: r.fileName }));
|
|
124
|
+
if (resolved.length) {
|
|
125
|
+
message.images = [...(message.images ?? []), ...resolved];
|
|
126
|
+
}
|
|
127
|
+
delete message.mediaPaths;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// User attachments arrive as `[media attached: file://<server-path>]` markers,
|
|
131
|
+
// which normalize-message extracts into images[].url as a RAW server-local path.
|
|
132
|
+
// Unlike MEDIA: paths (resolved above), these were never copied into the file
|
|
133
|
+
// store, so the app would try to load a path that only exists on the gateway host
|
|
134
|
+
// and the attachment bubble is lost on history sync. Resolve them the same way.
|
|
135
|
+
if (message.images?.length) {
|
|
136
|
+
message.images = message.images.map((img) => {
|
|
137
|
+
if (!img.url || img.data) return img;
|
|
138
|
+
const local = serverLocalPathForImageUrl(img.url);
|
|
139
|
+
if (!local) return img;
|
|
140
|
+
const resolved = resolveMediaAttachment(local);
|
|
141
|
+
if (!resolved) return img;
|
|
142
|
+
return { ...img, url: resolved.url, filename: img.filename ?? resolved.fileName };
|
|
143
|
+
});
|
|
105
144
|
}
|
|
106
|
-
delete message.mediaPaths;
|
|
107
145
|
}
|
|
108
146
|
|
|
109
147
|
const sessionId = resolveSessionId(sessionKey);
|
|
@@ -143,4 +143,34 @@ describe("handleHistorySessions", () => {
|
|
|
143
143
|
const keys = JSON.parse(res.body).sessions.map((s: any) => s.sessionKey);
|
|
144
144
|
expect(keys).toEqual(["agent:main:main"]);
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
it("keeps real conversations that merely carry a parentSessionKey", async () => {
|
|
148
|
+
setForward(
|
|
149
|
+
{ agents: { list: [{ id: "main" }] } },
|
|
150
|
+
{
|
|
151
|
+
main: {
|
|
152
|
+
// Webchat session branched off another session: has parentSessionKey
|
|
153
|
+
// but is a genuine user conversation — must be surfaced.
|
|
154
|
+
"agent:main:dashboard:b91ad945": {
|
|
155
|
+
sessionId: "wc",
|
|
156
|
+
updatedAt: 9,
|
|
157
|
+
parentSessionKey: "agent:main:fridaynext:mq5zn7dp",
|
|
158
|
+
sessionFile: transcript("wc.jsonl"),
|
|
159
|
+
},
|
|
160
|
+
// Real subagent fork (spawnedBy) must still be filtered.
|
|
161
|
+
"agent:main:fork": {
|
|
162
|
+
sessionId: "fk",
|
|
163
|
+
updatedAt: 8,
|
|
164
|
+
spawnedBy: "agent:main:main",
|
|
165
|
+
parentSessionKey: "agent:main:main",
|
|
166
|
+
sessionFile: transcript("fk.jsonl"),
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
const res = new MockRes();
|
|
172
|
+
await handleHistorySessions(makeReq(AUTH), res as any);
|
|
173
|
+
const keys = JSON.parse(res.body).sessions.map((s: any) => s.sessionKey);
|
|
174
|
+
expect(keys).toEqual(["agent:main:dashboard:b91ad945"]);
|
|
175
|
+
});
|
|
146
176
|
});
|
|
@@ -125,9 +125,13 @@ function readAgentSessions(agentId: string): FridayHistorySessionSummary[] {
|
|
|
125
125
|
const entry = rawEntry as Record<string, unknown>;
|
|
126
126
|
const canonicalKey = toCanonicalSessionKey(agentId, storeKey);
|
|
127
127
|
|
|
128
|
-
// Drop internal/system sessions and subagent links.
|
|
128
|
+
// Drop internal/system sessions and subagent links. We key only on
|
|
129
|
+
// `spawnedBy`/`subagentRole` (plus the `subagent:` prefix in
|
|
130
|
+
// isInternalSessionKey) — NOT on `parentSessionKey`, which real user
|
|
131
|
+
// conversations (e.g. webchat sessions branched off another session) also
|
|
132
|
+
// carry and which would otherwise be wrongly excluded from the sidebar.
|
|
129
133
|
if (isInternalSessionKey(canonicalKey, storeKey)) continue;
|
|
130
|
-
if (entry.spawnedBy || entry.subagentRole
|
|
134
|
+
if (entry.spawnedBy || entry.subagentRole) continue;
|
|
131
135
|
// Drop archived/empty sessions (transcript moved away or never written).
|
|
132
136
|
if (!hasLiveTranscript(entry, storePath)) continue;
|
|
133
137
|
|