@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.
Files changed (67) hide show
  1. package/README.md +35 -0
  2. package/index.ts +191 -0
  3. package/install.mjs +158 -0
  4. package/install.sh +118 -0
  5. package/openclaw.plugin.json +53 -0
  6. package/package.json +65 -0
  7. package/src/agent/abort-run.ts +10 -0
  8. package/src/agent/active-runs.ts +26 -0
  9. package/src/agent/dispatch-bridge.ts +18 -0
  10. package/src/agent/media-bridge.ts +23 -0
  11. package/src/agent-forward-runtime.ts +30 -0
  12. package/src/agent-run-context-bridge.ts +32 -0
  13. package/src/channel-actions.ts +129 -0
  14. package/src/channel.ts +284 -0
  15. package/src/collect-message-media-paths.ts +132 -0
  16. package/src/config.test.ts +33 -0
  17. package/src/config.ts +64 -0
  18. package/src/e2e/attachments-inbound.e2e.test.ts +43 -0
  19. package/src/e2e/attachments-outbound.e2e.test.ts +43 -0
  20. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +56 -0
  21. package/src/e2e/connect-and-connected.e2e.test.ts +44 -0
  22. package/src/e2e/offline-replay.e2e.test.ts +43 -0
  23. package/src/e2e/send-text.e2e.test.ts +73 -0
  24. package/src/e2e/slash-commands.e2e.test.ts +33 -0
  25. package/src/e2e/status-cors-auth.e2e.test.ts +41 -0
  26. package/src/e2e/tool-lifecycle.e2e.test.ts +49 -0
  27. package/src/friday-inbound-stats.ts +10 -0
  28. package/src/friday-session.forward-agent.test.ts +270 -0
  29. package/src/friday-session.ts +327 -0
  30. package/src/host-config.ts +20 -0
  31. package/src/http/handlers/cancel.test.ts +70 -0
  32. package/src/http/handlers/cancel.ts +35 -0
  33. package/src/http/handlers/files-download.ts +239 -0
  34. package/src/http/handlers/files-upload.ts +166 -0
  35. package/src/http/handlers/files.ts +335 -0
  36. package/src/http/handlers/messages.test.ts +119 -0
  37. package/src/http/handlers/messages.ts +555 -0
  38. package/src/http/handlers/models-list.ts +126 -0
  39. package/src/http/handlers/sessions-delete.ts +59 -0
  40. package/src/http/handlers/sessions-settings.ts +90 -0
  41. package/src/http/handlers/sse.test.ts +71 -0
  42. package/src/http/handlers/sse.ts +84 -0
  43. package/src/http/handlers/status.test.ts +52 -0
  44. package/src/http/handlers/status.ts +33 -0
  45. package/src/http/middleware/auth.test.ts +46 -0
  46. package/src/http/middleware/auth.ts +31 -0
  47. package/src/http/middleware/body.test.ts +27 -0
  48. package/src/http/middleware/body.ts +28 -0
  49. package/src/http/middleware/cors.test.ts +40 -0
  50. package/src/http/middleware/cors.ts +12 -0
  51. package/src/http/server.ts +106 -0
  52. package/src/logging.ts +27 -0
  53. package/src/openclaw.d.ts +32 -0
  54. package/src/run-metadata.ts +180 -0
  55. package/src/runtime.ts +14 -0
  56. package/src/session/session-manager.ts +230 -0
  57. package/src/session-usage-snapshot.ts +80 -0
  58. package/src/sse/emitter.test.ts +85 -0
  59. package/src/sse/emitter.ts +249 -0
  60. package/src/sse/frame-format.test.ts +56 -0
  61. package/src/sse/offline-queue.test.ts +65 -0
  62. package/src/sse/offline-queue.ts +140 -0
  63. package/src/test-support/app-simulator.ts +243 -0
  64. package/src/test-support/mock-dispatch.ts +181 -0
  65. package/src/test-support/mock-runtime.ts +74 -0
  66. package/src/vendor/runtime-store.ts +99 -0
  67. package/tsconfig.json +17 -0
