diff --git a/src/main/ai-vault/session-scanner-codex-non-user-origin.test.ts b/src/main/ai-vault/session-scanner-codex-non-user-origin.test.ts new file mode 100644 index 00000000000..7eb41e99289 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-non-user-origin.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest' +import { readCodexNonUserOrigin } from './session-scanner-codex-non-user-origin' + +// Payload shapes below mirror real `session_meta` records: `source.subagent` is +// an externally tagged union, so the spawn record nests under a `thread_spawn` +// tag and the parent, nickname and path are copied onto the payload's own keys +// beside it. +function spawnedPayload(threadSpawn: unknown, payloadCopies: Record = {}) { + return { + id: 'child-thread', + cwd: '/repo/app', + thread_source: 'subagent', + source: { subagent: { thread_spawn: threadSpawn } }, + ...payloadCopies + } +} + +describe('readCodexNonUserOrigin', () => { + it('keeps every field of a stated spawn record, including a null role', () => { + const origin = readCodexNonUserOrigin( + spawnedPayload({ + parent_thread_id: '01a06e83-42af-7741-b975-ab54925540f9', + depth: 1, + agent_path: '/root/readiness_final_fresh', + agent_nickname: 'Pascal', + agent_role: null + }) + ) + + expect(origin).toEqual({ + source: 'subagent', + kind: 'thread_spawn', + kindLabel: null, + threadSource: 'subagent', + parentage: { + parentThreadId: '01a06e83-42af-7741-b975-ab54925540f9', + depth: 1, + agentNickname: 'Pascal', + agentRole: null, + agentPath: '/root/readiness_final_fresh' + } + }) + }) + + it('carries the depth of a nested child instead of flattening it', () => { + const origin = readCodexNonUserOrigin( + spawnedPayload({ + parent_thread_id: 'middle-thread', + depth: 2, + agent_path: '/root/pr_review_pass_1/adversarial_correctness', + agent_nickname: 'Noether', + agent_role: 'explorer' + }) + ) + + expect(origin?.parentage?.depth).toBe(2) + expect(origin?.parentage?.parentThreadId).toBe('middle-thread') + expect(origin?.parentage?.agentRole).toBe('explorer') + }) + + it('keeps the rest of the spawn when the naming path is null', () => { + const origin = readCodexNonUserOrigin( + spawnedPayload({ + parent_thread_id: 'user-thread', + depth: 1, + agent_path: null, + agent_nickname: 'Laplace', + agent_role: 'explorer' + }) + ) + + expect(origin?.parentage).toEqual({ + parentThreadId: 'user-thread', + depth: 1, + agentNickname: 'Laplace', + agentRole: 'explorer', + agentPath: null + }) + }) + + it('reads the agent_type spelling of the role, nested and on the payload', () => { + // Codex documents `agent_type` as an alias of `agent_role` in both places. + expect(readCodexNonUserOrigin(spawnedPayload({ agent_type: 'explorer' }))?.parentage).toEqual({ + parentThreadId: null, + depth: null, + agentNickname: null, + agentRole: 'explorer', + agentPath: null + }) + expect( + readCodexNonUserOrigin(spawnedPayload({}, { agent_type: 'reviewer' }))?.parentage?.agentRole + ).toBe('reviewer') + }) + + it('reads the role the payload copied beside the spawn record', () => { + expect( + readCodexNonUserOrigin(spawnedPayload({}, { agent_role: 'explorer' }))?.parentage?.agentRole + ).toBe('explorer') + }) + + it('classifies a non-spawn thread by its tag instead of calling it a role', () => { + // `review`, `compact` and `memory_consolidation` are sibling tags naming the + // sort of non-user thread. They are not the spawned agent's role — that + // field exists only inside a spawn record — and the parent, when there is + // one, is stated on the payload's own key. + const origin = readCodexNonUserOrigin({ + id: 'child-thread', + thread_source: 'subagent', + source: { subagent: 'review' }, + parent_thread_id: '019f49cb-7af8-7e01-946a-274c65fe6103' + }) + + expect(origin).toEqual({ + source: 'subagent', + kind: 'review', + kindLabel: null, + threadSource: 'subagent', + parentage: { + parentThreadId: '019f49cb-7af8-7e01-946a-274c65fe6103', + depth: null, + agentNickname: null, + agentRole: null, + agentPath: null + } + }) + }) + + it('classifies a compaction thread that states no parent at all', () => { + expect(readCodexNonUserOrigin({ id: 'child-thread', source: { subagent: 'compact' } })).toEqual( + { + source: 'subagent', + kind: 'compact', + kindLabel: null, + threadSource: null, + parentage: null + } + ) + expect( + readCodexNonUserOrigin({ id: 'child-thread', source: { subagent: 'memory_consolidation' } }) + ?.kind + ).toBe('memory_consolidation') + }) + + it('keeps the label of a tag that carries free text', () => { + expect( + readCodexNonUserOrigin({ id: 'child-thread', source: { subagent: { other: 'gardener' } } }) + ).toEqual({ + source: 'subagent', + kind: 'other', + kindLabel: 'gardener', + threadSource: null, + parentage: null + }) + }) + + it('classifies machinery Codex ran for itself, not only agents it spawned', () => { + // `internal` is the other non-user branch of the same union. Its threads + // land in the same history tree and state no spawn record, and a release + // that omits `thread_source` leaves the tag as the only signal there is. + expect( + readCodexNonUserOrigin({ id: 'child-thread', source: { internal: 'guardian' } }) + ).toEqual({ + source: 'internal', + kind: 'guardian', + kindLabel: null, + threadSource: null, + parentage: null + }) + expect( + readCodexNonUserOrigin({ + id: 'child-thread', + source: { internal: 'memory_consolidation' } + })?.kind + ).toBe('memory_consolidation') + }) + + it('keeps every thread the user started, whatever its source tag', () => { + // Codex's runtime groups these with spawn records as real agent sessions, + // so none of them is machinery and none may be hidden. `custom` carries a + // label and is still the user's own. + for (const source of ['cli', 'vscode', 'exec', 'mcp', 'unknown', { custom: 'acme' }]) { + expect(readCodexNonUserOrigin({ id: 'user-thread', source })).toBeNull() + } + }) + + it('degrades field by field when a spawn record is malformed', () => { + const origin = readCodexNonUserOrigin( + spawnedPayload({ + parent_thread_id: 12345, + agent_nickname: 'Mendel', + agent_role: 'explorer' + }) + ) + + // The unreadable parent and the absent depth do not cost the two fields the + // record does state. + expect(origin?.parentage).toEqual({ + parentThreadId: null, + depth: null, + agentNickname: 'Mendel', + agentRole: 'explorer', + agentPath: null + }) + }) + + it('rejects a depth that contradicts being a child', () => { + expect(readCodexNonUserOrigin(spawnedPayload({ depth: 0 }))?.parentage).toBeNull() + expect(readCodexNonUserOrigin(spawnedPayload({ depth: 1.5 }))?.parentage).toBeNull() + expect(readCodexNonUserOrigin(spawnedPayload({ depth: '2' }))?.parentage).toBeNull() + }) + + it('still reports the origin when no part of the spawn is readable', () => { + const origin = readCodexNonUserOrigin(spawnedPayload(true)) + + expect(origin).toEqual({ + source: 'subagent', + kind: 'thread_spawn', + kindLabel: null, + threadSource: 'subagent', + parentage: null + }) + }) + + it('reports a subagent source that states no spawn at all', () => { + const origin = readCodexNonUserOrigin({ + id: 'child-thread', + source: { subagent: { thread_spawn: null } } + }) + + expect(origin).toEqual({ + source: 'subagent', + kind: 'thread_spawn', + kindLabel: null, + threadSource: null, + parentage: null + }) + }) + + it('reads no origin from a user thread', () => { + expect( + readCodexNonUserOrigin({ id: 'user-thread', thread_source: 'user', source: 'cli' }) + ).toBeNull() + expect(readCodexNonUserOrigin({ id: 'user-thread', source: 'vscode' })).toBeNull() + expect(readCodexNonUserOrigin({ id: 'user-thread' })).toBeNull() + }) + + it('keeps a thread whose source states no readable tag at all', () => { + // A tagged union spells a tag as a bare string or a single-key object, so + // none of these is one and none says the thread is not the user's. Reading + // one as a spawn would drop their own thread out of their history on a + // value that states nothing. + for (const source of [false, true, 0, 1, '', ' ', [], {}, { a: 1, b: 2 }]) { + expect(readCodexNonUserOrigin({ id: 'user-thread', source })).toBeNull() + } + }) + + it('classifies on the outer tag even when the kind beneath it is unreadable', () => { + // The outer tag is the discriminant: only a non-user source serializes + // under this key, so it states the thread is not the user's on its own. The + // kind beneath it is detail that later releases may respell, and losing the + // spelling must not leak every worker transcript into the user's history. + for (const subagent of [false, 0, '', [], { type: 'review', extra: 1 }]) { + expect(readCodexNonUserOrigin({ id: 'child-thread', source: { subagent } })).toEqual({ + source: 'subagent', + kind: null, + kindLabel: null, + threadSource: null, + parentage: null + }) + } + }) + + it('keeps a forked user thread, whose lineage is not a spawn parent', () => { + // `forked_from_id` and `parent_thread_id` are separate co-existing keys + // meaning different things. A forked thread is still the user's own. + expect( + readCodexNonUserOrigin({ + id: 'user-thread', + thread_source: 'user', + forked_from_id: '019f49cb-7af8-7e01-946a-274c65fe6103' + }) + ).toBeNull() + expect( + readCodexNonUserOrigin({ + id: 'user-thread', + forked_from_id: '019f49cb-7af8-7e01-946a-274c65fe6103' + }) + ).toBeNull() + }) + + it('lets a stated user thread_source outrank a subagent source', () => { + expect( + readCodexNonUserOrigin({ + id: 'user-thread', + thread_source: 'user', + source: { subagent: { thread_spawn: { parent_thread_id: 'other' } } } + }) + ).toBeNull() + }) + + it('reads the camelCase thread_source spelling', () => { + expect(readCodexNonUserOrigin({ id: 'child', threadSource: 'agent' })).toEqual({ + source: null, + kind: null, + kindLabel: null, + threadSource: 'agent', + parentage: null + }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-codex-non-user-origin.ts b/src/main/ai-vault/session-scanner-codex-non-user-origin.ts new file mode 100644 index 00000000000..1d308c40108 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-non-user-origin.ts @@ -0,0 +1,137 @@ +import { asRecord, extractString } from './session-scanner-values' + +/** + * What Codex stated about the spawn that produced a thread. `parentThreadId` is + * the only join key here: `agentPath` is a slash-rooted naming path + * (`/root/pr_review_pass_1`) that labels agents rather than identifying them. + * Fork lineage is a separate key and deliberately not read into this — a thread + * the user forked is still their own. + */ +export type CodexSubagentParentage = { + parentThreadId: string | null + /** 1 for a direct child of a user thread; real rollouts nest to 3. */ + depth: number | null + agentNickname: string | null + agentRole: string | null + agentPath: string | null +} + +/** + * Why a Codex rollout is not the user's own thread, and who spawned it. Those + * are two facts and Codex keeps them apart, so this does too. + * + * `source` is a nested union: its outer tag says an agent Codex spawned + * (`subagent`) or machinery it ran for itself (`internal`), and the inner `kind` + * says which — a spawn record, a review pass, a compaction, a guardian. Only the + * spawn kind carries a spawn record, so a null `parentage` means nothing about a + * spawn was readable, never that the thread is rooted. Every other outer tag + * (`cli`, `vscode`, `exec`, `mcp`, `custom`, `unknown`) is a thread the user + * started and produces no origin at all. + */ +export type CodexNonUserOrigin = { + /** The `source` outer tag: 'subagent' or 'internal'. Null when only `thread_source` stated it. */ + source: string | null + /** + * The inner tag, verbatim snake_case: 'thread_spawn', 'review', 'compact', + * 'memory_consolidation', 'other', 'guardian', or one a later release adds. + */ + kind: string | null + /** Free text the inner tag carries — the 'other' tag's label; null for tags without one. */ + kindLabel: string | null + /** Verbatim non-user `thread_source`; null on payloads that state none. */ + threadSource: string | null + parentage: CodexSubagentParentage | null +} + +// The two `source` tags that are not the user's own thread. Codex's runtime +// draws the same line: everything else, spawn records included, gets the +// treatment a real agent session gets. +const NON_USER_SOURCE_TAGS = new Set(['subagent', 'internal']) + +/** + * Read a `session_meta` payload's non-user origin, or null for a user thread. + * + * The payload states this in two places that disagree in coverage. `source` is + * the structural field Codex's own runtime switches on; `thread_source` is an + * analytics label that some releases omit entirely. A payload stating only one + * of them is normal, so each is read independently and a tag carrying no spawn + * record still classifies the thread. + */ +export function readCodexNonUserOrigin( + payload: Record +): CodexNonUserOrigin | null { + const threadSource = extractString(payload.thread_source) ?? extractString(payload.threadSource) + const outerTag = readCodexUnionTag(payload.source) + const nonUserSource = outerTag && NON_USER_SOURCE_TAGS.has(outerTag.kind) ? outerTag : null + if (threadSource) { + // A stated thread_source is the provider's own verdict, so it outranks + // `source` even when the two disagree. + if (threadSource.toLowerCase() === 'user') { + return null + } + } else if (!nonUserSource) { + return null + } + const innerTag = readCodexUnionTag(nonUserSource?.content) + return { + source: nonUserSource?.kind ?? null, + kind: innerTag?.kind ?? null, + kindLabel: extractString(innerTag?.content), + threadSource, + parentage: readCodexSubagentParentage(payload, asRecord(innerTag?.content)) + } +} + +type CodexUnionTag = { + kind: string + /** The tag's payload: free text, a nested tag, or the spawn record. */ + content: unknown +} + +/** + * Read one externally tagged union value: a payload-less tag is a bare string + * (`'cli'`, `'review'`), a tag with one is a single-key object + * (`{ subagent: ... }`, `{ thread_spawn: { ... } }`, `{ other: 'label' }`). + * Anything else states no tag — and treating an unreadable value as a spawn + * would drop the user's own thread out of their history, where letting an + * unrecognised one through only shows a transcript they can see and ignore. + */ +function readCodexUnionTag(value: unknown): CodexUnionTag | null { + const bareTag = extractString(value) + if (bareTag) { + return { kind: bareTag, content: undefined } + } + const record = asRecord(value) + const keys = record ? Object.keys(record) : [] + const kind = keys.length === 1 ? extractString(keys[0]) : null + return kind && record ? { kind, content: record[kind] } : null +} + +// The spawn record's fields are copied onto the payload's own keys, so each one +// falls back rather than being discarded with its record. `depth` has no copy to +// fall back to; `agent_role` is documented with `agent_type` as its alias, in +// both places. +function readCodexSubagentParentage( + payload: Record, + spawn: Record | null +): CodexSubagentParentage | null { + const parentage: CodexSubagentParentage = { + parentThreadId: + extractString(spawn?.parent_thread_id) ?? extractString(payload.parent_thread_id), + depth: codexSpawnDepth(spawn?.depth), + agentNickname: extractString(spawn?.agent_nickname) ?? extractString(payload.agent_nickname), + agentRole: + extractString(spawn?.agent_role) ?? + extractString(spawn?.agent_type) ?? + extractString(payload.agent_role) ?? + extractString(payload.agent_type), + agentPath: extractString(spawn?.agent_path) ?? extractString(payload.agent_path) + } + return Object.values(parentage).some((field) => field !== null) ? parentage : null +} + +// Codex numbers a direct child 1, so a fractional or non-positive depth is +// contradictory data: unknown beats recording it. +function codexSpawnDepth(value: unknown): number | null { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null +} diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index beff5eb06b0..f1be610f566 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -36,10 +36,11 @@ import { } from './session-scanner-values' import { remoteSessionContentLines } from './remote-session-content-lines' import { readCodexTimelineOnlyRecord } from './session-scanner-codex-record-fast-path' +import { extractCodexSessionMetadataTitle } from './session-scanner-codex-session-meta' import { - extractCodexSessionMetadataTitle, - isCodexWorkerSession -} from './session-scanner-codex-session-meta' + readCodexNonUserOrigin, + type CodexNonUserOrigin +} from './session-scanner-codex-non-user-origin' export async function parseCodexSessionFile( file: FileWithMtime, @@ -88,7 +89,12 @@ export async function parseCodexSessionContent(args: { type CodexSessionParseState = { accumulator: SessionAccumulator previousTotals: CodexUsageSnapshot | null - rejectedWorkerSession: boolean + // Codex's own classification of this thread as something other than the + // user's own — a spawned agent, a review pass, a compaction, a guardian. Codex + // writes all of those into the same history tree and AI Vault shows + // user-started sessions only, so the parse is rejected on this record's + // presence rather than on a separate flag beside it. + nonUserOrigin: CodexNonUserOrigin | null sawSessionMeta: boolean historyMode: string | null // Which source set the current title; an index-file title outranks the raw @@ -108,7 +114,7 @@ function createCodexParseState( messages }), previousTotals: null, - rejectedWorkerSession: false, + nonUserOrigin: null, sawSessionMeta: false, historyMode: null, titleSource: null @@ -124,7 +130,7 @@ function cloneCodexParseState(state: CodexSessionParseState): CodexSessionParseS } function consumeCodexRecordLine(state: CodexSessionParseState, line: string): void { - if (state.rejectedWorkerSession) { + if (state.nonUserOrigin) { return } const record = parseJsonObject(line) @@ -137,10 +143,8 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo const payload = asRecord(record.payload) if (record.type === 'session_meta' && payload) { - if (isCodexWorkerSession(payload)) { - // Why: Codex writes internal worker/sub-agent transcripts into the same - // history tree; AI Vault should show user-started sessions only. - state.rejectedWorkerSession = true + state.nonUserOrigin = readCodexNonUserOrigin(payload) + if (state.nonUserOrigin) { return } state.sawSessionMeta = true @@ -239,7 +243,7 @@ async function finalizeCodexParseState( executionHostPlatform?: NodeJS.Platform | null } ): Promise { - if (state.rejectedWorkerSession) { + if (state.nonUserOrigin) { return null } // Finalize a snapshot: the live state keeps accumulating appended lines. @@ -290,7 +294,7 @@ function codexResumeStateFromParseState( consumeCodexRecordLine(state, line.toString('utf8')) } }, - shouldStop: () => state.rejectedWorkerSession, + shouldStop: () => state.nonUserOrigin !== null, identity: () => accumulatorSessionIdentity(state.accumulator), clone: () => codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader), @@ -315,7 +319,7 @@ async function parseCodexSessionLines(args: { const state = createCodexParseState(args.file, args.messages) for await (const line of args.lines) { consumeCodexRecordLine(state, line) - if (state.rejectedWorkerSession) { + if (state.nonUserOrigin) { // Worker transcripts are excluded outright; stop reading early. return null } diff --git a/src/main/ai-vault/session-scanner-codex-session-meta.ts b/src/main/ai-vault/session-scanner-codex-session-meta.ts index f2cba3c97d0..061607b5dec 100644 --- a/src/main/ai-vault/session-scanner-codex-session-meta.ts +++ b/src/main/ai-vault/session-scanner-codex-session-meta.ts @@ -1,17 +1,8 @@ -import { asRecord, extractString, normalizeTitleText } from './session-scanner-values' +import { extractString, normalizeTitleText } from './session-scanner-values' // Field readers for Codex's `session_meta` record, whose key spelling has drifted // across Codex releases (snake_case rollouts, camelCase app-server rollouts). - -export function isCodexWorkerSession(payload: Record): boolean { - const threadSource = extractString(payload.thread_source) ?? extractString(payload.threadSource) - if (threadSource) { - return threadSource.toLowerCase() !== 'user' - } - - const source = asRecord(payload.source) - return Boolean(asRecord(source?.subagent)) -} +// Whether the thread is the user's own is read by `session-scanner-codex-non-user-origin.ts`. export function extractCodexSessionMetadataTitle(payload: Record): string | null { return ( diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index a1a35a6c46a..487661242da 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -79,12 +79,17 @@ describe('scanAiVaultSessions Codex worker sessions', () => { payload: { id: 'legacy-worker-session', cwd: '/repo/app', + parent_thread_id: 'user-session', + agent_nickname: 'Worker', + agent_path: '/root/legacy_worker', source: { subagent: { thread_spawn: { parent_thread_id: 'user-session', depth: 1, - agent_nickname: 'Worker' + agent_nickname: 'Worker', + agent_role: null, + agent_path: '/root/legacy_worker' } } } @@ -102,6 +107,96 @@ describe('scanAiVaultSessions Codex worker sessions', () => { ]) ) + await writeFile( + join(codexSessionsDir, '2026', '06', '12', 'rollout-nested-worker-session.jsonl'), + jsonLines([ + { + timestamp: '2026-06-12T10:03:00.000Z', + type: 'session_meta', + payload: { + id: 'nested-worker-session', + cwd: '/repo/app', + source: { + subagent: { + thread_spawn: { + parent_thread_id: 'legacy-worker-session', + depth: 2, + agent_nickname: 'Nested', + agent_role: 'explorer', + agent_path: '/root/legacy_worker/nested' + } + } + } + } + }, + { + timestamp: '2026-06-12T10:03:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Nested internal worker task' }] + } + } + ]) + ) + + // `review` is a sibling tag of `thread_spawn` in the same union, not an + // older spelling of it: it states no spawn record, so the parent is on the + // payload's own key. `thread_source` is omitted because some releases state + // none, and it is the only other signal that would keep this transcript out + // of the user's history. + await writeFile( + join(codexSessionsDir, '2026', '06', '12', 'rollout-role-only-worker-session.jsonl'), + jsonLines([ + { + timestamp: '2026-06-12T10:04:00.000Z', + type: 'session_meta', + payload: { + id: 'role-only-worker-session', + cwd: '/repo/app', + parent_thread_id: 'user-session', + source: { subagent: 'review' } + } + }, + { + timestamp: '2026-06-12T10:04:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Role-only internal worker task' }] + } + } + ]) + ) + + // A compaction thread is a non-user thread that names no parent at all, + // so its tag is the only thing keeping it out of the user's history. + await writeFile( + join(codexSessionsDir, '2026', '06', '12', 'rollout-compaction-session.jsonl'), + jsonLines([ + { + timestamp: '2026-06-12T10:05:00.000Z', + type: 'session_meta', + payload: { + id: 'compaction-session', + cwd: '/repo/app', + source: { subagent: 'compact' } + } + }, + { + timestamp: '2026-06-12T10:05:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Compaction of the user session' }] + } + } + ]) + ) + const result = await scanAiVaultSessions({ claudeProjectsDir: join(root, 'claude-projects'), codexSessionsDir,