From 298d07d9e422306805a91fb9b908ba372c600d3e Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 10 Sep 2026 09:40:49 +0200 Subject: [PATCH 1/3] test(cli): kill the surviving mutants of the telemetry context The telemetry scope held at a floor of 75 with 844 survivors and 135 uncovered mutants of 4413. Tests now name the behaviour each survivor stood for, across the domain (attribution, report, formats), the application use cases (report, diagnose, on, off, identity, local cost) and the adapters (sink, evidence, identity, backlog, run journal, the four per-tool transcript readers). Each was red against its hand-applied mutant before it went green; nothing under src/ changed. Mutation score measured on this tree: 95.1. The floor moves to 93. Refs #799 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- cli/mutation-scopes.json | 2 +- .../diagnose-telemetry-use-case.unit.test.ts | 359 ++++++++++++ .../person-identity-use-case.unit.test.ts | 85 +++ .../read-local-cost-sweep.unit.test.ts | 513 ++++++++++++++++++ .../report-cost-use-case.unit.test.ts | 275 ++++++++++ .../telemetry-off-use-case.unit.test.ts | 89 +++ .../telemetry-on-use-case.unit.test.ts | 123 +++++ .../domain/cost-report-envelope.unit.test.ts | 212 ++++++++ .../telemetry/domain/cost-report.unit.test.ts | 38 ++ .../domain/flow-attribution.unit.test.ts | 7 + ...claude-code-transcript-fields.unit.test.ts | 198 +++++++ .../domain/formats/codex-rollout.unit.test.ts | 153 ++++++ .../formats/copilot-events.unit.test.ts | 54 ++ .../formats/opencode-export.unit.test.ts | 52 ++ .../domain/journal-intervals.unit.test.ts | 24 + .../domain/person-resolution.unit.test.ts | 13 + .../domain/report-period.unit.test.ts | 15 + .../domain/report/axes/day-rows.unit.test.ts | 17 + .../domain/report/axes/flow-rows.unit.test.ts | 93 ++++ .../report/axes/person-rows.unit.test.ts | 120 ++++ .../axes/record-stated-rows.unit.test.ts | 80 +++ .../domain/report/axes/step-rows.unit.test.ts | 58 ++ .../domain/report/axes/task-rows.unit.test.ts | 198 +++++++ .../domain/report/axes/tool-rows.unit.test.ts | 27 + .../report/record-reconciliation.unit.test.ts | 163 ++++++ .../report/report-selection.unit.test.ts | 265 +++++++++ .../domain/report/row-ordering.unit.test.ts | 66 +++ .../domain/session-anchor.unit.test.ts | 18 + .../domain/session-project.unit.test.ts | 15 + .../telemetry/domain/skill-name.unit.test.ts | 4 + .../domain/step-attribution.unit.test.ts | 67 +++ .../domain/task-attribution.unit.test.ts | 21 + .../domain/task-identity.unit.test.ts | 10 + .../telemetry-claim-details.unit.test.ts | 433 +++++++++++++++ .../telemetry-export-leftover.unit.test.ts | 4 + .../domain/telemetry-switch.unit.test.ts | 10 + ...k-trust-reader-adapter.integration.test.ts | 30 + ...de-cost-reader-adapter.integration.test.ts | 73 +++ ...erson-identity-adapter.integration.test.ts | 160 +++++- ...n-journal-reader-lines.integration.test.ts | 312 +++++++++++ .../task-backlog-adapter.integration.test.ts | 66 +++ ...metry-evidence-adapter.integration.test.ts | 302 +++++++++++ ...telemetry-sink-adapter.integration.test.ts | 104 +++- .../telemetry-sink-location.unit.test.ts | 80 ++- ...metry-sink-windows-acl.integration.test.ts | 134 +++++ ...pt-cost-reader-adapter.integration.test.ts | 32 +- 46 files changed, 5169 insertions(+), 5 deletions(-) create mode 100644 cli/tests/contexts/telemetry/application/read-local-cost-sweep.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/formats/claude-code-transcript-fields.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/report/axes/day-rows.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/report/axes/flow-rows.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/report/axes/person-rows.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/report/axes/record-stated-rows.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/report/axes/step-rows.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/report/axes/task-rows.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/report/axes/tool-rows.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/report/record-reconciliation.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/report/report-selection.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/report/row-ordering.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/session-anchor.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/infrastructure/run-journal-reader-lines.integration.test.ts create mode 100644 cli/tests/contexts/telemetry/infrastructure/telemetry-sink-windows-acl.integration.test.ts diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index 22611e29f..4d444a0ac 100644 --- a/cli/mutation-scopes.json +++ b/cli/mutation-scopes.json @@ -18,7 +18,7 @@ }, "telemetry": { "mutate": "src/contexts/telemetry/**/*.ts", - "break": 75 + "break": 93 }, "translate": { "mutate": "src/contexts/translate/**/*.ts", diff --git a/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts index 5b4c177d3..091d508cd 100644 --- a/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts @@ -34,7 +34,10 @@ class StubHookTrustReader implements HookTrustReader { configPath: "/home/.codex/config.toml", }; + reads = 0; + async read(): Promise { + this.reads += 1; return this.trust; } } @@ -546,3 +549,359 @@ describe("DiagnoseTelemetryUseCase — what the host will actually load", () => expect(registration.entries).toEqual([]); }); }); + +class RecordingSessionCostReader implements SessionCostReader { + readonly asked: string[] = []; + + constructor(private readonly result: LocalCostReadResult) {} + + async read(sessionId: string): Promise { + this.asked.push(sessionId); + return this.result; + } +} + +function journalOf(session: RunJournal["session"], lines: Partial = {}): RunJournal { + return { boundaries: [], filesWritten: [], taskDeclarations: [], session, ...lines }; +} + +function claimsOf(result: Awaited>) { + if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); + return result; +} + +function useCaseOverIdentity(store: InMemoryPersonIdentityStore): DiagnoseTelemetryUseCase { + return new DiagnoseTelemetryUseCase( + new StubEvidenceReader(), + versionControl(true), + new InMemoryRunJournalReader(), + new Map(), + new StubHookTrustReader(), + store, + new InMemoryTelemetrySink(), + new FakeCurrentVersion("9.9.9-check"), + installedPluginsFromManifest(new InMemoryManifestRepository(Manifest.create())), + new Map() + ); +} + +describe("DiagnoseTelemetryUseCase — the setup it prints", () => { + it("names the sink's own root as where records land", async () => { + const { useCase } = buildUseCase({}); + + const result = await useCase.execute(runOptions()); + + expect(result.setup.recordsLocation).toStrictEqual({ path: "/fake/telemetry" }); + }); + + it("reports nobody chose an identity as unattached and readable, at the store's own path", async () => { + const { useCase } = buildUseCase({}); + + const result = await useCase.execute(runOptions()); + + expect(result.setup.identity).toStrictEqual({ + attached: false, + path: "/fake/home/.config/aidd/identity.json", + readable: true, + }); + }); + + it("reports a chosen identity as attached", async () => { + const useCase = useCaseOverIdentity( + new InMemoryPersonIdentityStore({ personId: "person-1", origin: "minted", alsoMe: [] }) + ); + + const result = await useCase.execute(runOptions()); + + expect(result.setup.identity).toStrictEqual({ + attached: true, + path: "/fake/home/.config/aidd/identity.json", + readable: true, + }); + }); + + it("reports a damaged identity file as unreadable and unattached, never crashing", async () => { + const store = new InMemoryPersonIdentityStore(null); + store.throwOnRead = new Error("identity.json is a directory"); + + const result = await useCaseOverIdentity(store).execute(runOptions()); + + expect(result.setup.identity).toStrictEqual({ + attached: false, + path: "/fake/home/.config/aidd/identity.json", + readable: false, + }); + }); + + it("says exactly why a non-repository is gated, blaming no hook", async () => { + const { useCase } = buildUseCase({ isRepository: false }); + + const result = await useCase.execute(runOptions()); + + expect(result.gate).toBe( + "not a git repository — the hook has nowhere to write here, not a hook that failed to fire" + ); + }); + + it("names the uncovered tools exactly, each with its declaration's own reason", async () => { + const { useCase } = buildUseCase({}); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.uncovered).toStrictEqual([ + { tool: "cursor", reason: "It writes no token count in any file it produces." }, + ]); + }); +}); + +describe("DiagnoseTelemetryUseCase — reading the host registries", () => { + it("asks no registry for a tool the manifest records no plugin for", async () => { + let codexReads = 0; + const codexRegistry: HostPluginRegistryReader = { + read: async () => { + codexReads += 1; + return { location: "/home/dev/.codex/config.toml", refs: new Map() }; + }, + }; + const registries = new Map(registryCarrying([REF])); + registries.set("codex", codexRegistry); + const { useCase } = buildUseCase({ + manifestRepo: manifestWithClaudePlugin("aidd-framework"), + hostRegistries: registries, + }); + + const result = await useCase.execute(runOptions()); + + expect(result.setup.hostRegistration.entries.map((entry) => entry.tool)).toStrictEqual([ + "claude", + ]); + expect(codexReads).toBe(0); + }); + + it("answers unanswerable for a host that keeps a registry nothing here reads", async () => { + const { useCase } = buildUseCase({ + manifestRepo: manifestWithClaudePlugin("aidd-framework"), + hostRegistries: new Map(), + }); + + const result = await useCase.execute(runOptions()); + + expect(result.setup.hostRegistration).toStrictEqual({ + entries: [ + { + tool: "claude", + plugin: "aidd-telemetry", + ref: REF, + answer: "unanswerable", + detail: "claude keeps a plugin registry, and nothing here has established its shape", + }, + ], + }); + }); + + it("answers unanswerable for a tool that declares no native activation at all", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "aidd-telemetry", + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + "project", + "aidd-framework" + ) + ); + const { useCase } = buildUseCase({ + manifestRepo: new InMemoryManifestRepository(manifest), + hostRegistries: new Map(), + }); + + const result = await useCase.execute(runOptions()); + + expect(result.setup.hostRegistration).toStrictEqual({ + entries: [ + { + tool: "cursor", + plugin: "aidd-telemetry", + ref: REF, + answer: "unanswerable", + detail: "cursor declares no plugin registry to read", + }, + ], + }); + }); +}); + +describe("DiagnoseTelemetryUseCase — what each journal contributes to the claims", () => { + it("survives a journal whose session_start line is torn away, still naming the version another stamped", async () => { + const { useCase } = buildUseCase({ + journals: [ + journalOf(undefined), + journalOf({ + type: "session_start", + at: "2026-08-20T09:00:00Z", + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + tool: "claude-code", + vendor_id: "s-1", + plugin_version: "0.3.0", + }), + ], + }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.setup.versions.plugin).toStrictEqual({ kind: "recorded", version: "0.3.0" }); + }); + + it("asks each covered tool about the sessions the journal names, and about no other", async () => { + const claudeReader = new RecordingSessionCostReader({ records: [], sessionFound: true }); + const { useCase } = buildUseCase({ + journals: [journalOf(undefined), journalOf(sessionStart("s-1"))], + readers: new Map([["claude", claudeReader]]), + }); + + await useCase.execute(runOptions()); + + expect(claudeReader.asked).toStrictEqual(["s-1"]); + }); + + it("summarises the read per covered tool, in the order the tools are declared", async () => { + const claudeReader = new StubSessionCostReader({ records: [], sessionFound: true }); + const { useCase } = buildUseCase({ + journals: [journalOf(sessionStart("s-1"))], + readers: new Map([["claude", claudeReader]]), + }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[2]).toStrictEqual({ + claim: "tool-files-readable", + verdict: "ok", + reason: "session-found", + detail: + "claude: 1 of 1 session(s) read; copilot: 0 of 1 session(s) read; " + + "opencode: 0 of 1 session(s) read; codex: 0 of 1 session(s) read", + }); + }); + + it("says a journal carrying only session_start closed no turn", async () => { + const { useCase } = buildUseCase({ journals: [journalOf(sessionStart("s-1"))] }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[1]).toStrictEqual({ + claim: "session-journalled", + verdict: "fail", + reason: "only-session-start", + detail: "1 run file(s), all carrying only session_start — nothing closed the turn", + }); + }); + + it("says a journal carrying any boundary closed its turn", async () => { + const { useCase } = buildUseCase({ + journals: [ + journalOf(sessionStart("s-1"), { + boundaries: [{ type: "turn_end", at: "2026-08-20T09:30:00Z" }], + }), + ], + }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[1]).toStrictEqual({ + claim: "session-journalled", + verdict: "ok", + reason: "turn-closed", + detail: "1 of 1 run file(s) carry more than session_start", + }); + }); + + it("names the runs directory in the claim that found no run file", async () => { + const { useCase } = buildUseCase({}); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[0]).toStrictEqual({ + claim: "hook-fired", + verdict: "fail", + reason: "recorder-declared-nowhere", + detail: + "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + + "recorder is declared nowhere this build checks", + }); + }); + + it("never asks Codex's hook trust for a session another tool anchors", async () => { + const hookTrustReader = new StubHookTrustReader(); + const { useCase } = buildUseCase({ hookTrustReader }); + + await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-1" })); + + expect(hookTrustReader.reads).toBe(0); + }); + + it("asks Codex's hook trust once for a Codex-anchored session", async () => { + const hookTrustReader = new StubHookTrustReader(); + const { useCase } = buildUseCase({ hookTrustReader }); + + await useCase.execute(runOptions({ CODEX_THREAD_ID: "codex-1" })); + + expect(hookTrustReader.reads).toBe(1); + }); + + it("has no join material when no interval opened and no record states a step", async () => { + const claudeReader = new StubSessionCostReader({ records: [candidate()], sessionFound: true }); + const { useCase } = buildUseCase({ + journals: [journalOf(sessionStart("s-1"))], + readers: new Map([["claude", claudeReader]]), + }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[3]).toStrictEqual({ + claim: "records-join", + verdict: "unknown", + reason: "no-join-material", + detail: "no step interval and no tool-stated step — see session journalled", + }); + }); + + it("joins a record whose tool stated its step, with no interval to judge it against", async () => { + const claudeReader = new StubSessionCostReader({ + records: [candidate({ step: "aidd-dev:01-plan" })], + sessionFound: true, + }); + const { useCase } = buildUseCase({ + journals: [journalOf(sessionStart("s-1"))], + readers: new Map([["claude", claudeReader]]), + }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[3]).toStrictEqual({ + claim: "records-join", + verdict: "ok", + reason: "records-joined", + detail: "1 of 1 record(s) joined a step, 0 unattributed", + }); + }); + + it("reads no record at all from a reader that threw", async () => { + const brokenReader = new StubSessionCostReader(undefined, "ENOENT: no such file"); + const { useCase } = buildUseCase({ + journals: [journalOf(sessionStart("s-1"))], + readers: new Map([["claude", brokenReader]]), + }); + + const result = claimsOf(await useCase.execute(runOptions())); + + expect(result.claims[3]).toStrictEqual({ + claim: "records-join", + verdict: "unknown", + reason: "no-record-to-join", + detail: "no record read to join", + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts index 4c0894e50..5fa6f50b2 100644 --- a/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts @@ -449,3 +449,88 @@ describe("what the errors tell a person to run", () => { } }); }); + +class AddCountingStore extends InMemoryPersonIdentityStore { + addAlsoMeCalls = 0; + + override async addAlsoMe(identity: string) { + this.addAlsoMeCalls += 1; + return super.addAlsoMe(identity); + } +} + +describe("PersonIdentityUseCase — the exact shape of what it answers", () => { + it("names the verb in the refusal of an empty identifier to use", async () => { + await expect( + useCase(new InMemoryPersonIdentityStore(null)).use({ identifier: " " }) + ).rejects.toThrow("`aidd telemetry identity use` needs a non-empty value."); + }); + + it("names the verb in the refusal of an empty identifier to link", async () => { + const store = new InMemoryPersonIdentityStore({ + personId: "person-a", + origin: "minted", + alsoMe: [], + }); + + await expect(useCase(store).link(" ")).rejects.toThrow( + "`aidd telemetry identity link` needs a non-empty value." + ); + }); + + it("answers a minted identifier with no replaced identifier and no display name set", async () => { + const store = new InMemoryPersonIdentityStore(null, "fresh-id"); + + const result = await useCase(store).use({}); + + expect(result).toStrictEqual({ + filePath: "/fake/home/.config/aidd/identity.json", + identity: { personId: "fresh-id", origin: "minted", alsoMe: [] }, + outcome: "minted", + }); + }); + + it("writes no display name key at all when none was asked for", async () => { + const store = new InMemoryPersonIdentityStore(null, "fresh-id"); + + await useCase(store).use({}); + + expect(await store.read()).toStrictEqual({ + personId: "fresh-id", + origin: "minted", + alsoMe: [], + }); + }); + + it("writes onto alsoMe exactly once for a new identifier, and never for one already listed", async () => { + const store = new AddCountingStore({ + personId: "person-a", + origin: "minted", + alsoMe: ["machine-2"], + }); + const uc = useCase(store); + + await uc.link("machine-2"); + await uc.link("person-a"); + await uc.link("machine-3"); + + expect(store.addAlsoMeCalls).toBe(1); + }); + + it("answers a clean withdrawal as not discarding anything damaged", async () => { + const store = new InMemoryPersonIdentityStore({ + personId: "person-1", + origin: "minted", + alsoMe: ["a-second-machine"], + }); + + const result = await useCase(store).off(); + + expect(result).toStrictEqual({ + filePath: "/fake/home/.config/aidd/identity.json", + removed: true, + discardedDamaged: false, + addedIdentifiersRemoved: 1, + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/read-local-cost-sweep.unit.test.ts b/cli/tests/contexts/telemetry/application/read-local-cost-sweep.unit.test.ts new file mode 100644 index 000000000..258e0deff --- /dev/null +++ b/cli/tests/contexts/telemetry/application/read-local-cost-sweep.unit.test.ts @@ -0,0 +1,513 @@ +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { + type LocalCostToolReport, + ReadLocalCostUseCase, +} from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; +import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; +import type { + LocalCostCandidateRecord, + SessionCostReader, +} from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import type { AiToolId } from "../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { NULL_PERSON_IDENTITY_READER } from "../../../helpers/ports/in-memory-person-identity-reader.js"; +import { + InMemoryRunJournalReader, + NULL_RUN_JOURNAL_READER, +} from "../../../helpers/ports/in-memory-run-journal-reader.js"; +import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; +import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; + +const PROJECT_ROOT = "/repo"; +const SESSION_ID = "s-1"; +const TURN_ID = "req_1"; +const AT = new Date("2026-08-20T12:00:00Z"); +const EVIDENCE_READER = new StubTelemetryEvidenceReader(); + +const CANDIDATE: LocalCostCandidateRecord = { + kind: "request", + vendor_id: SESSION_ID, + vendor_field: "sessionId", + turn_id: TURN_ID, + turn_field: "requestId", + model: "claude-sonnet-5", + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 30, + cache_creation_tokens: 40, +}; + +const STORED: TelemetrySinkRecord = { + ...CANDIDATE, + sink_schema_version: 2, + provenance: "local-read", + tool: "claude", + step_attribution: "unattributed", +}; + +const NOT_ASKED = { + status: "not-asked", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 0, +} as const; + +function notAsked(tool: AiToolId): LocalCostToolReport { + return { tool, ...NOT_ASKED }; +} + +const CURSOR_NOT_COVERED: LocalCostToolReport = { + tool: "cursor", + status: "not-covered", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 0, + reason: "It writes no token count in any file it produces.", +}; + +function sessionJournal(vendorId: string, host = "claude-code"): RunJournal { + return { + boundaries: [], + filesWritten: [], + taskDeclarations: [], + session: { + type: "session_start", + at: "2026-08-20T09:00:00Z", + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + tool: host, + vendor_id: vendorId, + }, + }; +} + +function journalNaming(...vendorIds: readonly string[]): InMemoryRunJournalReader { + const reader = new InMemoryRunJournalReader(); + for (const vendorId of vendorIds) reader.set(vendorId, sessionJournal(vendorId)); + return reader; +} + +type Answer = readonly LocalCostCandidateRecord[] | "absent" | Error; + +function readerAnswering(answers: ReadonlyMap): SessionCostReader { + return { + read: async (sessionId: string) => { + const answer = answers.get(sessionId) ?? "absent"; + if (answer instanceof Error) throw answer; + if (answer === "absent") return { records: [], sessionFound: false }; + return { records: answer, sessionFound: true }; + }, + }; +} + +function claudeOnly(reader: SessionCostReader): ReadonlyMap { + return new Map([["claude", reader]]); +} + +async function sweep( + readers: ReadonlyMap, + journals: InMemoryRunJournalReader | typeof NULL_RUN_JOURNAL_READER, + sink = new InMemoryTelemetrySink() +) { + const useCase = new ReadLocalCostUseCase( + sink, + readers, + journals, + NULL_PERSON_IDENTITY_READER, + EVIDENCE_READER + ); + return useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT }); +} + +async function readOne( + sink: InMemoryTelemetrySink, + candidates: readonly LocalCostCandidateRecord[] +): Promise { + const result = await new ReadLocalCostUseCase( + sink, + claudeOnly(readerAnswering(new Map([[SESSION_ID, candidates]]))), + NULL_RUN_JOURNAL_READER, + NULL_PERSON_IDENTITY_READER, + EVIDENCE_READER + ).execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT, sessionId: SESSION_ID }); + const claude = result.toolReports.find((report) => report.tool === "claude"); + if (claude === undefined) throw new Error("no claude report"); + return claude.recordsStored; +} + +function claudeReport(reports: readonly LocalCostToolReport[]): LocalCostToolReport { + const claude = reports.find((report) => report.tool === "claude"); + if (claude === undefined) throw new Error("no claude report"); + return claude; +} + +describe("which sessions a sweep reads", () => { + it("names every session the journal anchors and skips a run file that anchors none", async () => { + const journals = journalNaming("s-a"); + journals.set("torn", { boundaries: [], filesWritten: [], taskDeclarations: [] }); + journals.set("s-b", sessionJournal("s-b")); + + const result = await sweep(claudeOnly(readerAnswering(new Map())), journals); + + expect(result.sessions.map((session) => session.sessionId)).toStrictEqual(["s-a", "s-b"]); + }); + + it("answers not-asked for every tool, and nothing more, when the journal names no session", async () => { + const result = await sweep(claudeOnly(readerAnswering(new Map())), NULL_RUN_JOURNAL_READER); + + expect(result).toStrictEqual({ + sessions: [], + toolReports: [ + notAsked("claude"), + notAsked("cursor"), + notAsked("copilot"), + notAsked("opencode"), + notAsked("codex"), + ], + }); + }); + + it("states the refusal in the words a person can act on", async () => { + const refused = new StubTelemetryEvidenceReader(); + refused.enabled = false; + const useCase = new ReadLocalCostUseCase( + new InMemoryTelemetrySink(), + claudeOnly(readerAnswering(new Map([[SESSION_ID, [CANDIDATE]]]))), + journalNaming(SESSION_ID), + NULL_PERSON_IDENTITY_READER, + refused + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT }); + + expect(result).toStrictEqual({ + sessions: [], + toolReports: [ + notAsked("claude"), + notAsked("cursor"), + notAsked("copilot"), + notAsked("opencode"), + notAsked("codex"), + ], + refusedReason: + "measurement is refused — AIDD_TELEMETRY=0 or the project switch is off; nothing read, " + + "nothing stored", + }); + }); +}); + +describe("one session's answers, tool by tool", () => { + it("lists every tool once, in registry order, each with its own answer", async () => { + const result = await sweep( + claudeOnly(readerAnswering(new Map([[SESSION_ID, [CANDIDATE]]]))), + journalNaming(SESSION_ID) + ); + + expect(result.sessions).toStrictEqual([ + { + sessionId: SESSION_ID, + toolReports: [ + { tool: "claude", status: "found", recordsFound: 1, recordsStored: 1, sessionsFailed: 0 }, + CURSOR_NOT_COVERED, + notAsked("copilot"), + notAsked("opencode"), + notAsked("codex"), + ], + }, + ]); + }); + + it("reports not-found, never unreadable, for a covered tool that was given no reader", async () => { + const result = await sweep(new Map(), journalNaming(SESSION_ID)); + + expect(claudeReport(result.toolReports)).toStrictEqual({ + tool: "claude", + status: "not-found", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 0, + }); + }); + + it("carries no failure field at all for a tool whose every session read cleanly", async () => { + const result = await sweep( + claudeOnly(readerAnswering(new Map([[SESSION_ID, [CANDIDATE]]]))), + journalNaming(SESSION_ID) + ); + + expect(claudeReport(result.toolReports)).toStrictEqual({ + tool: "claude", + status: "found", + recordsFound: 1, + recordsStored: 1, + sessionsFailed: 0, + }); + }); +}); + +describe("the strongest answer a tool gave across a sweep", () => { + it("ranks empty above not-found, whichever session came first", async () => { + const result = await sweep( + claudeOnly( + readerAnswering( + new Map([ + ["s-a", "absent"], + ["s-b", []], + ]) + ) + ), + journalNaming("s-a", "s-b") + ); + + expect(claudeReport(result.toolReports).status).toBe("empty"); + }); + + it("ranks found above empty, so a session that billed nothing cannot hide one that did", async () => { + const result = await sweep( + claudeOnly( + readerAnswering( + new Map([ + ["s-a", []], + ["s-b", [{ ...CANDIDATE, vendor_id: "s-b" }]], + ]) + ) + ), + journalNaming("s-a", "s-b") + ); + + expect(claudeReport(result.toolReports)).toStrictEqual({ + tool: "claude", + status: "found", + recordsFound: 1, + recordsStored: 1, + sessionsFailed: 0, + }); + }); + + it("ranks not-found above not-asked, so a session that never asked cannot outrank one that looked", async () => { + const journals = new InMemoryRunJournalReader(); + journals.set("s-a", sessionJournal("s-a", "codex")); + journals.set("s-b", sessionJournal("s-b")); + + const result = await sweep( + claudeOnly(readerAnswering(new Map([["s-b", "absent"]]))), + journals + ); + + expect(claudeReport(result.toolReports)).toStrictEqual({ + tool: "claude", + status: "not-found", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 0, + }); + }); + + it("keeps the first of two unreadable sessions as the reason and the last as the failure", async () => { + const result = await sweep( + claudeOnly( + readerAnswering( + new Map([ + ["s-a", new Error("first")], + ["s-b", new Error("second")], + ]) + ) + ), + journalNaming("s-a", "s-b") + ); + + expect(claudeReport(result.toolReports)).toStrictEqual({ + tool: "claude", + status: "unreadable", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 2, + reason: "first", + failureReason: "second", + }); + }); + + it("ranks found above unreadable and still counts the session it could not read", async () => { + const result = await sweep( + claudeOnly( + readerAnswering( + new Map([ + ["s-a", new Error("boom")], + ["s-b", [{ ...CANDIDATE, vendor_id: "s-b" }]], + ]) + ) + ), + journalNaming("s-a", "s-b") + ); + + expect(claudeReport(result.toolReports)).toStrictEqual({ + tool: "claude", + status: "found", + recordsFound: 1, + recordsStored: 1, + sessionsFailed: 1, + failureReason: "boom", + }); + }); +}); + +describe("what counts as a correction of a stored turn", () => { + it("stores a reading that adds a counter the stored line never had", async () => { + const sink = new InMemoryTelemetrySink(); + const { cache_creation_tokens: _dropped, ...withoutCacheCreation } = STORED; + await sink.appendRecord(withoutCacheCreation, AT); + + expect(await readOne(sink, [CANDIDATE])).toBe(1); + }); + + it("drops a reading that lost a counter the stored line has, however large the rest", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord(STORED, AT); + const { cache_read_tokens: _dropped, ...withoutCacheRead } = CANDIDATE; + + expect(await readOne(sink, [{ ...withoutCacheRead, output_tokens: 900 }])).toBe(0); + }); + + it("drops a reading that grew one counter but shrank another", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord(STORED, AT); + + expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 900, input_tokens: 5 }])).toBe(0); + }); + + it("measures a correction against the largest stored reading, not the latest", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord({ ...STORED, output_tokens: 900 }, AT); + await sink.appendRecord(STORED, AT); + + expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 500 }])).toBe(0); + }); + + it("measures a correction against the largest stored reading, not the earliest", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord(STORED, AT); + await sink.appendRecord({ ...STORED, output_tokens: 900 }, AT); + + expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 500 }])).toBe(0); + }); + + it("measures against the earliest of two equally large stored readings", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord(STORED, AT); + await sink.appendRecord({ ...STORED, input_tokens: 20, output_tokens: 10 }, AT); + + expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 21 }])).toBe(1); + }); + + it("never lets a session total correct a request line that shares its turn id", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord(STORED, AT); + const sessionTotal: LocalCostCandidateRecord = { + kind: "session", + vendor_id: SESSION_ID, + vendor_field: "sessionId", + turn_id: TURN_ID, + turn_field: "requestId", + input_tokens: 100, + output_tokens: 200, + cache_read_tokens: 300, + cache_creation_tokens: 400, + }; + + expect(await readOne(sink, [sessionTotal])).toBe(0); + }); + + it("drops a turn already stored from an export, without a local-read line to measure against", async () => { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord({ ...STORED, provenance: "export" }, AT); + + expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 900 }])).toBe(0); + }); +}); + +class CountingSink extends InMemoryTelemetrySink { + vendorReads = 0; + listingFails = false; + + override async readRecordsForVendor(vendorId: string): Promise { + this.vendorReads += 1; + return super.readRecordsForVendor(vendorId); + } + + override async listDayFiles(): Promise { + if (this.listingFails) throw new Error("day files unlistable"); + return super.listDayFiles(); + } +} + +describe("housekeeping around a sweep", () => { + it("never consults the sink for a session whose reader returned nothing", async () => { + const sink = new CountingSink(); + + await readOne(sink, []); + + expect(sink.vendorReads).toBe(0); + }); + + it("warns, in its own words, when the day files cannot be listed, and still answers", async () => { + const sink = new CountingSink(); + sink.listingFails = true; + const logger = new CapturingLogger(); + const useCase = new ReadLocalCostUseCase( + sink, + claudeOnly(readerAnswering(new Map([[SESSION_ID, [CANDIDATE]]]))), + journalNaming(SESSION_ID), + NULL_PERSON_IDENTITY_READER, + EVIDENCE_READER, + undefined, + logger + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT }); + + expect(logger.warnMessages).toStrictEqual([ + "telemetry read: retention prune failed - day files unlistable", + ]); + expect(claudeReport(result.toolReports).recordsStored).toBe(1); + }); + + it("warns once per day file it could not delete, naming the file and the cause", async () => { + const sink = new InMemoryTelemetrySink(); + for (const day of ["2026-01-01", "2026-01-02", "2026-08-20"]) { + await sink.appendRecord(STORED, new Date(`${day}T00:00:00Z`)); + } + sink.undeletable.add("2026-01-01.jsonl"); + const logger = new CapturingLogger(); + const useCase = new ReadLocalCostUseCase( + sink, + claudeOnly(readerAnswering(new Map())), + journalNaming(SESSION_ID), + NULL_PERSON_IDENTITY_READER, + EVIDENCE_READER, + undefined, + logger, + 1 + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT }); + + expect(logger.warnMessages).toStrictEqual([ + "telemetry read: could not delete 2026-01-01.jsonl - cannot delete 2026-01-01.jsonl", + ]); + expect(sink.deletedFiles).toStrictEqual(["2026-01-02.jsonl"]); + }); + + it("leaves a dated record unattributed when no journal exists to place it in a step", async () => { + const sink = new InMemoryTelemetrySink(); + + await readOne(sink, [{ ...CANDIDATE, event_timestamp: "2026-08-20T10:02:00Z" }]); + + const [stored] = [...sink.files.values()].flat(); + expect(stored).toMatchObject({ step_attribution: "unattributed", step: undefined }); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts index 515fd004d..0f198d500 100644 --- a/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts @@ -892,3 +892,278 @@ describe("a report that catches the sink up first", () => { expect(reads).toBe(0); }); }); + +class RecordingTaskBacklogReader extends InMemoryTaskBacklogReader { + readonly asked: string[] = []; + + override async read(taskFolderPath: string) { + this.asked.push(taskFolderPath); + return super.read(taskFolderPath); + } +} + +describe("ReportCostUseCase — what it assembles for the report", () => { + const SESSION_AT = "2026-08-18T09:00:00Z"; + const NO_CAPABILITY_SUPPLY = { tokenCounters: true, amount: false } as const; + + let sink: InMemoryTelemetrySink; + let journals: InMemoryRunJournalReader; + let taskBacklog: RecordingTaskBacklogReader; + let logger: CapturingLogger; + + beforeEach(() => { + sink = new InMemoryTelemetrySink(); + journals = new InMemoryRunJournalReader(); + taskBacklog = new RecordingTaskBacklogReader(); + logger = new CapturingLogger(); + }); + + function report(read?: ReadLocalCostUseCase): ReportCostUseCase { + return new ReportCostUseCase( + sink, + journals, + new InMemoryPersonIdentityStore(), + new StubTelemetryEvidenceReader(), + taskBacklog, + logger, + read + ); + } + + function sessionJournal(vendorId: string, lines: Partial): RunJournal { + return { + boundaries: [], + filesWritten: [], + taskDeclarations: [], + session: { + type: "session_start", + at: SESSION_AT, + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + tool: "claude-code", + vendor_id: vendorId, + }, + ...lines, + }; + } + + it("declares every tool exactly as its profile does, limitation and refusal included", async () => { + const built = await report().execute({ ...BASE_OPTIONS, period: PERIOD }); + + const declarations = built.byTools.map(({ totals: _totals, ...declaration }) => declaration); + expect(declarations).toStrictEqual([ + { + tool: "claude", + coverage: "covered", + capability: { + localRead: { ...NO_CAPABILITY_SUPPLY, toolStatedStep: true, agentName: true }, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + { + tool: "cursor", + coverage: "not-covered", + reason: "It writes no token count in any file it produces.", + capability: { + localRead: null, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + { + tool: "copilot", + coverage: "covered", + reason: + "Its own file names outputTokens per turn, but session.shutdown carries all four " + + "counters for the whole session — a session total, never a sum of requests. Its four " + + "counters are measured disjoint, cached prompt included.", + capability: { + localRead: { ...NO_CAPABILITY_SUPPLY, toolStatedStep: false, agentName: false }, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + { + tool: "opencode", + coverage: "covered", + reason: + "Its four counters are measured disjoint for the anthropic provider and for one " + + "OpenAI-compatible provider whose cache was exercised — not confirmed for a " + + "provider that reports prompt tokens inclusive of the cached ones, which none " + + "captured here does.", + capability: { + localRead: { ...NO_CAPABILITY_SUPPLY, toolStatedStep: false, agentName: false }, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + { + tool: "codex", + coverage: "covered", + capability: { + localRead: { ...NO_CAPABILITY_SUPPLY, toolStatedStep: false, agentName: false }, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + ]); + }); + + it("hands the generic filters to the report, which narrows to the value asked for", async () => { + await sink.appendRecord(record({ vendor_id: "s-1", model: "opus" }), STORED_ON); + await sink.appendRecord(record({ vendor_id: "s-2", model: "sonnet" }), STORED_ON); + + const built = await report().execute({ + ...BASE_OPTIONS, + period: PERIOD, + filters: { model: "opus" }, + }); + + expect(built.filters).toStrictEqual({ model: "opus" }); + expect(built.totals).toStrictEqual({ requests: 1 }); + }); + + it("reports through a journal whose session_start line is torn away", async () => { + journals.set("torn", { boundaries: [], filesWritten: [], taskDeclarations: [] }); + await sink.appendRecord(record({ vendor_id: "s-1" }), STORED_ON); + + const built = await report().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.totals).toStrictEqual({ requests: 1 }); + }); + + it("catches up past a journal whose session_start line is torn away", async () => { + journals.set("torn", { boundaries: [], filesWritten: [], taskDeclarations: [] }); + const read = new ReadLocalCostUseCase( + sink, + new Map([["claude", { read: async () => ({ records: [], sessionFound: false }) }]]), + journals, + NULL_PERSON_IDENTITY_READER, + new StubTelemetryEvidenceReader() + ); + + const built = await report(read).execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.totals).toStrictEqual({ requests: 0 }); + }); + + it("witnesses a moment through the session_start line alone when no other line is dated", async () => { + journals.set( + "s-1", + sessionJournal("s-1", { + filesWritten: [ + { type: "file_written", at: "not a moment", path: `aidd_docs/tasks/${TASK}/plan.md` }, + ], + }) + ); + await sink.appendRecord( + record({ vendor_id: "s-1", event_timestamp: "2026-08-18T09:00:00.500Z" }), + STORED_ON + ); + + const built = await report().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.byTasks).toStrictEqual([ + { task: TASK, attribution: "inferred", totals: { requests: 1 } }, + ]); + }); + + it("witnesses nothing from a journal whose every line is undated, rather than everything", async () => { + journals.set( + "s-1", + sessionJournal("s-1", { + session: { + type: "session_start", + at: "not a moment", + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + tool: "claude-code", + vendor_id: "s-1", + }, + }) + ); + await sink.appendRecord(record({ vendor_id: "s-1" }), STORED_ON); + + const built = await report().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.byTasks).toStrictEqual([{ reason: "no-declaration", totals: { requests: 1 } }]); + }); + + it("asks the backlog once per task folder, never per written file and never for a path outside a task", async () => { + journals.set( + "s-1", + sessionJournal("s-1", { + filesWritten: [ + { type: "file_written", at: SESSION_AT, path: `aidd_docs/tasks/${TASK}/plan.md` }, + { type: "file_written", at: SESSION_AT, path: `aidd_docs/tasks/${TASK}/spec.md` }, + { type: "file_written", at: SESSION_AT, path: "src/index.ts" }, + ], + }) + ); + + await report().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(taskBacklog.asked).toStrictEqual([taskFolderPathFromIdentity(TASK)]); + }); + + it("asks the backlog about a task the journal only declared, with no file written into it", async () => { + journals.set( + "s-1", + sessionJournal("s-1", { + boundaries: [{ type: "turn_end", at: "2026-08-18T10:00:00Z" }], + taskDeclarations: [ + { type: "task_declared", at: SESSION_AT, path: `aidd_docs/tasks/${TASK}/spec.md` }, + ], + }) + ); + + await report().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(taskBacklog.asked).toStrictEqual([taskFolderPathFromIdentity(TASK)]); + }); + + it("warns nothing when every reader answered", async () => { + journals.set("s-1", sessionJournal("s-1", {})); + const read = new ReadLocalCostUseCase( + sink, + new Map([["claude", { read: async () => ({ records: [], sessionFound: true }) }]]), + journals, + NULL_PERSON_IDENTITY_READER, + new StubTelemetryEvidenceReader() + ); + + await report(read).execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("names the tool, the session and the reader's own reason in one warning", async () => { + journals.set("s-1", sessionJournal("s-1", {})); + const read = new ReadLocalCostUseCase( + sink, + new Map([ + [ + "claude", + { + read: async () => { + throw new Error("the transcript directory is unreadable"); + }, + }, + ], + ]), + journals, + NULL_PERSON_IDENTITY_READER, + new StubTelemetryEvidenceReader() + ); + + await report(read).execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(logger.warnMessages).toStrictEqual([ + "telemetry report: claude could not be read for session s-1 - the transcript directory is unreadable", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts index e8931c588..f8fefab61 100644 --- a/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts @@ -193,3 +193,92 @@ describe("TelemetryOffUseCase — taking back what on installed", () => { expect(logger.allMessages.join("\n")).not.toContain("still calls the delegate"); }); }); + +describe("TelemetryOffUseCase — what it says, word for word", () => { + const SWITCH_LINE = `AIDD telemetry switch -> ${SWITCH_PATH}`; + + it("says only that the switch was already off, on a project that was never on", async () => { + const { logger, useCase } = buildUseCase(); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(logger.infoMessages).toStrictEqual([ + SWITCH_LINE, + "AIDD telemetry: already off, unchanged.", + ]); + }); + + it("says only that the switch was already off, on a switch file already off", async () => { + const seed = { [SWITCH_PATH]: JSON.stringify({ telemetry: { enabled: false } }) }; + const { logger, useCase } = buildUseCase(seed); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(logger.infoMessages).toStrictEqual([ + SWITCH_LINE, + "AIDD telemetry: already off, unchanged.", + ]); + }); + + it("says the switch is off once it turned it off", async () => { + const seed = { [SWITCH_PATH]: JSON.stringify({ telemetry: { enabled: true } }) }; + const { logger, useCase } = buildUseCase(seed); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(logger.infoMessages).toStrictEqual([SWITCH_LINE, "AIDD telemetry: off."]); + }); + + it("reads an unparseable switch file as off, rather than crashing on it", async () => { + const seed = { [SWITCH_PATH]: "not json" }; + const { fs, useCase } = buildUseCase(seed); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.switchChanged).toBe(false); + expect(fs.getFile(SWITCH_PATH)).toBe("not json"); + }); + + it("names the leftover export file and its keys, and what to do by hand", async () => { + const { logger, evidence, useCase } = buildUseCase(); + const settingsPath = join(PROJECT_ROOT, ".claude", "settings.local.json"); + evidence.leftoverExport = [ + { path: settingsPath, keys: ["CLAUDE_CODE_ENABLE_TELEMETRY", "OTEL_EXPORTER_OTLP_ENDPOINT"] }, + ]; + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(logger.warnMessages).toStrictEqual([ + `${settingsPath} still sets CLAUDE_CODE_ENABLE_TELEMETRY, OTEL_EXPORTER_OTLP_ENDPOINT — ` + + "this switch cannot touch a tool's own settings file. Delete these keys from its " + + "`env` block by hand to stop that export.", + ]); + }); + + it("names the lefthook job left calling the removed delegate, word for word", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + const logger = new CapturingLogger(); + const git: VersionControl = { + ...noGit, + removeCommitMessageDelegate: async () => ({ + removed: true, + hookManager: "lefthook", + managerCallsDelegate: true, + }), + }; + const useCase = new TelemetryOffUseCase(fs, logger, new StubTelemetryEvidenceReader(), git); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(logger.infoMessages).toStrictEqual([ + SWITCH_LINE, + "AIDD telemetry: already off, unchanged.", + "New commits will carry no AIDD-Session-Id trailer. Commits already made " + + "keep theirs — nothing here rewrites history.", + "lefthook.yml still calls the delegate this just removed — that file is not this " + + "CLI's to edit, so the job is left in place. Its own `[ -f ]` guard now finds " + + "nothing there, so it runs nothing; delete it from " + + "lefthook.yml by hand if you want it gone too.", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts index 35916d1b2..4b604e524 100644 --- a/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts @@ -6,6 +6,7 @@ import { SESSION_TRAILER_DELEGATE_FILE, SESSION_TRAILER_TOKEN, sessionTrailerDelegateScript, + sessionTrailerLefthookJob, } from "../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; import type { VersionControl } from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; import { TelemetryProjectScopeRequiresYesError } from "../../../../src/kernel/errors.js"; @@ -252,3 +253,125 @@ describe("TelemetryOnUseCase — making commits joinable to the session that mad expect(calls).toHaveLength(2); }); }); + +describe("TelemetryOnUseCase — what it says, word for word", () => { + const SWITCH_LINE = `AIDD telemetry switch -> ${SWITCH_PATH}`; + const IGNORED_LINE = + "Added aidd_docs/runs/ to .gitignore — the journal names no person, only the " + + "repository, the task folders written into, the skills run, and their timings. " + + "Delete that line to commit it instead."; + + it("names the command in the refusal it throws without --yes", async () => { + const { useCase } = buildUseCase(); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: false })).rejects.toThrow( + `aidd telemetry on writes the git-tracked ${SWITCH_PATH}, turning telemetry on for ` + + "everyone who clones. Pass --yes to confirm." + ); + }); + + it("says the journal was git-ignored, then that the switch is on, and nothing else", async () => { + const { logger, useCase } = buildUseCase(); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(logger.infoMessages).toStrictEqual([SWITCH_LINE, IGNORED_LINE, "AIDD telemetry: on."]); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("says only that the switch was already on, the second time", async () => { + const { logger, useCase } = buildUseCase(); + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + logger.reset(); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(logger.infoMessages).toStrictEqual([ + SWITCH_LINE, + "AIDD telemetry: already on, unchanged.", + ]); + }); + + it("warns which journal files git already tracks, and touches none of them", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + const logger = new CapturingLogger(); + const git: VersionControl = { + ...noGit, + listTrackedFiles: async () => ["aidd_docs/runs/a.jsonl", "aidd_docs/runs/b.jsonl"], + }; + const useCase = new TelemetryOnUseCase( + fs, + logger, + new GitignoreUseCase(fs), + git, + new InMemoryTelemetrySink() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(logger.warnMessages).toStrictEqual([ + "Already tracked by git — the repository, the task folders written into, the skills " + + "run, and their timings:\n aidd_docs/runs/a.jsonl\n aidd_docs/runs/b.jsonl\n" + + "Nothing removed or rewritten — your call.", + ]); + }); + + it("says what the trailer line it installed does, and the command undoing it", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + const logger = new CapturingLogger(); + const git: VersionControl = { + ...noGit, + installCommitMessageDelegate: async () => ({ lineAdded: true }), + }; + const useCase = new TelemetryOnUseCase( + fs, + logger, + new GitignoreUseCase(fs), + git, + new InMemoryTelemetrySink() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(logger.infoMessages).toStrictEqual([ + SWITCH_LINE, + IGNORED_LINE, + "Commits made by an AI session will carry an AIDD-Session-Id trailer, so what " + + "a session cost can be read per commit. A commit no session made carries nothing. " + + "`aidd telemetry off` removes it.", + "AIDD telemetry: on.", + ]); + }); + + it("prints the job to add by hand when lefthook owns the hook and does not call the delegate yet", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + const logger = new CapturingLogger(); + const git: VersionControl = { + ...noGit, + installCommitMessageDelegate: async () => ({ + lineAdded: false, + hookManager: "lefthook", + managerCallsDelegate: false, + }), + }; + const useCase = new TelemetryOnUseCase( + fs, + logger, + new GitignoreUseCase(fs), + git, + new InMemoryTelemetrySink() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(logger.infoMessages).toStrictEqual([ + SWITCH_LINE, + IGNORED_LINE, + "lefthook owns prepare-commit-msg here, so nothing was appended to it. Commits will " + + "not carry an AIDD-Session-Id trailer until you " + + "add this command under `prepare-commit-msg:` in lefthook.yml:\n\n" + + `${sessionTrailerLefthookJob(SESSION_TRAILER_DELEGATE_FILE)}\n`, + "AIDD telemetry: on.", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts index 5d0f12206..01de37b9d 100644 --- a/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts @@ -10,6 +10,7 @@ import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { buildCostReport, + type CostReport, type CostReportInput, type CostReportToolDeclaration, } from "../../../../src/contexts/telemetry/domain/cost-report.js"; @@ -312,6 +313,217 @@ describe("toCostReportEnvelope", () => { }); }); +const CAPABILITY = { + localRead: null, + export: null, + journalAttributable: false, + taskAttributable: false, +} as const; + +const BARE_REPORT: CostReport = { + fromDay: "2026-08-17", + toDay: "2026-08-17", + sessions: 1, + totals: { requests: 1 }, + bySteps: [{ attribution: "unattributed", totals: { requests: 1 } }], + byModels: [{ totals: { requests: 1 } }], + byAgents: [{ attribution: "not-stated", totals: { requests: 1 } }], + byPrompts: [{ totals: { requests: 1 } }], + byTools: [ + { tool: "codex", coverage: "covered", capability: CAPABILITY, totals: { requests: 1 } }, + ], + byProjects: [{ totals: { requests: 1 } }], + byTasks: [{ totals: { requests: 1 } }], + byBacklog: [{ totals: { requests: 1 } }], + byFlows: [{ attribution: "unattributed", totals: { requests: 1 } }], + byDays: [{ day: "2026-08-17", totals: { requests: 1 } }], + byPeople: [{ resolution: "none", identities: [], totals: { requests: 1 } }], + attributionMix: [{ attribution: "unattributed", totals: { requests: 1 } }], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, +}; + +const FULL_TOTALS = { + requests: 2, + costMicroUsd: 1500000, + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 30, + cacheCreationTokens: 40, +} as const; + +const FULL_TOTALS_RENDERED = { + requests: 2, + cost_micro_usd: 1500000, + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 30, + cache_creation_tokens: 40, +} as const; + +const FULL_REPORT: CostReport = { + fromDay: "2026-08-17", + toDay: "2026-08-18", + task: "2026_08/widgets", + filters: { project: "acme/widgets", model: "opus" }, + emptySelection: { filter: "model", value: "opus", known: true, combination: true }, + sessions: 2, + totals: FULL_TOTALS, + activeTimeSeconds: 754, + bySteps: [{ step: "implement", attribution: "tool-stated", totals: FULL_TOTALS }], + byModels: [{ model: "opus", totals: FULL_TOTALS }], + byAgents: [{ agent: "Explore", attribution: "tool-stated", totals: FULL_TOTALS }], + byPrompts: [{ prompt: "p-1", startedAt: "2026-08-17T09:00:00Z", totals: FULL_TOTALS }], + byTools: [ + { + tool: "copilot", + coverage: "not-covered", + reason: "A session total only.", + capability: CAPABILITY, + totals: FULL_TOTALS, + sessionTotals: { requests: 0, outputTokens: 5 }, + }, + ], + byProjects: [{ project: "acme/widgets", totals: FULL_TOTALS }], + byTasks: [ + { task: "2026_08/widgets", attribution: "declared", totals: FULL_TOTALS }, + { reason: "no-declaration", totals: FULL_TOTALS }, + ], + byBacklog: [ + { backlog: "STORY-7", totals: FULL_TOTALS }, + { declaration: "unreadable", totals: FULL_TOTALS }, + { reason: "no-journal", totals: FULL_TOTALS }, + ], + byFlows: [ + { + flow: "aidd-orchestrator:01-sdlc", + attribution: "journal-interval", + startedAt: "2026-08-17T08:00:00Z", + totals: FULL_TOTALS, + }, + ], + byDays: [{ day: "2026-08-17", totals: FULL_TOTALS }], + byPeople: [ + { + resolution: "mapped", + person: "ada", + displayName: "Ada L.", + identities: ["ada@example.test"], + totals: FULL_TOTALS, + }, + ], + attributionMix: [{ attribution: "tool-stated", totals: FULL_TOTALS }], + taskAttributionMix: [{ attribution: "declared", totals: FULL_TOTALS }], + undatedRecords: 3, + unreadableLines: 4, + identityUnusableCause: "unreadable", + measurementEnabled: false, +}; + +describe("toCostReportEnvelope renders a report value field for field", () => { + it("leaves every optional field out entirely, never present as undefined, when the report has none", () => { + expect(toCostReportEnvelope(BARE_REPORT)).toStrictEqual({ + cost_report_version: COST_REPORT_ENVELOPE_VERSION, + period: { from_day: "2026-08-17", to_day: "2026-08-17" }, + measurement_enabled: true, + sessions: 1, + totals: { requests: 1 }, + by_step: [{ attribution: "unattributed", totals: { requests: 1 } }], + by_model: [{ totals: { requests: 1 } }], + by_tool: [ + { + tool: "codex", + coverage: "covered", + capability: { + local_read: null, + export: null, + journal_attributable: false, + task_attributable: false, + }, + totals: { requests: 1 }, + }, + ], + by_project: [{ totals: { requests: 1 } }], + by_task: [{ totals: { requests: 1 } }], + by_backlog: [{ totals: { requests: 1 } }], + by_flow: [{ attribution: "unattributed", totals: { requests: 1 } }], + by_agent: [{ attribution: "not-stated", totals: { requests: 1 } }], + by_prompt: [{ totals: { requests: 1 } }], + by_day: [{ day: "2026-08-17", totals: { requests: 1 } }], + by_person: [{ resolution: "none", identities: [], totals: { requests: 1 } }], + attribution: [{ attribution: "unattributed", totals: { requests: 1 } }], + read: { undated_records: 0, unreadable_lines: 0 }, + }); + }); + + it("carries every optional field under its snake_case name when the report has them all", () => { + expect(toCostReportEnvelope(FULL_REPORT)).toStrictEqual({ + cost_report_version: COST_REPORT_ENVELOPE_VERSION, + period: { from_day: "2026-08-17", to_day: "2026-08-18" }, + measurement_enabled: false, + task: "2026_08/widgets", + filters: { project: "acme/widgets", model: "opus" }, + empty_selection: { filter: "model", value: "opus", known: true, combination: true }, + sessions: 2, + totals: FULL_TOTALS_RENDERED, + active_time_s: 754, + by_step: [{ step: "implement", attribution: "tool-stated", totals: FULL_TOTALS_RENDERED }], + by_model: [{ model: "opus", totals: FULL_TOTALS_RENDERED }], + by_tool: [ + { + tool: "copilot", + coverage: "not-covered", + reason: "A session total only.", + capability: { + local_read: null, + export: null, + journal_attributable: false, + task_attributable: false, + }, + totals: FULL_TOTALS_RENDERED, + session_totals: { requests: 0, output_tokens: 5 }, + }, + ], + by_project: [{ project: "acme/widgets", totals: FULL_TOTALS_RENDERED }], + by_task: [ + { task: "2026_08/widgets", attribution: "declared", totals: FULL_TOTALS_RENDERED }, + { reason: "no-declaration", totals: FULL_TOTALS_RENDERED }, + ], + by_backlog: [ + { backlog: "STORY-7", totals: FULL_TOTALS_RENDERED }, + { declaration: "unreadable", totals: FULL_TOTALS_RENDERED }, + { reason: "no-journal", totals: FULL_TOTALS_RENDERED }, + ], + by_flow: [ + { + flow: "aidd-orchestrator:01-sdlc", + attribution: "journal-interval", + started_at: "2026-08-17T08:00:00Z", + totals: FULL_TOTALS_RENDERED, + }, + ], + by_agent: [{ agent: "Explore", attribution: "tool-stated", totals: FULL_TOTALS_RENDERED }], + by_prompt: [ + { prompt: "p-1", started_at: "2026-08-17T09:00:00Z", totals: FULL_TOTALS_RENDERED }, + ], + by_day: [{ day: "2026-08-17", totals: FULL_TOTALS_RENDERED }], + by_person: [ + { + resolution: "mapped", + person: "ada", + display_name: "Ada L.", + identities: ["ada@example.test"], + totals: FULL_TOTALS_RENDERED, + }, + ], + attribution: [{ attribution: "tool-stated", totals: FULL_TOTALS_RENDERED }], + task_attribution: [{ attribution: "declared", totals: FULL_TOTALS_RENDERED }], + read: { undated_records: 3, unreadable_lines: 4, identity_unusable: "unreadable" }, + }); + }); +}); + describe("the two renderings are one computation", () => { const RECORDS: readonly TelemetrySinkRecord[] = [ record({ diff --git a/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts index cb2720a79..71edf7b29 100644 --- a/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts @@ -6,6 +6,7 @@ import { type CostReportInput, type CostReportSessionJournal, type CostTotals, + TotalsAccumulator, toMicroUsd, } from "../../../../src/contexts/telemetry/domain/cost-report.js"; import { @@ -1800,3 +1801,40 @@ describe("buildCostReport — a line on disk holds whatever it holds, not what a expect(built.activeTimeSeconds).toBe(42); }); }); + +describe("buildCostReport — a field with nothing to say is absent, never an undefined key", () => { + it("carries exactly the keys an unfiltered, request-only period fills", () => { + const built = report({ records: [request({ cost_usd: 1 })] }); + + expect(Object.keys(built)).toStrictEqual([ + "fromDay", + "toDay", + "sessions", + "totals", + "bySteps", + "byModels", + "byAgents", + "byPrompts", + "byTools", + "byProjects", + "byTasks", + "byBacklog", + "byFlows", + "byDays", + "byPeople", + "attributionMix", + "undatedRecords", + "unreadableLines", + "measurementEnabled", + ]); + }); +}); + +describe("TotalsAccumulator", () => { + it("builds exactly the requests count for a record carrying neither cost nor counters", () => { + const accumulator = new TotalsAccumulator(); + accumulator.add(request()); + + expect(accumulator.build()).toStrictEqual({ requests: 1 }); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts index 92517b360..1f59952b9 100644 --- a/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts @@ -75,6 +75,13 @@ describe("ORCHESTRATING_SKILLS — declared once, both capture spellings", () => expect(bareOrchestratingSkillNames()).toEqual(["00-async-dev", "01-sdlc", "02-backlog"]); }); + it("sorts the bare names whatever order the set was declared in", () => { + expect(bareOrchestratingSkillNames(new Set(["zz:b-flow", "b-flow", "a-flow"]))).toStrictEqual([ + "a-flow", + "b-flow", + ]); + }); + it("hands out a project's fourth orchestrator too, without anything else being told about it", () => { const extended = new Set([...ORCHESTRATING_SKILLS, "acme:03-release", "03-release"]); diff --git a/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript-fields.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript-fields.unit.test.ts new file mode 100644 index 000000000..50d411968 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript-fields.unit.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { mapClaudeCodeTranscriptToSinkRecords } from "../../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; + +const SID = "22222222-2222-4222-8222-222222222222"; + +const USAGE = { + input_tokens: 10, + output_tokens: 5, + cache_read_input_tokens: 2, + cache_creation_input_tokens: 1, +}; + +const COUNTERS = { + input_tokens: 10, + output_tokens: 5, + cache_read_tokens: 2, + cache_creation_tokens: 1, +}; + +const BARE_RECORD = { + kind: "request", + vendor_id: SID, + vendor_field: "sessionId", + ...COUNTERS, +}; + +function chain(lines: readonly Record[]): string { + return lines.map((line) => JSON.stringify(line)).join("\n"); +} + +function assistantLine(overrides: Record = {}): Record { + return { + type: "assistant", + sessionId: SID, + message: { id: "msg_1", usage: USAGE }, + ...overrides, + }; +} + +function withUsage(usage: Record): string { + return chain([assistantLine({ message: { id: "msg_1", usage } })]); +} + +function skillCall(parts: readonly unknown[]): Record { + return { + type: "assistant", + uuid: "a1", + parentUuid: "u1", + sessionId: SID, + message: { content: parts }, + }; +} + +function promptedRecords(callParts: readonly unknown[]) { + return mapClaudeCodeTranscriptToSinkRecords( + chain([ + { type: "user", uuid: "u1", promptId: "p-abc" }, + skillCall(callParts), + assistantLine({ uuid: "a2", parentUuid: "a1" }), + ]) + ); +} + +describe("a billed line is one that carries every counter as a number", () => { + it("yields no record when input_tokens is missing", () => { + const { input_tokens: _dropped, ...usage } = USAGE; + + expect(mapClaudeCodeTranscriptToSinkRecords(withUsage(usage))).toStrictEqual([]); + }); + + it("yields no record when cache_read_input_tokens is missing", () => { + const { cache_read_input_tokens: _dropped, ...usage } = USAGE; + + expect(mapClaudeCodeTranscriptToSinkRecords(withUsage(usage))).toStrictEqual([]); + }); + + it("yields no record when output_tokens is missing", () => { + const { output_tokens: _dropped, ...usage } = USAGE; + + expect(mapClaudeCodeTranscriptToSinkRecords(withUsage(usage))).toStrictEqual([]); + }); + + it("yields no record when a counter is a string rather than a number", () => { + const content = withUsage({ ...USAGE, input_tokens: "10" }); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([]); + }); +}); + +describe("a billed line names its session and is an assistant turn", () => { + it("yields no record when sessionId is absent", () => { + const content = chain([assistantLine({ sessionId: undefined })]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([]); + }); + + it("yields no record when sessionId is not a string", () => { + const content = chain([assistantLine({ sessionId: 123 })]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([]); + }); + + it("yields no record for a user line, even one carrying counters", () => { + const content = chain([assistantLine({ type: "user" })]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([]); + }); +}); + +describe("the record carries exactly the keys the line states", () => { + it("holds identity and counters alone when the line names no request, model, effort or moment", () => { + const content = chain([assistantLine()]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([BARE_RECORD]); + }); + + it("names the agent only when the line is a sidechain", () => { + const content = chain([assistantLine({ attributionAgent: "Explore" })]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([BARE_RECORD]); + }); + + it("names the plugin beside the skill the line attributes", () => { + const content = chain([ + assistantLine({ attributionSkill: "aidd-dev:01-plan", attributionPlugin: "aidd-dev" }), + ]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([ + { ...BARE_RECORD, step: "aidd-dev:01-plan", step_plugin: "aidd-dev" }, + ]); + }); + + it("names no plugin when the line attributes a plugin but no skill", () => { + const content = chain([assistantLine({ attributionPlugin: "aidd-dev" })]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([BARE_RECORD]); + }); + + it("carries the prompt id with no prompt_skill key when the prompt invoked no skill", () => { + const content = chain([ + { type: "user", uuid: "u1", promptId: "p-abc" }, + assistantLine({ uuid: "a1", parentUuid: "u1" }), + ]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([ + { ...BARE_RECORD, prompt_id: "p-abc" }, + ]); + }); +}); + +describe("reading the Skill call that names a prompt's step", () => { + it("passes over a Skill call that carries no input and reads the next one", () => { + const records = promptedRecords([ + { type: "tool_use", name: "Skill" }, + { type: "tool_use", name: "Skill", input: { skill: "aidd-dev:02-implement" } }, + ]); + + expect(records[0]?.prompt_skill).toBe("aidd-dev:02-implement"); + }); + + it("passes over a null content part rather than throwing", () => { + const records = promptedRecords([ + null, + { type: "tool_use", name: "Skill", input: { skill: "aidd-dev:02-implement" } }, + ]); + + expect(records[0]?.prompt_skill).toBe("aidd-dev:02-implement"); + }); + + it("ignores a part named Skill whose type is not tool_use", () => { + const records = promptedRecords([ + { type: "text", name: "Skill", input: { skill: "aidd-dev:01-plan" } }, + ]); + + expect(records).toStrictEqual([{ ...BARE_RECORD, prompt_id: "p-abc" }]); + }); +}); + +describe("two lines restating one call", () => { + it("collapse on message.id even when neither carries a requestId", () => { + const content = chain([ + assistantLine({ message: { id: "msg_1", usage: { ...USAGE, output_tokens: 1 } } }), + assistantLine({ message: { id: "msg_1", usage: { ...USAGE, output_tokens: 9 } } }), + ]); + + expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([ + { ...BARE_RECORD, output_tokens: 9 }, + ]); + }); + + it("collapse on their text, whitespace aside, when neither carries any id", () => { + const line = JSON.stringify(assistantLine({ message: { usage: USAGE } })); + + expect(mapClaudeCodeTranscriptToSinkRecords(`${line}\n ${line} \n`)).toStrictEqual([ + BARE_RECORD, + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts index a82d82c14..5369556bd 100644 --- a/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts @@ -238,3 +238,156 @@ describe("a token_count re-emitted with an unmoved cumulative", () => { expect(mapCodexRolloutToSinkRecords(withoutTotal)[0]?.output_tokens).toBe(20); }); }); + +const sessionMeta = (id: unknown) => JSON.stringify({ type: "session_meta", payload: { id } }); +const turnContext = (payload: Record, timestamp?: string) => + JSON.stringify({ type: "turn_context", timestamp, payload }); +const counted = (last: Record, total?: Record) => + JSON.stringify({ + type: "event_msg", + payload: { type: "token_count", info: { last_token_usage: last, total_token_usage: total } }, + }); +const rolloutOf = (...lines: string[]) => mapCodexRolloutToSinkRecords(lines.join("\n")); + +const BARE_TURN = { + kind: "request", + vendor_id: "s-1", + vendor_field: "session_meta.id", + turn_id: "t-1", + turn_field: "turn_id", +}; + +describe("a turn is recorded only once its session and turn are named", () => { + it("yields no record when session_meta.id is not a string", () => { + const records = rolloutOf( + sessionMeta(123), + turnContext({ turn_id: "t-1" }), + counted({ output_tokens: 3 }) + ); + + expect(records).toStrictEqual([]); + }); + + it("yields no record when no session_meta line names the session", () => { + const records = rolloutOf(turnContext({ turn_id: "t-1" }), counted({ output_tokens: 3 })); + + expect(records).toStrictEqual([]); + }); + + it("yields no record for a turn_context that names no turn_id", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ model: "gpt-5.4" }), + counted({ output_tokens: 3 }) + ); + + expect(records).toStrictEqual([]); + }); +}); + +describe("the record carries exactly the counters the turn stated", () => { + it.each([ + ["input_tokens", { input_tokens: 7 }, { input_tokens: 7 }], + ["output_tokens", { output_tokens: 3 }, { output_tokens: 3 }], + ["cached_input_tokens", { cached_input_tokens: 5 }, { cache_read_tokens: 5 }], + ["cache_write_input_tokens", { cache_write_input_tokens: 4 }, { cache_creation_tokens: 4 }], + ])("holds identity plus %s alone when that is all the turn stated", (_name, last, stored) => { + const records = rolloutOf(sessionMeta("s-1"), turnContext({ turn_id: "t-1" }), counted(last)); + + expect(records).toStrictEqual([{ ...BARE_TURN, ...stored }]); + }); + + it("leaves a counter unset rather than coercing a string figure", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + counted({ input_tokens: "7", output_tokens: 3 }) + ); + + expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 3 }]); + }); + + it("adds cache_write_input_tokens across the turn's events", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + counted({ cache_write_input_tokens: 7 }), + counted({ cache_write_input_tokens: 5 }) + ); + + expect(records).toStrictEqual([{ ...BARE_TURN, cache_creation_tokens: 12 }]); + }); +}); + +describe("which lines count", () => { + it("counts both events when each states a cumulative with no figure in it", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + counted({ output_tokens: 10 }, {}), + counted({ output_tokens: 10 }, {}) + ); + + expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 20 }]); + }); + + it("counts a re-emitted event once even when its unmoved cumulative omits a metric", () => { + const total = { input_tokens: 100, cached_input_tokens: 0, output_tokens: 10 }; + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + counted({ output_tokens: 10 }, total), + counted({ output_tokens: 10 }, total) + ); + + expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 10 }]); + }); + + it("ignores a token_count carried by a line that is not an event_msg", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + JSON.stringify({ + type: "response_item", + payload: { type: "token_count", info: { last_token_usage: { output_tokens: 3 } } }, + }) + ); + + expect(records).toStrictEqual([]); + }); + + it("ignores an event_msg that is not a token_count, even one carrying usage", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + JSON.stringify({ + type: "event_msg", + payload: { type: "task_started", info: { last_token_usage: { output_tokens: 3 } } }, + }) + ); + + expect(records).toStrictEqual([]); + }); + + it("ignores a token_count that carries no info rather than throwing", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + JSON.stringify({ type: "event_msg", payload: { type: "token_count" } }), + counted({ output_tokens: 3 }) + ); + + expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 3 }]); + }); + + it("skips a half-written line rather than throwing", () => { + const records = rolloutOf( + sessionMeta("s-1"), + turnContext({ turn_id: "t-1" }), + '{"type":"event_msg","payload":{"type":"token_co', + counted({ output_tokens: 3 }) + ); + + expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 3 }]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts index 320013f78..3faa654e0 100644 --- a/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts @@ -120,3 +120,57 @@ describe("mapCopilotEventsToSinkRecords", () => { expect(mapCopilotEventsToSinkRecords.length).toBe(2); }); }); + +const TOKEN_DETAILS = { + input: { tokenCount: 10 }, + output: { tokenCount: 42 }, + cache_read: { tokenCount: 0 }, + cache_write: { tokenCount: 21070 }, +}; + +function shutdown(line: Record): string { + return JSON.stringify({ type: "session.shutdown", ...line }); +} + +describe("a shutdown counts only when every counter is stated as a number", () => { + it.each(["input", "output", "cache_read", "cache_write"] as const)( + "yields nothing, without throwing, when tokenDetails lacks %s", + (missing) => { + const { [missing]: _dropped, ...tokenDetails } = TOKEN_DETAILS; + + expect(mapCopilotEventsToSinkRecords(shutdown({ data: { tokenDetails } }), SESSION)).toEqual( + [] + ); + } + ); + + it("yields nothing when a counter is a string rather than a number", () => { + const tokenDetails = { ...TOKEN_DETAILS, input: { tokenCount: "10" } }; + + expect(mapCopilotEventsToSinkRecords(shutdown({ data: { tokenDetails } }), SESSION)).toEqual( + [] + ); + }); + + it("yields nothing for a shutdown that carries no data at all", () => { + expect(mapCopilotEventsToSinkRecords(shutdown({}), SESSION)).toEqual([]); + }); +}); + +describe("the record carries exactly the keys the shutdown states", () => { + it("holds identity and counters alone when the shutdown names no turn and no moment", () => { + const content = shutdown({ id: 7, data: { tokenDetails: TOKEN_DETAILS } }); + + expect(mapCopilotEventsToSinkRecords(content, SESSION)).toStrictEqual([ + { + kind: "session", + vendor_id: SESSION, + vendor_field: "sessionId", + input_tokens: 10, + output_tokens: 42, + cache_read_tokens: 0, + cache_creation_tokens: 21070, + }, + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts index f0e430173..21875dadb 100644 --- a/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts @@ -159,4 +159,56 @@ describe("mapOpencodeExportToSinkRecords", () => { expect(mapOpencodeExportToSinkRecords(null, SESSION_ID)).toEqual([]); expect(mapOpencodeExportToSinkRecords({ messages: [] }, SESSION_ID)).toEqual([]); }); + + it("skips a null message entry rather than throwing", () => { + expect(mapOpencodeExportToSinkRecords({ messages: [null] }, SESSION_ID)).toEqual([]); + }); +}); + +const BARE_MESSAGE = { + kind: "request", + vendor_id: SESSION_ID, + vendor_field: "sessionID", + turn_id: "msg_1", + turn_field: "id", +}; + +function recordsOf(info: Record) { + return mapOpencodeExportToSinkRecords( + { messages: [{ info: { id: "msg_1", ...info } }] }, + SESSION_ID + ); +} + +describe("the record carries exactly the keys the message states", () => { + it("holds identity alone when a billed message states no counter, model or time", () => { + expect(recordsOf({ tokens: { total: 5 } })).toStrictEqual([BARE_MESSAGE]); + }); + + it("reads a message whose tokens carry no cache block, without throwing", () => { + expect(recordsOf({ tokens: { total: 5, input: 3, output: 2 } })).toStrictEqual([ + { ...BARE_MESSAGE, input_tokens: 3, output_tokens: 2 }, + ]); + }); + + it("leaves a counter unset rather than storing a string figure", () => { + expect(recordsOf({ tokens: { total: 5, input: "3", output: 2 } })).toStrictEqual([ + { ...BARE_MESSAGE, output_tokens: 2 }, + ]); + }); + + it("names no model and no turn when neither is a string", () => { + const records = mapOpencodeExportToSinkRecords( + { messages: [{ info: { id: 42, modelID: 42, tokens: { total: 5 } } }] }, + SESSION_ID + ); + + expect(records).toStrictEqual([ + { kind: "request", vendor_id: SESSION_ID, vendor_field: "sessionID" }, + ]); + }); + + it.each([0, -1])("carries no moment for a creation time of %s", (created) => { + expect(recordsOf({ tokens: { total: 5 }, time: { created } })).toStrictEqual([BARE_MESSAGE]); + }); }); diff --git a/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts b/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts index 556ff2317..28c29a042 100644 --- a/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildClosedIntervals, type IntervalClosure, + momentFallsWithin, timed, } from "../../../../src/contexts/telemetry/domain/journal-intervals.js"; @@ -114,3 +115,26 @@ describe("IntervalClosure — the three ways an interval's end is reached", () = ]); }); }); + +describe("momentFallsWithin — over more than one interval", () => { + const FIRST = { + startMs: Date.parse("2026-01-01T00:00:00.000Z"), + endMs: Date.parse("2026-01-02T00:00:00.000Z"), + }; + const SECOND = { + startMs: Date.parse("2026-01-05T00:00:00.000Z"), + endMs: Date.parse("2026-01-06T00:00:00.000Z"), + }; + + it("holds for a moment inside one interval alone", () => { + expect(momentFallsWithin([FIRST, SECOND], "2026-01-05T12:00:00.000Z")).toBe(true); + }); + + it("holds for a moment at the very instant an interval opens", () => { + expect(momentFallsWithin([FIRST, SECOND], "2026-01-05T00:00:00.000Z")).toBe(true); + }); + + it("fails for a moment in the gap between two intervals", () => { + expect(momentFallsWithin([FIRST, SECOND], "2026-01-03T00:00:00.000Z")).toBe(false); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts b/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts index ee12d50e8..9ea401edd 100644 --- a/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts @@ -104,6 +104,19 @@ describe("resolvePerson", () => { expect(resolvePerson(null, undefined).resolution).toBe("none"); }); + it("reads an empty identifier as none carried, naming this machine's own person", () => { + expect(resolvePerson(identityWithAlsoMe(), "")).toStrictEqual({ + resolution: "this-machine", + personId: "person-a", + displayName: "Ada", + identities: ["person-a", "claude-machine-1", "codex-machine-2"], + }); + }); + + it("reads an empty identifier against no identity as none, never as an unresolved blank", () => { + expect(resolvePerson(null, "")).toStrictEqual({ resolution: "none", identities: [] }); + }); + it("a resolved person carries back every identity behind it, including its canonical one", () => { const resolved = resolvePerson(identityWithAlsoMe(), "codex-machine-2"); diff --git a/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts b/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts index aa7911260..306161d76 100644 --- a/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts @@ -80,6 +80,21 @@ describe("resolveReportPeriod", () => { expect(() => resolveReportPeriod({ from: "notaday" }, TODAY)).toThrow(/--from.*YYYY-MM-DD/u); }); + it("refuses a well-shaped day no calendar has as an invalid day, never as a date routine's own error", () => { + expect(() => resolveReportPeriod({ from: "2026-13-01" }, TODAY)).toThrow(InvalidReportDayError); + }); + + it("names --to when the end is not a day", () => { + expect(() => resolveReportPeriod({ to: "notaday" }, TODAY)).toThrow(/--to.*YYYY-MM-DD/u); + }); + + it("accepts the longest span, ten years, and counts it back from the end", () => { + expect(resolveReportPeriod({ to: "2026-08-21", days: "3650" }, TODAY)).toEqual({ + fromDay: "2016-08-24", + toDay: "2026-08-21", + }); + }); + it("refuses a span that is not a whole number of days", () => { for (const value of ["0", "-1", "1.5", "many", "4000"]) { expect(() => resolveReportPeriod({ days: value }, TODAY), value).toThrow( diff --git a/cli/tests/contexts/telemetry/domain/report/axes/day-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/day-rows.unit.test.ts new file mode 100644 index 000000000..5e73658ca --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/day-rows.unit.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { dayRange } from "../../../../../../src/contexts/telemetry/domain/report/axes/day-rows.js"; + +describe("dayRange", () => { + it("lists each UTC day from the first to the last exactly once", () => { + expect(dayRange("2026-08-30", "2026-09-02")).toStrictEqual([ + "2026-08-30", + "2026-08-31", + "2026-09-01", + "2026-09-02", + ]); + }); + + it("lists a one-day period as that one day", () => { + expect(dayRange("2026-08-18", "2026-08-18")).toStrictEqual(["2026-08-18"]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/flow-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/flow-rows.unit.test.ts new file mode 100644 index 000000000..ded85ac1f --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/flow-rows.unit.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + type CostReportSessionJournal, + TotalsAccumulator, +} from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; +import type { FlowInterval } from "../../../../../../src/contexts/telemetry/domain/flow-attribution.js"; +import { + allFlowIntervalsByVendorId, + type FlowRowKey, + flowKeyOf, + flowRows, +} from "../../../../../../src/contexts/telemetry/domain/report/axes/flow-rows.js"; +import type { TelemetrySinkRecord } from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +function record(overrides: Partial = {}): TelemetrySinkRecord { + return { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", + ...overrides, + }; +} + +function totalsOf(costUsd: number): TotalsAccumulator { + const accumulator = new TotalsAccumulator(); + accumulator.add(record({ cost_usd: costUsd })); + return accumulator; +} + +function sdlcRunAt(startedAt: string): FlowInterval { + const startMs = Date.parse(startedAt); + return { + skill: "aidd-orchestrator:01-sdlc", + startMs, + endMs: startMs + 60_000, + closedBy: "journal-end", + }; +} + +describe("allFlowIntervalsByVendorId", () => { + it("gives a session that opened no flow no entry at all", () => { + const journal: CostReportSessionJournal = { + vendorId: "s-1", + tool: "claude", + writtenPaths: [], + taskIntervals: [], + flowIntervals: [], + }; + + expect(allFlowIntervalsByVendorId([journal])).toStrictEqual(new Map()); + }); +}); + +describe("flowKeyOf, for a record no interval covers", () => { + const outside = flowKeyOf(record(), new Map()); + + it("keys a tool-stated step that orchestrates nothing on the outside-every-flow row", () => { + const key = flowKeyOf( + record({ step_attribution: "tool-stated", step: "aidd-dev:01-plan" }), + new Map() + ); + + expect(typeof key).toBe("symbol"); + expect(key).toBe(outside); + }); + + it("keys a tool-stated orchestrating step on the skill's own name", () => { + const key = flowKeyOf( + record({ step_attribution: "tool-stated", step: "aidd-orchestrator:01-sdlc" }), + new Map() + ); + + expect(key).toBe("aidd-orchestrator:01-sdlc"); + }); +}); + +describe("flowRows", () => { + it("breaks a tie between two runs of one skill on the moment each started", () => { + const flows = new Map([ + [sdlcRunAt("2026-08-18T11:00:00Z"), totalsOf(1)], + [sdlcRunAt("2026-08-18T10:00:00Z"), totalsOf(1)], + ]); + + expect(flowRows(flows).map((row) => row.startedAt)).toStrictEqual([ + "2026-08-18T10:00:00Z", + "2026-08-18T11:00:00Z", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/person-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/person-rows.unit.test.ts new file mode 100644 index 000000000..51cba9c74 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/person-rows.unit.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { TotalsAccumulator } from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; +import type { ResolvedPerson } from "../../../../../../src/contexts/telemetry/domain/person-resolution.js"; +import { + type PersonGroup, + personGroupKey, + personRawIdOf, + personRows, +} from "../../../../../../src/contexts/telemetry/domain/report/axes/person-rows.js"; +import { + parseTelemetrySinkLine, + type TelemetrySinkRecord, +} from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +const BASE: TelemetrySinkRecord = { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", +}; + +const NONE: ResolvedPerson = { resolution: "none", identities: [] }; + +function group(resolved: ResolvedPerson, costUsd: number): PersonGroup { + const totals = new TotalsAccumulator(); + totals.add({ ...BASE, cost_usd: costUsd }); + return { resolved, totals }; +} + +function rowsOf(...groups: readonly PersonGroup[]) { + return personRows(new Map(groups.map((entry, index) => [`k${index}`, entry]))); +} + +describe("personRawIdOf", () => { + it("reads an empty person_id as no identifier", () => { + expect(personRawIdOf({ ...BASE, person_id: "" })).toBeUndefined(); + }); + + it("reads a person_id stored as a number as no identifier", () => { + const stored = parseTelemetrySinkLine(JSON.stringify({ ...BASE, person_id: 42 })); + + expect(personRawIdOf(stored)).toBeUndefined(); + }); + + it("reads a real identifier as itself", () => { + expect(personRawIdOf({ ...BASE, person_id: "claude-machine-1" })).toBe("claude-machine-1"); + }); +}); + +describe("personGroupKey", () => { + it("keys an unresolved person on its raw identifier", () => { + expect(personGroupKey({ resolution: "unresolved", identities: ["raw-1"] })).toBe("raw-1"); + }); + + it("keys this machine's own person on the shared no-identifier key, never its personId", () => { + const key = personGroupKey({ resolution: "this-machine", personId: "p", identities: ["p"] }); + + expect(typeof key).toBe("symbol"); + expect(key).toBe(personGroupKey(NONE)); + }); + + it("keys a mapped person carrying no personId on the shared no-identifier key", () => { + const key = personGroupKey({ resolution: "mapped", identities: ["raw-1"] }); + + expect(typeof key).toBe("symbol"); + expect(key).toBe(personGroupKey(NONE)); + }); + + it("keys an unresolved person with no identifier on the shared no-identifier key", () => { + const key = personGroupKey({ resolution: "unresolved", identities: [] }); + + expect(typeof key).toBe("symbol"); + expect(key).toBe(personGroupKey(NONE)); + }); +}); + +describe("personRows", () => { + it("carries neither person nor displayName on a row that resolved to nobody", () => { + expect(rowsOf(group(NONE, 1))).toStrictEqual([ + { resolution: "none", identities: [], totals: { requests: 1, costMicroUsd: 1_000_000 } }, + ]); + }); + + it("reads mapped, this-machine, unresolved, none, whatever their sizes", () => { + const rows = rowsOf( + group(NONE, 4), + group({ resolution: "unresolved", identities: ["raw"] }, 3), + group({ resolution: "this-machine", personId: "me", identities: ["me"] }, 2), + group({ resolution: "mapped", personId: "her", identities: ["her"] }, 1) + ); + + expect(rows.map((row) => row.resolution)).toStrictEqual([ + "mapped", + "this-machine", + "unresolved", + "none", + ]); + }); + + it("breaks a tie between two mapped people on the person, not the evidence", () => { + const rows = rowsOf( + group({ resolution: "mapped", personId: "b", identities: ["aaa"] }, 1), + group({ resolution: "mapped", personId: "a", identities: ["zzz"] }, 1) + ); + + expect(rows.map((row) => row.person)).toStrictEqual(["a", "b"]); + }); + + it("breaks a tie between two unresolved identifiers on the identifier", () => { + const rows = rowsOf( + group({ resolution: "unresolved", identities: ["raw-b"] }, 1), + group({ resolution: "unresolved", identities: ["raw-a"] }, 1) + ); + + expect(rows.map((row) => row.identities)).toStrictEqual([["raw-a"], ["raw-b"]]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/record-stated-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/record-stated-rows.unit.test.ts new file mode 100644 index 000000000..b89a0ef93 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/record-stated-rows.unit.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { TotalsAccumulator } from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; +import { + type AgentKey, + agentRows, + modelKeyOf, + modelRows, + type PromptGroup, + projectRows, + promptRows, +} from "../../../../../../src/contexts/telemetry/domain/report/axes/record-stated-rows.js"; +import type { TelemetrySinkRecord } from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +const BASE: TelemetrySinkRecord = { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", +}; + +function totalsOf(costUsd: number): TotalsAccumulator { + const accumulator = new TotalsAccumulator(); + accumulator.add({ ...BASE, cost_usd: costUsd }); + return accumulator; +} + +describe("modelKeyOf", () => { + it("keys a record naming no model on a symbol, never on undefined", () => { + expect(typeof modelKeyOf(BASE)).toBe("symbol"); + }); +}); + +describe("a tie between two rows of equal size", () => { + it("is broken on the project name", () => { + const rows = projectRows( + new Map([ + ["proj-b", totalsOf(1)], + ["proj-a", totalsOf(1)], + ]) + ); + + expect(rows.map((row) => row.project)).toStrictEqual(["proj-a", "proj-b"]); + }); + + it("is broken on the agent name", () => { + const rows = agentRows( + new Map([ + ["Plan", totalsOf(1)], + ["Explore", totalsOf(1)], + ]) + ); + + expect(rows.map((row) => row.agent)).toStrictEqual(["Explore", "Plan"]); + }); + + it("is broken on the prompt id", () => { + const rows = promptRows( + new Map([ + ["p-b", { totals: totalsOf(1) }], + ["p-a", { totals: totalsOf(1) }], + ]) + ); + + expect(rows.map((row) => row.prompt)).toStrictEqual(["p-a", "p-b"]); + }); + + it("is broken on the model name", () => { + const rows = modelRows( + new Map([ + ["claude-sonnet-5", totalsOf(1)], + ["claude-opus-5", totalsOf(1)], + ]) + ); + + expect(rows.map((row) => row.model)).toStrictEqual(["claude-opus-5", "claude-sonnet-5"]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/step-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/step-rows.unit.test.ts new file mode 100644 index 000000000..0cf67c738 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/step-rows.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { TotalsAccumulator } from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; +import { + type StepGroup, + stepRowKey, + stepRows, +} from "../../../../../../src/contexts/telemetry/domain/report/axes/step-rows.js"; +import type { TelemetrySinkRecord } from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +const BASE: TelemetrySinkRecord = { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", +}; + +function group(overrides: Partial): StepGroup { + const totals = new TotalsAccumulator(); + totals.add({ ...BASE, cost_usd: 1 }); + return { attribution: "unattributed", totals, ...overrides }; +} + +describe("stepRowKey", () => { + it("joins the attribution and the step with one space", () => { + expect(stepRowKey({ ...BASE, step_attribution: "tool-stated", step: "aidd-dev:01-plan" })).toBe( + "tool-stated aidd-dev:01-plan" + ); + }); + + it("leaves the step empty after the space when none was found", () => { + expect(stepRowKey(BASE)).toBe("unattributed "); + }); +}); + +describe("stepRows", () => { + it("carries no step key at all on the unattributed row", () => { + expect(stepRows(new Map([["unattributed ", group({})]]))).toStrictEqual([ + { attribution: "unattributed", totals: { requests: 1, costMicroUsd: 1_000_000 } }, + ]); + }); + + it("breaks a tie between two steps on the step name", () => { + const rows = stepRows( + new Map([ + ["b", group({ attribution: "tool-stated", step: "aidd-dev:02-implement" })], + ["a", group({ attribution: "tool-stated", step: "aidd-dev:01-plan" })], + ]) + ); + + expect(rows.map((row) => row.step)).toStrictEqual([ + "aidd-dev:01-plan", + "aidd-dev:02-implement", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/task-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/task-rows.unit.test.ts new file mode 100644 index 000000000..3fb08ba97 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/task-rows.unit.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { + type CostReportSessionJournal, + TotalsAccumulator, +} from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; +import { + backlogKeyOf, + backlogRows, + type TaskGroup, + taskRowKeyOf, + taskRowOf, + taskRows, +} from "../../../../../../src/contexts/telemetry/domain/report/axes/task-rows.js"; +import type { TaskBacklogDeclaration } from "../../../../../../src/contexts/telemetry/domain/task-backlog-link.js"; +import type { TelemetrySinkRecord } from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +const FROM_MS = Date.parse("2026-08-18T10:00:00Z"); +const TO_MS = Date.parse("2026-08-18T11:00:00Z"); +const TASK_PATH = "aidd_docs/tasks/2026_08/refactor-sink/plan.md"; +const TASK = "2026_08/refactor-sink"; + +function record(overrides: Partial = {}): TelemetrySinkRecord { + return { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", + event_timestamp: "2026-08-18T10:30:00Z", + ...overrides, + }; +} + +function journal(overrides: Partial = {}): CostReportSessionJournal { + return { + vendorId: "s-1", + tool: "claude", + writtenPaths: [TASK_PATH], + taskIntervals: [], + flowIntervals: [], + witnessed: { fromMs: FROM_MS, toMs: TO_MS }, + ...overrides, + }; +} + +function totalsOf(costUsd: number): TotalsAccumulator { + const accumulator = new TotalsAccumulator(); + accumulator.add(record({ cost_usd: costUsd })); + return accumulator; +} + +function rowOf(target: TelemetrySinkRecord, sessionJournal: CostReportSessionJournal | undefined) { + return taskRowOf( + target, + new Map([["s-1", sessionJournal?.taskIntervals ?? []]]), + new Map(sessionJournal === undefined ? [] : [["s-1", sessionJournal]]) + ); +} + +describe("taskRowKeyOf", () => { + it("joins the attribution and the task with one space", () => { + expect(taskRowKeyOf({ task: TASK, attribution: "declared" })).toBe(`declared ${TASK}`); + }); + + it("keeps a reason as its own key", () => { + expect(taskRowKeyOf("journal-silent")).toBe("journal-silent"); + }); +}); + +describe("taskRowOf, when the intervals were read but no journal reached the session", () => { + it("answers no-declaration rather than throwing", () => { + expect(rowOf(record(), undefined)).toBe("no-declaration"); + }); +}); + +describe("taskRowOf, inferring from a session's sole written task", () => { + it("infers at the first moment the journal witnessed", () => { + const row = rowOf(record({ event_timestamp: "2026-08-18T10:00:00Z" }), journal()); + + expect(row).toStrictEqual({ task: TASK, attribution: "inferred" }); + }); + + it("infers at the last moment the journal witnessed", () => { + const row = rowOf(record({ event_timestamp: "2026-08-18T11:00:00Z" }), journal()); + + expect(row).toStrictEqual({ task: TASK, attribution: "inferred" }); + }); + + it("infers nothing one second after the last witnessed moment", () => { + expect(rowOf(record({ event_timestamp: "2026-08-18T11:00:01Z" }), journal())).toBe( + "no-declaration" + ); + }); + + it("infers nothing one second before the first witnessed moment", () => { + expect(rowOf(record({ event_timestamp: "2026-08-18T09:59:59Z" }), journal())).toBe( + "precedes-journal" + ); + }); + + it("infers nothing from a journal that witnessed no readable moment", () => { + expect(rowOf(record(), journal({ witnessed: undefined }))).toBe("no-declaration"); + }); + + it("infers nothing for a record whose own moment does not parse", () => { + expect(rowOf(record({ event_timestamp: "not a moment" }), journal())).toBe("no-declaration"); + }); +}); + +describe("taskRows", () => { + function group(overrides: Partial): TaskGroup { + return { totals: totalsOf(1), ...overrides }; + } + + it("drops a named group missing its attribution", () => { + expect(taskRows(new Map([["k", group({ task: TASK })]]))).toStrictEqual([]); + }); + + it("drops a named group missing its task", () => { + expect(taskRows(new Map([["k", group({ attribution: "declared" })]]))).toStrictEqual([]); + }); + + it("breaks a tie between two tasks on the task name", () => { + const rows = taskRows( + new Map([ + ["b", group({ task: "2026_08/b", attribution: "declared" })], + ["a", group({ task: "2026_08/a", attribution: "declared" })], + ]) + ); + + expect(rows.map((row) => row.task)).toStrictEqual(["2026_08/a", "2026_08/b"]); + }); + + it("breaks a tie between one task's two attributions on the attribution", () => { + const rows = taskRows( + new Map([ + ["i", group({ task: TASK, attribution: "inferred" })], + ["d", group({ task: TASK, attribution: "declared" })], + ]) + ); + + expect(rows.map((row) => row.attribution)).toStrictEqual(["declared", "inferred"]); + }); +}); + +describe("backlogRows", () => { + const noneDeclared = backlogKeyOf({ task: TASK, attribution: "declared" }, undefined); + const unreadable = backlogKeyOf( + { task: TASK, attribution: "declared" }, + new Map([[TASK, { kind: "unreadable" }]]) + ); + + it("names a declared item as its own row, never as a reason", () => { + expect(backlogRows(new Map([["ISSUE-7", totalsOf(1)]]))).toStrictEqual([ + { backlog: "ISSUE-7", totals: { requests: 1, costMicroUsd: 1_000_000 } }, + ]); + }); + + it("keeps the no-declaration and unreadable rows after the named ones", () => { + const rows = backlogRows( + new Map([ + [noneDeclared, totalsOf(3)], + [unreadable, totalsOf(2)], + ["ISSUE-7", totalsOf(1)], + ]) + ); + + expect(rows).toStrictEqual([ + { backlog: "ISSUE-7", totals: { requests: 1, costMicroUsd: 1_000_000 } }, + { declaration: "none", totals: { requests: 1, costMicroUsd: 3_000_000 } }, + { declaration: "unreadable", totals: { requests: 1, costMicroUsd: 2_000_000 } }, + ]); + }); + + it("orders two named items largest first", () => { + const rows = backlogRows( + new Map([ + ["ISSUE-1", totalsOf(1)], + ["ISSUE-2", totalsOf(2)], + ]) + ); + + expect(rows.map((row) => row.backlog)).toStrictEqual(["ISSUE-2", "ISSUE-1"]); + }); + + it("breaks a tie between two named items on the item", () => { + const rows = backlogRows( + new Map([ + ["ISSUE-2", totalsOf(1)], + ["ISSUE-1", totalsOf(1)], + ]) + ); + + expect(rows.map((row) => row.backlog)).toStrictEqual(["ISSUE-1", "ISSUE-2"]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/tool-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/tool-rows.unit.test.ts new file mode 100644 index 000000000..556dd8593 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/axes/tool-rows.unit.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import type { CostReportToolDeclaration } from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; +import { buildToolRows } from "../../../../../../src/contexts/telemetry/domain/report/axes/tool-rows.js"; + +const COVERED: CostReportToolDeclaration = { + tool: "claude", + coverage: "covered", + capability: { + localRead: null, + export: null, + journalAttributable: false, + taskAttributable: false, + }, +}; + +describe("buildToolRows", () => { + it("carries no reason key at all for a tool that declared none", () => { + expect(buildToolRows([COVERED], new Map(), new Map())).toStrictEqual([ + { + tool: "claude", + coverage: "covered", + capability: COVERED.capability, + totals: { requests: 0 }, + }, + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/record-reconciliation.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/record-reconciliation.unit.test.ts new file mode 100644 index 000000000..450d3a5e7 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/record-reconciliation.unit.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { + collapseBilledRequests, + collapseSupersededTurns, +} from "../../../../../src/contexts/telemetry/domain/report/record-reconciliation.js"; +import type { TelemetrySinkRecord } from "../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +const BASE: TelemetrySinkRecord = { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", +}; + +function record(overrides: Partial): TelemetrySinkRecord { + return { ...BASE, ...overrides }; +} + +describe("collapseSupersededTurns", () => { + it("keeps the reading of a re-read turn that carries the largest counters", () => { + const partial = record({ turn_id: "t-1", input_tokens: 10, output_tokens: 1 }); + const complete = record({ turn_id: "t-1", input_tokens: 10, output_tokens: 90 }); + + expect(collapseSupersededTurns([partial, complete])).toStrictEqual([complete]); + }); + + it("leaves a record read once exactly as it arrived", () => { + const only = record({ turn_id: "t-1", input_tokens: 10 }); + + expect(collapseSupersededTurns([only])).toStrictEqual([only]); + }); + + it("never groups an export record on its turn id, which there names a prompt several calls share", () => { + const first = record({ provenance: "export", turn_id: "p-1", output_tokens: 1 }); + const second = record({ provenance: "export", turn_id: "p-1", output_tokens: 2 }); + + expect(collapseSupersededTurns([first, second])).toStrictEqual([first, second]); + }); +}); + +describe("collapseBilledRequests", () => { + const BILLED = "req_1"; + + it("keeps the record carrying the amount as the one a report sums", () => { + const priced = record({ billed_request_id: BILLED, cost_usd: 0.5, output_tokens: 9 }); + const unpriced = record({ provenance: "export", billed_request_id: BILLED, output_tokens: 9 }); + + expect(collapseBilledRequests([unpriced, priced])).toStrictEqual([priced]); + }); + + it("answers the same record whichever of two unpriced readings came first", () => { + const local = record({ billed_request_id: BILLED, output_tokens: 9 }); + const exported = record({ provenance: "export", billed_request_id: BILLED, output_tokens: 9 }); + + expect(collapseBilledRequests([local, exported])).toStrictEqual( + collapseBilledRequests([exported, local]) + ); + expect(collapseBilledRequests([local, exported])).toHaveLength(1); + }); + + it("leaves a session record beside a request record sharing its billed id, never merged into it", () => { + const request = record({ billed_request_id: BILLED, cost_usd: 0.5 }); + const session = record({ kind: "session", billed_request_id: BILLED, output_tokens: 9 }); + + expect(collapseBilledRequests([request, session])).toStrictEqual([session, request]); + }); + + it("borrows the step a sibling resolved from a journal interval when the survivor states none", () => { + const priced = record({ provenance: "export", billed_request_id: BILLED, cost_usd: 0.5 }); + const stepped = record({ + billed_request_id: BILLED, + step_attribution: "journal-interval", + step: "implement", + step_plugin: "aidd-dev", + }); + + expect(collapseBilledRequests([priced, stepped])).toStrictEqual([ + { + ...priced, + step_attribution: "journal-interval", + step: "implement", + step_plugin: "aidd-dev", + }, + ]); + }); + + it("prefers the tool-stated step over a journal-interval one when both siblings resolved one", () => { + const priced = record({ provenance: "export", billed_request_id: BILLED, cost_usd: 0.5 }); + const fromJournal = record({ + billed_request_id: BILLED, + step_attribution: "journal-interval", + step: "plan", + }); + const fromTool = record({ + billed_request_id: BILLED, + step_attribution: "tool-stated", + step: "implement", + }); + + expect(collapseBilledRequests([priced, fromJournal, fromTool])).toStrictEqual([ + { ...priced, step_attribution: "tool-stated", step: "implement", step_plugin: undefined }, + ]); + }); + + it("keeps the step the survivor stated itself rather than a sibling's", () => { + const priced = record({ + provenance: "export", + billed_request_id: BILLED, + cost_usd: 0.5, + step_attribution: "journal-interval", + step: "plan", + }); + const fromTool = record({ + billed_request_id: BILLED, + step_attribution: "tool-stated", + step: "implement", + }); + + expect(collapseBilledRequests([priced, fromTool])).toStrictEqual([priced]); + }); + + it("borrows the person, name included, from the sibling that carried one", () => { + const priced = record({ provenance: "export", billed_request_id: BILLED, cost_usd: 0.5 }); + const known = record({ + billed_request_id: BILLED, + person_id: "ada", + person_display_name: "Ada L.", + }); + + expect(collapseBilledRequests([priced, known])).toStrictEqual([ + { ...priced, person_id: "ada", person_display_name: "Ada L." }, + ]); + }); + + it("borrows a person without inventing a display name the sibling never carried", () => { + const priced = record({ provenance: "export", billed_request_id: BILLED, cost_usd: 0.5 }); + const known = record({ billed_request_id: BILLED, person_id: "ada" }); + + expect(collapseBilledRequests([priced, known])).toStrictEqual([ + { ...priced, person_id: "ada" }, + ]); + }); + + it("keeps the person the survivor carried itself rather than a sibling's", () => { + const priced = record({ + billed_request_id: BILLED, + cost_usd: 0.5, + person_id: "ada", + person_display_name: "Ada L.", + }); + const other = record({ + provenance: "export", + billed_request_id: BILLED, + person_id: "bob", + person_display_name: "Bob", + }); + + expect(collapseBilledRequests([priced, other])).toStrictEqual([priced]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/report-selection.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/report-selection.unit.test.ts new file mode 100644 index 000000000..336b75615 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/report-selection.unit.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; +import type { + CostReportInput, + CostReportSessionJournal, +} from "../../../../../src/contexts/telemetry/domain/cost-report.js"; +import { + activeFilters, + emptySelectionOf, + selectionStages, + taskMembership, +} from "../../../../../src/contexts/telemetry/domain/report/report-selection.js"; +import type { TelemetrySinkRecord } from "../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +const TASK = "2026_08/widgets"; +const OTHER_TASK = "2026_08/gadgets"; +const IN_TASK = { + startMs: Date.parse("2026-08-17T09:00:00Z"), + endMs: Date.parse("2026-08-17T10:00:00Z"), +}; + +function record(overrides: Partial): TelemetrySinkRecord { + return { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", + event_timestamp: "2026-08-17T09:30:00Z", + ...overrides, + }; +} + +function journal(overrides: Partial): CostReportSessionJournal { + return { + vendorId: "s-1", + tool: "claude", + writtenPaths: [], + taskIntervals: [], + flowIntervals: [], + ...overrides, + }; +} + +function input(overrides: Partial): CostReportInput { + return { + fromDay: "2026-08-17", + toDay: "2026-08-17", + records: [], + journals: [], + declaredTools: [], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + ...overrides, + }; +} + +describe("taskMembership", () => { + it("keeps, per session, only the declared intervals naming the task asked for", () => { + const forTask = { ...IN_TASK, path: `aidd_docs/tasks/${TASK}/plan.md` }; + const forOther = { ...IN_TASK, path: `aidd_docs/tasks/${OTHER_TASK}/plan.md` }; + + const membership = taskMembership([journal({ taskIntervals: [forOther, forTask] })], TASK); + + expect([...membership.declaredIntervalsByVendorId]).toStrictEqual([["s-1", [forTask]]]); + }); + + it("gives a session that never declared the task no entry at all, rather than an empty one", () => { + const forOther = { ...IN_TASK, path: `aidd_docs/tasks/${OTHER_TASK}/plan.md` }; + + const membership = taskMembership([journal({ taskIntervals: [forOther] })], TASK); + + expect([...membership.declaredIntervalsByVendorId]).toStrictEqual([]); + }); +}); + +describe("selectionStages", () => { + it("names the task stage after the task, carrying what the task kept", () => { + const inside = record({ vendor_id: "s-1" }); + const outside = record({ vendor_id: "s-2" }); + const membership = taskMembership( + [journal({ writtenPaths: [`aidd_docs/tasks/${TASK}/plan.md`] })], + TASK + ); + + const stages = selectionStages([inside, outside], input({ task: TASK }), membership); + + expect(stages).toStrictEqual([ + { name: undefined, value: undefined, records: [inside, outside] }, + { name: "task", value: TASK, records: [inside] }, + ]); + }); +}); + +describe("emptySelectionOf", () => { + it("answers nothing when every stage kept something", () => { + const kept = record({ model: "opus" }); + const stages = selectionStages([kept], input({ filters: { model: "opus" } }), null); + + expect(emptySelectionOf(stages, input({ filters: { model: "opus" } }), null)).toBeUndefined(); + }); + + it("knows a task that only a declared interval names", () => { + const declared = { ...IN_TASK, path: `aidd_docs/tasks/${TASK}/plan.md` }; + const membership = taskMembership([journal({ taskIntervals: [declared] })], TASK); + const outside = record({ event_timestamp: "2026-08-17T12:00:00Z" }); + const stages = selectionStages([outside], input({ task: TASK }), membership); + + expect(emptySelectionOf(stages, input({ task: TASK }), membership)).toStrictEqual({ + filter: "task", + value: TASK, + known: true, + }); + }); + + it("knows a task that only a written file names", () => { + const membership = taskMembership( + [journal({ vendorId: "s-9", writtenPaths: [`aidd_docs/tasks/${TASK}/plan.md`] })], + TASK + ); + const elsewhere = record({ vendor_id: "s-1" }); + const stages = selectionStages([elsewhere], input({ task: TASK }), membership); + + expect(emptySelectionOf(stages, input({ task: TASK }), membership)).toStrictEqual({ + filter: "task", + value: TASK, + known: true, + }); + }); + + it("reports a task no session ever declared or wrote into as one never known", () => { + const membership = taskMembership([journal({})], TASK); + const stages = selectionStages([record({})], input({ task: TASK }), membership); + + expect(emptySelectionOf(stages, input({ task: TASK }), membership)).toStrictEqual({ + filter: "task", + value: TASK, + known: false, + }); + }); + + it("knows a tool from the declared list, however many tools are declared beside it", () => { + const declaredTools: CostReportInput["declaredTools"] = [ + { + tool: "claude", + coverage: "covered", + capability: { + localRead: null, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + { + tool: "codex", + coverage: "covered", + capability: { + localRead: null, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + ]; + const asked = input({ declaredTools, filters: { tool: "codex" } }); + const stages = selectionStages([record({ tool: "claude" })], asked, null); + + expect(emptySelectionOf(stages, asked, null)).toStrictEqual({ + filter: "tool", + value: "codex", + known: true, + }); + }); + + it("reports a tool outside the declared list as one never known", () => { + const asked = input({ filters: { tool: "codex" } }); + const stages = selectionStages([record({ tool: "claude" })], asked, null); + + expect(emptySelectionOf(stages, asked, null)).toStrictEqual({ + filter: "tool", + value: "codex", + known: false, + }); + }); + + it("knows a model seen anywhere the caller looked, even outside this period", () => { + const asked = input({ + filters: { model: "opus" }, + knownValues: { projects: new Set(), steps: new Set(), models: new Set(["opus"]) }, + }); + const stages = selectionStages([record({ model: "haiku" })], asked, null); + + expect(emptySelectionOf(stages, asked, null)).toStrictEqual({ + filter: "model", + value: "opus", + known: true, + }); + }); + + it("blames the combination when the culprit's value matched the period before other filters ran", () => { + const asked = input({ + filters: { project: "acme", model: "opus" }, + knownValues: { projects: new Set(["acme"]), steps: new Set(), models: new Set(["opus"]) }, + }); + const acmeOnHaiku = record({ project_id: "acme", model: "haiku" }); + const otherOnOpus = record({ project_id: "other", model: "opus" }); + const stages = selectionStages([acmeOnHaiku, otherOnOpus], asked, null); + + expect(emptySelectionOf(stages, asked, null)).toStrictEqual({ + filter: "model", + value: "opus", + known: true, + combination: true, + }); + }); + + it("measures a combination against the task's own records, not the whole period, under --task", () => { + const membership = taskMembership( + [journal({ vendorId: "s-1", writtenPaths: [`aidd_docs/tasks/${TASK}/plan.md`] })], + TASK + ); + const asked = input({ + task: TASK, + filters: { model: "opus" }, + knownValues: { projects: new Set(), steps: new Set(), models: new Set(["opus"]) }, + }); + const inTaskOnHaiku = record({ vendor_id: "s-1", model: "haiku" }); + const outsideOnOpus = record({ vendor_id: "s-2", model: "opus" }); + const stages = selectionStages([inTaskOnHaiku, outsideOnOpus], asked, membership); + + expect(emptySelectionOf(stages, asked, membership)).toStrictEqual({ + filter: "model", + value: "opus", + known: true, + }); + }); + + it("never blames a combination on the task filter itself", () => { + const membership = taskMembership([journal({})], TASK); + const stages = selectionStages([record({})], input({ task: TASK }), membership); + + expect(emptySelectionOf(stages, input({ task: TASK }), membership)).not.toHaveProperty( + "combination" + ); + }); +}); + +describe("activeFilters", () => { + it("answers nothing for an empty filters object", () => { + expect(activeFilters({})).toBeUndefined(); + }); + + it("answers nothing when every filter is present but undefined", () => { + expect(activeFilters({ project: undefined, model: undefined })).toBeUndefined(); + }); + + it("keeps only the filters given, in the fixed order project, step, model, tool", () => { + expect(activeFilters({ tool: "claude", project: "acme" })).toStrictEqual({ + project: "acme", + tool: "claude", + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/report/row-ordering.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/row-ordering.unit.test.ts new file mode 100644 index 000000000..c512469d4 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/row-ordering.unit.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { CostTotals } from "../../../../../src/contexts/telemetry/domain/cost-report.js"; +import { + bySize, + isoSecondsFromMs, +} from "../../../../../src/contexts/telemetry/domain/report/row-ordering.js"; + +interface Row { + readonly key: string; + readonly totals: CostTotals; +} + +function ordered(rows: readonly Row[]): readonly string[] { + return bySize( + rows, + (row) => row.totals, + (row) => row.key + ).map((row) => row.key); +} + +describe("bySize", () => { + it("weighs a costless row by all four counters added together, the cache ones included", () => { + const rows: Row[] = [ + { key: "cache-heavy", totals: { requests: 1, cacheCreationTokens: 100 } }, + { key: "output-heavy", totals: { requests: 1, inputTokens: 5, outputTokens: 10 } }, + { key: "input-heavy", totals: { requests: 1, inputTokens: 10, outputTokens: 1 } }, + ]; + + expect(ordered(rows)).toEqual(["cache-heavy", "output-heavy", "input-heavy"]); + }); + + it("breaks a tie on the row's own key, so the same rows always come out in one order", () => { + const rows: Row[] = [ + { key: "b", totals: { requests: 1, inputTokens: 3 } }, + { key: "a", totals: { requests: 1, inputTokens: 3 } }, + ]; + + expect(ordered(rows)).toEqual(["a", "b"]); + expect(ordered([...rows].reverse())).toEqual(["a", "b"]); + }); + + it("leaves the rows it was given untouched", () => { + const rows: Row[] = [ + { key: "small", totals: { requests: 1, inputTokens: 1 } }, + { key: "large", totals: { requests: 1, inputTokens: 9 } }, + ]; + + bySize( + rows, + (row) => row.totals, + (row) => row.key + ); + + expect(rows.map((row) => row.key)).toEqual(["small", "large"]); + }); +}); + +describe("isoSecondsFromMs", () => { + it("renders a moment to the second, the way the journal spells a line's own moment", () => { + expect(isoSecondsFromMs(Date.parse("2026-08-17T09:04:05.000Z"))).toBe("2026-08-17T09:04:05Z"); + }); + + it("drops the milliseconds rather than rounding them into the next second", () => { + expect(isoSecondsFromMs(Date.parse("2026-08-17T09:04:05.999Z"))).toBe("2026-08-17T09:04:05Z"); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/session-anchor.unit.test.ts b/cli/tests/contexts/telemetry/domain/session-anchor.unit.test.ts new file mode 100644 index 000000000..2c20117ce --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/session-anchor.unit.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { resolveSessionAnchor } from "../../../../src/contexts/telemetry/domain/session-anchor.js"; + +describe("resolveSessionAnchor", () => { + it("prefers the Codex thread over the enclosing Claude Code session", () => { + expect( + resolveSessionAnchor({ CODEX_THREAD_ID: "thread-1", CLAUDE_CODE_SESSION_ID: "claude-1" }) + ).toBe("thread-1"); + }); + + it("falls back to the Claude Code session when no Codex thread is set", () => { + expect(resolveSessionAnchor({ CLAUDE_CODE_SESSION_ID: "claude-1" })).toBe("claude-1"); + }); + + it("answers nothing when neither host named a session", () => { + expect(resolveSessionAnchor({})).toBeUndefined(); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts b/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts index dbcb740ac..755e3e42c 100644 --- a/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts @@ -57,4 +57,19 @@ describe("resolveSessionProject", () => { it("names no project for a session with no journal at all", () => { expect(resolveSessionProject(null)).toBeNull(); }); + + it("reads an empty remote as none, falling back to the directory-name field", () => { + const journal = journalOf(sessionOf({ project_id: "acme-widgets", project_remote: "" })); + + expect(resolveSessionProject(journal)).toStrictEqual({ + projectId: "acme-widgets", + projectField: "project_id", + }); + }); + + it("names no project when both fields are empty strings", () => { + expect( + resolveSessionProject(journalOf(sessionOf({ project_id: "", project_remote: "" }))) + ).toBeNull(); + }); }); diff --git a/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts b/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts index ddb00142c..bf9d4beaf 100644 --- a/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts @@ -28,4 +28,8 @@ describe("namesTheSameSkill — one skill, two hosts, two spellings", () => { expect(namesTheSameSkill("artifact-design", "aidd-context:artifact-design")).toBe(true); expect(namesTheSameSkill("artifact-design", "aidd-context:artifact-diagramming")).toBe(false); }); + + it("drops a one-letter plugin prefix, whose colon sits at index one", () => { + expect(namesTheSameSkill("01-plan", "p:01-plan")).toBe(true); + }); }); diff --git a/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts index c367d26a3..2bc49c5af 100644 --- a/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts @@ -406,3 +406,70 @@ describe("buildStepIntervals — a step the session never closed", () => { }); }); }); + +describe("buildStepIntervals — an orchestration starting while a plain step runs", () => { + it("closes the plain step at the orchestrating step_start, not at the journal's end", () => { + const intervals = buildStepIntervals( + journalWith( + [ + A_START, + { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-orchestrator:01-sdlc" }, + ], + [{ type: "file_written", at: "2026-08-20T11:00:00Z", path: "aidd_docs/note.md" }] + ) + ); + + expect(intervals.find((interval) => interval.skill === A_START.skill)).toStrictEqual({ + skill: A_START.skill, + startMs: Date.parse(A_START.at), + endMs: Date.parse("2026-08-20T10:05:00Z"), + closedBy: "boundary", + }); + }); +}); + +describe("attributeMoment — two intervals opened at the same second", () => { + const SDLC_AT_TEN = { + type: "step_start", + at: "2026-08-20T10:00:00Z", + skill: "aidd-orchestrator:01-sdlc", + } as const; + const SPEC_AT_TEN = { + type: "step_start", + at: "2026-08-20T10:00:00Z", + skill: "aidd-pm:04-spec", + } as const; + const LATER_TURN_END = { type: "turn_end", at: "2026-08-20T11:00:00Z" } as const; + + it("answers the orchestration when it is the one that closes first", () => { + const intervals = buildStepIntervals( + journalOf( + SDLC_AT_TEN, + SPEC_AT_TEN, + { type: "step_end", at: "2026-08-20T10:20:00Z", skill: "aidd-orchestrator:01-sdlc" }, + LATER_TURN_END + ) + ); + + expect(attributeMoment(intervals, "2026-08-20T10:05:00Z")).toStrictEqual({ + source: "journal-interval", + step: "aidd-orchestrator:01-sdlc", + }); + }); + + it("answers the invoked step when it is the one that closes first", () => { + const intervals = buildStepIntervals( + journalOf( + SDLC_AT_TEN, + SPEC_AT_TEN, + { type: "step_end", at: "2026-08-20T10:10:00Z", skill: "aidd-pm:04-spec" }, + LATER_TURN_END + ) + ); + + expect(attributeMoment(intervals, "2026-08-20T10:05:00Z")).toStrictEqual({ + source: "journal-interval", + step: "aidd-pm:04-spec", + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts index 4fb731108..7bd264c3b 100644 --- a/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts @@ -288,4 +288,25 @@ describe("taskUnattributedReason — which of four distinct facts applies", () = expect(taskUnattributedReason(intervals, undefined)).toBe("journal-silent"); expect(taskUnattributedReason(intervals, "not-a-date")).toBe("journal-silent"); }); + + it("reads a record at the journal's very first witnessed moment as inside the journal, not before it", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + const journalFromMs = Date.parse("2026-08-17T09:30:00Z"); + + expect(taskUnattributedReason(intervals, "2026-08-17T09:30:00Z", journalFromMs)).toBe( + "precedes-declaration" + ); + }); + + it("names precedes-declaration while any declaration still lies ahead of the moment", () => { + const intervals = buildTaskIntervals(journalOf([WANTED, OTHER], [TURN_END])); + + expect(taskUnattributedReason(intervals, "2026-08-17T10:05:00Z")).toBe("precedes-declaration"); + }); + + it("reads a record at the very instant of the only declaration as not before it", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + + expect(taskUnattributedReason(intervals, WANTED.at)).toBe("journal-silent"); + }); }); diff --git a/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts b/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts index 9937ceeee..daac6affe 100644 --- a/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts @@ -119,3 +119,13 @@ describe("taskIdentitiesFromWrittenPaths", () => { } }); }); + +describe("taskIdentityFromWrittenPath — where a single-file task must start and end", () => { + it("names no task for a single file whose name only begins with .md", () => { + expect(taskIdentityFromWrittenPath("aidd_docs/tasks/2026_08/2026_08_21_x.md.bak")).toBeNull(); + }); + + it("names no task for a single-file task path nested under another directory", () => { + expect(taskIdentityFromWrittenPath("docs/aidd_docs/tasks/2026_08/2026_08_21_x.md")).toBeNull(); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts new file mode 100644 index 000000000..3d4184f62 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts @@ -0,0 +1,433 @@ +import { describe, expect, it } from "vitest"; +import { + diagnoseTelemetryClaims, + type TelemetryClaim, + type TelemetryClaimId, + type TelemetryClaimJournal, + type TelemetryClaimToolRead, + type TelemetryEvidence, +} from "../../../../src/contexts/telemetry/domain/telemetry-claim.js"; + +const RUNS_DIR_LABEL = "aidd_docs/runs"; +const CODEX_CONFIG = "/home/.codex/config.toml"; + +function journal(overrides: Partial = {}): TelemetryClaimJournal { + return { vendorId: undefined, sessionStartAt: undefined, turnClosed: false, ...overrides }; +} + +function toolRead(overrides: Partial = {}): TelemetryClaimToolRead { + return { tool: "claude", sessionFound: false, hasIntervals: false, records: [], ...overrides }; +} + +function evidence(overrides: Partial = {}): TelemetryEvidence { + return { + journals: [], + toolReads: [], + runsDirLabel: RUNS_DIR_LABEL, + recorderDeclared: false, + recorderDeclarationReadable: true, + foreignSchemaVersions: [], + ...overrides, + }; +} + +function claimOf(overrides: Partial, id: TelemetryClaimId): TelemetryClaim { + const found = diagnoseTelemetryClaims(evidence(overrides)).find((c) => c.claim === id); + if (found === undefined) throw new Error(`no ${id} claim`); + return found; +} + +describe("the hook-fired claim, word for word", () => { + it("names the newest session_start across every anchored run file, whatever order they came in", () => { + const hookFired = claimOf( + { + journals: [ + journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" }), + journal({ vendorId: "s-2" }), + journal({ vendorId: "s-3", sessionStartAt: "2026-07-01T09:00:00Z" }), + ], + currentSessionId: "s-1", + }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "ok", + reason: "session-anchored", + detail: "3 run file(s), most recent session_start 2026-08-20T09:00:00Z", + }); + }); + + it("says the session_start was unreadable when no anchored run file carries one", () => { + const hookFired = claimOf( + { journals: [journal({ vendorId: "s-1" })], currentSessionId: "s-1" }, + "hook-fired" + ); + + expect(hookFired.detail).toBe( + "1 run file(s), most recent session_start an unreadable session_start" + ); + }); + + it("anchors this session on its own run file even when an older session's file sits beside it", () => { + const hookFired = claimOf( + { + journals: [ + journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" }), + journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" }), + ], + currentSessionId: "s-1", + }, + "hook-fired" + ); + + expect(hookFired.reason).toBe("session-anchored"); + }); + + it("spells out the Codex trust fault, its config path and both ways out", () => { + const hookFired = claimOf( + { + currentSessionId: "codex-1", + hookTrust: { readable: true, trusted: false, configPath: CODEX_CONFIG }, + }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "fail", + reason: "untrusted-codex-hook", + detail: + "Codex has not trusted this plugin's hook — no trusted_hash for hooks/hooks.json:session_start " + + "in /home/.codex/config.toml. Approve it interactively once, or pass " + + "--dangerously-bypass-hook-trust to codex exec for a headless run.", + }); + }); + + it("names this session as having left no run file once its Codex hook is trusted", () => { + const hookFired = claimOf( + { + journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], + currentSessionId: "codex-current", + hookTrust: { readable: true, trusted: true, configPath: CODEX_CONFIG }, + }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "fail", + reason: "session-left-no-run-file", + detail: "this session left no run file — the newest one is from 2026-07-01T09:00:00Z", + }); + }); + + it("adds nothing about trust to the never-fired fault when there is no trust gate", () => { + const hookFired = claimOf({ currentSessionId: "s-1" }, "hook-fired"); + + expect(hookFired.detail).toBe( + "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + + "recorder is declared nowhere this build checks" + ); + }); + + it("adds nothing about trust to the never-fired fault when the trust state is readable", () => { + const hookFired = claimOf( + { + currentSessionId: "codex-1", + hookTrust: { readable: true, trusted: true, configPath: CODEX_CONFIG }, + }, + "hook-fired" + ); + + expect(hookFired.detail).toBe( + "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + + "recorder is declared nowhere this build checks" + ); + }); + + it("appends the unreadable trust state, with its reason, to the never-fired fault", () => { + const hookFired = claimOf( + { + currentSessionId: "codex-1", + hookTrust: { readable: false, reason: "ENOENT" }, + }, + "hook-fired" + ); + + expect(hookFired.detail).toBe( + "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + + "recorder is declared nowhere this build checks — Codex's own hook trust state could " + + "not be read either (ENOENT), so this may be the same cause" + ); + }); + + it("tells a damaged declaring file apart from one that never declared the recorder", () => { + const hookFired = claimOf( + { currentSessionId: "s-1", recorderDeclared: false, recorderDeclarationReadable: false }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "unknown", + reason: "recorder-declaration-unreadable", + detail: + "no run file in aidd_docs/runs yet, and whether the recorder is declared could not be " + + "read — see recorder declared, above, for which location. A damaged declaring file is " + + "not the same absence as one that never declared the recorder.", + }); + }); + + it("counts the anchorless run files and names the two causes, never a hook that did not fire", () => { + const hookFired = claimOf( + { currentSessionId: "s-1", journals: [journal(), journal()] }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "fail", + reason: "anchorless-run-file", + detail: + "2 run file(s) in aidd_docs/runs, but none carry a readable session_start to anchor " + + "them — a torn write, or a hooks block that registers another event without " + + "SessionStart, never a hook that has not fired", + }); + }); + + it("lists the foreign schema versions once each, ascending, beside the count of files", () => { + const hookFired = claimOf( + { currentSessionId: "s-1", foreignSchemaVersions: [3, 2, 3, 1] }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "fail", + reason: "journal-in-another-schema", + detail: + "4 run file(s) in aidd_docs/runs written under a schema this build does not read " + + "(1, 2, 3) — a journal from another version of the plugin, never a hook that did not fire", + }); + }); + + it("spells out the missing anchor beside the newest session_start", () => { + const hookFired = claimOf( + { journals: [journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" })] }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "unknown", + reason: "no-session-anchor", + detail: + "1 run file(s), most recent session_start 2026-08-20T09:00:00Z — no session anchor " + + "available to tell whether this session's hook fired", + }); + }); +}); + +describe("the session-journalled claim, word for word", () => { + it("has nothing to read without a run file", () => { + expect(claimOf({}, "session-journalled")).toStrictEqual({ + claim: "session-journalled", + verdict: "unknown", + reason: "no-run-file-to-read", + detail: "no run file to read", + }); + }); + + it("counts the run files that carry only session_start", () => { + expect( + claimOf( + { journals: [journal({ vendorId: "s-1" }), journal({ vendorId: "s-2" })] }, + "session-journalled" + ) + ).toStrictEqual({ + claim: "session-journalled", + verdict: "fail", + reason: "only-session-start", + detail: "2 run file(s), all carrying only session_start — nothing closed the turn", + }); + }); + + it("counts the closed turns against the anchored run files", () => { + expect( + claimOf( + { + journals: [ + journal({ vendorId: "s-1", turnClosed: true }), + journal({ vendorId: "s-2" }), + journal({ vendorId: "s-3", turnClosed: true }), + ], + }, + "session-journalled" + ) + ).toStrictEqual({ + claim: "session-journalled", + verdict: "ok", + reason: "turn-closed", + detail: "2 of 3 run file(s) carry more than session_start", + }); + }); +}); + +describe("the tool-files-readable claim, word for word", () => { + it("has no session to look for when the journal names none", () => { + expect(claimOf({}, "tool-files-readable")).toStrictEqual({ + claim: "tool-files-readable", + verdict: "unknown", + reason: "no-session-named", + detail: "no session named by the journal", + }); + }); + + it("names every covered tool once and every journalled session when none was found", () => { + expect( + claimOf( + { + journals: [journal({ vendorId: "s-1" }), journal({ vendorId: "s-2" })], + toolReads: [ + toolRead({ tool: "claude" }), + toolRead({ tool: "codex" }), + toolRead({ tool: "claude" }), + ], + }, + "tool-files-readable" + ) + ).toStrictEqual({ + claim: "tool-files-readable", + verdict: "fail", + reason: "no-session-found-for-any-tool", + detail: + "no session found for any journalled session, across every covered tool (claude, codex) " + + "— while the journal names s-1, s-2", + }); + }); + + it("appends the failed attempts, quoting the last error, when none was found", () => { + expect( + claimOf( + { + journals: [journal({ vendorId: "s-1" })], + toolReads: [ + toolRead({ tool: "claude", error: "EACCES" }), + toolRead({ tool: "codex", error: "ENOENT" }), + ], + }, + "tool-files-readable" + ).detail + ).toBe( + "no session found for any journalled session, across every covered tool (claude, codex) " + + "— while the journal names s-1 — 2 read attempt(s) failed: ENOENT" + ); + }); + + it("tallies each tool's reads, joined by a semicolon, with no failure suffix when none failed", () => { + expect( + claimOf( + { + journals: [journal({ vendorId: "s-1" })], + toolReads: [ + toolRead({ tool: "claude", sessionFound: true }), + toolRead({ tool: "claude", sessionFound: false }), + toolRead({ tool: "codex", sessionFound: false }), + ], + }, + "tool-files-readable" + ) + ).toStrictEqual({ + claim: "tool-files-readable", + verdict: "ok", + reason: "session-found", + detail: "claude: 1 of 2 session(s) read; codex: 0 of 1 session(s) read", + }); + }); + + it("counts the reads a tool could not make beside the ones it made", () => { + expect( + claimOf( + { + journals: [journal({ vendorId: "s-1" })], + toolReads: [ + toolRead({ tool: "claude", sessionFound: true }), + toolRead({ tool: "claude", error: "EACCES" }), + toolRead({ tool: "claude", error: "ENOENT" }), + ], + }, + "tool-files-readable" + ).detail + ).toBe("claude: 1 of 3 session(s) read, 2 could not be read"); + }); +}); + +describe("the records-join claim, word for word", () => { + it("has nothing to join without a record", () => { + expect(claimOf({ toolReads: [toolRead()] }, "records-join")).toStrictEqual({ + claim: "records-join", + verdict: "unknown", + reason: "no-record-to-join", + detail: "no record read to join", + }); + }); + + it("has no join material without an interval or a tool-stated step", () => { + expect( + claimOf( + { toolReads: [toolRead({ records: [{ stepAttribution: "unattributed" }] })] }, + "records-join" + ) + ).toStrictEqual({ + claim: "records-join", + verdict: "unknown", + reason: "no-join-material", + detail: "no step interval and no tool-stated step — see session journalled", + }); + }); + + it("takes one read's intervals as join material for every read's records", () => { + expect( + claimOf( + { + toolReads: [ + toolRead({ tool: "claude", hasIntervals: true }), + toolRead({ tool: "codex", records: [{ stepAttribution: "unattributed" }] }), + ], + }, + "records-join" + ) + ).toStrictEqual({ + claim: "records-join", + verdict: "fail", + reason: "all-unattributed", + detail: "1 record(s) found, joined: 0 — every record unattributed", + }); + }); + + it("takes a single tool-stated record as join material, with no interval at all", () => { + expect( + claimOf( + { + toolReads: [ + toolRead({ + records: [ + { stepAttribution: "unattributed" }, + { stepAttribution: "tool-stated" }, + { stepAttribution: "unattributed" }, + ], + }), + ], + }, + "records-join" + ) + ).toStrictEqual({ + claim: "records-join", + verdict: "ok", + reason: "records-joined", + detail: "1 of 3 record(s) joined a step, 2 unattributed", + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts index a12aee834..488a0fb62 100644 --- a/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts @@ -36,4 +36,8 @@ describe("findLeftoverExportKeys", () => { it("reads an env block that is not an object as nothing found", () => { expect(findLeftoverExportKeys(JSON.stringify({ env: "not-an-object" }))).toEqual([]); }); + + it("reads a file whose whole content is a JSON array as nothing found, rather than throwing", () => { + expect(findLeftoverExportKeys("[1, 2]")).toEqual([]); + }); }); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts index 3af967d4c..7c950c465 100644 --- a/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts @@ -188,3 +188,13 @@ describe("buildTelemetrySwitchFile", () => { }); }); }); + +describe("parseTelemetrySwitchFile — an endpoint that is not a string", () => { + it("reads no endpoint from a number, rather than carrying the number", () => { + const config = parseTelemetrySwitchFile( + JSON.stringify({ telemetry: { enabled: true, endpoint: 42 } }) + ); + + expect(config).toStrictEqual({ enabled: true, endpoint: undefined }); + }); +}); diff --git a/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts index 1d8ce1536..0e7776ebb 100644 --- a/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts @@ -54,6 +54,36 @@ describe("reading whether Codex has been told it may run the recorder's hook", ( expect(trust).toMatchObject({ readable: true, trusted: false }); }); + it("reads a trusted_hash line without the key above it as not trusted", async () => { + codexHome('trusted_hash = "abc123"\n'); + + expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: false }); + }); + + it("reads the key wherever it sits in the file, not only on its first line", async () => { + codexHome(`[projects]\n[other]\n${APPROVED_KEY}\ntrusted_hash = "abc123"\n`); + + expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: true }); + }); + + it("reads a trusted_hash written without spaces around its equals sign as trusted", async () => { + codexHome(`${APPROVED_KEY}\ntrusted_hash="abc123"\n`); + + expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: true }); + }); + + it("reads an indented trusted_hash line as trusted", async () => { + codexHome(`${APPROVED_KEY}\n trusted_hash = "abc123"\n`); + + expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: true }); + }); + + it("reads a key whose name merely ends in trusted_hash as not trusted", async () => { + codexHome(`${APPROVED_KEY}\nnot_trusted_hash = "abc123"\n`); + + expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: false }); + }); + it("reads a config naming no hook of ours as not trusted", async () => { codexHome( '[hooks.state."someone-else@1.0.0:hooks/hooks.json:session_start:0:0"]\ntrusted_hash = "x"\n' diff --git a/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts index ced459539..f5312daf0 100644 --- a/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts @@ -156,6 +156,51 @@ describe("OpencodeCostReaderAdapter", () => { } ); + it.skipIf(skipOnWindows)( + "names the spawn failure itself, not an exit code, when the command exceeds its timeout", + async () => { + const env = installStandIn(SLOW_SCRIPT); + restorePath = env.restore; + + await expect(new OpencodeCostReaderAdapter(200).read(SESSION_ID)).rejects.toThrow( + /^opencode export ses_test_read failed: spawnSync opencode ETIMEDOUT$/ + ); + } + ); + + it.skipIf(skipOnWindows)( + "names the exit code and the trimmed stderr on a generic failure", + async () => { + const env = installStandIn(GENERIC_FAILURE_SCRIPT); + restorePath = env.restore; + + await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( + /^opencode export ses_test_read exited with code 2: internal error: storage unavailable$/ + ); + } + ); + + it.skipIf(skipOnWindows)("says so when a failing command wrote nothing to stderr", async () => { + const env = installStandIn("#!/bin/sh\nexit 3\n"); + restorePath = env.restore; + + await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( + /^opencode export ses_test_read exited with code 3: no stderr output$/ + ); + }); + + it.skipIf(skipOnWindows)( + "reads a command killed by a signal as an unknown exit code", + async () => { + const env = installStandIn("#!/bin/sh\nkill -KILL $$\n"); + restorePath = env.restore; + + await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( + /^opencode export ses_test_read exited with code unknown: no stderr output$/ + ); + } + ); + it.skipIf(skipOnWindows)( "throws OpencodeExportError when the command answers with something that is not JSON", async () => { @@ -167,4 +212,32 @@ describe("OpencodeCostReaderAdapter", () => { ); } ); + + it.skipIf(skipOnWindows)( + "names the parse failure when the command answers with something that is not JSON", + async () => { + const env = installStandIn('#!/bin/sh\necho "not json"\nexit 0\n'); + restorePath = env.restore; + + await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( + /^opencode export ses_test_read did not answer with JSON: Unexpected token/ + ); + } + ); + + it.skipIf(skipOnWindows)( + "finds the binary in a later PATH entry when the first holds nothing", + async () => { + const env = installStandIn(WELL_BEHAVED_SCRIPT); + restorePath = env.restore; + const binDir = process.env.PATH ?? ""; + const emptyDir = mkdtempSync(join(tmpdir(), "aidd-opencode-first-")); + process.env.PATH = `${emptyDir}:${binDir}`; + + const { sessionFound } = await new OpencodeCostReaderAdapter().read(SESSION_ID); + + rmSync(emptyDir, { recursive: true, force: true }); + expect(sessionFound).toBe(true); + } + ); }); diff --git a/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts index c2ac367b3..d4c18aa84 100644 --- a/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts @@ -1,8 +1,9 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { PersonIdentityAdapter } from "../../../../src/contexts/telemetry/infrastructure/person-identity-adapter.js"; +import { IdentityWriteError, UnreadableIdentityFileError } from "../../../../src/kernel/errors.js"; /** On real disk: every write here goes through the file and is read back through it, since * what this adapter stores is what decides whose records are whose. */ @@ -182,4 +183,161 @@ describe("PersonIdentityAdapter — what it writes, and what it reads back", () expect(JSON.parse(raw)).toMatchObject({ person_id: minted.personId, origin: "minted" }); expect(raw.endsWith("\n")).toBe(true); }); + + it("writes the quietest shape: no also_me key until an identifier is added", async () => { + const adapter = await adapterInFreshHome(); + const minted = await adapter.mint(); + + expect(JSON.parse(await readFile(adapter.filePath, "utf8"))).toStrictEqual({ + person_id: minted.personId, + origin: "minted", + }); + }); + + it.skipIf(process.platform === "win32")( + "writes a file readable by this person alone", + async () => { + const adapter = await adapterInFreshHome(); + + await adapter.mint(); + + expect(((await stat(adapter.filePath)).mode & 0o777).toString(8)).toBe("600"); + } + ); + + it("reads an empty person_id as nobody having chosen", async () => { + const adapter = await adapterInFreshHome(); + await writeFile(adapter.filePath, '{"person_id":""}\n'); + + expect(await adapter.read()).toBeNull(); + expect(await adapter.readStrict()).toBeNull(); + }); + + it("reads a person_id that is not a string as nobody having chosen", async () => { + const adapter = await adapterInFreshHome(); + await writeFile(adapter.filePath, '{"person_id":42}\n'); + + expect(await adapter.readStrict()).toBeNull(); + }); + + it("keeps only the strings among the identifiers added onto a person", async () => { + const adapter = await adapterInFreshHome(); + await writeFile(adapter.filePath, '{"person_id":"p-1","also_me":["machine-2",3,null]}\n'); + + expect(await adapter.readStrict()).toStrictEqual({ + personId: "p-1", + origin: "minted", + alsoMe: ["machine-2"], + }); + }); + + it("reads an empty display name as none at all", async () => { + const adapter = await adapterInFreshHome(); + await writeFile(adapter.filePath, '{"person_id":"p-1","display_name":""}\n'); + + expect(await adapter.readStrict()).toStrictEqual({ + personId: "p-1", + origin: "minted", + alsoMe: [], + }); + }); + + it("reads a display name that is not a string as none at all", async () => { + const adapter = await adapterInFreshHome(); + await writeFile(adapter.filePath, '{"person_id":"p-1","display_name":7}\n'); + + expect(await adapter.readStrict()).toStrictEqual({ + personId: "p-1", + origin: "minted", + alsoMe: [], + }); + }); + + it("says what it was asked to add onto when no identity exists", async () => { + const adapter = await adapterInFreshHome(); + + await expect(adapter.addAlsoMe("machine-2")).rejects.toThrow( + `Could not write the identity file at ${adapter.filePath} (no identity exists to add an identifier onto).` + ); + }); + + it("says what it was asked to remove from when no identity exists", async () => { + const adapter = await adapterInFreshHome(); + + await expect(adapter.removeAlsoMe("machine-2")).rejects.toThrow( + `Could not write the identity file at ${adapter.filePath} (no identity exists to remove an identifier onto).` + ); + }); + + it("refuses strictly a file that is there but cannot be read as a file", async () => { + const adapter = await adapterInFreshHome(); + await mkdir(adapter.filePath); + + const strict = adapter.readStrict(); + + await expect(strict).rejects.toBeInstanceOf(UnreadableIdentityFileError); + await expect(strict).rejects.toThrow( + `Could not read the identity file at ${adapter.filePath} (EISDIR` + ); + }); + + it("reports a write that could not go out, naming the file", async () => { + const home = await mkdtemp(join(tmpdir(), "aidd-identity-rw-")); + homes.push(home); + process.env.HOME = home; + await writeFile(join(home, ".config"), ""); + const adapter = new PersonIdentityAdapter(); + + const minted = adapter.mint(); + + await expect(minted).rejects.toBeInstanceOf(IdentityWriteError); + await expect(minted).rejects.toThrow( + `Could not write the identity file at ${adapter.filePath} (ENOTDIR` + ); + }); +}); + +describe("PersonIdentityAdapter.forget — what it removes and what it reports", () => { + let previousHome: string | undefined; + const homes: string[] = []; + + beforeEach(() => { + previousHome = process.env.HOME; + }); + + afterEach(async () => { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + for (const home of homes.splice(0)) await rm(home, { recursive: true, force: true }); + }); + + async function adapterInFreshHome(): Promise { + const home = await mkdtemp(join(tmpdir(), "aidd-identity-forget-")); + homes.push(home); + process.env.HOME = home; + await mkdir(join(home, ".config", "aidd"), { recursive: true }); + return new PersonIdentityAdapter(); + } + + it("removes a damaged identity that is a directory, not a file", async () => { + const adapter = await adapterInFreshHome(); + await mkdir(adapter.filePath); + await writeFile(join(adapter.filePath, "stray"), ""); + + expect(await adapter.forget(adapter.filePath)).toBe(true); + await expect(readFile(adapter.filePath, "utf8")).rejects.toThrow(); + }); + + it("reports a removal that failed for a reason other than being gone, as a removal", async () => { + const adapter = await adapterInFreshHome(); + await adapter.mint(); + const unreachable = join(adapter.filePath, "child"); + + const forgotten = adapter.forget(unreachable); + + await expect(forgotten).rejects.toBeInstanceOf(IdentityWriteError); + await expect(forgotten).rejects.toThrow( + `Could not remove the identity file at ${unreachable} (ENOTDIR` + ); + }); }); diff --git a/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-lines.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-lines.integration.test.ts new file mode 100644 index 000000000..f56eac3c1 --- /dev/null +++ b/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-lines.integration.test.ts @@ -0,0 +1,312 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + READABLE_JOURNAL_SCHEMA_VERSION, + RunJournalReaderAdapter, +} from "../../../../src/contexts/telemetry/infrastructure/run-journal-reader-adapter.js"; + +const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; +const SESSION_ID = "22222222-2222-4222-8222-222222222222"; +const AT = "2026-08-20T10:00:00Z"; + +const HEADER = { + type: "session_start", + at: "2026-08-20T09:59:00Z", + run_id: RUN_ID, + tool: "claude-code", + vendor_id: SESSION_ID, +} as const; + +function lines(...values: readonly unknown[]): string { + return `${values.map((value) => JSON.stringify(value)).join("\n")}\n`; +} + +describe("RunJournalReaderAdapter, one line at a time", () => { + let projectRoot: string; + let runsDir: string; + + beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), "aidd-run-journal-lines-")); + runsDir = join(projectRoot, "aidd_docs", "runs"); + await mkdir(runsDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + }); + + async function readAfterWriting(...values: readonly unknown[]) { + await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), lines(...values)); + return new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); + } + + async function readRaw(content: string) { + await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), content); + return new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); + } + + describe("the run file a session id names", () => { + it("ignores a file whose name carries the session id under another extension", async () => { + await writeFile( + join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonx`), + lines({ type: "step_start", at: AT, skill: "x" }) + ); + + await expect(new RunJournalReaderAdapter(projectRoot).read(SESSION_ID)).resolves.toBeNull(); + }); + + it("ignores a run file naming no session at all", async () => { + await writeFile(join(runsDir, `${RUN_ID}__.jsonl`), lines({ type: "turn_end", at: AT })); + + await expect(new RunJournalReaderAdapter(projectRoot).read(SESSION_ID)).resolves.toBeNull(); + }); + + it("ignores a file whose run id and session id are not joined by the double underscore", async () => { + await writeFile( + join(runsDir, `${RUN_ID}--${SESSION_ID}.jsonl`), + lines({ type: "turn_end", at: AT }) + ); + + await expect(new RunJournalReaderAdapter(projectRoot).read(SESSION_ID)).resolves.toBeNull(); + }); + }); + + describe("a boundary line", () => { + it("reads a step_end as the boundary that names its skill", async () => { + const journal = await readAfterWriting({ + type: "step_end", + at: AT, + skill: "aidd-dev:01-plan", + }); + + expect(journal?.boundaries).toStrictEqual([ + { type: "step_end", at: AT, skill: "aidd-dev:01-plan" }, + ]); + }); + + it("carries a step_start's turn_id when the hook wrote one", async () => { + const journal = await readAfterWriting({ + type: "step_start", + at: AT, + skill: "aidd-dev:01-plan", + turn_id: "turn-7", + }); + + expect(journal?.boundaries).toStrictEqual([ + { type: "step_start", at: AT, skill: "aidd-dev:01-plan", turn_id: "turn-7" }, + ]); + }); + + it("carries no turn_id key at all for a step_start written without one", async () => { + const journal = await readAfterWriting({ + type: "step_start", + at: AT, + skill: "aidd-dev:01-plan", + }); + + expect(journal?.boundaries).toStrictEqual([ + { type: "step_start", at: AT, skill: "aidd-dev:01-plan" }, + ]); + }); + + it("drops a turn_end that carries no moment", async () => { + const journal = await readAfterWriting({ type: "turn_end" }); + + expect(journal?.boundaries).toStrictEqual([]); + }); + + it("drops a step_start that names no skill", async () => { + const journal = await readAfterWriting({ type: "step_start", at: AT }); + + expect(journal?.boundaries).toStrictEqual([]); + }); + + it("drops a moment that is not a string", async () => { + const journal = await readAfterWriting({ type: "turn_end", at: 1755684000000 }); + + expect(journal?.boundaries).toStrictEqual([]); + }); + + it("reads a line of a type it has never heard of as nothing, even carrying a moment and a skill", async () => { + const journal = await readAfterWriting({ + type: "step_paused", + at: AT, + skill: "aidd-dev:01-plan", + }); + + expect(journal).toStrictEqual({ boundaries: [], filesWritten: [], taskDeclarations: [] }); + }); + }); + + describe("a file_written or task_declared line", () => { + it.each([ + ["file_written", { type: "file_written", path: "aidd_docs/tasks/2026_08/t/plan.md" }], + ["file_written", { type: "file_written", at: AT }], + ["task_declared", { type: "task_declared", path: "aidd_docs/tasks/2026_08/t/plan.md" }], + ["task_declared", { type: "task_declared", at: AT }], + ])("drops a %s line missing its moment or its path: %j", async (_type, line) => { + const journal = await readAfterWriting(line); + + expect(journal).toStrictEqual({ boundaries: [], filesWritten: [], taskDeclarations: [] }); + }); + + it("reads a task_declared line as exactly its moment and its path", async () => { + const journal = await readAfterWriting({ + type: "task_declared", + at: AT, + path: "aidd_docs/tasks/2026_08/t/spec.md", + source: "stated", + }); + + expect(journal?.taskDeclarations).toStrictEqual([ + { type: "task_declared", at: AT, path: "aidd_docs/tasks/2026_08/t/spec.md" }, + ]); + }); + }); + + describe("the header line", () => { + it("reads a minimal header as exactly its five fields, adding no key for a field nobody wrote", async () => { + const journal = await readAfterWriting(HEADER); + + expect(journal?.session).toStrictEqual(HEADER); + }); + + it.each(["at", "run_id", "tool", "vendor_id"])( + "refuses a header missing %s alone", + async (field) => { + const partial = Object.fromEntries(Object.entries(HEADER).filter(([key]) => key !== field)); + const journal = await readAfterWriting(partial); + + expect(journal?.session).toBeUndefined(); + } + ); + + it("refuses a header whose run_id is not a string", async () => { + const journal = await readAfterWriting({ ...HEADER, run_id: 42 }); + + expect(journal?.session).toBeUndefined(); + }); + + it("takes the header only from a session_start line", async () => { + const journal = await readAfterWriting({ ...HEADER, type: "session_end" }); + + expect(journal?.session).toBeUndefined(); + }); + + it("carries both worktree fields when the hook wrote them", async () => { + const journal = await readAfterWriting({ + ...HEADER, + worktree_id: "feature-x", + worktree_repo_id: "acme/widgets", + }); + + expect(journal?.session).toStrictEqual({ + ...HEADER, + worktree_id: "feature-x", + worktree_repo_id: "acme/widgets", + }); + }); + + it("carries project_id, project_remote and schema_version each on its own", async () => { + const journal = await readAfterWriting({ + ...HEADER, + schema_version: READABLE_JOURNAL_SCHEMA_VERSION, + project_id: "widgets", + project_remote: "github.com/acme/widgets", + }); + + expect(journal?.session).toStrictEqual({ + ...HEADER, + schema_version: READABLE_JOURNAL_SCHEMA_VERSION, + project_id: "widgets", + project_remote: "github.com/acme/widgets", + }); + }); + + it("reads a schema_version that is not a number as none stated, and still reads the journal", async () => { + const journal = await readAfterWriting( + { ...HEADER, schema_version: String(READABLE_JOURNAL_SCHEMA_VERSION) }, + { type: "turn_end", at: AT } + ); + + expect(journal).toStrictEqual({ + boundaries: [{ type: "turn_end", at: AT }], + filesWritten: [], + taskDeclarations: [], + session: HEADER, + }); + }); + + it("reads a schema_version that overflowed to a non-finite number as none stated", async () => { + const journal = await readRaw( + `${JSON.stringify(HEADER).slice(0, -1)},"schema_version":1e999}\n` + ); + + expect(journal?.session).toStrictEqual(HEADER); + }); + }); + + describe("the run files it lists", () => { + it("lists only .jsonl names, sorted", async () => { + await writeFile(join(runsDir, "README.md"), "not a run file\n"); + await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), lines(HEADER)); + + await expect(new RunJournalReaderAdapter(projectRoot).listRunFiles()).resolves.toStrictEqual([ + `${RUN_ID}__${SESSION_ID}.jsonl`, + ]); + }); + + it("lists no run file at all, rather than throwing, when the runs directory is missing", async () => { + await rm(runsDir, { recursive: true, force: true }); + + await expect(new RunJournalReaderAdapter(projectRoot).listRunFiles()).resolves.toStrictEqual( + [] + ); + }); + + it("names no foreign schema for a journal under its own schema, nor for one stating none", async () => { + await writeFile( + join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), + lines({ ...HEADER, schema_version: READABLE_JOURNAL_SCHEMA_VERSION }) + ); + await writeFile(join(runsDir, `${RUN_ID}__other.jsonl`), lines(HEADER)); + + await expect( + new RunJournalReaderAdapter(projectRoot).listForeignSchemas() + ).resolves.toStrictEqual([]); + }); + + it("still names a foreign schema beside a run file whose header is torn", async () => { + await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), '{"type":"session_st\n'); + await writeFile( + join(runsDir, `${RUN_ID}__other.jsonl`), + lines({ ...HEADER, schema_version: READABLE_JOURNAL_SCHEMA_VERSION + 1 }) + ); + + await expect( + new RunJournalReaderAdapter(projectRoot).listForeignSchemas() + ).resolves.toStrictEqual([READABLE_JOURNAL_SCHEMA_VERSION + 1]); + }); + + it("skips a directory that merely carries the run file extension", async () => { + await mkdir(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`)); + const adapter = new RunJournalReaderAdapter(projectRoot); + + await expect(adapter.listForeignSchemas()).resolves.toStrictEqual([]); + await expect(adapter.list()).resolves.toStrictEqual([]); + await expect(adapter.read(SESSION_ID)).resolves.toBeNull(); + }); + }); + + describe("deleteRunFile", () => { + it("names the refused name and the directory in the error it throws", async () => { + const adapter = new RunJournalReaderAdapter(projectRoot); + + await expect(adapter.deleteRunFile(runsDir, "../escape.jsonl")).rejects.toThrow( + `refusing to delete "../escape.jsonl" — not a run file name inside ${runsDir}` + ); + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts index 28107c4bb..dc7202ef0 100644 --- a/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts @@ -149,6 +149,72 @@ describe("TaskBacklogAdapter — reads a declaration without ever writing one", await expect(adapter.read(TASK_FOLDER)).resolves.toEqual({ kind: "unreadable" }); }); + it("answers unreadable for valid JSON missing written_at alone", async () => { + const root = await freshProject(); + await writeLink( + root, + JSON.stringify({ backlog: "ai-driven-dev/framework#617", written_by: "aidd-pm:04-spec" }) + ); + + await expect(new TaskBacklogAdapter(root).read(TASK_FOLDER)).resolves.toStrictEqual({ + kind: "unreadable", + }); + }); + + it("answers unreadable for valid JSON missing written_by alone", async () => { + const root = await freshProject(); + await writeLink( + root, + JSON.stringify({ backlog: "ai-driven-dev/framework#617", written_at: "2026-08-21T09:00:00Z" }) + ); + + await expect(new TaskBacklogAdapter(root).read(TASK_FOLDER)).resolves.toStrictEqual({ + kind: "unreadable", + }); + }); + + it("answers unreadable for an empty backlog reference", async () => { + const root = await freshProject(); + await writeLink( + root, + JSON.stringify({ + backlog: "", + written_at: "2026-08-21T09:00:00Z", + written_by: "aidd-pm:04-spec", + }) + ); + + await expect(new TaskBacklogAdapter(root).read(TASK_FOLDER)).resolves.toStrictEqual({ + kind: "unreadable", + }); + }); + + it("answers unreadable for a backlog reference that is not a string", async () => { + const root = await freshProject(); + await writeLink( + root, + JSON.stringify({ + backlog: 617, + written_at: "2026-08-21T09:00:00Z", + written_by: "aidd-pm:04-spec", + }) + ); + + await expect(new TaskBacklogAdapter(root).read(TASK_FOLDER)).resolves.toStrictEqual({ + kind: "unreadable", + }); + }); + + it("answers unreadable when the file is there but cannot be read, distinct from none", async () => { + const root = await freshProject(); + await mkdir(join(root, "aidd_docs", "tasks"), { recursive: true }); + await writeFile(join(root, "aidd_docs", "tasks", "not-a-folder"), ""); + + await expect( + new TaskBacklogAdapter(root).read("aidd_docs/tasks/not-a-folder/") + ).resolves.toStrictEqual({ kind: "unreadable" }); + }); + it("answers unreadable for a declaration missing its provenance", async () => { const root = await freshProject(); await writeLink(root, JSON.stringify({ backlog: "ai-driven-dev/framework#617" })); diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts index 128dfe1f7..12843be44 100644 --- a/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts @@ -93,6 +93,276 @@ describe("what the switch setup reports, beside the answer itself", () => { expect(setup.readable).toBe(true); expect(setup.enabled).toBe(false); }); + + it("reads a project that turned it on as readable and enabled, naming the file", async () => { + const root = project(); + writeJson(join(root, ".aidd", "config.json"), { telemetry: { enabled: true } }); + + expect(await adapter().readSwitchSetup(root)).toStrictEqual({ + path: join(root, ".aidd", "config.json"), + enabled: true, + readable: true, + }); + }); + + it("reads a switch path that is a directory as unreadable, not as absent", async () => { + const root = project(); + mkdirSync(join(root, ".aidd", "config.json"), { recursive: true }); + + expect(await adapter().readSwitchSetup(root)).toStrictEqual({ + path: join(root, ".aidd", "config.json"), + enabled: false, + readable: false, + }); + }); +}); + +describe("where the recorder declaration is looked for", () => { + it("checks the manifest, both enabledPlugins files, the three Claude hook scopes and Cursor's hooks file, once each", async () => { + const root = project(); + const home = process.env.HOME ?? ""; + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration).toStrictEqual({ + declared: false, + declaredAt: [], + locationsChecked: [ + join(root, ".aidd", "manifest.json"), + join(root, ".claude", "settings.json"), + join(root, ".github", "copilot", "settings.json"), + join(root, ".claude", "settings.local.json"), + join(home, ".claude", "settings.json"), + join(root, ".cursor", "hooks.json"), + ], + unreadable: [], + }); + }); +}); + +describe("what the manifest says about the recorder", () => { + async function declarationFor(manifest: unknown) { + const root = project(); + writeJson(join(root, ".aidd", "manifest.json"), manifest); + const declaration = await adapter().readRecorderDeclaration(root); + return { declaration, manifestFile: join(root, ".aidd", "manifest.json") }; + } + + it("names the manifest alone as the declaring location", async () => { + const { declaration, manifestFile } = await declarationFor({ + tools: { claude: { plugins: [{ name: "aidd-telemetry", version: "1.0.0" }] } }, + }); + + expect(declaration.declaredAt).toStrictEqual([manifestFile]); + expect(declaration.unreadable).toStrictEqual([]); + }); + + it("finds the recorder under a second tool when the first declares other plugins only", async () => { + const { declaration, manifestFile } = await declarationFor({ + tools: { + codex: { plugins: [{ name: "aidd-dev", version: "1.0.0" }] }, + claude: { + plugins: [ + { name: "aidd-dev", version: "1.0.0" }, + { name: "aidd-telemetry", version: "1.0.0" }, + ], + }, + }, + }); + + expect(declaration.declaredAt).toStrictEqual([manifestFile]); + }); + + it("reads a manifest declaring only other plugins as not declaring the recorder", async () => { + const { declaration } = await declarationFor({ + tools: { claude: { plugins: [{ name: "aidd-dev", version: "1.0.0" }] } }, + }); + + expect(declaration.declared).toBe(false); + expect(declaration.unreadable).toStrictEqual([]); + }); + + it.each([ + ["a JSON null", null], + ["no tools key", {}], + ["a tool entry that is not an object", { tools: { claude: "installed" } }], + ["a plugins value that is not a list", { tools: { claude: { plugins: "aidd-telemetry" } } }], + [ + "a plugin entry that is not an object", + { tools: { claude: { plugins: ["aidd-telemetry"] } } }, + ], + ])("reads a manifest holding %s as not declaring, and not as unreadable", async (_, manifest) => { + const { declaration } = await declarationFor(manifest); + + expect(declaration.declared).toBe(false); + expect(declaration.declaredAt).toStrictEqual([]); + expect(declaration.unreadable).toStrictEqual([]); + }); + + it("names a damaged manifest as the one unreadable location", async () => { + const root = project(); + write(join(root, ".aidd", "manifest.json"), "{ tools: "); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.unreadable).toStrictEqual([join(root, ".aidd", "manifest.json")]); + expect(declaration.declaredAt).toStrictEqual([]); + }); + + it("names a manifest path that is a directory as unreadable, never as absent", async () => { + const root = project(); + mkdirSync(join(root, ".aidd", "manifest.json"), { recursive: true }); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.unreadable).toStrictEqual([join(root, ".aidd", "manifest.json")]); + }); +}); + +describe("what a tool's enabledPlugins says about the recorder", () => { + it("names Copilot's settings file alone when only it enables the recorder", async () => { + const root = project(); + writeJson(join(root, ".github", "copilot", "settings.json"), { + enabledPlugins: { "aidd-telemetry@aidd-framework": true }, + }); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.declaredAt).toStrictEqual([ + join(root, ".github", "copilot", "settings.json"), + ]); + expect(declaration.unreadable).toStrictEqual([]); + }); + + it("finds the recorder among other enabled plugins", async () => { + const root = project(); + writeJson(join(root, ".claude", "settings.json"), { + enabledPlugins: { "aidd-dev@aidd-framework": true, "aidd-telemetry@aidd-framework": true }, + }); + + expect((await adapter().readRecorderDeclaration(root)).declaredAt).toStrictEqual([ + join(root, ".claude", "settings.json"), + ]); + }); + + it("reads a key enabling some other plugin as not declaring the recorder", async () => { + const root = project(); + writeJson(join(root, ".claude", "settings.json"), { + enabledPlugins: { "aidd-dev@aidd-framework": true }, + }); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.declared).toBe(false); + expect(declaration.unreadable).toStrictEqual([]); + }); + + it.each([ + ["a JSON null", null], + ["no enabledPlugins key", {}], + ])( + "reads a settings file holding %s as not declaring, and not as unreadable", + async (_, settings) => { + const root = project(); + writeJson(join(root, ".claude", "settings.json"), settings); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.declaredAt).toStrictEqual([]); + expect(declaration.unreadable).toStrictEqual([]); + } + ); + + it("names a damaged settings file as the one unreadable location", async () => { + const root = project(); + write(join(root, ".github", "copilot", "settings.json"), "{ enabledPlugins: "); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.unreadable).toStrictEqual([ + join(root, ".github", "copilot", "settings.json"), + ]); + expect(declaration.declaredAt).toStrictEqual([]); + }); +}); + +describe("what a hooks block says about the recorder", () => { + it("names the user-scope Claude settings file when its hooks block invokes the entry point", async () => { + const root = project(); + const homeSettings = join(process.env.HOME ?? "", ".claude", "settings.json"); + writeJson(homeSettings, { + hooks: { + SessionStart: [ + { + hooks: [{ type: "command", command: "node .claude/hooks/aidd-telemetry/journal.cjs" }], + }, + ], + }, + }); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.declaredAt).toStrictEqual([homeSettings]); + expect(declaration.unreadable).toStrictEqual([]); + }); + + it("names Cursor's hooks file when it invokes the entry point from the recorder's own script dir", async () => { + const root = project(); + writeJson(join(root, ".cursor", "hooks.json"), { + version: 1, + hooks: { + sessionStart: [ + { command: "node ./other/journal.cjs" }, + { command: "node .cursor/hooks/aidd-telemetry/journal.cjs" }, + ], + }, + }); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.declaredAt).toStrictEqual([join(root, ".cursor", "hooks.json")]); + }); + + it("refuses the recorder's own script name under a directory that is not its hooks dir", async () => { + const root = project(); + writeJson(join(root, ".cursor", "hooks.json"), { + version: 1, + hooks: { sessionStart: [{ command: "node ./vendor/aidd-telemetry/journal.cjs" }] }, + }); + + expect((await adapter().readRecorderDeclaration(root)).declared).toBe(false); + }); + + it("refuses another script under the plugin token, however its hooks dir is named", async () => { + const root = project(); + writeJson(join(root, ".claude", "settings.local.json"), { + hooks: { + SessionStart: [ + { + hooks: [ + { + type: "command", + // biome-ignore lint/suspicious/noTemplateCurlyInString: Claude Code resolves this placeholder, the settings file carries it verbatim + command: "node ${CLAUDE_PLUGIN_ROOT}/hooks/other.cjs", + }, + ], + }, + ], + }, + }); + + expect((await adapter().readRecorderDeclaration(root)).declared).toBe(false); + }); + + it("names a damaged hooks file as the one unreadable location", async () => { + const root = project(); + write(join(root, ".cursor", "hooks.json"), "{ version: 1, "); + + const declaration = await adapter().readRecorderDeclaration(root); + + expect(declaration.unreadable).toStrictEqual([join(root, ".cursor", "hooks.json")]); + expect(declaration.declaredAt).toStrictEqual([]); + }); }); describe("whether anything is declared to do the recording", () => { @@ -196,6 +466,38 @@ describe("a payload that matched no known host", () => { expect(await adapter().readUnrecognisedPayload(root)).toBeNull(); }); + it("answers nothing for another record kind even when it carries a moment", async () => { + const root = project(); + write( + join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), + `${JSON.stringify({ type: "something-else", at: "2026-03-02T08:00:00Z" })}\n` + ); + + expect(await adapter().readUnrecognisedPayload(root)).toBeNull(); + }); + + it("answers nothing when the moment is not a string", async () => { + const root = project(); + write( + join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), + `${JSON.stringify({ type: "unrecognised_payload", at: 1772438400 })}\n` + ); + + expect(await adapter().readUnrecognisedPayload(root)).toBeNull(); + }); + + it("skips blank lines before the first record", async () => { + const root = project(); + write( + join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), + `\n \n${JSON.stringify({ type: "unrecognised_payload", at: "2026-03-02T08:00:00Z" })}\n` + ); + + expect(await adapter().readUnrecognisedPayload(root)).toStrictEqual({ + at: "2026-03-02T08:00:00Z", + }); + }); + // The hook writing this file anchors at the repository root, never at the directory a // session started from, so a reader must walk up rather than join onto `projectRoot`. it("finds the file from a subdirectory of the repository, not only from its root", async () => { diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts index bc1266786..90e8be685 100644 --- a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts @@ -1,4 +1,4 @@ -import { appendFile, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { appendFile, chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -114,6 +114,52 @@ describe("TelemetrySinkAdapter", () => { expect(records).toHaveLength(1); }); + it.skipIf(process.platform === "win32")( + "writes a day file readable by this person alone", + async () => { + const adapter = new TelemetrySinkAdapter(userConfigDir); + await adapter.ensureWritable(); + + const { filePath } = await adapter.appendRecord(RECORD, new Date("2026-08-17T10:00:00Z")); + + expect(((await stat(filePath)).mode & 0o777).toString(8)).toBe("600"); + } + ); + + it("lists day files only, leaving any other entry of the directory out", async () => { + const adapter = new TelemetrySinkAdapter(userConfigDir); + await adapter.ensureWritable(); + await adapter.appendRecord(RECORD, new Date("2026-08-17T10:00:00Z")); + await writeFile(join(adapter.rootDir, "notes.txt"), ""); + await writeFile(join(adapter.rootDir, "2026-08-16.jsonl.bak"), ""); + + expect(await adapter.listDayFiles()).toStrictEqual(["2026-08-17.jsonl"]); + }); + + it("lists nothing, rather than failing, before the directory exists", async () => { + const adapter = new TelemetrySinkAdapter(userConfigDir); + + expect(await adapter.listDayFiles()).toStrictEqual([]); + expect(await adapter.readRecordsForVendor("s-1")).toStrictEqual([]); + }); + + it("names the file and the directory it refused to delete outside of", async () => { + const adapter = new TelemetrySinkAdapter(userConfigDir); + + await expect(adapter.deleteDayFile(adapter.rootDir, "../VICTIM.txt")).rejects.toThrow( + `refusing to delete "../VICTIM.txt" — not a day file name inside ${adapter.rootDir}` + ); + }); + + it("deletes a day file that is already gone without complaint", async () => { + const adapter = new TelemetrySinkAdapter(userConfigDir); + await adapter.ensureWritable(); + + await expect( + adapter.deleteDayFile(adapter.rootDir, "2026-08-01.jsonl") + ).resolves.toBeUndefined(); + }); + // chmod blocks no write for root or behind Windows ACLs, where this would pass without // testing anything. it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( @@ -263,6 +309,62 @@ describe("TelemetrySinkAdapter.readRecordsInPeriod", () => { expect(backwards).toEqual(forwards); }); + it("collects every project, step and model any record names, whatever its period", async () => { + await adapter.appendRecord( + { + ...RECORD, + vendor_id: "full", + event_timestamp: "2026-08-17T10:00:00.000Z", + project_id: "p-1", + step: "aidd-dev:01-plan", + model: "claude-opus-5", + }, + STORED_ON + ); + await adapter.appendRecord( + { + ...RECORD, + vendor_id: "outside", + event_timestamp: "2026-07-01T10:00:00.000Z", + project_id: "p-2", + step: "aidd-dev:02-implement", + model: "gpt-5", + }, + STORED_ON + ); + await append("bare", "2026-08-17"); + + const read = await period("2026-08-17", "2026-08-17"); + + expect(read.records.map((record) => record.vendor_id)).toStrictEqual(["full", "bare"]); + expect(read.knownValues).toStrictEqual({ + projects: new Set(["p-1", "p-2"]), + steps: new Set(["aidd-dev:01-plan", "aidd-dev:02-implement"]), + models: new Set(["claude-opus-5", "gpt-5"]), + }); + }); + + it("tolerates a day file that cannot be read, counting nothing for it", async () => { + await append("whole", "2026-08-17"); + await mkdir(join(adapter.rootDir, "2026-08-22.jsonl")); + + const read = await period("2026-08-17", "2026-08-17"); + + expect(read.records.map((record) => record.vendor_id)).toStrictEqual(["whole"]); + expect(read.undated).toStrictEqual([]); + expect(read.skippedLines).toBe(0); + }); + + it("counts a line of nothing but whitespace as blank, not as skipped", async () => { + await append("whole", "2026-08-17"); + await appendFile(join(adapter.rootDir, "2026-08-21.jsonl"), " \n\t\n"); + + const read = await period("2026-08-17", "2026-08-17"); + + expect(read.records.map((record) => record.vendor_id)).toStrictEqual(["whole"]); + expect(read.skippedLines).toBe(0); + }); + it("answers an empty period with no records and nothing skipped, never an error", async () => { expect(await period("2026-08-17", "2026-08-18")).toEqual({ records: [], diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts index b457fd632..cb25500d0 100644 --- a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts @@ -1,4 +1,12 @@ -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -68,6 +76,76 @@ describe("where the figures land by default", () => { expect(withPlatform("win32", defaultConfigDir)).toBe(join(home, ".config", "aidd")); }); + it("a Windows machine that already journalled under .config keeps landing there", () => { + const home = freshHome(); + process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); + mkdirSync(join(home, ".config", "aidd", "telemetry"), { recursive: true }); + writeFileSync(join(home, ".config", "aidd", "telemetry", "notes.txt"), ""); + writeFileSync(join(home, ".config", "aidd", "telemetry", "2026-08-17.jsonl"), ""); + + expect(withPlatform("win32", defaultConfigDir)).toBe(join(home, ".config", "aidd")); + }); + + it("a Windows machine whose .config holds no day file is a fresh one", () => { + const home = freshHome(); + process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); + mkdirSync(join(home, ".config", "aidd", "telemetry"), { recursive: true }); + writeFileSync(join(home, ".config", "aidd", "telemetry", "notes.jsonl.txt"), ""); + + expect(withPlatform("win32", defaultConfigDir)).toBe(join(process.env.APPDATA, "aidd")); + }); + + it("a POSIX machine ignores APPDATA even when it is set", () => { + const home = freshHome(); + process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); + + expect(withPlatform("linux", defaultConfigDir)).toBe(join(home, ".config", "aidd")); + }); +}); + +describe("which variable located the figures", () => { + const previousTelemetryDir = process.env.AIDD_TELEMETRY_DIR; + const previousUserConfigDir = process.env.AIDD_USER_CONFIG_DIR; + + afterEach(() => { + for (const [key, value] of [ + ["AIDD_TELEMETRY_DIR", previousTelemetryDir], + ["AIDD_USER_CONFIG_DIR", previousUserConfigDir], + ] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it("is the figures' own name when AIDD_TELEMETRY_DIR is set, whatever else is", () => { + process.env.AIDD_TELEMETRY_DIR = join(tmpdir(), "figures"); + process.env.AIDD_USER_CONFIG_DIR = join(tmpdir(), "older"); + + expect(new TelemetrySinkAdapter().locatedBy).toBe("telemetry-dir"); + }); + + it("is the older config variable when only that one is set", () => { + delete process.env.AIDD_TELEMETRY_DIR; + process.env.AIDD_USER_CONFIG_DIR = join(tmpdir(), "older"); + + expect(new TelemetrySinkAdapter().locatedBy).toBe("user-config-dir"); + }); + + it("is the older config variable when the constructor names the directory", () => { + delete process.env.AIDD_TELEMETRY_DIR; + delete process.env.AIDD_USER_CONFIG_DIR; + + expect(new TelemetrySinkAdapter(join(tmpdir(), "older")).locatedBy).toBe("user-config-dir"); + }); + + it("is the default when nothing names a location", () => { + freshHome(); + delete process.env.AIDD_TELEMETRY_DIR; + delete process.env.AIDD_USER_CONFIG_DIR; + + expect(new TelemetrySinkAdapter().locatedBy).toBe("default"); + }); + it("the plugin README states the exact default the code writes", () => { // Forward slashes rather than `join`, which yields `~\\.config\\aidd` on Windows: the // prose reads the same on every platform, only the code follows the host's separator. diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-windows-acl.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-windows-acl.integration.test.ts new file mode 100644 index 000000000..f5e53fe8a --- /dev/null +++ b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-windows-acl.integration.test.ts @@ -0,0 +1,134 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import { TelemetrySinkAdapter } from "../../../../src/contexts/telemetry/infrastructure/telemetry-sink-adapter.js"; + +const RECORD: TelemetrySinkRecord = { + sink_schema_version: 2, + kind: "request", + provenance: "export", + tool: "claude", + vendor_id: "s-1", + vendor_field: "session.id", + cost_usd: 1, + step_attribution: "unattributed", +}; + +const ENV_KEYS = [ + "PATH", + "HOME", + "APPDATA", + "USERDOMAIN", + "USERNAME", + "ICACLS_LOG", + "AIDD_TELEMETRY_DIR", + "AIDD_USER_CONFIG_DIR", +] as const; + +const DAY = new Date("2026-08-17T10:00:00Z"); + +describe.skipIf(process.platform === "win32")( + "a default location on Windows is restricted to this account through icacls", + () => { + const previousEnv = new Map(); + const previousPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + let sandbox: string; + let logPath: string; + + beforeEach(() => { + for (const key of ENV_KEYS) previousEnv.set(key, process.env[key]); + sandbox = mkdtempSync(join(tmpdir(), "aidd-sink-acl-")); + const bin = join(sandbox, "bin"); + mkdirSync(bin); + logPath = join(sandbox, "icacls.log"); + writeFileSync(join(bin, "icacls"), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$ICACLS_LOG"\n'); + chmodSync(join(bin, "icacls"), 0o755); + process.env.PATH = `${bin}:${process.env.PATH ?? ""}`; + process.env.ICACLS_LOG = logPath; + process.env.HOME = join(sandbox, "home"); + process.env.APPDATA = join(sandbox, "appdata"); + process.env.USERDOMAIN = "ACME"; + process.env.USERNAME = "ada"; + delete process.env.AIDD_TELEMETRY_DIR; + delete process.env.AIDD_USER_CONFIG_DIR; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + }); + + afterEach(() => { + if (previousPlatform) Object.defineProperty(process, "platform", previousPlatform); + for (const key of ENV_KEYS) { + const value = previousEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(sandbox, { recursive: true, force: true }); + }); + + function calls(): string[] { + try { + return readFileSync(logPath, "utf8").trim().split("\n"); + } catch { + return []; + } + } + + it("grants the directory to DOMAIN\\user alone, recursively, on ensureWritable", async () => { + const sink = new TelemetrySinkAdapter(); + + await sink.ensureWritable(); + + expect(sink.rootDir).toBe(join(sandbox, "appdata", "aidd", "telemetry")); + expect(calls()).toStrictEqual([ + `${sink.rootDir} /inheritance:r /grant:r ACME\\ada:(OI)(CI)F /T /C /Q`, + ]); + }); + + it("grants a day file to this user alone, once, on the append that creates it", async () => { + const sink = new TelemetrySinkAdapter(); + + const { filePath } = await sink.appendRecord(RECORD, DAY); + await sink.appendRecord(RECORD, DAY); + + const dir = `${sink.rootDir} /inheritance:r /grant:r ACME\\ada:(OI)(CI)F /T /C /Q`; + expect(calls()).toStrictEqual([ + dir, + `${filePath} /inheritance:r /grant:r ACME\\ada:F /C /Q`, + dir, + ]); + }); + + it("names the user without a domain when USERDOMAIN is unset", async () => { + delete process.env.USERDOMAIN; + const sink = new TelemetrySinkAdapter(); + + await sink.ensureWritable(); + + expect(calls()).toStrictEqual([ + `${sink.rootDir} /inheritance:r /grant:r ada:(OI)(CI)F /T /C /Q`, + ]); + }); + + it("runs icacls for nobody when no account name resolves", async () => { + delete process.env.USERDOMAIN; + process.env.USERNAME = ""; + const sink = new TelemetrySinkAdapter(); + + await sink.ensureWritable(); + await sink.appendRecord(RECORD, DAY); + + expect(calls()).toStrictEqual([]); + }); + + it("leaves a location the person named themselves untouched, directory and day file alike", async () => { + process.env.AIDD_TELEMETRY_DIR = join(sandbox, "shared"); + const sink = new TelemetrySinkAdapter(); + + await sink.ensureWritable(); + await sink.appendRecord(RECORD, DAY); + + expect(calls()).toStrictEqual([]); + }); + } +); diff --git a/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts index d9a3e20b1..dcd0454df 100644 --- a/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts @@ -1,5 +1,8 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { createClaudeCodeTranscriptAccumulator } from "../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; import { createCodexRolloutAccumulator } from "../../../../src/contexts/telemetry/domain/formats/codex-rollout.js"; import { TranscriptCostReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.js"; @@ -37,6 +40,33 @@ describe("TranscriptCostReaderAdapter — Claude Code", () => { expect(await adapter.read("no-such-session")).toEqual({ records: [], sessionFound: false }); }); + const created: string[] = []; + afterEach(() => { + for (const dir of created) rmSync(dir, { recursive: true, force: true }); + created.length = 0; + }); + + it("walks only directories and regular files, so a symlink named like a transcript is no session", async () => { + const home = mkdtempSync(join(tmpdir(), "aidd-transcript-walk-")); + created.push(home); + const projectDir = join(home, ".claude", "projects", "fake-project"); + mkdirSync(projectDir, { recursive: true }); + symlinkSync( + join(HOME_DIR, ".claude", "projects", "fake-project", `${CLAUDE_SID}.jsonl`), + join(projectDir, `${CLAUDE_SID}.jsonl`) + ); + const linkedAdapter = new TranscriptCostReaderAdapter( + home, + CLAUDE_CODE_TRANSCRIPT_LOCATION, + createClaudeCodeTranscriptAccumulator + ); + + await expect(linkedAdapter.read(CLAUDE_SID)).resolves.toEqual({ + records: [], + sessionFound: false, + }); + }); + it("answers with nothing, not an error, when the declared root does not exist", async () => { const adapterWithNoHome = new TranscriptCostReaderAdapter( `${HOME_DIR}/does-not-exist`, From 7373e484f38515e51ae6d6efad986df33d36bec2 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 10 Sep 2026 09:50:49 +0200 Subject: [PATCH 2/3] test(cli): keep the identity tests off the real Windows profile On Windows the identity file lives under %APPDATA%, never HOME, and these tests moved HOME alone: the runner's CI wrote and read C:\Users\runneradmin\AppData\Roaming\aidd\identity.json, so "no identity exists" found one a previous test had left there. Every sandbox now moves both together. The two tests asserting a POSIX ENOTDIR skip on Windows, which answers ENOENT for a path running through a file. The backlog "unreadable" case read through a file used as a folder, which is ENOTDIR on POSIX and ENOENT on Windows. A directory where the link file belongs is EISDIR on both; still red when ENOENT stops being told apart. Refs #799 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- ...erson-identity-adapter.integration.test.ts | 86 ++++++++++++------- .../task-backlog-adapter.integration.test.ts | 9 +- 2 files changed, 60 insertions(+), 35 deletions(-) diff --git a/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts index d4c18aa84..824c83755 100644 --- a/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts @@ -5,19 +5,29 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { PersonIdentityAdapter } from "../../../../src/contexts/telemetry/infrastructure/person-identity-adapter.js"; import { IdentityWriteError, UnreadableIdentityFileError } from "../../../../src/kernel/errors.js"; +/** Windows reads `%APPDATA%`, never `HOME`: a sandbox that moved `HOME` alone wrote the real profile. */ +function relocateProfile(home: string): void { + process.env.HOME = home; + process.env.APPDATA = join(home, ".config"); +} + /** On real disk: every write here goes through the file and is read back through it, since * what this adapter stores is what decides whose records are whose. */ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is handed", () => { let previousHome: string | undefined; + let previousAppData: string | undefined; const homes: string[] = []; beforeEach(() => { previousHome = process.env.HOME; + previousAppData = process.env.APPDATA; }); afterEach(async () => { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; + if (previousAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = previousAppData; for (const home of homes.splice(0)) await rm(home, { recursive: true, force: true }); }); @@ -29,7 +39,7 @@ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is it("removes the identity file it was constructed against", async () => { const home = await freshHome(); - process.env.HOME = home; + relocateProfile(home); const adapter = new PersonIdentityAdapter(); await adapter.mint(); @@ -41,7 +51,7 @@ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is it("is a no-op, not a failure, when the path is already gone", async () => { const home = await freshHome(); - process.env.HOME = home; + relocateProfile(home); const adapter = new PersonIdentityAdapter(); await expect(adapter.forget(adapter.filePath)).resolves.toBe(false); @@ -51,7 +61,7 @@ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is // relocation between the preview and the removal cannot redirect it. it("acts on the path it is handed, immune to HOME being relocated afterwards", async () => { const realHome = await freshHome(); - process.env.HOME = realHome; + relocateProfile(realHome); const adapter = new PersonIdentityAdapter(); await adapter.mint(); const shownPath = adapter.filePath; // what a preview would have shown @@ -61,7 +71,7 @@ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is const victimPath = join(elsewhereHome, ".config", "aidd", "identity.json"); await writeFile(victimPath, '{"person_id":"victim"}\n'); - process.env.HOME = elsewhereHome; // relocated AFTER the path was shown + relocateProfile(elsewhereHome); // relocated AFTER the path was shown await adapter.forget(shownPath); @@ -72,15 +82,19 @@ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is describe("PersonIdentityAdapter — what it writes, and what it reads back", () => { let previousHome: string | undefined; + let previousAppData: string | undefined; const homes: string[] = []; beforeEach(() => { previousHome = process.env.HOME; + previousAppData = process.env.APPDATA; }); afterEach(async () => { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; + if (previousAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = previousAppData; for (const home of homes.splice(0)) await rm(home, { recursive: true, force: true }); }); @@ -89,7 +103,7 @@ describe("PersonIdentityAdapter — what it writes, and what it reads back", () async function adapterInFreshHome(): Promise { const home = await mkdtemp(join(tmpdir(), "aidd-identity-rw-")); homes.push(home); - process.env.HOME = home; + relocateProfile(home); await mkdir(join(home, ".config", "aidd"), { recursive: true }); return new PersonIdentityAdapter(); } @@ -281,40 +295,48 @@ describe("PersonIdentityAdapter — what it writes, and what it reads back", () ); }); - it("reports a write that could not go out, naming the file", async () => { - const home = await mkdtemp(join(tmpdir(), "aidd-identity-rw-")); - homes.push(home); - process.env.HOME = home; - await writeFile(join(home, ".config"), ""); - const adapter = new PersonIdentityAdapter(); - - const minted = adapter.mint(); - - await expect(minted).rejects.toBeInstanceOf(IdentityWriteError); - await expect(minted).rejects.toThrow( - `Could not write the identity file at ${adapter.filePath} (ENOTDIR` - ); - }); + // POSIX errno: Windows answers ENOENT where a path runs through a file. + it.skipIf(process.platform === "win32")( + "reports a write that could not go out, naming the file", + async () => { + const home = await mkdtemp(join(tmpdir(), "aidd-identity-rw-")); + homes.push(home); + relocateProfile(home); + await writeFile(join(home, ".config"), ""); + const adapter = new PersonIdentityAdapter(); + + const minted = adapter.mint(); + + await expect(minted).rejects.toBeInstanceOf(IdentityWriteError); + await expect(minted).rejects.toThrow( + `Could not write the identity file at ${adapter.filePath} (ENOTDIR` + ); + } + ); }); describe("PersonIdentityAdapter.forget — what it removes and what it reports", () => { let previousHome: string | undefined; + let previousAppData: string | undefined; const homes: string[] = []; beforeEach(() => { previousHome = process.env.HOME; + previousAppData = process.env.APPDATA; }); afterEach(async () => { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; + if (previousAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = previousAppData; for (const home of homes.splice(0)) await rm(home, { recursive: true, force: true }); }); async function adapterInFreshHome(): Promise { const home = await mkdtemp(join(tmpdir(), "aidd-identity-forget-")); homes.push(home); - process.env.HOME = home; + relocateProfile(home); await mkdir(join(home, ".config", "aidd"), { recursive: true }); return new PersonIdentityAdapter(); } @@ -328,16 +350,20 @@ describe("PersonIdentityAdapter.forget — what it removes and what it reports", await expect(readFile(adapter.filePath, "utf8")).rejects.toThrow(); }); - it("reports a removal that failed for a reason other than being gone, as a removal", async () => { - const adapter = await adapterInFreshHome(); - await adapter.mint(); - const unreachable = join(adapter.filePath, "child"); + // POSIX errno: Windows answers ENOENT where a path runs through a file. + it.skipIf(process.platform === "win32")( + "reports a removal that failed for a reason other than being gone, as a removal", + async () => { + const adapter = await adapterInFreshHome(); + await adapter.mint(); + const unreachable = join(adapter.filePath, "child"); - const forgotten = adapter.forget(unreachable); + const forgotten = adapter.forget(unreachable); - await expect(forgotten).rejects.toBeInstanceOf(IdentityWriteError); - await expect(forgotten).rejects.toThrow( - `Could not remove the identity file at ${unreachable} (ENOTDIR` - ); - }); + await expect(forgotten).rejects.toBeInstanceOf(IdentityWriteError); + await expect(forgotten).rejects.toThrow( + `Could not remove the identity file at ${unreachable} (ENOTDIR` + ); + } + ); }); diff --git a/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts index dc7202ef0..e61f7f3d8 100644 --- a/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts @@ -207,12 +207,11 @@ describe("TaskBacklogAdapter — reads a declaration without ever writing one", it("answers unreadable when the file is there but cannot be read, distinct from none", async () => { const root = await freshProject(); - await mkdir(join(root, "aidd_docs", "tasks"), { recursive: true }); - await writeFile(join(root, "aidd_docs", "tasks", "not-a-folder"), ""); + await mkdir(join(root, "aidd_docs", "tasks", "t", "backlog-link.json"), { recursive: true }); - await expect( - new TaskBacklogAdapter(root).read("aidd_docs/tasks/not-a-folder/") - ).resolves.toStrictEqual({ kind: "unreadable" }); + await expect(new TaskBacklogAdapter(root).read("aidd_docs/tasks/t/")).resolves.toStrictEqual({ + kind: "unreadable", + }); }); it("answers unreadable for a declaration missing its provenance", async () => { From 92c3d43d2236b45c5795fb38b73eb8aca334ad3b Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 10 Sep 2026 12:33:42 +0200 Subject: [PATCH 3/3] test(cli): name the POSIX-errno skip once in the identity tests The same Windows explanation sat above two skips. Stacked with the runtime tests, the repetition pushed tests/ past its comment baseline (2989 against 2987); one named predicate says it once. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../person-identity-adapter.integration.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts index 824c83755..6c88de722 100644 --- a/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts @@ -6,6 +6,9 @@ import { PersonIdentityAdapter } from "../../../../src/contexts/telemetry/infras import { IdentityWriteError, UnreadableIdentityFileError } from "../../../../src/kernel/errors.js"; /** Windows reads `%APPDATA%`, never `HOME`: a sandbox that moved `HOME` alone wrote the real profile. */ +/** POSIX errno: Windows answers ENOENT where a path runs through a file. */ +const POSIX_ERRNO_ONLY = process.platform === "win32"; + function relocateProfile(home: string): void { process.env.HOME = home; process.env.APPDATA = join(home, ".config"); @@ -294,9 +297,7 @@ describe("PersonIdentityAdapter — what it writes, and what it reads back", () `Could not read the identity file at ${adapter.filePath} (EISDIR` ); }); - - // POSIX errno: Windows answers ENOENT where a path runs through a file. - it.skipIf(process.platform === "win32")( + it.skipIf(POSIX_ERRNO_ONLY)( "reports a write that could not go out, naming the file", async () => { const home = await mkdtemp(join(tmpdir(), "aidd-identity-rw-")); @@ -349,9 +350,7 @@ describe("PersonIdentityAdapter.forget — what it removes and what it reports", expect(await adapter.forget(adapter.filePath)).toBe(true); await expect(readFile(adapter.filePath, "utf8")).rejects.toThrow(); }); - - // POSIX errno: Windows answers ENOENT where a path runs through a file. - it.skipIf(process.platform === "win32")( + it.skipIf(POSIX_ERRNO_ONLY)( "reports a removal that failed for a reason other than being gone, as a removal", async () => { const adapter = await adapterInFreshHome();