@syengup/friday-channel-next 0.0.46 → 0.1.2

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.
@@ -0,0 +1,225 @@
1
+ import { listDevicePairing, approveDevicePairing } from "openclaw/plugin-sdk/device-bootstrap";
2
+ import { extractBearerToken } from "../middleware/auth.js";
3
+ import { loadNodePairingModule } from "../../agent/node-pairing-bridge.js";
4
+ import { createFridayNextLogger } from "../../logging.js";
5
+ const REQUIRED_NODE_CAPS = ["location", "canvas"];
6
+ const REQUIRED_NODE_COMMANDS = [
7
+ "location.get",
8
+ "canvas.present",
9
+ "canvas.hide",
10
+ "canvas.navigate",
11
+ "canvas.eval",
12
+ "canvas.snapshot",
13
+ "canvas.a2ui.push",
14
+ "canvas.a2ui.pushJSONL",
15
+ "canvas.a2ui.reset",
16
+ ];
17
+ export async function handleHealth(req, res) {
18
+ if (req.method !== "GET") {
19
+ res.statusCode = 405;
20
+ res.setHeader("Content-Type", "application/json");
21
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
22
+ return true;
23
+ }
24
+ const token = extractBearerToken(req);
25
+ if (!token) {
26
+ res.statusCode = 401;
27
+ res.setHeader("Content-Type", "application/json");
28
+ res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
29
+ return true;
30
+ }
31
+ const url = new URL(req.url ?? "/", "http://localhost");
32
+ const deviceId = (url.searchParams.get("deviceId") ?? "").trim();
33
+ const nodeDeviceId = (url.searchParams.get("nodeDeviceId") ?? "").trim();
34
+ const selfHeal = (url.searchParams.get("selfHeal") ?? "").toLowerCase() === "true";
35
+ const result = {
36
+ ok: true,
37
+ timestamp: Date.now(),
38
+ deviceId,
39
+ nodeDeviceId,
40
+ };
41
+ const log = createFridayNextLogger("health");
42
+ if (deviceId) {
43
+ result.devicePairing = await checkDevicePairing(deviceId, selfHeal, result, log);
44
+ }
45
+ if (nodeDeviceId) {
46
+ result.nodePairing = await checkNodePairing(nodeDeviceId, selfHeal, result, log);
47
+ }
48
+ const statuses = [
49
+ result.devicePairing?.status,
50
+ result.nodePairing?.status,
51
+ ].filter(Boolean);
52
+ result.ok = statuses.length === 0 || statuses.every((s) => s === "ok" || s === "pending");
53
+ res.statusCode = 200;
54
+ res.setHeader("Content-Type", "application/json");
55
+ res.end(JSON.stringify(result));
56
+ return true;
57
+ }
58
+ async function checkDevicePairing(deviceId, selfHeal, result, log) {
59
+ const normalizedDeviceId = deviceId.trim().toUpperCase();
60
+ let pairing;
61
+ try {
62
+ pairing = await listDevicePairing();
63
+ }
64
+ catch (err) {
65
+ log.error(`listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
66
+ return {
67
+ status: "failed",
68
+ detail: `listDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`,
69
+ devicePaired: false,
70
+ };
71
+ }
72
+ const pairedDevice = (pairing?.paired ?? []).find((entry) => entry.deviceId?.trim().toUpperCase() === normalizedDeviceId);
73
+ if (pairedDevice) {
74
+ const approvedScopes = pairedDevice.approvedScopes ?? [];
75
+ const tokens = pairedDevice.tokens ?? {};
76
+ const hasValidToken = Object.values(tokens).some((t) => !t.revokedAtMs);
77
+ if (approvedScopes.length === 0 || !hasValidToken) {
78
+ const issues = [];
79
+ if (approvedScopes.length === 0)
80
+ issues.push("no approved scopes");
81
+ if (!hasValidToken)
82
+ issues.push("all tokens revoked");
83
+ return {
84
+ status: "degraded",
85
+ detail: `Device paired but degraded: ${issues.join(", ")}`,
86
+ devicePaired: true,
87
+ approvedScopesEmpty: approvedScopes.length === 0,
88
+ tokensRevoked: !hasValidToken,
89
+ };
90
+ }
91
+ return { status: "ok", detail: "Device paired and healthy", devicePaired: true };
92
+ }
93
+ const pendingDevice = (pairing?.pending ?? []).find((entry) => entry.deviceId?.trim().toUpperCase() === normalizedDeviceId);
94
+ if (pendingDevice && selfHeal) {
95
+ try {
96
+ const approved = await approveDevicePairing(pendingDevice.requestId);
97
+ const succeeded = approved && approved.status === "approved";
98
+ (result.repairActions ??= []).push({
99
+ component: "devicePairing",
100
+ action: "approveDevicePairing",
101
+ result: succeeded ? "ok" : "failed",
102
+ detail: succeeded
103
+ ? `Auto-approved device ${normalizedDeviceId}`
104
+ : `approveDevicePairing returned status=${approved?.status ?? "null"}`,
105
+ });
106
+ if (succeeded) {
107
+ log.info(`Auto-approved device ${normalizedDeviceId}`);
108
+ return { status: "ok", detail: "Device was pending, auto-approved", devicePaired: true };
109
+ }
110
+ }
111
+ catch (err) {
112
+ log.error(`approveDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
113
+ (result.repairActions ??= []).push({
114
+ component: "devicePairing",
115
+ action: "approveDevicePairing",
116
+ result: "failed",
117
+ detail: err instanceof Error ? err.message : String(err),
118
+ });
119
+ }
120
+ return {
121
+ status: "degraded",
122
+ detail: "Device pending but auto-approve failed",
123
+ devicePaired: false,
124
+ };
125
+ }
126
+ if (pendingDevice) {
127
+ return { status: "pending", detail: "Device is pending approval", devicePaired: false };
128
+ }
129
+ return { status: "not_found", detail: `Device ${normalizedDeviceId} not registered`, devicePaired: false };
130
+ }
131
+ async function checkNodePairing(nodeDeviceId, selfHeal, result, log) {
132
+ const normalizedNodeId = nodeDeviceId.trim().toUpperCase();
133
+ let listData, listNodePairing, approveNodePairing;
134
+ try {
135
+ ({ listNodePairing, approveNodePairing } = await loadNodePairingModule());
136
+ }
137
+ catch (err) {
138
+ log.error(`loadNodePairingModule failed: ${err instanceof Error ? err.message : String(err)}`);
139
+ return {
140
+ status: "failed",
141
+ detail: `loadNodePairingModule failed: ${err instanceof Error ? err.message : String(err)}`,
142
+ nodePaired: false,
143
+ };
144
+ }
145
+ try {
146
+ listData = await listNodePairing();
147
+ }
148
+ catch (err) {
149
+ log.error(`listNodePairing failed: ${err instanceof Error ? err.message : String(err)}`);
150
+ return {
151
+ status: "failed",
152
+ detail: `listNodePairing failed: ${err instanceof Error ? err.message : String(err)}`,
153
+ nodePaired: false,
154
+ };
155
+ }
156
+ const pairedNodes = listData?.paired ?? [];
157
+ const pairedMatch = pairedNodes.find((entry) => entry.nodeId?.trim().toUpperCase() === normalizedNodeId);
158
+ if (pairedMatch) {
159
+ const caps = pairedMatch.caps ?? [];
160
+ const commands = pairedMatch.commands ?? [];
161
+ const hasRequiredCaps = REQUIRED_NODE_CAPS.every((c) => caps.includes(c));
162
+ const hasRequiredCommands = REQUIRED_NODE_COMMANDS.every((c) => commands.includes(c));
163
+ const capsValid = caps.length > 0 && hasRequiredCaps;
164
+ const commandsValid = commands.length > 0 && hasRequiredCommands;
165
+ if (capsValid && commandsValid) {
166
+ return {
167
+ status: "ok",
168
+ detail: `Node paired with ${caps.length} caps, ${commands.length} commands`,
169
+ nodePaired: true,
170
+ capsCount: caps.length,
171
+ commandsCount: commands.length,
172
+ capsValid: true,
173
+ commandsValid: true,
174
+ };
175
+ }
176
+ return {
177
+ status: "degraded",
178
+ detail: `Node paired but caps/commands incomplete: caps=${caps.length} (valid=${capsValid}), commands=${commands.length} (valid=${commandsValid})`,
179
+ nodePaired: true,
180
+ capsCount: caps.length,
181
+ commandsCount: commands.length,
182
+ capsValid,
183
+ commandsValid,
184
+ };
185
+ }
186
+ const pendingNodes = listData?.pending ?? [];
187
+ const pendingMatch = pendingNodes.find((entry) => entry.nodeId?.trim().toUpperCase() === normalizedNodeId);
188
+ if (pendingMatch && selfHeal) {
189
+ try {
190
+ const callerScopes = ["operator.admin", "operator.pairing", "operator.read", "operator.write"];
191
+ const approved = await approveNodePairing(pendingMatch.requestId, { callerScopes });
192
+ const succeeded = approved != null && !("status" in approved && approved.status === "forbidden") && "requestId" in approved;
193
+ (result.repairActions ??= []).push({
194
+ component: "nodePairing",
195
+ action: "approveNodePairing",
196
+ result: succeeded ? "ok" : "failed",
197
+ detail: succeeded
198
+ ? `Auto-approved node ${normalizedNodeId}`
199
+ : `approveNodePairing returned status=${approved?.status ?? "null"}`,
200
+ });
201
+ if (succeeded) {
202
+ log.info(`Auto-approved node ${normalizedNodeId}`);
203
+ return { status: "ok", detail: "Node was pending, auto-approved", nodePaired: true };
204
+ }
205
+ }
206
+ catch (err) {
207
+ log.error(`approveNodePairing failed: ${err instanceof Error ? err.message : String(err)}`);
208
+ (result.repairActions ??= []).push({
209
+ component: "nodePairing",
210
+ action: "approveNodePairing",
211
+ result: "failed",
212
+ detail: err instanceof Error ? err.message : String(err),
213
+ });
214
+ }
215
+ return {
216
+ status: "degraded",
217
+ detail: "Node pending but auto-approve failed",
218
+ nodePaired: false,
219
+ };
220
+ }
221
+ if (pendingMatch) {
222
+ return { status: "pending", detail: "Node is pending approval", nodePaired: false };
223
+ }
224
+ return { status: "not_found", detail: `Node ${normalizedNodeId} not registered`, nodePaired: false };
225
+ }
@@ -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;
@@ -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
@@ -179,6 +179,10 @@ for (const id of ["friday-next", "canvas"]) {
179
179
  else if (!config.plugins.entries[id].enabled) { config.plugins.entries[id].enabled = true; configChanged = true; }
180
180
  }
