@app-vitals/agent-runtime 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/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 +38 -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,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for agent/src/runtime/progress-milestones.ts
|
|
3
|
+
*
|
|
4
|
+
* Strategy: call phaseForBlock directly with plain content-block objects.
|
|
5
|
+
* The load-bearing invariant under test — `block.input` must never be read —
|
|
6
|
+
* is verified both via string-absence assertions and via a `Proxy`/getter
|
|
7
|
+
* trap that throws if `input` is ever accessed, proving the code path
|
|
8
|
+
* genuinely never touches it (not just that it doesn't leak downstream).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import { phaseForBlock } from "./progress-milestones.ts";
|
|
13
|
+
|
|
14
|
+
describe("phaseForBlock — thinking / text blocks", () => {
|
|
15
|
+
test("maps a thinking block to the thinking phase", () => {
|
|
16
|
+
expect(phaseForBlock({ type: "thinking", thinking: "hmm" })).toBe(
|
|
17
|
+
"thinking",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("maps a text block to the writing phase", () => {
|
|
22
|
+
expect(phaseForBlock({ type: "text", text: "Here you go." })).toBe(
|
|
23
|
+
"writing",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("phaseForBlock — reading tools", () => {
|
|
29
|
+
for (const name of ["Read", "NotebookRead"]) {
|
|
30
|
+
test(`maps ${name} to the reading phase`, () => {
|
|
31
|
+
expect(phaseForBlock({ type: "tool_use", name, input: {} })).toBe(
|
|
32
|
+
"reading",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("phaseForBlock — searching tools", () => {
|
|
39
|
+
for (const name of ["Grep", "Glob"]) {
|
|
40
|
+
test(`maps ${name} to the searching phase`, () => {
|
|
41
|
+
expect(phaseForBlock({ type: "tool_use", name, input: {} })).toBe(
|
|
42
|
+
"searching",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("phaseForBlock — web tools", () => {
|
|
49
|
+
for (const name of ["WebSearch", "WebFetch"]) {
|
|
50
|
+
test(`maps ${name} to the web phase`, () => {
|
|
51
|
+
expect(phaseForBlock({ type: "tool_use", name, input: {} })).toBe("web");
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("phaseForBlock — editing tools", () => {
|
|
57
|
+
for (const name of ["Edit", "Write", "NotebookEdit"]) {
|
|
58
|
+
test(`maps ${name} to the editing phase`, () => {
|
|
59
|
+
expect(phaseForBlock({ type: "tool_use", name, input: {} })).toBe(
|
|
60
|
+
"editing",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("phaseForBlock — running tools", () => {
|
|
67
|
+
for (const name of ["Bash", "KillShell", "BashOutput"]) {
|
|
68
|
+
test(`maps ${name} to the running phase`, () => {
|
|
69
|
+
expect(phaseForBlock({ type: "tool_use", name, input: {} })).toBe(
|
|
70
|
+
"running",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("phaseForBlock — delegating tools", () => {
|
|
77
|
+
for (const name of ["Agent", "Task"]) {
|
|
78
|
+
test(`maps ${name} to the delegating phase`, () => {
|
|
79
|
+
expect(phaseForBlock({ type: "tool_use", name, input: {} })).toBe(
|
|
80
|
+
"delegating",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("phaseForBlock — planning tools", () => {
|
|
87
|
+
for (const name of ["TodoWrite", "ExitPlanMode", "EnterPlanMode"]) {
|
|
88
|
+
test(`maps ${name} to the planning phase`, () => {
|
|
89
|
+
expect(phaseForBlock({ type: "tool_use", name, input: {} })).toBe(
|
|
90
|
+
"planning",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("phaseForBlock — generic fallback", () => {
|
|
97
|
+
test("maps an mcp__*-prefixed tool name to the generic tool phase", () => {
|
|
98
|
+
expect(
|
|
99
|
+
phaseForBlock({
|
|
100
|
+
type: "tool_use",
|
|
101
|
+
name: "mcp__linear__create_issue",
|
|
102
|
+
input: {},
|
|
103
|
+
}),
|
|
104
|
+
).toBe("tool");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("maps an unrecognized tool name to the generic tool phase", () => {
|
|
108
|
+
expect(
|
|
109
|
+
phaseForBlock({ type: "tool_use", name: "SomeFutureTool", input: {} }),
|
|
110
|
+
).toBe("tool");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("never throws for an unknown tool name — always returns a phase", () => {
|
|
114
|
+
expect(() =>
|
|
115
|
+
phaseForBlock({ type: "tool_use", name: "", input: {} }),
|
|
116
|
+
).not.toThrow();
|
|
117
|
+
expect(phaseForBlock({ type: "tool_use", name: "", input: {} })).toBe(
|
|
118
|
+
"tool",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("phaseForBlock — unmapped block types", () => {
|
|
124
|
+
test("returns undefined for a tool_result block (out of scope)", () => {
|
|
125
|
+
expect(
|
|
126
|
+
phaseForBlock({
|
|
127
|
+
type: "tool_result",
|
|
128
|
+
tool_use_id: "tu_1",
|
|
129
|
+
content: "ok",
|
|
130
|
+
} as never),
|
|
131
|
+
).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ─── The load-bearing invariant: block.input is NEVER read ──────────────────
|
|
136
|
+
|
|
137
|
+
describe("phaseForBlock — never reads block.input", () => {
|
|
138
|
+
test("phase output never contains secret/path substrings embedded in input", () => {
|
|
139
|
+
const forbidden = ["/home/alice/.ssh/id_rsa", "sk-fake-12345"];
|
|
140
|
+
const block = {
|
|
141
|
+
type: "tool_use" as const,
|
|
142
|
+
name: "Bash",
|
|
143
|
+
input: {
|
|
144
|
+
command: "cat /home/alice/.ssh/id_rsa",
|
|
145
|
+
api_key: "sk-fake-12345",
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
const phase = phaseForBlock(block);
|
|
149
|
+
expect(phase).toBe("running");
|
|
150
|
+
for (const secret of forbidden) {
|
|
151
|
+
expect(String(phase)).not.toContain(secret);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("throws if `input` is ever accessed — proves the code path never reads it", () => {
|
|
156
|
+
let accessed = false;
|
|
157
|
+
const block = {
|
|
158
|
+
type: "tool_use" as const,
|
|
159
|
+
name: "Read",
|
|
160
|
+
get input() {
|
|
161
|
+
accessed = true;
|
|
162
|
+
throw new Error("block.input must never be read by phaseForBlock");
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const phase = phaseForBlock(block);
|
|
167
|
+
|
|
168
|
+
expect(accessed).toBe(false);
|
|
169
|
+
expect(phase).toBe("reading");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("throws-on-access input still works for an unrecognized tool name (generic fallback path)", () => {
|
|
173
|
+
let accessed = false;
|
|
174
|
+
const block = {
|
|
175
|
+
type: "tool_use" as const,
|
|
176
|
+
name: "SomeFutureTool",
|
|
177
|
+
get input() {
|
|
178
|
+
accessed = true;
|
|
179
|
+
throw new Error("block.input must never be read by phaseForBlock");
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const phase = phaseForBlock(block);
|
|
184
|
+
|
|
185
|
+
expect(accessed).toBe(false);
|
|
186
|
+
expect(phase).toBe("tool");
|
|
187
|
+
});
|
|
188
|
+
});
|
package/run-telemetry.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/run-telemetry.ts
|
|
3
|
+
*
|
|
4
|
+
* Per-run measurement derived from the Claude CLI's `stream-json` events,
|
|
5
|
+
* accumulated incrementally by `claude.ts`'s `_consumeStream` alongside the
|
|
6
|
+
* existing per-model usage map. Three things are captured:
|
|
7
|
+
*
|
|
8
|
+
* 1. **First-turn context baseline** — the usage of the first `assistant`
|
|
9
|
+
* message. `input + cache_creation + cache_read` of that turn is the
|
|
10
|
+
* full context the model saw before any work happened (system prompt,
|
|
11
|
+
* tool schemas, always-loaded CLAUDE.md/rules, the skill listing, the
|
|
12
|
+
* cron prompt). Summing all three makes the number independent of cache
|
|
13
|
+
* warmth: a warm prefix cache just moves tokens from `cache_creation`
|
|
14
|
+
* to `cache_read`. It is the measured, per-model cost of the agent's
|
|
15
|
+
* always-loaded markdown — the number the prompt-audit patrol compares
|
|
16
|
+
* before/after a CLAUDE.md change.
|
|
17
|
+
* 2. **Turn and tool-call counts** — distinct usage-bearing message ids and
|
|
18
|
+
* distinct `tool_use` block ids.
|
|
19
|
+
* 3. **Per-skill attribution** — which plugin skills were invoked (the
|
|
20
|
+
* `Skill` tool), how often, and the tokens of every turn that ran while
|
|
21
|
+
* that skill was the most recently invoked one. Attribution is
|
|
22
|
+
* deterministic and last-wins: a skill's body stays in the conversation
|
|
23
|
+
* for the rest of the session, so every later turn is charged to the
|
|
24
|
+
* most recent invoke until another skill is invoked. Turns before any
|
|
25
|
+
* invoke accrue to the `root` bucket. Sub-agent turns (events carrying
|
|
26
|
+
* `parent_tool_use_id`) are charged to an `agent:<subagent_type>`
|
|
27
|
+
* bucket and never change the current skill.
|
|
28
|
+
*
|
|
29
|
+
* Pure accumulator — no I/O. The same event shapes appear in Claude Code's
|
|
30
|
+
* transcript JSONL, so the accumulator can also replay a saved transcript.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { ContentBlock } from "./progress-milestones.ts";
|
|
34
|
+
|
|
35
|
+
interface FirstTurnUsage {
|
|
36
|
+
model: string;
|
|
37
|
+
messageId: string;
|
|
38
|
+
inputTokens: number;
|
|
39
|
+
cacheCreationTokens: number;
|
|
40
|
+
cacheReadTokens: number;
|
|
41
|
+
/** input + cacheCreation + cacheRead — the full context at turn 1. */
|
|
42
|
+
contextTokens: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type AttributionKind = "skill" | "agent" | "root";
|
|
46
|
+
|
|
47
|
+
export interface SkillUsageRow {
|
|
48
|
+
kind: AttributionKind;
|
|
49
|
+
/** Skill name as invoked (e.g. "shipwright:task-store"), subagent type, or "root". */
|
|
50
|
+
name: string;
|
|
51
|
+
/** Distinct Skill/Agent tool_use blocks that invoked this name. 0 for root. */
|
|
52
|
+
invocations: number;
|
|
53
|
+
/** Usage-bearing turns charged to this bucket. */
|
|
54
|
+
turns: number;
|
|
55
|
+
inputTokens: number;
|
|
56
|
+
outputTokens: number;
|
|
57
|
+
cacheReadTokens: number;
|
|
58
|
+
cacheCreationTokens: number;
|
|
59
|
+
/**
|
|
60
|
+
* `input + cache_creation` of the first usage-bearing turn after this
|
|
61
|
+
* skill's first invoke — the new context admitted at that turn, which is
|
|
62
|
+
* dominated by the skill body just loaded. Null until that turn arrives,
|
|
63
|
+
* and always null for root/agent buckets.
|
|
64
|
+
*/
|
|
65
|
+
invokeContextDelta: number | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RunTelemetry {
|
|
69
|
+
firstTurn?: FirstTurnUsage;
|
|
70
|
+
turns: number;
|
|
71
|
+
toolCalls: number;
|
|
72
|
+
skillUsage: SkillUsageRow[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Minimal usage shape shared by stream-json and transcript lines. */
|
|
76
|
+
interface TurnUsageLike {
|
|
77
|
+
input_tokens?: number;
|
|
78
|
+
output_tokens?: number;
|
|
79
|
+
cache_read_input_tokens?: number;
|
|
80
|
+
cache_creation_input_tokens?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface Bucket extends SkillUsageRow {
|
|
84
|
+
awaitingInvokeDelta: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function bucketKey(kind: AttributionKind, name: string): string {
|
|
88
|
+
return `${kind}:${name}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function newBucket(kind: AttributionKind, name: string): Bucket {
|
|
92
|
+
return {
|
|
93
|
+
kind,
|
|
94
|
+
name,
|
|
95
|
+
invocations: 0,
|
|
96
|
+
turns: 0,
|
|
97
|
+
inputTokens: 0,
|
|
98
|
+
outputTokens: 0,
|
|
99
|
+
cacheReadTokens: 0,
|
|
100
|
+
cacheCreationTokens: 0,
|
|
101
|
+
invokeContextDelta: null,
|
|
102
|
+
awaitingInvokeDelta: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function toolUseInput(block: ContentBlock): Record<string, unknown> {
|
|
107
|
+
const input = (block as { input?: unknown }).input;
|
|
108
|
+
return input && typeof input === "object"
|
|
109
|
+
? (input as Record<string, unknown>)
|
|
110
|
+
: {};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class RunTelemetryAccumulator {
|
|
114
|
+
private firstTurn: FirstTurnUsage | undefined;
|
|
115
|
+
private turns = 0;
|
|
116
|
+
private readonly seenToolUseIds = new Set<string>();
|
|
117
|
+
private readonly buckets = new Map<string, Bucket>();
|
|
118
|
+
/** Bucket key the next non-subagent usage-bearing turn is charged to. */
|
|
119
|
+
private current = bucketKey("root", "root");
|
|
120
|
+
/** Agent tool_use id → subagent type, for `parent_tool_use_id` lookups. */
|
|
121
|
+
private readonly subagentByToolUseId = new Map<string, string>();
|
|
122
|
+
|
|
123
|
+
constructor() {
|
|
124
|
+
this.buckets.set(this.current, newBucket("root", "root"));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Observe an assistant message's content blocks. Safe to call on every
|
|
129
|
+
* stream line, including repeats of the same message id — tool_use blocks
|
|
130
|
+
* are deduped by their own id.
|
|
131
|
+
*/
|
|
132
|
+
observeContent(blocks: ContentBlock[] | undefined): void {
|
|
133
|
+
if (!Array.isArray(blocks)) return;
|
|
134
|
+
for (const block of blocks) {
|
|
135
|
+
if (block.type !== "tool_use") continue;
|
|
136
|
+
const id = (block as { id?: unknown }).id;
|
|
137
|
+
if (typeof id !== "string" || this.seenToolUseIds.has(id)) continue;
|
|
138
|
+
this.seenToolUseIds.add(id);
|
|
139
|
+
|
|
140
|
+
const name = (block as { name?: unknown }).name;
|
|
141
|
+
if (name === "Skill") {
|
|
142
|
+
const skill = toolUseInput(block).skill;
|
|
143
|
+
if (typeof skill === "string" && skill.length > 0) {
|
|
144
|
+
const key = bucketKey("skill", skill);
|
|
145
|
+
const bucket = this.ensureBucket("skill", skill);
|
|
146
|
+
bucket.invocations += 1;
|
|
147
|
+
if (bucket.invokeContextDelta === null)
|
|
148
|
+
bucket.awaitingInvokeDelta = true;
|
|
149
|
+
this.current = key;
|
|
150
|
+
}
|
|
151
|
+
} else if (name === "Agent" || name === "Task") {
|
|
152
|
+
const subagent = toolUseInput(block).subagent_type;
|
|
153
|
+
const type =
|
|
154
|
+
typeof subagent === "string" && subagent.length > 0
|
|
155
|
+
? subagent
|
|
156
|
+
: "unknown";
|
|
157
|
+
this.subagentByToolUseId.set(id, type);
|
|
158
|
+
this.ensureBucket("agent", type).invocations += 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Observe a usage-bearing assistant turn. The caller dedupes by message id
|
|
165
|
+
* (same contract as `_consumeStream`'s per-model accumulation).
|
|
166
|
+
*/
|
|
167
|
+
observeUsage(
|
|
168
|
+
messageId: string,
|
|
169
|
+
model: string,
|
|
170
|
+
usage: TurnUsageLike,
|
|
171
|
+
parentToolUseId?: string,
|
|
172
|
+
): void {
|
|
173
|
+
const input = usage.input_tokens ?? 0;
|
|
174
|
+
const output = usage.output_tokens ?? 0;
|
|
175
|
+
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
|
176
|
+
const cacheCreation = usage.cache_creation_input_tokens ?? 0;
|
|
177
|
+
|
|
178
|
+
this.turns += 1;
|
|
179
|
+
if (this.firstTurn === undefined && parentToolUseId === undefined) {
|
|
180
|
+
this.firstTurn = {
|
|
181
|
+
model,
|
|
182
|
+
messageId,
|
|
183
|
+
inputTokens: input,
|
|
184
|
+
cacheCreationTokens: cacheCreation,
|
|
185
|
+
cacheReadTokens: cacheRead,
|
|
186
|
+
contextTokens: input + cacheCreation + cacheRead,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const bucket =
|
|
191
|
+
parentToolUseId !== undefined
|
|
192
|
+
? this.ensureBucket(
|
|
193
|
+
"agent",
|
|
194
|
+
this.subagentByToolUseId.get(parentToolUseId) ?? "unknown",
|
|
195
|
+
)
|
|
196
|
+
: (this.buckets.get(this.current) ?? this.ensureBucket("root", "root"));
|
|
197
|
+
|
|
198
|
+
bucket.turns += 1;
|
|
199
|
+
bucket.inputTokens += input;
|
|
200
|
+
bucket.outputTokens += output;
|
|
201
|
+
bucket.cacheReadTokens += cacheRead;
|
|
202
|
+
bucket.cacheCreationTokens += cacheCreation;
|
|
203
|
+
if (bucket.awaitingInvokeDelta) {
|
|
204
|
+
bucket.invokeContextDelta = input + cacheCreation;
|
|
205
|
+
bucket.awaitingInvokeDelta = false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
get toolCalls(): number {
|
|
210
|
+
return this.seenToolUseIds.size;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
snapshot(): RunTelemetry {
|
|
214
|
+
const skillUsage: SkillUsageRow[] = [];
|
|
215
|
+
for (const {
|
|
216
|
+
awaitingInvokeDelta: _pending,
|
|
217
|
+
...row
|
|
218
|
+
} of this.buckets.values()) {
|
|
219
|
+
// Drop an untouched root bucket so a run that never produced a turn
|
|
220
|
+
// reports an empty attribution list rather than a zero row.
|
|
221
|
+
if (row.kind === "root" && row.turns === 0) continue;
|
|
222
|
+
skillUsage.push({ ...row });
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
...(this.firstTurn !== undefined && { firstTurn: { ...this.firstTurn } }),
|
|
226
|
+
turns: this.turns,
|
|
227
|
+
toolCalls: this.toolCalls,
|
|
228
|
+
skillUsage,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private ensureBucket(kind: AttributionKind, name: string): Bucket {
|
|
233
|
+
const key = bucketKey(kind, name);
|
|
234
|
+
let bucket = this.buckets.get(key);
|
|
235
|
+
if (!bucket) {
|
|
236
|
+
bucket = newBucket(kind, name);
|
|
237
|
+
this.buckets.set(key, bucket);
|
|
238
|
+
}
|
|
239
|
+
return bucket;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ContentBlock } from "./progress-milestones.ts";
|
|
3
|
+
import { RunTelemetryAccumulator } from "./run-telemetry.ts";
|
|
4
|
+
|
|
5
|
+
const MODEL = "claude-sonnet-4-6";
|
|
6
|
+
|
|
7
|
+
function usage(
|
|
8
|
+
input: number,
|
|
9
|
+
output: number,
|
|
10
|
+
cacheRead = 0,
|
|
11
|
+
cacheCreation = 0,
|
|
12
|
+
) {
|
|
13
|
+
return {
|
|
14
|
+
input_tokens: input,
|
|
15
|
+
output_tokens: output,
|
|
16
|
+
cache_read_input_tokens: cacheRead,
|
|
17
|
+
cache_creation_input_tokens: cacheCreation,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function skillBlock(id: string, skill: string): ContentBlock {
|
|
22
|
+
return {
|
|
23
|
+
type: "tool_use",
|
|
24
|
+
id,
|
|
25
|
+
name: "Skill",
|
|
26
|
+
input: { skill },
|
|
27
|
+
} as ContentBlock;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function agentBlock(
|
|
31
|
+
id: string,
|
|
32
|
+
subagentType: string,
|
|
33
|
+
name: "Agent" | "Task" = "Agent",
|
|
34
|
+
): ContentBlock {
|
|
35
|
+
return {
|
|
36
|
+
type: "tool_use",
|
|
37
|
+
id,
|
|
38
|
+
name,
|
|
39
|
+
input: { subagent_type: subagentType, prompt: "go" },
|
|
40
|
+
} as ContentBlock;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readBlock(id: string): ContentBlock {
|
|
44
|
+
return { type: "tool_use", id, name: "Read", input: {} } as ContentBlock;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("RunTelemetryAccumulator — first-turn baseline", () => {
|
|
48
|
+
test("captures the first usage-bearing turn and sums all three input kinds", () => {
|
|
49
|
+
const acc = new RunTelemetryAccumulator();
|
|
50
|
+
acc.observeUsage("msg_1", MODEL, usage(2, 297, 37_771, 41_415));
|
|
51
|
+
acc.observeUsage("msg_2", MODEL, usage(500, 10, 79_000, 600));
|
|
52
|
+
|
|
53
|
+
const snap = acc.snapshot();
|
|
54
|
+
expect(snap.firstTurn).toEqual({
|
|
55
|
+
model: MODEL,
|
|
56
|
+
messageId: "msg_1",
|
|
57
|
+
inputTokens: 2,
|
|
58
|
+
cacheCreationTokens: 41_415,
|
|
59
|
+
cacheReadTokens: 37_771,
|
|
60
|
+
contextTokens: 2 + 41_415 + 37_771,
|
|
61
|
+
});
|
|
62
|
+
expect(snap.turns).toBe(2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a sub-agent turn never becomes the first-turn baseline", () => {
|
|
66
|
+
const acc = new RunTelemetryAccumulator();
|
|
67
|
+
acc.observeContent([agentBlock("tu_agent", "Explore")]);
|
|
68
|
+
acc.observeUsage("sub_1", MODEL, usage(50, 5), "tu_agent");
|
|
69
|
+
acc.observeUsage("msg_1", MODEL, usage(1, 1, 100, 200));
|
|
70
|
+
|
|
71
|
+
expect(acc.snapshot().firstTurn?.messageId).toBe("msg_1");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a Task-named dispatch buckets and attributes sub-agent turns like an Agent-named one", () => {
|
|
75
|
+
const run = (name: "Agent" | "Task") => {
|
|
76
|
+
const acc = new RunTelemetryAccumulator();
|
|
77
|
+
acc.observeContent([agentBlock("tu_agent", "Explore", name)]);
|
|
78
|
+
acc.observeUsage("sub_1", MODEL, usage(50, 5), "tu_agent");
|
|
79
|
+
acc.observeUsage("msg_1", MODEL, usage(1, 1, 100, 200));
|
|
80
|
+
return acc.snapshot();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const taskSnap = run("Task");
|
|
84
|
+
expect(taskSnap).toEqual(run("Agent"));
|
|
85
|
+
expect(taskSnap.skillUsage.map((r) => r.name)).toContain("Explore");
|
|
86
|
+
expect(taskSnap.firstTurn?.messageId).toBe("msg_1");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("snapshot of an empty run has no firstTurn, zero counts, and no rows", () => {
|
|
90
|
+
expect(new RunTelemetryAccumulator().snapshot()).toEqual({
|
|
91
|
+
turns: 0,
|
|
92
|
+
toolCalls: 0,
|
|
93
|
+
skillUsage: [],
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("RunTelemetryAccumulator — tool calls", () => {
|
|
99
|
+
test("counts distinct tool_use ids across repeated lines of the same message", () => {
|
|
100
|
+
const acc = new RunTelemetryAccumulator();
|
|
101
|
+
acc.observeContent([readBlock("tu_1")]);
|
|
102
|
+
acc.observeContent([readBlock("tu_1"), readBlock("tu_2")]);
|
|
103
|
+
acc.observeContent(undefined);
|
|
104
|
+
acc.observeContent([{ type: "text", text: "hi" } as ContentBlock]);
|
|
105
|
+
|
|
106
|
+
expect(acc.toolCalls).toBe(2);
|
|
107
|
+
expect(acc.snapshot().toolCalls).toBe(2);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("RunTelemetryAccumulator — skill attribution", () => {
|
|
112
|
+
test("turns before any invoke accrue to root; later turns to the last invoked skill", () => {
|
|
113
|
+
const acc = new RunTelemetryAccumulator();
|
|
114
|
+
acc.observeUsage("msg_1", MODEL, usage(10, 1));
|
|
115
|
+
acc.observeContent([skillBlock("tu_skill", "shipwright:task-store")]);
|
|
116
|
+
acc.observeUsage("msg_2", MODEL, usage(20, 2, 0, 9_000));
|
|
117
|
+
acc.observeUsage("msg_3", MODEL, usage(30, 3));
|
|
118
|
+
|
|
119
|
+
const rows = acc.snapshot().skillUsage;
|
|
120
|
+
expect(rows).toEqual([
|
|
121
|
+
{
|
|
122
|
+
kind: "root",
|
|
123
|
+
name: "root",
|
|
124
|
+
invocations: 0,
|
|
125
|
+
turns: 1,
|
|
126
|
+
inputTokens: 10,
|
|
127
|
+
outputTokens: 1,
|
|
128
|
+
cacheReadTokens: 0,
|
|
129
|
+
cacheCreationTokens: 0,
|
|
130
|
+
invokeContextDelta: null,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
kind: "skill",
|
|
134
|
+
name: "shipwright:task-store",
|
|
135
|
+
invocations: 1,
|
|
136
|
+
turns: 2,
|
|
137
|
+
inputTokens: 50,
|
|
138
|
+
outputTokens: 5,
|
|
139
|
+
cacheReadTokens: 0,
|
|
140
|
+
cacheCreationTokens: 9_000,
|
|
141
|
+
invokeContextDelta: 20 + 9_000,
|
|
142
|
+
},
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a second invoke of the same skill counts as an invocation but keeps the first delta", () => {
|
|
147
|
+
const acc = new RunTelemetryAccumulator();
|
|
148
|
+
acc.observeContent([skillBlock("tu_a", "shipwright:review")]);
|
|
149
|
+
acc.observeUsage("msg_1", MODEL, usage(5, 1, 0, 100));
|
|
150
|
+
acc.observeContent([skillBlock("tu_b", "shipwright:review")]);
|
|
151
|
+
acc.observeUsage("msg_2", MODEL, usage(5, 1, 0, 999));
|
|
152
|
+
|
|
153
|
+
const [row] = acc.snapshot().skillUsage;
|
|
154
|
+
expect(row.name).toBe("shipwright:review");
|
|
155
|
+
expect(row.invocations).toBe(2);
|
|
156
|
+
expect(row.turns).toBe(2);
|
|
157
|
+
expect(row.invokeContextDelta).toBe(105);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("a skill invoked with no usage-bearing turn afterwards has a null delta", () => {
|
|
161
|
+
const acc = new RunTelemetryAccumulator();
|
|
162
|
+
acc.observeContent([skillBlock("tu_a", "shipwright:deploy")]);
|
|
163
|
+
const [row] = acc.snapshot().skillUsage;
|
|
164
|
+
expect(row.invocations).toBe(1);
|
|
165
|
+
expect(row.turns).toBe(0);
|
|
166
|
+
expect(row.invokeContextDelta).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a Skill block without a skill name is counted as a tool call but not attributed", () => {
|
|
170
|
+
const acc = new RunTelemetryAccumulator();
|
|
171
|
+
acc.observeContent([
|
|
172
|
+
{
|
|
173
|
+
type: "tool_use",
|
|
174
|
+
id: "tu_x",
|
|
175
|
+
name: "Skill",
|
|
176
|
+
input: {},
|
|
177
|
+
} as ContentBlock,
|
|
178
|
+
]);
|
|
179
|
+
acc.observeUsage("msg_1", MODEL, usage(1, 1));
|
|
180
|
+
const snap = acc.snapshot();
|
|
181
|
+
expect(snap.toolCalls).toBe(1);
|
|
182
|
+
expect(snap.skillUsage.map((r) => r.kind)).toEqual(["root"]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("switching skills charges later turns to the newest invoke only", () => {
|
|
186
|
+
const acc = new RunTelemetryAccumulator();
|
|
187
|
+
acc.observeContent([skillBlock("tu_a", "a")]);
|
|
188
|
+
acc.observeUsage("msg_1", MODEL, usage(1, 1));
|
|
189
|
+
acc.observeContent([skillBlock("tu_b", "b")]);
|
|
190
|
+
acc.observeUsage("msg_2", MODEL, usage(2, 2));
|
|
191
|
+
acc.observeUsage("msg_3", MODEL, usage(3, 3));
|
|
192
|
+
|
|
193
|
+
const byName = Object.fromEntries(
|
|
194
|
+
acc.snapshot().skillUsage.map((r) => [r.name, r]),
|
|
195
|
+
);
|
|
196
|
+
expect(byName.a.turns).toBe(1);
|
|
197
|
+
expect(byName.b.turns).toBe(2);
|
|
198
|
+
expect(byName.b.inputTokens).toBe(5);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("RunTelemetryAccumulator — sub-agent attribution", () => {
|
|
203
|
+
test("turns carrying parent_tool_use_id go to agent:<type> and leave the current skill alone", () => {
|
|
204
|
+
const acc = new RunTelemetryAccumulator();
|
|
205
|
+
acc.observeContent([skillBlock("tu_skill", "shipwright:patch")]);
|
|
206
|
+
acc.observeContent([agentBlock("tu_agent", "shipwright:code-reviewer")]);
|
|
207
|
+
acc.observeUsage("sub_1", MODEL, usage(100, 10), "tu_agent");
|
|
208
|
+
acc.observeUsage("sub_2", MODEL, usage(100, 10), "tu_agent");
|
|
209
|
+
acc.observeUsage("msg_2", MODEL, usage(7, 1, 0, 50));
|
|
210
|
+
|
|
211
|
+
const byKey = Object.fromEntries(
|
|
212
|
+
acc.snapshot().skillUsage.map((r) => [`${r.kind}:${r.name}`, r]),
|
|
213
|
+
);
|
|
214
|
+
expect(byKey["agent:shipwright:code-reviewer"]).toMatchObject({
|
|
215
|
+
invocations: 1,
|
|
216
|
+
turns: 2,
|
|
217
|
+
inputTokens: 200,
|
|
218
|
+
invokeContextDelta: null,
|
|
219
|
+
});
|
|
220
|
+
expect(byKey["skill:shipwright:patch"]).toMatchObject({
|
|
221
|
+
turns: 1,
|
|
222
|
+
inputTokens: 7,
|
|
223
|
+
invokeContextDelta: 57,
|
|
224
|
+
});
|
|
225
|
+
expect(acc.snapshot().turns).toBe(3);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("a sub-agent turn whose parent tool_use was never seen lands in agent:unknown", () => {
|
|
229
|
+
const acc = new RunTelemetryAccumulator();
|
|
230
|
+
acc.observeUsage("sub_1", MODEL, usage(1, 1), "tu_missing");
|
|
231
|
+
expect(acc.snapshot().skillUsage).toEqual([
|
|
232
|
+
expect.objectContaining({ kind: "agent", name: "unknown", turns: 1 }),
|
|
233
|
+
]);
|
|
234
|
+
});
|
|
235
|
+
});
|