From 7f4244cc8a228eba56b962e365e190f1a079d245 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 21 Aug 2026 07:06:31 -0700 Subject: [PATCH 1/3] fix(sdk): align scan session attribution rules Share scan-directory matching and timestamp parsing between live cost tracking and saved logs. Preserve millisecond precision for ownership checks while keeping replay boundaries in seconds. Require directory-associated sessions to be independent and follow known parent links for child sessions. Cover subsecond and invalid timestamps, unrelated and delayed parents, Windows drive and case handling, and packaged helper availability. --- sdk/typescript/scripts/check-package.mjs | 1 + sdk/typescript/src/cost.ts | 29 +-- sdk/typescript/src/scan-logs.ts | 24 +-- sdk/typescript/src/scan-sessions.ts | 25 +++ sdk/typescript/tests-ts/cost.test.ts | 216 ++++++++++++++++------ sdk/typescript/tests-ts/scan-logs.test.ts | 4 +- 6 files changed, 201 insertions(+), 98 deletions(-) create mode 100644 sdk/typescript/src/scan-sessions.ts diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index f8f794683..72603d9fc 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -187,6 +187,7 @@ const distFiles = new Set( "scan-dashboard", "scan-history-renderer", "scan-logs", + "scan-sessions", "targets", "trusted-executable", "version", diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 3bab2937f..761499eb0 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -1,5 +1,5 @@ import { open, readdir } from "node:fs/promises"; -import { isAbsolute, join, relative, sep } from "node:path"; +import { join } from "node:path"; import { estimateScanCost, tokenUsage, @@ -10,6 +10,7 @@ import { scanActivityFromSessionEvent, type ScanActivity, } from "./scan-activity.js"; +import { isScanArtifactDirectory, sessionStartedAt } from "./scan-sessions.js"; import { scanProgressUpdatesFromEvent, type ScanProgress, @@ -210,6 +211,7 @@ export class ScanCostTracker { for (const session of this.#sessions.values()) { if ( session.threadId === null || + session.parentThreadId !== null || session.workingDirectory === null || scanStartedAt === null || session.startedAt === null || @@ -217,22 +219,11 @@ export class ScanCostTracker { ) { continue; } - const artifactsDirectory = join( - this.#options.scanDirectory, - "artifacts", - ); - const workers = join(artifactsDirectory, "deep_discovery", "workers"); - const workerDirectory = relative(workers, session.workingDirectory); - const components = workerDirectory.split(sep); if ( - relative(artifactsDirectory, session.workingDirectory) === "" || - (!isAbsolute(workerDirectory) && - components.length === 2 && - components[0] !== ".." && - relative( - join(workers, components[0]!, "output"), - session.workingDirectory, - ) === "") + isScanArtifactDirectory( + this.#options.scanDirectory, + session.workingDirectory, + ) ) { included.add(session.threadId); } @@ -470,9 +461,7 @@ function readSessionEvent( if (typeof payload["cwd"] === "string") { session.workingDirectory = payload["cwd"]; } - if (typeof payload["timestamp"] === "string") { - session.startedAt = Math.floor(Date.parse(payload["timestamp"]) / 1_000); - } + session.startedAt = sessionStartedAt(payload["timestamp"]); const source = payload["source"]; const subagent = isRecord(source) ? source["subagent"] : undefined; const spawn = isRecord(subagent) ? subagent["thread_spawn"] : undefined; @@ -493,7 +482,7 @@ function readSessionEvent( payload["type"] === "task_started" && typeof payload["started_at"] === "number" && session.startedAt !== null && - payload["started_at"] >= session.startedAt + payload["started_at"] >= Math.floor(session.startedAt / 1_000) ) { session.replaying = false; session.events?.push(event); diff --git a/sdk/typescript/src/scan-logs.ts b/sdk/typescript/src/scan-logs.ts index 36ca1164f..0b9b7454b 100644 --- a/sdk/typescript/src/scan-logs.ts +++ b/sdk/typescript/src/scan-logs.ts @@ -1,8 +1,9 @@ import { createReadStream } from "node:fs"; -import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; +import { basename, dirname, join, relative } from "node:path"; import { createInterface } from "node:readline"; import { sessionFiles } from "./cost.js"; import { CodexSecurityError } from "./errors.js"; +import { isScanArtifactDirectory, sessionStartedAt } from "./scan-sessions.js"; interface ScanLogOptions { scanId: string; @@ -36,14 +37,10 @@ export async function readScanLogs(options: ScanLogOptions) { const parent = metadata["parent_thread_id"] ?? (isRecord(spawn) ? spawn["parent_thread_id"] : undefined); - const startedAt = - typeof metadata["timestamp"] === "string" - ? Date.parse(metadata["timestamp"]) - : Number.NaN; logs.set(threadId, { threadId, parentThreadId: typeof parent === "string" ? parent : null, - startedAt: Number.isFinite(startedAt) ? startedAt : null, + startedAt: sessionStartedAt(metadata["timestamp"]), workingDirectory: typeof metadata["cwd"] === "string" ? metadata["cwd"] : null, path, @@ -149,20 +146,7 @@ function belongsToScan( } for (const directoryRoot of roots) { - const artifacts = join(directoryRoot, "artifacts"); - if (relative(artifacts, session.workingDirectory) === "") return true; - const workers = join(artifacts, "deep_discovery", "workers"); - const directory = relative(workers, session.workingDirectory); - const components = directory.split(sep); - if ( - !isAbsolute(directory) && - components.length === 2 && - components[0] !== ".." && - relative( - join(workers, components[0]!, "output"), - session.workingDirectory, - ) === "" - ) { + if (isScanArtifactDirectory(directoryRoot, session.workingDirectory)) { return true; } } diff --git a/sdk/typescript/src/scan-sessions.ts b/sdk/typescript/src/scan-sessions.ts new file mode 100644 index 000000000..2dbf98b9e --- /dev/null +++ b/sdk/typescript/src/scan-sessions.ts @@ -0,0 +1,25 @@ +import { isAbsolute, join, relative, sep } from "node:path"; + +export function sessionStartedAt(timestamp: unknown): number | null { + const startedAt = + typeof timestamp === "string" ? Date.parse(timestamp) : Number.NaN; + return Number.isFinite(startedAt) ? startedAt : null; +} + +export function isScanArtifactDirectory( + scanDirectory: string, + workingDirectory: string, +): boolean { + const artifacts = join(scanDirectory, "artifacts"); + if (relative(artifacts, workingDirectory) === "") return true; + + const workers = join(artifacts, "deep_discovery", "workers"); + const directory = relative(workers, workingDirectory); + const components = directory.split(sep); + return ( + !isAbsolute(directory) && + components.length === 2 && + components[0] !== ".." && + relative(join(workers, components[0]!, "output"), workingDirectory) === "" + ); +} diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index e29731c67..7111b045a 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -8,7 +8,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, parse, sep } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { estimateScanCost, @@ -16,6 +16,7 @@ import { type ScanSessionEvent, } from "../src/cost.js"; import type { ScanActivity } from "../src/scan-activity.js"; +import { readScanLogs } from "../src/scan-logs.js"; import type { ScanProgress } from "../src/worker-progress.js"; const temporaryDirectories: string[] = []; @@ -547,7 +548,7 @@ describe("live scan cost tracking", () => { const events: ScanSessionEvent[] = []; const tracker = new ScanCostTracker({ codexHome: home, - scanDirectory: missing === "main" ? scanDirectory : undefined, + scanDirectory, model: "gpt-5.6-sol", onSessionEvent: (event) => events.push(event), }); @@ -588,35 +589,39 @@ describe("live scan cost tracking", () => { test("counts independent Deep workers inside the scan directory only", async () => { const home = await codexHome(); const scanDirectory = join(home, "scans", "current"); + const artifacts = join(scanDirectory, "artifacts"); + const workerDirectory = join( + artifacts, + "deep_discovery", + "workers", + "worker", + "output", + ); await writeSession( home, "scan-thread", { input_tokens: 1_000, output_tokens: 10 }, undefined, scanDirectory, - "2026-07-26T12:00:00Z", + "2026-07-26T12:00:00.900Z", ); await writeSession( home, "deep-worker", { input_tokens: 250, output_tokens: 2 }, undefined, - join( - scanDirectory, - "artifacts", - "deep_discovery", - "workers", - "worker", - "output", - ), - "2026-07-26T12:01:00Z", + process.platform === "win32" + ? workerDirectory.toUpperCase() + : workerDirectory, + "2026-07-26T12:00:00.900Z", ); await writeSession( home, "deep-reducer", { input_tokens: 125, output_tokens: 1 }, undefined, - join(scanDirectory, "artifacts"), + (process.platform === "win32" ? artifacts.toUpperCase() : artifacts) + + sep, "2026-07-26T12:02:00Z", ); await writeSession( @@ -692,54 +697,153 @@ describe("live scan cost tracking", () => { ).toEqual([1, 2, 3]); }); - test("excludes sessions beside the deep worker output directories", async () => { - const home = await codexHome(); - const scanDirectory = join(home, "scans", "current"); - await writeSession( - home, - "scan-thread", - { input_tokens: 1_000, output_tokens: 10 }, + test.each([ + [ + "sessions beside the deep worker output directories", + (scan: string) => join(scan, "artifacts", "deep_discovery", "output"), + "2026-07-26T12:02:00Z", undefined, - scanDirectory, - "2026-07-26T12:00:00Z", - ); - await writeSession( - home, - "deep-worker", - { input_tokens: 250, output_tokens: 2 }, + ], + [ + "sessions on another Windows drive", + (scan: string) => + parse(scan).root.toLowerCase().startsWith("c:") + ? "D:\\output" + : "C:\\output", + "2026-07-26T12:02:00Z", undefined, - join( - scanDirectory, - "artifacts", - "deep_discovery", - "workers", - "worker", - "output", - ), - "2026-07-26T12:01:00Z", - ); - // A session running in a directory that sits beside workers/ under - // deep_discovery is not a worker output directory and must not be counted. - await writeSession( - home, - "bystander", - { input_tokens: 1_000_000, output_tokens: 1_000_000 }, + ], + [ + "sessions earlier in the same second", + (scan: string) => join(scan, "artifacts"), + "2026-07-26T12:00:00.100Z", undefined, - join(scanDirectory, "artifacts", "deep_discovery", "output"), + ], + [ + "sessions with an invalid timestamp", + (scan: string) => join(scan, "artifacts"), + "not-a-timestamp", + undefined, + ], + [ + "sessions with an unrelated parent", + (scan: string) => join(scan, "artifacts"), "2026-07-26T12:02:00Z", - ); - const tracker = new ScanCostTracker({ - codexHome: home, - model: "gpt-5.6-sol", - scanDirectory, - }); - tracker.start("scan-thread"); + "unrelated-parent", + ], + ] as const)( + "excludes %s from scan cost and logs", + async (_name, workingDirectory, timestamp, parentThreadId) => { + const home = await codexHome(); + const scanDirectory = join(home, "scans", "current"); + await writeSession( + home, + "scan-thread", + { input_tokens: 1_000, output_tokens: 10 }, + undefined, + scanDirectory, + "2026-07-26T12:00:00.900Z", + ); + await writeSession( + home, + "deep-worker", + { input_tokens: 250, output_tokens: 2 }, + undefined, + join( + scanDirectory, + "artifacts", + "deep_discovery", + "workers", + "worker", + "output", + ), + "2026-07-26T12:00:00.950Z", + ); + await writeSession( + home, + "bystander", + { input_tokens: 1_000_000, output_tokens: 1_000_000 }, + parentThreadId, + workingDirectory(scanDirectory), + timestamp, + ); + await writeSession( + home, + "bystander-child", + { input_tokens: 1_000_000, output_tokens: 1_000_000 }, + "bystander", + ); + const events: ScanSessionEvent[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + scanDirectory, + maxCostUsd: 0.01, + onSessionEvent: (event) => events.push(event), + }); + tracker.start("scan-thread"); - expect((await tracker.stop()).usage).toMatchObject({ - input_tokens: 1_250, - output_tokens: 12, - }); - }); + const snapshot = await tracker.stop(); + expect(snapshot.usage).toMatchObject({ + input_tokens: 1_250, + output_tokens: 12, + }); + expect(snapshot.cost?.estimatedUsd).toBe(0.00661); + const included = [ + ...new Set(events.map(({ threadId }) => threadId)), + ].sort(); + expect(included).toEqual(["deep-worker", "scan-thread"]); + const logs = await readScanLogs({ + scanId: "scan-example", + threadId: "scan-thread", + codexHome: home, + scanDirectory, + }); + expect(logs.sessions.map(({ threadId }) => threadId).sort()).toEqual( + included, + ); + }, + ); + + test.each([undefined, "not-a-timestamp"])( + "does not infer independent workers when the scan timestamp is %s", + async (timestamp) => { + const home = await codexHome(); + const scanDirectory = join(home, "scan"); + await writeSession( + home, + "scan-thread", + { input_tokens: 1_000, output_tokens: 10 }, + undefined, + scanDirectory, + timestamp, + ); + await writeSession( + home, + "independent-worker", + { input_tokens: 1_000_000, output_tokens: 1_000_000 }, + undefined, + join(scanDirectory, "artifacts"), + "2026-07-26T12:01:00Z", + ); + await writeSession( + home, + "child-worker", + { input_tokens: 250, output_tokens: 2 }, + "scan-thread", + ); + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + scanDirectory, + }); + tracker.start("scan-thread"); + expect((await tracker.stop()).usage).toMatchObject({ + input_tokens: 1_250, + output_tokens: 12, + }); + }, + ); test("ignores replayed parent history in forked worker sessions", async () => { const home = await codexHome(); diff --git a/sdk/typescript/tests-ts/scan-logs.test.ts b/sdk/typescript/tests-ts/scan-logs.test.ts index f29bb87ad..be57c441e 100644 --- a/sdk/typescript/tests-ts/scan-logs.test.ts +++ b/sdk/typescript/tests-ts/scan-logs.test.ts @@ -255,12 +255,12 @@ describe("saved scan logs", () => { [ "sibling-directory", join(artifacts, "deep_discovery", "output"), - "2026-08-11T12:03:00.000Z", + "2026-08-11T12:01:00.000Z", ], [ "nested-scan", join(scanDirectory, "nested", "artifacts"), - "2026-08-11T12:03:00.000Z", + "2026-08-11T12:01:00.000Z", ], ] as const) { await writeSession( From 1cd043da8c94d25bd56e48a02278e5386c881a30 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 21 Aug 2026 07:24:57 -0700 Subject: [PATCH 2/3] fix(sdk): recognize forked session parentage Share parent extraction between live costs and saved logs, including forked_from_id and the bundled workbench's parent-field precedence. Cover legitimate descendants, unrelated forks, and inherited usage replay for all three supported parent metadata formats. --- sdk/typescript/src/cost.ts | 14 +- sdk/typescript/src/scan-logs.ts | 14 +- sdk/typescript/src/scan-sessions.ts | 20 + sdk/typescript/tests-ts/cost.test.ts | 589 ++++++++++-------- sdk/typescript/tests-ts/scan-sessions.test.ts | 43 ++ 5 files changed, 390 insertions(+), 290 deletions(-) create mode 100644 sdk/typescript/tests-ts/scan-sessions.test.ts diff --git a/sdk/typescript/src/cost.ts b/sdk/typescript/src/cost.ts index 761499eb0..a8cf6c72e 100644 --- a/sdk/typescript/src/cost.ts +++ b/sdk/typescript/src/cost.ts @@ -10,7 +10,11 @@ import { scanActivityFromSessionEvent, type ScanActivity, } from "./scan-activity.js"; -import { isScanArtifactDirectory, sessionStartedAt } from "./scan-sessions.js"; +import { + isScanArtifactDirectory, + sessionParentThreadId, + sessionStartedAt, +} from "./scan-sessions.js"; import { scanProgressUpdatesFromEvent, type ScanProgress, @@ -462,13 +466,7 @@ function readSessionEvent( session.workingDirectory = payload["cwd"]; } session.startedAt = sessionStartedAt(payload["timestamp"]); - const source = payload["source"]; - const subagent = isRecord(source) ? source["subagent"] : undefined; - const spawn = isRecord(subagent) ? subagent["thread_spawn"] : undefined; - const parent = - payload["parent_thread_id"] ?? - (isRecord(spawn) ? spawn["parent_thread_id"] : undefined); - if (typeof parent === "string") session.parentThreadId = parent; + session.parentThreadId = sessionParentThreadId(payload); session.events?.push(event); return; } diff --git a/sdk/typescript/src/scan-logs.ts b/sdk/typescript/src/scan-logs.ts index 0b9b7454b..97d6a7edb 100644 --- a/sdk/typescript/src/scan-logs.ts +++ b/sdk/typescript/src/scan-logs.ts @@ -3,7 +3,11 @@ import { basename, dirname, join, relative } from "node:path"; import { createInterface } from "node:readline"; import { sessionFiles } from "./cost.js"; import { CodexSecurityError } from "./errors.js"; -import { isScanArtifactDirectory, sessionStartedAt } from "./scan-sessions.js"; +import { + isScanArtifactDirectory, + sessionParentThreadId, + sessionStartedAt, +} from "./scan-sessions.js"; interface ScanLogOptions { scanId: string; @@ -31,15 +35,9 @@ export async function readScanLogs(options: ScanLogOptions) { const metadata = first["payload"]; const threadId = metadata["id"]; if (typeof threadId !== "string") break; - const source = metadata["source"]; - const subagent = isRecord(source) ? source["subagent"] : undefined; - const spawn = isRecord(subagent) ? subagent["thread_spawn"] : undefined; - const parent = - metadata["parent_thread_id"] ?? - (isRecord(spawn) ? spawn["parent_thread_id"] : undefined); logs.set(threadId, { threadId, - parentThreadId: typeof parent === "string" ? parent : null, + parentThreadId: sessionParentThreadId(metadata), startedAt: sessionStartedAt(metadata["timestamp"]), workingDirectory: typeof metadata["cwd"] === "string" ? metadata["cwd"] : null, diff --git a/sdk/typescript/src/scan-sessions.ts b/sdk/typescript/src/scan-sessions.ts index 2dbf98b9e..676f7a10b 100644 --- a/sdk/typescript/src/scan-sessions.ts +++ b/sdk/typescript/src/scan-sessions.ts @@ -6,6 +6,22 @@ export function sessionStartedAt(timestamp: unknown): number | null { return Number.isFinite(startedAt) ? startedAt : null; } +export function sessionParentThreadId( + metadata: Readonly>, +): string | null { + const source = metadata["source"]; + const subagent = isRecord(source) ? source["subagent"] : undefined; + const spawn = isRecord(subagent) ? subagent["thread_spawn"] : undefined; + for (const parent of [ + isRecord(spawn) ? spawn["parent_thread_id"] : undefined, + metadata["parent_thread_id"], + metadata["forked_from_id"], + ]) { + if (typeof parent === "string" && parent !== "") return parent; + } + return null; +} + export function isScanArtifactDirectory( scanDirectory: string, workingDirectory: string, @@ -23,3 +39,7 @@ export function isScanArtifactDirectory( relative(join(workers, components[0]!, "output"), workingDirectory) === "" ); } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 7111b045a..7a289a211 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -20,6 +20,18 @@ import { readScanLogs } from "../src/scan-logs.js"; import type { ScanProgress } from "../src/worker-progress.js"; const temporaryDirectories: string[] = []; +const parentFields = ["source", "parent_thread_id", "forked_from_id"] as const; +type SessionParentField = (typeof parentFields)[number]; + +function parentMetadata(parentThreadId: string, field: SessionParentField) { + return field === "source" + ? { + source: { + subagent: { thread_spawn: { parent_thread_id: parentThreadId } }, + }, + } + : { [field]: parentThreadId }; +} async function waitFor(check: () => boolean): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { @@ -52,6 +64,7 @@ async function writeSession( parentThreadId?: string, workingDirectory?: string, timestamp?: string, + parentField: SessionParentField = "source", ): Promise { const directory = join(home, "sessions", "2026", "07", "26"); await mkdir(directory, { recursive: true }); @@ -67,13 +80,7 @@ async function writeSession( ...(timestamp === undefined ? {} : { timestamp }), ...(parentThreadId === undefined ? {} - : { - source: { - subagent: { - thread_spawn: { parent_thread_id: parentThreadId }, - }, - }, - }), + : parentMetadata(parentThreadId, parentField)), }, }), JSON.stringify({ @@ -586,116 +593,131 @@ describe("live scan cost tracking", () => { }, ); - test("counts independent Deep workers inside the scan directory only", async () => { - const home = await codexHome(); - const scanDirectory = join(home, "scans", "current"); - const artifacts = join(scanDirectory, "artifacts"); - const workerDirectory = join( - artifacts, - "deep_discovery", - "workers", - "worker", - "output", - ); - await writeSession( - home, - "scan-thread", - { input_tokens: 1_000, output_tokens: 10 }, - undefined, - scanDirectory, - "2026-07-26T12:00:00.900Z", - ); - await writeSession( - home, - "deep-worker", - { input_tokens: 250, output_tokens: 2 }, - undefined, - process.platform === "win32" - ? workerDirectory.toUpperCase() - : workerDirectory, - "2026-07-26T12:00:00.900Z", - ); - await writeSession( - home, - "deep-reducer", - { input_tokens: 125, output_tokens: 1 }, - undefined, - (process.platform === "win32" ? artifacts.toUpperCase() : artifacts) + - sep, - "2026-07-26T12:02:00Z", - ); - await writeSession( - home, - "deep-worker-child", - { input_tokens: 50, output_tokens: 1 }, - "deep-worker", - ); - await writeSession( - home, - "unrelated-thread", - { input_tokens: 1_000_000, output_tokens: 1_000_000 }, - undefined, - `${scanDirectory}-other`, - ); - await writeSession( - home, - "previous-scan", - { input_tokens: 1_000_000, output_tokens: 1_000_000 }, - undefined, - join(scanDirectory, "artifacts", "deep_discovery", "previous-worker"), - "2026-07-26T11:59:00Z", - ); - await writeSession( - home, - "unknown-start", - { input_tokens: 1_000_000, output_tokens: 1_000_000 }, - undefined, - join( - scanDirectory, - "artifacts", + test.each([...parentFields])( + "counts independent Deep workers and %s descendants", + async (parentField) => { + const home = await codexHome(); + const scanDirectory = join(home, "scans", "current"); + const artifacts = join(scanDirectory, "artifacts"); + const workerDirectory = join( + artifacts, "deep_discovery", "workers", - "stale", + "worker", "output", - ), - ); - await writeSession( - home, - "nested-scan", - { input_tokens: 1_000_000, output_tokens: 1_000_000 }, - undefined, - join(scanDirectory, "nested", "artifacts"), - "2026-07-26T12:03:00Z", - ); - const events: ScanSessionEvent[] = []; - const tracker = new ScanCostTracker({ - codexHome: home, - model: "gpt-5.6-sol", - scanDirectory, - onSessionEvent: (event) => events.push(event), - }); - tracker.start("scan-thread"); - - expect((await tracker.stop()).usage).toMatchObject({ - input_tokens: 1_425, - output_tokens: 14, - }); - const labels = new Map( - events.map(({ threadId, worker }) => [threadId, worker]), - ); - expect(new Set(labels.keys())).toEqual( - new Set([ + ); + await writeSession( + home, "scan-thread", + { input_tokens: 1_000, output_tokens: 10 }, + undefined, + scanDirectory, + "2026-07-26T12:00:00.900Z", + ); + await writeSession( + home, "deep-worker", + { input_tokens: 250, output_tokens: 2 }, + undefined, + process.platform === "win32" + ? workerDirectory.toUpperCase() + : workerDirectory, + "2026-07-26T12:00:00.900Z", + ); + await writeSession( + home, "deep-reducer", + { input_tokens: 125, output_tokens: 1 }, + undefined, + (process.platform === "win32" ? artifacts.toUpperCase() : artifacts) + + sep, + "2026-07-26T12:02:00Z", + ); + await writeSession( + home, "deep-worker-child", - ]), - ); - expect(labels.get("scan-thread")).toBeUndefined(); - expect( - [...labels.values()].filter((worker) => worker !== undefined).sort(), - ).toEqual([1, 2, 3]); - }); + { input_tokens: 50, output_tokens: 1 }, + "deep-worker", + undefined, + undefined, + parentField, + ); + await writeSession( + home, + "unrelated-thread", + { input_tokens: 1_000_000, output_tokens: 1_000_000 }, + undefined, + `${scanDirectory}-other`, + ); + await writeSession( + home, + "previous-scan", + { input_tokens: 1_000_000, output_tokens: 1_000_000 }, + undefined, + join(scanDirectory, "artifacts", "deep_discovery", "previous-worker"), + "2026-07-26T11:59:00Z", + ); + await writeSession( + home, + "unknown-start", + { input_tokens: 1_000_000, output_tokens: 1_000_000 }, + undefined, + join( + scanDirectory, + "artifacts", + "deep_discovery", + "workers", + "stale", + "output", + ), + ); + await writeSession( + home, + "nested-scan", + { input_tokens: 1_000_000, output_tokens: 1_000_000 }, + undefined, + join(scanDirectory, "nested", "artifacts"), + "2026-07-26T12:03:00Z", + ); + const events: ScanSessionEvent[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, + model: "gpt-5.6-sol", + scanDirectory, + onSessionEvent: (event) => events.push(event), + }); + tracker.start("scan-thread"); + + expect((await tracker.stop()).usage).toMatchObject({ + input_tokens: 1_425, + output_tokens: 14, + }); + const labels = new Map( + events.map(({ threadId, worker }) => [threadId, worker]), + ); + expect(new Set(labels.keys())).toEqual( + new Set([ + "scan-thread", + "deep-worker", + "deep-reducer", + "deep-worker-child", + ]), + ); + expect(labels.get("scan-thread")).toBeUndefined(); + expect( + [...labels.values()].filter((worker) => worker !== undefined).sort(), + ).toEqual([1, 2, 3]); + const logs = await readScanLogs({ + scanId: "scan-example", + threadId: "scan-thread", + codexHome: home, + scanDirectory, + }); + expect(new Set(logs.sessions.map(({ threadId }) => threadId))).toEqual( + new Set(labels.keys()), + ); + }, + ); test.each([ [ @@ -703,6 +725,7 @@ describe("live scan cost tracking", () => { (scan: string) => join(scan, "artifacts", "deep_discovery", "output"), "2026-07-26T12:02:00Z", undefined, + "source", ], [ "sessions on another Windows drive", @@ -712,28 +735,46 @@ describe("live scan cost tracking", () => { : "C:\\output", "2026-07-26T12:02:00Z", undefined, + "source", ], [ "sessions earlier in the same second", (scan: string) => join(scan, "artifacts"), "2026-07-26T12:00:00.100Z", undefined, + "source", ], [ "sessions with an invalid timestamp", (scan: string) => join(scan, "artifacts"), "not-a-timestamp", undefined, + "source", ], [ "sessions with an unrelated parent", (scan: string) => join(scan, "artifacts"), "2026-07-26T12:02:00Z", "unrelated-parent", + "source", + ], + [ + "sessions with an unrelated direct parent", + (scan: string) => join(scan, "artifacts"), + "2026-07-26T12:02:00Z", + "unrelated-parent", + "parent_thread_id", + ], + [ + "sessions forked from an unrelated parent", + (scan: string) => join(scan, "artifacts"), + "2026-07-26T12:02:00Z", + "unrelated-parent", + "forked_from_id", ], ] as const)( "excludes %s from scan cost and logs", - async (_name, workingDirectory, timestamp, parentThreadId) => { + async (_name, workingDirectory, timestamp, parentThreadId, parentField) => { const home = await codexHome(); const scanDirectory = join(home, "scans", "current"); await writeSession( @@ -766,6 +807,7 @@ describe("live scan cost tracking", () => { parentThreadId, workingDirectory(scanDirectory), timestamp, + parentField, ); await writeSession( home, @@ -845,184 +887,183 @@ describe("live scan cost tracking", () => { }, ); - test("ignores replayed parent history in forked worker sessions", async () => { - const home = await codexHome(); - const inherited = { - input_tokens: 1_000, - cached_input_tokens: 500, - cache_write_input_tokens: 100, - output_tokens: 100, - reasoning_output_tokens: 20, - }; - await writeSession(home, "scan-thread", inherited); - const worker = await writeSession(home, "worker-thread", inherited); - const command = - 'rg "password" "$CODEX_SECURITY_REPOSITORY/routes/login.ts"'; + test.each([...parentFields])( + "ignores replayed parent history in %s worker sessions", + async (parentField) => { + const home = await codexHome(); + const inherited = { + input_tokens: 1_000, + cached_input_tokens: 500, + cache_write_input_tokens: 100, + output_tokens: 100, + reasoning_output_tokens: 20, + }; + await writeSession(home, "scan-thread", inherited); + const worker = await writeSession(home, "worker-thread", inherited); + const command = + 'rg "password" "$CODEX_SECURITY_REPOSITORY/routes/login.ts"'; - await writeFile( - worker, - [ - { - type: "session_meta", - payload: { - id: "worker-thread", - timestamp: "2026-07-26T12:02:00.250Z", - source: { - subagent: { - thread_spawn: { parent_thread_id: "scan-thread" }, - }, + await writeFile( + worker, + [ + { + type: "session_meta", + payload: { + id: "worker-thread", + timestamp: "2026-07-26T12:02:00.250Z", + ...parentMetadata("scan-thread", parentField), }, }, - }, - { - type: "session_meta", - payload: { - id: "scan-thread", - timestamp: "2026-07-26T12:00:00.000Z", - source: "exec", + { + type: "session_meta", + payload: { + id: "scan-thread", + timestamp: "2026-07-26T12:00:00.000Z", + source: "exec", + }, }, - }, - { - type: "event_msg", - payload: { type: "task_started", started_at: 1_785_067_200 }, - }, - { - type: "event_msg", - payload: { - type: "agent_message", - message: "Inherited parent commentary.", + { + type: "event_msg", + payload: { type: "task_started", started_at: 1_785_067_200 }, }, - }, - { - type: "response_item", - payload: { - type: "function_call", - name: "exec_command", - call_id: "inherited-search", - arguments: JSON.stringify({ cmd: command }), + { + type: "event_msg", + payload: { + type: "agent_message", + message: "Inherited parent commentary.", + }, }, - }, - { type: "response_item", payload: progressMessage(7) }, - { - type: "event_msg", - payload: { - type: "token_count", - info: { total_token_usage: inherited }, + { + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + call_id: "inherited-search", + arguments: JSON.stringify({ cmd: command }), + }, }, - }, - { - type: "event_msg", - payload: { type: "task_started", started_at: 1_785_067_320 }, - }, - { - type: "event_msg", - timestamp: "2026-07-26T12:02:01.000Z", - payload: { - type: "agent_message", - message: "Reviewing the login query.", + { type: "response_item", payload: progressMessage(7) }, + { + type: "event_msg", + payload: { + type: "token_count", + info: { total_token_usage: inherited }, + }, }, - }, - { - type: "response_item", - payload: { - type: "function_call", - name: "exec_command", - call_id: "worker-search", - arguments: JSON.stringify({ cmd: command }), + { + type: "event_msg", + payload: { type: "task_started", started_at: 1_785_067_320 }, }, - }, - { - type: "response_item", - payload: { - type: "function_call_output", - call_id: "worker-search", - output: - "Batch reviewed.\n" + - 'CODEX_SECURITY_SCAN_PROGRESS {"phase":"discovery","filesCompleted":3,"filesTotal":8}', + { + type: "event_msg", + timestamp: "2026-07-26T12:02:01.000Z", + payload: { + type: "agent_message", + message: "Reviewing the login query.", + }, }, - }, - { - type: "event_msg", - payload: { - type: "token_count", - info: { - total_token_usage: { - input_tokens: 1_300, - cached_input_tokens: 650, - cache_write_input_tokens: 150, - output_tokens: 130, - reasoning_output_tokens: 30, + { + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + call_id: "worker-search", + arguments: JSON.stringify({ cmd: command }), + }, + }, + { + type: "response_item", + payload: { + type: "function_call_output", + call_id: "worker-search", + output: + "Batch reviewed.\n" + + 'CODEX_SECURITY_SCAN_PROGRESS {"phase":"discovery","filesCompleted":3,"filesTotal":8}', + }, + }, + { + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: 1_300, + cached_input_tokens: 650, + cache_write_input_tokens: 150, + output_tokens: 130, + reasoning_output_tokens: 30, + }, }, }, }, - }, - ] - .map((event) => JSON.stringify(event)) - .join("\n") + "\n", - ); - - const activities: ScanActivity[] = []; - const progress: ScanProgress[] = []; - const events: ScanSessionEvent[] = []; - const tracker = new ScanCostTracker({ - codexHome: home, - model: "gpt-5.6-terra", - repository: "/code/juice-shop", - expectedFilesTotal: 8, - onActivity: (activity) => activities.push(activity), - onProgress: (update) => progress.push(update), - onSessionEvent: (event) => events.push(event), - }); - tracker.start("scan-thread"); + ] + .map((event) => JSON.stringify(event)) + .join("\n") + "\n", + ); - expect(await tracker.stop()).toEqual({ - usage: { - input_tokens: 1_300, - cached_input_tokens: 650, - cache_write_input_tokens: 150, - output_tokens: 130, - reasoning_output_tokens: 30, - total_tokens: 1_430, - }, - cost: { + const activities: ScanActivity[] = []; + const progress: ScanProgress[] = []; + const events: ScanSessionEvent[] = []; + const tracker = new ScanCostTracker({ + codexHome: home, model: "gpt-5.6-terra", - inputTokens: 1_300, - cachedInputTokens: 650, - cacheWriteInputTokens: 150, - outputTokens: 130, - estimatedUsd: 0.003065, - }, - }); - expect(activities).toEqual([ - expect.objectContaining({ - kind: "message", - description: "Reviewing the login query.", - worker: 1, - }), - expect.objectContaining({ - id: "worker-thread:worker-search", - kind: "command", - status: "running", - worker: 1, - }), - expect.objectContaining({ - id: "worker-thread:worker-search", - kind: "command", - status: "completed", - worker: 1, - }), - ]); - expect(progress).toEqual([ - { phase: "discovery", filesCompleted: 3, filesTotal: 8 }, - ]); - const workerEvents = events.filter( - ({ threadId }) => threadId === "worker-thread", - ); - expect(workerEvents).toHaveLength(6); - expect(JSON.stringify(workerEvents)).not.toContain( - "Inherited parent commentary.", - ); - }); + repository: "/code/juice-shop", + expectedFilesTotal: 8, + onActivity: (activity) => activities.push(activity), + onProgress: (update) => progress.push(update), + onSessionEvent: (event) => events.push(event), + }); + tracker.start("scan-thread"); + + expect(await tracker.stop()).toEqual({ + usage: { + input_tokens: 1_300, + cached_input_tokens: 650, + cache_write_input_tokens: 150, + output_tokens: 130, + reasoning_output_tokens: 30, + total_tokens: 1_430, + }, + cost: { + model: "gpt-5.6-terra", + inputTokens: 1_300, + cachedInputTokens: 650, + cacheWriteInputTokens: 150, + outputTokens: 130, + estimatedUsd: 0.003065, + }, + }); + expect(activities).toEqual([ + expect.objectContaining({ + kind: "message", + description: "Reviewing the login query.", + worker: 1, + }), + expect.objectContaining({ + id: "worker-thread:worker-search", + kind: "command", + status: "running", + worker: 1, + }), + expect.objectContaining({ + id: "worker-thread:worker-search", + kind: "command", + status: "completed", + worker: 1, + }), + ]); + expect(progress).toEqual([ + { phase: "discovery", filesCompleted: 3, filesTotal: 8 }, + ]); + const workerEvents = events.filter( + ({ threadId }) => threadId === "worker-thread", + ); + expect(workerEvents).toHaveLength(6); + expect(JSON.stringify(workerEvents)).not.toContain( + "Inherited parent commentary.", + ); + }, + ); test("forwards actions from this scan's delegated workers only", async () => { const home = await codexHome(); diff --git a/sdk/typescript/tests-ts/scan-sessions.test.ts b/sdk/typescript/tests-ts/scan-sessions.test.ts new file mode 100644 index 000000000..6df59bff6 --- /dev/null +++ b/sdk/typescript/tests-ts/scan-sessions.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test"; +import { sessionParentThreadId } from "../src/scan-sessions.js"; + +test.each([ + [ + "prefers the spawned parent over legacy parent fields", + { + source: { + subagent: { thread_spawn: { parent_thread_id: "spawn-parent" } }, + }, + parent_thread_id: "direct-parent", + forked_from_id: "fork-parent", + }, + "spawn-parent", + ], + [ + "prefers the direct parent over fork ancestry", + { parent_thread_id: "direct-parent", forked_from_id: "fork-parent" }, + "direct-parent", + ], + [ + "falls back from an empty spawned parent to the direct parent", + { + source: { subagent: { thread_spawn: { parent_thread_id: "" } } }, + parent_thread_id: "direct-parent", + }, + "direct-parent", + ], + [ + "falls back from an empty direct parent to fork ancestry", + { parent_thread_id: "", forked_from_id: "fork-parent" }, + "fork-parent", + ], + [ + "ignores a non-string direct parent when fork ancestry is present", + { parent_thread_id: null, forked_from_id: "fork-parent" }, + "fork-parent", + ], + ["recognizes independent CLI sessions", { source: "cli" }, null], + ["treats an empty parent as missing", { forked_from_id: "" }, null], +] as const)("session parent metadata %s", (_name, metadata, expected) => { + expect(sessionParentThreadId(metadata)).toBe(expected); +}); From 77d3b57c1e3911a45a9c2bafd0bf629ee578e1a3 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 21 Aug 2026 07:39:31 -0700 Subject: [PATCH 3/3] test(sdk): keep metadata coverage with cost tests Keep all seven parent-format checks in the existing cost suite so this follow-up does not reshuffle unrelated Windows CI shards. Production code and assertions are unchanged. --- sdk/typescript/tests-ts/cost.test.ts | 42 ++++++++++++++++++ sdk/typescript/tests-ts/scan-sessions.test.ts | 43 ------------------- 2 files changed, 42 insertions(+), 43 deletions(-) delete mode 100644 sdk/typescript/tests-ts/scan-sessions.test.ts diff --git a/sdk/typescript/tests-ts/cost.test.ts b/sdk/typescript/tests-ts/cost.test.ts index 7a289a211..50b46d27a 100644 --- a/sdk/typescript/tests-ts/cost.test.ts +++ b/sdk/typescript/tests-ts/cost.test.ts @@ -17,6 +17,7 @@ import { } from "../src/cost.js"; import type { ScanActivity } from "../src/scan-activity.js"; import { readScanLogs } from "../src/scan-logs.js"; +import { sessionParentThreadId } from "../src/scan-sessions.js"; import type { ScanProgress } from "../src/worker-progress.js"; const temporaryDirectories: string[] = []; @@ -127,6 +128,47 @@ function progressMessage( }; } +test.each([ + [ + "prefers the spawned parent over legacy parent fields", + { + source: { + subagent: { thread_spawn: { parent_thread_id: "spawn-parent" } }, + }, + parent_thread_id: "direct-parent", + forked_from_id: "fork-parent", + }, + "spawn-parent", + ], + [ + "prefers the direct parent over fork ancestry", + { parent_thread_id: "direct-parent", forked_from_id: "fork-parent" }, + "direct-parent", + ], + [ + "falls back from an empty spawned parent to the direct parent", + { + source: { subagent: { thread_spawn: { parent_thread_id: "" } } }, + parent_thread_id: "direct-parent", + }, + "direct-parent", + ], + [ + "falls back from an empty direct parent to fork ancestry", + { parent_thread_id: "", forked_from_id: "fork-parent" }, + "fork-parent", + ], + [ + "ignores a non-string direct parent when fork ancestry is present", + { parent_thread_id: null, forked_from_id: "fork-parent" }, + "fork-parent", + ], + ["recognizes independent CLI sessions", { source: "cli" }, null], + ["treats an empty parent as missing", { forked_from_id: "" }, null], +] as const)("session parent metadata %s", (_name, metadata, expected) => { + expect(sessionParentThreadId(metadata)).toBe(expected); +}); + describe("scan cost", () => { test.each([ [{ cache_write_tokens: 15 }, 15], diff --git a/sdk/typescript/tests-ts/scan-sessions.test.ts b/sdk/typescript/tests-ts/scan-sessions.test.ts deleted file mode 100644 index 6df59bff6..000000000 --- a/sdk/typescript/tests-ts/scan-sessions.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect, test } from "bun:test"; -import { sessionParentThreadId } from "../src/scan-sessions.js"; - -test.each([ - [ - "prefers the spawned parent over legacy parent fields", - { - source: { - subagent: { thread_spawn: { parent_thread_id: "spawn-parent" } }, - }, - parent_thread_id: "direct-parent", - forked_from_id: "fork-parent", - }, - "spawn-parent", - ], - [ - "prefers the direct parent over fork ancestry", - { parent_thread_id: "direct-parent", forked_from_id: "fork-parent" }, - "direct-parent", - ], - [ - "falls back from an empty spawned parent to the direct parent", - { - source: { subagent: { thread_spawn: { parent_thread_id: "" } } }, - parent_thread_id: "direct-parent", - }, - "direct-parent", - ], - [ - "falls back from an empty direct parent to fork ancestry", - { parent_thread_id: "", forked_from_id: "fork-parent" }, - "fork-parent", - ], - [ - "ignores a non-string direct parent when fork ancestry is present", - { parent_thread_id: null, forked_from_id: "fork-parent" }, - "fork-parent", - ], - ["recognizes independent CLI sessions", { source: "cli" }, null], - ["treats an empty parent as missing", { forked_from_id: "" }, null], -] as const)("session parent metadata %s", (_name, metadata, expected) => { - expect(sessionParentThreadId(metadata)).toBe(expected); -});