@@ -0,0 +1,270 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ resetFridayAgentForwardRuntimeForTest,
4
+ setFridayAgentForwardRuntime,
5
+ } from "./agent-forward-runtime.js";
6
+ import {
7
+ forwardAgentEventRaw,
8
+ registerFridaySessionDeviceMapping,
9
+ resetOpenClawRunDeviceMappingForTest,
10
+ resetThinkingStreamAccumStateForTest,
11
+ } from "./friday-session.js";
12
+ import { resetRunMetadataForTest } from "./run-metadata.js";
13
+ import { sseEmitter } from "./sse/emitter.js";
14
+ import { toSessionStoreKey } from "./session/session-manager.js";
15
+
16
+ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
17
+ const runId = "run-thinking-test";
18
+ const sessionKey = "agent:main:friday-session-test";
19
+ const deviceId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE";
20
+
21
+ beforeEach(() => {
22
+ sseEmitter.resetForTest();
23
+ resetThinkingStreamAccumStateForTest();
24
+ resetOpenClawRunDeviceMappingForTest();
25
+ resetFridayAgentForwardRuntimeForTest();
26
+ resetRunMetadataForTest();
27
+ registerFridaySessionDeviceMapping(sessionKey, deviceId);
28
+ vi.spyOn(sseEmitter, "broadcastToRun").mockImplementation(() => {});
29
+ });
30
+
31
+ afterEach(() => {
32
+ resetFridayAgentForwardRuntimeForTest();
33
+ vi.restoreAllMocks();
34
+ });
35
+
36
+ it("rewrites cumulative thinking into incremental delta + reasoningPrefixChars", () => {
37
+ const t1 = "Reasoning:\n_A_";
38
+ const t2 = "Reasoning:\n_AB_";
39
+ forwardAgentEventRaw({
40
+ runId,
41
+ seq: 1,
42
+ ts: 100,
43
+ stream: "thinking",
44
+ sessionKey,
45
+ data: { text: t1, delta: t1 },
46
+ });
47
+ forwardAgentEventRaw({
48
+ runId,
49
+ seq: 2,
50
+ ts: 101,
51
+ stream: "thinking",
52
+ sessionKey,
53
+ data: { text: t2, delta: t2 },
54
+ });
55
+
56
+ expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(2);
57
+ const first = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data.data;
58
+ const second = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[1][1].data.data;
59
+
60
+ expect(first.text).toBe(t1);
61
+ expect(first.delta).toBe(t1);
62
+ expect(first.reasoningPrefixChars).toBe(0);
63
+
64
+ expect(second.text).toBe(t2);
65
+ expect(second.delta).toBe("B_");
66
+ expect(second.reasoningPrefixChars).toBe(commonPrefixLen(t1, t2));
67
+ expect(t2.startsWith(t1.slice(0, second.reasoningPrefixChars))).toBe(true);
68
+ });
69
+
70
+ it("clears per-run cache on lifecycle end so the next thinking frame is full delta again", () => {
71
+ const t1 = "Reasoning:\n_x_";
72
+ forwardAgentEventRaw({
73
+ runId,
74
+ seq: 1,
75
+ stream: "thinking",
76
+ sessionKey,
77
+ data: { text: t1, delta: t1 },
78
+ });
79
+ forwardAgentEventRaw({
80
+ runId,
81
+ seq: 2,
82
+ stream: "lifecycle",
83
+ sessionKey,
84
+ data: { phase: "end" },
85
+ });
86
+ forwardAgentEventRaw({
87
+ runId,
88
+ seq: 3,
89
+ stream: "thinking",
90
+ sessionKey,
91
+ data: { text: t1, delta: t1 },
92
+ });
93
+
94
+ expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(3);
95
+ const third = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[2][1].data.data;
96
+ expect(third.delta).toBe(t1);
97
+ expect(third.reasoningPrefixChars).toBe(0);
98
+ });
99
+
100
+ it("clears per-run cache on lifecycle error", () => {
101
+ const t1 = "Reasoning:\n_y_";
102
+ forwardAgentEventRaw({
103
+ runId,
104
+ seq: 1,
105
+ stream: "thinking",
106
+ sessionKey,
107
+ data: { text: t1, delta: t1 },
108
+ });
109
+ forwardAgentEventRaw({
110
+ runId,
111
+ seq: 2,
112
+ stream: "lifecycle",
113
+ sessionKey,
114
+ data: { phase: "error" },
115
+ });
116
+ forwardAgentEventRaw({
117
+ runId,
118
+ seq: 3,
119
+ stream: "thinking",
120
+ sessionKey,
121
+ data: { text: t1, delta: t1 },
122
+ });
123
+
124
+ const third = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[2][1].data.data;
125
+ expect(third.reasoningPrefixChars).toBe(0);
126
+ expect(third.delta).toBe(t1);
127
+ });
128
+
129
+ it("merges run metadata into lifecycle.end (model, tokens, context usage)", () => {
130
+ forwardAgentEventRaw({
131
+ runId,
132
+ seq: 1,
133
+ stream: "assistant",
134
+ sessionKey,
135
+ data: {
136
+ modelName: "gpt-test",
137
+ usage: { input: 100, output: 50, total: 150 },
138
+ contextWindow: 128000,
139
+ },
140
+ });
141
+ forwardAgentEventRaw({
142
+ runId,
143
+ seq: 2,
144
+ stream: "lifecycle",
145
+ sessionKey,
146
+ data: { phase: "end", endedAt: 1 },
147
+ });
148
+ const endCall = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls.find(
149
+ (c) => (c[1] as { data?: { stream?: string } }).data?.stream === "lifecycle",
150
+ );
151
+ expect(endCall).toBeTruthy();
152
+ const data = (endCall![1] as { data: { data: Record<string, unknown> } }).data.data;
153
+ expect(data.phase).toBe("end");
154
+ expect(data.modelName).toBe("gpt-test");
155
+ expect(data.totalTokens).toBe(150);
156
+ expect(data.contextTokensUsed).toBe(100);
157
+ expect(data.contextWindowMax).toBe(128000);
158
+ });
159
+
160
+ it("forwards lifecycle.end when sessionKey and run context are missing but run was mapped earlier", () => {
161
+ const clientRun = "client-post-run-id";
162
+ sseEmitter.trackDeviceForRun(deviceId.toUpperCase(), clientRun);
163
+
164
+ forwardAgentEventRaw({
165
+ runId,
166
+ seq: 1,
167
+ stream: "lifecycle",
168
+ sessionKey,
169
+ data: { phase: "start" },
170
+ });
171
+
172
+ forwardAgentEventRaw({
173
+ runId,
174
+ seq: 2,
175
+ stream: "lifecycle",
176
+ data: { phase: "end" },
177
+ });
178
+
179
+ expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(2);
180
+ const endPayload = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[1][1].data;
181
+ expect(endPayload.stream).toBe("lifecycle");
182
+ expect((endPayload.data as { phase?: string }).phase).toBe("end");
183
+ expect(endPayload.sessionKey).toBe(sessionKey);
184
+ });
185
+
186
+ it("passes through non-thinking streams without rewriting data", () => {
187
+ const data = { phase: "delta", text: "hello" };
188
+ forwardAgentEventRaw({
189
+ runId,
190
+ seq: 10,
191
+ stream: "assistant",
192
+ sessionKey,
193
+ data,
194
+ });
195
+ expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
196
+ const payload = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data;
197
+ expect(payload.data).toStrictEqual(data);
198
+ expect("reasoningPrefixChars" in (payload.data as object)).toBe(false);
199
+ });
200
+
201
+ it("merges sessionUsage from session store on lifecycle end after persist (deferred)", async () => {
202
+ const storeKey = toSessionStoreKey(sessionKey);
203
+ const store: Record<string, Record<string, unknown>> = {
204
+ [storeKey]: {
205
+ model: "my-model",
206
+ modelProvider: "openai",
207
+ inputTokens: 100,
208
+ outputTokens: 50,
209
+ totalTokens: 9999,
210
+ totalTokensFresh: true,
211
+ contextTokens: 128000,
212
+ estimatedCostUsd: 0.01,
213
+ cacheRead: 10,
214
+ cacheWrite: 0,
215
+ },
216
+ };
217
+ const loadSessionStore = vi.fn(() => store);
218
+ const mockApi = {
219
+ runtime: {
220
+ config: {
221
+ current: () => ({ session: {} }),
222
+ },
223
+ agent: {
224
+ session: {
225
+ resolveStorePath: () => "/tmp/sessions.json",
226
+ loadSessionStore,
227
+ },
228
+ },
229
+ },
230
+ };
231
+ setFridayAgentForwardRuntime(mockApi as never);
232
+
233
+ forwardAgentEventRaw({
234
+ runId,
235
+ seq: 1,
236
+ stream: "lifecycle",
237
+ sessionKey,
238
+ data: { phase: "end" },
239
+ });
240
+ expect(sseEmitter.broadcastToRun).not.toHaveBeenCalled();
241
+
242
+ await new Promise<void>((resolve) => setImmediate(resolve));
243
+
244
+ expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
245
+ const forwarded = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data;
246
+ expect(forwarded.stream).toBe("lifecycle");
247
+ expect((forwarded.data as Record<string, unknown>).sessionUsage).toEqual({
248
+ modelId: "my-model",
249
+ modelProvider: "openai",
250
+ tokens: {
251
+ input: 100,
252
+ output: 50,
253
+ cacheRead: 10,
254
+ cacheWrite: 0,
255
+ total: 9999,
256
+ totalFresh: true,
257
+ },
258
+ context: { windowMax: 128000, used: 9999 },
259
+ estimatedCostUsd: 0.01,
260
+ });
261
+ expect(loadSessionStore).toHaveBeenCalledWith("/tmp/sessions.json", { skipCache: true });
262
+ });
263
+ });
264
+
265
+ function commonPrefixLen(a: string, b: string): number {
266
+ const len = Math.min(a.length, b.length);
267
+ let i = 0;
268
+ while (i < len && a.charCodeAt(i) === b.charCodeAt(i)) i++;
269
+ return i;
270
+ }
@@ -0,0 +1,327 @@
1
+ import { sseEmitter } from "./sse/emitter.js";
2
+ import { getFridayAgentForwardRuntime } from "./agent-forward-runtime.js";
3
+ import { toSessionStoreKey } from "./session/session-manager.js";
4
+ import { getOpenClawAgentRunContext } from "./agent-run-context-bridge.js";
5
+ import { observeAgentEventForActiveRuns } from "./agent/active-runs.js";
6
+ import { getRunMetadata, ingestAgentEventMetadata } from "./run-metadata.js";
7
+ import { buildSessionUsageSnapshot } from "./session-usage-snapshot.js";
8
+
9
+ /** Last `data.text` per run for `stream: "thinking"` — OpenClaw core may send cumulative `delta`; we rewrite true increments for the app. */
10
+ const lastThinkingTextByRun = new Map<string, string>();
11
+
12
+ function commonPrefixLength(a: string, b: string): number {
13
+ const len = Math.min(a.length, b.length);
14
+ let i = 0;
15
+ while (i < len && a.charCodeAt(i) === b.charCodeAt(i)) i++;
16
+ return i;
17
+ }
18
+
19
+ /** Vitest-only: clears per-run reasoning text cache used for incremental `delta` rewriting. */
20
+ export function resetThinkingStreamAccumStateForTest(): void {
21
+ lastThinkingTextByRun.clear();
22
+ }
23
+
24
+ /**
25
+ * OpenClaw `runId` → device UUID (uppercase).
26
+ * When `lifecycle.end` / `error` is emitted, the gateway may call `clearAgentRunContext` before this extension's
27
+ * `onAgentEvent` runs; combined with stripped `sessionKey` for non–Control-UI-visible runs, `forwardAgentEventRaw`
28
+ * would otherwise return early and never forward the terminal lifecycle frame.
29
+ */
30
+ const openClawRunIdToDeviceId = new Map<string, string>();
31
+
32
+ /** Vitest-only */
33
+ export function resetOpenClawRunDeviceMappingForTest(): void {
34
+ openClawRunIdToDeviceId.clear();
35
+ }
36
+
37
+ /** Parse deviceId from a Friday Next channel sessionKey (friday-{deviceId} or legacy agent:main:friday-*). */
38
+ export function deviceIdFromSessionKey(sessionKey: string): string | null {
39
+ const m1 = sessionKey.match(/^friday-next-(.+)$/i);
40
+ if (m1) return m1[1] ?? null;
41
+ const m2 = sessionKey.match(/^agent:main:friday-next-(.+)$/i);
42
+ return m2 ? m2[1] ?? null : null;
43
+ }
44
+
45
+ /**
46
+ * When the app uses a plain `sessionKey` (e.g. `main` → `agent:main:main` in the gateway),
47
+ * sub-agent / announce runs still emit `onAgentEvent` with that store key — not `friday-{deviceId}`.
48
+ * Each POST /friday-next/messages registers both the raw and store keys so forwards and tool hooks resolve.
49
+ */
50
+ const sessionKeyToDeviceId = new Map<string, string>();
51
+ /** Gateway / store session keys → app's history `sessionKey` (verbatim from POST). */
52
+ const gatewayKeyToHistorySessionKey = new Map<string, string>();
53
+ /** deviceId → latest app history sessionKey (verbatim from POST). */
54
+ const deviceIdToLatestHistorySessionKey = new Map<string, string>();
55
+ /** Last device that called POST /friday-next/messages (same gateway process). Used for cron/outbound when `to` is placeholder and the app is offline (no SSE). */
56
+ let lastRegisteredFridayDeviceId: string | undefined;
57
+
58
+ function normalizeFridaySessionKeyCase(sk: string): string {
59
+ return /^friday-next-|^agent:main:friday-next-/i.test(sk) || /^agent:main:friday-next:direct:/i.test(sk)
60
+ ? sk.toLowerCase()
61
+ : sk;
62
+ }
63
+
64
+ export function registerFridaySessionDeviceMapping(rawSessionKey: string, deviceId: string): void {
65
+ const sk = rawSessionKey.trim();
66
+ const did = deviceId.trim().toUpperCase();
67
+ if (!sk || !did) return;
68
+ const storeKey = toSessionStoreKey(sk);
69
+ for (const k of new Set([
70
+ sk,
71
+ storeKey,
72
+ normalizeFridaySessionKeyCase(sk),
73
+ normalizeFridaySessionKeyCase(storeKey),
74
+ ])) {
75
+ sessionKeyToDeviceId.set(k, did);
76
+ gatewayKeyToHistorySessionKey.set(k, sk);
77
+ }
78
+ deviceIdToLatestHistorySessionKey.set(did, sk);
79
+ lastRegisteredFridayDeviceId = did;
80
+ }
81
+
82
+ /** In-process fallback for tool hooks / telemetry (same idea as outbound sole-device). */
83
+ export function getLastRegisteredFridayDeviceId(): string | undefined {
84
+ return lastRegisteredFridayDeviceId;
85
+ }
86
+
87
+ /** Resolve device for gateway `sessionKey` (friday-style or last POST mapping). */
88
+ export function resolveFridayDeviceIdForSessionKey(sessionKey: string): string | null {
89
+ const mapped =
90
+ sessionKeyToDeviceId.get(sessionKey) ??
91
+ sessionKeyToDeviceId.get(toSessionStoreKey(sessionKey)) ??
92
+ sessionKeyToDeviceId.get(normalizeFridaySessionKeyCase(sessionKey)) ??
93
+ sessionKeyToDeviceId.get(normalizeFridaySessionKeyCase(toSessionStoreKey(sessionKey)));
94
+ if (mapped) return mapped;
95
+ return deviceIdFromSessionKey(sessionKey);
96
+ }
97
+
98
+ function historySessionKeyForGatewaySessionKey(sk: string): string | undefined {
99
+ return (
100
+ gatewayKeyToHistorySessionKey.get(sk) ??
101
+ gatewayKeyToHistorySessionKey.get(toSessionStoreKey(sk)) ??
102
+ gatewayKeyToHistorySessionKey.get(normalizeFridaySessionKeyCase(sk)) ??
103
+ gatewayKeyToHistorySessionKey.get(normalizeFridaySessionKeyCase(toSessionStoreKey(sk)))
104
+ );
105
+ }
106
+
107
+ /** Tool hooks / core may pass gateway store keys; resolve app's POST sessionKey. */
108
+ export function resolveFridayHistorySessionKey(gatewaySessionKey: string): string | undefined {
109
+ const sk = gatewaySessionKey.trim();
110
+ if (!sk) return undefined;
111
+ return historySessionKeyForGatewaySessionKey(sk);
112
+ }
113
+
114
+ /** Resolve latest known app sessionKey by deviceId (from last POST). */
115
+ export function latestHistorySessionKeyForDeviceId(deviceId: string): string | undefined {
116
+ return deviceIdToLatestHistorySessionKey.get(deviceId.trim().toUpperCase());
117
+ }
118
+
119
+ /**
120
+ * Session key hint for outbound delivery when ctx has no `sessionKey` (typical cron).
121
+ * Uses in-process mapping only (no plugin-side history files).
122
+ */
123
+ export function resolveHistorySessionKeyForFridayDevice(deviceId: string): string | undefined {
124
+ const did = deviceId.trim().toUpperCase();
125
+ if (!did || did.toLowerCase() === "friday-next") return undefined;
126
+ const fromMemory = latestHistorySessionKeyForDeviceId(did);
127
+ if (fromMemory) return fromMemory;
128
+ return `agent:main:friday-next-${did}`;
129
+ }
130
+
131
+ const DEFAULT_SESSION_STORE_AGENT_ID = "main";
132
+
133
+ type ForwardAgentEventArgs = {
134
+ runId: string;
135
+ seq?: number;
136
+ ts?: number;
137
+ stream: string;
138
+ data: Record<string, unknown>;
139
+ sessionKey?: string;
140
+ };
141
+
142
+ function mergeRunMetadataIntoLifecycleEnd(
143
+ runId: string,
144
+ base: Record<string, unknown>,
145
+ ): Record<string, unknown> {
146
+ const meta = getRunMetadata(runId);
147
+ if (!meta) return base;
148
+ const extra: Record<string, unknown> = {};
149
+ if (typeof meta.modelName === "string" && meta.modelName.trim()) {
150
+ extra.modelName = meta.modelName.trim();
151
+ }
152
+ if (typeof meta.totalTokens === "number" && Number.isFinite(meta.totalTokens) && meta.totalTokens > 0) {
153
+ extra.totalTokens = Math.floor(meta.totalTokens);
154
+ }
155
+ if (
156
+ typeof meta.contextTokensUsed === "number" &&
157
+ Number.isFinite(meta.contextTokensUsed) &&
158
+ meta.contextTokensUsed > 0
159
+ ) {
160
+ extra.contextTokensUsed = Math.floor(meta.contextTokensUsed);
161
+ }
162
+ if (
163
+ typeof meta.contextWindowMax === "number" &&
164
+ Number.isFinite(meta.contextWindowMax) &&
165
+ meta.contextWindowMax > 0
166
+ ) {
167
+ extra.contextWindowMax = Math.floor(meta.contextWindowMax);
168
+ }
169
+ if (Object.keys(extra).length === 0) return base;
170
+ return { ...base, ...extra };
171
+ }
172
+
173
+ function tryReadSessionUsageFromStore(sessionKeyForStore: string): ReturnType<typeof buildSessionUsageSnapshot> {
174
+ const access = getFridayAgentForwardRuntime();
175
+ if (!access) return undefined;
176
+ try {
177
+ const cfg = access.getConfig() as { session?: { store?: string } } | null | undefined;
178
+ const storeConfig = cfg?.session?.store;
179
+ const storePath = access.resolveStorePath(storeConfig, { agentId: DEFAULT_SESSION_STORE_AGENT_ID });
180
+ const store = access.loadSessionStore(storePath, { skipCache: true }) as Record<string, Record<string, unknown>>;
181
+ const canonical = toSessionStoreKey(sessionKeyForStore);
182
+ const entry = store[canonical] ?? store[sessionKeyForStore.trim()];
183
+ if (!entry || typeof entry !== "object") return undefined;
184
+ return buildSessionUsageSnapshot(entry);
185
+ } catch {
186
+ return undefined;
187
+ }
188
+ }
189
+
190
+ function completeAgentEventForward(params: {
191
+ evt: ForwardAgentEventArgs;
192
+ sk: string;
193
+ deviceIdRaw: string;
194
+ outgoingData: Record<string, unknown>;
195
+ isTerminalLifecycle: boolean;
196
+ }): void {
197
+ const { evt, sk, deviceIdRaw, outgoingData, isTerminalLifecycle } = params;
198
+
199
+ observeAgentEventForActiveRuns({ stream: evt.stream, runId: evt.runId, data: outgoingData });
200
+
201
+ const deviceId = deviceIdRaw.toUpperCase();
202
+ const targetRunId = sseEmitter.getLastRunIdForDevice(deviceId) ?? evt.runId;
203
+ const directToDevice = !sseEmitter.hasTrackedDevices(targetRunId);
204
+
205
+ const payload: Record<string, unknown> = {
206
+ runId: evt.runId,
207
+ seq: evt.seq,
208
+ ts: evt.ts,
209
+ stream: evt.stream,
210
+ data: outgoingData,
211
+ sessionKey: evt.sessionKey ?? sk,
212
+ };
213
+ if (directToDevice) payload.deviceId = deviceId;
214
+
215
+ sseEmitter.broadcastToRun(targetRunId, { type: "agent", data: payload });
216
+
217
+ if (isTerminalLifecycle) {
218
+ openClawRunIdToDeviceId.delete(evt.runId);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Resolve the real device UUID for Friday outbound (`sendText` / `sendMedia`).
224
+ */
225
+ export function resolveFridayDeviceIdForOutbound(to: string | undefined, rawCtx?: Record<string, unknown>): string {
226
+ const trimmed = (to ?? "").trim();
227
+ if (trimmed && trimmed.toLowerCase() !== "friday-next") {
228
+ return trimmed;
229
+ }
230
+ const sk =
231
+ (typeof rawCtx?.requesterSessionKey === "string" && rawCtx.requesterSessionKey.trim()) ||
232
+ (typeof rawCtx?.sessionKey === "string" && rawCtx.sessionKey.trim()) ||
233
+ "";
234
+ if (sk) {
235
+ const fromSession = resolveFridayDeviceIdForSessionKey(sk);
236
+ if (fromSession) return fromSession;
237
+ }
238
+ const sole = sseEmitter.getSoleConnectedDeviceId();
239
+ if (sole) return sole;
240
+ if (lastRegisteredFridayDeviceId) return lastRegisteredFridayDeviceId;
241
+ return trimmed || "friday-next";
242
+ }
243
+
244
+ /**
245
+ * Forward global OpenClaw agent events to the Friday SSE connection (transparent).
246
+ *
247
+ * Asynchronous follow-up runs still reach the device via `getLastRunIdForDevice` when the parent run
248
+ * is no longer tracked.
249
+ */
250
+ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
251
+ ingestAgentEventMetadata(evt.runId, evt.data);
252
+
253
+ let sk = typeof evt.sessionKey === "string" ? evt.sessionKey.trim() : "";
254
+ if (!sk) {
255
+ const ctx = getOpenClawAgentRunContext(evt.runId);
256
+ const fromCtx = typeof ctx?.sessionKey === "string" ? ctx.sessionKey.trim() : "";
257
+ if (fromCtx) sk = fromCtx;
258
+ }
259
+
260
+ let deviceIdRaw = sk ? resolveFridayDeviceIdForSessionKey(sk) : null;
261
+ if (!deviceIdRaw) {
262
+ const mapped = openClawRunIdToDeviceId.get(evt.runId);
263
+ if (mapped) deviceIdRaw = mapped;
264
+ }
265
+ if (!deviceIdRaw) return;
266
+
267
+ if (!sk) {
268
+ sk =
269
+ latestHistorySessionKeyForDeviceId(deviceIdRaw) ?? `friday-next-${deviceIdRaw}`;
270
+ }
271
+
272
+ openClawRunIdToDeviceId.set(evt.runId, deviceIdRaw.toUpperCase());
273
+
274
+ let outgoingData: Record<string, unknown> = { ...evt.data };
275
+
276
+ if (evt.stream === "thinking") {
277
+ const currentText = typeof evt.data.text === "string" ? evt.data.text : "";
278
+ const prior = lastThinkingTextByRun.get(evt.runId) ?? "";
279
+ const prefixLen = commonPrefixLength(prior, currentText);
280
+ const delta = currentText.slice(prefixLen);
281
+ lastThinkingTextByRun.set(evt.runId, currentText);
282
+ outgoingData = {
283
+ ...evt.data,
284
+ text: currentText,
285
+ delta,
286
+ reasoningPrefixChars: prefixLen,
287
+ };
288
+ } else if (evt.stream === "lifecycle") {
289
+ const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
290
+ if (phase === "end") {
291
+ outgoingData = mergeRunMetadataIntoLifecycleEnd(evt.runId, outgoingData);
292
+ }
293
+ if (phase === "end" || phase === "error") {
294
+ lastThinkingTextByRun.delete(evt.runId);
295
+ }
296
+ }
297
+
298
+ const lifecyclePhase =
299
+ evt.stream === "lifecycle" && typeof evt.data.phase === "string" ? evt.data.phase : "";
300
+ const isTerminalLifecycle = evt.stream === "lifecycle" && (lifecyclePhase === "end" || lifecyclePhase === "error");
301
+
302
+ if (isTerminalLifecycle && getFridayAgentForwardRuntime()) {
303
+ setImmediate(() => {
304
+ let data = outgoingData;
305
+ const usage = tryReadSessionUsageFromStore(sk);
306
+ if (usage) {
307
+ data = { ...outgoingData, sessionUsage: usage };
308
+ }
309
+ completeAgentEventForward({
310
+ evt,
311
+ sk,
312
+ deviceIdRaw,
313
+ outgoingData: data,
314
+ isTerminalLifecycle: true,
315
+ });
316
+ });
317
+ return;
318
+ }
319
+
320
+ completeAgentEventForward({
321
+ evt,
322
+ sk,
323
+ deviceIdRaw,
324
+ outgoingData,
325
+ isTerminalLifecycle,
326
+ });
327
+ }
@@ -0,0 +1,20 @@
1
+ type HostConfigLoader = {
2
+ loadConfig: () => unknown;
3
+ };
4
+
5
+ function isHostConfigLoader(value: unknown): value is HostConfigLoader {
6
+ return (
7
+ !!value &&
8
+ typeof value === "object" &&
9
+ typeof (value as { loadConfig?: unknown }).loadConfig === "function"
10
+ );
11
+ }
12
+
13
+ export function getHostOpenClawConfigSnapshot(config: unknown): unknown {
14
+ if (!isHostConfigLoader(config)) return {};
15
+ try {
16
+ return config.loadConfig();
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
@@ -0,0 +1,70 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { PassThrough } from "node:stream";
4
+ import type { IncomingMessage, ServerResponse } from "node:http";
5
+ import { handleCancel } from "./cancel.js";
6
+ import { sseEmitter } from "../../sse/emitter.js";
7
+ import { setMockRuntime } from "../../test-support/mock-runtime.js";
8
+ import { resetMockDispatch } from "../../test-support/mock-dispatch.js";
9
+
10
+ class MockRes extends EventEmitter {
11
+ statusCode = 0;
12
+ headers: Record<string, string> = {};
13
+ body = "";
14
+ setHeader(name: string, value: string): void {
15
+ this.headers[name.toLowerCase()] = value;
16
+ }
17
+ end(body?: string): void {
18
+ if (body) this.body += body;
19
+ }
20
+ }
21
+
22
+ function mockReq(method: string, headers: Record<string, string> = {}): PassThrough & { method: string; headers: Record<string, string> } {
23
+ const stream = new PassThrough() as unknown as PassThrough & { method: string; headers: Record<string, string> };
24
+ stream.method = method;
25
+ stream.headers = headers;
26
+ return stream;
27
+ }
28
+
29
+ describe("handleCancel", () => {
30
+ beforeEach(() => {
31
+ setMockRuntime();
32
+ });
33
+
34
+ it("returns 405 on non-post", async () => {
35
+ const req = { method: "GET", headers: {} } as IncomingMessage;
36
+ const res = new MockRes() as unknown as ServerResponse;
37
+ await handleCancel(req, res);
38
+ expect((res as unknown as MockRes).statusCode).toBe(405);
39
+ });
40
+
41
+ it("returns 401 for missing auth", async () => {
42
+ const req = mockReq("POST");
43
+ const res = new MockRes() as unknown as ServerResponse;
44
+ const p = handleCancel(req as unknown as IncomingMessage, res);
45
+ req.end(JSON.stringify({ runId: "run-1" }));
46
+ await p;
47
+ expect((res as unknown as MockRes).statusCode).toBe(401);
48
+ });
49
+
50
+ it("returns 400 for missing runId", async () => {
51
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
52
+ const res = new MockRes() as unknown as ServerResponse;
53
+ const p = handleCancel(req as unknown as IncomingMessage, res);
54
+ req.end(JSON.stringify({}));
55
+ await p;
56
+ expect((res as unknown as MockRes).statusCode).toBe(400);
57
+ });
58
+
59
+ it("untracks run under Vitest (abort skipped)", async () => {
60
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
61
+ const res = new MockRes() as unknown as ServerResponse;
62
+ const spyUntrack = vi.spyOn(sseEmitter, "untrackRun").mockImplementation(() => {});
63
+ const p = handleCancel(req as unknown as IncomingMessage, res);
64
+ req.end(JSON.stringify({ runId: "run-1" }));
65
+ await p;
66
+ expect(spyUntrack).toHaveBeenCalledWith("run-1");
67
+ expect((res as unknown as MockRes).statusCode).toBe(200);
68
+ spyUntrack.mockRestore();
69
+ });
70
+ });