@app-vitals/agent-runtime 0.0.1 → 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/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +50 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for HttpChatTokenReporter.
|
|
3
|
+
*
|
|
4
|
+
* Strategy: spin up a real Bun.serve stub admin API, inject its URL, and verify
|
|
5
|
+
* the reporter POSTs the correct daily-usage payload — no global.fetch overrides,
|
|
6
|
+
* no mock.module(). Time is injected via a FixedClock so the date is deterministic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
formatDailyDate,
|
|
12
|
+
HttpChatTokenReporter,
|
|
13
|
+
NoopChatTokenReporter,
|
|
14
|
+
} from "./chat-token-reporter.ts";
|
|
15
|
+
import {
|
|
16
|
+
liveClaudeConfig,
|
|
17
|
+
type ModelUsage,
|
|
18
|
+
setLiveClaudeConfig,
|
|
19
|
+
type TokenUsage,
|
|
20
|
+
} from "./claude.ts";
|
|
21
|
+
import { FixedClock } from "./clock.ts";
|
|
22
|
+
|
|
23
|
+
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
interface CapturedRequest {
|
|
26
|
+
url: string;
|
|
27
|
+
method: string;
|
|
28
|
+
headers: Record<string, string>;
|
|
29
|
+
body: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface StubState {
|
|
33
|
+
captured: CapturedRequest[];
|
|
34
|
+
statusToReturn: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function startStubServer(
|
|
38
|
+
port: number,
|
|
39
|
+
state: StubState,
|
|
40
|
+
// biome-ignore lint/suspicious/noExplicitAny: Server type param varies by bun version
|
|
41
|
+
): ReturnType<typeof Bun.serve<any>> {
|
|
42
|
+
return Bun.serve({
|
|
43
|
+
port,
|
|
44
|
+
fetch: async (req) => {
|
|
45
|
+
const body = await req.json().catch(() => null);
|
|
46
|
+
state.captured.push({
|
|
47
|
+
url: req.url,
|
|
48
|
+
method: req.method,
|
|
49
|
+
headers: Object.fromEntries(req.headers.entries()),
|
|
50
|
+
body,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (state.statusToReturn >= 400) {
|
|
54
|
+
return new Response(JSON.stringify({ error: "stub error" }), {
|
|
55
|
+
status: state.statusToReturn,
|
|
56
|
+
headers: { "Content-Type": "application/json" },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return new Response(
|
|
60
|
+
JSON.stringify({ row: { id: "chat-token-daily-stub-1" } }),
|
|
61
|
+
{
|
|
62
|
+
status: 200,
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const USAGE: TokenUsage = {
|
|
71
|
+
input_tokens: 100,
|
|
72
|
+
output_tokens: 50,
|
|
73
|
+
cache_read_input_tokens: 20,
|
|
74
|
+
cache_creation_input_tokens: 10,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ─── formatDailyDate ──────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
describe("formatDailyDate", () => {
|
|
80
|
+
test("formats a Date as YYYY-MM-DD in the given timezone", () => {
|
|
81
|
+
const date = new Date("2026-01-15T12:00:00.000Z");
|
|
82
|
+
expect(formatDailyDate(date, "UTC")).toBe("2026-01-15");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("respects the timezone when crossing a day boundary", () => {
|
|
86
|
+
// 00:30 UTC on the 15th is still the 14th in US Pacific.
|
|
87
|
+
const date = new Date("2026-01-15T00:30:00.000Z");
|
|
88
|
+
expect(formatDailyDate(date, "America/Los_Angeles")).toBe("2026-01-14");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ─── HttpChatTokenReporter ────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
describe("HttpChatTokenReporter", () => {
|
|
95
|
+
// biome-ignore lint/suspicious/noExplicitAny: Server type param varies by bun version
|
|
96
|
+
let server: ReturnType<typeof Bun.serve<any>>;
|
|
97
|
+
let state: StubState;
|
|
98
|
+
let baseUrl: string;
|
|
99
|
+
const AGENT_ID = "agent-abc";
|
|
100
|
+
const API_KEY = "test-api-key";
|
|
101
|
+
const CLOCK = FixedClock(new Date("2026-01-15T12:00:00.000Z"));
|
|
102
|
+
let originalModel: string;
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
state = { captured: [], statusToReturn: 200 };
|
|
106
|
+
server = startStubServer(0, state);
|
|
107
|
+
baseUrl = `http://localhost:${server.port}`;
|
|
108
|
+
// liveClaudeConfig is a shared mutable module singleton (see claude.ts) —
|
|
109
|
+
// tests must not depend on the ambient ANTHROPIC_MODEL env var. Inject a
|
|
110
|
+
// model guaranteed to be present in pricing.ts's RATES table with a
|
|
111
|
+
// non-zero rate, and restore the original value in afterEach so this
|
|
112
|
+
// override doesn't leak into sibling test files sharing the same Bun
|
|
113
|
+
// test process.
|
|
114
|
+
originalModel = liveClaudeConfig.model;
|
|
115
|
+
setLiveClaudeConfig({ model: "claude-sonnet-4-6" });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(async () => {
|
|
119
|
+
await server.stop(true);
|
|
120
|
+
setLiveClaudeConfig({ model: originalModel });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
function makeReporter() {
|
|
124
|
+
return new HttpChatTokenReporter({
|
|
125
|
+
apiUrl: baseUrl,
|
|
126
|
+
agentId: AGENT_ID,
|
|
127
|
+
apiKey: API_KEY,
|
|
128
|
+
clock: CLOCK,
|
|
129
|
+
timeZone: "UTC",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test("recordSession POSTs to /agents/:agentId/chat-tokens/daily", async () => {
|
|
134
|
+
const reporter = makeReporter();
|
|
135
|
+
await reporter.recordSession(USAGE);
|
|
136
|
+
|
|
137
|
+
expect(state.captured).toHaveLength(1);
|
|
138
|
+
expect(state.captured[0].method).toBe("POST");
|
|
139
|
+
expect(state.captured[0].url).toContain(
|
|
140
|
+
`/agents/${AGENT_ID}/chat-tokens/daily`,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("recordSession sends the correct date and token fields in modelBreakdown", async () => {
|
|
145
|
+
const reporter = makeReporter();
|
|
146
|
+
await reporter.recordSession(USAGE);
|
|
147
|
+
|
|
148
|
+
const body = state.captured[0].body as Record<string, unknown>;
|
|
149
|
+
expect(body.date).toBe("2026-01-15");
|
|
150
|
+
expect(Array.isArray(body.modelBreakdown)).toBe(true);
|
|
151
|
+
const breakdown = body.modelBreakdown as Array<Record<string, unknown>>;
|
|
152
|
+
expect(breakdown.length).toBe(1);
|
|
153
|
+
expect(breakdown[0].inputTokens).toBe(100);
|
|
154
|
+
expect(breakdown[0].outputTokens).toBe(50);
|
|
155
|
+
expect(breakdown[0].cacheReadTokens).toBe(20);
|
|
156
|
+
expect(breakdown[0].cacheCreationTokens).toBe(10);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("recordSession sends modelBreakdown when modelUsage is provided", async () => {
|
|
160
|
+
const reporter = makeReporter();
|
|
161
|
+
const modelUsage: ModelUsage = {
|
|
162
|
+
"claude-sonnet-4-5": {
|
|
163
|
+
inputTokens: 80,
|
|
164
|
+
outputTokens: 40,
|
|
165
|
+
cacheReadInputTokens: 15,
|
|
166
|
+
cacheCreationInputTokens: 8,
|
|
167
|
+
costUSD: 0.006,
|
|
168
|
+
},
|
|
169
|
+
"claude-haiku-3-5": {
|
|
170
|
+
inputTokens: 20,
|
|
171
|
+
outputTokens: 10,
|
|
172
|
+
cacheReadInputTokens: 5,
|
|
173
|
+
cacheCreationInputTokens: 2,
|
|
174
|
+
costUSD: 0.0008,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
await reporter.recordSession(USAGE, undefined, modelUsage);
|
|
178
|
+
|
|
179
|
+
const body = state.captured[0].body as Record<string, unknown>;
|
|
180
|
+
expect(Array.isArray(body.modelBreakdown)).toBe(true);
|
|
181
|
+
const breakdown = body.modelBreakdown as Array<Record<string, unknown>>;
|
|
182
|
+
expect(breakdown.length).toBe(2);
|
|
183
|
+
const models = breakdown.map((b) => b.model).sort();
|
|
184
|
+
expect(models).toEqual(["claude-haiku-3-5", "claude-sonnet-4-5"]);
|
|
185
|
+
const byModel = Object.fromEntries(breakdown.map((b) => [b.model, b]));
|
|
186
|
+
// costUsd is passed through directly from the CLI's costUSD per model
|
|
187
|
+
expect(byModel["claude-sonnet-4-5"].costUsd).toBe(0.006);
|
|
188
|
+
expect(byModel["claude-haiku-3-5"].costUsd).toBe(0.0008);
|
|
189
|
+
expect(byModel["claude-sonnet-4-5"].inputTokens).toBe(80);
|
|
190
|
+
expect(byModel["claude-sonnet-4-5"].outputTokens).toBe(40);
|
|
191
|
+
expect(byModel["claude-sonnet-4-5"].cacheReadTokens).toBe(15);
|
|
192
|
+
expect(byModel["claude-sonnet-4-5"].cacheCreationTokens).toBe(8);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("recordSession uses live model as fallback when modelUsage is not provided", async () => {
|
|
196
|
+
const reporter = makeReporter();
|
|
197
|
+
await reporter.recordSession(USAGE);
|
|
198
|
+
|
|
199
|
+
const body = state.captured[0].body as Record<string, unknown>;
|
|
200
|
+
// When no modelUsage provided, modelBreakdown has a single entry using liveClaudeConfig.model
|
|
201
|
+
const breakdown = body.modelBreakdown as Array<Record<string, unknown>>;
|
|
202
|
+
expect(Array.isArray(breakdown)).toBe(true);
|
|
203
|
+
expect(breakdown.length).toBe(1);
|
|
204
|
+
// model field is set (to liveClaudeConfig.model)
|
|
205
|
+
expect(typeof breakdown[0].model).toBe("string");
|
|
206
|
+
expect((breakdown[0].model as string).length).toBeGreaterThan(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("recordSession sends costUsd in modelBreakdown from totalCostUsd when provided", async () => {
|
|
210
|
+
const reporter = makeReporter();
|
|
211
|
+
await reporter.recordSession(USAGE, 0.0099);
|
|
212
|
+
|
|
213
|
+
const body = state.captured[0].body as Record<string, unknown>;
|
|
214
|
+
const breakdown = body.modelBreakdown as Array<Record<string, unknown>>;
|
|
215
|
+
expect(breakdown[0].costUsd).toBe(0.0099);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("recordSession falls back to calculateCost in modelBreakdown when totalCostUsd not provided", async () => {
|
|
219
|
+
const reporter = makeReporter();
|
|
220
|
+
await reporter.recordSession(USAGE);
|
|
221
|
+
|
|
222
|
+
const body = state.captured[0].body as Record<string, unknown>;
|
|
223
|
+
const breakdown = body.modelBreakdown as Array<Record<string, unknown>>;
|
|
224
|
+
// Falls back to calculateCost(usage, liveClaudeConfig.model) — non-zero computed cost
|
|
225
|
+
expect(typeof breakdown[0].costUsd).toBe("number");
|
|
226
|
+
expect(breakdown[0].costUsd as number).toBeGreaterThan(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("recordSession sends Authorization: Bearer header", async () => {
|
|
230
|
+
const reporter = makeReporter();
|
|
231
|
+
await reporter.recordSession(USAGE);
|
|
232
|
+
|
|
233
|
+
expect(state.captured[0].headers.authorization).toBe(`Bearer ${API_KEY}`);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("recordSession(undefined) makes NO request (no-op)", async () => {
|
|
237
|
+
const reporter = makeReporter();
|
|
238
|
+
await reporter.recordSession(undefined);
|
|
239
|
+
|
|
240
|
+
expect(state.captured).toHaveLength(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("recordSession swallows network errors (does not throw)", async () => {
|
|
244
|
+
const reporter = new HttpChatTokenReporter({
|
|
245
|
+
apiUrl: "http://localhost:19999", // nothing listening
|
|
246
|
+
agentId: AGENT_ID,
|
|
247
|
+
apiKey: API_KEY,
|
|
248
|
+
clock: CLOCK,
|
|
249
|
+
timeZone: "UTC",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await expect(reporter.recordSession(USAGE)).resolves.toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("recordSession swallows HTTP error responses (does not throw)", async () => {
|
|
256
|
+
state.statusToReturn = 500;
|
|
257
|
+
const reporter = makeReporter();
|
|
258
|
+
|
|
259
|
+
await expect(reporter.recordSession(USAGE)).resolves.toBeUndefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("recordSession swallows HTTP 4xx responses (does not throw)", async () => {
|
|
263
|
+
state.statusToReturn = 400;
|
|
264
|
+
const reporter = makeReporter();
|
|
265
|
+
|
|
266
|
+
await expect(reporter.recordSession(USAGE)).resolves.toBeUndefined();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ─── NoopChatTokenReporter ────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
describe("NoopChatTokenReporter", () => {
|
|
273
|
+
test("recordSession resolves immediately and does not throw", async () => {
|
|
274
|
+
const reporter = new NoopChatTokenReporter();
|
|
275
|
+
await expect(reporter.recordSession(USAGE)).resolves.toBeUndefined();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("recordSession(undefined) resolves and does not throw", async () => {
|
|
279
|
+
const reporter = new NoopChatTokenReporter();
|
|
280
|
+
await expect(reporter.recordSession(undefined)).resolves.toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatTokenReporter — reports per-session Slack chat token usage to the admin
|
|
3
|
+
* daily-upsert endpoint: POST /agents/:agentId/chat-tokens/daily.
|
|
4
|
+
*
|
|
5
|
+
* Each completed Slack session (DM, mention, or reaction) POSTs its accumulated
|
|
6
|
+
* token counts broken down by model; the admin endpoint accumulates rows
|
|
7
|
+
* atomically per (agent, date, model).
|
|
8
|
+
*
|
|
9
|
+
* HttpChatTokenReporter: production implementation, fire-and-forget (never throws).
|
|
10
|
+
* NoopChatTokenReporter: testing / default when the admin API is not configured.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ModelUsage, TokenUsage } from "./claude.ts";
|
|
14
|
+
import { liveClaudeConfig } from "./claude.ts";
|
|
15
|
+
import { type Clock, SystemClock } from "./clock.ts";
|
|
16
|
+
import { calculateCost } from "./pricing.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Format a Date as YYYY-MM-DD in an IANA timezone.
|
|
20
|
+
*
|
|
21
|
+
* Uses the `en-CA` locale, which renders dates as YYYY-MM-DD. When `timeZone`
|
|
22
|
+
* is undefined, the process-local zone (the "agent timezone") is used.
|
|
23
|
+
*/
|
|
24
|
+
export function formatDailyDate(date: Date, timeZone?: string): string {
|
|
25
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
26
|
+
timeZone,
|
|
27
|
+
year: "numeric",
|
|
28
|
+
month: "2-digit",
|
|
29
|
+
day: "2-digit",
|
|
30
|
+
}).format(date);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ChatTokenReporter {
|
|
34
|
+
/** Called at Slack session completion. No-op when usage is undefined. */
|
|
35
|
+
recordSession(
|
|
36
|
+
usage: TokenUsage | undefined,
|
|
37
|
+
totalCostUsd?: number,
|
|
38
|
+
modelUsage?: ModelUsage,
|
|
39
|
+
): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface HttpChatTokenReporterOptions {
|
|
43
|
+
apiUrl: string;
|
|
44
|
+
agentId: string;
|
|
45
|
+
apiKey: string;
|
|
46
|
+
clock?: Clock;
|
|
47
|
+
timeZone?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class HttpChatTokenReporter implements ChatTokenReporter {
|
|
51
|
+
private readonly clock: Clock;
|
|
52
|
+
private readonly timeZone?: string;
|
|
53
|
+
|
|
54
|
+
constructor(private opts: HttpChatTokenReporterOptions) {
|
|
55
|
+
this.clock = opts.clock ?? SystemClock();
|
|
56
|
+
this.timeZone = opts.timeZone;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async recordSession(
|
|
60
|
+
usage: TokenUsage | undefined,
|
|
61
|
+
totalCostUsd?: number,
|
|
62
|
+
modelUsage?: ModelUsage,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
if (usage === undefined) return;
|
|
65
|
+
|
|
66
|
+
const { apiUrl, agentId, apiKey } = this.opts;
|
|
67
|
+
const url = `${apiUrl}/agents/${agentId}/chat-tokens/daily`;
|
|
68
|
+
|
|
69
|
+
const date = formatDailyDate(this.clock.now(), this.timeZone);
|
|
70
|
+
|
|
71
|
+
// Build modelBreakdown from modelUsage when provided.
|
|
72
|
+
// When modelUsage is absent, fall back to a single entry using the live model.
|
|
73
|
+
let modelBreakdown: Array<{
|
|
74
|
+
model: string;
|
|
75
|
+
inputTokens: number;
|
|
76
|
+
outputTokens: number;
|
|
77
|
+
cacheReadTokens: number;
|
|
78
|
+
cacheCreationTokens: number;
|
|
79
|
+
costUsd: number;
|
|
80
|
+
}>;
|
|
81
|
+
|
|
82
|
+
if (modelUsage && Object.keys(modelUsage).length > 0) {
|
|
83
|
+
modelBreakdown = Object.entries(modelUsage).map(([model, mu]) => ({
|
|
84
|
+
model,
|
|
85
|
+
inputTokens: mu.inputTokens,
|
|
86
|
+
outputTokens: mu.outputTokens,
|
|
87
|
+
cacheReadTokens: mu.cacheReadInputTokens,
|
|
88
|
+
cacheCreationTokens: mu.cacheCreationInputTokens,
|
|
89
|
+
costUsd: mu.costUSD,
|
|
90
|
+
}));
|
|
91
|
+
} else {
|
|
92
|
+
modelBreakdown = [
|
|
93
|
+
{
|
|
94
|
+
model: liveClaudeConfig.model,
|
|
95
|
+
inputTokens: usage.input_tokens,
|
|
96
|
+
outputTokens: usage.output_tokens,
|
|
97
|
+
cacheReadTokens: usage.cache_read_input_tokens,
|
|
98
|
+
cacheCreationTokens: usage.cache_creation_input_tokens,
|
|
99
|
+
costUsd: totalCostUsd ?? calculateCost(usage, liveClaudeConfig.model),
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const body = { date, modelBreakdown };
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetch(url, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
Authorization: `Bearer ${apiKey}`,
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify(body),
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
console.warn(
|
|
117
|
+
`[chat-token-reporter] POST ${url} returned ${res.status} — swallowing`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.warn(
|
|
122
|
+
`[chat-token-reporter] POST ${url} failed: ${String(err)} — swallowing`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class NoopChatTokenReporter implements ChatTokenReporter {
|
|
129
|
+
async recordSession(
|
|
130
|
+
_usage: TokenUsage | undefined,
|
|
131
|
+
_totalCostUsd?: number,
|
|
132
|
+
_modelUsage?: ModelUsage,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
// intentional no-op
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects the installed Claude Code CLI version once at agent startup so it can
|
|
3
|
+
* be logged and surfaced in /health. Fail-soft: any exec failure, non-semver
|
|
4
|
+
* output, or timeout resolves to "unknown" — never throws, never blocks startup
|
|
5
|
+
* beyond the bounded timeout.
|
|
6
|
+
*
|
|
7
|
+
* The exec is injected (no mock.module in tests); `bunExec` is the real one.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const UNKNOWN_CLAUDE_CODE_VERSION = "unknown";
|
|
11
|
+
|
|
12
|
+
/** Upper bound on how long `claude --version` may delay startup. */
|
|
13
|
+
const CLAUDE_VERSION_TIMEOUT_MS = 5_000;
|
|
14
|
+
|
|
15
|
+
/** Runs a command and resolves to its stdout; rejects on non-zero exit. */
|
|
16
|
+
type VersionExec = (cmd: string[]) => Promise<string>;
|
|
17
|
+
|
|
18
|
+
const SEMVER_RE = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/;
|
|
19
|
+
|
|
20
|
+
/** Extracts the first semver from `claude --version` output (e.g. "2.1.236 (Claude Code)"). */
|
|
21
|
+
export function parseClaudeVersion(output: string): string | null {
|
|
22
|
+
return SEMVER_RE.exec(output)?.[1] ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function detectClaudeCodeVersion(
|
|
26
|
+
exec: VersionExec,
|
|
27
|
+
timeoutMs: number = CLAUDE_VERSION_TIMEOUT_MS,
|
|
28
|
+
): Promise<string> {
|
|
29
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
30
|
+
const timeout = new Promise<null>((resolve) => {
|
|
31
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
const output = await Promise.race([
|
|
35
|
+
exec(["claude", "--version"]).catch(() => null),
|
|
36
|
+
timeout,
|
|
37
|
+
]);
|
|
38
|
+
if (output === null) return UNKNOWN_CLAUDE_CODE_VERSION;
|
|
39
|
+
return parseClaudeVersion(output) ?? UNKNOWN_CLAUDE_CODE_VERSION;
|
|
40
|
+
} catch {
|
|
41
|
+
return UNKNOWN_CLAUDE_CODE_VERSION;
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const bunExec: VersionExec = async (cmd) => {
|
|
48
|
+
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" });
|
|
49
|
+
const [out, code] = await Promise.all([
|
|
50
|
+
new Response(proc.stdout).text(),
|
|
51
|
+
proc.exited,
|
|
52
|
+
]);
|
|
53
|
+
if (code !== 0) throw new Error(`${cmd[0]} exited with code ${code}`);
|
|
54
|
+
return out;
|
|
55
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for agent/src/runtime/claude-version.ts — injected exec, no module mocks.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
detectClaudeCodeVersion,
|
|
8
|
+
parseClaudeVersion,
|
|
9
|
+
UNKNOWN_CLAUDE_CODE_VERSION,
|
|
10
|
+
} from "./claude-version.ts";
|
|
11
|
+
|
|
12
|
+
describe("parseClaudeVersion", () => {
|
|
13
|
+
it("parses the CLI's '<semver> (Claude Code)' output", () => {
|
|
14
|
+
expect(parseClaudeVersion("2.1.236 (Claude Code)\n")).toBe("2.1.236");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("parses a bare semver", () => {
|
|
18
|
+
expect(parseClaudeVersion("2.1.236")).toBe("2.1.236");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps a prerelease suffix", () => {
|
|
22
|
+
expect(parseClaudeVersion("2.2.0-beta.1 (Claude Code)")).toBe(
|
|
23
|
+
"2.2.0-beta.1",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns null when no semver is present", () => {
|
|
28
|
+
expect(parseClaudeVersion("")).toBeNull();
|
|
29
|
+
expect(parseClaudeVersion("command not found")).toBeNull();
|
|
30
|
+
expect(parseClaudeVersion("v2.1")).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("detectClaudeCodeVersion", () => {
|
|
35
|
+
it("runs `claude --version` and returns the parsed version", async () => {
|
|
36
|
+
const calls: string[][] = [];
|
|
37
|
+
const version = await detectClaudeCodeVersion(async (cmd) => {
|
|
38
|
+
calls.push(cmd);
|
|
39
|
+
return "2.1.236 (Claude Code)\n";
|
|
40
|
+
});
|
|
41
|
+
expect(version).toBe("2.1.236");
|
|
42
|
+
expect(calls).toEqual([["claude", "--version"]]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns 'unknown' when exec rejects", async () => {
|
|
46
|
+
const version = await detectClaudeCodeVersion(async () => {
|
|
47
|
+
throw new Error("ENOENT");
|
|
48
|
+
});
|
|
49
|
+
expect(version).toBe(UNKNOWN_CLAUDE_CODE_VERSION);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns 'unknown' when exec throws synchronously", async () => {
|
|
53
|
+
const version = await detectClaudeCodeVersion(() => {
|
|
54
|
+
throw new Error("spawn failed");
|
|
55
|
+
});
|
|
56
|
+
expect(version).toBe(UNKNOWN_CLAUDE_CODE_VERSION);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns 'unknown' when output has no semver", async () => {
|
|
60
|
+
const version = await detectClaudeCodeVersion(async () => "garbage");
|
|
61
|
+
expect(version).toBe(UNKNOWN_CLAUDE_CODE_VERSION);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns 'unknown' within the timeout when exec hangs", async () => {
|
|
65
|
+
const start = Date.now();
|
|
66
|
+
const version = await detectClaudeCodeVersion(
|
|
67
|
+
() => new Promise<string>(() => {}),
|
|
68
|
+
50,
|
|
69
|
+
);
|
|
70
|
+
expect(version).toBe(UNKNOWN_CLAUDE_CODE_VERSION);
|
|
71
|
+
expect(Date.now() - start).toBeLessThan(1_000);
|
|
72
|
+
});
|
|
73
|
+
});
|