@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.
- package/LICENSE +21 -0
- 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 +4 -0
- package/package.json +12 -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
|
@@ -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
|
+
}
|
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
|
@@ -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.
|
|
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("
|
|
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
|
|