@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
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { Readable, Writable } from "node:stream";
|
|
3
|
+
import { registerFridayNextHttpRoutes } from "../http/server.js";
|
|
4
|
+
|
|
5
|
+
type Headers = Record<string, string>;
|
|
6
|
+
|
|
7
|
+
export type SseFrame = {
|
|
8
|
+
id?: number;
|
|
9
|
+
event?: string;
|
|
10
|
+
data?: Record<string, unknown>;
|
|
11
|
+
raw: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
class MockReq extends Readable {
|
|
15
|
+
method: string;
|
|
16
|
+
url: string;
|
|
17
|
+
headers: Record<string, string>;
|
|
18
|
+
|
|
19
|
+
constructor(method: string, url: string, headers: Record<string, string>, body?: Buffer) {
|
|
20
|
+
super();
|
|
21
|
+
this.method = method;
|
|
22
|
+
this.url = url;
|
|
23
|
+
this.headers = headers;
|
|
24
|
+
if (body && body.length > 0) this.push(body);
|
|
25
|
+
this.push(null);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_read(): void {
|
|
29
|
+
// no-op
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class MockRes extends Writable {
|
|
34
|
+
statusCode = 200;
|
|
35
|
+
headers: Record<string, string> = {};
|
|
36
|
+
body = Buffer.alloc(0);
|
|
37
|
+
writes: string[] = [];
|
|
38
|
+
headersSent = false;
|
|
39
|
+
|
|
40
|
+
_write(chunk: Buffer | string, _enc: BufferEncoding, cb: (err?: Error | null) => void): void {
|
|
41
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
42
|
+
this.writes.push(buf.toString("utf-8"));
|
|
43
|
+
this.body = Buffer.concat([this.body, buf]);
|
|
44
|
+
cb();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setHeader(name: string, value: string): void {
|
|
48
|
+
this.headers[name.toLowerCase()] = String(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getHeader(name: string): string | undefined {
|
|
52
|
+
return this.headers[name.toLowerCase()];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
flushHeaders(): void {
|
|
56
|
+
this.headersSent = true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
end(chunk?: Buffer | string): this {
|
|
60
|
+
if (chunk) {
|
|
61
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
62
|
+
this.writes.push(buf.toString("utf-8"));
|
|
63
|
+
this.body = Buffer.concat([this.body, buf]);
|
|
64
|
+
}
|
|
65
|
+
this.emit("finish");
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createRouteHarness() {
|
|
71
|
+
let routeHandler: ((req: Readable & { method?: string; url?: string }, res: Writable) => Promise<boolean>) | null = null;
|
|
72
|
+
const fakeApi = {
|
|
73
|
+
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
|
74
|
+
registerHttpRoute(route: { handler: (req: never, res: never) => Promise<boolean> }) {
|
|
75
|
+
routeHandler = route.handler as never;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
registerFridayNextHttpRoutes(fakeApi as never);
|
|
79
|
+
if (!routeHandler) throw new Error("route handler not registered");
|
|
80
|
+
return routeHandler;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseSseFrames(rawChunks: string[]): SseFrame[] {
|
|
84
|
+
const frames: SseFrame[] = [];
|
|
85
|
+
const joined = rawChunks.join("");
|
|
86
|
+
for (const raw of joined.split("\n\n")) {
|
|
87
|
+
const trimmed = raw.trim();
|
|
88
|
+
if (!trimmed || trimmed.startsWith(":")) continue;
|
|
89
|
+
const frame: SseFrame = { raw };
|
|
90
|
+
for (const line of trimmed.split("\n")) {
|
|
91
|
+
if (line.startsWith("id:")) frame.id = Number.parseInt(line.slice(3).trim(), 10);
|
|
92
|
+
if (line.startsWith("event:")) frame.event = line.slice(6).trim();
|
|
93
|
+
if (line.startsWith("data:")) {
|
|
94
|
+
const text = line.slice(5).trim();
|
|
95
|
+
try {
|
|
96
|
+
frame.data = JSON.parse(text) as Record<string, unknown>;
|
|
97
|
+
} catch {
|
|
98
|
+
frame.data = { raw: text };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
frames.push(frame);
|
|
103
|
+
}
|
|
104
|
+
return frames;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function jsonBody(res: MockRes): Record<string, unknown> {
|
|
108
|
+
const text = res.body.toString("utf-8");
|
|
109
|
+
if (!text) return {};
|
|
110
|
+
return JSON.parse(text) as Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function waitFor(
|
|
114
|
+
predicate: () => boolean,
|
|
115
|
+
timeoutMs = 2000,
|
|
116
|
+
tickMs = 10,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
const start = Date.now();
|
|
119
|
+
while (!predicate()) {
|
|
120
|
+
if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout");
|
|
121
|
+
await new Promise((r) => setTimeout(r, tickMs));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createAppSimulator(opts?: { deviceId?: string; token?: string }) {
|
|
126
|
+
const routeHandler = createRouteHarness();
|
|
127
|
+
const token = opts?.token ?? "test-token";
|
|
128
|
+
const deviceId = opts?.deviceId ?? "DEV-A";
|
|
129
|
+
|
|
130
|
+
let sseReq: (Readable & EventEmitter) | null = null;
|
|
131
|
+
let sseRes: MockRes | null = null;
|
|
132
|
+
|
|
133
|
+
const request = async (arg: {
|
|
134
|
+
method: string;
|
|
135
|
+
path: string;
|
|
136
|
+
headers?: Headers;
|
|
137
|
+
body?: Buffer | string;
|
|
138
|
+
}): Promise<MockRes> => {
|
|
139
|
+
const headers = {
|
|
140
|
+
authorization: `Bearer ${token}`,
|
|
141
|
+
...(arg.headers ?? {}),
|
|
142
|
+
};
|
|
143
|
+
const body = typeof arg.body === "string" ? Buffer.from(arg.body) : arg.body;
|
|
144
|
+
const req = new MockReq(arg.method, arg.path, headers, body);
|
|
145
|
+
const res = new MockRes();
|
|
146
|
+
await routeHandler(req as never, res as never);
|
|
147
|
+
return res;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
async connectSSE(arg?: { deviceId?: string; lastEventId?: number; token?: string }) {
|
|
152
|
+
const did = (arg?.deviceId ?? deviceId).trim();
|
|
153
|
+
const headers: Headers = { authorization: `Bearer ${arg?.token ?? token}` };
|
|
154
|
+
const q = new URLSearchParams({ deviceId: did });
|
|
155
|
+
if (arg?.lastEventId != null && arg.lastEventId > 0) {
|
|
156
|
+
q.set("lastEventId", String(arg.lastEventId));
|
|
157
|
+
headers["last-event-id"] = String(arg.lastEventId);
|
|
158
|
+
}
|
|
159
|
+
sseReq = new MockReq("GET", `/friday-next/events?${q.toString()}`, headers);
|
|
160
|
+
sseRes = new MockRes();
|
|
161
|
+
await routeHandler(sseReq as never, sseRes as never);
|
|
162
|
+
return sseRes;
|
|
163
|
+
},
|
|
164
|
+
disconnectSSE() {
|
|
165
|
+
sseReq?.emit("close");
|
|
166
|
+
sseReq = null;
|
|
167
|
+
sseRes = null;
|
|
168
|
+
},
|
|
169
|
+
getSseFrames(): SseFrame[] {
|
|
170
|
+
return parseSseFrames(sseRes?.writes ?? []);
|
|
171
|
+
},
|
|
172
|
+
async waitForSse(predicate: (frames: SseFrame[]) => boolean, timeoutMs = 2000) {
|
|
173
|
+
await waitFor(() => predicate(parseSseFrames(sseRes?.writes ?? [])), timeoutMs);
|
|
174
|
+
return parseSseFrames(sseRes?.writes ?? []);
|
|
175
|
+
},
|
|
176
|
+
async sendMessage(arg: {
|
|
177
|
+
text?: string;
|
|
178
|
+
deviceId?: string;
|
|
179
|
+
sessionKey?: string;
|
|
180
|
+
attachments?: string[];
|
|
181
|
+
}) {
|
|
182
|
+
const payload = {
|
|
183
|
+
deviceId: arg.deviceId ?? deviceId,
|
|
184
|
+
text: arg.text ?? "",
|
|
185
|
+
sessionKey: arg.sessionKey ?? "s1",
|
|
186
|
+
attachments: arg.attachments ?? [],
|
|
187
|
+
};
|
|
188
|
+
const res = await request({
|
|
189
|
+
method: "POST",
|
|
190
|
+
path: "/friday-next/messages",
|
|
191
|
+
headers: { "content-type": "application/json" },
|
|
192
|
+
body: JSON.stringify(payload),
|
|
193
|
+
});
|
|
194
|
+
return { status: res.statusCode, body: jsonBody(res) };
|
|
195
|
+
},
|
|
196
|
+
async uploadFiles(parts: Array<{ name: string; filename: string; contentType: string; content: string | Buffer }>) {
|
|
197
|
+
const boundary = "----friday-next-e2e-boundary";
|
|
198
|
+
const chunks: Buffer[] = [];
|
|
199
|
+
for (const part of parts) {
|
|
200
|
+
const head =
|
|
201
|
+
`--${boundary}\r\n` +
|
|
202
|
+
`Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n` +
|
|
203
|
+
`Content-Type: ${part.contentType}\r\n\r\n`;
|
|
204
|
+
chunks.push(Buffer.from(head));
|
|
205
|
+
chunks.push(Buffer.isBuffer(part.content) ? part.content : Buffer.from(part.content));
|
|
206
|
+
chunks.push(Buffer.from("\r\n"));
|
|
207
|
+
}
|
|
208
|
+
chunks.push(Buffer.from(`--${boundary}--\r\n`));
|
|
209
|
+
const res = await request({
|
|
210
|
+
method: "POST",
|
|
211
|
+
path: "/friday-next/files",
|
|
212
|
+
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
213
|
+
body: Buffer.concat(chunks),
|
|
214
|
+
});
|
|
215
|
+
return { status: res.statusCode, body: jsonBody(res) };
|
|
216
|
+
},
|
|
217
|
+
async downloadFile(url: string, headers?: Headers) {
|
|
218
|
+
const res = await request({ method: "GET", path: url, headers });
|
|
219
|
+
return { status: res.statusCode, body: res.body, headers: res.headers };
|
|
220
|
+
},
|
|
221
|
+
async cancel(runId: string) {
|
|
222
|
+
const res = await request({
|
|
223
|
+
method: "POST",
|
|
224
|
+
path: "/friday-next/cancel",
|
|
225
|
+
headers: { "content-type": "application/json" },
|
|
226
|
+
body: JSON.stringify({ runId }),
|
|
227
|
+
});
|
|
228
|
+
return { status: res.statusCode, body: jsonBody(res) };
|
|
229
|
+
},
|
|
230
|
+
async status() {
|
|
231
|
+
const res = await request({ method: "GET", path: "/friday-next/status" });
|
|
232
|
+
return { status: res.statusCode, body: jsonBody(res), headers: res.headers };
|
|
233
|
+
},
|
|
234
|
+
async options(path: string, origin = "https://example.com") {
|
|
235
|
+
const res = await request({ method: "OPTIONS", path, headers: { origin } });
|
|
236
|
+
return { status: res.statusCode, headers: res.headers };
|
|
237
|
+
},
|
|
238
|
+
async rawRequest(arg: { method: string; path: string; headers?: Headers; body?: string | Buffer }) {
|
|
239
|
+
const res = await request(arg);
|
|
240
|
+
return { status: res.statusCode, body: res.body.toString("utf-8"), headers: res.headers };
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { __setMockFridayDispatchForTests, __resetMockFridayDispatchForTests } from "../agent/dispatch-bridge.js";
|
|
2
|
+
import { forwardAgentEventRaw } from "../friday-session.js";
|
|
3
|
+
|
|
4
|
+
type DispatchArg = Parameters<typeof __setMockFridayDispatchForTests>[0] extends (arg: infer A) => unknown
|
|
5
|
+
? A
|
|
6
|
+
: never;
|
|
7
|
+
|
|
8
|
+
type DispatchCallbacks = NonNullable<DispatchArg["dispatcherOptions"]>;
|
|
9
|
+
|
|
10
|
+
type ScriptStep = (args: DispatchArg, callbacks: DispatchCallbacks) => Promise<void> | void;
|
|
11
|
+
|
|
12
|
+
function runIdFromArgs(args: DispatchArg): string {
|
|
13
|
+
return (args as { replyOptions?: { runId?: string } })?.replyOptions?.runId ?? "mock-run";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sessionKeyFromArgs(args: DispatchArg): string {
|
|
17
|
+
return (args as { ctx?: { SessionKey?: string } })?.ctx?.SessionKey ?? "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class MockDispatchScript {
|
|
21
|
+
private readonly steps: ScriptStep[] = [];
|
|
22
|
+
|
|
23
|
+
lifecycle(phase: "start" | "end" | "error"): this {
|
|
24
|
+
this.steps.push((args, _callbacks) => {
|
|
25
|
+
const runId = runIdFromArgs(args);
|
|
26
|
+
const sessionKey = sessionKeyFromArgs(args);
|
|
27
|
+
forwardAgentEventRaw({
|
|
28
|
+
runId,
|
|
29
|
+
seq: 1,
|
|
30
|
+
ts: Date.now(),
|
|
31
|
+
stream: "lifecycle",
|
|
32
|
+
data: { phase },
|
|
33
|
+
sessionKey,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
reasoning(text: string): this {
|
|
40
|
+
this.steps.push((args, _callbacks) => {
|
|
41
|
+
const runId = runIdFromArgs(args);
|
|
42
|
+
const sessionKey = sessionKeyFromArgs(args);
|
|
43
|
+
forwardAgentEventRaw({
|
|
44
|
+
runId,
|
|
45
|
+
seq: 1,
|
|
46
|
+
ts: Date.now(),
|
|
47
|
+
stream: "thinking",
|
|
48
|
+
data: { phase: "delta", text },
|
|
49
|
+
sessionKey,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
reasoningEnd(): this {
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
partial(text: string): this {
|
|
60
|
+
this.steps.push((args, _callbacks) => {
|
|
61
|
+
const runId = runIdFromArgs(args);
|
|
62
|
+
const sessionKey = sessionKeyFromArgs(args);
|
|
63
|
+
forwardAgentEventRaw({
|
|
64
|
+
runId,
|
|
65
|
+
seq: 1,
|
|
66
|
+
ts: Date.now(),
|
|
67
|
+
stream: "assistant",
|
|
68
|
+
data: { phase: "delta", text },
|
|
69
|
+
sessionKey,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
toolStart(
|
|
76
|
+
name: string,
|
|
77
|
+
toolArgs: unknown,
|
|
78
|
+
options?: { meta?: string; displayEmoji?: string; displayLabel?: string },
|
|
79
|
+
): this {
|
|
80
|
+
this.steps.push((dispatchArgs, _callbacks) => {
|
|
81
|
+
const runId = runIdFromArgs(dispatchArgs);
|
|
82
|
+
const sessionKey = sessionKeyFromArgs(dispatchArgs);
|
|
83
|
+
forwardAgentEventRaw({
|
|
84
|
+
runId,
|
|
85
|
+
seq: 1,
|
|
86
|
+
ts: Date.now(),
|
|
87
|
+
stream: "tool",
|
|
88
|
+
data: {
|
|
89
|
+
phase: "start",
|
|
90
|
+
name,
|
|
91
|
+
args: toolArgs,
|
|
92
|
+
...(options?.meta ? { meta: options.meta } : {}),
|
|
93
|
+
...(options?.displayEmoji ? { displayEmoji: options.displayEmoji } : {}),
|
|
94
|
+
...(options?.displayLabel ? { displayLabel: options.displayLabel } : {}),
|
|
95
|
+
},
|
|
96
|
+
sessionKey,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
return this;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
toolEnd(name: string, result: unknown): this {
|
|
103
|
+
this.steps.push((args, _callbacks) => {
|
|
104
|
+
const runId = runIdFromArgs(args);
|
|
105
|
+
const sessionKey = sessionKeyFromArgs(args);
|
|
106
|
+
forwardAgentEventRaw({
|
|
107
|
+
runId,
|
|
108
|
+
seq: 1,
|
|
109
|
+
ts: Date.now(),
|
|
110
|
+
stream: "tool",
|
|
111
|
+
data: { phase: "end", name, result },
|
|
112
|
+
sessionKey,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
toolError(name: string, error: unknown): this {
|
|
119
|
+
this.steps.push((args, _callbacks) => {
|
|
120
|
+
const runId = runIdFromArgs(args);
|
|
121
|
+
const sessionKey = sessionKeyFromArgs(args);
|
|
122
|
+
forwardAgentEventRaw({
|
|
123
|
+
runId,
|
|
124
|
+
seq: 1,
|
|
125
|
+
ts: Date.now(),
|
|
126
|
+
stream: "tool",
|
|
127
|
+
data: { phase: "error", name, error },
|
|
128
|
+
sessionKey,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
return this;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
block(text: string, mediaUrls: string[] = [], audioAsVoice = false): this {
|
|
135
|
+
this.steps.push(async (_args, callbacks) => {
|
|
136
|
+
await callbacks.deliver?.({ text, mediaUrls, audioAsVoice } as never, { kind: "block" } as never);
|
|
137
|
+
});
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
deliverFinal(payload: { text: string; mediaUrls?: string[]; audioAsVoice?: boolean; isError?: boolean }): this {
|
|
142
|
+
this.steps.push(async (_args, callbacks) => {
|
|
143
|
+
await callbacks.deliver?.(payload as never, { kind: "final" } as never);
|
|
144
|
+
});
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
throwError(error: string): this {
|
|
149
|
+
this.steps.push((_args, callbacks) => {
|
|
150
|
+
callbacks.onError?.(new Error(error));
|
|
151
|
+
});
|
|
152
|
+
return this;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
complete(): this {
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
install(): { restore: () => void; calls: DispatchArg[] } {
|
|
160
|
+
const calls: DispatchArg[] = [];
|
|
161
|
+
__setMockFridayDispatchForTests(async (args) => {
|
|
162
|
+
calls.push(args as DispatchArg);
|
|
163
|
+
const callbacks = (args.dispatcherOptions ?? {}) as DispatchCallbacks;
|
|
164
|
+
for (const step of this.steps) {
|
|
165
|
+
await step(args as DispatchArg, callbacks);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
restore: () => __resetMockFridayDispatchForTests(),
|
|
170
|
+
calls,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function mockDispatchScript(): MockDispatchScript {
|
|
176
|
+
return new MockDispatchScript();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function resetMockDispatch(): void {
|
|
180
|
+
__resetMockFridayDispatchForTests();
|
|
181
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { setFridayNextRuntime } from "../runtime.js";
|
|
5
|
+
import { setOfflineQueueBaseDirForTest } from "../sse/offline-queue.js";
|
|
6
|
+
import { sseEmitter } from "../sse/emitter.js";
|
|
7
|
+
import { resetActiveRunsForTest } from "../agent/active-runs.js";
|
|
8
|
+
import { resetRunMetadataForTest } from "../run-metadata.js";
|
|
9
|
+
|
|
10
|
+
export type MockRuntimeOptions = {
|
|
11
|
+
authToken?: string;
|
|
12
|
+
corsEnabled?: boolean;
|
|
13
|
+
allowOrigin?: string;
|
|
14
|
+
historyDir?: string;
|
|
15
|
+
sseKeepaliveSec?: number;
|
|
16
|
+
sseBacklogPerDevice?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function createTempHistoryDir(): string {
|
|
20
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "friday-next-e2e-"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function removeTempHistoryDir(dir: string): void {
|
|
24
|
+
try {
|
|
25
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
} catch {
|
|
27
|
+
// ignore
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setMockRuntime(opts: MockRuntimeOptions = {}): void {
|
|
32
|
+
sseEmitter.resetForTest();
|
|
33
|
+
resetActiveRunsForTest();
|
|
34
|
+
resetRunMetadataForTest();
|
|
35
|
+
const historyDir = opts.historyDir ?? createTempHistoryDir();
|
|
36
|
+
setOfflineQueueBaseDirForTest(path.join(historyDir, "events-queue"));
|
|
37
|
+
const cfg = {
|
|
38
|
+
gateway: {
|
|
39
|
+
auth: {
|
|
40
|
+
token: opts.authToken ?? "test-token",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
channels: {
|
|
44
|
+
"friday-next": {
|
|
45
|
+
enabled: true,
|
|
46
|
+
transport: "http+sse",
|
|
47
|
+
pathPrefix: "/friday-next",
|
|
48
|
+
historyLimit: 25,
|
|
49
|
+
historyDir,
|
|
50
|
+
logLevel: "info",
|
|
51
|
+
authToken: opts.authToken ?? "test-token",
|
|
52
|
+
cors: {
|
|
53
|
+
enabled: opts.corsEnabled ?? false,
|
|
54
|
+
allowOrigin: opts.allowOrigin ?? "*",
|
|
55
|
+
},
|
|
56
|
+
sse: {
|
|
57
|
+
keepaliveSec: opts.sseKeepaliveSec ?? 30,
|
|
58
|
+
backlogPerDevice: opts.sseBacklogPerDevice ?? 200,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
setFridayNextRuntime({
|
|
64
|
+
config: {
|
|
65
|
+
loadConfig: () => cfg,
|
|
66
|
+
},
|
|
67
|
+
logger: {
|
|
68
|
+
info: () => {},
|
|
69
|
+
warn: () => {},
|
|
70
|
+
error: () => {},
|
|
71
|
+
debug: () => {},
|
|
72
|
+
},
|
|
73
|
+
} as never);
|
|
74
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored from OpenClaw `plugin-sdk/runtime-store` (tiny, no transitive deps).
|
|
3
|
+
* Keeps friday-next HTTP tests from importing the full gateway graph.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const pluginRuntimeStoreRegistryKey = Symbol.for("openclaw.plugin-sdk.runtime-store-registry");
|
|
7
|
+
|
|
8
|
+
type PluginRuntimeStoreRegistry = Map<string, { runtime: unknown }>;
|
|
9
|
+
type PluginRuntimeStoreKeyOptions = {
|
|
10
|
+
key: string;
|
|
11
|
+
errorMessage: string;
|
|
12
|
+
};
|
|
13
|
+
type PluginRuntimeStorePluginOptions = {
|
|
14
|
+
pluginId: string;
|
|
15
|
+
errorMessage: string;
|
|
16
|
+
};
|
|
17
|
+
type PluginRuntimeStoreOptions = PluginRuntimeStoreKeyOptions | PluginRuntimeStorePluginOptions;
|
|
18
|
+
|
|
19
|
+
function getPluginRuntimeStoreRegistry(): PluginRuntimeStoreRegistry {
|
|
20
|
+
const globalRecord = globalThis as typeof globalThis & {
|
|
21
|
+
[pluginRuntimeStoreRegistryKey]?: PluginRuntimeStoreRegistry;
|
|
22
|
+
};
|
|
23
|
+
globalRecord[pluginRuntimeStoreRegistryKey] ??= new Map();
|
|
24
|
+
return globalRecord[pluginRuntimeStoreRegistryKey];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function pluginRuntimeStoreKeyForPluginId(pluginId: string): string {
|
|
28
|
+
const normalizedPluginId = pluginId.trim();
|
|
29
|
+
if (!normalizedPluginId) {
|
|
30
|
+
throw new Error("createPluginRuntimeStore: pluginId must not be empty");
|
|
31
|
+
}
|
|
32
|
+
return `plugin-runtime:${normalizedPluginId}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolvePluginRuntimeStoreOptions(
|
|
36
|
+
options: string | PluginRuntimeStoreOptions,
|
|
37
|
+
): PluginRuntimeStoreKeyOptions {
|
|
38
|
+
if (typeof options === "string") {
|
|
39
|
+
return { key: options, errorMessage: options };
|
|
40
|
+
}
|
|
41
|
+
if ("pluginId" in options) {
|
|
42
|
+
return {
|
|
43
|
+
key: pluginRuntimeStoreKeyForPluginId(options.pluginId),
|
|
44
|
+
errorMessage: options.errorMessage,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return options;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createPluginRuntimeStore<T>(errorMessage: string): {
|
|
51
|
+
setRuntime: (next: T) => void;
|
|
52
|
+
clearRuntime: () => void;
|
|
53
|
+
tryGetRuntime: () => T | null;
|
|
54
|
+
getRuntime: () => T;
|
|
55
|
+
};
|
|
56
|
+
export function createPluginRuntimeStore<T>(options: PluginRuntimeStoreOptions): {
|
|
57
|
+
setRuntime: (next: T) => void;
|
|
58
|
+
clearRuntime: () => void;
|
|
59
|
+
tryGetRuntime: () => T | null;
|
|
60
|
+
getRuntime: () => T;
|
|
61
|
+
};
|
|
62
|
+
export function createPluginRuntimeStore<T>(options: string | PluginRuntimeStoreOptions): {
|
|
63
|
+
setRuntime: (next: T) => void;
|
|
64
|
+
clearRuntime: () => void;
|
|
65
|
+
tryGetRuntime: () => T | null;
|
|
66
|
+
getRuntime: () => T;
|
|
67
|
+
} {
|
|
68
|
+
const resolved = resolvePluginRuntimeStoreOptions(options);
|
|
69
|
+
const slot =
|
|
70
|
+
typeof options === "string"
|
|
71
|
+
? { runtime: null as unknown }
|
|
72
|
+
: (() => {
|
|
73
|
+
const registry = getPluginRuntimeStoreRegistry();
|
|
74
|
+
let existingSlot = registry.get(resolved.key);
|
|
75
|
+
if (!existingSlot) {
|
|
76
|
+
existingSlot = { runtime: null };
|
|
77
|
+
registry.set(resolved.key, existingSlot);
|
|
78
|
+
}
|
|
79
|
+
return existingSlot;
|
|
80
|
+
})();
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
setRuntime(next: T) {
|
|
84
|
+
slot.runtime = next;
|
|
85
|
+
},
|
|
86
|
+
clearRuntime() {
|
|
87
|
+
slot.runtime = null;
|
|
88
|
+
},
|
|
89
|
+
tryGetRuntime() {
|
|
90
|
+
return (slot.runtime as T | null) ?? null;
|
|
91
|
+
},
|
|
92
|
+
getRuntime() {
|
|
93
|
+
if (slot.runtime === null) {
|
|
94
|
+
throw new Error(resolved.errorMessage);
|
|
95
|
+
}
|
|
96
|
+
return slot.runtime as T;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"types": ["node"],
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"outDir": "dist",
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"sourceMap": false,
|
|
13
|
+
"declarationMap": false
|
|
14
|
+
},
|
|
15
|
+
"include": ["index.ts", "src/**/*.ts", "src/**/*.d.ts"],
|
|
16
|
+
"exclude": ["src/e2e/**", "src/**/*.test.ts", "src/test-support/**", "scripts/**"]
|
|
17
|
+
}
|