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..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 @@ -1,22 +1,36 @@ -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"; + +/** 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"); +} /** 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 }); }); @@ -28,7 +42,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(); @@ -40,7 +54,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); @@ -50,7 +64,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 @@ -60,7 +74,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); @@ -71,15 +85,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 }); }); @@ -88,7 +106,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(); } @@ -182,4 +200,169 @@ 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.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-")); + 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); + relocateProfile(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.skipIf(POSIX_ERRNO_ONLY)( + "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..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 @@ -149,6 +149,71 @@ 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", "t", "backlog-link.json"), { recursive: true }); + + await expect(new TaskBacklogAdapter(root).read("aidd_docs/tasks/t/")).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`,