mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
refactor(ai-vault): read Codex's stated subagent parentage instead of a boolean (#22298)
* feat(ai-vault): read Codex's stated subagent parentage
Codex states a spawned thread's parentage in `session_meta`: the parent
thread id, the spawn depth, and the agent's nickname, role and naming
path. Add a reader that keeps all five as a typed record.
Releases disagree about where they state it. Newer ones nest the full
record under `source.subagent.thread_spawn` and copy the parent,
nickname and path onto the payload's own keys; 0.144-0.147 name only the
agent's role there and leave those copies as the sole statement of the
parent. Every field is read independently, so a release that states
three of them is not discarded for omitting the other two, and a
malformed field costs only itself.
* refactor(ai-vault): reject Codex worker transcripts on the parentage record
The scanner collapsed Codex's whole spawn record to a yes/no to decide
whether a rollout belonged in Agent Session History. The parse state now
holds the record itself and derives that decision from its presence, so
the parent thread id, depth and agent name survive the scan instead of
being thrown away at the point they are read.
Two behaviour notes. A release that states only `source: { subagent:
'review' }` is now recognised as a spawned thread; the previous check
required that key to be an object, so such a transcript would have shown
up in the user's own history on a release that states no `thread_source`
alongside it. And a transcript that states `thread_source: 'user'` is
still treated as the user's own even if a subagent source sits beside
it, unchanged from before.
* fix(ai-vault): an unreadable subagent source is not a spawn statement
Detection with no stated thread_source accepted any value under
`source.subagent` that was not undefined/null, so `subagent: false` (or 0,
or "") would have read as a spawn and hidden the user's own thread from
Agent Session History. Every release spells a subagent source as either the
spawn record or the agent's role, so readability is the gate: a value that
is neither states nothing. Letting a worker transcript through is visible
and recoverable; dropping a user's session is neither.
* refactor(ai-vault): classify a Codex thread by its source tag, not its role
`source.subagent` is an externally tagged union naming the sort of non-user
thread: a spawn record, but equally a review pass, a compaction, a memory
consolidation, or a labelled `other`. Those are siblings that exist today,
not an older spelling of the spawn record, so reading them is not a legacy
fallback and the tag is not the spawned agent's role — `agent_role` is a
field that exists only inside a spawn record.
Why this is not a tag rename. Why a thread is not the user's own and who
spawned it are two facts, and folding the first into the second made a
compaction read as an agent whose role is "compact". `kind` now carries the
union tag (with `kindLabel` for the free text `other` states) and parentage
stays the join key, so the two can disagree without either being lost. A
transcript is rejected on the classification, never on parentage — which is
also why a forked thread's lineage can never be mistaken for a spawn.
Also read the union faithfully: a tag is a bare string or a single-key
object, and a value that is neither states no tag at all. That keeps a
user's own thread visible on an unreadable value, where the previous
presence test would have hidden it. Adds the documented `agent_type` alias
of `agent_role`, and the payload-level copy of the role that the other three
spawn fields already fell back to.
* fix(ai-vault): hide the machinery Codex runs for itself, not only spawned agents
Codex's `source` is a nested union, and two of its outer tags are not the
user's thread: `subagent` (an agent it spawned, or a review or compaction it
ran) and `internal` (guardian and memory-consolidation machinery). Only the
first was ever read, so an `internal` rollout landed in Agent Session History
as if the user had started it whenever the release omitted `thread_source` —
and that field is optional, absent on 1,315 of 13,137 local rollouts.
Reading the outer tag rather than one hardcoded key also fixes the direction
of the readability rule. The outer tag is the discriminant: nothing but a
non-user source serializes under those keys, so it classifies the thread on
its own, and a kind beneath it that a later release respells no longer leaks
every worker transcript into the user's history. An unreadable value in
`source` itself still states nothing and leaves the thread visible.
Renamed to match what it decides — whether a thread is the user's own, which
was never only about subagents. Every other tag (cli, vscode, exec, mcp,
custom, unknown) is a thread the user started and is now pinned as such.
* refactor(ai-vault): read only the source tag Codex actually writes
Backs out the reader for Codex's other non-user `source` tag. It has zero
records across 13,137 local rollouts, and the threads it would name state a
`thread_source` unconditionally on the path that creates them, so they are
already classified. Reading a shape that has never been observed, for a case
already covered, is speculation — unlike the bare-string subagent tag, which
has 16 real records behind it.
The nested-union reading stays: the outer tag is still what classifies the
thread, so a kind beneath it that a later release respells cannot leak a
worker transcript into the user's history, and the tags the user's own
threads carry stay pinned as visible.
* Revert "refactor(ai-vault): read only the source tag Codex actually writes"
This reverts commit 616a2b7c7d.
Backing the `internal` tag out was right against a bolt-on reading a second
hardcoded key, but not against a reader that decodes the `source` union
generically: there, excluding it means special-casing a documented producer
variant back out of a general reader, and the transcripts it names reach the
user's history on any payload that omits `thread_source` — 1,315 of 13,137
local rollouts. Hiding it costs one entry in the tag set.
This commit is contained in:
@@ -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<string, unknown> = {}) {
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
): 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<string, unknown>,
|
||||
spawn: Record<string, unknown> | 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
|
||||
}
|
||||
@@ -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<AiVaultSession | null> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): 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, unknown>): string | null {
|
||||
return (
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user