181
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
+
182
186
  // Channel
183
187
  if (!config.channels) config.channels = {};
184
188
  if (!config.channels["friday-next"]) { config.channels["friday-next"] = { enabled: true, transport: "http+sse" }; configChanged = true; }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.0.46",
3
+ "version": "0.1.2",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "files": [
7
8
  "dist/",
@@ -11,6 +12,15 @@
11
12
  "tsconfig.json",
12
13
  "openclaw.plugin.json"
13
14
  ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json",
17
+ "prepublishOnly": "pnpm build",
18
+ "test": "npm run test:unit && npm run test:e2e",
19
+ "test:unit": "vitest run",
20
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
21
+ "test:smoke": "node scripts/e2e-smoke.mjs",
22
+ "test:msg-live": "node scripts/message-roundtrip-live.mjs"
23
+ },
14
24
  "bin": {
15
25
  "friday-channel-next": "install.js"
16
26
  },
@@ -56,13 +66,5 @@
56
66
  "typescript": "^6.0.3",
57
67
  "vitest": "^4.1.5",
58
68
  "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
69
  }
68
- }
70
+ }
@@ -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("merges sessionUsage from session store on lifecycle end after persist (deferred)", async () => {
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: "my-model",
206
- modelProvider: "openai",
207
- inputTokens: 100,
208
- outputTokens: 50,
209
- totalTokens: 9999,
279
+ model: "store-model",
280
+ modelProvider: "store-provider",
281
+ inputTokens: 200,
282
+ outputTokens: 80,
283
+ totalTokens: 5000,
210
284
  totalTokensFresh: true,
211
- contextTokens: 128000,
212
- estimatedCostUsd: 0.01,
213
- cacheRead: 10,
285
+ contextTokens: 64000,
286
+ estimatedCostUsd: 0.05,
287
+ cacheRead: 20,
214
288
  cacheWrite: 0,
215
289
  },
216
290
  };
217
- const loadSessionStore = vi.fn(() => store);
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) => setImmediate(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
- 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 });
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