@app-vitals/agent-lib 0.0.1 → 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.
- package/admin-types.ts +4097 -0
- package/claude-timeout.ts +10 -0
- package/claude-timeout.unit.test.ts +8 -0
- package/cli-flags.ts +83 -0
- package/cli-flags.unit.test.ts +137 -0
- package/clone-plan.ts +73 -0
- package/clone-plan.unit.test.ts +69 -0
- package/default-agent-env.ts +15 -0
- package/default-agent-env.unit.test.ts +19 -0
- package/github-login.ts +20 -0
- package/github-login.unit.test.ts +78 -0
- package/graceful-shutdown.ts +143 -0
- package/graceful-shutdown.unit.test.ts +236 -0
- package/org-repo.ts +9 -0
- package/org-repo.unit.test.ts +42 -0
- package/package.json +37 -4
- package/pg-sslmode.ts +66 -0
- package/pg-sslmode.unit.test.ts +74 -0
- package/pool-observability.ts +209 -0
- package/pool-observability.unit.test.ts +233 -0
- package/pricing.ts +98 -0
- package/pricing.unit.test.ts +214 -0
- package/progress-phases.ts +58 -0
- package/progress-phases.unit.test.ts +50 -0
- package/request-context.ts +23 -0
- package/request-context.unit.test.ts +51 -0
- package/secret-env-vars.ts +41 -0
- package/secret-env-vars.unit.test.ts +62 -0
- package/sentry.ts +201 -0
- package/sentry.unit.test.ts +294 -0
- package/task-store-types.ts +3256 -0
- package/task-store-types.unit.test.ts +24 -0
- package/web/toolbar.ts +244 -0
- package/web/toolbar.unit.test.ts +243 -0
- package/README.md +0 -3
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type CheckoutPool,
|
|
4
|
+
type Clock,
|
|
5
|
+
isPoolTimeoutError,
|
|
6
|
+
poolErrorContext,
|
|
7
|
+
routeContextMiddleware,
|
|
8
|
+
runWithRouteContext,
|
|
9
|
+
snapshotPool,
|
|
10
|
+
startPoolMonitor,
|
|
11
|
+
trackConnectionHold,
|
|
12
|
+
} from "./pool-observability.ts";
|
|
13
|
+
|
|
14
|
+
function makePool(over: Partial<CheckoutPool> = {}) {
|
|
15
|
+
const released: unknown[][] = [];
|
|
16
|
+
const pool: CheckoutPool = {
|
|
17
|
+
totalCount: 3,
|
|
18
|
+
idleCount: 1,
|
|
19
|
+
waitingCount: 0,
|
|
20
|
+
options: { max: 10 },
|
|
21
|
+
connect: async () => ({
|
|
22
|
+
release: (...args: unknown[]) => {
|
|
23
|
+
released.push(args);
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
...over,
|
|
27
|
+
};
|
|
28
|
+
return { pool, released };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeClock(startMs = 0) {
|
|
32
|
+
let t = startMs;
|
|
33
|
+
const clock: Clock = { now: () => new Date(t) };
|
|
34
|
+
return {
|
|
35
|
+
clock,
|
|
36
|
+
advance: (ms: number) => {
|
|
37
|
+
t += ms;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("snapshotPool", () => {
|
|
43
|
+
test("maps pool counts and max", () => {
|
|
44
|
+
expect(snapshotPool(makePool().pool)).toEqual({
|
|
45
|
+
total: 3,
|
|
46
|
+
idle: 1,
|
|
47
|
+
waiting: 0,
|
|
48
|
+
max: 10,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("isPoolTimeoutError", () => {
|
|
54
|
+
test("matches Prisma P2028", () => {
|
|
55
|
+
expect(isPoolTimeoutError({ code: "P2028", message: "x" })).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
test("matches message variants", () => {
|
|
58
|
+
expect(
|
|
59
|
+
isPoolTimeoutError(
|
|
60
|
+
new Error("Unable to start a transaction in the given time."),
|
|
61
|
+
),
|
|
62
|
+
).toBe(true);
|
|
63
|
+
expect(
|
|
64
|
+
isPoolTimeoutError(new Error("timeout exceeded when trying to connect")),
|
|
65
|
+
).toBe(true);
|
|
66
|
+
expect(
|
|
67
|
+
isPoolTimeoutError(
|
|
68
|
+
new Error("Connection terminated due to connection timeout"),
|
|
69
|
+
),
|
|
70
|
+
).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
test("rejects unrelated errors", () => {
|
|
73
|
+
expect(isPoolTimeoutError(new Error("syntax error"))).toBe(false);
|
|
74
|
+
expect(isPoolTimeoutError({ code: "P2002" })).toBe(false);
|
|
75
|
+
expect(isPoolTimeoutError(null)).toBe(false);
|
|
76
|
+
expect(isPoolTimeoutError("timeout")).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("startPoolMonitor", () => {
|
|
81
|
+
function harness(pool: CheckoutPool) {
|
|
82
|
+
let tick: () => void = () => {};
|
|
83
|
+
let cleared: unknown;
|
|
84
|
+
const logs: unknown[][] = [];
|
|
85
|
+
const stop = startPoolMonitor(pool, {
|
|
86
|
+
intervalMs: 5,
|
|
87
|
+
log: (...a) => void logs.push(a),
|
|
88
|
+
setInterval: (fn, ms) => {
|
|
89
|
+
expect(ms).toBe(5);
|
|
90
|
+
tick = fn;
|
|
91
|
+
return "h";
|
|
92
|
+
},
|
|
93
|
+
clearInterval: (h) => {
|
|
94
|
+
cleared = h;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return { tick: () => tick(), stop, logs, cleared: () => cleared };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
test("does not log when nobody is waiting", () => {
|
|
101
|
+
const h = harness(makePool().pool);
|
|
102
|
+
h.tick();
|
|
103
|
+
expect(h.logs).toHaveLength(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("logs a snapshot when waiting > 0", () => {
|
|
107
|
+
const h = harness(makePool({ waitingCount: 4 }).pool);
|
|
108
|
+
h.tick();
|
|
109
|
+
expect(h.logs).toHaveLength(1);
|
|
110
|
+
expect(h.logs[0]?.[1]).toMatchObject({ waiting: 4, total: 3, max: 10 });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("stop fn clears the interval", () => {
|
|
114
|
+
const h = harness(makePool().pool);
|
|
115
|
+
h.stop();
|
|
116
|
+
expect(h.cleared()).toBe("h");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("trackConnectionHold", () => {
|
|
121
|
+
async function run(
|
|
122
|
+
holdMs: number,
|
|
123
|
+
route?: { method: string; route: string },
|
|
124
|
+
) {
|
|
125
|
+
const { pool, released } = makePool({ waitingCount: 2 });
|
|
126
|
+
const { clock, advance } = makeClock();
|
|
127
|
+
const logs: unknown[][] = [];
|
|
128
|
+
trackConnectionHold(pool, {
|
|
129
|
+
thresholdMs: 2000,
|
|
130
|
+
clock,
|
|
131
|
+
log: (...a) => void logs.push(a),
|
|
132
|
+
});
|
|
133
|
+
const exec = async () => {
|
|
134
|
+
const client = await pool.connect();
|
|
135
|
+
advance(holdMs);
|
|
136
|
+
client.release();
|
|
137
|
+
return client;
|
|
138
|
+
};
|
|
139
|
+
const client = route
|
|
140
|
+
? await runWithRouteContext(route, exec)
|
|
141
|
+
: await exec();
|
|
142
|
+
return { client, logs, released };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
test("does not log under the threshold", async () => {
|
|
146
|
+
const { logs, released } = await run(1500);
|
|
147
|
+
expect(logs).toHaveLength(0);
|
|
148
|
+
expect(released).toHaveLength(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("logs route, heldMs and pool snapshot over the threshold", async () => {
|
|
152
|
+
const { logs } = await run(2500, { method: "GET", route: "/tasks/:id" });
|
|
153
|
+
expect(logs).toHaveLength(1);
|
|
154
|
+
expect(logs[0]?.[1]).toMatchObject({
|
|
155
|
+
route: "GET /tasks/:id",
|
|
156
|
+
heldMs: 2500,
|
|
157
|
+
pool: { total: 3, waiting: 2 },
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("route is null outside a request", async () => {
|
|
162
|
+
const { logs } = await run(2500);
|
|
163
|
+
expect(logs[0]?.[1]).toMatchObject({ route: null });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("release is called once per call, error arg forwarded", async () => {
|
|
167
|
+
const { pool, released } = makePool();
|
|
168
|
+
trackConnectionHold(pool);
|
|
169
|
+
const client = await pool.connect();
|
|
170
|
+
const err = new Error("boom");
|
|
171
|
+
client.release(err);
|
|
172
|
+
expect(released).toEqual([[err]]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("double release behaves as unwrapped (underlying called each time)", async () => {
|
|
176
|
+
const { pool, released } = makePool();
|
|
177
|
+
const { clock, advance } = makeClock();
|
|
178
|
+
const logs: unknown[][] = [];
|
|
179
|
+
trackConnectionHold(pool, { clock, log: (...a) => void logs.push(a) });
|
|
180
|
+
const client = await pool.connect();
|
|
181
|
+
advance(5000);
|
|
182
|
+
client.release();
|
|
183
|
+
client.release(true);
|
|
184
|
+
expect(released).toEqual([[], [true]]);
|
|
185
|
+
expect(logs).toHaveLength(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("restore fn reinstates original connect", async () => {
|
|
189
|
+
const { pool } = makePool();
|
|
190
|
+
const original = pool.connect;
|
|
191
|
+
const restore = trackConnectionHold(pool);
|
|
192
|
+
expect(pool.connect).not.toBe(original);
|
|
193
|
+
restore();
|
|
194
|
+
expect(pool.connect).toBe(original);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("routeContextMiddleware", () => {
|
|
199
|
+
test("records method and matched route pattern for poolErrorContext", async () => {
|
|
200
|
+
const { pool } = makePool();
|
|
201
|
+
let seen: Record<string, unknown> | undefined;
|
|
202
|
+
const c = {
|
|
203
|
+
req: {
|
|
204
|
+
method: "GET",
|
|
205
|
+
path: "/tasks/42",
|
|
206
|
+
matchedRoutes: [{ path: "/*" }, { path: "/tasks/:id" }],
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
await routeContextMiddleware()(c, async () => {
|
|
210
|
+
seen = poolErrorContext(pool);
|
|
211
|
+
});
|
|
212
|
+
expect(seen).toEqual({
|
|
213
|
+
total: 3,
|
|
214
|
+
idle: 1,
|
|
215
|
+
waiting: 0,
|
|
216
|
+
max: 10,
|
|
217
|
+
route: "GET /tasks/:id",
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("falls back to the request path without matchedRoutes", async () => {
|
|
222
|
+
let seen: Record<string, unknown> | undefined;
|
|
223
|
+
const c = { req: { method: "POST", path: "/x" } };
|
|
224
|
+
await routeContextMiddleware()(c, async () => {
|
|
225
|
+
seen = poolErrorContext(makePool().pool);
|
|
226
|
+
});
|
|
227
|
+
expect(seen?.route).toBe("POST /x");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("poolErrorContext has null route outside a request", () => {
|
|
231
|
+
expect(poolErrorContext(makePool().pool).route).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
});
|
package/pricing.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export interface TokenUsage {
|
|
2
|
+
input_tokens: number;
|
|
3
|
+
output_tokens: number;
|
|
4
|
+
cache_read_input_tokens: number;
|
|
5
|
+
cache_creation_input_tokens: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ModelRates {
|
|
9
|
+
input: number;
|
|
10
|
+
output: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const OPUS_MODEL = "claude-opus-4-8";
|
|
14
|
+
|
|
15
|
+
export const RATES: Record<string, ModelRates> = {
|
|
16
|
+
"claude-fable-5-1": { input: 10.0, output: 50.0 },
|
|
17
|
+
"claude-fable-5": { input: 10.0, output: 50.0 },
|
|
18
|
+
"claude-opus-5-5": { input: 4.0, output: 20.0 },
|
|
19
|
+
"claude-opus-4-8": { input: 5.0, output: 25.0 },
|
|
20
|
+
"claude-opus-4-7": { input: 5.0, output: 25.0 },
|
|
21
|
+
"claude-opus-4-6": { input: 5.0, output: 25.0 },
|
|
22
|
+
"claude-sonnet-5-5": { input: 2.0, output: 10.0 },
|
|
23
|
+
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
|
|
24
|
+
"claude-haiku-5-5": { input: 0.1, output: 0.5 },
|
|
25
|
+
"claude-haiku-4-5": { input: 1.0, output: 5.0 },
|
|
26
|
+
"claude-haiku-4-6": { input: 1.0, output: 5.0 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Context window (input tokens) per rate key. Used by the prompt-audit
|
|
31
|
+
* tooling to express always-loaded context as a share of the window and to
|
|
32
|
+
* size the skill-listing budget (Claude Code reserves 1% of the window for
|
|
33
|
+
* the skill listing). Keys mirror RATES.
|
|
34
|
+
*/
|
|
35
|
+
export const CONTEXT_WINDOW: Record<string, number> = {
|
|
36
|
+
"claude-fable-5-1": 1_000_000,
|
|
37
|
+
"claude-fable-5": 1_000_000,
|
|
38
|
+
"claude-opus-5-5": 1_000_000,
|
|
39
|
+
"claude-opus-4-8": 1_000_000,
|
|
40
|
+
"claude-opus-4-7": 1_000_000,
|
|
41
|
+
"claude-opus-4-6": 1_000_000,
|
|
42
|
+
"claude-sonnet-5-5": 1_000_000,
|
|
43
|
+
"claude-sonnet-4-6": 1_000_000,
|
|
44
|
+
"claude-haiku-5-5": 1_000_000,
|
|
45
|
+
"claude-haiku-4-5": 200_000,
|
|
46
|
+
"claude-haiku-4-6": 200_000,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function calculateCost(usage: TokenUsage, model: string): number {
|
|
50
|
+
const rates = RATES[model];
|
|
51
|
+
if (!rates) return 0;
|
|
52
|
+
|
|
53
|
+
const { input, output } = rates;
|
|
54
|
+
const cost =
|
|
55
|
+
usage.input_tokens * input +
|
|
56
|
+
usage.output_tokens * output +
|
|
57
|
+
usage.cache_creation_input_tokens * input * 1.25 +
|
|
58
|
+
usage.cache_read_input_tokens * input * 0.1;
|
|
59
|
+
|
|
60
|
+
return cost / 1_000_000;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function normalizeModelToRateKey(model: string): string | null {
|
|
64
|
+
const bareModel = model.replace(/-\d{8}$/, "");
|
|
65
|
+
// Current-generation ids price as themselves. The bare "sonnet"/"opus"/
|
|
66
|
+
// "haiku" aliases resolve to the 5.5 keys; 4.x ids keep their own mapping.
|
|
67
|
+
if (
|
|
68
|
+
bareModel === "claude-fable-5-1" ||
|
|
69
|
+
bareModel === "claude-opus-5-5" ||
|
|
70
|
+
bareModel === "claude-sonnet-5-5" ||
|
|
71
|
+
bareModel === "claude-haiku-5-5"
|
|
72
|
+
) {
|
|
73
|
+
return bareModel;
|
|
74
|
+
}
|
|
75
|
+
if (bareModel === "haiku") return "claude-haiku-5-5";
|
|
76
|
+
if (bareModel === "opus") return "claude-opus-5-5";
|
|
77
|
+
if (bareModel === "sonnet") return "claude-sonnet-5-5";
|
|
78
|
+
if (
|
|
79
|
+
bareModel === "claude-haiku-4-5" ||
|
|
80
|
+
bareModel === "claude-haiku-4-6"
|
|
81
|
+
) {
|
|
82
|
+
return "claude-haiku-4-5";
|
|
83
|
+
}
|
|
84
|
+
if (bareModel === "claude-sonnet-4-6") {
|
|
85
|
+
return "claude-sonnet-4-6";
|
|
86
|
+
}
|
|
87
|
+
if (
|
|
88
|
+
bareModel === "claude-opus-4-8" ||
|
|
89
|
+
bareModel === "claude-opus-4-7" ||
|
|
90
|
+
bareModel === "claude-opus-4-6"
|
|
91
|
+
) {
|
|
92
|
+
return "claude-opus-4-8";
|
|
93
|
+
}
|
|
94
|
+
if (bareModel === "fable" || bareModel === "claude-fable-5") {
|
|
95
|
+
return "claude-fable-5";
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
CONTEXT_WINDOW,
|
|
4
|
+
OPUS_MODEL,
|
|
5
|
+
RATES,
|
|
6
|
+
calculateCost,
|
|
7
|
+
normalizeModelToRateKey,
|
|
8
|
+
} from "./pricing.ts";
|
|
9
|
+
|
|
10
|
+
const SAMPLE_USAGE = {
|
|
11
|
+
input_tokens: 100,
|
|
12
|
+
output_tokens: 200,
|
|
13
|
+
cache_read_input_tokens: 50,
|
|
14
|
+
cache_creation_input_tokens: 10,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ZERO_USAGE = {
|
|
18
|
+
input_tokens: 0,
|
|
19
|
+
output_tokens: 0,
|
|
20
|
+
cache_read_input_tokens: 0,
|
|
21
|
+
cache_creation_input_tokens: 0,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("OPUS_MODEL", () => {
|
|
25
|
+
test("is the canonical opus key present in RATES", () => {
|
|
26
|
+
expect(OPUS_MODEL).toBe("claude-opus-4-8");
|
|
27
|
+
expect(RATES[OPUS_MODEL]).toBeDefined();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("calculateCost — non-zero for known keys", () => {
|
|
32
|
+
test("returns non-zero for claude-sonnet-4-6 with sample usage", () => {
|
|
33
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-sonnet-4-6")).toBeGreaterThan(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("returns non-zero for claude-haiku-4-5 with sample usage", () => {
|
|
37
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-haiku-4-5")).toBeGreaterThan(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns non-zero for claude-opus-4-8 with sample usage", () => {
|
|
41
|
+
expect(calculateCost(SAMPLE_USAGE, "claude-opus-4-8")).toBeGreaterThan(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns 0 for unknown model", () => {
|
|
45
|
+
expect(calculateCost(SAMPLE_USAGE, "gpt-4")).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("returns 0 for zero usage", () => {
|
|
49
|
+
expect(calculateCost(ZERO_USAGE, "claude-sonnet-4-6")).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("normalizeModelToRateKey — haiku family", () => {
|
|
54
|
+
test('"haiku" maps to haiku canonical key', () => {
|
|
55
|
+
expect(normalizeModelToRateKey("haiku")).toBe("claude-haiku-5-5");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('"claude-haiku-4-5" maps to haiku canonical key', () => {
|
|
59
|
+
expect(normalizeModelToRateKey("claude-haiku-4-5")).toBe("claude-haiku-4-5");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('"claude-haiku-4-6" maps to haiku canonical key', () => {
|
|
63
|
+
expect(normalizeModelToRateKey("claude-haiku-4-6")).toBe("claude-haiku-4-5");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("normalizeModelToRateKey — sonnet family", () => {
|
|
68
|
+
test('"sonnet" maps to sonnet canonical key', () => {
|
|
69
|
+
expect(normalizeModelToRateKey("sonnet")).toBe("claude-sonnet-5-5");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('"claude-sonnet-4-6" maps to sonnet canonical key', () => {
|
|
73
|
+
expect(normalizeModelToRateKey("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("normalizeModelToRateKey — opus family", () => {
|
|
78
|
+
test('"opus" maps to opus canonical key', () => {
|
|
79
|
+
expect(normalizeModelToRateKey("opus")).toBe("claude-opus-5-5");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('"claude-opus-4-8" maps to opus canonical key', () => {
|
|
83
|
+
expect(normalizeModelToRateKey("claude-opus-4-8")).toBe("claude-opus-4-8");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('"claude-opus-4-7" maps to opus canonical key', () => {
|
|
87
|
+
expect(normalizeModelToRateKey("claude-opus-4-7")).toBe("claude-opus-4-8");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('"claude-opus-4-6" maps to opus canonical key', () => {
|
|
91
|
+
expect(normalizeModelToRateKey("claude-opus-4-6")).toBe("claude-opus-4-8");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("normalizeModelToRateKey — fable family", () => {
|
|
96
|
+
test('"fable" maps to fable canonical key', () => {
|
|
97
|
+
expect(normalizeModelToRateKey("fable")).toBe("claude-fable-5");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('"claude-fable-5" maps to fable canonical key', () => {
|
|
101
|
+
expect(normalizeModelToRateKey("claude-fable-5")).toBe("claude-fable-5");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("normalizeModelToRateKey — dated CLI snapshot IDs", () => {
|
|
106
|
+
test('"claude-haiku-4-5-20251001" strips snapshot suffix and maps to haiku canonical key', () => {
|
|
107
|
+
expect(normalizeModelToRateKey("claude-haiku-4-5-20251001")).toBe(
|
|
108
|
+
"claude-haiku-4-5",
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('"claude-haiku-4-6-20251001" strips snapshot suffix and maps to haiku canonical key', () => {
|
|
113
|
+
expect(normalizeModelToRateKey("claude-haiku-4-6-20251001")).toBe(
|
|
114
|
+
"claude-haiku-4-5",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('"claude-sonnet-4-6-20250929" strips snapshot suffix and maps to sonnet canonical key', () => {
|
|
119
|
+
expect(normalizeModelToRateKey("claude-sonnet-4-6-20250929")).toBe(
|
|
120
|
+
"claude-sonnet-4-6",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('"claude-opus-4-8-20250915" strips snapshot suffix and maps to opus canonical key', () => {
|
|
125
|
+
expect(normalizeModelToRateKey("claude-opus-4-8-20250915")).toBe(
|
|
126
|
+
"claude-opus-4-8",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('"claude-opus-4-7-20250601" strips snapshot suffix and maps to opus canonical key', () => {
|
|
131
|
+
expect(normalizeModelToRateKey("claude-opus-4-7-20250601")).toBe(
|
|
132
|
+
"claude-opus-4-8",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('"claude-fable-5-20251101" strips snapshot suffix and maps to fable canonical key', () => {
|
|
137
|
+
expect(normalizeModelToRateKey("claude-fable-5-20251101")).toBe(
|
|
138
|
+
"claude-fable-5",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('unknown dated snapshot still returns null (no crash)', () => {
|
|
143
|
+
expect(normalizeModelToRateKey("claude-nope-1-0-20251001")).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("normalizeModelToRateKey — unknown inputs", () => {
|
|
148
|
+
test('"unknown-model" returns null', () => {
|
|
149
|
+
expect(normalizeModelToRateKey("unknown-model")).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('empty string returns null', () => {
|
|
153
|
+
expect(normalizeModelToRateKey("")).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('"gpt-4" returns null', () => {
|
|
157
|
+
expect(normalizeModelToRateKey("gpt-4")).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("normalizeModelToRateKey — 5.x ids price as themselves", () => {
|
|
162
|
+
for (const id of [
|
|
163
|
+
"claude-fable-5-1",
|
|
164
|
+
"claude-opus-5-5",
|
|
165
|
+
"claude-sonnet-5-5",
|
|
166
|
+
"claude-haiku-5-5",
|
|
167
|
+
]) {
|
|
168
|
+
test(`${id} resolves to itself`, () => {
|
|
169
|
+
expect(normalizeModelToRateKey(id)).toBe(id);
|
|
170
|
+
expect(RATES[id]).toBeDefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test(`${id} dated snapshot resolves to itself`, () => {
|
|
174
|
+
expect(normalizeModelToRateKey(`${id}-20260101`)).toBe(id);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("5.x rates match the published rate card", () => {
|
|
180
|
+
test.each([
|
|
181
|
+
["claude-fable-5-1", 10, 50],
|
|
182
|
+
["claude-opus-5-5", 4, 20],
|
|
183
|
+
["claude-sonnet-5-5", 2, 10],
|
|
184
|
+
["claude-haiku-5-5", 0.1, 0.5],
|
|
185
|
+
])("%s is $%d in / $%d out per MTok", (key, input, output) => {
|
|
186
|
+
expect(RATES[key]).toEqual({ input, output });
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("normalizeModelToRateKey — bare aliases resolve to 5.5 keys", () => {
|
|
191
|
+
test("haiku/sonnet/opus aliases map to the 5.5 generation", () => {
|
|
192
|
+
expect(normalizeModelToRateKey("haiku")).toBe("claude-haiku-5-5");
|
|
193
|
+
expect(normalizeModelToRateKey("sonnet")).toBe("claude-sonnet-5-5");
|
|
194
|
+
expect(normalizeModelToRateKey("opus")).toBe("claude-opus-5-5");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("CONTEXT_WINDOW", () => {
|
|
199
|
+
test("has exactly the same keys as RATES", () => {
|
|
200
|
+
expect(Object.keys(CONTEXT_WINDOW).sort()).toEqual(Object.keys(RATES).sort());
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("every window is a positive integer", () => {
|
|
204
|
+
for (const size of Object.values(CONTEXT_WINDOW)) {
|
|
205
|
+
expect(Number.isInteger(size) && size > 0).toBe(true);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("haiku 4.x is 200k; 5.x models are 1M", () => {
|
|
210
|
+
expect(CONTEXT_WINDOW["claude-haiku-4-5"]).toBe(200_000);
|
|
211
|
+
expect(CONTEXT_WINDOW["claude-opus-5-5"]).toBe(1_000_000);
|
|
212
|
+
expect(CONTEXT_WINDOW["claude-haiku-5-5"]).toBe(1_000_000);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/progress-phases.ts
|
|
3
|
+
* The closed set of generic progress phases derived from a Claude Code
|
|
4
|
+
* `stream-json` session (see `agent/src/runtime/progress-milestones.ts`), plus their
|
|
5
|
+
* human-readable labels. Consumed by `agent/src/runtime/claude.ts`'s `ProgressCallback`
|
|
6
|
+
* and, eventually, by UI surfaces that want to show "what is the agent doing
|
|
7
|
+
* right now" without exposing raw tool inputs (which may contain secrets or
|
|
8
|
+
* file paths).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The closed set of generic progress phases a Claude stream can report. */
|
|
12
|
+
export type ProgressPhase =
|
|
13
|
+
| "starting"
|
|
14
|
+
| "thinking"
|
|
15
|
+
| "reading"
|
|
16
|
+
| "searching"
|
|
17
|
+
| "web"
|
|
18
|
+
| "editing"
|
|
19
|
+
| "running"
|
|
20
|
+
| "delegating"
|
|
21
|
+
| "planning"
|
|
22
|
+
| "tool"
|
|
23
|
+
| "writing";
|
|
24
|
+
|
|
25
|
+
/** Runtime-checkable list of every valid ProgressPhase, in a stable order. */
|
|
26
|
+
export const PROGRESS_PHASES: readonly ProgressPhase[] = [
|
|
27
|
+
"starting",
|
|
28
|
+
"thinking",
|
|
29
|
+
"reading",
|
|
30
|
+
"searching",
|
|
31
|
+
"web",
|
|
32
|
+
"editing",
|
|
33
|
+
"running",
|
|
34
|
+
"delegating",
|
|
35
|
+
"planning",
|
|
36
|
+
"tool",
|
|
37
|
+
"writing",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Short human-readable label per phase, for eventual UI use. Every label
|
|
42
|
+
* ends in an ellipsis ("…") — they describe an *in-progress* action (e.g.
|
|
43
|
+
* a Slack Thinking Steps card mid-run), and a bare label like "Reading
|
|
44
|
+
* files" reads as already-completed rather than ongoing.
|
|
45
|
+
*/
|
|
46
|
+
export const PROGRESS_LABELS: Record<ProgressPhase, string> = {
|
|
47
|
+
starting: "Starting up…",
|
|
48
|
+
thinking: "Thinking…",
|
|
49
|
+
reading: "Reading files…",
|
|
50
|
+
searching: "Searching the codebase…",
|
|
51
|
+
web: "Searching the web…",
|
|
52
|
+
editing: "Editing files…",
|
|
53
|
+
running: "Running commands…",
|
|
54
|
+
delegating: "Delegating to a subagent…",
|
|
55
|
+
planning: "Planning…",
|
|
56
|
+
tool: "Using a tool…",
|
|
57
|
+
writing: "Writing a response…",
|
|
58
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { PROGRESS_LABELS, PROGRESS_PHASES } from "./progress-phases.ts";
|
|
3
|
+
|
|
4
|
+
describe("PROGRESS_PHASES", () => {
|
|
5
|
+
test("is the closed set of exactly eleven phases", () => {
|
|
6
|
+
expect(PROGRESS_PHASES).toHaveLength(11);
|
|
7
|
+
expect(new Set(PROGRESS_PHASES).size).toBe(11);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("contains exactly the documented phase names", () => {
|
|
11
|
+
expect([...(PROGRESS_PHASES as readonly string[])].sort()).toEqual(
|
|
12
|
+
[
|
|
13
|
+
"starting",
|
|
14
|
+
"thinking",
|
|
15
|
+
"reading",
|
|
16
|
+
"searching",
|
|
17
|
+
"web",
|
|
18
|
+
"editing",
|
|
19
|
+
"running",
|
|
20
|
+
"delegating",
|
|
21
|
+
"planning",
|
|
22
|
+
"tool",
|
|
23
|
+
"writing",
|
|
24
|
+
].sort(),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("PROGRESS_LABELS", () => {
|
|
30
|
+
test("has exactly one label per phase in PROGRESS_PHASES", () => {
|
|
31
|
+
const labelKeys = Object.keys(PROGRESS_LABELS).sort();
|
|
32
|
+
expect(labelKeys).toEqual([...PROGRESS_PHASES].sort());
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("every label is a non-empty human-readable string", () => {
|
|
36
|
+
for (const phase of PROGRESS_PHASES) {
|
|
37
|
+
expect(typeof PROGRESS_LABELS[phase]).toBe("string");
|
|
38
|
+
expect(PROGRESS_LABELS[phase].length).toBeGreaterThan(0);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// STS2-1.1: every in-progress label must end in "…" for consistent
|
|
43
|
+
// Thinking Steps stream rendering (a bare, un-ellipsized label like
|
|
44
|
+
// "Reading files" reads as a completed action, not an in-progress one).
|
|
45
|
+
test("every label ends in an ellipsis (…), consistently", () => {
|
|
46
|
+
for (const phase of PROGRESS_PHASES) {
|
|
47
|
+
expect(PROGRESS_LABELS[phase].endsWith("…")).toBe(true);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents an authenticated request caller, shared across services that
|
|
3
|
+
* resolve caller identity in auth middleware (admin, task-store, metrics).
|
|
4
|
+
*
|
|
5
|
+
* scope === "*" → unrestricted/admin (all clients/agents)
|
|
6
|
+
* scope === "<id>" → scoped to a single identity (clientId, agentId, etc.)
|
|
7
|
+
*/
|
|
8
|
+
export interface Caller {
|
|
9
|
+
name: string;
|
|
10
|
+
scope: string; // "*" or a scoped identity such as a clientId/agentId
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Produce a stable, readable label for a caller, suitable for log lines.
|
|
15
|
+
* Returns "anonymous" when no caller is present (e.g. unauthenticated or
|
|
16
|
+
* not-yet-resolved requests). When the auth middleware's scope kind is
|
|
17
|
+
* known, it is appended in brackets (e.g. "svc (platform.read) [platform-read]").
|
|
18
|
+
*/
|
|
19
|
+
export function callerLabel(caller?: Caller, scopeKind?: string): string {
|
|
20
|
+
if (!caller) return "anonymous";
|
|
21
|
+
const label = `${caller.name} (${caller.scope})`;
|
|
22
|
+
return scopeKind ? `${label} [${scopeKind}]` : label;
|
|
23
|
+
}
|