@syengup/friday-channel-next 0.0.45 → 0.1.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/dist/index.js +10 -0
- package/dist/src/agent/run-usage-accumulator.d.ts +13 -0
- package/dist/src/agent/run-usage-accumulator.js +58 -0
- package/dist/src/friday-session.js +84 -15
- package/dist/src/health/self-health.d.ts +39 -0
- package/dist/src/health/self-health.js +174 -0
- package/dist/src/http/handlers/health.d.ts +23 -0
- package/dist/src/http/handlers/health.js +225 -0
- package/dist/src/http/server.js +5 -0
- package/dist/src/run-metadata.d.ts +6 -0
- package/dist/src/run-metadata.js +24 -1
- package/index.ts +16 -0
- package/install.js +17 -1
- package/package.json +11 -10
- package/src/agent/run-usage-accumulator.ts +70 -0
- package/src/friday-session.forward-agent.test.ts +100 -33
- package/src/friday-session.ts +78 -16
- package/src/http/handlers/health.test.ts +515 -0
- package/src/http/handlers/health.ts +289 -0
- package/src/http/server.ts +6 -0
- package/src/run-metadata.ts +28 -1
package/dist/src/http/server.js
CHANGED
|
@@ -15,6 +15,7 @@ import { handleSessionsDelete } from "./handlers/sessions-delete.js";
|
|
|
15
15
|
import { handleSessionsSettings } from "./handlers/sessions-settings.js";
|
|
16
16
|
import { handleModelsList } from "./handlers/models-list.js";
|
|
17
17
|
import { handleStatus } from "./handlers/status.js";
|
|
18
|
+
import { handleHealth } from "./handlers/health.js";
|
|
18
19
|
import { applyCorsHeaders } from "./middleware/cors.js";
|
|
19
20
|
import { resolveFridayNextConfig } from "../config.js";
|
|
20
21
|
import { getHostOpenClawConfigSnapshot } from "../host-config.js";
|
|
@@ -67,6 +68,10 @@ async function handleFridayNextRoute(req, res) {
|
|
|
67
68
|
if (req.method === "GET" && pathname === "/friday-next/status") {
|
|
68
69
|
return await handleStatus(req, res);
|
|
69
70
|
}
|
|
71
|
+
// Route: GET /friday-next/health?deviceId=...&nodeDeviceId=...&selfHeal=true
|
|
72
|
+
if (req.method === "GET" && pathname === "/friday-next/health") {
|
|
73
|
+
return await handleHealth(req, res);
|
|
74
|
+
}
|
|
70
75
|
// Not found
|
|
71
76
|
return false;
|
|
72
77
|
}
|
|
@@ -5,11 +5,17 @@ type RunRoute = {
|
|
|
5
5
|
};
|
|
6
6
|
export type RunMetadata = {
|
|
7
7
|
modelName?: string;
|
|
8
|
+
modelProvider?: string;
|
|
8
9
|
totalTokens?: number;
|
|
9
10
|
/** Tokens counted toward the model context window (prompt-side: input + cache read + cache write when present). */
|
|
10
11
|
contextTokensUsed?: number;
|
|
11
12
|
/** Resolved model context window limit when the runtime exposes it. */
|
|
12
13
|
contextWindowMax?: number;
|
|
14
|
+
/** Detailed token breakdown captured from agent event usage (current run, not stale store read). */
|
|
15
|
+
inputTokens?: number;
|
|
16
|
+
outputTokens?: number;
|
|
17
|
+
cacheReadTokens?: number;
|
|
18
|
+
cacheWriteTokens?: number;
|
|
13
19
|
};
|
|
14
20
|
/** Vitest / harness: clears per-run metadata and final-delivered flags (not routes). */
|
|
15
21
|
export declare function resetRunMetadataForTest(): void;
|
package/dist/src/run-metadata.js
CHANGED
|
@@ -106,6 +106,11 @@ export function ingestAgentEventMetadata(runId, data) {
|
|
|
106
106
|
undefined;
|
|
107
107
|
if (modelName)
|
|
108
108
|
next.modelName = modelName;
|
|
109
|
+
const modelProvider = (typeof data.modelProvider === "string" && data.modelProvider.trim()) ||
|
|
110
|
+
(typeof data.provider === "string" && data.provider.trim()) ||
|
|
111
|
+
undefined;
|
|
112
|
+
if (modelProvider)
|
|
113
|
+
next.modelProvider = modelProvider;
|
|
109
114
|
const usage = recordValue(data.usage);
|
|
110
115
|
const totalTokens = finiteNumber(data.totalTokens) ??
|
|
111
116
|
finiteNumber(data.total_tokens) ??
|
|
@@ -115,6 +120,19 @@ export function ingestAgentEventMetadata(runId, data) {
|
|
|
115
120
|
if (typeof totalTokens === "number" && totalTokens > 0) {
|
|
116
121
|
next.totalTokens = Math.floor(totalTokens);
|
|
117
122
|
}
|
|
123
|
+
const usageForTokens = usage ?? data;
|
|
124
|
+
const input = pickInputTokens(usageForTokens);
|
|
125
|
+
if (typeof input === "number" && input >= 0)
|
|
126
|
+
next.inputTokens = Math.floor(input);
|
|
127
|
+
const output = pickOutputTokens(usageForTokens);
|
|
128
|
+
if (typeof output === "number" && output >= 0)
|
|
129
|
+
next.outputTokens = Math.floor(output);
|
|
130
|
+
const cacheRead = pickCacheRead(usageForTokens);
|
|
131
|
+
if (typeof cacheRead === "number" && cacheRead >= 0)
|
|
132
|
+
next.cacheReadTokens = Math.floor(cacheRead);
|
|
133
|
+
const cacheWrite = pickCacheWrite(usageForTokens);
|
|
134
|
+
if (typeof cacheWrite === "number" && cacheWrite >= 0)
|
|
135
|
+
next.cacheWriteTokens = Math.floor(cacheWrite);
|
|
118
136
|
const usageForContext = usage ?? data;
|
|
119
137
|
const ctxUsed = contextTokensFromUsageRecord(usageForContext);
|
|
120
138
|
if (typeof ctxUsed === "number" && ctxUsed > 0) {
|
|
@@ -131,9 +149,14 @@ export function ingestAgentEventMetadata(runId, data) {
|
|
|
131
149
|
}
|
|
132
150
|
}
|
|
133
151
|
if (next.modelName ||
|
|
152
|
+
next.modelProvider ||
|
|
134
153
|
typeof next.totalTokens === "number" ||
|
|
135
154
|
typeof next.contextTokensUsed === "number" ||
|
|
136
|
-
typeof next.contextWindowMax === "number"
|
|
155
|
+
typeof next.contextWindowMax === "number" ||
|
|
156
|
+
typeof next.inputTokens === "number" ||
|
|
157
|
+
typeof next.outputTokens === "number" ||
|
|
158
|
+
typeof next.cacheReadTokens === "number" ||
|
|
159
|
+
typeof next.cacheWriteTokens === "number") {
|
|
137
160
|
setRunMetadata(runId, next);
|
|
138
161
|
}
|
|
139
162
|
}
|
package/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "./src/friday-session.js";
|
|
17
17
|
import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
|
|
18
18
|
import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
|
|
19
|
+
import { accumulateRunUsage } from "./src/agent/run-usage-accumulator.js";
|
|
19
20
|
|
|
20
21
|
export { fridayNextChannelPlugin } from "./src/channel.js";
|
|
21
22
|
export { setFridayNextRuntime } from "./src/runtime.js";
|
|
@@ -103,6 +104,21 @@ export default defineChannelPluginEntry({
|
|
|
103
104
|
});
|
|
104
105
|
});
|
|
105
106
|
|
|
107
|
+
api.on("llm_output", (event: any) => {
|
|
108
|
+
accumulateRunUsage(
|
|
109
|
+
event.runId,
|
|
110
|
+
{
|
|
111
|
+
input: event.usage?.input,
|
|
112
|
+
output: event.usage?.output,
|
|
113
|
+
cacheRead: event.usage?.cacheRead,
|
|
114
|
+
cacheWrite: event.usage?.cacheWrite,
|
|
115
|
+
total: event.usage?.total,
|
|
116
|
+
},
|
|
117
|
+
event.model,
|
|
118
|
+
event.provider,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
106
122
|
if (fridayNextToolHooksRegistered) {
|
|
107
123
|
return;
|
|
108
124
|
}
|
package/install.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execSync } from "node:child_process";
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
4
4
|
import { homedir, networkInterfaces } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
|
|
@@ -102,6 +102,13 @@ try {
|
|
|
102
102
|
if (out.trim()) console.log(out.trim());
|
|
103
103
|
installed = true;
|
|
104
104
|
log("Plugin registered with install record — auto-upgrade enabled.");
|
|
105
|
+
|
|
106
|
+
// Remove old manual install to avoid "duplicate plugin id" warning.
|
|
107
|
+
const legacyDir = join(USER_HOME, ".openclaw", "extensions", "friday-channel-next");
|
|
108
|
+
if (existsSync(legacyDir)) {
|
|
109
|
+
try { rmSync(legacyDir, { recursive: true, force: true }); log("Removed legacy manual install."); }
|
|
110
|
+
catch { /* non-critical */ }
|
|
111
|
+
}
|
|
105
112
|
} catch (e) {
|
|
106
113
|
const msg = (e.stderr || e.stdout || e.message || "").toString();
|
|
107
114
|
warn("openclaw plugins install failed: " + msg.trim().split("\n").pop());
|
|
@@ -118,6 +125,11 @@ if (!installed) {
|
|
|
118
125
|
}
|
|
119
126
|
warn("Manual install complete, but auto-upgrade is NOT available.");
|
|
120
127
|
warn("To enable auto-upgrade later, run: openclaw plugins install @syengup/friday-channel-next");
|
|
128
|
+
// Clean up legacy dir even in fallback to avoid duplicate warnings
|
|
129
|
+
if (existsSync(join(USER_HOME, ".openclaw", "extensions", "friday-channel-next"))) {
|
|
130
|
+
warn("Legacy install detected. Remove it to avoid duplicate warnings:");
|
|
131
|
+
warn(" rm -rf ~/.openclaw/extensions/friday-channel-next");
|
|
132
|
+
}
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
// --------------- configure OpenClaw ---------------
|
|
@@ -167,6 +179,10 @@ for (const id of ["friday-next", "canvas"]) {
|
|
|
167
179
|
else if (!config.plugins.entries[id].enabled) { config.plugins.entries[id].enabled = true; configChanged = true; }
|
|
168
180
|
}
|
|
169
181
|
|
|
182
|
+
// llm_output hook requires allowConversationAccess for non-bundled plugins.
|
|
183
|
+
if (!config.plugins.entries["friday-next"].hooks) { config.plugins.entries["friday-next"].hooks = {}; configChanged = true; }
|
|
184
|
+
if (!config.plugins.entries["friday-next"].hooks.allowConversationAccess) { config.plugins.entries["friday-next"].hooks.allowConversationAccess = true; configChanged = true; }
|
|
185
|
+
|
|
170
186
|
// Channel
|
|
171
187
|
if (!config.channels) config.channels = {};
|
|
172
188
|
if (!config.channels["friday-next"]) { config.channels["friday-next"] = { enabled: true, transport: "http+sse" }; configChanged = true; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syengup/friday-channel-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "OpenClaw Friday Next Apple channel plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
"tsconfig.json",
|
|
12
12
|
"openclaw.plugin.json"
|
|
13
13
|
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json",
|
|
16
|
+
"prepublishOnly": "pnpm build",
|
|
17
|
+
"test": "npm run test:unit && npm run test:e2e",
|
|
18
|
+
"test:unit": "vitest run",
|
|
19
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
20
|
+
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
21
|
+
"test:msg-live": "node scripts/message-roundtrip-live.mjs"
|
|
22
|
+
},
|
|
14
23
|
"bin": {
|
|
15
24
|
"friday-channel-next": "install.js"
|
|
16
25
|
},
|
|
@@ -56,13 +65,5 @@
|
|
|
56
65
|
"typescript": "^6.0.3",
|
|
57
66
|
"vitest": "^4.1.5",
|
|
58
67
|
"zod": "^4.3.6"
|
|
59
|
-
},
|
|
60
|
-
"scripts": {
|
|
61
|
-
"build": "tsc -p tsconfig.json",
|
|
62
|
-
"test": "npm run test:unit && npm run test:e2e",
|
|
63
|
-
"test:unit": "vitest run",
|
|
64
|
-
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
65
|
-
"test:smoke": "node scripts/e2e-smoke.mjs",
|
|
66
|
-
"test:msg-live": "node scripts/message-roundtrip-live.mjs"
|
|
67
68
|
}
|
|
68
|
-
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { FridaySessionUsagePayload } from "../session-usage-snapshot.js";
|
|
2
|
+
|
|
3
|
+
type UsageFields = {
|
|
4
|
+
input?: number;
|
|
5
|
+
output?: number;
|
|
6
|
+
cacheRead?: number;
|
|
7
|
+
cacheWrite?: number;
|
|
8
|
+
total?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type AccumulatedUsage = {
|
|
12
|
+
input: number;
|
|
13
|
+
output: number;
|
|
14
|
+
cacheRead: number;
|
|
15
|
+
cacheWrite: number;
|
|
16
|
+
total: number;
|
|
17
|
+
model?: string;
|
|
18
|
+
provider?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const usageByRunId = new Map<string, AccumulatedUsage>();
|
|
22
|
+
|
|
23
|
+
function ensure(runId: string): AccumulatedUsage {
|
|
24
|
+
let entry = usageByRunId.get(runId);
|
|
25
|
+
if (!entry) {
|
|
26
|
+
entry = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
|
|
27
|
+
usageByRunId.set(runId, entry);
|
|
28
|
+
}
|
|
29
|
+
return entry;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function accumulateRunUsage(
|
|
33
|
+
runId: string,
|
|
34
|
+
usage: UsageFields,
|
|
35
|
+
model?: string,
|
|
36
|
+
provider?: string,
|
|
37
|
+
): void {
|
|
38
|
+
if (!runId.trim()) return;
|
|
39
|
+
const entry = ensure(runId);
|
|
40
|
+
if (typeof usage.input === "number" && usage.input > 0) entry.input += usage.input;
|
|
41
|
+
if (typeof usage.output === "number" && usage.output > 0) entry.output += usage.output;
|
|
42
|
+
if (typeof usage.cacheRead === "number" && usage.cacheRead > 0) entry.cacheRead += usage.cacheRead;
|
|
43
|
+
if (typeof usage.cacheWrite === "number" && usage.cacheWrite > 0) entry.cacheWrite += usage.cacheWrite;
|
|
44
|
+
if (typeof usage.total === "number" && usage.total > 0) entry.total += usage.total;
|
|
45
|
+
if (model && model.trim()) entry.model = model.trim();
|
|
46
|
+
if (provider && provider.trim()) entry.provider = provider.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function consumeRunUsage(runId: string): FridaySessionUsagePayload | undefined {
|
|
50
|
+
const entry = usageByRunId.get(runId);
|
|
51
|
+
if (!entry) return undefined;
|
|
52
|
+
usageByRunId.delete(runId);
|
|
53
|
+
const tokens: NonNullable<FridaySessionUsagePayload["tokens"]> = {};
|
|
54
|
+
if (entry.input > 0) tokens.input = entry.input;
|
|
55
|
+
if (entry.output > 0) tokens.output = entry.output;
|
|
56
|
+
if (entry.cacheRead > 0) tokens.cacheRead = entry.cacheRead;
|
|
57
|
+
if (entry.cacheWrite > 0) tokens.cacheWrite = entry.cacheWrite;
|
|
58
|
+
if (entry.total > 0) tokens.total = entry.total;
|
|
59
|
+
tokens.totalFresh = true;
|
|
60
|
+
if (Object.keys(tokens).length === 1) return undefined; // only totalFresh, no actual tokens
|
|
61
|
+
const payload: FridaySessionUsagePayload = { tokens };
|
|
62
|
+
if (entry.model) payload.modelId = entry.model;
|
|
63
|
+
if (entry.provider) payload.modelProvider = entry.provider;
|
|
64
|
+
return payload;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Vitest-only. */
|
|
68
|
+
export function resetRunUsageAccumulatorForTest(): void {
|
|
69
|
+
usageByRunId.clear();
|
|
70
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resetThinkingStreamAccumStateForTest,
|
|
11
11
|
} from "./friday-session.js";
|
|
12
12
|
import { resetRunMetadataForTest } from "./run-metadata.js";
|
|
13
|
+
import { accumulateRunUsage, resetRunUsageAccumulatorForTest } from "./agent/run-usage-accumulator.js";
|
|
13
14
|
import { sseEmitter } from "./sse/emitter.js";
|
|
14
15
|
import { toSessionStoreKey } from "./session/session-manager.js";
|
|
15
16
|
|
|
@@ -24,6 +25,7 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
24
25
|
resetOpenClawRunDeviceMappingForTest();
|
|
25
26
|
resetFridayAgentForwardRuntimeForTest();
|
|
26
27
|
resetRunMetadataForTest();
|
|
28
|
+
resetRunUsageAccumulatorForTest();
|
|
27
29
|
registerFridaySessionDeviceMapping(sessionKey, deviceId);
|
|
28
30
|
vi.spyOn(sseEmitter, "broadcastToRun").mockImplementation(() => {});
|
|
29
31
|
});
|
|
@@ -198,38 +200,107 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
198
200
|
expect("reasoningPrefixChars" in (payload.data as object)).toBe(false);
|
|
199
201
|
});
|
|
200
202
|
|
|
201
|
-
it("
|
|
203
|
+
it("builds sessionUsage from llm_output accumulated usage on lifecycle end", () => {
|
|
204
|
+
// Simulate llm_output hook accumulating usage across API calls.
|
|
205
|
+
accumulateRunUsage(runId, { input: 100, output: 50, cacheRead: 10, total: 150 }, "my-model", "openai");
|
|
206
|
+
accumulateRunUsage(runId, { input: 30, output: 10, cacheRead: 0, total: 40 }, "my-model", "openai");
|
|
207
|
+
|
|
208
|
+
forwardAgentEventRaw({
|
|
209
|
+
runId,
|
|
210
|
+
seq: 1,
|
|
211
|
+
stream: "lifecycle",
|
|
212
|
+
sessionKey,
|
|
213
|
+
data: { phase: "end" },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Lifecycle events are synchronous now (no file I/O wait).
|
|
217
|
+
expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
|
|
218
|
+
const forwarded = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data;
|
|
219
|
+
expect(forwarded.stream).toBe("lifecycle");
|
|
220
|
+
const sessionUsage = (forwarded.data as Record<string, unknown>).sessionUsage as Record<string, unknown>;
|
|
221
|
+
expect(sessionUsage).toBeDefined();
|
|
222
|
+
expect(sessionUsage.modelId).toBe("my-model");
|
|
223
|
+
expect(sessionUsage.modelProvider).toBe("openai");
|
|
224
|
+
// Accumulated totals across both API calls.
|
|
225
|
+
expect((sessionUsage.tokens as Record<string, unknown>).input).toBe(130);
|
|
226
|
+
expect((sessionUsage.tokens as Record<string, unknown>).output).toBe(60);
|
|
227
|
+
expect((sessionUsage.tokens as Record<string, unknown>).cacheRead).toBe(10);
|
|
228
|
+
expect((sessionUsage.tokens as Record<string, unknown>).total).toBe(190);
|
|
229
|
+
expect((sessionUsage.tokens as Record<string, unknown>).totalFresh).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("merges llm_output usage with RunMetadata for sessionUsage on lifecycle end", () => {
|
|
233
|
+
// Simulate llm_output hook.
|
|
234
|
+
accumulateRunUsage(runId, { input: 500, output: 100, cacheRead: 200, cacheWrite: 0, total: 800 }, "llm-model", "llm-provider");
|
|
235
|
+
|
|
236
|
+
// Send an agent event that populates RunMetadata (model, context window).
|
|
237
|
+
forwardAgentEventRaw({
|
|
238
|
+
runId,
|
|
239
|
+
seq: 1,
|
|
240
|
+
stream: "assistant",
|
|
241
|
+
sessionKey,
|
|
242
|
+
data: {
|
|
243
|
+
model: "agent-model",
|
|
244
|
+
provider: "agent-provider",
|
|
245
|
+
usage: { input: 999, total: 999 },
|
|
246
|
+
contextWindow: 100000,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
forwardAgentEventRaw({
|
|
251
|
+
runId,
|
|
252
|
+
seq: 2,
|
|
253
|
+
stream: "lifecycle",
|
|
254
|
+
sessionKey,
|
|
255
|
+
data: { phase: "end" },
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Assistant (1st) + lifecycle.end (2nd, synchronous).
|
|
259
|
+
expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(2);
|
|
260
|
+
const lifecycleCall = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[1];
|
|
261
|
+
const lifecycleData = (lifecycleCall[1] as { data: { data: Record<string, unknown> } }).data.data;
|
|
262
|
+
const sessionUsage = lifecycleData.sessionUsage as Record<string, unknown> | undefined;
|
|
263
|
+
expect(sessionUsage).toBeDefined();
|
|
264
|
+
// llm_output tokens win (authoritative per-API-call data).
|
|
265
|
+
expect(sessionUsage!.modelId).toBe("llm-model");
|
|
266
|
+
expect(sessionUsage!.modelProvider).toBe("llm-provider");
|
|
267
|
+
expect((sessionUsage!.tokens as Record<string, unknown>).input).toBe(500);
|
|
268
|
+
expect((sessionUsage!.tokens as Record<string, unknown>).output).toBe(100);
|
|
269
|
+
expect((sessionUsage!.tokens as Record<string, unknown>).cacheRead).toBe(200);
|
|
270
|
+
expect((sessionUsage!.tokens as Record<string, unknown>).total).toBe(800);
|
|
271
|
+
// Context from RunMetadata (not available from llm_output).
|
|
272
|
+
expect((sessionUsage!.context as Record<string, unknown>).windowMax).toBe(100000);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("falls back to store read when llm_output has no data (deferred)", async () => {
|
|
202
276
|
const storeKey = toSessionStoreKey(sessionKey);
|
|
203
277
|
const store: Record<string, Record<string, unknown>> = {
|
|
204
278
|
[storeKey]: {
|
|
205
|
-
model: "
|
|
206
|
-
modelProvider: "
|
|
207
|
-
inputTokens:
|
|
208
|
-
outputTokens:
|
|
209
|
-
totalTokens:
|
|
279
|
+
model: "store-model",
|
|
280
|
+
modelProvider: "store-provider",
|
|
281
|
+
inputTokens: 200,
|
|
282
|
+
outputTokens: 80,
|
|
283
|
+
totalTokens: 5000,
|
|
210
284
|
totalTokensFresh: true,
|
|
211
|
-
contextTokens:
|
|
212
|
-
estimatedCostUsd: 0.
|
|
213
|
-
cacheRead:
|
|
285
|
+
contextTokens: 64000,
|
|
286
|
+
estimatedCostUsd: 0.05,
|
|
287
|
+
cacheRead: 20,
|
|
214
288
|
cacheWrite: 0,
|
|
215
289
|
},
|
|
216
290
|
};
|
|
217
|
-
|
|
218
|
-
const mockApi = {
|
|
291
|
+
setFridayAgentForwardRuntime({
|
|
219
292
|
runtime: {
|
|
220
|
-
config: {
|
|
221
|
-
current: () => ({ session: {} }),
|
|
222
|
-
},
|
|
293
|
+
config: { current: () => ({ session: {} }) },
|
|
223
294
|
agent: {
|
|
224
295
|
session: {
|
|
225
296
|
resolveStorePath: () => "/tmp/sessions.json",
|
|
226
|
-
loadSessionStore,
|
|
297
|
+
loadSessionStore: vi.fn(() => store),
|
|
227
298
|
},
|
|
228
299
|
},
|
|
229
300
|
},
|
|
230
|
-
};
|
|
231
|
-
setFridayAgentForwardRuntime(mockApi as never);
|
|
301
|
+
} as never);
|
|
232
302
|
|
|
303
|
+
// No llm_output data accumulated — store is the only token source.
|
|
233
304
|
forwardAgentEventRaw({
|
|
234
305
|
runId,
|
|
235
306
|
seq: 1,
|
|
@@ -237,28 +308,24 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
|
|
|
237
308
|
sessionKey,
|
|
238
309
|
data: { phase: "end" },
|
|
239
310
|
});
|
|
311
|
+
// Deferred: not broadcast yet (setTimeout 100ms hasn't fired).
|
|
240
312
|
expect(sseEmitter.broadcastToRun).not.toHaveBeenCalled();
|
|
241
313
|
|
|
242
|
-
await new Promise<void>((resolve) =>
|
|
314
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 150));
|
|
243
315
|
|
|
244
316
|
expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
|
|
245
317
|
const forwarded = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data;
|
|
246
318
|
expect(forwarded.stream).toBe("lifecycle");
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
},
|
|
258
|
-
context: { windowMax: 128000, used: 9999 },
|
|
259
|
-
estimatedCostUsd: 0.01,
|
|
260
|
-
});
|
|
261
|
-
expect(loadSessionStore).toHaveBeenCalledWith("/tmp/sessions.json", { skipCache: true });
|
|
319
|
+
const sessionUsage = (forwarded.data as Record<string, unknown>).sessionUsage as Record<string, unknown>;
|
|
320
|
+
expect(sessionUsage).toBeDefined();
|
|
321
|
+
expect(sessionUsage.modelId).toBe("store-model");
|
|
322
|
+
expect(sessionUsage.modelProvider).toBe("store-provider");
|
|
323
|
+
expect((sessionUsage.tokens as Record<string, unknown>).input).toBe(200);
|
|
324
|
+
expect((sessionUsage.tokens as Record<string, unknown>).output).toBe(80);
|
|
325
|
+
expect((sessionUsage.tokens as Record<string, unknown>).total).toBe(5000);
|
|
326
|
+
expect((sessionUsage.tokens as Record<string, unknown>).totalFresh).toBe(true);
|
|
327
|
+
expect((sessionUsage.context as Record<string, unknown>).windowMax).toBe(64000);
|
|
328
|
+
expect(sessionUsage.estimatedCostUsd).toBe(0.05);
|
|
262
329
|
});
|
|
263
330
|
});
|
|
264
331
|
|
package/src/friday-session.ts
CHANGED
|
@@ -4,7 +4,9 @@ import { toSessionStoreKey } from "./session/session-manager.js";
|
|
|
4
4
|
import { getOpenClawAgentRunContext } from "./agent-run-context-bridge.js";
|
|
5
5
|
import { observeAgentEventForActiveRuns } from "./agent/active-runs.js";
|
|
6
6
|
import { getRunMetadata, ingestAgentEventMetadata } from "./run-metadata.js";
|
|
7
|
+
import { consumeRunUsage } from "./agent/run-usage-accumulator.js";
|
|
7
8
|
import { buildSessionUsageSnapshot } from "./session-usage-snapshot.js";
|
|
9
|
+
import type { FridaySessionUsagePayload } from "./session-usage-snapshot.js";
|
|
8
10
|
import {
|
|
9
11
|
lookupByRunId,
|
|
10
12
|
registerSessionKeyForRun,
|
|
@@ -178,7 +180,7 @@ function mergeRunMetadataIntoLifecycleEnd(
|
|
|
178
180
|
return { ...base, ...extra };
|
|
179
181
|
}
|
|
180
182
|
|
|
181
|
-
function tryReadSessionUsageFromStore(sessionKeyForStore: string):
|
|
183
|
+
function tryReadSessionUsageFromStore(sessionKeyForStore: string): FridaySessionUsagePayload | undefined {
|
|
182
184
|
const access = getFridayAgentForwardRuntime();
|
|
183
185
|
if (!access) return undefined;
|
|
184
186
|
try {
|
|
@@ -195,6 +197,51 @@ function tryReadSessionUsageFromStore(sessionKeyForStore: string): ReturnType<ty
|
|
|
195
197
|
}
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
function buildSessionUsageFromRunMetadata(runId: string): FridaySessionUsagePayload | undefined {
|
|
201
|
+
const meta = getRunMetadata(runId);
|
|
202
|
+
if (!meta) return undefined;
|
|
203
|
+
const payload: FridaySessionUsagePayload = {};
|
|
204
|
+
if (typeof meta.modelName === "string" && meta.modelName.trim()) {
|
|
205
|
+
payload.modelId = meta.modelName.trim();
|
|
206
|
+
}
|
|
207
|
+
if (typeof meta.modelProvider === "string" && meta.modelProvider.trim()) {
|
|
208
|
+
payload.modelProvider = meta.modelProvider.trim();
|
|
209
|
+
}
|
|
210
|
+
const tokens: NonNullable<typeof payload.tokens> = {};
|
|
211
|
+
if (typeof meta.inputTokens === "number") tokens.input = meta.inputTokens;
|
|
212
|
+
if (typeof meta.outputTokens === "number") tokens.output = meta.outputTokens;
|
|
213
|
+
if (typeof meta.cacheReadTokens === "number") tokens.cacheRead = meta.cacheReadTokens;
|
|
214
|
+
if (typeof meta.cacheWriteTokens === "number") tokens.cacheWrite = meta.cacheWriteTokens;
|
|
215
|
+
if (typeof meta.totalTokens === "number") tokens.total = meta.totalTokens;
|
|
216
|
+
if (Object.keys(tokens).length > 0) payload.tokens = tokens;
|
|
217
|
+
const context: NonNullable<typeof payload.context> = {};
|
|
218
|
+
if (typeof meta.contextWindowMax === "number") context.windowMax = meta.contextWindowMax;
|
|
219
|
+
if (typeof meta.totalTokens === "number") context.used = meta.totalTokens;
|
|
220
|
+
if (Object.keys(context).length > 0) payload.context = context;
|
|
221
|
+
if (!payload.modelId && !payload.modelProvider && !payload.tokens && !payload.context) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
return payload;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function mergeUsage(
|
|
228
|
+
llmUsage: FridaySessionUsagePayload | undefined,
|
|
229
|
+
memUsage: FridaySessionUsagePayload | undefined,
|
|
230
|
+
): FridaySessionUsagePayload | undefined {
|
|
231
|
+
if (!llmUsage && !memUsage) return undefined;
|
|
232
|
+
if (!llmUsage) return memUsage;
|
|
233
|
+
if (!memUsage) return llmUsage;
|
|
234
|
+
// llm_output tokens are authoritative (per API call, no race);
|
|
235
|
+
// RunMetadata fills context window gaps.
|
|
236
|
+
return {
|
|
237
|
+
modelId: llmUsage.modelId ?? memUsage.modelId,
|
|
238
|
+
modelProvider: llmUsage.modelProvider ?? memUsage.modelProvider,
|
|
239
|
+
tokens: llmUsage.tokens,
|
|
240
|
+
context: memUsage.context ?? llmUsage.context,
|
|
241
|
+
estimatedCostUsd: llmUsage.estimatedCostUsd ?? memUsage.estimatedCostUsd,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
198
245
|
function completeAgentEventForward(params: {
|
|
199
246
|
evt: ForwardAgentEventArgs;
|
|
200
247
|
sk: string;
|
|
@@ -436,23 +483,38 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
|
|
|
436
483
|
}
|
|
437
484
|
}
|
|
438
485
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
486
|
+
// Build sessionUsage: llm_output hook (primary, no race) → store read (fallback).
|
|
487
|
+
if (isTerminalLifecycle) {
|
|
488
|
+
const llmUsage = consumeRunUsage(evt.runId);
|
|
489
|
+
const memUsage = buildSessionUsageFromRunMetadata(evt.runId);
|
|
490
|
+
const hasRealTokens = llmUsage?.tokens && Object.keys(llmUsage.tokens).length > 1;
|
|
491
|
+
|
|
492
|
+
if (hasRealTokens) {
|
|
493
|
+
const usage = mergeUsage(llmUsage, memUsage);
|
|
443
494
|
if (usage) {
|
|
444
|
-
|
|
495
|
+
outgoingData = { ...outgoingData, sessionUsage: usage };
|
|
445
496
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
497
|
+
} else if (getFridayAgentForwardRuntime()) {
|
|
498
|
+
// llm_output hook fires async ~20ms after lifecycle.end.
|
|
499
|
+
// Wait 100ms then re-check before falling back to store read.
|
|
500
|
+
setTimeout(() => {
|
|
501
|
+
let data = outgoingData;
|
|
502
|
+
const retryLlm = consumeRunUsage(evt.runId);
|
|
503
|
+
const usage = mergeUsage(retryLlm, memUsage) ?? tryReadSessionUsageFromStore(sk);
|
|
504
|
+
if (usage) {
|
|
505
|
+
data = { ...outgoingData, sessionUsage: usage };
|
|
506
|
+
}
|
|
507
|
+
completeAgentEventForward({
|
|
508
|
+
evt,
|
|
509
|
+
sk,
|
|
510
|
+
deviceIdRaw,
|
|
511
|
+
outgoingData: data,
|
|
512
|
+
isTerminalLifecycle: true,
|
|
513
|
+
subagentMeta,
|
|
514
|
+
});
|
|
515
|
+
}, 100);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
456
518
|
}
|
|
457
519
|
|
|
458
520
|
completeAgentEventForward({
|