@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
package/piper-voice.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/piper-voice.ts
|
|
3
|
+
*
|
|
4
|
+
* Discovery-based validation for the PIPER_VOICE config value, plus a
|
|
5
|
+
* resolver that turns a validated voice name into the baked `.onnx` /
|
|
6
|
+
* `.onnx.json` pair path.
|
|
7
|
+
*
|
|
8
|
+
* Piper voices are baked into the image at VOICES_DIR — a hardcoded
|
|
9
|
+
* constant, not env-configurable. Voice selection must fail loudly at
|
|
10
|
+
* startup (log the requested value and everything actually discovered)
|
|
11
|
+
* rather than silently at speak time, so this module owns that check.
|
|
12
|
+
* Actual Piper synthesis is a future task (PPR-1.2); this module only
|
|
13
|
+
* validates + resolves paths.
|
|
14
|
+
*
|
|
15
|
+
* The directory scan mirrors the non-throwing, injectable-readdir pattern
|
|
16
|
+
* used by discoverBakedMarketplaces (agent/src/runtime/setup.ts) and scanReposDir
|
|
17
|
+
* (agent/src/coding/check-helpers.ts): readdirSync wrapped in try/catch, empty
|
|
18
|
+
* array on failure, and the readdir function itself is swappable so unit
|
|
19
|
+
* tests never touch the real filesystem.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readdirSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
/** Default voice used when PIPER_VOICE is unset. Must be present in every baked image. */
|
|
26
|
+
export const DEFAULT_PIPER_VOICE = "en_US-hfc_female-medium";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Baked voices directory. Hardcoded, not env-configurable — voice
|
|
30
|
+
* availability is an IMAGE property (mirrors BAKED_MARKETPLACES_ROOT in
|
|
31
|
+
* setup.ts).
|
|
32
|
+
*/
|
|
33
|
+
export const VOICES_DIR = "/app/agent/voices/";
|
|
34
|
+
|
|
35
|
+
type ReaddirFn = (dir: string) => string[];
|
|
36
|
+
|
|
37
|
+
const defaultReaddirFn: ReaddirFn = (dir: string): string[] => {
|
|
38
|
+
try {
|
|
39
|
+
return readdirSync(dir);
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Scans `dir` for Piper voice files and returns the distinct base voice
|
|
47
|
+
* names found (each voice ships as a `<name>.onnx` + `<name>.onnx.json`
|
|
48
|
+
* pair — this collapses both into one name). Non-throwing.
|
|
49
|
+
*/
|
|
50
|
+
function discoverVoices(dir: string, readdirFn: ReaddirFn): string[] {
|
|
51
|
+
let entries: string[];
|
|
52
|
+
try {
|
|
53
|
+
entries = readdirFn(dir);
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const names = new Set<string>();
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (entry.endsWith(".onnx.json")) {
|
|
61
|
+
names.add(entry.slice(0, -".onnx.json".length));
|
|
62
|
+
} else if (entry.endsWith(".onnx")) {
|
|
63
|
+
names.add(entry.slice(0, -".onnx".length));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [...names];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validates the requested PIPER_VOICE against the discovered baked voices
|
|
71
|
+
* directory and returns the voice name to use.
|
|
72
|
+
*
|
|
73
|
+
* - PIPER_VOICE unset -> returns DEFAULT_PIPER_VOICE.
|
|
74
|
+
* - PIPER_VOICE set and found in the directory listing -> returns it as-is.
|
|
75
|
+
* - PIPER_VOICE set but NOT found -> logs loudly (naming both the requested
|
|
76
|
+
* voice and every voice actually discovered) and falls back to
|
|
77
|
+
* DEFAULT_PIPER_VOICE. Never throws — a startup misconfiguration here
|
|
78
|
+
* must not crash the agent.
|
|
79
|
+
*/
|
|
80
|
+
export function validatePiperVoice(
|
|
81
|
+
requestedVoice: string | undefined,
|
|
82
|
+
readdirFn: ReaddirFn = defaultReaddirFn,
|
|
83
|
+
): string {
|
|
84
|
+
const discovered = discoverVoices(VOICES_DIR, readdirFn);
|
|
85
|
+
|
|
86
|
+
if (requestedVoice === undefined) {
|
|
87
|
+
return DEFAULT_PIPER_VOICE;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (discovered.includes(requestedVoice)) {
|
|
91
|
+
return requestedVoice;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.warn(
|
|
95
|
+
`[piper-voice] PIPER_VOICE "${requestedVoice}" was not found in ${VOICES_DIR}. ` +
|
|
96
|
+
`Discovered voices: ${discovered.length > 0 ? discovered.join(", ") : "(none)"}. ` +
|
|
97
|
+
`Falling back to default voice "${DEFAULT_PIPER_VOICE}".`,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return DEFAULT_PIPER_VOICE;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolves a validated voice name into its baked `.onnx` / `.onnx.json`
|
|
105
|
+
* pair path. Centralizes directory logic so a future synthesizePiper
|
|
106
|
+
* (PPR-1.2) doesn't duplicate it.
|
|
107
|
+
*/
|
|
108
|
+
export function resolvePiperVoicePaths(voiceName: string): {
|
|
109
|
+
onnxPath: string;
|
|
110
|
+
onnxJsonPath: string;
|
|
111
|
+
} {
|
|
112
|
+
return {
|
|
113
|
+
onnxPath: join(VOICES_DIR, `${voiceName}.onnx`),
|
|
114
|
+
onnxJsonPath: join(VOICES_DIR, `${voiceName}.onnx.json`),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for agent/src/runtime/piper-voice.ts
|
|
3
|
+
*
|
|
4
|
+
* Strategy: inject a fake readdirFn — no real filesystem I/O (agent/src test
|
|
5
|
+
* isolation rule). Spy on console.warn/console.error by swapping the bound
|
|
6
|
+
* method on the console object itself (not a global override) and restoring
|
|
7
|
+
* it after each assertion.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_PIPER_VOICE,
|
|
13
|
+
resolvePiperVoicePaths,
|
|
14
|
+
VOICES_DIR,
|
|
15
|
+
validatePiperVoice,
|
|
16
|
+
} from "./piper-voice.ts";
|
|
17
|
+
|
|
18
|
+
let warnCalls: unknown[][] = [];
|
|
19
|
+
let errorCalls: unknown[][] = [];
|
|
20
|
+
let originalWarn: typeof console.warn;
|
|
21
|
+
let originalError: typeof console.error;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
warnCalls = [];
|
|
25
|
+
errorCalls = [];
|
|
26
|
+
originalWarn = console.warn;
|
|
27
|
+
originalError = console.error;
|
|
28
|
+
console.warn = (...args: unknown[]) => {
|
|
29
|
+
warnCalls.push(args);
|
|
30
|
+
};
|
|
31
|
+
console.error = (...args: unknown[]) => {
|
|
32
|
+
errorCalls.push(args);
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
console.warn = originalWarn;
|
|
38
|
+
console.error = originalError;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const makeReaddirFn = (files: string[]) => (_dir: string) => files;
|
|
42
|
+
|
|
43
|
+
describe("validatePiperVoice", () => {
|
|
44
|
+
test("defaults to en_US-hfc_female-medium when PIPER_VOICE is unset", () => {
|
|
45
|
+
const readdirFn = makeReaddirFn([
|
|
46
|
+
"en_US-hfc_female-medium.onnx",
|
|
47
|
+
"en_US-hfc_female-medium.onnx.json",
|
|
48
|
+
]);
|
|
49
|
+
const result = validatePiperVoice(undefined, readdirFn);
|
|
50
|
+
expect(result).toBe(DEFAULT_PIPER_VOICE);
|
|
51
|
+
expect(result).toBe("en_US-hfc_female-medium");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns the requested voice when present in the discovered directory listing", () => {
|
|
55
|
+
const readdirFn = makeReaddirFn([
|
|
56
|
+
"en_US-hfc_female-medium.onnx",
|
|
57
|
+
"en_US-hfc_female-medium.onnx.json",
|
|
58
|
+
"en_GB-alan-medium.onnx",
|
|
59
|
+
"en_GB-alan-medium.onnx.json",
|
|
60
|
+
]);
|
|
61
|
+
const result = validatePiperVoice("en_GB-alan-medium", readdirFn);
|
|
62
|
+
expect(result).toBe("en_GB-alan-medium");
|
|
63
|
+
// No warnings/errors on a hit.
|
|
64
|
+
expect(warnCalls.length).toBe(0);
|
|
65
|
+
expect(errorCalls.length).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("falls back and logs loudly when the requested voice is not discovered", () => {
|
|
69
|
+
const readdirFn = makeReaddirFn([
|
|
70
|
+
"en_US-hfc_female-medium.onnx",
|
|
71
|
+
"en_US-hfc_female-medium.onnx.json",
|
|
72
|
+
"en_GB-alan-medium.onnx",
|
|
73
|
+
"en_GB-alan-medium.onnx.json",
|
|
74
|
+
]);
|
|
75
|
+
const result = validatePiperVoice("nonexistent-voice", readdirFn);
|
|
76
|
+
|
|
77
|
+
// Process must not crash — a usable voice name is returned.
|
|
78
|
+
expect(typeof result).toBe("string");
|
|
79
|
+
|
|
80
|
+
const logged = [...warnCalls, ...errorCalls];
|
|
81
|
+
expect(logged.length).toBeGreaterThan(0);
|
|
82
|
+
const allLoggedText = logged.map((call) => call.join(" ")).join("\n");
|
|
83
|
+
expect(allLoggedText).toContain("nonexistent-voice");
|
|
84
|
+
expect(allLoggedText).toContain("en_US-hfc_female-medium");
|
|
85
|
+
expect(allLoggedText).toContain("en_GB-alan-medium");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("does not throw when the directory scan fails (readdirFn throws)", () => {
|
|
89
|
+
const throwingReaddirFn = (_dir: string): string[] => {
|
|
90
|
+
throw new Error("ENOENT: no such directory");
|
|
91
|
+
};
|
|
92
|
+
expect(() =>
|
|
93
|
+
validatePiperVoice("en_US-hfc_female-medium", throwingReaddirFn),
|
|
94
|
+
).not.toThrow();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("uses the hardcoded VOICES_DIR constant, not an env-configurable path", () => {
|
|
98
|
+
let receivedDir: string | undefined;
|
|
99
|
+
const readdirFn = (dir: string) => {
|
|
100
|
+
receivedDir = dir;
|
|
101
|
+
return ["en_US-hfc_female-medium.onnx"];
|
|
102
|
+
};
|
|
103
|
+
validatePiperVoice(undefined, readdirFn);
|
|
104
|
+
expect(receivedDir).toBe(VOICES_DIR);
|
|
105
|
+
expect(VOICES_DIR).toBe("/app/agent/voices/");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("resolvePiperVoicePaths", () => {
|
|
110
|
+
test("returns the .onnx/.onnx.json pair path for a voice name", () => {
|
|
111
|
+
const { onnxPath, onnxJsonPath } = resolvePiperVoicePaths(
|
|
112
|
+
"en_US-hfc_female-medium",
|
|
113
|
+
);
|
|
114
|
+
expect(onnxPath).toBe(`${VOICES_DIR}en_US-hfc_female-medium.onnx`);
|
|
115
|
+
expect(onnxJsonPath).toBe(`${VOICES_DIR}en_US-hfc_female-medium.onnx.json`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("resolves paths for a different voice name", () => {
|
|
119
|
+
const { onnxPath, onnxJsonPath } =
|
|
120
|
+
resolvePiperVoicePaths("en_GB-alan-medium");
|
|
121
|
+
expect(onnxPath).toBe(`${VOICES_DIR}en_GB-alan-medium.onnx`);
|
|
122
|
+
expect(onnxJsonPath).toBe(`${VOICES_DIR}en_GB-alan-medium.onnx.json`);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/pricing.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for agent/src/runtime/pricing.ts — calculateCost()
|
|
3
|
+
*
|
|
4
|
+
* Pure function — no side effects, no mocks needed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import type { TokenUsage } from "./claude.ts";
|
|
9
|
+
import { calculateCost } from "./pricing.ts";
|
|
10
|
+
|
|
11
|
+
const SAMPLE_USAGE: TokenUsage = {
|
|
12
|
+
input_tokens: 100,
|
|
13
|
+
output_tokens: 200,
|
|
14
|
+
cache_read_input_tokens: 50,
|
|
15
|
+
cache_creation_input_tokens: 10,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const ZERO_USAGE: TokenUsage = {
|
|
19
|
+
input_tokens: 0,
|
|
20
|
+
output_tokens: 0,
|
|
21
|
+
cache_read_input_tokens: 0,
|
|
22
|
+
cache_creation_input_tokens: 0,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ─── unknown model ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe("unknown model", () => {
|
|
28
|
+
test("returns 0 for an unrecognised model ID", () => {
|
|
29
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-sonnet-4-5")).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns 0 for empty string model ID", () => {
|
|
33
|
+
expect(calculateCost(SAMPLE_USAGE, "")).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("returns 0 for zero usage with unknown model", () => {
|
|
37
|
+
expect(calculateCost(ZERO_USAGE, "gpt-4")).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ─── claude-sonnet-4-6 ($3.00 input, $15.00 output) ──────────────────────────
|
|
42
|
+
|
|
43
|
+
describe("claude-sonnet-4-6", () => {
|
|
44
|
+
test("returns correct USD for SAMPLE_USAGE", () => {
|
|
45
|
+
// (100 * 3.00 + 200 * 15.00 + 10 * 3.00 * 1.25 + 50 * 3.00 * 0.1) / 1_000_000
|
|
46
|
+
// = (300 + 3000 + 37.5 + 15) / 1_000_000 = 0.0033525
|
|
47
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-sonnet-4-6")).toBe(0.0033525);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("returns 0 for zero usage", () => {
|
|
51
|
+
expect(calculateCost(ZERO_USAGE, "claude-sonnet-4-6")).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ─── claude-haiku-4-5 ($1.00 input, $5.00 output) ────────────────────────────
|
|
56
|
+
|
|
57
|
+
describe("claude-haiku-4-5", () => {
|
|
58
|
+
test("returns correct USD for SAMPLE_USAGE", () => {
|
|
59
|
+
// (100 * 1.00 + 200 * 5.00 + 10 * 1.00 * 1.25 + 50 * 1.00 * 0.1) / 1_000_000
|
|
60
|
+
// = (100 + 1000 + 12.5 + 5) / 1_000_000 = 0.0011175
|
|
61
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-haiku-4-5")).toBe(0.0011175);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ─── claude-opus-4-8 ($5.00 input, $25.00 output) ────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe("claude-opus-4-8", () => {
|
|
68
|
+
test("returns correct USD for SAMPLE_USAGE", () => {
|
|
69
|
+
// (100 * 5.00 + 200 * 25.00 + 10 * 5.00 * 1.25 + 50 * 5.00 * 0.1) / 1_000_000
|
|
70
|
+
// = (500 + 5000 + 62.5 + 25) / 1_000_000 = 0.0055875
|
|
71
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-opus-4-8")).toBe(0.0055875);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ─── claude-opus-4-7 ($5.00 input, $25.00 output) ────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe("claude-opus-4-7", () => {
|
|
78
|
+
test("returns correct USD for SAMPLE_USAGE (same rates as opus-4-8)", () => {
|
|
79
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-opus-4-7")).toBe(0.0055875);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ─── claude-opus-4-6 ($5.00 input, $25.00 output) ────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe("claude-opus-4-6", () => {
|
|
86
|
+
test("returns correct USD for SAMPLE_USAGE (same rates as opus-4-8)", () => {
|
|
87
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-opus-4-6")).toBe(0.0055875);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ─── claude-fable-5 ($10.00 input, $50.00 output) ────────────────────────────
|
|
92
|
+
|
|
93
|
+
describe("claude-fable-5", () => {
|
|
94
|
+
test("returns correct USD for SAMPLE_USAGE", () => {
|
|
95
|
+
// (100 * 10.00 + 200 * 50.00 + 10 * 10.00 * 1.25 + 50 * 10.00 * 0.1) / 1_000_000
|
|
96
|
+
// = (1000 + 10000 + 125 + 50) / 1_000_000 = 0.011175
|
|
97
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-fable-5")).toBe(0.011175);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ─── cache multipliers ────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe("cache multipliers", () => {
|
|
104
|
+
test("cache_creation_input_tokens uses 1.25x input rate", () => {
|
|
105
|
+
const usage: TokenUsage = {
|
|
106
|
+
input_tokens: 0,
|
|
107
|
+
output_tokens: 0,
|
|
108
|
+
cache_read_input_tokens: 0,
|
|
109
|
+
cache_creation_input_tokens: 1_000_000,
|
|
110
|
+
};
|
|
111
|
+
// claude-sonnet-4-6 input = $3.00/M, cache write = 3.00 * 1.25 = $3.75/M
|
|
112
|
+
// 1_000_000 * 3.75 / 1_000_000 = 3.75
|
|
113
|
+
expect(calculateCost(usage, "claude-sonnet-4-6")).toBe(3.75);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("cache_read_input_tokens uses 0.1x input rate", () => {
|
|
117
|
+
const usage: TokenUsage = {
|
|
118
|
+
input_tokens: 0,
|
|
119
|
+
output_tokens: 0,
|
|
120
|
+
cache_read_input_tokens: 1_000_000,
|
|
121
|
+
cache_creation_input_tokens: 0,
|
|
122
|
+
};
|
|
123
|
+
// claude-sonnet-4-6 input = $3.00/M, cache read = 3.00 * 0.1 = $0.30/M
|
|
124
|
+
// 1_000_000 * 0.30 / 1_000_000 = 0.30
|
|
125
|
+
expect(calculateCost(usage, "claude-sonnet-4-6")).toBe(0.3);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("input_tokens only (no cache) charges at base input rate", () => {
|
|
129
|
+
const usage: TokenUsage = {
|
|
130
|
+
input_tokens: 1_000_000,
|
|
131
|
+
output_tokens: 0,
|
|
132
|
+
cache_read_input_tokens: 0,
|
|
133
|
+
cache_creation_input_tokens: 0,
|
|
134
|
+
};
|
|
135
|
+
// claude-haiku-4-5: $1.00/M input
|
|
136
|
+
expect(calculateCost(usage, "claude-haiku-4-5")).toBe(1.0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("output_tokens only charges at output rate", () => {
|
|
140
|
+
const usage: TokenUsage = {
|
|
141
|
+
input_tokens: 0,
|
|
142
|
+
output_tokens: 1_000_000,
|
|
143
|
+
cache_read_input_tokens: 0,
|
|
144
|
+
cache_creation_input_tokens: 0,
|
|
145
|
+
};
|
|
146
|
+
// claude-haiku-4-5: $5.00/M output
|
|
147
|
+
expect(calculateCost(usage, "claude-haiku-4-5")).toBe(5.0);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for agent/src/runtime/process-tree-kill.ts
|
|
3
|
+
*
|
|
4
|
+
* Real dependency under test: the Linux kernel's procfs
|
|
5
|
+
* (/proc/<pid>/task/<tid>/children) and real signal delivery. These spawn
|
|
6
|
+
* genuine short-lived subprocesses that fork their own children — the exact
|
|
7
|
+
* shape the timeout path has to clean up (a `claude` CLI that spawned an
|
|
8
|
+
* install/build). No DB, no network, no mock.module().
|
|
9
|
+
*
|
|
10
|
+
* Linux-only: procfs's `children` file has no portable equivalent, and the
|
|
11
|
+
* agent only ever runs on Linux (agent/Dockerfile). The suite self-skips
|
|
12
|
+
* elsewhere rather than failing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
|
|
18
|
+
const { collectDescendantPids, killTree } = await import(
|
|
19
|
+
"./process-tree-kill.ts"
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const onLinux = process.platform === "linux" && existsSync("/proc/self/task");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* True unless the pid is gone OR is an unreaped zombie. A SIGKILLed process
|
|
26
|
+
* whose parent died too lingers as a zombie until init reaps it — `kill(pid,
|
|
27
|
+
* 0)` still succeeds for a zombie, so it alone would be a false "alive".
|
|
28
|
+
*/
|
|
29
|
+
function isAlive(pid: number): boolean {
|
|
30
|
+
let stat: string;
|
|
31
|
+
try {
|
|
32
|
+
stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
// "<pid> (comm) <state> ..." — comm can contain spaces/parens, so slice
|
|
37
|
+
// from the LAST ')' to find the state field.
|
|
38
|
+
const state = stat.slice(
|
|
39
|
+
stat.lastIndexOf(")") + 2,
|
|
40
|
+
stat.lastIndexOf(")") + 3,
|
|
41
|
+
);
|
|
42
|
+
return state !== "Z";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function waitUntilDead(pid: number, timeoutMs = 2000): Promise<boolean> {
|
|
46
|
+
const deadline = Date.now() + timeoutMs;
|
|
47
|
+
while (Date.now() < deadline) {
|
|
48
|
+
if (!isAlive(pid)) return true;
|
|
49
|
+
await Bun.sleep(10);
|
|
50
|
+
}
|
|
51
|
+
return !isAlive(pid);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Spawns `sh` which forks a long-lived grandchild and prints its pid, then
|
|
56
|
+
* waits. Returns the direct child proc plus the grandchild's pid.
|
|
57
|
+
*/
|
|
58
|
+
async function spawnWithGrandchild(): Promise<{
|
|
59
|
+
proc: Bun.Subprocess;
|
|
60
|
+
grandchildPid: number;
|
|
61
|
+
}> {
|
|
62
|
+
const proc = Bun.spawn(["sh", "-c", 'sleep 300 & echo "$!"; wait'], {
|
|
63
|
+
stdout: "pipe",
|
|
64
|
+
stderr: "ignore",
|
|
65
|
+
});
|
|
66
|
+
const deadline = Date.now() + 5000;
|
|
67
|
+
let buffered = "";
|
|
68
|
+
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
|
69
|
+
const decoder = new TextDecoder();
|
|
70
|
+
try {
|
|
71
|
+
while (!buffered.includes("\n") && Date.now() < deadline) {
|
|
72
|
+
const { value, done } = await reader.read();
|
|
73
|
+
if (done) break;
|
|
74
|
+
if (value) buffered += decoder.decode(value, { stream: true });
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
reader.releaseLock();
|
|
78
|
+
}
|
|
79
|
+
const grandchildPid = Number.parseInt(buffered.trim(), 10);
|
|
80
|
+
return { proc, grandchildPid };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe.if(onLinux)("collectDescendantPids (real processes)", () => {
|
|
84
|
+
test("finds a grandchild the direct child forked", async () => {
|
|
85
|
+
const { proc, grandchildPid } = await spawnWithGrandchild();
|
|
86
|
+
try {
|
|
87
|
+
expect(Number.isInteger(grandchildPid)).toBe(true);
|
|
88
|
+
const descendants = collectDescendantPids(proc.pid);
|
|
89
|
+
expect(descendants).toContain(grandchildPid);
|
|
90
|
+
} finally {
|
|
91
|
+
killTree(proc);
|
|
92
|
+
await waitUntilDead(grandchildPid);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe.if(onLinux)("killTree (real processes, procfs fallback)", () => {
|
|
98
|
+
test("terminates the direct child AND its forked descendant", async () => {
|
|
99
|
+
const { proc, grandchildPid } = await spawnWithGrandchild();
|
|
100
|
+
expect(isAlive(grandchildPid)).toBe(true);
|
|
101
|
+
|
|
102
|
+
killTree(proc);
|
|
103
|
+
|
|
104
|
+
await proc.exited;
|
|
105
|
+
expect(await waitUntilDead(proc.pid)).toBe(true);
|
|
106
|
+
expect(await waitUntilDead(grandchildPid)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a plain proc.kill() leaves the descendant alive — the regression this guards", async () => {
|
|
110
|
+
// Pins the *reason* killTree exists: signalling only the direct child is
|
|
111
|
+
// demonstrably insufficient. If this ever starts failing, the kernel/shell
|
|
112
|
+
// stopped reparenting orphans and killTree's whole premise changed.
|
|
113
|
+
const { proc, grandchildPid } = await spawnWithGrandchild();
|
|
114
|
+
try {
|
|
115
|
+
proc.kill();
|
|
116
|
+
await proc.exited;
|
|
117
|
+
await Bun.sleep(50);
|
|
118
|
+
expect(isAlive(grandchildPid)).toBe(true);
|
|
119
|
+
} finally {
|
|
120
|
+
try {
|
|
121
|
+
process.kill(grandchildPid, "SIGKILL");
|
|
122
|
+
} catch {
|
|
123
|
+
// already gone
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("is a no-op that does not throw when the process already exited", async () => {
|
|
129
|
+
const proc = Bun.spawn(["true"], { stdout: "ignore", stderr: "ignore" });
|
|
130
|
+
await proc.exited;
|
|
131
|
+
expect(() => killTree(proc)).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
});
|