@syengup/friday-channel-next 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/index.ts +191 -0
- package/install.mjs +158 -0
- package/install.sh +118 -0
- package/openclaw.plugin.json +53 -0
- package/package.json +65 -0
- package/src/agent/abort-run.ts +10 -0
- package/src/agent/active-runs.ts +26 -0
- package/src/agent/dispatch-bridge.ts +18 -0
- package/src/agent/media-bridge.ts +23 -0
- package/src/agent-forward-runtime.ts +30 -0
- package/src/agent-run-context-bridge.ts +32 -0
- package/src/channel-actions.ts +129 -0
- package/src/channel.ts +284 -0
- package/src/collect-message-media-paths.ts +132 -0
- package/src/config.test.ts +33 -0
- package/src/config.ts +64 -0
- package/src/e2e/attachments-inbound.e2e.test.ts +43 -0
- package/src/e2e/attachments-outbound.e2e.test.ts +43 -0
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +56 -0
- package/src/e2e/connect-and-connected.e2e.test.ts +44 -0
- package/src/e2e/offline-replay.e2e.test.ts +43 -0
- package/src/e2e/send-text.e2e.test.ts +73 -0
- package/src/e2e/slash-commands.e2e.test.ts +33 -0
- package/src/e2e/status-cors-auth.e2e.test.ts +41 -0
- package/src/e2e/tool-lifecycle.e2e.test.ts +49 -0
- package/src/friday-inbound-stats.ts +10 -0
- package/src/friday-session.forward-agent.test.ts +270 -0
- package/src/friday-session.ts +327 -0
- package/src/host-config.ts +20 -0
- package/src/http/handlers/cancel.test.ts +70 -0
- package/src/http/handlers/cancel.ts +35 -0
- package/src/http/handlers/files-download.ts +239 -0
- package/src/http/handlers/files-upload.ts +166 -0
- package/src/http/handlers/files.ts +335 -0
- package/src/http/handlers/messages.test.ts +119 -0
- package/src/http/handlers/messages.ts +555 -0
- package/src/http/handlers/models-list.ts +126 -0
- package/src/http/handlers/sessions-delete.ts +59 -0
- package/src/http/handlers/sessions-settings.ts +90 -0
- package/src/http/handlers/sse.test.ts +71 -0
- package/src/http/handlers/sse.ts +84 -0
- package/src/http/handlers/status.test.ts +52 -0
- package/src/http/handlers/status.ts +33 -0
- package/src/http/middleware/auth.test.ts +46 -0
- package/src/http/middleware/auth.ts +31 -0
- package/src/http/middleware/body.test.ts +27 -0
- package/src/http/middleware/body.ts +28 -0
- package/src/http/middleware/cors.test.ts +40 -0
- package/src/http/middleware/cors.ts +12 -0
- package/src/http/server.ts +106 -0
- package/src/logging.ts +27 -0
- package/src/openclaw.d.ts +32 -0
- package/src/run-metadata.ts +180 -0
- package/src/runtime.ts +14 -0
- package/src/session/session-manager.ts +230 -0
- package/src/session-usage-snapshot.ts +80 -0
- package/src/sse/emitter.test.ts +85 -0
- package/src/sse/emitter.ts +249 -0
- package/src/sse/frame-format.test.ts +56 -0
- package/src/sse/offline-queue.test.ts +65 -0
- package/src/sse/offline-queue.ts +140 -0
- package/src/test-support/app-simulator.ts +243 -0
- package/src/test-support/mock-dispatch.ts +181 -0
- package/src/test-support/mock-runtime.ts +74 -0
- package/src/vendor/runtime-store.ts +99 -0
- package/tsconfig.json +17 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type FridayNextLogLevel = "debug" | "info" | "warn" | "error";
|
|
2
|
+
|
|
3
|
+
export type FridayNextConfig = {
|
|
4
|
+
channelId: "friday-next";
|
|
5
|
+
pathPrefix: string;
|
|
6
|
+
transport: string;
|
|
7
|
+
historyLimit: number;
|
|
8
|
+
historyDir: string;
|
|
9
|
+
logLevel: FridayNextLogLevel;
|
|
10
|
+
authToken: string;
|
|
11
|
+
corsEnabled: boolean;
|
|
12
|
+
corsAllowOrigin: string;
|
|
13
|
+
sseKeepaliveSec: number;
|
|
14
|
+
sseBacklogPerDevice: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function asObject(value: unknown): Record<string, unknown> {
|
|
18
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
19
|
+
? (value as Record<string, unknown>)
|
|
20
|
+
: {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function asString(value: unknown, fallback: string): string {
|
|
24
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function asNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
28
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
29
|
+
return Math.max(min, Math.min(max, Math.floor(value)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function asBool(value: unknown, fallback: boolean): boolean {
|
|
33
|
+
return typeof value === "boolean" ? value : fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveFridayNextConfig(cfg: unknown): FridayNextConfig {
|
|
37
|
+
const root = asObject(cfg);
|
|
38
|
+
const channels = asObject(root.channels);
|
|
39
|
+
const section = asObject(channels["friday-next"]);
|
|
40
|
+
const sse = asObject(section.sse);
|
|
41
|
+
const cors = asObject(section.cors);
|
|
42
|
+
|
|
43
|
+
const authToken =
|
|
44
|
+
asString(asObject(root.gateway).auth && asObject(asObject(root.gateway).auth).token, "") ||
|
|
45
|
+
asString(section.authToken, "") ||
|
|
46
|
+
asString(process.env.FRIDAY_NEXT_AUTH_TOKEN, "");
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
channelId: "friday-next",
|
|
50
|
+
pathPrefix: asString(section.pathPrefix, "/friday-next"),
|
|
51
|
+
transport: asString(section.transport, "http+sse"),
|
|
52
|
+
historyLimit: asNumber(section.historyLimit, 25, 1, 200),
|
|
53
|
+
historyDir: asString(
|
|
54
|
+
section.historyDir,
|
|
55
|
+
`${process.env.HOME ?? ""}/.openclaw/friday-next/history`,
|
|
56
|
+
),
|
|
57
|
+
logLevel: asString(section.logLevel, "info") as FridayNextLogLevel,
|
|
58
|
+
authToken,
|
|
59
|
+
corsEnabled: asBool(cors.enabled, false),
|
|
60
|
+
corsAllowOrigin: asString(cors.allowOrigin, "*"),
|
|
61
|
+
sseKeepaliveSec: asNumber(sse.keepaliveSec, 30, 5, 120),
|
|
62
|
+
sseBacklogPerDevice: asNumber(sse.backlogPerDevice, 200, 0, 1000),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createAppSimulator } from "../test-support/app-simulator.js";
|
|
3
|
+
import { mockDispatchScript, resetMockDispatch } from "../test-support/mock-dispatch.js";
|
|
4
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
|
|
5
|
+
|
|
6
|
+
describe("e2e attachments inbound", () => {
|
|
7
|
+
let historyDir = "";
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
historyDir = createTempHistoryDir();
|
|
10
|
+
setMockRuntime({ historyDir, authToken: "test-token" });
|
|
11
|
+
mockDispatchScript().deliverFinal({ text: "ok" }).install();
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
resetMockDispatch();
|
|
15
|
+
removeTempHistoryDir(historyDir);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("multipart 上传 + 下载 + range", async () => {
|
|
19
|
+
const app = createAppSimulator({ token: "test-token" });
|
|
20
|
+
const up = await app.uploadFiles([
|
|
21
|
+
{ name: "file", filename: "a.txt", contentType: "text/plain", content: "hello world" },
|
|
22
|
+
]);
|
|
23
|
+
expect(up.status).toBe(200);
|
|
24
|
+
const url = String(up.body.files?.[0]?.url ?? "");
|
|
25
|
+
expect(url.startsWith("/friday-next/files/")).toBe(true);
|
|
26
|
+
|
|
27
|
+
const full = await app.downloadFile(url);
|
|
28
|
+
expect(full.status).toBe(200);
|
|
29
|
+
expect(full.body.toString("utf-8")).toBe("hello world");
|
|
30
|
+
|
|
31
|
+
const ranged = await app.downloadFile(url, { range: "bytes=0-4" });
|
|
32
|
+
expect(ranged.status).toBe(206);
|
|
33
|
+
expect(ranged.body.toString("utf-8")).toBe("hello");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("空上传与非法编码报错", async () => {
|
|
37
|
+
const app = createAppSimulator({ token: "test-token" });
|
|
38
|
+
const empty = await app.uploadFiles([]);
|
|
39
|
+
expect(empty.status).toBe(400);
|
|
40
|
+
const bad = await app.downloadFile("/friday-next/files/%");
|
|
41
|
+
expect(bad.status).toBe(400);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { createAppSimulator } from "../test-support/app-simulator.js";
|
|
5
|
+
import { mockDispatchScript, resetMockDispatch } from "../test-support/mock-dispatch.js";
|
|
6
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
|
|
7
|
+
|
|
8
|
+
describe("e2e attachments outbound", () => {
|
|
9
|
+
let historyDir = "";
|
|
10
|
+
let mediaFile = "";
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
historyDir = createTempHistoryDir();
|
|
14
|
+
mediaFile = path.join(historyDir, "voice.mp3");
|
|
15
|
+
fs.writeFileSync(mediaFile, "audio-bytes");
|
|
16
|
+
setMockRuntime({ historyDir, authToken: "test-token" });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
resetMockDispatch();
|
|
21
|
+
removeTempHistoryDir(historyDir);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("block/final deliver 含可解析的媒体 URL", async () => {
|
|
25
|
+
mockDispatchScript()
|
|
26
|
+
.lifecycle("start")
|
|
27
|
+
.block("one", [mediaFile], true)
|
|
28
|
+
.deliverFinal({ text: "done", mediaUrls: [mediaFile], audioAsVoice: true })
|
|
29
|
+
.lifecycle("end")
|
|
30
|
+
.install();
|
|
31
|
+
|
|
32
|
+
const app = createAppSimulator({ token: "test-token" });
|
|
33
|
+
await app.connectSSE();
|
|
34
|
+
await app.sendMessage({ text: "play", sessionKey: "s1" });
|
|
35
|
+
const frames = await app.waitForSse((f) => f.filter((x) => x.event === "deliver").length >= 2);
|
|
36
|
+
|
|
37
|
+
const delivers = frames.filter((x) => x.event === "deliver");
|
|
38
|
+
expect(delivers.length).toBeGreaterThanOrEqual(1);
|
|
39
|
+
const urls = (delivers[delivers.length - 1]?.data?.payload as { mediaUrls?: string[] })?.mediaUrls ?? [];
|
|
40
|
+
expect(urls.some((u) => u.includes("/friday-next/files/"))).toBe(true);
|
|
41
|
+
app.disconnectSSE();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createAppSimulator } from "../test-support/app-simulator.js";
|
|
3
|
+
import { mockDispatchScript, resetMockDispatch } from "../test-support/mock-dispatch.js";
|
|
4
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
|
|
5
|
+
|
|
6
|
+
describe("e2e cancel reconnect errors", () => {
|
|
7
|
+
let historyDir = "";
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
historyDir = createTempHistoryDir();
|
|
10
|
+
setMockRuntime({ historyDir, authToken: "test-token", sseBacklogPerDevice: 20 });
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
resetMockDispatch();
|
|
14
|
+
removeTempHistoryDir(historyDir);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("cancel 缺 runId 与 200 cancel", async () => {
|
|
18
|
+
mockDispatchScript().lifecycle("start").partial("a").deliverFinal({ text: "a" }).lifecycle("end").install();
|
|
19
|
+
const app = createAppSimulator({ token: "test-token" });
|
|
20
|
+
await app.connectSSE();
|
|
21
|
+
const sent = await app.sendMessage({ text: "go", sessionKey: "c1" });
|
|
22
|
+
const runId = String(sent.body.runId ?? "");
|
|
23
|
+
expect(runId.length).toBeGreaterThan(0);
|
|
24
|
+
await app.waitForSse((f) => f.some((x) => x.event === "deliver"));
|
|
25
|
+
const bad = await app.rawRequest({
|
|
26
|
+
method: "POST",
|
|
27
|
+
path: "/friday-next/cancel",
|
|
28
|
+
headers: { "content-type": "application/json" },
|
|
29
|
+
body: "{}",
|
|
30
|
+
});
|
|
31
|
+
expect(bad.status).toBe(400);
|
|
32
|
+
const ok = await app.cancel(runId);
|
|
33
|
+
expect(ok.status).toBe(200);
|
|
34
|
+
app.disconnectSSE();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("Last-Event-ID replay 与多 device 隔离", async () => {
|
|
38
|
+
mockDispatchScript().lifecycle("start").partial("h").partial("hi").deliverFinal({ text: "hi" }).lifecycle("end").install();
|
|
39
|
+
const appA = createAppSimulator({ token: "test-token", deviceId: "A" });
|
|
40
|
+
await appA.connectSSE();
|
|
41
|
+
await appA.sendMessage({ text: "one", sessionKey: "r1" });
|
|
42
|
+
const framesA = await appA.waitForSse((f) => f.some((x) => x.event === "deliver"));
|
|
43
|
+
const maxId = Math.max(...framesA.map((x) => x.id ?? 0));
|
|
44
|
+
appA.disconnectSSE();
|
|
45
|
+
await appA.connectSSE({ deviceId: "A", lastEventId: Math.max(1, maxId - 1) });
|
|
46
|
+
const replayed = appA.getSseFrames().filter((x) => (x.id ?? 0) > Math.max(1, maxId - 1));
|
|
47
|
+
expect(replayed.length).toBeGreaterThan(0);
|
|
48
|
+
|
|
49
|
+
const appB = createAppSimulator({ token: "test-token", deviceId: "B" });
|
|
50
|
+
await appB.connectSSE();
|
|
51
|
+
const framesB = appB.getSseFrames();
|
|
52
|
+
expect(framesB[0]?.id).toBe(1);
|
|
53
|
+
appA.disconnectSSE();
|
|
54
|
+
appB.disconnectSSE();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createAppSimulator } from "../test-support/app-simulator.js";
|
|
3
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
|
|
4
|
+
|
|
5
|
+
describe("e2e connect and connected", () => {
|
|
6
|
+
let historyDir = "";
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
historyDir = createTempHistoryDir();
|
|
10
|
+
setMockRuntime({ historyDir, authToken: "test-token" });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
removeTempHistoryDir(historyDir);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("无 Bearer 返回 401", async () => {
|
|
18
|
+
const app = createAppSimulator({ token: "" });
|
|
19
|
+
const res = await app.rawRequest({
|
|
20
|
+
method: "GET",
|
|
21
|
+
path: "/friday-next/events?deviceId=dev-a",
|
|
22
|
+
headers: {},
|
|
23
|
+
});
|
|
24
|
+
expect(res.status).toBe(401);
|
|
25
|
+
expect(res.body).toContain("Unauthorized");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("正确 Bearer 返回 SSE 头与 connected 首帧", async () => {
|
|
29
|
+
const app = createAppSimulator({ token: "test-token" });
|
|
30
|
+
const sseRes = await app.connectSSE({ deviceId: "dev-a" });
|
|
31
|
+
expect(sseRes.statusCode).toBe(200);
|
|
32
|
+
expect(sseRes.getHeader("content-type")).toContain("text/event-stream");
|
|
33
|
+
expect(sseRes.getHeader("x-accel-buffering")).toBe("no");
|
|
34
|
+
expect(sseRes.getHeader("cache-control")).toContain("no-cache");
|
|
35
|
+
|
|
36
|
+
const frames = await app.waitForSse((f) => f.some((x) => x.event === "connected"));
|
|
37
|
+
const connected = frames.find((x) => x.event === "connected");
|
|
38
|
+
expect(connected).toBeTruthy();
|
|
39
|
+
expect(connected?.data?.deviceId).toBe("DEV-A");
|
|
40
|
+
expect(typeof connected?.data?.lastSeq).toBe("number");
|
|
41
|
+
expect(typeof connected?.data?.serverTime).toBe("number");
|
|
42
|
+
app.disconnectSSE();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createAppSimulator } from "../test-support/app-simulator.js";
|
|
3
|
+
import { mockDispatchScript, resetMockDispatch } from "../test-support/mock-dispatch.js";
|
|
4
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
|
|
5
|
+
|
|
6
|
+
describe("e2e offline SSE replay", () => {
|
|
7
|
+
let historyDir = "";
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
historyDir = createTempHistoryDir();
|
|
11
|
+
setMockRuntime({ historyDir, authToken: "test-token", sseBacklogPerDevice: 50 });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
resetMockDispatch();
|
|
16
|
+
removeTempHistoryDir(historyDir);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("断开后产生的事件在 Last-Event-ID 重连后回放", async () => {
|
|
20
|
+
mockDispatchScript().lifecycle("start").partial("x").deliverFinal({ text: "x" }).lifecycle("end").install();
|
|
21
|
+
|
|
22
|
+
const app = createAppSimulator({ token: "test-token", deviceId: "replay-dev" });
|
|
23
|
+
await app.connectSSE({ deviceId: "replay-dev" });
|
|
24
|
+
await app.sendMessage({ text: "first", sessionKey: "r1", deviceId: "replay-dev" });
|
|
25
|
+
await app.waitForSse((f) => f.some((x) => x.event === "deliver"));
|
|
26
|
+
const framesA = app.getSseFrames();
|
|
27
|
+
const maxId = Math.max(...framesA.map((x) => x.id ?? 0));
|
|
28
|
+
app.disconnectSSE();
|
|
29
|
+
|
|
30
|
+
resetMockDispatch();
|
|
31
|
+
mockDispatchScript().lifecycle("start").partial("y").deliverFinal({ text: "y" }).lifecycle("end").install();
|
|
32
|
+
await app.sendMessage({ text: "again", sessionKey: "r1", deviceId: "replay-dev" });
|
|
33
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
34
|
+
|
|
35
|
+
await app.connectSSE({ deviceId: "replay-dev", lastEventId: maxId });
|
|
36
|
+
const framesB = await app.waitForSse(
|
|
37
|
+
(f) => f.some((x) => (x.id ?? 0) > maxId && x.event === "deliver"),
|
|
38
|
+
3000,
|
|
39
|
+
);
|
|
40
|
+
expect(framesB.some((x) => (x.id ?? 0) > maxId)).toBe(true);
|
|
41
|
+
app.disconnectSSE();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createAppSimulator } from "../test-support/app-simulator.js";
|
|
3
|
+
import { mockDispatchScript, resetMockDispatch } from "../test-support/mock-dispatch.js";
|
|
4
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
|
|
5
|
+
|
|
6
|
+
describe("e2e send text", () => {
|
|
7
|
+
let historyDir = "";
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
historyDir = createTempHistoryDir();
|
|
11
|
+
setMockRuntime({ historyDir, authToken: "test-token" });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
resetMockDispatch();
|
|
16
|
+
removeTempHistoryDir(historyDir);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("202 与 agent + deliver SSE", async () => {
|
|
20
|
+
mockDispatchScript()
|
|
21
|
+
.lifecycle("start")
|
|
22
|
+
.reasoning("think")
|
|
23
|
+
.partial("Hello world")
|
|
24
|
+
.deliverFinal({ text: "Hello world" })
|
|
25
|
+
.lifecycle("end")
|
|
26
|
+
.install();
|
|
27
|
+
|
|
28
|
+
const app = createAppSimulator({ token: "test-token", deviceId: "dev-a" });
|
|
29
|
+
await app.connectSSE();
|
|
30
|
+
const sent = await app.sendMessage({ text: "hi", sessionKey: "s1" });
|
|
31
|
+
expect(sent.status).toBe(202);
|
|
32
|
+
expect(sent.body.accepted).toBe(true);
|
|
33
|
+
expect(sent.body.deviceId).toBe("DEV-A");
|
|
34
|
+
expect(typeof sent.body.runId).toBe("string");
|
|
35
|
+
|
|
36
|
+
const frames = await app.waitForSse((f) => f.some((x) => x.event === "deliver"));
|
|
37
|
+
const events = frames.map((x) => x.event);
|
|
38
|
+
expect(events).toContain("agent");
|
|
39
|
+
expect(events).toContain("deliver");
|
|
40
|
+
app.disconnectSSE();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("assistant 流式事件可达", async () => {
|
|
44
|
+
mockDispatchScript().lifecycle("start").partial("abXdef").deliverFinal({ text: "abXdef" }).lifecycle("end").install();
|
|
45
|
+
const app = createAppSimulator({ token: "test-token" });
|
|
46
|
+
await app.connectSSE();
|
|
47
|
+
await app.sendMessage({ text: "hi", sessionKey: "s1" });
|
|
48
|
+
const frames = await app.waitForSse((f) => f.some((x) => x.event === "agent"));
|
|
49
|
+
const agent = frames.filter((x) => x.event === "agent");
|
|
50
|
+
expect(agent.length).toBeGreaterThan(0);
|
|
51
|
+
app.disconnectSSE();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("缺少必要字段返回 400", async () => {
|
|
55
|
+
const app = createAppSimulator({ token: "test-token" });
|
|
56
|
+
const badText = await app.sendMessage({ text: "", sessionKey: "s1" });
|
|
57
|
+
expect(badText.status).toBe(400);
|
|
58
|
+
const badDevice = await app.rawRequest({
|
|
59
|
+
method: "POST",
|
|
60
|
+
path: "/friday-next/messages",
|
|
61
|
+
headers: { "content-type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ text: "hi", sessionKey: "s1" }),
|
|
63
|
+
});
|
|
64
|
+
expect(badDevice.status).toBe(400);
|
|
65
|
+
const badSession = await app.rawRequest({
|
|
66
|
+
method: "POST",
|
|
67
|
+
path: "/friday-next/messages",
|
|
68
|
+
headers: { "content-type": "application/json" },
|
|
69
|
+
body: JSON.stringify({ text: "hi", deviceId: "A" }),
|
|
70
|
+
});
|
|
71
|
+
expect(badSession.status).toBe(400);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createAppSimulator } from "../test-support/app-simulator.js";
|
|
3
|
+
import { mockDispatchScript, resetMockDispatch } from "../test-support/mock-dispatch.js";
|
|
4
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
|
|
5
|
+
|
|
6
|
+
describe("e2e slash commands", () => {
|
|
7
|
+
let historyDir = "";
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
historyDir = createTempHistoryDir();
|
|
10
|
+
setMockRuntime({ historyDir, authToken: "test-token" });
|
|
11
|
+
mockDispatchScript().deliverFinal({ text: "ok" }).lifecycle("end").complete().install();
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
resetMockDispatch();
|
|
15
|
+
removeTempHistoryDir(historyDir);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("/new /reset 透传为 202", async () => {
|
|
19
|
+
const app = createAppSimulator({ token: "test-token" });
|
|
20
|
+
const a = await app.sendMessage({ text: "/new", sessionKey: "sk1" });
|
|
21
|
+
expect(a.status).toBe(202);
|
|
22
|
+
const b = await app.sendMessage({ text: "/reset", sessionKey: "sk1" });
|
|
23
|
+
expect(b.status).toBe(202);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("/stop 与非法斜杠路径可接受", async () => {
|
|
27
|
+
const app = createAppSimulator({ token: "test-token" });
|
|
28
|
+
const stop = await app.sendMessage({ text: "/stop", sessionKey: "sk2" });
|
|
29
|
+
expect(stop.status).toBe(202);
|
|
30
|
+
const illegal = await app.sendMessage({ text: "/???", sessionKey: "sk2" });
|
|
31
|
+
expect(illegal.status).toBe(202);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createAppSimulator } from "../test-support/app-simulator.js";
|
|
3
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
|
|
4
|
+
import { registerFridayNextHttpRoutes } from "../http/server.js";
|
|
5
|
+
|
|
6
|
+
describe("e2e status cors auth", () => {
|
|
7
|
+
let historyDir = "";
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
historyDir = createTempHistoryDir();
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
removeTempHistoryDir(historyDir);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("status 返回标准字段", async () => {
|
|
16
|
+
setMockRuntime({ historyDir, authToken: "test-token" });
|
|
17
|
+
const app = createAppSimulator({ token: "test-token" });
|
|
18
|
+
const status = await app.status();
|
|
19
|
+
expect(status.status).toBe(200);
|
|
20
|
+
expect(status.body.channel).toBe("friday-next");
|
|
21
|
+
expect(status.body.version).toBe("v2");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("CORS 预检", async () => {
|
|
25
|
+
setMockRuntime({ historyDir, authToken: "test-token", corsEnabled: true, allowOrigin: "https://app.example" });
|
|
26
|
+
const app = createAppSimulator({ token: "test-token" });
|
|
27
|
+
const res = await app.options("/friday-next/events", "https://app.example");
|
|
28
|
+
expect(res.status).toBe(204);
|
|
29
|
+
expect(res.headers["access-control-allow-origin"]).toBe("https://app.example");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("authToken 为空时警告", () => {
|
|
33
|
+
setMockRuntime({ historyDir, authToken: "" });
|
|
34
|
+
const warn = vi.fn();
|
|
35
|
+
registerFridayNextHttpRoutes({
|
|
36
|
+
logger: { info: vi.fn(), warn, error: vi.fn(), debug: vi.fn() },
|
|
37
|
+
registerHttpRoute: vi.fn(),
|
|
38
|
+
} as never);
|
|
39
|
+
expect(warn).toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createAppSimulator } from "../test-support/app-simulator.js";
|
|
3
|
+
import { mockDispatchScript, resetMockDispatch } from "../test-support/mock-dispatch.js";
|
|
4
|
+
import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
|
|
5
|
+
|
|
6
|
+
describe("e2e tool lifecycle", () => {
|
|
7
|
+
let historyDir = "";
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
historyDir = createTempHistoryDir();
|
|
10
|
+
setMockRuntime({ historyDir, authToken: "test-token" });
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
resetMockDispatch();
|
|
14
|
+
removeTempHistoryDir(historyDir);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("agent tool 流 + deliver 可达", async () => {
|
|
18
|
+
mockDispatchScript()
|
|
19
|
+
.lifecycle("start")
|
|
20
|
+
.toolStart(
|
|
21
|
+
"browser",
|
|
22
|
+
{ action: "goto", url: "https://a.com" },
|
|
23
|
+
{ meta: "navigate url=https://a.com", displayEmoji: "🌐", displayLabel: "Browser" },
|
|
24
|
+
)
|
|
25
|
+
.toolEnd("browser", { ok: true })
|
|
26
|
+
.toolError("exec", { message: "failed" })
|
|
27
|
+
.deliverFinal({ text: "done" })
|
|
28
|
+
.lifecycle("end")
|
|
29
|
+
.install();
|
|
30
|
+
const app = createAppSimulator({ token: "test-token" });
|
|
31
|
+
await app.connectSSE();
|
|
32
|
+
await app.sendMessage({ text: "run tools", sessionKey: "tool-sk" });
|
|
33
|
+
const frames = await app.waitForSse((f) => f.some((x) => x.event === "deliver"));
|
|
34
|
+
const agents = frames.filter((x) => x.event === "agent");
|
|
35
|
+
expect(agents.some((x) => (x.data?.stream as string) === "tool")).toBe(true);
|
|
36
|
+
const toolStart = agents.find(
|
|
37
|
+
(x) =>
|
|
38
|
+
(x.data?.stream as string) === "tool" &&
|
|
39
|
+
(x.data?.data as Record<string, unknown> | undefined)?.phase === "start",
|
|
40
|
+
);
|
|
41
|
+
const toolStartData = toolStart?.data?.data as Record<string, unknown> | undefined;
|
|
42
|
+
expect(toolStartData?.args).toEqual({ action: "goto", url: "https://a.com" });
|
|
43
|
+
expect(toolStartData?.meta).toBe("navigate url=https://a.com");
|
|
44
|
+
expect(toolStartData?.displayEmoji).toBe("🌐");
|
|
45
|
+
expect(toolStartData?.displayLabel).toBe("Browser");
|
|
46
|
+
expect(frames.some((x) => x.event === "deliver")).toBe(true);
|
|
47
|
+
app.disconnectSSE();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Last accepted POST /friday-next/messages timestamp for Control UI channel health. */
|
|
2
|
+
let lastInboundAtMs: number | null = null;
|
|
3
|
+
|
|
4
|
+
export function touchFridayInbound(): void {
|
|
5
|
+
lastInboundAtMs = Date.now();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getLastFridayInboundAt(): number | null {
|
|
9
|
+
return lastInboundAtMs;
|
|
10
|
+
}
|