@app-vitals/agent-runtime 0.0.0-stage → 0.1.0

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.
Files changed (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +40 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. 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,7 @@
1
+ export type { TokenUsage } from "@app-vitals/agent-lib/pricing";
2
+ export {
3
+ calculateCost,
4
+ normalizeModelToRateKey,
5
+ OPUS_MODEL,
6
+ RATES,
7
+ } from "@app-vitals/agent-lib/pricing";
@@ -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
+ });