Merge branch 'main' into brennanb2025/unify-agent-session-launch

This commit is contained in:
Merge Sim
2026-09-09 01:42:02 -07:00
226 changed files with 12308 additions and 1742 deletions
+3 -2
View File
@@ -13289,7 +13289,7 @@
"https://github.com/stablyai/orca/issues/13821",
"https://github.com/stablyai/orca/issues/14347"
],
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. A successful orchestration.workerStart must durably record exactly one accepted and started turn; a swallowed Enter must fail with agent_prompt_stalled and never trigger a blind rescue Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.",
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Local worker-start with supported observation must preserve an unobserved turn as start_unknown without revoking authority, closing questions, or triggering a rescue Enter; a worker report during observation must settle normally. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.",
"oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, Claude/Codex marker-gated multi-frame renders, and the legacy platform delay for every other configured agent. The candidate resets settlement on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. The worker-start contract drives the production RPC through a delayed fake Codex composer and independently checks exact turn/Enter counts plus reopened SQLite Task, Dispatch, worker receipt, and mutation receipt state for accepted and swallowed outcomes. Other orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
@@ -13339,7 +13339,8 @@
"file": "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts",
"assertions": [
"delayed composer readiness produces exactly one submitted and started turn with no premature Enter and durable ready receipts",
"a swallowed Enter records agent_prompt_stalled across Task, Dispatch, worker, and mutation receipts without a rescue Enter"
"a swallowed Enter durably records start_unknown without a rescue Enter or capability revocation",
"early worker reports settle during observation, and outstanding questions survive observation uncertainty"
]
},
{
@@ -129,6 +129,7 @@ for (const count of [36, 50, 250]) {
const issues = makeJiraIssues(count)
const before = () =>
[...issues]
// oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Baseline measures per-comparison setup against a reused collator.
.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
.map((issue) => issue.key)
const after = () => sortJiraIssues(issues, 'key', 'asc').map((issue) => issue.key)
@@ -142,6 +143,7 @@ for (const count of [36, 50, 250]) {
for (const count of [10, 50, 250]) {
const values = makeBaseSensitivityValues(count)
const before = () =>
// oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Baseline measures per-comparison setup against a reused collator.
[...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
const after = () => [...values].sort(compareBaseSensitivityLocaleText)
assertSameOrder(before, after, `base ${count}`)
@@ -117,7 +117,7 @@ function blockContent(message: NativeChatMessage): string {
if (block.type === 'tool-result') {
return block.output
}
return block.path ?? block.url ?? block.alt ?? ''
return block.type === 'image-ref' ? (block.path ?? block.url ?? block.alt ?? '') : block.groupId
}
function messageWeight(message: NativeChatMessage, content: string): number {
+46
View File
@@ -0,0 +1,46 @@
# Structured worktree status validation
Validated on September 7, 2026 in a background Electron dev instance of
`pr19217-review-r2`, based on `ce1024096b` with the source-adapter refactor.
CDP app identity confirmed the checkout; screenshots show the full hidden renderer.
The command output is the real `orca worktree ps --json` response reduced to status,
agent state, provider, and pane key for readability.
## Functional correctness
A real Codex structured session appeared as `working` in `worktree.ps` while the
sidebar showed working. Closing its chat tab removed that exact session's row and
returned the worktree to `active`. A different completed chat remained present,
confirming that closure removed only the selected session.
- [Working: CLI and sidebar](working.png)
- [Closed: CLI and sidebar](closed.png)
The disappearing session is `codex_40677067_f492_4d7d_86dd_ec566ede04c3`.
The host's held-session roster controls eligibility; its retained broadcast cache
is history, not a roster. Failed eviction intentionally keeps an entry for retry.
## Architecture
PTY reconciliation and process admission belong to the PTY source adapter.
Structured input comes from the current host's held-session projections. One
admitted collection feeds row shaping and worktree aggregation, with no structured
boolean bypass. PTY hooks and retained reports still arrive independently, so their
precedence and conservative remote evidence rules remain necessary. No second
persistent status store or provider polling was introduced.
## Validation and limits
Independent final review found no proven issues. Runtime, host lifecycle, status
feed and source-admission suites passed: 1,344 tests, one skipped. Node typecheck,
targeted lint and diff checks passed. Ablating the runtime call to enumerate
retained history caused the executable call-site test to fail with two rows where
one was expected; restoring the live accessor passed both call-site tests.
Live screenshots prove Codex working and closure on macOS. Claude provider turns,
approval/input states, live Windows/Linux/WSL/SSH/relay/mobile scenarios and
release-scale latency/heap measurements remain unverified. Existing tests cover
remote/WSL evidence, monitoring precedence and lifecycle cases. The existing
30-minute freshness rule and CLI activity timestamps are preserved; complete
CLI/sidebar timing parity is not claimed. The wire keeps its existing row shape
and status vocabulary; mobile receives the new rows without a new opcode.
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

@@ -104,6 +104,34 @@ describe('orchestration worker-start CLI contract', () => {
expect(process.exitCode).toBeUndefined()
})
it.each(['succeeded', 'failed'])(
'accepts a successful start whose task already %s',
async (workerOutcome) => {
const receipt = {
taskId: 'task_1',
dispatchId: 'ctx_1',
state: 'ready',
stage: 'settled',
workerOutcome,
effects: [],
residualResources: []
}
callMock.mockResolvedValue({ result: receipt })
await invokeWorkerStart(
new Map([
['task', 'task_1'],
['from', 'term_coord']
])
)
expect(process.exitCode).toBeUndefined()
expect(printResult).toHaveBeenCalledWith(
expect.objectContaining({ result: receipt }),
true,
expect.any(Function)
)
}
)
it('capability-gates and forwards per-invocation launch preferences', async () => {
callMock
.mockResolvedValueOnce({
@@ -18,6 +18,7 @@ import { launchOrcaApp } from './launch'
import { addEnvironmentFromPairingCode } from './environments'
import { RuntimeClientError } from './types'
import {
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
@@ -70,6 +71,7 @@ describe('CLI remote WebSocket transport', () => {
expect(runtime.authFrames).toContainEqual(
expect.objectContaining({
clientCapabilities: [
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
+12 -2
View File
@@ -13,7 +13,13 @@ export async function statRemoteSessionFile(
agent: AiVaultAgent,
executionHostId: ExecutionHostId,
issues: AiVaultScanIssue[],
options?: { missingIsExpected?: boolean; signal?: AbortSignal }
options?: {
missingIsExpected?: boolean
signal?: AbortSignal
// Lets a caller tell a missing path from a failed stat, which both report
// as null; the issue is recorded either way before this rethrows.
rethrowFailures?: boolean
}
): Promise<FileWithMtime | null> {
try {
throwIfAiVaultScanCancelled(options?.signal)
@@ -31,7 +37,8 @@ export async function statRemoteSessionFile(
}
} catch (error) {
throwIfAiVaultScanCancelled(options?.signal)
if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) {
const missing = isMissingRemoteSessionPathError(error)
if (!options?.missingIsExpected || !missing) {
recordSessionScanIssue(issues, {
executionHostId,
agent,
@@ -39,6 +46,9 @@ export async function statRemoteSessionFile(
message: errorMessage(error)
})
}
if (options?.rethrowFailures && !missing) {
throw error
}
return null
}
}
@@ -1,5 +1,6 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types'
import { sidecarUnchanged, type SessionSidecarObservation } from './session-sidecar-stat'
// Matches the local scanner's cap. The relay sidecar is forked with
// --max-old-space-size=384, and a retained session row is a title, a preview
@@ -11,6 +12,7 @@ type RemoteSessionParseCacheEntry = {
mtimeMs: number
sizeBytes: number | null
hostKey: string
sidecar?: SessionSidecarObservation
session: AiVaultSession | null
}
@@ -55,13 +57,15 @@ function storeEntry(path: string, entry: RemoteSessionParseCacheEntry): void {
* (#13753). The local scanner has had `parseAgentSessionFileCached` for exactly
* this reason; this is its remote counterpart.
*
* `(mtimeMs, sizeBytes)` is a sound validity key here because discovery already
* folds a source's `contentDependencyPath` stat into both fields
* (remote-session-scanner-discovery.ts), so a metadata-only transcript whose
* companion file changed still looks changed. Sources whose parse reads a file
* discovery does not stat — Codex looks its title up in `session_index.jsonl` —
* are not covered by that key and pass `refreshReusedSession` to re-derive the
* uncovered part without touching the transcript.
* `(mtimeMs, sizeBytes)` covers the transcript, and the sidecar observation
* discovery records beside it (remote-session-scanner-discovery.ts) covers a
* source's companion file, so a metadata-only transcript whose companion
* changed still looks changed. Remote Cline is the only such source; remote
* Cursor streams transcript content with no sibling to read. Sources whose
* parse reads a file discovery does not stat — Codex looks its title up in
* `session_index.jsonl` — are not covered by either and pass
* `refreshReusedSession` to re-derive the uncovered part without touching the
* transcript.
*
* Only a completed parse is stored. A read that threw stays uncached so a
* transient filesystem failure cannot pin a wrong answer for the corpus's life.
@@ -80,7 +84,10 @@ export async function parseRemoteSessionFileCached(args: {
entry !== undefined &&
entry.hostKey === args.hostKey &&
entry.mtimeMs === file.mtimeMs &&
(entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes)
(entry.sizeBytes === null ||
file.sizeBytes === undefined ||
entry.sizeBytes === file.sizeBytes) &&
sidecarUnchanged(entry.sidecar, file.sidecar)
if (unchanged) {
if (args.stats) {
args.stats.reused++
@@ -101,6 +108,7 @@ export async function parseRemoteSessionFileCached(args: {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
hostKey: args.hostKey,
sidecar: file.sidecar,
session
})
return session
@@ -4,6 +4,7 @@ import type { ExecutionHostId } from '../../shared/execution-host'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote-session-file-stat'
import type { FileWithMtime } from './session-scanner-types'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import { errorMessage } from './session-scanner-values'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
@@ -61,23 +62,38 @@ async function statRemoteCandidateFile(
if (!file || !source.contentDependencyPath) {
return file
}
const dependency = await statRemoteSessionFile(
context.provider,
source.contentDependencyPath(path),
source.agent,
context.executionHostId,
issues,
{ missingIsExpected: true, signal: context.signal }
)
if (!dependency) {
return file
}
const mtimeMs = Math.max(file.mtimeMs, dependency.mtimeMs)
return {
...file,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString(),
sizeBytes: (file.sizeBytes ?? 0) + (dependency.sizeBytes ?? 0)
const sidecarPath = source.contentDependencyPath(path)
// Recorded beside the transcript's own stat, never folded into it: one key
// cannot mean both "the transcript grew" and "the sibling changed".
return { ...file, sidecar: await observeRemoteSidecar(source, context, sidecarPath, issues) }
}
/**
* A stat that failed for any reason other than a missing path is `'unknown'`,
* not `'none'`: serving the cached session over an unreadable sibling would
* publish metadata nobody can currently see. `statRemoteSessionFile` already
* recorded the issue for the failure.
*/
async function observeRemoteSidecar(
source: RemoteSessionSource,
context: RemoteScannerContext,
sidecarPath: string,
issues: AiVaultScanIssue[]
): Promise<SessionSidecarObservation> {
try {
const sidecar = await statRemoteSessionFile(
context.provider,
sidecarPath,
source.agent,
context.executionHostId,
issues,
{ missingIsExpected: true, signal: context.signal, rethrowFailures: true }
)
return sidecar
? { path: sidecarPath, mtimeMs: sidecar.mtimeMs, sizeBytes: sidecar.sizeBytes ?? 0 }
: 'none'
} catch {
return 'unknown'
}
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
import { resetRemoteSessionParseCacheForTests } from './remote-session-parse-cache'
describe('remote sidecar observations', () => {
const sessionId = '1786466194549_sidecar'
const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}`
const messagesPath = `${sessionDir}/${sessionId}.messages.json`
function addCline(provider: MemoryRemoteProvider, firstPrompt: string): void {
provider.addFile(
`${sessionDir}/${sessionId}.json`,
JSON.stringify({
version: 1,
session_id: sessionId,
started_at: '2026-08-11T16:36:34.551Z',
cwd: '/home/ada/repo'
}),
10
)
provider.addFile(
messagesPath,
JSON.stringify({
version: 1,
updated_at: '2026-08-11T16:38:00.000Z',
sessionId,
messages: [{ role: 'user', content: [{ type: 'text', text: firstPrompt }] }]
}),
11
)
}
const scan = (provider: MemoryRemoteProvider): ReturnType<typeof scanRemoteAiVaultSessions> =>
scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
it('re-parses when a messages-file stat fails rather than serving the cached row', async () => {
resetRemoteSessionParseCacheForTests()
const provider = new MemoryRemoteProvider()
addCline(provider, 'first prompt')
expect((await scan(provider)).sessions[0]).toMatchObject({ title: 'first prompt' })
// The sidecar changed underneath, and its stat now fails for a reason that
// is not "missing": nothing about it may be assumed, so the row is re-read.
addCline(provider, 'second prompt')
provider.failStat(
messagesPath,
Object.assign(new Error('permission denied'), { code: 'EACCES' })
)
const refused = await scan(provider)
expect(refused.sessions[0]).toMatchObject({ title: 'second prompt' })
expect(refused.issues.map((issue) => issue.path)).toContain(messagesPath)
})
it('treats a genuinely missing messages file as no sidecar, not as unknown', async () => {
resetRemoteSessionParseCacheForTests()
const provider = new MemoryRemoteProvider()
provider.addFile(
`${sessionDir}/${sessionId}.json`,
JSON.stringify({
version: 1,
session_id: sessionId,
started_at: '2026-08-11T16:36:34.551Z',
cwd: '/home/ada/repo'
}),
10
)
const first = await scan(provider)
const second = await scan(provider)
expect(first.issues).toEqual([])
expect(second.issues).toEqual([])
expect(second.sessions[0]?.sessionId).toBe(sessionId)
})
})
@@ -0,0 +1,31 @@
import { expect, it } from 'vitest'
import { SessionNewestFiles } from './session-newest-files'
import type { FileWithMtime } from './session-scanner-types'
function file(i: number): FileWithMtime {
const mtimeMs = (i * 7919) % 997
return { path: String(i), mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() }
}
it('retains at most 12 of 100,000 candidates with stable newest-first ties', () => {
const all = Array.from({ length: 100_000 }, (_, i) => file(i))
const retained = new SessionNewestFiles(12)
let peak = 0
for (const candidate of all) {
retained.add(candidate)
peak = Math.max(peak, retained.size)
}
expect(peak).toBe(12)
expect(retained.newest()).toEqual(all.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, 12))
})
it('supports full backfill and empty requests', () => {
const all = new SessionNewestFiles(Infinity)
const none = new SessionNewestFiles(0)
for (let i = 0; i < 100; i++) {
all.add(file(i))
none.add(file(i))
}
expect(all.newest()).toHaveLength(100)
expect(none.newest()).toEqual([])
})
+50
View File
@@ -0,0 +1,50 @@
import type { FileWithMtime } from './session-scanner-types'
/** Retain only the requested newest files, preserving traversal order on ties. */
export class SessionNewestFiles {
private readonly files: FileWithMtime[] = []
private readonly limit: number
constructor(limit: number) {
this.limit = Math.max(0, Math.trunc(limit) || 0)
}
add(file: FileWithMtime): void {
// The backfill enumerates with no limit; skip the insert search entirely.
if (!Number.isFinite(this.limit)) {
this.files.push(file)
return
}
if (this.limit <= 0) {
return
}
const last = this.files.at(-1)
if (this.files.length >= this.limit && last && file.mtimeMs <= last.mtimeMs) {
return
}
let low = 0
let high = this.files.length
while (low < high) {
const middle = (low + high) >>> 1
if (this.files[middle].mtimeMs >= file.mtimeMs) {
low = middle + 1
} else {
high = middle
}
}
this.files.splice(low, 0, file)
if (this.files.length > this.limit) {
this.files.pop()
}
}
get size(): number {
return this.files.length
}
/** The unbounded path appends in traversal order, so the sort is not redundant. */
newest(): FileWithMtime[] {
return [...this.files].sort((a, b) => b.mtimeMs - a.mtimeMs)
}
}
@@ -29,9 +29,12 @@ import {
parseAgentSessionFileCached,
resetSessionParseCacheForTests,
seedSessionParseCache,
snapshotSessionParseCacheForPersistence,
type PersistedSessionParseCacheEntry,
type SessionParseStats
} from './session-scanner-parse-cache'
import { getSessionParseCacheEntry } from './session-parse-cache-store'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import { isolatedScanRoots } from './session-scanner-test-fixtures'
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types'
@@ -526,3 +529,38 @@ describe('session parse cache persistence', () => {
debugSpy.mockRestore()
})
})
describe('sidecar observations survive the round trip', () => {
const OBSERVATIONS: [string, SessionSidecarObservation | undefined][] = [
['an object', { path: '/chats/a/meta.json', mtimeMs: 42, sizeBytes: 7 }],
['none', 'none'],
['unknown', 'unknown'],
['absent', undefined]
]
it.each(OBSERVATIONS)('restores %s exactly', async (_label, sidecar) => {
const root = await makeTempDir()
const cacheFile = join(root, 'session-parse-cache.json')
const path = await writeTranscript(root)
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
await ensureSessionParseCacheLoaded()
const stats = createSessionParseStats()
await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats)
const seeded = snapshotSessionParseCacheForPersistence().map(
([entryPath, entry]): [string, PersistedSessionParseCacheEntry] => [
entryPath,
sidecar === undefined ? entry : { ...entry, sidecar }
]
)
resetSessionParseCacheForTests()
seedSessionParseCache(seeded)
scheduleSessionParseCachePersist(stats)
await flushSessionParseCachePersistForTests()
simulateRestart(cacheFile)
await ensureSessionParseCacheLoaded()
expect(getSessionParseCacheEntry(path)?.sidecar).toEqual(sidecar)
})
})
@@ -11,6 +11,7 @@ import {
type PersistedSessionParseCacheEntry,
type SessionParseStats
} from './session-scanner-parse-cache'
import type { SessionSidecarObservation } from './session-sidecar-stat'
// Bump when the persisted entry layout or cached session semantics change; a
// mismatched file is discarded whole.
@@ -181,17 +182,37 @@ function parsePersistedEntry(item: unknown): [string, PersistedSessionParseCache
if (entry.session !== null && typeof entry.session !== 'object') {
return null
}
const sidecar = parsePersistedSidecar(entry.sidecar)
return [
path,
{
mtimeMs: entry.mtimeMs,
sizeBytes: entry.sizeBytes,
platform: entry.platform as NodeJS.Platform,
session: entry.session as PersistedSessionParseCacheEntry['session']
session: entry.session as PersistedSessionParseCacheEntry['session'],
...(sidecar === undefined ? {} : { sidecar })
}
]
}
// Why: added after SCHEMA_VERSION 2 shipped, so a file an older build wrote has
// no such field. Absent (or unreadable) means unknown, which costs one re-parse
// of the rows that have a sibling and nothing at all for the rest.
function parsePersistedSidecar(value: unknown): SessionSidecarObservation | undefined {
if (value === 'none' || value === 'unknown') {
return value
}
if (typeof value !== 'object' || value === null) {
return undefined
}
const record = value as Record<string, unknown>
return typeof record.path === 'string' &&
typeof record.mtimeMs === 'number' &&
typeof record.sizeBytes === 'number'
? { path: record.path, mtimeMs: record.mtimeMs, sizeBytes: record.sizeBytes }
: undefined
}
async function persistSnapshot(current: SessionParseCachePersistenceOptions): Promise<void> {
const directory = dirname(current.filePath)
const tempPath = join(directory, `session-parse-cache-${process.pid}-${Date.now()}.tmp`)
@@ -0,0 +1,111 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ResumableSessionParseState } from './session-scanner-types'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import type { TranscriptMessageChannel } from './session-transcript-channel'
// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a
// full steady-state result set stays resident between forced rescans.
const MAX_CACHE_ENTRIES = 4096
export type SessionParseResumePoint = {
state: ResumableSessionParseState
// Byte offset just past the last complete ('\n'-terminated) line consumed;
// a trailing unterminated line is deliberately left before this point.
byteOffset: number
// Bound to the cached state, which keeps the reference its parsers were built
// with; a resumed read re-points this channel instead of replacing it.
channel: TranscriptMessageChannel
}
export type SessionParseCacheEntry = {
mtimeMs: number
sizeBytes: number | null
platform: NodeJS.Platform
session: AiVaultSession | null
// What the sibling file looked like when `session` was built. Tracked apart
// from the transcript's key so each can go stale on its own.
sidecar?: SessionSidecarObservation
// The session the transcript alone produced, before any sibling was merged
// onto it. In-memory only: without it a sibling change costs one re-parse.
foldSession?: AiVaultSession | null
resume: SessionParseResumePoint | null
}
const cache = new Map<string, SessionParseCacheEntry>()
export function resetSessionParseCacheForTests(): void {
cache.clear()
}
// Drops one entry after its file is deleted. Cleanliness, not correctness:
// discovery walks disk first, so a trashed file is never rediscovered anyway.
export function invalidateSessionParseCacheEntry(path: string): void {
cache.delete(path)
}
// Persisted subset of a cache entry: the non-serializable `resume` parser
// state is dropped, and `foldSession` with it, so a restart pays one re-parse
// for a session whose sibling moved rather than storing every row twice
// (see session-parse-cache-persistence.ts).
export type PersistedSessionParseCacheEntry = Omit<SessionParseCacheEntry, 'resume' | 'foldSession'>
export function snapshotSessionParseCacheForPersistence(): [
string,
PersistedSessionParseCacheEntry
][] {
return [...cache].map(([path, entry]): [string, PersistedSessionParseCacheEntry] => [
path,
{
mtimeMs: entry.mtimeMs,
sizeBytes: entry.sizeBytes,
platform: entry.platform,
session: entry.session,
...(entry.sidecar === undefined ? {} : { sidecar: entry.sidecar })
}
])
}
// Seeded entries carry `resume: null`: after a restart an unchanged file is a
// cache hit; a file that changed while the app was closed pays one full
// (not incremental) re-parse.
export function seedSessionParseCache(
entries: Iterable<[string, PersistedSessionParseCacheEntry]>
): void {
const list = [...entries]
// Snapshot order is oldest→newest (LRU); an over-cap list keeps the newest
// tail rather than seeding the oldest entries and dropping the tail.
for (const [path, entry] of list.slice(Math.max(0, list.length - MAX_CACHE_ENTRIES))) {
if (cache.size >= MAX_CACHE_ENTRIES) {
return
}
// In-process entries are always fresher than persisted ones; never clobber.
if (cache.has(path)) {
continue
}
cache.set(path, {
mtimeMs: entry.mtimeMs,
sizeBytes: entry.sizeBytes,
platform: entry.platform,
session: entry.session,
// Absent in files an older build wrote; `sidecarUnchanged` reads that as
// unknown, so such a row re-enriches on its first scan.
sidecar: entry.sidecar,
resume: null
})
}
}
export function getSessionParseCacheEntry(path: string): SessionParseCacheEntry | undefined {
return cache.get(path)
}
export function storeSessionParseCacheEntry(path: string, entry: SessionParseCacheEntry): void {
cache.delete(path)
cache.set(path, entry)
if (cache.size > MAX_CACHE_ENTRIES) {
const oldest = cache.keys().next()
if (!oldest.done) {
cache.delete(oldest.value)
}
}
}
@@ -0,0 +1,28 @@
const pending = new Map<string, Promise<unknown>>()
/**
* Serializes parses of one transcript path.
*
* Two callers really do overlap on the same file: a forced refresh aborts the
* running scan while its in-flight parse keeps going as the replacement scan
* starts it again, and `session-title-file-reader.ts` parses on its own, with
* no scan involved. Overlapping reads share the cached resume point's message
* channel, so the second `beginRead` would drop the first read's consumers and
* the first `finishRead` would hand them the wrong outcome; the later store
* could also move the cursor backwards.
*/
export async function inSessionParseFileLane<T>(path: string, parse: () => Promise<T>): Promise<T> {
const previous = pending.get(path)
const run = (async () => {
await previous?.catch(() => undefined)
return parse()
})()
pending.set(path, run)
try {
return await run
} finally {
if (pending.get(path) === run) {
pending.delete(path)
}
}
}
@@ -23,6 +23,12 @@ import {
normalizePreviewText,
timestampMs
} from './session-scanner-values'
import { NO_TRANSCRIPT_MESSAGES, type TranscriptMessageSink } from './session-transcript-consumers'
import {
boundedText,
transcriptMessageRole,
transcriptMessagesFromContent
} from './session-transcript-message-content'
const SESSION_PREVIEW_MESSAGE_LIMIT = 5
@@ -30,9 +36,12 @@ export function createAccumulator(args: {
agent: AiVaultAgent
file: FileWithMtime
sessionId: string
// Where every decoded message goes; absent for one-shot parses with no reader.
messages?: TranscriptMessageSink
}): SessionAccumulator {
return {
agent: args.agent,
messages: args.messages ?? NO_TRANSCRIPT_MESSAGES,
sessionId: args.sessionId,
title: null,
fallbackTitle: null,
@@ -75,6 +84,9 @@ export function accumulatorFoldResumeState(
},
// Finalize a snapshot: the live accumulator (and its preview array) keeps
// accumulating appended lines after this session object is handed out.
// A sibling file's metadata is merged onto this result by the parse cache,
// never into the fold, so re-merging it later starts from what the
// transcript alone said (see session-scanner-sidecar-enrichment.ts).
finalize: (platform, options) =>
finalizeSession(cloneSessionAccumulator(accumulator), platform, options)
}
@@ -96,7 +108,7 @@ export function finalizeSession(
const title =
accumulator.title ||
accumulator.fallbackTitle ||
`${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}`
generatedSessionTitle(accumulator.agent, sessionId)
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
@@ -137,6 +149,15 @@ export function finalizeSession(
}
}
/**
* The title a session gets when neither the transcript nor the agent named it.
* Exported so a later merge can tell "the fold found no title" from a real one
* without re-deriving the string (session-scanner-sidecar-enrichment.ts).
*/
export function generatedSessionTitle(agent: AiVaultAgent, sessionId: string): string {
return `${aiVaultAgentLabel(agent)} ${sessionId.slice(0, 8)}`
}
export function updateTimeline(accumulator: SessionAccumulator, timestamp: unknown): void {
const parsed = timestampMs(timestamp)
if (!Number.isFinite(parsed)) {
@@ -161,8 +182,13 @@ export function addPreviewMessage(
// Why: Claude meta/injected turns still preview, but must not seed the
// copyable first-prompt row.
seedFirstUserPrompt?: boolean
// Set false by callers that already published this record's messages.
publishMessage?: boolean
}
): void {
if (args.publishMessage !== false && accumulator.messages.active) {
publishTranscriptMessage(accumulator, args.role, args.text, args.timestamp)
}
// Seeded before the preview-empty return so the copy body never depends on
// preview-only normalization rules.
seedFullFirstUserPrompt(
@@ -199,15 +225,41 @@ export function addPreviewContent(
() => extractFullFirstUserPromptText(content),
options?.seedFirstUserPrompt
)
// Published from the content value, not the preview string: a consumer needs
// the whole turn, including the tool blocks the 220-char preview drops.
if (accumulator.messages.active) {
for (const message of transcriptMessagesFromContent(role, content, timestampIso(timestamp))) {
accumulator.messages.push(message)
}
}
addPreviewMessage(accumulator, {
role,
text: extractPreviewContentText(content),
timestamp,
// Content path already seeded above when capture is enabled.
seedFirstUserPrompt: false
seedFirstUserPrompt: false,
publishMessage: false
})
}
/** One already-flattened turn; the content path publishes per block instead. */
function publishTranscriptMessage(
accumulator: SessionAccumulator,
role: AiVaultSessionPreviewMessage['role'],
text: string | null,
timestamp: unknown
): void {
const messageRole = transcriptMessageRole(role)
const messageText = text === null ? null : boundedText(text)
if (messageRole && messageText) {
accumulator.messages.push({
role: messageRole,
text: messageText,
timestamp: timestampIso(timestamp)
})
}
}
/**
* Seed the copyable first prompt from the first real user turn. `fullText` is a
* thunk so list scans (capture mode `none`) never pay the extraction cost.
@@ -6,7 +6,10 @@ import { parseClineSessionFile } from './session-scanner-cline-parser'
import { parseGrokSessionFile } from './session-scanner-grok-parser'
import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import {
looksLikeOpenCodeSqliteCandidate,
splitOpenCodeSqliteCandidate
} from './session-scanner-opencode-sqlite-paths'
import { parseOpenCodeSqliteSessionViaWorker } from './session-scanner-opencode-sqlite-worker-spawn'
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
import { parseGeminiSessionFile } from './session-scanner-gemini-parsers'
@@ -16,6 +19,16 @@ import { parseCursorSessionFile } from './session-scanner-cursor-parser'
import { parseHermesSessionFile } from './session-scanner-hermes-parser'
import { parseOpenCodeSessionFile } from './session-scanner-opencode-parser'
import type { SessionFileCandidate } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
/**
* False when a parser decodes its messages somewhere the channel cannot reach.
* OpenCode's SQLite sessions are read on a worker thread, so their messages
* never come back over the sink and the read must not be reported as complete.
*/
export function parserPublishesMessages(candidate: SessionFileCandidate): boolean {
return candidate.agent !== 'opencode' || !looksLikeOpenCodeSqliteCandidate(candidate.file.path)
}
/**
* Parse a single agent session file into an `AiVaultSession`. Routes to the
@@ -24,25 +37,33 @@ import type { SessionFileCandidate } from './session-scanner-types'
* `parseOpenCodeSqliteSession` instead of the legacy JSON parser.
* @param candidate - The session file candidate to parse.
* @param platform - The platform to use for resume command generation.
* @param messages - Where the parser publishes every decoded message.
* @returns The parsed `AiVaultSession`, or `null` if parsing fails.
*/
export async function parseAgentSessionFile(
candidate: SessionFileCandidate,
platform: NodeJS.Platform
platform: NodeJS.Platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
switch (candidate.agent) {
case 'claude':
return parseClaudeSessionFile(candidate.file, platform)
return parseClaudeSessionFile(candidate.file, platform, messages)
case 'codex':
return parseCodexSessionFile(candidate.file, platform, candidate.codexHome)
return parseCodexSessionFile(
candidate.file,
platform,
candidate.codexHome,
undefined,
messages
)
case 'gemini':
return parseGeminiSessionFile(candidate.file, platform)
return parseGeminiSessionFile(candidate.file, platform, messages)
case 'antigravity':
return parseAntigravitySessionFile(candidate.file, platform)
return parseAntigravitySessionFile(candidate.file, platform, messages)
case 'copilot':
return parseCopilotSessionFile(candidate.file, platform)
return parseCopilotSessionFile(candidate.file, platform, messages)
case 'cursor':
return parseCursorSessionFile(candidate.file, platform)
return parseCursorSessionFile(candidate.file, platform, messages)
case 'opencode': {
// Why: OpenCode 1.17.x sessions are read from SQLite via a synthetic
// <dbPath>#<sessionId> candidate path. Legacy file-based sessions use
@@ -55,29 +76,29 @@ export async function parseAgentSessionFile(
platform
})
}
return parseOpenCodeSessionFile(candidate.file, platform)
return parseOpenCodeSessionFile(candidate.file, platform, messages)
}
case 'grok':
return parseGrokSessionFile(candidate.file, platform)
return parseGrokSessionFile(candidate.file, platform, messages)
case 'hermes':
return parseHermesSessionFile(candidate.file, platform)
return parseHermesSessionFile(candidate.file, platform, messages)
case 'rovo':
return parseRovoSessionFile(candidate.file, platform)
return parseRovoSessionFile(candidate.file, platform, messages)
case 'openclaw':
return parseMessageGraphSessionFile('openclaw', candidate.file, platform)
return parseMessageGraphSessionFile('openclaw', candidate.file, platform, messages)
case 'pi':
return parseMessageGraphSessionFile('pi', candidate.file, platform)
return parseMessageGraphSessionFile('pi', candidate.file, platform, messages)
case 'omp':
return parseMessageGraphSessionFile('omp', candidate.file, platform)
return parseMessageGraphSessionFile('omp', candidate.file, platform, messages)
case 'prime-agent':
return parseMessageGraphSessionFile('prime-agent', candidate.file, platform)
return parseMessageGraphSessionFile('prime-agent', candidate.file, platform, messages)
case 'droid':
return parseDroidSessionFile(candidate.file, platform)
return parseDroidSessionFile(candidate.file, platform, messages)
case 'cline':
return parseClineSessionFile(candidate.file, platform)
return parseClineSessionFile(candidate.file, platform, messages)
case 'devin':
return parseDevinSessionFile(candidate.file, platform)
return parseDevinSessionFile(candidate.file, platform, messages)
case 'kimi':
return parseKimiSessionFile(candidate.file, platform)
return parseKimiSessionFile(candidate.file, platform, messages)
}
}
@@ -8,6 +8,7 @@ import {
clineMessagesPathForMetadata,
isClineSessionMetadataPath
} from './session-scanner-cline-parser'
import { cursorChatMetaPath } from './session-scanner-cursor-chat-meta'
import { resolveKimiSessionsDir } from './session-scanner-kimi-paths'
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts'
import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots'
@@ -61,8 +62,9 @@ export type AiVaultAgentSource = {
rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[]
extensions: readonly string[]
filePredicate?: (filePath: string) => boolean
// A sibling whose stat participates in candidate freshness and recency.
contentDependencyPath?: (filePath: string) => string
// A sibling whose stat participates in candidate freshness and recency; async
// for agents that have to look the sibling up rather than derive its path.
contentDependencyPath?: (filePath: string) => string | undefined | Promise<string | undefined>
// Return false to skip a directory; depth 0 is a child of the root.
directoryPredicate?: (name: string, depth: number) => boolean
// Roots that are alternates for one install rather than distinct locations,
@@ -126,7 +128,8 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
'projects'
]),
extensions: ['.jsonl'],
filePredicate: (filePath) => pathSegments(filePath).includes('agent-transcripts')
filePredicate: (filePath) => pathSegments(filePath).includes('agent-transcripts'),
contentDependencyPath: cursorChatMetaPath
},
grok: {
rootDirs: (options, wslHomeDirs) =>
@@ -15,6 +15,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
type ParserSessionOptions = {
@@ -24,12 +25,13 @@ type ParserSessionOptions = {
export async function parseAntigravitySessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
return await parseAntigravitySessionLines({ file, lines, platform })
return await parseAntigravitySessionLines({ file, lines, platform, messages })
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-parse throw cannot leak the gated transcript handle.
@@ -54,13 +56,14 @@ export async function parseAntigravitySessionContent(
}
export function createAntigravitySessionResumeState(
file: FileWithMtime
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
const sessionId = antigravityConversationIdFromTranscriptPath(file.path) ?? ''
// Why: the transcript has no cwd/model fields. Workspace enrichment is a
// separate, conservative history join; protobuf/SQLite blobs are unstable.
return accumulatorFoldResumeState(
createAccumulator({ agent: 'antigravity', file, sessionId }),
createAccumulator({ agent: 'antigravity', file, sessionId, messages }),
consumeAntigravityRecordLine
)
}
@@ -70,8 +73,9 @@ async function parseAntigravitySessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createAntigravitySessionResumeState(args.file)
const state = createAntigravitySessionResumeState(args.file, args.messages)
for await (const line of args.lines) {
state.consumeLine(line)
}
@@ -0,0 +1,46 @@
import { readCodexRolloutSessionMetaId } from '../codex/codex-rollout-session-meta'
import { codexRolloutHardlinkIdentity, dedupeCodexRolloutAliases } from './codex-session-root-dedup'
import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths'
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
import { DEFAULT_CODEX_HOME_DIR } from './session-scanner-source-discovery'
import type {
AiVaultScanOptions,
SessionFileCandidate,
SessionFileDiscovery
} from './session-scanner-types'
/** Newest-first parse candidates for a discovery set, with Codex hardlink aliases collapsed. */
export async function sessionCandidatesFromDiscoveries(
discoveries: SessionFileDiscovery[],
options: AiVaultScanOptions
): Promise<SessionFileCandidate[]> {
return dedupeCodexRolloutAliases(
discoveries
.flatMap((discovery) =>
discovery.files.map((file): SessionFileCandidate => ({
agent: discovery.agent,
file,
codexHome:
discovery.agent === 'codex'
? codexHomeForSessionsDir(
discovery.rootDir,
options.defaultCodexHomeDir ?? DEFAULT_CODEX_HOME_DIR
)
: null,
antigravityHistoryPath:
discovery.agent === 'antigravity'
? antigravityHistoryPathForBrainDir(discovery.rootDir)
: undefined
}))
)
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs),
{
isCodex: (candidate) => candidate.agent === 'codex',
getFilePath: (candidate) => candidate.file.path,
getCodexHome: (candidate) => candidate.codexHome,
getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file)
},
(filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'),
options.signal
)
}
@@ -9,6 +9,7 @@ import {
updateTimeline
} from './session-scanner-accumulator'
import type { FileWithMtime } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
arrayValue,
asRecord,
@@ -35,7 +36,8 @@ export function clineMessagesPathForMetadata(filePath: string): string {
export async function parseClineSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messageSink?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const metadataContent = await wslGatedReadFile(file.path, 'utf-8', 'scan')
let messagesContent: string | null = null
@@ -52,7 +54,7 @@ export async function parseClineSessionFile(
throw error
}
}
return parseClineSessionContent(file, metadataContent, messagesContent, platform)
return parseClineSessionContent(file, metadataContent, messagesContent, platform, {}, messageSink)
}
function isMissingSessionPathError(error: unknown): boolean {
@@ -68,7 +70,8 @@ export function parseClineSessionContent(
metadataContent: string,
messagesContent: string | null,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
options: ParserSessionOptions = {},
messageSink?: TranscriptMessageSink
): AiVaultSession | null {
const metadata = parseJsonRecord(metadataContent)
if (!metadata) {
@@ -76,7 +79,12 @@ export function parseClineSessionContent(
}
const pathSegments = file.path.replace(/\\/g, '/').split('/').filter(Boolean)
const sessionId = extractString(metadata.session_id) ?? pathSegments.at(-2) ?? ''
const accumulator = createAccumulator({ agent: 'cline', file, sessionId })
const accumulator = createAccumulator({
agent: 'cline',
file,
sessionId,
messages: messageSink
})
accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root)
accumulator.model = extractString(metadata.model)
updateTimeline(accumulator, metadata.started_at)
@@ -22,6 +22,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
addCodexUsage,
asRecord,
@@ -29,18 +30,22 @@ import {
extractModel,
extractString,
normalizeCodexUsage,
normalizeTitleText,
parseJsonObject,
subtractCodexUsage
} from './session-scanner-values'
import { remoteSessionContentLines } from './remote-session-content-lines'
import { readCodexTimelineOnlyRecord } from './session-scanner-codex-record-fast-path'
import {
extractCodexSessionMetadataTitle,
isCodexWorkerSession
} from './session-scanner-codex-session-meta'
export async function parseCodexSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
codexHome: string | null = null,
executionHostId?: ExecutionHostId
executionHostId?: ExecutionHostId,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
@@ -53,6 +58,7 @@ export async function parseCodexSessionFile(
platform,
codexHome,
executionHostId,
messages,
titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
})
}
@@ -89,12 +95,16 @@ type CodexSessionParseState = {
titleSource: 'meta' | 'user' | null
}
function createCodexParseState(file: FileWithMtime): CodexSessionParseState {
function createCodexParseState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): CodexSessionParseState {
return {
accumulator: createAccumulator({
agent: 'codex',
file,
sessionId: sessionIdFromFileName(file.path)
sessionId: sessionIdFromFileName(file.path),
messages
}),
previousTotals: null,
rejectedWorkerSession: false,
@@ -257,10 +267,13 @@ async function finalizeCodexParseState(
export function createCodexSessionResumeState(
file: FileWithMtime,
codexHome: string | null
codexHome: string | null,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
return codexResumeStateFromParseState(createCodexParseState(file), codexHome, (sessionId) =>
readCodexSessionIndexTitle(file.path, codexHome, sessionId)
return codexResumeStateFromParseState(
createCodexParseState(file, messages),
codexHome,
(sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
)
}
@@ -298,8 +311,9 @@ async function parseCodexSessionLines(args: {
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
titleReader?: (sessionId: string) => Promise<string | null>
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createCodexParseState(args.file)
const state = createCodexParseState(args.file, args.messages)
for await (const line of args.lines) {
consumeCodexRecordLine(state, line)
if (state.rejectedWorkerSession) {
@@ -314,21 +328,3 @@ async function parseCodexSessionLines(args: {
executionHostPlatform: args.executionHostPlatform
})
}
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))
}
function extractCodexSessionMetadataTitle(payload: Record<string, unknown>): string | null {
return (
normalizeTitleText(extractString(payload.title) ?? '') ??
normalizeTitleText(extractString(payload.thread_name) ?? '') ??
normalizeTitleText(extractString(payload.threadName) ?? '')
)
}
@@ -0,0 +1,22 @@
import { asRecord, 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))
}
export function extractCodexSessionMetadataTitle(payload: Record<string, unknown>): string | null {
return (
normalizeTitleText(extractString(payload.title) ?? '') ??
normalizeTitleText(extractString(payload.thread_name) ?? '') ??
normalizeTitleText(extractString(payload.threadName) ?? '')
)
}
@@ -8,6 +8,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
accumulatorFoldResumeState,
addPreviewMessage,
@@ -32,13 +33,14 @@ type ParserSessionOptions = {
export async function parseCopilotSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseCopilotSessionLines({ file, lines, platform })
return parseCopilotSessionLines({ file, lines, platform, messages })
}
export async function parseCopilotSessionContent(
@@ -107,9 +109,17 @@ function consumeCopilotRecordLine(accumulator: SessionAccumulator, line: string)
}
}
export function createCopilotSessionResumeState(file: FileWithMtime): ResumableSessionParseState {
export function createCopilotSessionResumeState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent: 'copilot', file, sessionId: sessionIdFromFileName(file.path) }),
createAccumulator({
agent: 'copilot',
file,
sessionId: sessionIdFromFileName(file.path),
messages
}),
consumeCopilotRecordLine
)
}
@@ -119,8 +129,9 @@ async function parseCopilotSessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createCopilotSessionResumeState(args.file)
const state = createCopilotSessionResumeState(args.file, args.messages)
for await (const line of args.lines) {
state.consumeLine(line)
}
@@ -0,0 +1,552 @@
import { appendFile, mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-error'
// Why: a refused WSL read is the one build failure that must not be cached.
let failNextChatsReaddir = false
let failNextChatsRootReaddir = false
let failMetaJsonReads = false
let failMetaJsonStats: false | true | 'eacces' = false
let chatsRootReads = 0
vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => {
const actual = await importOriginal<typeof WslTranscriptFsAccess>()
return {
...actual,
wslGatedReaddir: (
...args: Parameters<typeof actual.wslGatedReaddir>
): ReturnType<typeof actual.wslGatedReaddir> => {
if (args[0].endsWith('chats')) {
chatsRootReads += 1
if (failNextChatsRootReaddir) {
failNextChatsRootReaddir = false
return Promise.reject(new WslTranscriptFsError('timeout', 'wsl fs timed out'))
}
}
if (failNextChatsReaddir && args[0].includes('workspace-hash')) {
failNextChatsReaddir = false
return Promise.reject(new WslTranscriptFsError('timeout', 'wsl fs timed out'))
}
return actual.wslGatedReaddir(...args)
},
wslGatedReadFile: (
...args: Parameters<typeof actual.wslGatedReadFile>
): ReturnType<typeof actual.wslGatedReadFile> => {
if (failMetaJsonReads && String(args[0]).endsWith('meta.json')) {
return Promise.reject(new WslTranscriptFsError('timeout', 'wsl fs timed out'))
}
return actual.wslGatedReadFile(...args)
},
wslGatedStat: (
...args: Parameters<typeof actual.wslGatedStat>
): ReturnType<typeof actual.wslGatedStat> => {
if (failMetaJsonStats && String(args[0]).endsWith('meta.json')) {
return Promise.reject(
failMetaJsonStats === 'eacces'
? Object.assign(new Error('permission denied'), { code: 'EACCES' })
: new WslTranscriptFsError('timeout', 'wsl fs timed out')
)
}
return actual.wslGatedStat(...args)
}
}
})
import type * as WslTranscriptFsAccess from '../native-chat/wsl-transcript-fs-access'
import {
cursorChatMetaPath,
readCursorChatMeta,
resetCursorChatMetaIndexCacheForTests,
withCursorChatMetaScan
} from './session-scanner-cursor-chat-meta'
import { parseCursorSessionContent } from './session-scanner-cursor-parser'
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import { AI_VAULT_AGENT_SOURCES } from './session-scanner-agent-sources'
import { discoverFiles } from './session-scanner-discovery'
import { scanAiVaultSessions } from './session-scanner'
import {
createSessionParseStats,
parseAgentSessionFileCached,
resetSessionParseCacheForTests,
seedSessionParseCache,
snapshotSessionParseCacheForPersistence,
type SessionParseStats
} from './session-scanner-parse-cache'
import {
getSessionParseCacheEntry,
type PersistedSessionParseCacheEntry
} from './session-parse-cache-store'
import { isolatedScanRoots } from './session-scanner-test-fixtures'
import type { FileWithMtime } from './session-scanner-types'
import type { SessionSidecarStat } from './session-sidecar-stat'
// Cursor's real meta.json keys (~/.cursor/chats/<md5 of cwd>/<uuid>/meta.json, 2026-09).
type CursorMetaFixture = {
schemaVersion: number
createdAtMs: number
updatedAtMs: number
cwd: string
hasConversation: boolean
title?: string
}
const CREATED_AT_MS = 1_787_039_612_017
const UPDATED_AT_MS = 1_787_039_640_532
let tempRoots: string[] = []
afterEach(async () => {
resetCursorChatMetaIndexCacheForTests()
resetSessionParseCacheForTests()
failNextChatsRootReaddir = false
failMetaJsonReads = false
failMetaJsonStats = false
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
async function createCursorHome(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-cursor-chat-meta-'))
tempRoots.push(root)
const cursorHome = join(root, '.cursor')
await mkdir(cursorHome, { recursive: true })
return cursorHome
}
async function writeTranscript(
cursorHome: string,
projectSlug: string,
chatId: string,
lines: string[]
): Promise<string> {
const chatDir = join(cursorHome, 'projects', projectSlug, 'agent-transcripts', chatId)
await mkdir(chatDir, { recursive: true })
const transcriptPath = join(chatDir, `${chatId}.jsonl`)
await writeFile(transcriptPath, lines.map((line) => `${line}\n`).join(''))
return transcriptPath
}
async function writeChatMeta(
cursorHome: string,
workspaceHash: string,
chatId: string,
meta: Partial<CursorMetaFixture> = {}
): Promise<string> {
const chatDir = join(cursorHome, 'chats', workspaceHash, chatId)
await mkdir(chatDir, { recursive: true })
const metaPath = join(chatDir, 'meta.json')
await writeFile(
metaPath,
JSON.stringify({
schemaVersion: 1,
createdAtMs: CREATED_AT_MS,
updatedAtMs: UPDATED_AT_MS,
cwd: '/private/tmp/workspace',
hasConversation: true,
...meta
} satisfies CursorMetaFixture)
)
return metaPath
}
function fileWithMtime(path: string): FileWithMtime {
return { path, mtimeMs: 1, modifiedAt: new Date(1).toISOString() }
}
describe('cursor chat meta', () => {
it('resolves the meta.json under the workspace hash that holds the chat id', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'aa37220647fb7ce5eb044aa4bda60807', 'other-chat')
const metaPath = await writeChatMeta(cursorHome, '96fa26ac0f433670ebec73ecef20b47b', 'chat-1', {
title: 'Shell Command Hostname'
})
const transcriptPath = await writeTranscript(cursorHome, 'private-tmp-workspace', 'chat-1', [])
expect(await cursorChatMetaPath(transcriptPath)).toBe(metaPath)
expect(await readCursorChatMeta(transcriptPath)).toEqual({
title: 'Shell Command Hostname',
cwd: '/private/tmp/workspace',
createdAt: new Date(CREATED_AT_MS).toISOString(),
updatedAt: new Date(UPDATED_AT_MS).toISOString()
})
})
it('re-indexes after a chat appears under an already indexed workspace', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-first')
const firstTranscript = await writeTranscript(cursorHome, 'slug', 'chat-first', [])
expect(await cursorChatMetaPath(firstTranscript)).toBeDefined()
const laterMetaPath = await writeChatMeta(cursorHome, 'workspace-hash', 'chat-later')
const laterTranscript = await writeTranscript(cursorHome, 'slug', 'chat-later', [])
expect(await cursorChatMetaPath(laterTranscript)).toBe(laterMetaPath)
})
it('does not cache a metadata index whose build was refused by the WSL gate', async () => {
const cursorHome = await createCursorHome()
const metaPath = await writeChatMeta(cursorHome, 'workspace-hash', 'chat-refused')
const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-refused', [])
failNextChatsReaddir = true
// A refusal degrades to "no metadata" rather than taking the session down.
await expect(cursorChatMetaPath(transcriptPath)).resolves.toBeUndefined()
// The next scan rebuilds instead of replaying the rejected promise.
await expect(cursorChatMetaPath(transcriptPath)).resolves.toBe(metaPath)
})
it('validates the index once per scan, not once per transcript', async () => {
const cursorHome = await createCursorHome()
const transcripts: string[] = []
for (const chatId of ['chat-a', 'chat-b', 'chat-c']) {
await writeChatMeta(cursorHome, 'workspace-hash', chatId)
transcripts.push(await writeTranscript(cursorHome, 'slug', chatId, []))
}
chatsRootReads = 0
const inScan = await withCursorChatMetaScan(() =>
Promise.all(transcripts.map((path) => cursorChatMetaPath(path)))
)
expect(inScan.every(Boolean)).toBe(true)
expect(chatsRootReads).toBe(1)
// Outside a scan every lookup re-validates, which is what the parse path needs.
chatsRootReads = 0
await Promise.all(transcripts.map((path) => cursorChatMetaPath(path)))
expect(chatsRootReads).toBe(3)
})
it('yields nothing and does not throw when there is no chats tree', async () => {
const cursorHome = await createCursorHome()
const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-orphan', [])
await expect(cursorChatMetaPath(transcriptPath)).resolves.toBeUndefined()
await expect(readCursorChatMeta(transcriptPath)).resolves.toBeNull()
await expect(readCursorChatMeta('/nowhere/near/cursor/chat.jsonl')).resolves.toBeNull()
})
it('yields nothing and does not throw when meta.json is malformed', async () => {
const cursorHome = await createCursorHome()
const chatDir = join(cursorHome, 'chats', 'workspace-hash', 'chat-bad')
await mkdir(chatDir, { recursive: true })
await writeFile(join(chatDir, 'meta.json'), '{ not json')
const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-bad', [])
await expect(readCursorChatMeta(transcriptPath)).resolves.toBeNull()
})
})
async function cursorCandidate(cursorHome: string): Promise<FileWithMtime> {
const issues: AiVaultScanIssue[] = []
const discovery = await discoverFiles({
rootDir: join(cursorHome, 'projects'),
limit: 10,
agent: 'cursor',
issues,
extensions: [...AI_VAULT_AGENT_SOURCES.cursor.extensions],
filePredicate: AI_VAULT_AGENT_SOURCES.cursor.filePredicate,
contentDependencyPath: AI_VAULT_AGENT_SOURCES.cursor.contentDependencyPath
})
return discovery.files[0]
}
/** The production path: the parse cache owns the sidecar merge, not the parser. */
function parseCursorCached(
file: FileWithMtime,
stats: SessionParseStats = createSessionParseStats()
): Promise<{ session: AiVaultSession | null; stats: SessionParseStats }> {
return withCursorChatMetaScan(async () => {
const session = await parseAgentSessionFileCached(
{ agent: 'cursor', file, codexHome: null },
'darwin',
stats
)
return { session, stats }
})
}
async function writeCursorScanFixture(chatIds: string[]): Promise<{
cursorHome: string
scanOptions: ReturnType<typeof isolatedScanRoots> & { cursorProjectsDir: string }
}> {
const cursorHome = await createCursorHome()
for (const chatId of chatIds) {
await writeChatMeta(cursorHome, 'workspace-hash', chatId, { cwd: `/tmp/ws-${chatId}` })
await writeTranscript(cursorHome, 'slug', chatId, [
JSON.stringify({ role: 'user', message: { content: [{ type: 'text', text: chatId }] } })
])
}
const root = join(cursorHome, '..')
return {
cursorHome,
scanOptions: {
...isolatedScanRoots(root),
cursorProjectsDir: join(cursorHome, 'projects')
}
}
}
describe('cursor discovery sidecar observation', () => {
it('records meta.json beside the transcript stat instead of folding it in', async () => {
const cursorHome = await createCursorHome()
const metaPath = await writeChatMeta(cursorHome, 'workspace-hash', 'chat-7')
const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-7', [])
const before = await cursorCandidate(cursorHome)
const future = new Date(Date.now() + 10_000)
await utimes(metaPath, future, future)
const after = await cursorCandidate(cursorHome)
// The transcript's own key is untouched by a sibling rewrite.
const transcriptStat = await stat(transcriptPath)
expect(after.mtimeMs).toBe(transcriptStat.mtimeMs)
expect(after.sizeBytes).toBe(transcriptStat.size)
expect(after.mtimeMs).toBe(before.mtimeMs)
// The sibling is observed separately, and it did move.
expect(before.sidecar).toMatchObject({ path: metaPath })
expect(after.sidecar).toMatchObject({ path: metaPath })
expect((after.sidecar as SessionSidecarStat).mtimeMs).toBeGreaterThan(
(before.sidecar as SessionSidecarStat).mtimeMs
)
})
})
describe('cursor sidecar enrichment', () => {
it('fills cwd, timestamps and title from meta.json', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-2', { title: 'Named From Meta' })
await writeTranscript(cursorHome, 'slug', 'chat-2', [
JSON.stringify({ role: 'assistant', message: { content: 'hello' } })
])
resetSessionParseCacheForTests()
const { session } = await parseCursorCached(await cursorCandidate(cursorHome))
expect(session?.cwd).toBe('/private/tmp/workspace')
expect(session?.title).toBe('Named From Meta')
expect(session?.createdAt).toBe(new Date(CREATED_AT_MS).toISOString())
expect(session?.updatedAt).toBe(new Date(UPDATED_AT_MS).toISOString())
})
it('keeps a transcript title and timestamps over meta.json', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-3', { title: 'Meta Title' })
await writeTranscript(cursorHome, 'slug', 'chat-3', [
JSON.stringify({
role: 'user',
timestamp: '2026-01-01T00:00:00.000Z',
message: { content: 'transcript first prompt' }
})
])
resetSessionParseCacheForTests()
const { session } = await parseCursorCached(await cursorCandidate(cursorHome))
expect(session?.title).toBe('transcript first prompt')
expect(session?.createdAt).toBe('2026-01-01T00:00:00.000Z')
// cwd is never in the transcript, so it still comes from meta.json.
expect(session?.cwd).toBe('/private/tmp/workspace')
})
it('builds the resume command from the meta.json cwd', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-4', { cwd: '/repo/from-meta' })
await writeTranscript(cursorHome, 'slug', 'chat-4', [
JSON.stringify({ role: 'user', message: { content: 'hi' } })
])
resetSessionParseCacheForTests()
const { session } = await parseCursorCached(await cursorCandidate(cursorHome))
expect(session?.resumeCommand).toContain('/repo/from-meta')
})
it('leaves remote content parses to the transcript alone', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-5', { title: 'Meta Title' })
const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-5', [])
const session = await parseCursorSessionContent(
fileWithMtime(transcriptPath),
`${JSON.stringify({ role: 'assistant', message: { content: 'remote' } })}\n`,
'linux'
)
expect(session?.cwd).toBeNull()
expect(session?.title).not.toBe('Meta Title')
})
it('re-enriches without a parse when only the sidecar is rewritten', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-8', { cwd: '/repo/first' })
await writeTranscript(cursorHome, 'slug', 'chat-8', [
JSON.stringify({ role: 'user', message: { content: 'hi' } })
])
resetSessionParseCacheForTests()
await parseCursorCached(await cursorCandidate(cursorHome))
const future = new Date(Date.now() + 10_000)
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-8', { cwd: '/repo/second' })
await utimes(join(cursorHome, 'chats', 'workspace-hash', 'chat-8', 'meta.json'), future, future)
const { session, stats } = await parseCursorCached(await cursorCandidate(cursorHome))
// The transcript is not re-read: the merge runs over the stored fold result.
expect(stats.reused).toBe(1)
expect(stats.fullParses).toBe(0)
expect(stats.incremental).toBe(0)
// A rewritten cwd REPLACES the merged one; `??=` on the cached session could
// never do this, because the cached cwd is already non-null.
expect(session?.cwd).toBe('/repo/second')
expect(session?.resumeCommand).toContain('/repo/second')
})
it('treats a persisted entry with no sidecar as unknown and enriches once', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-9', { cwd: '/repo/persisted' })
await writeTranscript(cursorHome, 'slug', 'chat-9', [
JSON.stringify({ role: 'user', message: { content: 'hi' } })
])
resetSessionParseCacheForTests()
await parseCursorCached(await cursorCandidate(cursorHome))
// What a build older than the sidecar field wrote: no such key.
const persisted = snapshotSessionParseCacheForPersistence().map(
([path, entry]): [string, PersistedSessionParseCacheEntry] => {
const { sidecar: _sidecar, ...rest } = entry
return [path, rest]
}
)
resetSessionParseCacheForTests()
seedSessionParseCache(persisted)
const { session, stats } = await parseCursorCached(await cursorCandidate(cursorHome))
expect(stats.reused).toBe(0)
expect(session?.cwd).toBe('/repo/persisted')
})
})
describe('cursor chat meta scan failures', () => {
it('lists cursor sessions without metadata when the chats tree is refused, then heals', async () => {
const { cursorHome, scanOptions } = await writeCursorScanFixture(['chat-a', 'chat-b'])
resetSessionParseCacheForTests()
failNextChatsRootReaddir = true
const refused = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
const refusedCursor = refused.sessions.filter((session) => session.agent === 'cursor')
expect(refusedCursor).toHaveLength(2)
expect(refusedCursor.map((session) => session.cwd)).toEqual([null, null])
// One issue for the chats root, not one per transcript.
expect(refused.issues).toHaveLength(1)
expect(refused.issues[0].path).toBe(join(cursorHome, 'chats'))
expect(refused.issues[0].agent).toBe('cursor')
const healed = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
expect(healed.issues).toEqual([])
expect(
healed.sessions
.filter((session) => session.agent === 'cursor')
.map((session) => session.cwd)
.sort()
).toEqual(['/tmp/ws-chat-a', '/tmp/ws-chat-b'])
})
it('re-enriches after a refused meta.json read without losing the resume cursor', async () => {
const { scanOptions } = await writeCursorScanFixture(['chat-a'])
resetSessionParseCacheForTests()
failMetaJsonReads = true
const refused = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
const listed = refused.sessions.find((session) => session.agent === 'cursor')
expect(listed?.cwd).toBeNull()
expect(refused.issues).toHaveLength(1)
// The sibling alone is unknown; the transcript's work and its resume point
// are kept, so the next healthy scan merges without re-reading bytes.
const entry = getSessionParseCacheEntry(listed?.filePath ?? '')
expect(entry?.sidecar).toBe('unknown')
expect(entry?.resume).not.toBeNull()
failMetaJsonReads = false
const healed = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
expect(healed.issues).toEqual([])
expect(healed.sessions.find((session) => session.agent === 'cursor')?.cwd).toBe(
'/tmp/ws-chat-a'
)
})
it('treats a local EACCES on the sidecar stat as unknown, not as absent', async () => {
const { scanOptions } = await writeCursorScanFixture(['chat-a'])
resetSessionParseCacheForTests()
// On mac/Linux/Windows the gated stat is a bare fs stat, so a permissions
// failure is not a WslTranscriptFsError and must not read as "no sidecar".
failMetaJsonStats = 'eacces'
const refused = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
const listed = refused.sessions.find((session) => session.agent === 'cursor')
expect(listed?.sessionId).toBeTruthy()
expect(refused.issues).toHaveLength(1)
expect(refused.issues[0].agent).toBe('cursor')
expect(getSessionParseCacheEntry(listed?.filePath ?? '')?.sidecar).toBe('unknown')
failMetaJsonStats = false
const healed = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
expect(healed.issues).toEqual([])
expect(healed.sessions.find((session) => session.agent === 'cursor')?.cwd).toBe(
'/tmp/ws-chat-a'
)
})
it('lists a cursor session whose meta.json stat is refused instead of dropping it', async () => {
const { scanOptions } = await writeCursorScanFixture(['chat-a'])
resetSessionParseCacheForTests()
failMetaJsonStats = true
const refused = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
expect(refused.sessions.filter((session) => session.agent === 'cursor')).toHaveLength(1)
expect(refused.issues).toHaveLength(1)
expect(refused.issues[0].agent).toBe('cursor')
failMetaJsonStats = false
const healed = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
expect(healed.issues).toEqual([])
expect(healed.sessions.find((session) => session.agent === 'cursor')?.cwd).toBe(
'/tmp/ws-chat-a'
)
})
it('reads the chats root once per scan across discovery and parse', async () => {
const { scanOptions } = await writeCursorScanFixture(['chat-a', 'chat-b', 'chat-c'])
resetSessionParseCacheForTests()
chatsRootReads = 0
const result = await scanAiVaultSessions({ ...scanOptions, platform: 'darwin', limit: 20 })
expect(result.sessions.filter((session) => session.agent === 'cursor')).toHaveLength(3)
expect(chatsRootReads).toBe(1)
})
it('resumes an appended transcript after a refused sidecar scan', async () => {
const cursorHome = await createCursorHome()
await writeChatMeta(cursorHome, 'workspace-hash', 'chat-r', { cwd: '/repo/resume' })
const transcriptPath = await writeTranscript(cursorHome, 'slug', 'chat-r', [
JSON.stringify({ role: 'user', message: { content: 'one' } })
])
resetSessionParseCacheForTests()
await parseCursorCached(await cursorCandidate(cursorHome))
await appendFile(
transcriptPath,
`${JSON.stringify({ role: 'user', message: { content: 'two' } })}\n`
)
failMetaJsonReads = true
const { stats: refusedStats } = await parseCursorCached(await cursorCandidate(cursorHome))
expect(refusedStats.incremental).toBe(1)
failMetaJsonReads = false
const { session, stats } = await parseCursorCached(await cursorCandidate(cursorHome))
expect(stats.reused).toBe(1)
expect(stats.fullParses).toBe(0)
expect(session?.cwd).toBe('/repo/resume')
})
})
@@ -0,0 +1,294 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { basename, dirname, join } from 'node:path'
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { timestampIso } from './session-scanner-accumulator'
import { extractString, normalizeTitleText, readJsonObjectIfExists } from './session-scanner-values'
// Cursor keeps a chat's transcript and its metadata in two unrelated trees:
// <cursor>/projects/<slug>/agent-transcripts/<uuid>/<uuid>.jsonl holds the
// messages, while <cursor>/chats/<md5 of cwd>/<uuid>/meta.json holds the cwd,
// title and timestamps. The md5 hashes the very cwd we are looking for, so the
// only way across is an index of the chat directories.
const CURSOR_CHATS_DIR = 'chats'
const CURSOR_CHAT_META_FILE = 'meta.json'
const CURSOR_TRANSCRIPTS_DIR = 'agent-transcripts'
const CURSOR_PROJECTS_DIR = 'projects'
// Why: custom and WSL Cursor homes can vary over a long-lived main process.
const CURSOR_CHAT_META_INDEX_CACHE_MAX = 8
export type CursorChatMeta = {
title: string | null
cwd: string | null
createdAt: string | null
updatedAt: string | null
}
type CursorChatMetaIndexEntry = {
signature: string
metaPathByChatId: Map<string, string>
}
const cursorChatMetaIndexCache = new Map<string, Promise<CursorChatMetaIndexEntry>>()
type CursorChatMetaScan = {
index: Map<string, Promise<Map<string, string>>>
// Chats roots this scan could not read, reported once by the scan owner.
refusals: Map<string, string>
// Transcripts whose own meta.json read was refused, so the metadata merged
// onto them is not what the file on disk says.
refusedTranscripts: Set<string>
}
// Why: validating the module cache costs a readdir of the chats root plus a stat
// per workspace, and it cannot be skipped because the signature is built from
// those stats. Discovery asks once per transcript and finalize asks again, so
// the scope has to span both phases for one scan to see the tree once.
const scanScopedIndex = new AsyncLocalStorage<CursorChatMetaScan>()
export function resetCursorChatMetaIndexCacheForTests(): void {
cursorChatMetaIndexCache.clear()
}
/** Runs one whole scan, discovery and parse; every Cursor transcript in it shares one index read. */
export function withCursorChatMetaScan<T>(fn: () => Promise<T>): Promise<T> {
return scanScopedIndex.run(
{ index: new Map(), refusals: new Map(), refusedTranscripts: new Set() },
fn
)
}
/**
* True when this transcript's own meta.json read was refused, so the caller
* records the sidecar as unknown rather than as the observation discovery made.
* The transcript's own work and its resume point are kept either way.
*/
export function wasCursorChatMetaRefused(transcriptPath: string): boolean {
return scanScopedIndex.getStore()?.refusedTranscripts.has(transcriptPath) ?? false
}
/** Chats roots the current scan was refused, for the caller to report as scan issues. */
export function cursorChatMetaRefusals(): { chatsRoot: string; message: string }[] {
const scan = scanScopedIndex.getStore()
return scan ? [...scan.refusals].map(([chatsRoot, message]) => ({ chatsRoot, message })) : []
}
/** Path a discovery stat can watch so a rewritten meta.json invalidates the parse cache. */
export async function cursorChatMetaPath(transcriptPath: string): Promise<string | undefined> {
const chatsRoot = cursorChatsRootFromTranscriptPath(transcriptPath)
const chatId = cursorChatIdFromTranscriptPath(transcriptPath)
if (!chatsRoot || !chatId) {
return undefined
}
const index = await readCursorChatMetaIndexOncePerScan(chatsRoot)
return index.get(chatId)
}
function readCursorChatMetaIndexOncePerScan(chatsRoot: string): Promise<Map<string, string>> {
const scan = scanScopedIndex.getStore()
if (!scan) {
return readCursorChatMetaIndexOrNone(chatsRoot)
}
let pending = scan.index.get(chatsRoot)
if (!pending) {
pending = readCursorChatMetaIndexOrNone(chatsRoot)
scan.index.set(chatsRoot, pending)
}
return pending
}
/**
* A refused WSL read is not "no chats", but it must not take the transcript
* down with it: before this join a stalled distro could not hide a Cursor
* session at all. Degrade to no metadata for the scan and report the root once.
* The session still lists from its transcript alone, and the sidecar is
* recorded as unknown, so the next healthy scan merges the real metadata in
* without re-reading a byte of the transcript.
*/
async function readCursorChatMetaIndexOrNone(chatsRoot: string): Promise<Map<string, string>> {
try {
return await readCursorChatMetaIndex(chatsRoot)
} catch (error) {
if (!(error instanceof WslTranscriptFsError)) {
throw error
}
recordCursorChatMetaRefusal(chatsRoot, error.message)
return new Map()
}
}
function recordCursorChatMetaRefusal(chatsRoot: string, message: string): void {
const scan = scanScopedIndex.getStore()
if (scan && !scan.refusals.has(chatsRoot)) {
scan.refusals.set(chatsRoot, message)
}
}
export async function readCursorChatMeta(transcriptPath: string): Promise<CursorChatMeta | null> {
const metaPath = await cursorChatMetaPath(transcriptPath)
if (!metaPath) {
return null
}
let record: Record<string, unknown> | null
try {
record = await readJsonObjectIfExists(metaPath)
} catch (error) {
if (!(error instanceof WslTranscriptFsError)) {
throw error
}
// The session still lists, but unlike the index read this transcript's key
// already includes meta.json's stat, so the caller must not cache the
// un-enriched result. One issue per chats root, as for a refused index.
recordCursorChatMetaRefusal(
cursorChatsRootFromTranscriptPath(transcriptPath) ?? metaPath,
error.message
)
scanScopedIndex.getStore()?.refusedTranscripts.add(transcriptPath)
return null
}
if (!record) {
return null
}
return {
title: normalizeTitleText(extractString(record.title) ?? ''),
cwd: extractString(record.cwd),
createdAt: timestampIso(record.createdAtMs),
updatedAt: timestampIso(record.updatedAtMs)
}
}
function cursorChatIdFromTranscriptPath(transcriptPath: string): string | null {
const chatDir = dirname(transcriptPath)
return basename(dirname(chatDir)) === CURSOR_TRANSCRIPTS_DIR ? basename(chatDir) : null
}
function cursorChatsRootFromTranscriptPath(transcriptPath: string): string | null {
let currentDir = dirname(transcriptPath)
while (currentDir && dirname(currentDir) !== currentDir) {
// The chats tree is a sibling of the projects tree, custom Cursor homes included.
if (basename(currentDir) === CURSOR_PROJECTS_DIR) {
return join(dirname(currentDir), CURSOR_CHATS_DIR)
}
currentDir = dirname(currentDir)
}
return null
}
async function readCursorChatMetaIndex(chatsRoot: string): Promise<Map<string, string>> {
let workspaceDirs: string[]
try {
workspaceDirs = (await wslGatedReaddir(chatsRoot, 'scan'))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
} catch (error) {
// Why: a refused WSL read is not "no chats"; letting it through keeps the
// session out of the parse cache instead of caching it without metadata.
if (error instanceof WslTranscriptFsError) {
throw error
}
return new Map()
}
const signature = await readCursorChatsSignature(chatsRoot, workspaceDirs)
const cached = await readCachedCursorChatMetaIndex(chatsRoot, signature)
if (cached) {
return cached
}
const pending = buildCursorChatMetaIndex(chatsRoot, workspaceDirs).then((metaPathByChatId) => ({
signature,
metaPathByChatId
}))
storeCursorChatMetaIndexEntry(chatsRoot, pending)
// Why: a rejected build (a refused WSL read) must not be served from the
// cache forever; the next scan rebuilds while this one still sees the error.
pending.catch(() => {
if (cursorChatMetaIndexCache.get(chatsRoot) === pending) {
cursorChatMetaIndexCache.delete(chatsRoot)
}
})
return (await pending).metaPathByChatId
}
// Why: a new chat only bumps its own workspace directory, so the chats root's
// own mtime would keep serving an index that is missing the newest sessions.
async function readCursorChatsSignature(
chatsRoot: string,
workspaceDirs: string[]
): Promise<string> {
const parts = await Promise.all(
workspaceDirs.map(async (name) => {
try {
const dirStat = await wslGatedStat(join(chatsRoot, name), 'scan')
return `${name}:${dirStat.mtimeMs}`
} catch {
return `${name}:?`
}
})
)
return parts.join('|')
}
async function buildCursorChatMetaIndex(
chatsRoot: string,
workspaceDirs: string[]
): Promise<Map<string, string>> {
const metaPathByChatId = new Map<string, string>()
for (const workspaceDir of workspaceDirs) {
let chatDirs
try {
chatDirs = await wslGatedReaddir(join(chatsRoot, workspaceDir), 'scan')
} catch (error) {
if (error instanceof WslTranscriptFsError) {
throw error
}
continue
}
for (const chatDir of chatDirs) {
// Why: the same chat id never appears under two workspace hashes, so the
// first hit wins and a duplicate would only cost a wasted read.
if (chatDir.isDirectory() && !metaPathByChatId.has(chatDir.name)) {
metaPathByChatId.set(
chatDir.name,
join(chatsRoot, workspaceDir, chatDir.name, CURSOR_CHAT_META_FILE)
)
}
}
}
return metaPathByChatId
}
async function readCachedCursorChatMetaIndex(
chatsRoot: string,
signature: string
): Promise<Map<string, string> | undefined> {
const cached = cursorChatMetaIndexCache.get(chatsRoot)
if (!cached) {
return undefined
}
const entry = await cached
if (entry.signature !== signature) {
return undefined
}
// Why: a concurrent scan can replace this Promise while it resolves; only the
// still-current entry may refresh recency without bypassing the cap.
if (cursorChatMetaIndexCache.get(chatsRoot) === cached) {
cursorChatMetaIndexCache.delete(chatsRoot)
cursorChatMetaIndexCache.set(chatsRoot, cached)
}
return entry.metaPathByChatId
}
function storeCursorChatMetaIndexEntry(
chatsRoot: string,
pending: Promise<CursorChatMetaIndexEntry>
): void {
cursorChatMetaIndexCache.delete(chatsRoot)
cursorChatMetaIndexCache.set(chatsRoot, pending)
if (cursorChatMetaIndexCache.size > CURSOR_CHAT_META_INDEX_CACHE_MAX) {
const oldest = cursorChatMetaIndexCache.keys().next()
if (!oldest.done) {
cursorChatMetaIndexCache.delete(oldest.value)
}
}
}
@@ -8,6 +8,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
accumulatorFoldResumeState,
addPreviewContent,
@@ -30,13 +31,14 @@ type ParserSessionOptions = {
export async function parseCursorSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseCursorSessionLines({ file, lines, platform })
return parseCursorSessionLines({ file, lines, platform, messages })
}
export async function parseCursorSessionContent(
@@ -75,9 +77,17 @@ function consumeCursorRecordLine(accumulator: SessionAccumulator, line: string):
}
}
export function createCursorSessionResumeState(file: FileWithMtime): ResumableSessionParseState {
export function createCursorSessionResumeState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent: 'cursor', file, sessionId: sessionIdFromFileName(file.path) }),
createAccumulator({
agent: 'cursor',
file,
sessionId: sessionIdFromFileName(file.path),
messages
}),
consumeCursorRecordLine
)
}
@@ -87,8 +97,9 @@ async function parseCursorSessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createCursorSessionResumeState(args.file)
const state = createCursorSessionResumeState(args.file, args.messages)
for await (const line of args.lines) {
state.consumeLine(line)
}
@@ -2,6 +2,7 @@ import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
addPreviewContent,
createAccumulator,
@@ -25,20 +26,34 @@ type ParserSessionOptions = {
export async function parseDevinSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
return parseDevinSessionContent(
return parseDevinSessionRecord(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
platform
platform,
{},
messages
)
}
/** Remote transcript content, streamed from a host that has no reader attached. */
export function parseDevinSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): AiVaultSession | null {
return parseDevinSessionRecord(file, content, platform, options)
}
function parseDevinSessionRecord(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: ParserSessionOptions,
messages?: TranscriptMessageSink
): AiVaultSession | null {
const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
@@ -48,7 +63,7 @@ export function parseDevinSessionContent(
extractString(record.session_id) ??
extractString(record.sessionId) ??
sessionIdFromFileName(file.path)
const accumulator = createAccumulator({ agent: 'devin', file, sessionId })
const accumulator = createAccumulator({ agent: 'devin', file, sessionId, messages })
const agentRecord = asRecord(record.agent)
accumulator.model =
extractString(agentRecord?.model_name) ??
+104 -59
View File
@@ -1,10 +1,12 @@
import type { Dirent } from 'node:fs'
import { extname, join } from 'node:path'
import { SessionNewestFiles } from './session-newest-files'
import type { SessionSidecarObservation } from './session-sidecar-stat'
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { recordSessionScanIssue } from './session-scan-issues'
import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
import type { SessionFileDiscovery } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
export async function discoverFiles(args: {
@@ -14,16 +16,55 @@ export async function discoverFiles(args: {
issues: AiVaultScanIssue[]
extensions: string[]
filePredicate?: (path: string) => boolean
contentDependencyPath?: (path: string) => string
contentDependencyPath?: (path: string) => string | undefined | Promise<string | undefined>
directoryPredicate?: (name: string, depth: number) => boolean
}): Promise<SessionFileDiscovery> {
let paths: string[]
const files = new SessionNewestFiles(args.limit)
let refusedSidecar = false
try {
paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, {
extensions: new Set(args.extensions),
filePredicate: args.filePredicate,
directoryPredicate: args.directoryPredicate
})
await forEachSessionFile(
args.rootDir,
args.agent,
args.issues,
{
extensions: new Set(args.extensions),
filePredicate: args.filePredicate,
directoryPredicate: args.directoryPredicate
},
async (path) => {
try {
const fileStat = await wslGatedStat(path, 'scan')
const sidecarPath = await args.contentDependencyPath?.(path)
const sidecar = await observeSessionSidecar(sidecarPath)
if (sidecar === 'unknown' && !refusedSidecar) {
// One issue per root: a refused sibling is a property of the tree,
// not of each transcript that happens to point at it.
refusedSidecar = true
recordSessionScanIssue(args.issues, {
agent: args.agent,
path: sidecarPath ?? args.rootDir,
message: 'Session metadata could not be read this scan.'
})
}
files.add({
path,
mtimeMs: fileStat.mtimeMs,
modifiedAt: new Date(fileStat.mtimeMs).toISOString(),
sizeBytes: fileStat.size,
sidecar,
dev: fileStat.dev,
ino: fileStat.ino,
nlink: fileStat.nlink
})
} catch (err) {
recordSessionScanIssue(args.issues, {
agent: args.agent,
path,
message: errorMessage(err)
})
}
}
)
} catch (err) {
// Why: discoverAiVaultSessionSources fans out with Promise.all, so one
// stalled distro would otherwise reject the whole vault scan — including
@@ -38,68 +79,74 @@ export async function discoverFiles(args: {
})
return { agent: args.agent, rootDir: args.rootDir, files: [] }
}
const files: FileWithMtime[] = []
for (const path of paths) {
try {
const fileStat = await wslGatedStat(path, 'scan')
const dependencyStat = await optionalContentDependencyStat(args.contentDependencyPath?.(path))
const mtimeMs = Math.max(fileStat.mtimeMs, dependencyStat?.mtimeMs ?? 0)
files.push({
path,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString(),
sizeBytes: fileStat.size + (dependencyStat?.size ?? 0),
dev: fileStat.dev,
ino: fileStat.ino,
nlink: fileStat.nlink
})
} catch (err) {
recordSessionScanIssue(args.issues, {
agent: args.agent,
path,
message: errorMessage(err)
})
}
}
return {
agent: args.agent,
rootDir: args.rootDir,
files: files.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, args.limit)
}
return { agent: args.agent, rootDir: args.rootDir, files: files.newest() }
}
async function optionalContentDependencyStat(
/**
* A sibling that cannot be statted is not "no sibling": it must not take the
* transcript down with it, and it must not read as absent either, or the parse
* cache would treat a session enriched from a file nobody can see as current
* forever. Only a genuinely missing path is `'none'`; every other failure —
* a stalled WSL distro, EACCES, EIO — is `'unknown'`.
*/
async function observeSessionSidecar(
filePath: string | undefined
): Promise<{ mtimeMs: number; size: number } | null> {
): Promise<SessionSidecarObservation> {
if (!filePath) {
return null
return 'none'
}
try {
const fileStat = await wslGatedStat(filePath, 'scan')
return { mtimeMs: fileStat.mtimeMs, size: fileStat.size }
return { path: filePath, mtimeMs: fileStat.mtimeMs, sizeBytes: fileStat.size }
} catch (error) {
if (error instanceof WslTranscriptFsError) {
throw error
}
return null
return isMissingSidecarError(error) ? 'none' : 'unknown'
}
}
function isMissingSidecarError(error: unknown): boolean {
if (error instanceof WslTranscriptFsError) {
return false
}
const code =
error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: null
return code === 'ENOENT' || code === 'ENOTDIR'
}
export type SessionFileWalkOptions = {
extensions: Set<string>
filePredicate?: (path: string) => boolean
// Return false to skip descending into a directory; depth 0 is a child of
// rootDir, so pruned subtrees are never stat'd or parsed.
directoryPredicate?: (name: string, depth: number) => boolean
readDirectory?: (dirPath: string) => Promise<Dirent[]>
signal?: AbortSignal
}
/** Collecting form for callers that want every match; bounded scans stream. */
export async function walkSessionFiles(
dirPath: string,
agent: AiVaultAgent,
issues: AiVaultScanIssue[],
options: {
extensions: Set<string>
filePredicate?: (path: string) => boolean
// Return false to skip descending into a directory; depth 0 is a child of
// rootDir, so pruned subtrees are never stat'd or parsed.
directoryPredicate?: (name: string, depth: number) => boolean
readDirectory?: (dirPath: string) => Promise<Dirent[]>
signal?: AbortSignal
},
depth = 0
options: SessionFileWalkOptions
): Promise<string[]> {
const files: string[] = []
await forEachSessionFile(dirPath, agent, issues, options, async (path) => {
files.push(path)
})
return files
}
/** Streams matches to `onFile` so a bounded consumer never retains the whole tree. */
export async function forEachSessionFile(
dirPath: string,
agent: AiVaultAgent,
issues: AiVaultScanIssue[],
options: SessionFileWalkOptions,
onFile: (path: string) => Promise<void>,
depth = 0
): Promise<void> {
options.signal?.throwIfAborted()
let entries
try {
@@ -113,10 +160,9 @@ export async function walkSessionFiles(
if (error instanceof WslTranscriptFsError) {
throw error
}
return []
return
}
const files: string[] = []
for (const entry of entries) {
options.signal?.throwIfAborted()
const fullPath = join(dirPath, entry.name)
@@ -124,7 +170,7 @@ export async function walkSessionFiles(
// Skip whole subtrees an agent never wants (e.g. subagent transcripts),
// avoiding the readdir cost of descending into them.
if (options.directoryPredicate?.(entry.name, depth) ?? true) {
files.push(...(await walkSessionFiles(fullPath, agent, issues, options, depth + 1)))
await forEachSessionFile(fullPath, agent, issues, options, onFile, depth + 1)
}
continue
}
@@ -133,8 +179,7 @@ export async function walkSessionFiles(
options.extensions.has(extname(entry.name).toLowerCase()) &&
(options.filePredicate?.(fullPath) ?? true)
) {
files.push(fullPath)
await onFile(fullPath)
}
}
return files
}
@@ -8,6 +8,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
accumulatorFoldResumeState,
addPreviewMessage,
@@ -32,12 +33,13 @@ type ParserSessionOptions = {
export async function parseDroidSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
return await parseDroidSessionLines({ file, lines, platform })
return await parseDroidSessionLines({ file, lines, platform, messages })
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-parse throw cannot leak the gated transcript handle.
@@ -94,9 +96,17 @@ function consumeDroidRecordLine(accumulator: SessionAccumulator, line: string):
}
}
export function createDroidSessionResumeState(file: FileWithMtime): ResumableSessionParseState {
export function createDroidSessionResumeState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent: 'droid', file, sessionId: sessionIdFromFileName(file.path) }),
createAccumulator({
agent: 'droid',
file,
sessionId: sessionIdFromFileName(file.path),
messages
}),
consumeDroidRecordLine
)
}
@@ -106,8 +116,9 @@ async function parseDroidSessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createDroidSessionResumeState(args.file)
const state = createDroidSessionResumeState(args.file, args.messages)
for await (const line of args.lines) {
state.consumeLine(line)
}
@@ -8,6 +8,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
accumulatorFoldResumeState,
addPreviewContent,
@@ -27,16 +28,19 @@ import {
export async function parseGeminiSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
if (file.path.endsWith('.jsonl')) {
return parseGeminiJsonlSessionFile(file, platform)
return parseGeminiJsonlSessionFile(file, platform, messages)
}
return parseGeminiJsonSessionContent(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
platform
platform,
{},
messages
)
}
@@ -62,7 +66,8 @@ function parseGeminiJsonSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: ResumableParseFinalizeOptions = {}
options: ResumableParseFinalizeOptions = {},
messages?: TranscriptMessageSink
): AiVaultSession | null {
const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
@@ -71,7 +76,8 @@ function parseGeminiJsonSessionContent(
const accumulator = createAccumulator({
agent: 'gemini',
file,
sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path)
sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path),
messages
})
updateTimeline(accumulator, extractString(record.startTime))
updateTimeline(accumulator, extractString(record.lastUpdated))
@@ -83,13 +89,14 @@ function parseGeminiJsonSessionContent(
export async function parseGeminiJsonlSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform
platform: NodeJS.Platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseGeminiJsonlSessionLines({ file, lines, platform })
return parseGeminiJsonlSessionLines({ file, lines, platform, messages })
}
function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: string): void {
@@ -114,10 +121,16 @@ function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: str
// Resumable only for the JSONL log format; Gemini's legacy single-JSON
// session documents are rewritten in place and must be re-read whole.
export function createGeminiJsonlSessionResumeState(
file: FileWithMtime
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }),
createAccumulator({
agent: 'gemini',
file,
sessionId: sessionIdFromFileName(file.path),
messages
}),
consumeGeminiJsonlRecordLine
)
}
@@ -127,8 +140,9 @@ async function parseGeminiJsonlSessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ResumableParseFinalizeOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createGeminiJsonlSessionResumeState(args.file)
const state = createGeminiJsonlSessionResumeState(args.file, args.messages)
for await (const line of args.lines) {
state.consumeLine(line)
}
@@ -10,6 +10,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
accumulatorFoldResumeState,
addPreviewContent,
@@ -38,7 +39,8 @@ type ParserSessionOptions = {
export async function parseRovoSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const metadata = asRecord(
JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown
@@ -49,7 +51,8 @@ export async function parseRovoSessionFile(
const accumulator = createAccumulator({
agent: 'rovo',
file,
sessionId: basename(dirname(file.path))
sessionId: basename(dirname(file.path)),
messages
})
accumulator.title = firstString(metadata, ['title', 'name', 'summary'])
accumulator.cwd = firstString(metadata, [
@@ -174,12 +177,13 @@ export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp' | 'prime-agent'
export async function parseMessageGraphSessionFile(
agent: MessageGraphAgent,
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
const lines = createInterface({ input, crlfDelay: Infinity })
try {
return await parseMessageGraphSessionLines({ agent, file, lines, platform })
return await parseMessageGraphSessionLines({ agent, file, lines, platform, messages })
} finally {
// readline.close() leaves the underlying stream open; destroy it so a
// mid-parse throw cannot leak the gated transcript handle.
@@ -245,10 +249,11 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st
export function createMessageGraphSessionResumeState(
agent: MessageGraphAgent,
file: FileWithMtime
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
const state = accumulatorFoldResumeState(
createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }),
createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path), messages }),
consumeMessageGraphRecordLine
)
// Why: only OMP materializes task-subagent transcripts beside its sessions
@@ -263,8 +268,9 @@ async function parseMessageGraphSessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createMessageGraphSessionResumeState(args.agent, args.file)
const state = createMessageGraphSessionResumeState(args.agent, args.file, args.messages)
for await (const line of args.lines) {
state.consumeLine(line)
}
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
addPreviewMessage,
createAccumulator,
@@ -34,7 +35,8 @@ const GROK_USER_QUERY_PREVIEW_SCAN_LIMIT = 4096
export async function parseGrokSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown)
if (!record) {
@@ -42,7 +44,7 @@ export async function parseGrokSessionFile(
}
const info = asRecord(record.info)
const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path))
const accumulator = createAccumulator({ agent: 'grok', file, sessionId })
const accumulator = createAccumulator({ agent: 'grok', file, sessionId, messages })
accumulator.cwd = extractString(info?.cwd)
accumulator.title =
normalizeTitleText(extractString(record.generated_title) ?? '') ??
@@ -2,6 +2,7 @@ import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileWithMtime } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
addPreviewContent,
createAccumulator,
@@ -24,20 +25,34 @@ type ParserSessionOptions = {
export async function parseHermesSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
return parseHermesSessionContent(
return parseHermesSessionRecord(
file,
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
platform
platform,
{},
messages
)
}
/** Remote transcript content, streamed from a host that has no reader attached. */
export async function parseHermesSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): Promise<AiVaultSession | null> {
return parseHermesSessionRecord(file, content, platform, options)
}
async function parseHermesSessionRecord(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: ParserSessionOptions,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
@@ -46,7 +61,8 @@ export async function parseHermesSessionContent(
const accumulator = createAccumulator({
agent: 'hermes',
file,
sessionId: extractString(record.session_id) ?? sessionIdFromFileName(file.path)
sessionId: extractString(record.session_id) ?? sessionIdFromFileName(file.path),
messages
})
accumulator.model = extractString(record.model)
accumulator.cwd = extractString(record.cwd)
@@ -16,6 +16,7 @@ import {
readKimiWorkDirBySessionId
} from './session-scanner-kimi-paths'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
asRecord,
extractContentText,
@@ -32,7 +33,8 @@ import {
// session_index.jsonl; model/messages/tokens come from the wire transcript.
export async function parseKimiSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
let stateRecord: Record<string, unknown> | null
try {
@@ -53,7 +55,7 @@ export async function parseKimiSessionFile(
}
const sessionId = kimiSessionIdFromStatePath(file.path)
const accumulator = createAccumulator({ agent: 'kimi', file, sessionId })
const accumulator = createAccumulator({ agent: 'kimi', file, sessionId, messages })
// Why: Kimi sessions are work-dir-scoped — the resume command must `cd` into
// the original directory or the CLI rejects it. That path lives only in the
@@ -3,6 +3,7 @@ import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { join } from 'node:path'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
addPreviewMessage,
createAccumulator,
@@ -25,14 +26,15 @@ import {
export async function parseOpenCodeSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const record = asRecord(JSON.parse(await wslGatedReadFile(file.path, 'utf-8', 'scan')) as unknown)
if (!record) {
return null
}
const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path)
const accumulator = createAccumulator({ agent: 'opencode', file, sessionId })
const accumulator = createAccumulator({ agent: 'opencode', file, sessionId, messages })
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
accumulator.cwd = extractString(record.directory)
updateTimeline(accumulator, timeObjectValue(record.time, 'created'))
@@ -0,0 +1,29 @@
import type SyncDatabase from '../sqlite/sync-database'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
// Why: OpenCode's schema has moved more than once, so every read probes for the
// columns it names. These are the two shapes the session parser depends on;
// keeping them here stops each reader from inventing its own partial gate.
/** Enough of `message` to count a session's turns. */
export function canCountOpenCodeMessages(db: SyncDatabase): boolean {
return (
tableExists(db, 'message') &&
columnExists(db, 'message', 'session_id') &&
columnExists(db, 'message', 'data')
)
}
/** Enough of `message`×`part` to read a session's parts in turn order. */
export function canReadOpenCodeMessageParts(db: SyncDatabase): boolean {
return (
canCountOpenCodeMessages(db) &&
columnExists(db, 'message', 'id') &&
// Every parts read orders by it; unprobed, a schema without it throws mid-read.
columnExists(db, 'message', 'time_created') &&
tableExists(db, 'part') &&
columnExists(db, 'part', 'message_id') &&
columnExists(db, 'part', 'time_created') &&
columnExists(db, 'part', 'data')
)
}
@@ -1,4 +1,4 @@
import type { Worker } from 'node:worker_threads'
import { LazyWorkerThreadHost, type WorkerThreadFactory } from '../lazy-worker-thread-host'
import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
import type {
OpenCodeSqliteListRequest,
@@ -11,9 +11,10 @@ import type { SessionFileCandidate } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
// Why (#8864): a lazily-spawned, unref'd worker runs OpenCode SQLite reads off
// the main-process event loop. Lifecycle (idle teardown, FIFO one-at-a-time
// dispatch, per-call timeouts, respawn-on-fault) mirrors src/main/speech/
// stt-service.ts. The default spawn + shared singleton live in
// the main-process event loop. This module owns the request half (FIFO
// one-at-a-time dispatch, per-call timeouts, respawn-on-fault); the thread's
// own lifetime belongs to LazyWorkerThreadHost, shared with the port-scan probe
// client. The default spawn + shared singleton live in
// session-scanner-opencode-sqlite-worker-spawn.ts.
export const LIST_TIMEOUT_MS = 30_000
@@ -25,8 +26,6 @@ export const IDLE_TEARDOWN_MS = 30_000
// fresh scan burst starts from idle (so the cap is per-scan, not process-wide).
export const MAX_CONSECUTIVE_DEATHS = 3
export type WorkerFactory = () => Worker
// Omit<union, 'id'> collapses to the shared keys, so omit each member and let
// the client stamp the correlation id.
type OpenCodeSqliteRequestBody =
@@ -53,20 +52,27 @@ class OpenCodeSqliteWorkerUnavailableError extends Error {}
* no worker can be spawned rather than moving SQLite work onto the main thread.
*/
export class OpenCodeSqliteWorkerClient {
private worker: Worker | null = null
private active: PendingCall | null = null
private queue: PendingCall[] = []
private idleTimer: NodeJS.Timeout | null = null
private consecutiveDeaths = 0
private nextId = 1
private loggedWorkerUnavailable = false
private cleanupWorkerListeners: (() => void) | null = null
private readonly workerFactory: WorkerFactory
private readonly log: (message: string) => void
private readonly host: LazyWorkerThreadHost<OpenCodeSqliteWorkerResponse>
constructor(options: { workerFactory: WorkerFactory; log?: (message: string) => void }) {
this.workerFactory = options.workerFactory
this.log = options.log ?? ((message) => console.warn(message))
constructor(options: { workerFactory: WorkerThreadFactory; log?: (message: string) => void }) {
const log = options.log ?? ((message: string) => console.warn(message))
this.host = new LazyWorkerThreadHost<OpenCodeSqliteWorkerResponse>({
factory: options.workerFactory,
idleTeardownMs: IDLE_TEARDOWN_MS,
onMessage: (response) => this.onMessage(response),
onError: (error) => this.onWorkerFault(error),
onExit: (code) => this.onWorkerExit(code),
isIdle: () => !this.active && this.queue.length === 0,
// Why (#8864): never fall back to synchronous SQLite reads here; a missing
// bundle or resource-exhausted spawn must omit OpenCode history rather than
// reintroduce the main-process hang this worker boundary prevents.
onUnavailable: (err) =>
log(`OpenCode SQLite worker unavailable; skipping its history. ${errorMessage(err)}`)
})
}
/**
@@ -167,7 +173,7 @@ export class OpenCodeSqliteWorkerClient {
if (this.active || this.queue.length === 0) {
return
}
const worker = this.ensureWorker()
const worker = this.host.ensure()
if (!worker) {
this.failQueuedAsUnavailable()
return
@@ -177,7 +183,7 @@ export class OpenCodeSqliteWorkerClient {
return
}
this.active = call
this.clearIdleTimer()
this.host.clearIdleTimer()
// Timeout clock starts at dispatch (not enqueue): a batch may enqueue up to
// 8 parses at once, and a queue-inclusive timeout would fire falsely.
call.timer = setTimeout(() => this.onTimeout(call), call.timeoutMs)
@@ -185,39 +191,6 @@ export class OpenCodeSqliteWorkerClient {
worker.postMessage(call.request)
}
private ensureWorker(): Worker | null {
if (this.worker) {
return this.worker
}
try {
const worker = this.workerFactory()
const onMessage = (response: OpenCodeSqliteWorkerResponse): void => this.onMessage(response)
const onError = (error: Error): void => this.onWorkerFault(error)
const onExit = (code: number): void => this.onWorkerExit(code)
worker.on('message', onMessage)
worker.on('error', onError)
worker.on('exit', onExit)
this.cleanupWorkerListeners = () => {
worker.off('message', onMessage)
worker.off('error', onError)
worker.off('exit', onExit)
}
// Never keep the app alive for a scan worker.
worker.unref?.()
this.worker = worker
return worker
} catch (err) {
// Why (#8864): never fall back to synchronous SQLite reads here; a missing
// bundle or resource-exhausted spawn must omit OpenCode history rather than
// reintroduce the main-process hang this worker boundary prevents.
if (!this.loggedWorkerUnavailable) {
this.loggedWorkerUnavailable = true
this.log(`OpenCode SQLite worker unavailable; skipping its history. ${errorMessage(err)}`)
}
return null
}
}
private onMessage(response: OpenCodeSqliteWorkerResponse): void {
const call = this.active
if (!call || call.request.id !== response.id) {
@@ -243,7 +216,7 @@ export class OpenCodeSqliteWorkerClient {
// A clean self-exit is not a death, but the stale handle must be dropped
// or the next dispatch would post into the dead worker and stall to timeout.
if (code === 0 && !this.active && this.queue.length === 0) {
this.destroyWorker()
this.host.destroy()
return
}
this.onWorkerFault(new Error(`OpenCode SQLite worker exited with code ${code}`))
@@ -251,7 +224,7 @@ export class OpenCodeSqliteWorkerClient {
private onWorkerFault(error: Error): void {
const failed = this.active
this.destroyWorker()
this.host.destroy()
this.consecutiveDeaths++
if (failed) {
this.settle(failed, () => failed.reject(error))
@@ -302,46 +275,7 @@ export class OpenCodeSqliteWorkerClient {
if (this.queue.length > 0) {
this.pump()
} else {
this.scheduleIdleTeardown()
this.host.scheduleIdleTeardown()
}
}
private scheduleIdleTeardown(): void {
this.clearIdleTimer()
if (!this.worker) {
return
}
this.idleTimer = setTimeout(() => this.teardownIfIdle(), IDLE_TEARDOWN_MS)
this.idleTimer.unref?.()
}
private teardownIfIdle(): void {
this.idleTimer = null
// Only tear down with nothing active AND nothing queued: a request arriving
// as the timer fires must never be lost to a self-exiting worker.
if (this.active || this.queue.length > 0) {
return
}
this.destroyWorker()
}
private clearIdleTimer(): void {
if (this.idleTimer) {
clearTimeout(this.idleTimer)
this.idleTimer = null
}
}
private destroyWorker(): void {
this.clearIdleTimer()
const worker = this.worker
this.worker = null
if (!worker) {
return
}
this.cleanupWorkerListeners?.()
this.cleanupWorkerListeners = null
worker.removeAllListeners()
void worker.terminate().catch(() => undefined)
}
}
@@ -10,6 +10,10 @@ import {
shouldCaptureFullFirstUserPrompt
} from './session-scanner-first-user-prompt'
import { readOpenCodeDatabase } from './session-scanner-opencode-sqlite-open'
import {
canCountOpenCodeMessages,
canReadOpenCodeMessageParts
} from './session-scanner-opencode-sqlite-schema'
import { normalizeTitleText } from './session-scanner-values'
import type SyncDatabase from '../sqlite/sync-database'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
@@ -70,14 +74,6 @@ function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string
return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0'
}
function canCountOpenCodeMessages(db: SyncDatabase): boolean {
return (
tableExists(db, 'message') &&
columnExists(db, 'message', 'session_id') &&
columnExists(db, 'message', 'data')
)
}
function buildSessionQuery(db: SyncDatabase): string {
const messageCountSubquery = canCountOpenCodeMessages(db)
? `(SELECT COUNT(*) FROM message m
@@ -154,14 +150,7 @@ function extractPartText(partData: string): string | null {
}
function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): string | null {
if (
!canCountOpenCodeMessages(db) ||
!tableExists(db, 'part') ||
!columnExists(db, 'message', 'id') ||
!columnExists(db, 'part', 'message_id') ||
!columnExists(db, 'part', 'time_created') ||
!columnExists(db, 'part', 'data')
) {
if (!canReadOpenCodeMessageParts(db)) {
return null
}
@@ -206,14 +195,7 @@ function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string):
}
function buildPreviewQuery(db: SyncDatabase): string | null {
if (
!canCountOpenCodeMessages(db) ||
!tableExists(db, 'part') ||
!columnExists(db, 'message', 'id') ||
!columnExists(db, 'part', 'message_id') ||
!columnExists(db, 'part', 'time_created') ||
!columnExists(db, 'part', 'data')
) {
if (!canReadOpenCodeMessageParts(db)) {
return null
}
return `SELECT json_extract(m.data, '$.role') AS role,
@@ -205,6 +205,61 @@ describe('codex-specific resume behavior', () => {
})
describe('non-resumable formats keep reuse-only caching', () => {
it('re-parses cline when only its messages sidecar changed', async () => {
const root = await makeTempDir()
const sessionDir = join(root, 'cline-1')
await mkdir(sessionDir, { recursive: true })
const metadataPath = join(sessionDir, 'cline-1.json')
const messagesPath = join(sessionDir, 'cline-1.messages.json')
await writeFile(
metadataPath,
JSON.stringify({
session_id: 'cline-1',
cwd: '/tmp/cline',
started_at: '2026-05-01T10:00:00Z'
})
)
const writeMessages = (text: string): Promise<void> =>
writeFile(
messagesPath,
JSON.stringify({
updated_at: '2026-05-01T10:00:01Z',
messages: [{ role: 'user', content: [{ type: 'text', text }] }]
})
)
await writeMessages('first ask')
// Cline reads the sidecar as part of its parse, so a change to it has to
// re-parse; there is no metadata-only merge to re-run.
const candidate = async (): Promise<SessionFileCandidate> => {
const base = await candidateFor('cline', metadataPath)
const sidecarStat = await stat(messagesPath)
return {
...base,
file: {
...base.file,
sidecar: {
path: messagesPath,
mtimeMs: sidecarStat.mtimeMs,
sizeBytes: sidecarStat.size
}
}
}
}
const stats = createSessionParseStats()
const seeded = await parseAgentSessionFileCached(await candidate(), process.platform, stats)
expect(seeded?.title).toBe('first ask')
await parseAgentSessionFileCached(await candidate(), process.platform, stats)
expect(stats).toMatchObject({ fullParses: 1, reused: 1 })
await writeMessages('second ask, rather longer than the first')
const rewritten = await parseAgentSessionFileCached(await candidate(), process.platform, stats)
expect(rewritten?.title).toBe('second ask, rather longer than the first')
expect(stats).toMatchObject({ fullParses: 2, reused: 1 })
})
it('re-parses a changed grok summary fully and reuses it when unchanged', async () => {
const root = await makeTempDir()
const sessionDir = join(root, 'session-1')
+124 -228
View File
@@ -1,7 +1,6 @@
import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { inSessionParseFileLane } from './session-parse-file-lane'
import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { createCodexSessionResumeState } from './session-scanner-codex-parser'
import { createDroidSessionResumeState } from './session-scanner-droid-parser'
import { createMessageGraphSessionResumeState } from './session-scanner-graph-parsers'
@@ -13,27 +12,30 @@ import { countSubagentTranscripts } from './session-scanner-subagent-transcripts
import { countOmpSubagentTranscripts } from './session-scanner-omp-subagent-transcripts'
import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types'
import { refreshCachedCodexTitle } from './session-scanner-codex-cached-title'
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
import {
getSessionParseCacheEntry,
storeSessionParseCacheEntry,
type SessionParseCacheEntry
} from './session-parse-cache-store'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import { sidecarUnchanged } from './session-sidecar-stat'
import {
enrichSessionFromSidecar,
sidecarEnrichesWithoutReparse
} from './session-scanner-sidecar-enrichment'
import {
readResumableTranscript,
readWholeTranscript,
type TranscriptReadStats
} from './session-transcript-reader'
// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a
// full steady-state result set stays resident between forced rescans.
const MAX_CACHE_ENTRIES = 4096
const NEWLINE_BYTE = 0x0a
type ResumePoint = {
state: ResumableSessionParseState
// Byte offset just past the last complete ('\n'-terminated) line consumed;
// a trailing unterminated line is deliberately left before this point.
byteOffset: number
}
type SessionParseCacheEntry = {
mtimeMs: number
sizeBytes: number | null
platform: NodeJS.Platform
session: AiVaultSession | null
resume: ResumePoint | null
}
export {
invalidateSessionParseCacheEntry,
resetSessionParseCacheForTests,
seedSessionParseCache,
snapshotSessionParseCacheForPersistence,
type PersistedSessionParseCacheEntry
} from './session-parse-cache-store'
// Incremental append-parsing applies only to transcripts that are append-only
// JSONL line-folds. Whole-JSON documents (grok/rovo/devin/hermes/gemini-json)
@@ -44,31 +46,32 @@ type SessionParseCacheEntry = {
// cached state instead, never pay for a throwaway accumulator.
function resumableStateFactoryFor(
candidate: SessionFileCandidate
): (() => ResumableSessionParseState) | null {
): ((messages: TranscriptMessageSink) => ResumableSessionParseState) | null {
switch (candidate.agent) {
case 'claude':
return () => createClaudeSessionResumeState(candidate.file)
return (messages) => createClaudeSessionResumeState(candidate.file, messages)
case 'codex':
return () => createCodexSessionResumeState(candidate.file, candidate.codexHome)
return (messages) =>
createCodexSessionResumeState(candidate.file, candidate.codexHome, messages)
case 'cursor':
return () => createCursorSessionResumeState(candidate.file)
return (messages) => createCursorSessionResumeState(candidate.file, messages)
case 'copilot':
return () => createCopilotSessionResumeState(candidate.file)
return (messages) => createCopilotSessionResumeState(candidate.file, messages)
case 'droid':
return () => createDroidSessionResumeState(candidate.file)
return (messages) => createDroidSessionResumeState(candidate.file, messages)
case 'openclaw':
case 'pi':
case 'omp':
case 'prime-agent': {
const agent = candidate.agent
return () => createMessageGraphSessionResumeState(agent, candidate.file)
return (messages) => createMessageGraphSessionResumeState(agent, candidate.file, messages)
}
case 'gemini':
return candidate.file.path.endsWith('.jsonl')
? () => createGeminiJsonlSessionResumeState(candidate.file)
? (messages) => createGeminiJsonlSessionResumeState(candidate.file, messages)
: null
case 'antigravity':
return () => createAntigravitySessionResumeState(candidate.file)
return (messages) => createAntigravitySessionResumeState(candidate.file, messages)
case 'devin':
case 'grok':
case 'hermes':
@@ -80,245 +83,138 @@ function resumableStateFactoryFor(
}
}
export type SessionParseStats = {
export type SessionParseStats = TranscriptReadStats & {
reused: number
incremental: number
fullParses: number
// Transcripts the parser already excluded (Codex workers), re-listed after a
// write and dismissed without reading. Counted apart from `incremental` so a
// scan span still shows how much work the early stop actually removed.
earlyStopped: number
bytesRead: number
}
export function createSessionParseStats(): SessionParseStats {
return { reused: 0, incremental: 0, fullParses: 0, earlyStopped: 0, bytesRead: 0 }
}
const cache = new Map<string, SessionParseCacheEntry>()
export function resetSessionParseCacheForTests(): void {
cache.clear()
}
// Drops one entry after its file is deleted. Cleanliness, not correctness:
// discovery walks disk first, so a trashed file is never rediscovered anyway.
export function invalidateSessionParseCacheEntry(path: string): void {
cache.delete(path)
}
// Persisted subset of a cache entry: the non-serializable `resume` parser
// state is dropped (see session-parse-cache-persistence.ts).
export type PersistedSessionParseCacheEntry = Omit<SessionParseCacheEntry, 'resume'>
export function snapshotSessionParseCacheForPersistence(): [
string,
PersistedSessionParseCacheEntry
][] {
return [...cache].map(([path, entry]): [string, PersistedSessionParseCacheEntry] => [
path,
{
mtimeMs: entry.mtimeMs,
sizeBytes: entry.sizeBytes,
platform: entry.platform,
session: entry.session
}
])
}
// Seeded entries carry `resume: null`: after a restart an unchanged file is a
// cache hit; a file that changed while the app was closed pays one full
// (not incremental) re-parse.
export function seedSessionParseCache(
entries: Iterable<[string, PersistedSessionParseCacheEntry]>
): void {
const list = [...entries]
// Snapshot order is oldest→newest (LRU); an over-cap list keeps the newest
// tail rather than seeding the oldest entries and dropping the tail.
for (const [path, entry] of list.slice(Math.max(0, list.length - MAX_CACHE_ENTRIES))) {
if (cache.size >= MAX_CACHE_ENTRIES) {
return
}
// In-process entries are always fresher than persisted ones; never clobber.
if (cache.has(path)) {
continue
}
cache.set(path, {
mtimeMs: entry.mtimeMs,
sizeBytes: entry.sizeBytes,
platform: entry.platform,
session: entry.session,
resume: null
})
}
}
function storeEntry(path: string, entry: SessionParseCacheEntry): void {
cache.delete(path)
cache.set(path, entry)
if (cache.size > MAX_CACHE_ENTRIES) {
const oldest = cache.keys().next()
if (!oldest.done) {
cache.delete(oldest.value)
}
}
}
/**
* Parse a session file, reusing prior work where the file is provably
* unchanged (mtime+size) and, for append-only JSONL transcripts (Claude,
* Codex, Cursor, Copilot, Droid, OpenClaw/Pi/OMP, Gemini-JSONL), resuming the
* parse from the last consumed byte when the file only grew. This is what
* keeps the renderer's ~5s forced rescans from re-reading gigabytes of
* transcripts (STA-1278/STA-1417: main process pegging one core during
* multi-agent workloads).
* The session list's cursor over the transcript reader: it remembers what each
* file looked like when it was last listed, reuses that work where the file is
* provably unchanged (mtime+size), and otherwise asks the reader to resume from
* the last consumed byte or re-read the file whole. This is what keeps the
* renderer's ~5s forced rescans from re-reading gigabytes of transcripts
* (STA-1278/STA-1417: main process pegging one core during multi-agent
* workloads). Other consumers of the reader keep their own equivalent cursor
* and never consult this one.
*/
export async function parseAgentSessionFileCached(
candidate: SessionFileCandidate,
platform: NodeJS.Platform,
stats?: SessionParseStats
): Promise<AiVaultSession | null> {
const { file } = candidate
const entry = cache.get(file.path)
// The whole lookup-read-store sequence runs in the lane: a concurrent parse of
// the same path shares this entry's resume point and its message channel.
return inSessionParseFileLane(candidate.file.path, () =>
parseCachedInLane(candidate, platform, stats)
)
}
const unchanged =
async function parseCachedInLane(
candidate: SessionFileCandidate,
platform: NodeJS.Platform,
stats?: SessionParseStats
): Promise<AiVaultSession | null> {
const { file } = candidate
const entry = getSessionParseCacheEntry(file.path)
const transcriptUnchanged =
entry !== undefined &&
entry.platform === platform &&
entry.mtimeMs === file.mtimeMs &&
(entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes)
if (unchanged) {
if (stats) {
stats.reused++
if (transcriptUnchanged) {
if (sidecarUnchanged(entry.sidecar, file.sidecar)) {
return reuseCachedSession(candidate, entry, stats)
}
// A zero-turn transcript usually never changes again, but its sibling
// subagent dir (Claude `<session>/subagents/`, OMP's same-named artifact
// dir) can gain files after the parent's last write (a still-running
// subagent finishing). The mtime+size key can't see that, so refresh the
// cheap directory count on reuse.
if (entry.session && entry.session.messageCount === 0) {
const subagentTranscriptCount =
candidate.agent === 'claude'
? await countSubagentTranscripts(file.path)
: candidate.agent === 'omp'
? await countOmpSubagentTranscripts(file.path)
: null
if (
subagentTranscriptCount !== null &&
subagentTranscriptCount !== entry.session.subagentTranscriptCount
) {
entry.session = { ...entry.session, subagentTranscriptCount }
// Only the sibling moved. For an agent whose sibling just adds metadata,
// re-merge it onto the stored fold result; the transcript is not re-read.
if (sidecarEnrichesWithoutReparse(candidate) && entry.foldSession !== undefined) {
const enriched = await enrichSessionFromSidecar(candidate, entry.foldSession, platform)
entry.session = enriched.session
entry.sidecar = enriched.refused ? 'unknown' : file.sidecar
storeSessionParseCacheEntry(file.path, entry)
if (stats) {
stats.reused++
}
return entry.session
}
// Codex titles come from session_index.jsonl, which mtime+size can't see.
// Remote counterpart: remote-session-scanner.ts's reusedCodexTitleRefresh.
if (entry.session && candidate.agent === 'codex') {
entry.session = await refreshCachedCodexTitle(candidate, entry.session)
}
storeEntry(file.path, entry)
return entry.session
}
const stateFactory = resumableStateFactoryFor(candidate)
if (stateFactory) {
const parsed = await parseResumableCandidate({
const read = await readResumableTranscript({
candidate,
platform,
entry,
stats,
stateFactory
resume: entry?.platform === platform ? entry.resume : null,
stateFactory,
stats
})
storeEntry(file.path, parsed)
return parsed.session
const enriched = await enrichSessionFromSidecar(candidate, read.session, platform)
storeSessionParseCacheEntry(file.path, {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
platform,
session: enriched.session,
// A refused sibling leaves the transcript's own work cached and resumable;
// only the sibling is recorded as unknown, so the next healthy scan
// re-merges it without re-reading the transcript.
sidecar: enriched.refused ? 'unknown' : file.sidecar,
foldSession: read.session,
resume: read.resume
})
return enriched.session
}
if (stats) {
stats.fullParses++
stats.bytesRead += file.sizeBytes ?? 0
}
const session = await parseAgentSessionFile(candidate, platform)
storeEntry(file.path, {
const session = await readWholeTranscript({ candidate, platform, stats })
storeSessionParseCacheEntry(file.path, {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
platform,
session,
// A whole-file parse reads the sibling itself, so a change to it re-parses.
sidecar: file.sidecar,
foldSession: session,
resume: null
})
return session
}
async function parseResumableCandidate(args: {
candidate: SessionFileCandidate
platform: NodeJS.Platform
entry: SessionParseCacheEntry | undefined
async function reuseCachedSession(
candidate: SessionFileCandidate,
entry: SessionParseCacheEntry,
stats?: SessionParseStats
stateFactory: () => ResumableSessionParseState
}): Promise<SessionParseCacheEntry> {
const { file } = args.candidate
const resume = args.entry?.platform === args.platform ? args.entry.resume : null
const canResume =
resume !== null &&
resume !== undefined &&
typeof file.sizeBytes === 'number' &&
file.sizeBytes >= resume.byteOffset &&
(resume.byteOffset === 0 || (await endsWithNewlineAt(file.path, resume.byteOffset)))
// Clone before consuming: a failed read must not corrupt the cached state,
// or the next resume would double-count the lines applied before the error.
const state = canResume ? resume.state.clone() : args.stateFactory()
const startOffset = canResume ? resume.byteOffset : 0
// Mirrors the reader's entry guard so a dismissed transcript is not reported
// as an incremental parse that read nothing.
const stoppedBeforeRead = state.shouldStop?.() === true
if (args.stats) {
if (stoppedBeforeRead) {
args.stats.earlyStopped++
} else if (canResume) {
args.stats.incremental++
} else {
args.stats.fullParses++
): Promise<AiVaultSession | null> {
if (stats) {
stats.reused++
}
// A zero-turn transcript usually never changes again, but its sibling
// subagent dir (Claude `<session>/subagents/`, OMP's same-named artifact
// dir) can gain files after the parent's last write (a still-running
// subagent finishing). The mtime+size key can't see that, so refresh the
// cheap directory count on reuse.
if (entry.session && entry.session.messageCount === 0) {
const subagentTranscriptCount =
candidate.agent === 'claude'
? await countSubagentTranscripts(candidate.file.path)
: candidate.agent === 'omp'
? await countOmpSubagentTranscripts(candidate.file.path)
: null
if (
subagentTranscriptCount !== null &&
subagentTranscriptCount !== entry.session.subagentTranscriptCount
) {
entry.session = { ...entry.session, subagentTranscriptCount }
}
}
const readResult = await consumeCompleteJsonlLines({
path: file.path,
start: startOffset,
onLine: (line) => state.consumeLine(line),
// Bound: the optional hooks are declared as methods, so a parser written
// with method syntax must not lose `this` on the way into the reader.
onLineBytes: state.consumeLineBytes?.bind(state),
shouldStop: state.shouldStop?.bind(state)
})
if (args.stats) {
args.stats.bytesRead += readResult.bytesRead
}
// The stat this scan displays is current even when nothing new was consumed.
state.touchFile(file)
// Keep parity with the one-shot parser: a final unterminated line is shown,
// but stays out of the resumable state so the (possibly still-growing) line
// is re-read once complete instead of being half-counted.
let displayState = state
if (readResult.trailingPartialLine !== null) {
displayState = state.clone()
displayState.consumeLine(readResult.trailingPartialLine)
}
return {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
platform: args.platform,
session: await displayState.finalize(args.platform),
resume: { state, byteOffset: readResult.consumedThrough }
// Codex titles come from session_index.jsonl, which mtime+size can't see.
// Remote counterpart: remote-session-scanner.ts's reusedCodexTitleRefresh.
if (entry.session && candidate.agent === 'codex') {
entry.session = await refreshCachedCodexTitle(candidate, entry.session)
}
}
// A resume point is only valid if it still sits just past a line break;
// anything else means the file was rewritten, not appended. Heuristic: a
// grown rewrite keeping '\n' at exactly this byte would slip through, but
// agent transcripts are append-only so that trade is accepted (worst case is
// a stale vault row until the file is next truncated or the app restarts).
async function endsWithNewlineAt(path: string, offset: number): Promise<boolean> {
const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan')
return slice.length === 1 && slice[0] === NEWLINE_BYTE
storeSessionParseCacheEntry(candidate.file.path, entry)
return entry.session
}
@@ -10,6 +10,7 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
addPreviewContent,
createAccumulator,
@@ -42,12 +43,16 @@ export type ClaudeSessionParseState = {
firstUserTitle: string | null
}
export function createClaudeSessionParseState(file: FileWithMtime): ClaudeSessionParseState {
export function createClaudeSessionParseState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): ClaudeSessionParseState {
return {
accumulator: createAccumulator({
agent: 'claude',
file,
sessionId: sessionIdFromFileName(file.path)
sessionId: sessionIdFromFileName(file.path),
messages
}),
metaTitle: null,
generatedTitle: null,
@@ -188,8 +193,11 @@ export async function finalizeClaudeSessionParseState(
return finalizeSession(snapshot.accumulator, platform, options)
}
export function createClaudeSessionResumeState(file: FileWithMtime): ResumableSessionParseState {
return claudeResumeStateFromParseState(createClaudeSessionParseState(file))
export function createClaudeSessionResumeState(
file: FileWithMtime,
messages?: TranscriptMessageSink
): ResumableSessionParseState {
return claudeResumeStateFromParseState(createClaudeSessionParseState(file, messages))
}
function claudeResumeStateFromParseState(
@@ -207,13 +215,14 @@ function claudeResumeStateFromParseState(
export async function parseClaudeSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan'),
crlfDelay: Infinity
})
return parseClaudeSessionLines({ file, lines, platform })
return parseClaudeSessionLines({ file, lines, platform, messages })
}
export async function parseClaudeSessionContent(
@@ -236,8 +245,9 @@ async function parseClaudeSessionLines(args: {
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
messages?: TranscriptMessageSink
}): Promise<AiVaultSession | null> {
const state = createClaudeSessionParseState(args.file)
const state = createClaudeSessionParseState(args.file, args.messages)
for await (const line of args.lines) {
consumeClaudeSessionLine(state, line)
}
@@ -0,0 +1,79 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { buildAiVaultResumeCommand } from '../../shared/ai-vault-resume-command'
import { generatedSessionTitle } from './session-scanner-accumulator'
import { readCursorChatMeta, wasCursorChatMetaRefused } from './session-scanner-cursor-chat-meta'
import type { SessionFileCandidate } from './session-scanner-types'
/**
* Merges an agent's sibling file onto the session its transcript alone
* produced. Kept out of the fold and applied here so it is a pure function of
* (fold result, sibling): re-running it starts from what the transcript said,
* never from a previous merge, so a rewritten sibling replaces the fields it
* supplied last time instead of losing to them.
*
* The reuse-time counterpart of session-scanner-codex-cached-title.ts, for
* agents whose sibling the transcript key cannot see.
*/
export type SidecarEnrichment = {
session: AiVaultSession | null
/** The sibling could not be read; the caller records the observation as unknown. */
refused: boolean
}
/** True when the sibling only adds metadata, so a change to it needs no re-parse. */
export function sidecarEnrichesWithoutReparse(candidate: SessionFileCandidate): boolean {
return candidate.agent === 'cursor'
}
export async function enrichSessionFromSidecar(
candidate: SessionFileCandidate,
foldSession: AiVaultSession | null,
platform: NodeJS.Platform
): Promise<SidecarEnrichment> {
if (candidate.agent !== 'cursor' || !foldSession) {
return { session: foldSession, refused: false }
}
const meta = await readCursorChatMeta(candidate.file.path)
if (!meta) {
return { session: foldSession, refused: wasCursorChatMetaRefused(candidate.file.path) }
}
return { session: mergeCursorChatMeta(foldSession, meta, platform), refused: false }
}
/** Fills only what the transcript never recorded; its own records always win. */
export function mergeCursorChatMeta(
session: AiVaultSession,
meta: {
title: string | null
cwd: string | null
createdAt: string | null
updatedAt: string | null
},
platform: NodeJS.Platform
): AiVaultSession {
// A generated title means the fold found none, so the sibling's may stand in.
const named = session.title !== generatedSessionTitle(session.agent, session.sessionId)
const cwd = session.cwd ?? meta.cwd
const merged: AiVaultSession = {
...session,
title: named ? session.title : (meta.title ?? session.title),
cwd,
createdAt: session.createdAt ?? meta.createdAt,
updatedAt: session.updatedAt ?? meta.updatedAt
}
if (cwd === session.cwd) {
return merged
}
// The resume command embeds the cwd, so it has to be rebuilt with it.
return {
...merged,
resumeCommand: buildAiVaultResumeCommand({
agent: merged.agent,
sessionId: merged.sessionId,
resumeFilePath: merged.filePath,
cwd,
platform
})
}
}
@@ -17,6 +17,8 @@ export async function discoverAiVaultSessionSources(args: {
const { options, limitPerAgent, issues } = args
const wslHomeDirs = normalizedWslHomeDirs(options.wslHomeDirs)
// The Cursor chat-meta scan scope is owned by scanAiVaultSessions: it has to
// span parse as well, and finalize runs after this returns.
return Promise.all([
// Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a
// SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy)
+10 -1
View File
@@ -5,6 +5,8 @@ import type {
AiVaultSessionPreviewMessage
} from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import type { SessionSidecarObservation } from './session-sidecar-stat'
export type AiVaultScanOptions = {
claudeProjectsDir?: string
@@ -54,8 +56,12 @@ export type FileWithMtime = {
modifiedAt: string
// Present when discovery statted the file; lets the parse cache detect
// unchanged/truncated files without a second stat. Synthetic candidates
// such as OpenCode SQLite rows omit it.
// such as OpenCode SQLite rows omit it. The transcript's own length: a byte
// offset into it may be compared against this directly.
sizeBytes?: number
// What discovery saw of the agent's sibling file, tracked apart from the
// transcript's own stat (see session-sidecar-stat.ts).
sidecar?: SessionSidecarObservation
// Present when discovery can prove filesystem identity. Codex dual-root
// scans use a multi-link inode to collapse only actual hardlink aliases.
dev?: number
@@ -108,6 +114,9 @@ export type ResumableSessionParseState = {
export type SessionAccumulator = {
agent: AiVaultAgent
// Every decoded message this fold sees also goes here, for the reader's
// consumers. Shared by clones on purpose: one read, one message stream.
messages: TranscriptMessageSink
sessionId: string
title: string | null
fallbackTitle: string | null
+82 -106
View File
@@ -6,19 +6,13 @@ import type {
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import { withSpan } from '../observability/tracer'
import { sessionSortTime } from './session-scanner-accumulator'
import {
codexRolloutHardlinkIdentity,
dedupeCodexRolloutAliases,
dedupeCodexSessionsBySessionId
} from './codex-session-root-dedup'
import { readCodexRolloutSessionMetaId } from '../codex/codex-rollout-session-meta'
import { dedupeCodexSessionsBySessionId } from './codex-session-root-dedup'
import {
createAntigravityWorkspaceResolver,
readLocalAntigravityHistory,
type AntigravityWorkspaceResolver
} from './session-scanner-antigravity-history'
import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths'
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
import { sessionCandidatesFromDiscoveries } from './session-scanner-candidates'
import {
ensureSessionParseCacheLoaded,
scheduleSessionParseCachePersist
@@ -30,10 +24,8 @@ import {
} from './session-scanner-parse-cache'
import { recordSessionScanIssue } from './session-scan-issues'
import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery'
import {
DEFAULT_CODEX_HOME_DIR,
discoverAiVaultSessionSources
} from './session-scanner-source-discovery'
import { discoverAiVaultSessionSources } from './session-scanner-source-discovery'
import { cursorChatMetaRefusals, withCursorChatMetaScan } from './session-scanner-cursor-chat-meta'
import type {
AiVaultScanOptions,
SessionFileCandidate,
@@ -62,103 +54,87 @@ export async function scanAiVaultSessions(
// The span makes scan cost visible in the local trace file: STA-1278-style
// "one core pegged" reports need to show whether transcript scanning is the
// subsystem burning CPU, and how much of each scan the cache absorbed.
return withSpan('aiVault.scan', async (span) => {
const limit = options.unlimited
? Number.POSITIVE_INFINITY
: clampPositiveInteger(options.limit, DEFAULT_AI_VAULT_SCAN_LIMIT)
const limitPerAgent = options.unlimited
? Number.POSITIVE_INFINITY
: clampPositiveInteger(options.limitPerAgent, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER)
const platform = options.platform ?? process.platform
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
const issues: AiVaultScanIssue[] = []
const parseStats = createSessionParseStats()
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(
readLocalAntigravityHistory
)
// Why: persisted entries must be seeded before any candidate is parsed, or
// the cold scan gains nothing from the cache file (#9210).
throwIfAiVaultScanCancelled(options.signal)
await ensureSessionParseCacheLoaded()
const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues })
throwIfAiVaultScanCancelled(options.signal)
// The Cursor chat-meta scope spans discovery AND parse: its sibling meta.json
// is looked up in both phases, and one scan must read the chats tree once.
return withSpan('aiVault.scan', (span) =>
withCursorChatMetaScan(async () => {
const limit = options.unlimited
? Number.POSITIVE_INFINITY
: clampPositiveInteger(options.limit, DEFAULT_AI_VAULT_SCAN_LIMIT)
const limitPerAgent = options.unlimited
? Number.POSITIVE_INFINITY
: clampPositiveInteger(options.limitPerAgent, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER)
const platform = options.platform ?? process.platform
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
const issues: AiVaultScanIssue[] = []
const parseStats = createSessionParseStats()
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(
readLocalAntigravityHistory
)
// Why: persisted entries must be seeded before any candidate is parsed, or
// the cold scan gains nothing from the cache file (#9210).
throwIfAiVaultScanCancelled(options.signal)
await ensureSessionParseCacheLoaded()
const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues })
throwIfAiVaultScanCancelled(options.signal)
const candidates = await dedupeCodexRolloutAliases(
discoveries
.flatMap((discovery) =>
discovery.files.map((file): SessionFileCandidate => ({
agent: discovery.agent,
file,
codexHome:
discovery.agent === 'codex'
? codexHomeForSessionsDir(
discovery.rootDir,
options.defaultCodexHomeDir ?? DEFAULT_CODEX_HOME_DIR
)
: null,
antigravityHistoryPath:
discovery.agent === 'antigravity'
? antigravityHistoryPathForBrainDir(discovery.rootDir)
: undefined
}))
)
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs),
{
isCodex: (candidate) => candidate.agent === 'codex',
getFilePath: (candidate) => candidate.file.path,
getCodexHome: (candidate) => candidate.codexHome,
getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file)
},
(filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'),
options.signal
)
const candidates = await sessionCandidatesFromDiscoveries(discoveries, options)
const parsedSessions = await parseSessionCandidates({
candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER),
limit,
platform,
executionHostId,
issues,
parseStats,
signal: options.signal,
antigravityWorkspaceResolver
const parsedSessions = await parseSessionCandidates({
candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER),
limit,
platform,
executionHostId,
issues,
parseStats,
signal: options.signal,
antigravityWorkspaceResolver
})
const cappedSessions = dedupeCodexSessionsBySessionId(parsedSessions)
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
const scopeSessions = await scanInScopeSessions({
discoveries,
scopePaths: options.scopePaths ?? [],
limit,
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
executionHostId,
issues,
parseStats,
signal: options.signal
})
// Scope discovery can return without parsing anything, so an abort landing
// here would otherwise persist and return a cancelled scan as complete.
throwIfAiVaultScanCancelled(options.signal)
for (const refusal of cursorChatMetaRefusals()) {
// One issue per refused chats root, not one per Cursor transcript.
recordSessionScanIssue(issues, {
agent: 'cursor',
path: refusal.chatsRoot,
message: refusal.message
})
}
span.setAttribute('candidates', candidates.length)
span.setAttribute('reused', parseStats.reused)
span.setAttribute('incremental', parseStats.incremental)
span.setAttribute('fullParses', parseStats.fullParses)
span.setAttribute('earlyStopped', parseStats.earlyStopped)
span.setAttribute('bytesRead', parseStats.bytesRead)
span.setAttribute('issues', issues.length)
scheduleSessionParseCachePersist(parseStats)
return {
sessions: mergeSessions(cappedSessions, scopeSessions),
issues: issues.map((issue) => ({ executionHostId, ...issue })),
scannedAt: new Date().toISOString()
}
})
const cappedSessions = dedupeCodexSessionsBySessionId(parsedSessions)
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
const scopeSessions = await scanInScopeSessions({
discoveries,
scopePaths: options.scopePaths ?? [],
limit,
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
executionHostId,
issues,
parseStats,
signal: options.signal
})
// Scope discovery can return without parsing anything, so an abort landing
// here would otherwise persist and return a cancelled scan as complete.
throwIfAiVaultScanCancelled(options.signal)
span.setAttribute('candidates', candidates.length)
span.setAttribute('reused', parseStats.reused)
span.setAttribute('incremental', parseStats.incremental)
span.setAttribute('fullParses', parseStats.fullParses)
span.setAttribute('earlyStopped', parseStats.earlyStopped)
span.setAttribute('bytesRead', parseStats.bytesRead)
span.setAttribute('issues', issues.length)
scheduleSessionParseCachePersist(parseStats)
return {
sessions: mergeSessions(cappedSessions, scopeSessions),
issues: issues.map((issue) => ({ executionHostId, ...issue })),
scannedAt: new Date().toISOString()
}
})
)
}
// In-scope sessions are guaranteed regardless of the recency cap, so the global
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { sidecarUnchanged, type SessionSidecarObservation } from './session-sidecar-stat'
const META = { path: '/chats/a/meta.json', mtimeMs: 100, sizeBytes: 20 } as const
const OTHER = { path: '/chats/a/meta.json', mtimeMs: 101, sizeBytes: 20 } as const
type Named = [label: string, value: SessionSidecarObservation | undefined]
const ENTRIES: Named[] = [
['undefined', undefined],
["'none'", 'none'],
["'unknown'", 'unknown'],
['object', { ...META }]
]
const OBSERVED: Named[] = [
['undefined', undefined],
["'none'", 'none'],
["'unknown'", 'unknown'],
['same object', { ...META }],
['different object', { ...OTHER }]
]
// entry (row) x observed (column). A `true` cell is a cache hit.
const TRUTH_TABLE: Record<string, Record<string, boolean>> = {
undefined: {
undefined: true,
"'none'": true,
"'unknown'": false,
'same object': false,
'different object': false
},
"'none'": {
undefined: true,
"'none'": true,
"'unknown'": false,
'same object': false,
'different object': false
},
"'unknown'": {
undefined: false,
"'none'": false,
"'unknown'": false,
'same object': false,
'different object': false
},
object: {
undefined: false,
"'none'": false,
"'unknown'": false,
'same object': true,
'different object': false
}
}
describe.each(ENTRIES)('cached %s', (entryLabel, entry) => {
it.each(OBSERVED)(`vs observed %s`, (observedLabel, observed) => {
expect(sidecarUnchanged(entry, observed)).toBe(TRUTH_TABLE[entryLabel][observedLabel])
})
})
it('treats a vanished sidecar as a change, not as "never had one"', () => {
expect(sidecarUnchanged({ ...META }, 'none')).toBe(false)
})
it('never concludes anything from an unreadable sidecar, in either position', () => {
expect(sidecarUnchanged('unknown', 'none')).toBe(false)
expect(sidecarUnchanged('unknown', { ...META })).toBe(false)
expect(sidecarUnchanged({ ...META }, 'unknown')).toBe(false)
})
it('keeps an agent with no sidecar at all a cache hit', () => {
expect(sidecarUnchanged(undefined, undefined)).toBe(true)
expect(sidecarUnchanged(undefined, 'none')).toBe(true)
})
+49
View File
@@ -0,0 +1,49 @@
// Why: some agents keep part of a session beside its transcript — Cursor's
// chat meta.json, Cline's messages file. Folding that file's stat into the
// transcript's own mtime/size makes one key mean two things, so a byte offset
// into the transcript can no longer be compared against it and a refused read
// of the sibling takes the transcript down with it. The sidecar is observed
// separately and compared separately.
export type SessionSidecarStat = {
path: string
mtimeMs: number
sizeBytes: number
}
export type SessionSidecarObservation =
| SessionSidecarStat
/** This agent declares no sidecar, or it does not exist. */
| 'none'
/** It could not be read this scan; nothing may be concluded from its absence. */
| 'unknown'
/**
* Whether a cached observation still describes what discovery just saw.
*
* Asymmetric on purpose: `file` is observed now, so a missing value means the
* agent has no sidecar, while `entry` may predate the field (an entry seeded
* from a cache file an older build wrote), so a missing value means unknown.
*
* `'none'` is a claim, not an absence of one: a sidecar that was there and is
* gone changed, and one that was unreadable last time is still unknown now.
*/
export function sidecarUnchanged(
entry: SessionSidecarObservation | undefined,
file: SessionSidecarObservation | undefined
): boolean {
const observed = file ?? 'none'
if (observed === 'unknown' || entry === 'unknown') {
return false
}
if (observed === 'none') {
// Absent now: a hit only if it was absent before, or the agent never had one.
return entry === undefined || entry === 'none'
}
return (
typeof entry === 'object' &&
entry.path === observed.path &&
entry.mtimeMs === observed.mtimeMs &&
entry.sizeBytes === observed.sizeBytes
)
}
@@ -31,6 +31,9 @@ async function readOneTitle(
if (!stats.isFile() || signal?.aborted) {
return null
}
// Why: this key is a raw lstat with no content dependency, so it only
// matches the scanner's for providers that declare none — today claude and
// codex, which is all this request type carries.
const session = await parseAgentSessionFileCached(
{
agent: request.agent,
@@ -0,0 +1,92 @@
import {
hasTranscriptConsumers,
transcriptConsumers,
type TranscriptMessage,
type TranscriptMessageSink,
type TranscriptReadConsumer,
type TranscriptReadOutcome,
type TranscriptReadStart
} from './session-transcript-consumers'
/**
* The sink a parser pushes into, and the fan-out to every registered consumer.
*
* One channel belongs to one file for as long as its resumable parse state
* lives, because the cached state (and every clone of it) holds this reference.
* A read re-points the channel at that read's consumers instead of replacing it.
*/
export class TranscriptMessageChannel implements TranscriptMessageSink {
private readers: TranscriptReadConsumer[] = []
private muted = false
/** True while a read is open with at least one consumer attached. */
get active(): boolean {
return this.readers.length > 0
}
beginRead(start: TranscriptReadStart): void {
this.muted = false
this.readers = []
// Keeps a scan with no consumers allocation-free on its hottest path.
if (!hasTranscriptConsumers()) {
return
}
for (const consumer of transcriptConsumers()) {
try {
const reader = consumer.beginRead(start)
if (reader) {
this.readers.push(reader)
}
} catch {
// A consumer that cannot open this read simply does not see it.
}
}
}
push(message: TranscriptMessage): void {
if (this.muted || this.readers.length === 0) {
return
}
// A throwing consumer is dropped for the rest of the read rather than
// failing the parse; it then gets no `finish`, so it never records a cursor
// for a stream it did not see in full.
let index = 0
while (index < this.readers.length) {
try {
this.readers[index].message(message)
index++
} catch {
this.readers.splice(index, 1)
}
}
}
/**
* Suppresses emission for a display-only re-read: the trailing unterminated
* line is shown in the list but is re-read once complete, so emitting it here
* would hand every consumer the same line twice. `fn` must be synchronous.
*/
mute<T>(fn: () => T): T {
const previous = this.muted
this.muted = true
try {
return fn()
} finally {
this.muted = previous
}
}
finishRead(outcome: TranscriptReadOutcome): void {
const readers = this.readers
this.readers = []
this.muted = false
for (const reader of readers) {
try {
reader.finish(outcome)
} catch {
// A consumer failure must never fail the session list.
}
}
}
}
@@ -0,0 +1,330 @@
import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import {
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from './session-scanner-parse-cache'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types'
import { readWholeTranscript } from './session-transcript-reader'
const OPENCODE_SQLITE_SESSION = {
id: 'local:opencode:sqlite-session:db',
agent: 'opencode' as const,
sessionId: 'sqlite-session'
}
// Stands in for the worker thread: the point is that its messages never come
// back over the channel, not what the SQLite read returns.
vi.mock('./session-scanner-opencode-sqlite-worker-spawn', async (importOriginal) => ({
...(await importOriginal<typeof OpenCodeSqliteWorkerSpawn>()),
parseOpenCodeSqliteSessionViaWorker: () => Promise.resolve(OPENCODE_SQLITE_SESSION)
}))
import type * as OpenCodeSqliteWorkerSpawn from './session-scanner-opencode-sqlite-worker-spawn'
import {
registerTranscriptConsumer,
resetTranscriptConsumersForTests,
type TranscriptMessage,
type TranscriptReadOutcome,
type TranscriptReadStart
} from './session-transcript-consumers'
type RecordedRead = {
start: TranscriptReadStart
messages: TranscriptMessage[]
outcome: TranscriptReadOutcome | null
}
function recordingConsumer(): { reads: RecordedRead[]; unregister: () => void } {
const reads: RecordedRead[] = []
const unregister = registerTranscriptConsumer({
beginRead: (start) => {
const read: RecordedRead = { start, messages: [], outcome: null }
reads.push(read)
return {
message: (message) => read.messages.push(message),
finish: (outcome) => {
read.outcome = outcome
}
}
}
})
return { reads, unregister }
}
function textsFor(reads: RecordedRead[], agent: string): string[] {
return reads
.filter((read) => read.start.candidate.agent === agent)
.flatMap((read) => read.messages.map((message) => `${message.role}:${message.text}`))
}
let tempRoots: string[] = []
afterEach(async () => {
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
function claudeTurns(from: number, to: number): unknown[] {
const records: unknown[] = []
for (let index = from; index <= to; index++) {
records.push({
type: 'user',
sessionId: 'claude-session',
timestamp: `2026-05-01T10:0${index}:00.000Z`,
cwd: '/tmp/claude',
message: { role: 'user', content: `ask ${index}` }
})
records.push({
type: 'assistant',
sessionId: 'claude-session',
timestamp: `2026-05-01T10:0${index}:01.000Z`,
message: {
role: 'assistant',
content: [
{ type: 'text', text: `reply ${index}` },
{ type: 'tool_use', name: 'Bash', input: { command: `ls ${index}` } }
]
}
})
}
return records
}
async function writeClaudeFixture(): Promise<{
root: string
roots: ReturnType<typeof isolatedScanRoots>
transcript: string
}> {
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-consumers-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const transcript = join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl')
await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true })
await writeFile(transcript, `${jsonLines(claudeTurns(1, 4))}\n`)
return { root, roots, transcript }
}
it('delivers one message stream to every registered consumer', async () => {
const { roots } = await writeClaudeFixture()
const first = recordingConsumer()
const second = recordingConsumer()
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
expect(result.issues).toEqual([])
const stream = textsFor(first.reads, 'claude')
expect(stream).toEqual(textsFor(second.reads, 'claude'))
expect(stream).toEqual([
'user:ask 1',
'assistant:reply 1',
'tool:Bash: ls 1',
'user:ask 2',
'assistant:reply 2',
'tool:Bash: ls 2',
'user:ask 3',
'assistant:reply 3',
'tool:Bash: ls 3',
'user:ask 4',
'assistant:reply 4',
'tool:Bash: ls 4'
])
// The list's own fold keeps only the newest five preview turns, so the stream
// is demonstrably the reader's, not a projection of the session row.
const session = result.sessions.find((entry) => entry.agent === 'claude')
expect(session?.previewMessages).toHaveLength(5)
expect(session?.messageCount).toBe(8)
})
it('leaves the session list identical whether or not a consumer is registered', async () => {
const withoutConsumer = await writeClaudeFixture()
const bare = await scanAiVaultSessions({
...withoutConsumer.roots,
platform: 'darwin',
limit: 20
})
resetSessionParseCacheForTests()
recordingConsumer()
const observed = await scanAiVaultSessions({
...withoutConsumer.roots,
platform: 'darwin',
limit: 20
})
expect(observed.sessions).toEqual(bare.sessions)
})
it('replays only the appended lines on a resumed read', async () => {
const { roots, transcript } = await writeClaudeFixture()
const consumer = recordingConsumer()
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
const firstRead = consumer.reads.at(-1)
expect(firstRead?.start.mode).toBe('replace')
expect(firstRead?.start.previousByteOffset).toBe(0)
expect(firstRead?.outcome?.incomplete).toBe(false)
await appendFile(transcript, `${jsonLines(claudeTurns(5, 5))}\n`)
consumer.reads.length = 0
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
const resumed = consumer.reads.find((read) => read.start.candidate.agent === 'claude')
expect(resumed?.start.mode).toBe('append')
expect(resumed?.start.previousByteOffset).toBe(firstRead?.outcome?.byteOffset)
expect(textsFor(consumer.reads, 'claude')).toEqual([
'user:ask 5',
'assistant:reply 5',
'tool:Bash: ls 5'
])
})
it('publishes a trailing unterminated line once, when it is complete', async () => {
const { roots, transcript } = await writeClaudeFixture()
const consumer = recordingConsumer()
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
// A half-written record: the list shows it, the stream must not carry it yet.
const [partial] = claudeTurns(5, 5)
await appendFile(transcript, JSON.stringify(partial))
consumer.reads.length = 0
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
expect(textsFor(consumer.reads, 'claude')).toEqual([])
await appendFile(transcript, '\n')
consumer.reads.length = 0
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
expect(textsFor(consumer.reads, 'claude')).toEqual(['user:ask 5'])
})
it('keeps the session list working when a consumer throws', async () => {
const { roots } = await writeClaudeFixture()
registerTranscriptConsumer({
beginRead: () => ({
message: () => {
throw new Error('consumer exploded')
},
finish: () => undefined
})
})
const healthy = recordingConsumer()
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
expect(result.issues).toEqual([])
expect(result.sessions.find((entry) => entry.agent === 'claude')?.messageCount).toBe(8)
expect(textsFor(healthy.reads, 'claude')).toHaveLength(12)
})
it('skips a read a consumer declines without disturbing the others', async () => {
const { roots } = await writeClaudeFixture()
registerTranscriptConsumer({ beginRead: () => null })
const healthy = recordingConsumer()
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
expect(textsFor(healthy.reads, 'claude')).toHaveLength(12)
})
async function claudeCandidate(transcript: string): Promise<SessionFileCandidate> {
const stats = await stat(transcript)
const file: FileWithMtime = {
path: transcript,
mtimeMs: stats.mtimeMs,
modifiedAt: stats.mtime.toISOString(),
sizeBytes: stats.size
}
return { agent: 'claude', file, codexHome: null }
}
it('serializes overlapping parses of one path so no consumer read is orphaned', async () => {
const { transcript } = await writeClaudeFixture()
// Seed a resume point: the channel it stores is what concurrent reads share.
await parseAgentSessionFileCached(await claudeCandidate(transcript), 'darwin')
await appendFile(transcript, `${jsonLines(claudeTurns(5, 5))}\n`)
const consumer = recordingConsumer()
const appended = await claudeCandidate(transcript)
const [first, second] = await Promise.all([
parseAgentSessionFileCached(appended, 'darwin'),
parseAgentSessionFileCached(appended, 'darwin')
])
// Every read that opened must also close, or its consumer keeps a half-read
// stream forever and never learns the outcome.
expect(consumer.reads.filter((read) => read.outcome === null)).toEqual([])
expect(consumer.reads).toHaveLength(1)
expect(consumer.reads[0].start.mode).toBe('append')
expect(textsFor(consumer.reads, 'claude')).toEqual([
'user:ask 5',
'assistant:reply 5',
'tool:Bash: ls 5'
])
// The later caller reuses the stored entry rather than moving the cursor back.
expect(first?.messageCount).toBe(10)
expect(second?.messageCount).toBe(10)
})
it('reports a read whose parser cannot publish its messages as not complete', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-opencode-'))
tempRoots.push(root)
const dbPath = join(root, 'opencode.db')
await writeFile(dbPath, '')
const consumer = recordingConsumer()
const session = await readWholeTranscript({
candidate: {
agent: 'opencode',
codexHome: null,
file: {
path: `${dbPath}#sqlite-session`,
mtimeMs: 1,
modifiedAt: new Date(1).toISOString(),
sizeBytes: 10
}
},
platform: 'darwin'
})
expect(session).toEqual(OPENCODE_SQLITE_SESSION)
expect(consumer.reads).toHaveLength(1)
expect(consumer.reads[0].messages).toEqual([])
expect(consumer.reads[0].outcome?.incomplete).toBe(true)
})
it('reports the transcript size, not the cache key, as a whole-file read offset', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-cline-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
// Cline is whole-file and declares a sibling content dependency, so its cache
// key covers two files while the read covers one.
const sessionDir = join(roots.clineSessionsDir, 'cline-session')
await mkdir(sessionDir, { recursive: true })
const metadataPath = join(sessionDir, 'cline-session.json')
await writeFile(
metadataPath,
JSON.stringify({
session_id: 'cline-session',
started_at: '2026-05-01T10:00:00.000Z',
cwd: '/tmp/cline'
})
)
await writeFile(
join(sessionDir, 'cline-session.messages.json'),
JSON.stringify({
updated_at: '2026-05-01T10:00:01.000Z',
messages: [{ role: 'user', content: [{ type: 'text', text: 'x'.repeat(400) }] }]
})
)
const consumer = recordingConsumer()
await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
const read = consumer.reads.find((entry) => entry.start.candidate.agent === 'cline')
expect(read?.outcome?.byteOffset).toBe((await stat(metadataPath)).size)
})
@@ -0,0 +1,85 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { SessionFileCandidate } from './session-scanner-types'
// Why: the transcript reader owns discovery, per-file cursors and decoding; a
// consumer only folds the message stream. Registering a second consumer (a
// search index, a digest) must not require touching the reader or the parse
// cache, so the reader publishes reads rather than knowing who reads them.
export type TranscriptMessageRole = 'user' | 'assistant' | 'tool'
export type TranscriptMessage = {
role: TranscriptMessageRole
/** Untruncated decoded text; caps and redaction are consumer policy. */
text: string
timestamp: string | null
}
/** Where a parser hands its decoded messages; the reader supplies the instance. */
export type TranscriptMessageSink = {
/** False when nobody is listening: parsers skip the extraction entirely. */
readonly active: boolean
push(message: TranscriptMessage): void
}
export const NO_TRANSCRIPT_MESSAGES: TranscriptMessageSink = {
active: false,
push: () => undefined
}
export type TranscriptReadStart = {
candidate: SessionFileCandidate
/** `replace`: the whole file is being re-read; `append`: a resumed read. */
mode: 'replace' | 'append'
/** Byte offset the messages of this read continue from. */
previousByteOffset: number
}
export type TranscriptReadOutcome = {
/** Null when the parser rejected the file (an excluded Codex worker transcript). */
session: AiVaultSession | null
/** Byte offset just past the last complete line this read consumed. */
byteOffset: number
/**
* The messages of this read are not the whole span: the read failed part way,
* or the parser decodes where the channel cannot reach it. A consumer must
* not record a cursor for an incomplete read.
*/
incomplete: boolean
}
/** One consumer's view of one file read. */
export type TranscriptReadConsumer = {
message(message: TranscriptMessage): void
finish(outcome: TranscriptReadOutcome): void
}
export type TranscriptConsumer = {
/**
* Open this read, or return null to ignore it. A consumer whose own cursor is
* behind `previousByteOffset` declines here and re-reads on its own schedule;
* it must never ask another consumer where it is.
*/
beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null
}
const consumers = new Set<TranscriptConsumer>()
export function registerTranscriptConsumer(consumer: TranscriptConsumer): () => void {
consumers.add(consumer)
return () => {
consumers.delete(consumer)
}
}
export function transcriptConsumers(): readonly TranscriptConsumer[] {
return [...consumers]
}
export function hasTranscriptConsumers(): boolean {
return consumers.size > 0
}
export function resetTranscriptConsumersForTests(): void {
consumers.clear()
}
@@ -0,0 +1,61 @@
import { expect, it } from 'vitest'
import { transcriptMessagesFromContent } from './session-transcript-message-content'
const AT = '2026-05-01T10:00:00.000Z'
it('keeps a plain string turn under the record role', () => {
expect(transcriptMessagesFromContent('user', 'just words', AT)).toEqual([
{ role: 'user', text: 'just words', timestamp: AT }
])
})
it('drops turns whose role a consumer cannot use', () => {
expect(transcriptMessagesFromContent('system', 'boot', AT)).toEqual([])
expect(transcriptMessagesFromContent('unknown', 'noise', AT)).toEqual([])
})
it('joins text blocks and appends tool blocks as their own messages', () => {
expect(
transcriptMessagesFromContent(
'assistant',
[
{ type: 'text', text: 'first' },
{ type: 'tool_use', name: 'Bash', input: { command: 'ls -la', description: 'ignored' } },
{ type: 'thinking', text: 'second' },
{ type: 'image', source: {} }
],
AT
)
).toEqual([
{ role: 'assistant', text: 'first\nsecond', timestamp: AT },
{ role: 'tool', text: 'Bash: ls -la', timestamp: AT }
])
})
it('reads a tool result carried on a user record as a tool message', () => {
expect(
transcriptMessagesFromContent(
'user',
[{ type: 'tool_result', content: [{ type: 'text', text: 'exit 0' }] }],
AT
)
).toEqual([{ role: 'tool', text: 'exit 0', timestamp: AT }])
})
it('names a tool call even with no recognisable argument', () => {
expect(
transcriptMessagesFromContent('assistant', [{ type: 'tool_use', name: 'Read', input: {} }], AT)
).toEqual([{ role: 'tool', text: 'Read', timestamp: AT }])
})
it('emits nothing for blank or absent content', () => {
expect(transcriptMessagesFromContent('user', ' ', AT)).toEqual([])
expect(transcriptMessagesFromContent('user', null, AT)).toEqual([])
expect(transcriptMessagesFromContent('assistant', [{ type: 'tool_use' }], AT)).toEqual([])
})
it('does not apply the list preview cap', () => {
const long = 'x'.repeat(5000)
const [message] = transcriptMessagesFromContent('user', [{ type: 'text', text: long }], AT)
expect(message.text).toHaveLength(5000)
})
@@ -0,0 +1,138 @@
import { asRecord } from './session-scanner-record-value'
import { sliceAtCodeUnitLimit } from './session-scanner-text-normalization'
import type { AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types'
import type { TranscriptMessage, TranscriptMessageRole } from './session-transcript-consumers'
// Safety bound only: a consumer applies its own caps. Matches the first-prompt
// copy path's ceiling so one pathological paste cannot dominate a scan.
const TRANSCRIPT_MESSAGE_TEXT_LIMIT = 256 * 1024
const TOOL_ARGUMENT_SCAN_LIMIT = 2000
const TEXT_BLOCK_TYPES = new Set(['text', 'input_text', 'output_text', 'thinking', 'reasoning'])
// The argument that identifies what a tool call actually did.
const TOOL_INPUT_KEYS = ['command', 'cmd', 'file_path', 'path', 'pattern', 'query', 'description']
type PreviewRole = AiVaultSessionPreviewMessage['role']
/** Only conversational roles reach consumers; system/unknown turns are noise. */
export function transcriptMessageRole(role: PreviewRole): TranscriptMessageRole | null {
return role === 'user' || role === 'assistant' || role === 'tool' ? role : null
}
export function toolCallText(name: unknown, input: unknown): string | null {
const toolName = typeof name === 'string' && name.trim() ? name.trim() : null
const inputRecord = asRecord(input)
let argument: string | null = null
if (inputRecord) {
for (const key of TOOL_INPUT_KEYS) {
const value = inputRecord[key]
if (typeof value === 'string' && value.trim()) {
argument = value
break
}
}
} else if (typeof input === 'string' && input.trim()) {
argument = input
}
if (!toolName && !argument) {
return null
}
const bounded = argument ? sliceAtCodeUnitLimit(argument, TOOL_ARGUMENT_SCAN_LIMIT) : null
return toolName && bounded ? `${toolName}: ${bounded}` : (toolName ?? bounded)
}
/** Flattens a tool_result body (a string, or an array of text blocks). */
function toolResultText(content: unknown): string | null {
if (typeof content === 'string') {
return content.trim() ? content : null
}
if (!Array.isArray(content)) {
return null
}
const parts: string[] = []
let length = 0
for (const item of content) {
const text = typeof item === 'string' ? item : asRecord(item)?.text
if (typeof text === 'string' && text) {
parts.push(text)
length += text.length
if (length >= TRANSCRIPT_MESSAGE_TEXT_LIMIT) {
break
}
}
}
const joined = parts.join('\n')
return joined.trim() ? joined : null
}
/**
* Splits one provider content value into the messages it decodes to. Text
* blocks keep the record's role; tool_use and tool_result blocks become `tool`
* messages whichever record carried them (Claude stores tool results on user
* records), so a consumer never has to know a provider's record shapes.
*/
export function transcriptMessagesFromContent(
role: PreviewRole,
content: unknown,
timestamp: string | null
): TranscriptMessage[] {
const messages: TranscriptMessage[] = []
const textRole = transcriptMessageRole(role)
if (typeof content === 'string') {
const text = boundedText(content)
return text && textRole ? [{ role: textRole, text, timestamp }] : []
}
const blocks = Array.isArray(content) ? content : content != null ? [content] : []
const textParts: string[] = []
for (const block of blocks) {
if (typeof block === 'string') {
textParts.push(block)
continue
}
const item = asRecord(block)
if (!item) {
continue
}
const type = typeof item.type === 'string' ? item.type : null
if (type === 'tool_use') {
pushMessage(messages, 'tool', toolCallText(item.name, item.input), timestamp)
continue
}
if (type === 'tool_result') {
pushMessage(messages, 'tool', toolResultText(item.content), timestamp)
continue
}
if (type !== null && !TEXT_BLOCK_TYPES.has(type)) {
continue
}
const text = typeof item.text === 'string' ? item.text : item.content
if (typeof text === 'string' && text) {
textParts.push(text)
}
}
if (textRole && textParts.length > 0) {
// The record's own words lead; its tool blocks follow in transcript order.
const text = boundedText(textParts.join('\n'))
if (text) {
messages.unshift({ role: textRole, text, timestamp })
}
}
return messages
}
function pushMessage(
messages: TranscriptMessage[],
role: TranscriptMessageRole,
text: string | null,
timestamp: string | null
): void {
const bounded = text === null ? null : boundedText(text)
if (bounded) {
messages.push({ role, text: bounded, timestamp })
}
}
export function boundedText(value: string): string | null {
const bounded = sliceAtCodeUnitLimit(value, TRANSCRIPT_MESSAGE_TEXT_LIMIT)
return bounded.trim() ? bounded : null
}
@@ -0,0 +1,150 @@
import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { parseAgentSessionFile, parserPublishesMessages } from './session-scanner-agent-parser'
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types'
import type { SessionParseResumePoint } from './session-parse-cache-store'
import { TranscriptMessageChannel } from './session-transcript-channel'
const NEWLINE_BYTE = 0x0a
// Why: this layer owns reading a transcript and nothing else. It decides where
// a read starts, drives the parser, publishes the decoded messages to every
// registered consumer, and reports where the read ended. Which of those results
// are cached, listed or indexed belongs to the callers.
export type TranscriptReadStats = {
incremental: number
fullParses: number
// Transcripts the parser already excluded (Codex workers), re-listed after a
// write and dismissed without reading. Counted apart from `incremental` so a
// scan span still shows how much work the early stop actually removed.
earlyStopped: number
bytesRead: number
}
export type ResumableTranscriptRead = {
session: AiVaultSession | null
/** The fold to resume from next time, and the channel bound to it. */
resume: SessionParseResumePoint
}
/**
* Read an append-only transcript, resuming from `resume` when the file only
* grew and the recorded offset still sits on a line boundary. Anything else
* (a rewrite, a truncation, a platform change) re-reads the whole file.
*/
export async function readResumableTranscript(args: {
candidate: SessionFileCandidate
platform: NodeJS.Platform
resume: SessionParseResumePoint | null
stateFactory: (messages: TranscriptMessageChannel) => ResumableSessionParseState
stats?: TranscriptReadStats
}): Promise<ResumableTranscriptRead> {
const { file } = args.candidate
const resume = args.resume
const canResume =
resume !== null &&
typeof file.sizeBytes === 'number' &&
file.sizeBytes >= resume.byteOffset &&
(resume.byteOffset === 0 || (await endsWithNewlineAt(file.path, resume.byteOffset)))
// Clone before consuming: a failed read must not corrupt the cached state,
// or the next resume would double-count the lines applied before the error.
const channel = canResume ? resume.channel : new TranscriptMessageChannel()
const state = canResume ? resume.state.clone() : args.stateFactory(channel)
const startOffset = canResume ? resume.byteOffset : 0
// Mirrors the reader's entry guard so a dismissed transcript is not reported
// as an incremental parse that read nothing.
const stoppedBeforeRead = state.shouldStop?.() === true
if (args.stats) {
if (stoppedBeforeRead) {
args.stats.earlyStopped++
} else if (canResume) {
args.stats.incremental++
} else {
args.stats.fullParses++
}
}
channel.beginRead({
candidate: args.candidate,
mode: canResume ? 'append' : 'replace',
previousByteOffset: startOffset
})
try {
const readResult = await consumeCompleteJsonlLines({
path: file.path,
start: startOffset,
onLine: (line) => state.consumeLine(line),
// Bound: the optional hooks are declared as methods, so a parser written
// with method syntax must not lose `this` on the way into the reader.
onLineBytes: state.consumeLineBytes?.bind(state),
shouldStop: state.shouldStop?.bind(state)
})
if (args.stats) {
args.stats.bytesRead += readResult.bytesRead
}
// The stat this scan displays is current even when nothing new was consumed.
state.touchFile(file)
// Keep parity with the one-shot parser: a final unterminated line is shown,
// but stays out of the resumable state so the (possibly still-growing) line
// is re-read once complete instead of being half-counted.
let displayState = state
if (readResult.trailingPartialLine !== null) {
const partialLine = readResult.trailingPartialLine
displayState = state.clone()
channel.mute(() => displayState.consumeLine(partialLine))
}
const session = await displayState.finalize(args.platform)
channel.finishRead({ session, byteOffset: readResult.consumedThrough, incomplete: false })
return {
session,
resume: { state, byteOffset: readResult.consumedThrough, channel }
}
} catch (error) {
channel.finishRead({ session: null, byteOffset: startOffset, incomplete: true })
throw error
}
}
/**
* Read a transcript whose format is rewritten in place rather than appended
* (whole-JSON documents, Kimi's state doc, OpenCode). There is no cursor to
* keep, so every read is a whole-file `replace`.
*/
export async function readWholeTranscript(args: {
candidate: SessionFileCandidate
platform: NodeJS.Platform
stats?: TranscriptReadStats
}): Promise<AiVaultSession | null> {
const { file } = args.candidate
if (args.stats) {
args.stats.fullParses++
args.stats.bytesRead += file.sizeBytes ?? 0
}
const publishes = parserPublishesMessages(args.candidate)
const channel = new TranscriptMessageChannel()
channel.beginRead({ candidate: args.candidate, mode: 'replace', previousByteOffset: 0 })
try {
const session = await parseAgentSessionFile(args.candidate, args.platform, channel)
channel.finishRead({ session, byteOffset: file.sizeBytes ?? 0, incomplete: !publishes })
return session
} catch (error) {
channel.finishRead({ session: null, byteOffset: 0, incomplete: true })
throw error
}
}
// A resume point is only valid if it still sits just past a line break;
// anything else means the file was rewritten, not appended. Heuristic: a
// grown rewrite keeping '\n' at exactly this byte would slip through, but
// agent transcripts are append-only so that trade is accepted (worst case is
// a stale vault row until the file is next truncated or the app restarts).
async function endsWithNewlineAt(path: string, offset: number): Promise<boolean> {
const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan')
return slice.length === 1 && slice[0] === NEWLINE_BYTE
}
@@ -20,14 +20,22 @@ function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
function taskId(message: Record<string, unknown>): string | null {
const value = message.task_id
return typeof value === 'string' && value.length > 0 && value.length <= MAX_TASK_ID_LENGTH
? value
: null
/** The bound every task id shares, wherever it enters. An id the roster stores
* becomes a durable entry key, so a provisional one takes the same bound the
* announced path applies — an over-long id is rejected, never truncated. */
export function isBoundedClaudeTaskId(value: string): boolean {
return value.length > 0 && value.length <= MAX_TASK_ID_LENGTH
}
function taskDescription(value: unknown): string | undefined {
/** The task's canonical, resume-stable id. Shared with the subagent roster so
* both readers of this channel agree on what identifies a task. */
export function claudeTaskId(message: Record<string, unknown>): string | null {
const value = message.task_id
return typeof value === 'string' && isBoundedClaudeTaskId(value) ? value : null
}
/** A task's human label, collapsed and bounded. */
export function claudeTaskDescription(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
@@ -107,7 +115,7 @@ export class ClaudeBackgroundTaskTracker {
this.replaceAggregateRoster(message.tasks)
return true
}
const id = taskId(message)
const id = claudeTaskId(message)
if (!id) {
return false
}
@@ -126,13 +134,13 @@ export class ClaudeBackgroundTaskTracker {
}
const existing = this.tasks.get(id)
if (
(patch.is_backgrounded === true || taskDescription(patch.description)) &&
(patch.is_backgrounded === true || claudeTaskDescription(patch.description)) &&
(!this.aggregateRosterObserved || existing)
) {
this.upsert(id, {
backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true,
kind: existing?.kind ?? 'unknown',
description: taskDescription(patch.description) ?? existing?.description
description: claudeTaskDescription(patch.description) ?? existing?.description
})
return true
}
@@ -152,7 +160,7 @@ export class ClaudeBackgroundTaskTracker {
this.upsert(id, {
backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor',
kind,
description: taskDescription(message.description)
description: claudeTaskDescription(message.description)
})
return true
}
@@ -172,14 +180,14 @@ export class ClaudeBackgroundTaskTracker {
if (!task || task.ambient === true) {
continue
}
const id = taskId(task)
const id = claudeTaskId(task)
if (!id) {
continue
}
this.tasks.set(id, {
backgrounded: true,
kind: classifyClaudeBackgroundTaskKind(task.task_type),
description: taskDescription(task.description)
description: claudeTaskDescription(task.description)
})
}
}
@@ -0,0 +1,165 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import {
claudeDispatchInvokesSlashCommand,
claudeDispatchMessageContent
} from './claude-structured-dispatch-content'
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
)
function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem {
return { kind: 'message', role: 'user', blocks }
}
const REMOTE_IMAGE = { type: 'image-ref' as const, url: 'https://example.test/a.png' }
describe('claudeDispatchMessageContent', () => {
it('puts the text block last so a slash command still expands with an attachment', async () => {
const content = await claudeDispatchMessageContent(
// The composer builds text-then-images; Claude only treats a leading `/` as a
// command when the LAST block is text.
userMessage([{ type: 'text', text: '/goal ship the parser' }, REMOTE_IMAGE])
)
expect(content).toEqual([
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } },
{ type: 'text', text: '/goal ship the parser' }
])
})
it('keeps every image ahead of the text and preserves each side’s order', async () => {
const second = { type: 'image-ref' as const, url: 'https://example.test/b.png' }
const content = await claudeDispatchMessageContent(
userMessage([{ type: 'text', text: 'look' }, REMOTE_IMAGE, second])
)
expect(content.map((part) => (part as { type: string }).type)).toEqual([
'image',
'image',
'text'
])
expect(content[0]).toEqual({
type: 'image',
source: { type: 'url', url: 'https://example.test/a.png' }
})
expect(content[1]).toEqual({
type: 'image',
source: { type: 'url', url: 'https://example.test/b.png' }
})
})
it('sends text alone unchanged', async () => {
const content = await claudeDispatchMessageContent(userMessage([{ type: 'text', text: 'hi' }]))
expect(content).toEqual([{ type: 'text', text: 'hi' }])
})
it('sends an image with no text', async () => {
const content = await claudeDispatchMessageContent(userMessage([REMOTE_IMAGE]))
expect(content).toEqual([
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }
])
})
it('rejects a message with no renderable block', async () => {
await expect(
claudeDispatchMessageContent(userMessage([{ type: 'text', text: '' }]))
).rejects.toThrow('Claude dispatch requires text or an image')
})
it('rejects a non-user message', async () => {
await expect(
claudeDispatchMessageContent({
...userMessage([{ type: 'text', text: 'hi' }]),
role: 'assistant'
})
).rejects.toThrow('Claude dispatch accepts only user messages')
})
it('joins several text blocks so a command is not stranded ahead of trailing prose', async () => {
// Appending each block would leave `thanks` trailing, and Claude reads only that block.
const content = await claudeDispatchMessageContent(
userMessage([
{ type: 'text', text: '/goal ship' },
REMOTE_IMAGE,
{ type: 'text', text: 'thanks' }
])
)
expect(content).toEqual([
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } },
{ type: 'text', text: '/goal ship\nthanks' }
])
expect(claudeDispatchInvokesSlashCommand(content)).toBe(true)
})
it('puts a locally attached image ahead of the text, the shape the composer sends', async () => {
const dir = await mkdtemp(join(tmpdir(), 'claude-dispatch-content-'))
const path = join(dir, 'shot.png')
await writeFile(path, PNG)
try {
const content = await claudeDispatchMessageContent(
userMessage([
{ type: 'text', text: '/goal ship' },
{ type: 'image-ref', path }
])
)
expect(content).toEqual([
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: PNG.toString('base64') }
},
{ type: 'text', text: '/goal ship' }
])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
})
describe('claudeDispatchInvokesSlashCommand', () => {
it('reads the trailing prompt Claude recovers, not any text block', () => {
expect(
claudeDispatchInvokesSlashCommand([
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } },
{ type: 'text', text: '/goal ship' }
])
).toBe(true)
// The pre-fix order: Claude recovers no prompt at all, so no command runs.
expect(
claudeDispatchInvokesSlashCommand([
{ type: 'text', text: '/goal ship' },
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }
])
).toBe(false)
})
it('reads the joined prompt, so a command behind leading prose is not one', async () => {
// Keeping the blocks separate would leave `/goal ship` trailing and falsely claim a command.
const content = await claudeDispatchMessageContent(
userMessage([
{ type: 'text', text: 'take a look' },
{ type: 'text', text: '/goal ship' }
])
)
expect(content).toEqual([{ type: 'text', text: 'take a look\n/goal ship' }])
expect(claudeDispatchInvokesSlashCommand(content)).toBe(false)
})
it('matches untrimmed, as Claude does, and ignores a promptless turn', () => {
expect(claudeDispatchInvokesSlashCommand([{ type: 'text', text: ' /goal ship' }])).toBe(false)
expect(claudeDispatchInvokesSlashCommand([{ type: 'text', text: 'ship it' }])).toBe(false)
expect(claudeDispatchInvokesSlashCommand([])).toBe(false)
})
})
@@ -3,6 +3,7 @@ import { open } from 'node:fs/promises'
import { extname } from 'node:path'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import type { NativeChatBlock } from '../../shared/native-chat-types'
import { claudeRecord } from './claude-structured-item-translation'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_IMAGE_COUNT = 20
@@ -88,27 +89,49 @@ async function imageContent(
}
}
/**
* Claude encodes a user turn as attachment blocks followed by the typed text, and recovers the
* typed prompt by reading only the trailing text block. Verified against the real CLI over
* stream-json: a body ending in an image has no recoverable prompt, so its `/command` reaches
* the model as prose instead of being expanded.
*/
export async function claudeDispatchMessageContent(
body: AgentJournalMessageItem
): Promise<unknown[]> {
if (body.role !== 'user') {
throw new Error('Claude dispatch accepts only user messages')
}
const content: unknown[] = []
const images: unknown[] = []
const texts: string[] = []
const imageBudget: ImageBudget = { count: 0, localBytes: 0 }
for (const block of body.blocks as NativeChatBlock[]) {
if (block.type === 'text' && block.text.length > 0) {
content.push({ type: 'text', text: block.text })
texts.push(block.text)
} else if (block.type === 'image-ref') {
content.push(await imageContent(block, imageBudget))
images.push(await imageContent(block, imageBudget))
}
}
// Join rather than append each block: only the trailing text is read as the prompt, so several
// text blocks would silently discard every one but the last.
const content = texts.length > 0 ? [...images, { type: 'text', text: texts.join('\n') }] : images
if (content.length === 0) {
throw new Error('Claude dispatch requires text or an image')
}
return content
}
/** The prompt Claude recovers from a dispatch, or null when the turn carries no prompt. */
function claudeDispatchPrompt(content: readonly unknown[]): string | null {
const last = claudeRecord(content.at(-1))
return last?.type === 'text' && typeof last.text === 'string' ? last.text : null
}
/** Mirrors how Claude decides a turn is a command. Untrimmed on purpose: Claude does not trim
* here either, so leading whitespace really does mean no command runs. */
export function claudeDispatchInvokesSlashCommand(content: readonly unknown[]): boolean {
return claudeDispatchPrompt(content)?.startsWith('/') === true
}
/**
* Keep waiter metadata bounded even when a dispatch contains large base64 images.
* The digest is only diagnostic: replay acknowledgement must use provider identity.
@@ -117,10 +140,7 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string {
const digest = createHash('sha256')
const summary = content
.map((part) => {
const record =
typeof part === 'object' && part !== null && !Array.isArray(part)
? (part as Record<string, unknown>)
: null
const record = claudeRecord(part)
const type = typeof record?.type === 'string' ? record.type : 'unknown'
if (type === 'text') {
return `text:${typeof record?.text === 'string' ? record.text.length : 0}`
@@ -136,10 +156,7 @@ export function claudeDispatchContentKey(content: readonly unknown[]): string {
})
.join(',')
for (const [index, part] of content.entries()) {
const record =
typeof part === 'object' && part !== null && !Array.isArray(part)
? (part as Record<string, unknown>)
: null
const record = claudeRecord(part)
const type = typeof record?.type === 'string' ? record.type : 'unknown'
digest.update(`${index}:${type}:`)
if (type === 'text' && typeof record?.text === 'string') {
@@ -423,6 +423,73 @@ describe('Claude structured dispatch image limits', () => {
})
})
it('accepts a slash command sent with an attachment from its result receipt', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{
clientMessageId: 'client-1',
body: userMessage([
{ type: 'text', text: '/permissions' },
{ type: 'image-ref', url: 'https://example.test/a.png' }
])
},
100
)
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
// The mapper moves the image ahead of the prompt, so Claude runs the command and replies
// with a result receipt instead of a user replay.
expect(
resolveClaudeReplayWaiter(session, {
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'command-result-uuid'
})
).toBe(false)
await expect(dispatched).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { uuid: 'command-result-uuid' }
})
// The sent order is the fix: the waiter's verdict alone was already what it is today.
expect(session.connection.send).toHaveBeenCalledWith(
expect.objectContaining({
message: {
role: 'user',
content: [
{ type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } },
{ type: 'text', text: '/permissions' }
]
}
})
)
})
it('does not take a result receipt for leading whitespace Claude never reads as a command', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{
clientMessageId: 'client-1',
body: userMessage([{ type: 'text', text: ' /permissions' }])
},
100
)
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
expect(
resolveClaudeReplayWaiter(session, {
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'unrelated-result-uuid'
})
).toBe(false)
await expect(dispatched).resolves.toMatchObject({ state: 'unknown' })
})
it('correlates a later slash-command result by user_message_uuid despite a timed-out slash waiter', async () => {
const session = sessionFor()
const first = dispatchClaudeTurn(
@@ -12,6 +12,7 @@ import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-se
import { readClaudeFrameString } from './claude-structured-init-proof'
import {
claudeDispatchContentKey,
claudeDispatchInvokesSlashCommand,
claudeDispatchMessageContent
} from './claude-structured-dispatch-content'
@@ -231,9 +232,9 @@ export async function dispatchClaudeTurn(
return { state: 'rejected', reason: (error as Error).message }
}
const dispatchSequence = ++session.dispatchSequence
const acceptsResult = input.body.blocks.some(
(block) => block.type === 'text' && block.text.trimStart().startsWith('/')
)
// Read the sent content, not the journal blocks: only the mapped trailing prompt decides
// whether Claude runs a command, so the two cannot disagree about which frame settles this.
const acceptsResult = claudeDispatchInvokesSlashCommand(content)
const sentUuid = randomUUID()
const replay = waitForReplay(
session,
@@ -74,6 +74,18 @@ export function claudeMessageIdentity(
return { provider: 'claude', sessionId: envelope.sessionId, uuid: envelope.uuid }
}
/** User bubbles belong to the submitted message; SDK user frames carry echoes
* and tool results, so a user envelope keeps only its tool results. */
export function claudeOutputEnvelope(envelope: ClaudeMessageEnvelope): ClaudeMessageEnvelope {
if (envelope.role !== 'user') {
return envelope
}
return {
...envelope,
content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result')
}
}
function messageBlocks(envelope: ClaudeMessageEnvelope): NativeChatBlock[] {
const blocks: NativeChatBlock[] = []
for (const value of envelope.content) {
@@ -0,0 +1,259 @@
import { describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import type {
NativeChatSubagentEntry,
NativeChatSubagentGroupBlock
} from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
const GROUP_ITEM_ID = 'claude-subagents:claude-session:user-1'
/** The union's other arms carry no client message id, so reading one narrows. */
function orcaClientMessageId(identity: AgentJournalItemIdentity): string | null {
return identity.provider === 'orca' ? identity.clientMessageId : null
}
function harness() {
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => items.push({ identity, body }),
appendTombstone: vi.fn(),
publish: vi.fn()
}
const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' })
const groupRows = () =>
items.filter((item) => orcaClientMessageId(item.identity) === GROUP_ITEM_ID)
const agentsOf = (body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] => {
if (!body || body.kind !== 'message') {
return []
}
const block = body.blocks.find(
(candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group'
)
return block ? block.agents : []
}
/** The last roster row written for one group, so a test can read a group that
* is no longer the live one. */
const rosterIn = (groupId: string): NativeChatSubagentEntry[] =>
agentsOf(
items.findLast((item) => orcaClientMessageId(item.identity) === `claude-subagents:${groupId}`)
?.body
)
const rosterOf = (turnUuid: string): NativeChatSubagentEntry[] =>
rosterIn(`claude-session:${turnUuid}`)
const roster = (): NativeChatSubagentEntry[] => agentsOf(groupRows().at(-1)?.body)
const fallbackRows = (): AgentJournalItemBody[] =>
items
.filter((item) => (orcaClientMessageId(item.identity) ?? '').startsWith('provider-frame:'))
.map((item) => item.body)
return { translator, groupRows, roster, rosterIn, rosterOf, fallbackRows }
}
function userTurn(uuid: string) {
return {
type: 'message' as const,
sessionId: 'orca-session',
startsTurn: true as const,
message: {
type: 'user',
uuid,
session_id: 'claude-session',
parent_tool_use_id: null,
message: { role: 'user', content: [{ type: 'text', text: 'go' }] }
}
}
}
function systemFrame(subtype: string, fields: Record<string, unknown>) {
return {
type: 'message' as const,
sessionId: 'orca-session',
message: { type: 'system', subtype, session_id: 'claude-session', ...fields }
}
}
function spawnResult(uuid: string, toolUseId: string) {
return {
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'user',
uuid,
session_id: 'claude-session',
parent_tool_use_id: null,
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'done' }]
}
}
}
}
function resultFrame() {
return {
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'result',
subtype: 'success',
session_id: 'claude-session',
uuid: 'result-1',
result: 'ok'
}
}
}
describe('claude journal translation — subagents', () => {
it('rosters a spawned subagent and settles it on the spawn call result', () => {
const { translator, roster, fallbackRows } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-1',
tool_use_id: 'toolu_1',
task_type: 'local_agent',
subagent_type: 'explorer',
description: 'Map the lane'
})
)
expect(roster()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Map the lane', state: 'working' })
])
// The task frames stay status-chrome, so none of them prints an opcode row.
expect(fallbackRows()).toEqual([])
translator.handle(spawnResult('user-2', 'toolu_1'))
expect(roster()).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('marks a child still working at turn end unverifiable', () => {
const { translator, roster } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-1',
task_type: 'local_agent',
description: 'Map the lane'
})
)
translator.handle(resultFrame())
expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('leaves a backgrounded child running past the end of its turn', () => {
const { translator, roster } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-1',
tool_use_id: 'toolu_1',
task_type: 'local_agent',
description: 'Watch the build',
is_backgrounded: true
})
)
// A backgrounded spawn returns its tool result immediately; the child runs on.
translator.handle(spawnResult('user-2', 'toolu_1'))
translator.handle(resultFrame())
expect(roster()).toEqual([expect.objectContaining({ state: 'working' })])
translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' })
expect(roster()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('keeps a backgrounded shell task out of the roster entirely', () => {
const { translator, groupRows } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-bash',
tool_use_id: 'toolu_bash',
task_type: 'local_bash',
description: 'sleep 20',
is_backgrounded: true
})
)
translator.handle(resultFrame())
expect(groupRows()).toEqual([])
})
it('shows a subagent whose release announces no task frames, from its child traffic', () => {
const { translator, roster } = harness()
translator.handle(userTurn('user-1'))
translator.handle({
type: 'message' as const,
sessionId: 'orca-session',
message: {
type: 'assistant',
uuid: 'child-1',
session_id: 'claude-session',
parent_tool_use_id: 'toolu_1',
message: { role: 'assistant', content: [{ type: 'text', text: 'looking' }] }
}
})
expect(roster()).toEqual([
expect.objectContaining({ id: 'toolu_1', label: 'subagent', state: 'working' })
])
})
it('settles the turn a new turn superseded, and leaves the new one running', () => {
const { translator, rosterOf } = harness()
translator.handle(userTurn('user-1'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-1',
task_type: 'local_agent',
description: 'First turn'
})
)
// A second turn starts with no result frame for the first: the first turn
// ends here, and nothing else will ever name its group again.
translator.handle(userTurn('user-2'))
translator.handle(
systemFrame('task_started', {
task_id: 'task-2',
task_type: 'local_agent',
description: 'Second turn'
})
)
expect(rosterOf('user-1')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
expect(rosterOf('user-2')).toEqual([expect.objectContaining({ state: 'working' })])
})
it('does not let an unrelated turn end settle a child announced outside a turn', () => {
const { translator, rosterIn } = harness()
// No turn is live yet, so this child has no turn key to belong to.
translator.handle(
systemFrame('task_started', {
task_id: 'task-early',
task_type: 'local_agent',
description: 'Before the turn'
})
)
translator.handle(userTurn('user-1'))
translator.handle(resultFrame())
expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })])
// The outcome still lands, which a latched `unverifiable` would have lost.
translator.handle(
systemFrame('task_updated', { task_id: 'task-early', patch: { status: 'completed' } })
)
expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('settles a child left outside every turn when the session ends', () => {
const { translator, rosterIn } = harness()
translator.handle(
systemFrame('task_started', {
task_id: 'task-early',
task_type: 'local_agent',
description: 'Before the turn'
})
)
translator.handle(userTurn('user-1'))
translator.handle(resultFrame())
translator.handle({ type: 'ended', sessionId: 'orca-session', reason: 'closed' })
expect(rosterIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
})
@@ -11,9 +11,8 @@ import {
claudeMessageBody,
claudeMessageIdentity,
claudeHasReplayContent,
claudeRecord,
claudeOutputEnvelope,
claudeStreamingMessageBody,
claudeText,
claudeThinkingIdentity,
claudeThinkingText,
claudeToolBody,
@@ -29,16 +28,15 @@ import {
claudeQuestionItems
} from './claude-structured-prompt-items'
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
import { readableProviderFrameText } from '../native-chat/agent-session-wire/unhandled-provider-frame'
import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity'
import {
CLAUDE_UNRENDERABLE_CONTENT_TEXT,
appendUnmodeledClaudeContent,
claudeProviderFrameKind,
claudeResultFailure,
createClaudeProviderFrameFallback,
isModeledClaudeContent,
isSettledClaudeResultKind
} from './claude-structured-provider-fallback'
import { ClaudeSubagentRoster } from './claude-subagent-roster'
import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity'
import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
@@ -89,10 +87,16 @@ export function createClaudeJournalTranslator(
const promptItems = new Map<string, AgentJournalItemIdentity[]>()
const streamedBlocks = createClaudeStreamedBlockRegistry()
let currentTurn: { sessionId: string; turnId: string } | null = null
const groupKeyOf = (turn: { sessionId: string; turnId: string } | null): string | null =>
turn ? `${turn.sessionId}:${turn.turnId}` : null
const providerFallback = createClaudeProviderFrameFallback(
deps.sink,
deps.fallbackIdPrefix ?? 'acquisition'
)
const subagents = new ClaudeSubagentRoster({
sink: deps.sink,
currentGroupKey: () => groupKeyOf(currentTurn)
})
const streamedText = createClaudeStreamedTextCheckpoints({
...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }),
...(deps.schedule ? { schedule: deps.schedule } : {}),
@@ -144,14 +148,10 @@ export function createClaudeJournalTranslator(
return false
}
let changed = false
// User bubbles belong to the submitted message; SDK user frames carry echoes and tool results.
const outputEnvelope =
envelope.role === 'user'
? {
...envelope,
content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result')
}
: envelope
if (envelope.parentToolUseId) {
subagents.observeChildActivity(envelope.parentToolUseId)
}
const outputEnvelope = claudeOutputEnvelope(envelope)
const body = claudeMessageBody(outputEnvelope)
// The final frame of a streamed block lands on the block's identity, not its own uuid.
const identity =
@@ -180,6 +180,8 @@ export function createClaudeJournalTranslator(
claudeToolIdentity(envelope.sessionId, result.toolUseId),
claudeToolBody({ tool, result })
)
// A spawn call's result is the parent turn's evidence its child finished.
subagents.observeToolResult(result.toolUseId, result.failed)
// Tool inputs are only needed until their matching result arrives.
tools.delete(result.toolUseId)
changed = true
@@ -192,21 +194,7 @@ export function createClaudeJournalTranslator(
})
changed = true
}
const unhandledContent = outputEnvelope.content.filter((part) => !isModeledClaudeContent(part))
for (const part of unhandledContent) {
const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown'
providerFallback.append(
`message:${envelope.role}:content:${partType}`,
part,
readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT
)
changed = true
}
// An empty user frame is a replay with nothing to show, not an unknown kind.
if (envelope.content.length === 0 && envelope.role === 'assistant') {
providerFallback.append(`message:${envelope.role}:empty`, message)
changed = true
}
changed = appendUnmodeledClaudeContent(providerFallback, outputEnvelope, message) || changed
if (
envelope.role === 'user' &&
startsTurn &&
@@ -214,6 +202,9 @@ export function createClaudeJournalTranslator(
message.parent_tool_use_id === null
) {
if (currentTurn) {
// A new turn starting is the only end the previous one gets when its
// result never arrives; settling it later would sweep THIS turn.
subagents.settleTurn(groupKeyOf(currentTurn))
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
}
currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid }
@@ -254,6 +245,8 @@ export function createClaudeJournalTranslator(
handle: (event) => {
if (event.type === 'ended') {
streamedText.flush()
// No event will ever settle a child once the provider is gone.
subagents.settleSession()
if (currentTurn) {
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
currentTurn = null
@@ -274,6 +267,9 @@ export function createClaudeJournalTranslator(
promptItems.delete(event.promptKey)
deps.sink.publish()
} else if (event.type === 'message' && event.message.type === 'result') {
// The turn is over however it ended, so a foreground child still
// reported as working will never be settled by an event.
subagents.settleTurn(groupKeyOf(currentTurn))
if (currentTurn) {
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
currentTurn = null
@@ -291,6 +287,9 @@ export function createClaudeJournalTranslator(
providerFallback.append(kind, event.message, failure?.text)
}
} else if (event.type === 'message') {
// These frames stay `status-chrome`: the roster reads them here, and the
// fallback below still drops the raw frame instead of printing an opcode.
subagents.observeSystemFrame(event.message)
const kind = claudeProviderFrameKind(event.message)
if (!handleMessage(event.message, event.startsTurn === true)) {
providerFallback.append(kind, event.message)
@@ -310,6 +309,7 @@ export function createClaudeJournalTranslator(
tools.clear()
promptItems.clear()
streamedBlocks.clear()
subagents.dispose()
}
}
}
@@ -4,8 +4,15 @@ import {
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema'
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
import { claudeRecord, claudeText } from './claude-structured-item-translation'
import {
readableProviderFrameText,
unhandledProviderFrameJournalItem
} from '../native-chat/agent-session-wire/unhandled-provider-frame'
import {
claudeRecord,
claudeText,
type ClaudeMessageEnvelope
} from './claude-structured-item-translation'
export function claudeProviderFrameKind(message: Record<string, unknown>): string {
const type = claudeText(message.type) ?? 'unknown'
@@ -123,3 +130,30 @@ export function createClaudeProviderFrameFallback(
}
}
}
export type ClaudeProviderFrameFallback = ReturnType<typeof createClaudeProviderFrameFallback>
/** Journal each content part this build does not model, plus the empty assistant
* frame a replay leaves behind (an empty USER frame is a replay with nothing to
* show, not an unknown kind). Returns whether anything was appended. */
export function appendUnmodeledClaudeContent(
fallback: ClaudeProviderFrameFallback,
envelope: ClaudeMessageEnvelope,
message: Record<string, unknown>
): boolean {
let changed = false
for (const part of envelope.content.filter((part) => !isModeledClaudeContent(part))) {
const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown'
fallback.append(
`message:${envelope.role}:content:${partType}`,
part,
readableProviderFrameText(part) ?? CLAUDE_UNRENDERABLE_CONTENT_TEXT
)
changed = true
}
if (envelope.content.length === 0 && envelope.role === 'assistant') {
fallback.append(`message:${envelope.role}:empty`, message)
changed = true
}
return changed
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import { claudeSubagentGroupBody } from './claude-subagent-group-row'
function entry(id: string, state: NativeChatSubagentEntry['state']): NativeChatSubagentEntry {
return { id, label: id, state, startedAt: 1 }
}
/** The fallback sentence is the WHOLE row on mobile and paired web, which have
* no roster renderer, so these assertions are the entire contract there. */
function sentence(agents: readonly NativeChatSubagentEntry[]): string {
const body = claudeSubagentGroupBody('turn-1', agents)
const block = body.kind === 'message' ? body.blocks[0] : undefined
return block && block.type === 'text' ? block.text : ''
}
describe('claudeSubagentGroupBody fallback sentence', () => {
it('reads as a plain completion when every child completed', () => {
expect(sentence([entry('a', 'completed'), entry('b', 'completed')])).toBe('Ran 2 subagents')
})
it('keeps the singular noun for a lone child', () => {
expect(sentence([entry('a', 'completed')])).toBe('Ran 1 subagent')
expect(sentence([entry('a', 'working')])).toBe('Kicked off 1 subagent')
})
it('names an unverifiable child instead of claiming the group ran', () => {
expect(sentence([entry('a', 'completed'), entry('b', 'unverifiable')])).toBe(
'Ran 2 subagents (1 unverifiable)'
)
})
it('ranks the adverse outcome worst-first', () => {
expect(
sentence([entry('a', 'failed'), entry('b', 'unverifiable'), entry('c', 'completed')])
).toBe('Ran 3 subagents (1 failed)')
expect(sentence([entry('a', 'stopped'), entry('b', 'unverifiable')])).toBe(
'Ran 2 subagents (1 stopped)'
)
})
it('shows the adverse outcome while a sibling still works', () => {
expect(
sentence([entry('a', 'working'), entry('b', 'working'), entry('c', 'unverifiable')])
).toBe('Kicked off 3 subagents (1 unverifiable)')
})
it('leaves a benign settled state out of the sentence', () => {
expect(sentence([entry('a', 'idle'), entry('b', 'completed')])).toBe('Ran 2 subagents')
})
it('counts every child holding the worst adverse state', () => {
expect(sentence([entry('a', 'failed'), entry('b', 'failed'), entry('c', 'stopped')])).toBe(
'Ran 3 subagents (2 failed)'
)
})
})
@@ -0,0 +1,32 @@
// The journal row one Claude spawn group writes: its durable identity and the
// body it revises in place.
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { subagentGroupFallbackText } from '../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
/** Durable journal identity for the group's row — stable across revisions and
* across a restart, so replay finds the same row instead of appending a new one. */
export function claudeSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity {
return { provider: 'orca', clientMessageId: `claude-subagents:${groupId}` }
}
/** The roster row: the structured block plus the plain sentence an older client
* renders in its place. A message whose only block is the new variant would
* reach such a client with nothing it can draw. */
export function claudeSubagentGroupBody(
groupId: string,
agents: readonly NativeChatSubagentEntry[]
): AgentJournalItemBody {
return {
kind: 'message',
role: 'system',
blocks: [
{ type: 'text', text: subagentGroupFallbackText(agents) },
{ type: 'subagent-group', groupId, agents: [...agents] }
]
}
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { ClaudeSubagentIds } from './claude-subagent-id-aliases'
describe('ClaudeSubagentIds', () => {
it('resolves an aliased tool id to its task, and an unaliased id to itself', () => {
const ids = new ClaudeSubagentIds()
ids.alias('toolu_1', 'task-1')
expect(ids.canonical('toolu_1')).toBe('task-1')
expect(ids.canonical('toolu_unknown')).toBe('toolu_unknown')
})
it('remembers an exclusion under either of the ids that named it', () => {
const ids = new ClaudeSubagentIds()
ids.exclude('task-bash')
expect(ids.isExcluded('toolu_bash', 'task-bash')).toBe(true)
expect(ids.isExcluded(null, null)).toBe(false)
expect(ids.isExcluded('task-agent')).toBe(false)
})
it('drops the oldest alias past the bound and keeps the newest', () => {
const ids = new ClaudeSubagentIds()
for (let index = 0; index <= 512; index += 1) {
ids.alias(`toolu_${index}`, `task-${index}`)
}
// Evicted: the id now stands only for itself.
expect(ids.canonical('toolu_0')).toBe('toolu_0')
expect(ids.canonical('toolu_512')).toBe('task-512')
expect(ids.canonical('toolu_1')).toBe('task-1')
})
it('drops the oldest exclusion past the bound and keeps the newest', () => {
const ids = new ClaudeSubagentIds()
for (let index = 0; index <= 512; index += 1) {
ids.exclude(`task-${index}`)
}
expect(ids.isExcluded('task-0')).toBe(false)
expect(ids.isExcluded('task-512')).toBe(true)
expect(ids.isExcluded('task-1')).toBe(true)
})
it('does not retain oversized aliases or exclusions', () => {
const ids = new ClaudeSubagentIds()
const oversized = 'x'.repeat(513)
ids.alias(oversized, 'task-1')
ids.alias('tool-1', oversized)
ids.exclude(oversized)
expect(ids.canonical(oversized)).toBe(oversized)
expect(ids.canonical('tool-1')).toBe('tool-1')
expect(ids.isExcluded(oversized)).toBe(false)
})
it('forgets everything on clear', () => {
const ids = new ClaudeSubagentIds()
ids.alias('toolu_1', 'task-1')
ids.exclude('task-1')
ids.clear()
expect(ids.canonical('toolu_1')).toBe('toolu_1')
expect(ids.isExcluded('task-1')).toBe(false)
})
})
@@ -0,0 +1,63 @@
// Which Claude ids name the same subagent, and which name no subagent at all.
//
// Claude re-announces a resumed task under a NEW `tool_use_id` while `task_id`
// stays put, so tool ids are aliases of a canonical task id — a store keyed on
// the tool id would show the child twice after every resume.
//
// The exclusions matter just as much: `task_updated` carries no `task_type` and
// child traffic carries no task metadata at all, so the one announcement that
// said "this is a backgrounded shell, not an agent" has to be remembered or a
// later frame re-admits it.
import { isBoundedClaudeTaskId } from './claude-background-task-tracker'
/** Both maps are event-accumulated and nothing prunes them, so both are bounded. */
const MAX_TOOL_USE_ALIASES = 512
const MAX_EXCLUDED_IDS = 512
export class ClaudeSubagentIds {
private readonly canonicalByToolUse = new Map<string, string>()
private readonly excluded = new Set<string>()
/** The task id a tool id stands for, or the id itself when nothing aliases it. */
canonical(id: string): string {
return this.canonicalByToolUse.get(id) ?? id
}
alias(toolUseId: string, taskId: string): void {
if (!isBoundedClaudeTaskId(toolUseId) || !isBoundedClaudeTaskId(taskId)) {
return
}
this.canonicalByToolUse.set(toolUseId, taskId)
while (this.canonicalByToolUse.size > MAX_TOOL_USE_ALIASES) {
const oldest = this.canonicalByToolUse.keys().next()
if (oldest.done || oldest.value === toolUseId) {
break
}
this.canonicalByToolUse.delete(oldest.value)
}
}
exclude(id: string): void {
if (!isBoundedClaudeTaskId(id)) {
return
}
this.excluded.add(id)
while (this.excluded.size > MAX_EXCLUDED_IDS) {
const oldest = this.excluded.values().next()
if (oldest.done || oldest.value === id) {
break
}
this.excluded.delete(oldest.value)
}
}
isExcluded(...ids: (string | null)[]): boolean {
return ids.some((id) => id !== null && this.excluded.has(id))
}
clear(): void {
this.canonicalByToolUse.clear()
this.excluded.clear()
}
}
@@ -0,0 +1,75 @@
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { ClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
const MAX_INVOCATIONS_PER_SUBAGENT = 16
export type TrackedEntry = {
entry: NativeChatSubagentEntry
/** The only signal separating a child that dies with its turn from one told to
* outlive it. A turn-end sweep must leave a backgrounded child alone. */
backgrounded: boolean
toolUseId: string | null
invocationIds: Set<string> | null
/** Label before its ordinal suffix, so a later announcement can tell a
* provisional row from one that already carries the provider's own name. */
labelBase: string
}
export type RosterGroup = {
groupId: string
identity: AgentJournalItemIdentity
/** Insertion order is the display order; the map holds the state. */
entries: Map<string, TrackedEntry>
/** Lifetime admissions bound retained labels even when entries are removed. */
admittedEntries: number
/** Labels remain reserved after removal or provisional-name replacement. */
claimedLabels: Set<string>
/** Last body written, so an idempotent replay writes no new revision. */
lastSerialized: string | null
}
// Invocation history stays with the entry, independent of the evicting alias cache.
export function applyClaudeSubagentInvocation(
tracked: TrackedEntry,
frame: ClaudeSubagentTaskFrame,
now: () => number
): boolean {
if (tracked.invocationIds === null) {
return false
}
const newInvocation =
frame.announcement && frame.toolUseId !== null && !tracked.invocationIds.has(frame.toolUseId)
if (newInvocation && frame.toolUseId) {
if (tracked.invocationIds.size >= MAX_INVOCATIONS_PER_SUBAGENT) {
tracked.invocationIds = null
tracked.entry = { ...tracked.entry, state: 'unverifiable', settledAt: now() }
return true
}
tracked.invocationIds.add(frame.toolUseId)
if (tracked.toolUseId !== null && tracked.toolUseId !== frame.toolUseId) {
tracked.backgrounded = frame.backgrounded ?? false
tracked.entry = { ...tracked.entry, state: frame.state ?? 'working', settledAt: undefined }
}
tracked.toolUseId = frame.toolUseId
} else if (tracked.toolUseId && frame.toolUseId && tracked.toolUseId !== frame.toolUseId) {
return false
}
if (tracked.toolUseId === null) {
tracked.toolUseId = frame.toolUseId
}
return true
}
/** Two children can share a description; the ordinal keeps their rows apart
* without inventing a name the provider never sent. The probe is over the
* labels actually rendered, not a per-base counter: a generated `Audit 2`
* must not collide with a provider that names its own child `Audit 2`. */
export function claimClaudeSubagentLabel(group: RosterGroup, base: string): string {
let candidate = base
for (let ordinal = 2; group.claimedLabels.has(candidate); ordinal++) {
candidate = `${base} ${ordinal}`
}
group.claimedLabels.add(candidate)
return candidate
}
@@ -0,0 +1,602 @@
import { describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import type {
NativeChatSubagentEntry,
NativeChatSubagentGroupBlock
} from '../../shared/native-chat-types'
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import {
createDeferredStructuredAgentSessionEventSink,
type StructuredAgentSessionEventSink
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { ClaudeSubagentRoster } from './claude-subagent-roster'
const TURN_1 = 'claude-session:turn-1'
function agentsOf(body: AgentJournalItemBody | undefined): NativeChatSubagentEntry[] {
if (!body || body.kind !== 'message') {
return []
}
const block = body.blocks.find(
(candidate): candidate is NativeChatSubagentGroupBlock => candidate.type === 'subagent-group'
)
return block ? block.agents : []
}
function isGroupRow(identity: AgentJournalItemIdentity, groupId: string): boolean {
return identity.provider === 'orca' && identity.clientMessageId === `claude-subagents:${groupId}`
}
function harness(groupKey: string | null = TURN_1) {
const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = []
const tombstones: AgentJournalItemIdentity[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity, body) => items.push({ identity, body }),
appendTombstone: (identity) => tombstones.push(identity),
publish: vi.fn()
}
let clock = 1_000
let key = groupKey
const roster = new ClaudeSubagentRoster({
sink,
currentGroupKey: () => key,
now: () => (clock += 1)
})
const roles = (): NativeChatSubagentEntry[] => agentsOf(items.at(-1)?.body)
/** The last row written for one group, so a test can read a row that is no
* longer the newest one. */
const rolesIn = (groupId: string): NativeChatSubagentEntry[] =>
agentsOf(items.findLast((item) => isGroupRow(item.identity, groupId))?.body)
return {
roster,
items,
tombstones,
roles,
rolesIn,
setGroupKey: (next: string | null) => {
key = next
}
}
}
function system(subtype: string, fields: Record<string, unknown>): Record<string, unknown> {
return { type: 'system', subtype, session_id: 'claude-session', ...fields }
}
function started(fields: Record<string, unknown>): Record<string, unknown> {
return system('task_started', { task_type: 'local_agent', ...fields })
}
describe('ClaudeSubagentRoster', () => {
it('builds the row from task_started, with the fallback sentence beside the block', () => {
const { roster, items, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Review the diff' })
)
expect(items).toHaveLength(1)
expect(items[0]?.identity).toEqual({
provider: 'orca',
clientMessageId: 'claude-subagents:claude-session:turn-1'
})
const body = items[0]?.body
expect(body?.kind === 'message' && body.blocks[0]).toEqual({
type: 'text',
text: 'Kicked off 1 subagent'
})
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Review the diff', state: 'working' })
])
})
it('keeps a backgrounded shell task out of the roster', () => {
const { roster, items } = harness()
roster.observeSystemFrame(
system('task_started', {
task_id: 'task-bash',
tool_use_id: 'toolu_bash',
task_type: 'local_bash',
description: 'sleep 20',
is_backgrounded: true
})
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-bash', patch: { status: 'running' } })
)
// Its own frames carry a tool_use_id, so only the excluded-id memory stops it.
roster.observeChildActivity('toolu_bash')
expect(items).toHaveLength(0)
})
it('never renders a task marked skip_transcript', () => {
const { roster, items } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-a', tool_use_id: 'toolu_a', skip_transcript: true })
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-a', patch: { status: 'completed' } })
)
roster.observeChildActivity('toolu_a')
expect(items).toHaveLength(0)
})
it('drops a provisional row once an announcement says the task is not a subagent', () => {
const { roster, items, tombstones, roles } = harness()
roster.observeChildActivity('toolu_bash')
expect(roles()).toHaveLength(1)
roster.observeSystemFrame(
system('task_started', {
task_id: 'task-bash',
tool_use_id: 'toolu_bash',
task_type: 'local_bash'
})
)
expect(tombstones).toEqual([
{ provider: 'orca', clientMessageId: 'claude-subagents:claude-session:turn-1' }
])
expect(items).toHaveLength(1)
})
it('does not duplicate a resumed task re-announced under a new tool_use_id', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_first', description: 'Audit' })
)
roster.observeChildActivity('toolu_first')
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_second', description: 'Audit' })
)
roster.observeChildActivity('toolu_second')
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Audit', state: 'working' })
])
})
it('adopts a row built from child traffic when the announcement finally names it', () => {
const { roster, roles } = harness()
roster.observeChildActivity('toolu_1')
expect(roles()).toEqual([expect.objectContaining({ id: 'toolu_1', label: 'subagent' })])
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' })
)
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' })
])
})
it('is idempotent: a repeated frame writes no new revision', () => {
const { roster, items } = harness()
const frame = started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Audit' })
roster.observeSystemFrame(frame)
roster.observeSystemFrame(frame)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'running' } })
)
expect(items).toHaveLength(1)
})
it('latches a terminal state against a later live report', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'failed' } })
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'running' } })
)
expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })])
})
it('ignores an update for a task it never rostered', () => {
const { roster, items } = harness()
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-unknown', patch: { status: 'running' } })
)
expect(items).toHaveLength(0)
})
it('disambiguates children that share a description', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Explore' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Explore' }))
expect(roles().map((agent) => agent.label)).toEqual(['Explore', 'Explore 2'])
})
describe('turn end', () => {
it('leaves a backgrounded child working and marks a foreground one unverifiable', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-fg', description: 'Foreground' }))
roster.observeSystemFrame(
started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true })
)
roster.settleTurn(TURN_1)
expect(roles()).toEqual([
expect.objectContaining({ label: 'Foreground', state: 'unverifiable' }),
expect.objectContaining({ label: 'Background', state: 'working' })
])
})
it('never re-settles a child that already reported an outcome', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } })
)
roster.settleTurn(TURN_1)
expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('sweeps backgrounded children only when the provider itself is gone', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true })
)
roster.settleTurn(TURN_1)
roster.settleSession()
expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
})
describe('spawn tool result', () => {
it('settles a foreground child', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' }))
roster.observeToolResult('toolu_1', false)
expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('reports a failed spawn as failed', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'toolu_1' }))
roster.observeToolResult('toolu_1', true)
expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })])
})
it('ignores the immediate result a backgrounded spawn returns', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', is_backgrounded: true })
)
roster.observeToolResult('toolu_1', false)
expect(roles()).toEqual([expect.objectContaining({ state: 'working' })])
})
it('ignores results for tools that are not spawn calls', () => {
const { roster, items } = harness()
roster.observeToolResult('toolu_read', false)
expect(items).toHaveLength(0)
})
})
describe('label ordinals', () => {
it('never re-issues an ordinal a removed row gave up', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' }))
// task-1 is re-announced as a shell task, so its row goes; reclaiming the
// ordinal it held would print a second 'Audit 2' beside the one still shown.
roster.observeSystemFrame(
system('task_started', { task_id: 'task-1', task_type: 'local_bash' })
)
roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit' }))
expect(roles().map((agent) => agent.label)).toEqual(['Audit 2', 'Audit 3'])
})
it('never generates a label a provider-supplied one already took', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Audit' }))
// The provider's own name for the third child is the label the ordinal just
// generated for the second; a per-base counter would print it twice.
roster.observeSystemFrame(started({ task_id: 'task-3', description: 'Audit 2' }))
const labels = roles().map((agent) => agent.label)
expect(labels).toEqual(['Audit', 'Audit 2', 'Audit 2 2'])
expect(new Set(labels).size).toBe(labels.length)
})
})
describe('child traffic for an id the CLI never declared', () => {
it('creates nothing once the CLI has announced any task at all', () => {
const { roster, items } = harness()
// A rejected announcement still proves this CLI declares what it spawns.
roster.observeSystemFrame(
system('task_started', { task_id: 'task-bash', task_type: 'local_bash' })
)
roster.observeChildActivity('toolu_never_announced')
expect(items).toHaveLength(0)
})
it('rejects an over-long provisional id instead of storing it as an entry id', () => {
const { roster, items } = harness()
// The announced path drops an id past `claudeTaskId`'s bound; the
// provisional one writes the same durable entry id, so it must too.
roster.observeChildActivity(`toolu_${'x'.repeat(512)}`)
expect(items).toHaveLength(0)
roster.observeChildActivity(`toolu_${'x'.repeat(500)}`)
expect(items).toHaveLength(1)
})
it('still rosters a subagent announced after a task the filter rejected', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
system('task_started', { task_id: 'task-bash', task_type: 'local_bash' })
)
// The gate closes the child-traffic fallback, never the announcement path.
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' })
)
roster.observeChildActivity('toolu_1')
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' })
])
})
it('leaves a grandchild parented inside the sidechain out of the roster', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'toolu_1', description: 'Explore' })
)
roster.observeChildActivity('toolu_1')
// A tool the subagent itself ran: never announced, so never excluded either.
roster.observeChildActivity('toolu_inner')
expect(roles()).toEqual([
expect.objectContaining({ id: 'task-1', label: 'Explore', state: 'working' })
])
})
it('still mints the provisional row for a release that announces no task', () => {
const { roster, roles } = harness()
roster.observeChildActivity('toolu_1')
// Not an announcement: the fallback path stays open for this release.
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-x', patch: { status: 'running' } })
)
roster.observeChildActivity('toolu_2')
expect(roles().map((agent) => agent.label)).toEqual(['subagent', 'subagent 2'])
})
})
describe('groups that no later event can reach', () => {
it('loses contact with a group evicted past the bound', () => {
const { roster, rolesIn, setGroupKey } = harness('turn-0')
for (let index = 0; index < 33; index += 1) {
setGroupKey(`turn-${index}`)
roster.observeSystemFrame(started({ task_id: `task-${index}`, description: 'Audit' }))
}
expect(rolesIn('turn-0')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
expect(rolesIn('turn-32')).toEqual([expect.objectContaining({ state: 'working' })])
})
it('loses contact with a live child when the translator is disposed without an end', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-bg', description: 'Background', is_backgrounded: true })
)
roster.dispose()
expect(roles()).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('writes nothing on dispose when the session already settled', () => {
const { roster, items } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
roster.settleSession()
const written = items.length
roster.dispose()
expect(items).toHaveLength(written)
})
})
it('groups children outside any turn under their own row', () => {
const { roster, items } = harness(null)
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'Audit' }))
expect(items[0]?.identity).toEqual({
provider: 'orca',
clientMessageId: 'claude-subagents:outside-turn'
})
})
})
describe('ClaudeSubagentRoster — the turn that is ending', () => {
it('leaves a child announced outside any turn alone when an unrelated turn ends', () => {
const { roster, rolesIn, setGroupKey } = harness(null)
roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' }))
setGroupKey(TURN_1)
roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' }))
roster.settleTurn(TURN_1)
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'working' })])
expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })])
// `unverifiable` latches, so sweeping it above would have swallowed this.
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-early', patch: { status: 'completed' } })
)
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('sweeps the outside-turn group when a turn with no key of its own ends', () => {
const { roster, rolesIn } = harness(null)
roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' }))
roster.settleTurn(null)
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('still settles an outside-turn child once the session itself ends', () => {
const { roster, rolesIn, setGroupKey } = harness(null)
roster.observeSystemFrame(started({ task_id: 'task-early', description: 'Early' }))
setGroupKey(TURN_1)
roster.observeSystemFrame(started({ task_id: 'task-turn', description: 'In turn' }))
roster.settleTurn(TURN_1)
roster.settleSession()
expect(rolesIn('outside-turn')).toEqual([expect.objectContaining({ state: 'unverifiable' })])
})
it('sweeps the turn that ended, not whichever turn is live now', () => {
const { roster, rolesIn, setGroupKey } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'First turn' }))
setGroupKey('claude-session:turn-2')
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Second turn' }))
// Turn 1's result lands after turn 2 has already begun.
roster.settleTurn(TURN_1)
expect(rolesIn(TURN_1)).toEqual([expect.objectContaining({ state: 'unverifiable' })])
expect(rolesIn('claude-session:turn-2')).toEqual([
expect.objectContaining({ state: 'working' })
])
})
})
describe('ClaudeSubagentRoster — through the real sink queue', () => {
it('lands every revision, not just the one that was already in flight', async () => {
const appended: AgentJournalItemBody[] = []
let published = 0
const journal = {
appendItem: async (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
appended.push(body)
return { cursor: { epoch: 'e', sequence: appended.length } }
},
appendTombstone: async () => ({ epoch: 'e', sequence: 0 })
} as unknown as AgentSessionJournal
const deferred = createDeferredStructuredAgentSessionEventSink()
deferred.bind({
journal,
fence: 1,
publish: () => {
published += 1
}
})
const roster = new ClaudeSubagentRoster({ sink: deferred.sink, currentGroupKey: () => TURN_1 })
// The first append is in flight while the rest are submitted, so a publish
// sharing the row's coalescing key would evict them.
roster.observeSystemFrame(started({ task_id: 'task-1', description: 'One' }))
roster.observeSystemFrame(started({ task_id: 'task-2', description: 'Two' }))
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { status: 'completed' } })
)
const drained = await deferred.drained()
expect(drained).toEqual({ ok: true })
expect(agentsOf(appended.at(-1))).toEqual([
expect.objectContaining({ id: 'task-1', label: 'One', state: 'completed' }),
expect.objectContaining({ id: 'task-2', label: 'Two', state: 'working' })
])
expect(published).toBeGreaterThan(0)
})
})
describe('ClaudeSubagentRoster — authoritative outcomes and retained budgets', () => {
it('accepts a notification after the foreground turn lost contact', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1' }))
roster.settleTurn(TURN_1)
roster.observeSystemFrame(
system('task_notification', { task_id: 'task-1', status: 'completed' })
)
expect(roles()).toEqual([expect.objectContaining({ state: 'completed' })])
})
it('settles a background child from its notification without a task_updated', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', is_backgrounded: true }))
roster.settleTurn(TURN_1)
roster.observeSystemFrame(system('task_notification', { task_id: 'task-1', status: 'failed' }))
expect(roles()).toEqual([expect.objectContaining({ state: 'failed' })])
})
it('bounds lifetime admissions when reclassification repeatedly removes entries', () => {
const { roster, items } = harness()
for (let i = 0; i < 100; i++) {
roster.observeSystemFrame(started({ task_id: `task-${i}`, description: `Agent ${i}` }))
roster.observeSystemFrame(
system('task_started', { task_id: `task-${i}`, task_type: 'local_bash' })
)
}
expect(items).toHaveLength(64)
})
})
describe('ClaudeSubagentRoster — resumed invocation', () => {
it('reopens one canonical child on a new announcement without replaying old results', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
roster.observeToolResult('first', false)
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true })
)
expect(roles()).toEqual([expect.objectContaining({ id: 'task-1', state: 'working' })])
expect(roles()[0].settledAt).toBeUndefined()
roster.observeSystemFrame(
system('task_notification', { task_id: 'task-1', tool_use_id: 'first', status: 'completed' })
)
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
expect(roles()[0].state).toBe('working')
roster.observeSystemFrame(
system('task_notification', {
task_id: 'task-1',
tool_use_id: 'resumed',
status: 'completed'
})
)
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'resumed', is_backgrounded: true })
)
expect(roles()[0].state).toBe('completed')
})
})
describe('ClaudeSubagentRoster — invocation fences', () => {
it('ignores a previous invocation tool result even without a background flag', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
roster.observeToolResult('first', false)
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'next' }))
roster.observeToolResult('first', true)
expect(roles()[0].state).toBe('working')
roster.observeToolResult('next', false)
expect(roles()[0].state).toBe('completed')
})
it('does not treat an evicted alias as a new invocation', () => {
const { roster, rolesIn, setGroupKey } = harness()
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
roster.observeToolResult('first', false)
setGroupKey('churn')
for (let i = 0; i < 513; i++) {
roster.observeSystemFrame(
system('task_updated', { task_id: `other-${i}`, tool_use_id: `tool-${i}` })
)
}
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'first' }))
expect(rolesIn(TURN_1)[0].state).toBe('completed')
})
it('bounds invocation history and refuses to reopen beyond the retained budget', () => {
const { roster, roles } = harness()
for (let i = 0; i < 20; i++) {
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: `tool-${i}` }))
if (i >= 16) {
expect(roles()[0].state).toBe('unverifiable')
}
roster.observeToolResult(`tool-${i}`, false)
}
roster.observeSystemFrame(started({ task_id: 'task-1', tool_use_id: 'tool-0' }))
expect(roles()[0].state).toBe('unverifiable')
})
})
it('merges an explicit foreground patch without clearing on absent metadata', () => {
const { roster, roles } = harness()
roster.observeSystemFrame(
started({ task_id: 'task-1', tool_use_id: 'tool', is_backgrounded: true })
)
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { description: 'Audit' } })
)
roster.observeToolResult('tool', false)
expect(roles()[0].state).toBe('working')
roster.observeSystemFrame(
system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: false } })
)
roster.observeToolResult('tool', false)
expect(roles()[0].state).toBe('completed')
})
+388
View File
@@ -0,0 +1,388 @@
// The Claude subagent roster: one journal row per turn that spawned children.
//
// Entries are built from `task_started`, never from child traffic: a
// BACKGROUNDED subagent emits no child frames at all, so a roster fed by
// `parent_tool_use_id` alone would leave every one of them an unlabelled row
// forever. Child traffic only creates an entry for CLI releases that announce
// no task frames.
//
// Claude re-announces a resumed task under a NEW `tool_use_id`, so `task_id` is
// the key and tool ids are aliases; keying on the tool id would duplicate the
// child on every resume. Outcomes latch within an invocation; a new spawn
// alias can reopen it, and authoritative evidence can correct lost contact.
import {
canReplaceSubagentState,
isTerminalSubagentState
} from '../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { isBoundedClaudeTaskId } from './claude-background-task-tracker'
import { claudeSubagentGroupBody, claudeSubagentGroupIdentity } from './claude-subagent-group-row'
import { ClaudeSubagentIds } from './claude-subagent-id-aliases'
import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
import {
applyClaudeSubagentInvocation,
claimClaudeSubagentLabel,
type RosterGroup,
type TrackedEntry
} from './claude-subagent-roster-state'
/** Spawn-group rows kept live per session, and children per row. Both bound an
* event-accumulated map that no provider snapshot ever prunes. */
const MAX_SUBAGENT_GROUPS = 32
const MAX_SUBAGENTS_PER_GROUP = 64
/** The turn a group belongs to when Claude reports a task outside any turn. */
const OUTSIDE_TURN = 'outside-turn'
const UNLABELLED_AGENT = 'subagent'
export type ClaudeSubagentRosterDeps = {
sink: StructuredAgentSessionEventSink
/** The turn that owns children spawned right now; null outside any turn. */
currentGroupKey: () => string | null
now?: () => number
}
export class ClaudeSubagentRoster {
private readonly groups = new Map<string, RosterGroup>()
/** Canonical id → the group holding its entry, so a late update for a child
* from an earlier turn revises that turn's row instead of the live one. */
private readonly groupIdByEntry = new Map<string, string>()
private readonly ids = new ClaudeSubagentIds()
/** Set by ANY `task_started`, including one the subagent filter rejects. Once
* this CLI has proven it declares its tasks, child traffic for an id it never
* announced is a nested tool or a grandchild, not a subagent. */
private announcesTasks = false
private readonly now: () => number
constructor(private readonly deps: ClaudeSubagentRosterDeps) {
this.now = deps.now ?? (() => Date.now())
}
/** Consume a `message:system:task_*` frame. Returns false when it is not one. */
observeSystemFrame(message: Record<string, unknown>): boolean {
const frame = readClaudeSubagentTaskFrame(message)
if (!frame) {
return false
}
this.announcesTasks ||= frame.announcement
if (frame.excluded) {
// Child traffic may already have built a provisional row under the tool id;
// the announcement is the first frame that says it is not a subagent.
for (const id of [frame.taskId, frame.toolUseId]) {
if (id !== null) {
this.ids.exclude(id)
this.remove(id)
}
}
return true
}
if (this.ids.isExcluded(frame.taskId, frame.toolUseId)) {
return true
}
if (frame.toolUseId) {
this.ids.alias(frame.toolUseId, frame.taskId)
}
const located =
this.locate(frame.taskId) ??
(frame.toolUseId ? this.adopt(frame.toolUseId, frame.taskId) : null)
if (!located) {
if (frame.announcesSubagent) {
this.create(
frame.taskId,
frame.label,
frame.state ?? 'working',
frame.backgrounded ?? false,
frame.toolUseId
)
}
return true
}
const tracked = located.group.entries.get(frame.taskId)
if (tracked && !applyClaudeSubagentInvocation(tracked, frame, this.now)) {
return true
}
this.revise(located.group, frame.taskId, {
label: frame.label,
state: frame.state,
backgrounded: frame.backgrounded
})
return true
}
/**
* A frame carrying `parent_tool_use_id` — the child's own traffic. It refreshes
* nothing on an announced child; it exists so a CLI release that sends no task
* frames still shows the subagent it is running.
*/
observeChildActivity(parentToolUseId: string): void {
const canonical = this.ids.canonical(parentToolUseId)
if (this.ids.isExcluded(parentToolUseId, canonical)) {
return
}
if (this.locate(canonical)) {
return
}
if (this.announcesTasks) {
// A nested Task, a workflow child, or a grandchild parented to a tool id
// inside the sidechain all reach here. This CLI announces what it spawns,
// so an id it never declared cannot be a subagent — and a row invented for
// one is unlabelled forever and can only ever end `unverifiable`. The
// bounded exclusion set cannot cover an id that was never announced.
return
}
if (!isBoundedClaudeTaskId(canonical)) {
// `claudeTaskId` rejects an over-long announced id rather than truncating
// it; a provisional id becomes the same durable entry key, so it cannot
// enter under a looser rule.
return
}
this.create(canonical, null, 'working', false, parentToolUseId)
}
/**
* The parent turn's tool result for a spawn call. It settles a foreground
* child, whose result IS the turn's evidence the child finished. A backgrounded
* child's spawn call returns immediately while the child keeps running, so its
* result proves nothing and is ignored.
*/
observeToolResult(toolUseId: string, failed: boolean): void {
const canonical = this.ids.canonical(toolUseId)
const located = this.locate(canonical)
if (
!located ||
located.tracked.invocationIds === null ||
located.tracked.backgrounded ||
(located.tracked.toolUseId !== null && located.tracked.toolUseId !== toolUseId)
) {
return
}
this.revise(located.group, canonical, {
label: null,
state: failed ? 'failed' : 'completed',
backgrounded: false
})
}
/**
* The parent turn ended. A foreground child still reported as working will
* never be settled by an event, so it becomes `unverifiable`: contact was
* lost, which is NOT evidence the child exited. A backgrounded child was
* explicitly told to outlive the turn and is left alone.
*/
settleTurn(groupKey: string | null): void {
// Only the group this key names. `OUTSIDE_TURN` belongs to no turn, so an
// unrelated turn ending is no evidence about a child announced outside it.
// `settleSession` reaches what no turn does.
this.sweep(this.groups.get(groupKey ?? OUTSIDE_TURN), false)
}
/** The provider is gone. Nothing more will arrive for any child, backgrounded
* or not, so every one of them loses contact at once. */
settleSession(): void {
for (const group of this.groups.values()) {
this.sweep(group, true)
}
}
dispose(): void {
// Teardown paths reach here without an `ended` event, so a row still
// reporting `working` would have nothing left to revise it. A session that
// did settle first leaves every child terminal, so this writes nothing.
this.settleSession()
this.groups.clear()
this.groupIdByEntry.clear()
this.ids.clear()
this.announcesTasks = false
}
private sweep(group: RosterGroup | undefined, includeBackgrounded: boolean): void {
if (!group) {
return
}
let changed = false
for (const [id, tracked] of group.entries) {
if (isTerminalSubagentState(tracked.entry.state)) {
continue
}
if (tracked.backgrounded && !includeBackgrounded) {
continue
}
group.entries.set(id, {
...tracked,
entry: { ...tracked.entry, state: 'unverifiable', settledAt: this.now() }
})
changed = true
}
if (changed) {
this.write(group)
}
}
private create(
id: string,
label: string | null,
state: NativeChatSubagentEntry['state'],
backgrounded: boolean,
toolUseId: string | null
): void {
const group = this.groupFor()
if (group.admittedEntries >= MAX_SUBAGENTS_PER_GROUP) {
return
}
group.admittedEntries += 1
const now = this.now()
const labelBase = label ?? UNLABELLED_AGENT
group.entries.set(id, {
backgrounded,
toolUseId,
invocationIds: new Set(toolUseId ? [toolUseId] : []),
labelBase,
entry: {
id,
label: claimClaudeSubagentLabel(group, labelBase),
state,
startedAt: now,
...(isTerminalSubagentState(state) ? { settledAt: now } : {})
}
})
this.groupIdByEntry.set(id, group.groupId)
this.write(group)
}
private revise(
group: RosterGroup,
id: string,
change: {
label: string | null
state: NativeChatSubagentEntry['state'] | null
backgrounded: boolean | null
}
): void {
const tracked = group.entries.get(id)
if (!tracked) {
return
}
const next: TrackedEntry = {
...tracked,
backgrounded: change.backgrounded ?? tracked.backgrounded,
entry: { ...tracked.entry }
}
// A provisional row built from child traffic takes the real name the first
// announcement carries; an announced row keeps the name it was given.
if (
change.label &&
tracked.labelBase === UNLABELLED_AGENT &&
change.label !== UNLABELLED_AGENT
) {
next.labelBase = change.label
next.entry.label = claimClaudeSubagentLabel(group, change.label)
}
// Proven outcomes latch; lost contact can still receive a later verdict.
if (change.state && canReplaceSubagentState(tracked.entry.state, change.state)) {
next.entry.state = change.state
if (isTerminalSubagentState(change.state)) {
next.entry.settledAt = this.now()
}
}
group.entries.set(id, next)
this.write(group)
}
/** Re-key a provisional entry from its tool id onto the canonical task id the
* announcement finally named, so the child does not appear twice. */
private adopt(toolUseId: string, taskId: string): { group: RosterGroup } | null {
if (toolUseId === taskId) {
return null
}
const located = this.locate(toolUseId)
if (!located) {
return null
}
located.group.entries.delete(toolUseId)
located.group.entries.set(taskId, {
...located.tracked,
entry: { ...located.tracked.entry, id: taskId }
})
this.groupIdByEntry.delete(toolUseId)
this.groupIdByEntry.set(taskId, located.group.groupId)
return { group: located.group }
}
private remove(id: string): void {
const located = this.locate(id)
if (!located) {
return
}
located.group.entries.delete(id)
this.groupIdByEntry.delete(id)
this.write(located.group)
}
private locate(id: string): { group: RosterGroup; tracked: TrackedEntry } | null {
const groupId = this.groupIdByEntry.get(id)
const group = groupId === undefined ? undefined : this.groups.get(groupId)
const tracked = group?.entries.get(id)
return group && tracked ? { group, tracked } : null
}
private groupFor(): RosterGroup {
const groupId = this.deps.currentGroupKey() ?? OUTSIDE_TURN
const existing = this.groups.get(groupId)
if (existing) {
return existing
}
const group: RosterGroup = {
groupId,
identity: claudeSubagentGroupIdentity(groupId),
entries: new Map(),
admittedEntries: 0,
claimedLabels: new Set(),
lastSerialized: null
}
this.groups.set(groupId, group)
while (this.groups.size > MAX_SUBAGENT_GROUPS) {
const oldest = this.groups.keys().next()
if (oldest.done || oldest.value === groupId) {
break
}
const evicted = this.groups.get(oldest.value)
// Once the group leaves the map nothing can reach its children again —
// not even a session sweep — so contact is lost here.
this.sweep(evicted, true)
for (const id of evicted?.entries.keys() ?? []) {
this.groupIdByEntry.delete(id)
}
this.groups.delete(oldest.value)
}
return group
}
private write(group: RosterGroup): void {
const agents = [...group.entries.values()].map((tracked) => tracked.entry)
const options = { coalescingKey: `claude-subagents:${group.groupId}` }
if (agents.length === 0) {
// The row's last child turned out not to be a subagent. An empty roster is
// not a roster of nothing, so the row goes rather than reading "Ran 0".
if (group.lastSerialized !== null) {
group.lastSerialized = null
this.deps.sink.appendTombstone(group.identity, options)
this.deps.sink.publish()
}
return
}
const body = claudeSubagentGroupBody(group.groupId, agents)
const serialized = JSON.stringify(body)
if (serialized === group.lastSerialized) {
// Nothing changed — a duplicate delivery must not burn a revision.
return
}
group.lastSerialized = serialized
this.deps.sink.appendItem(group.identity, body, options)
// Publish keeps the sink's own coalescing slot: sharing the row's key makes
// each queued publish evict the append it was meant to flush.
this.deps.sink.publish()
}
}
@@ -0,0 +1,201 @@
import { describe, expect, it } from 'vitest'
import { readClaudeSubagentTaskFrame } from './claude-subagent-task-frames'
function system(subtype: string, fields: Record<string, unknown>): Record<string, unknown> {
return { type: 'system', subtype, session_id: 'claude-session', ...fields }
}
describe('readClaudeSubagentTaskFrame', () => {
it('ignores frames that are not task frames', () => {
expect(readClaudeSubagentTaskFrame({ type: 'assistant', subtype: 'task_started' })).toBeNull()
expect(readClaudeSubagentTaskFrame(system('init', { task_id: 'task-1' }))).toBeNull()
expect(readClaudeSubagentTaskFrame(system('task_started', {}))).toBeNull()
expect(readClaudeSubagentTaskFrame(system('task_started', { task_id: '' }))).toBeNull()
})
describe('task_type triage', () => {
it('announces a local_agent task', () => {
const frame = readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-1',
tool_use_id: 'toolu_1',
task_type: 'local_agent',
subagent_type: 'code-reviewer',
description: 'Review the diff'
})
)
expect(frame).toMatchObject({
taskId: 'task-1',
toolUseId: 'toolu_1',
label: 'Review the diff',
announcesSubagent: true,
excluded: false
})
})
it('excludes a backgrounded shell command even though it carries a tool_use_id', () => {
const frame = readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-bash',
tool_use_id: 'toolu_bash',
task_type: 'local_bash',
description: 'sleep 20',
is_backgrounded: true
})
)
expect(frame).toMatchObject({
taskId: 'task-bash',
toolUseId: 'toolu_bash',
announcesSubagent: false,
excluded: true
})
})
it('excludes workflows and monitors', () => {
for (const taskType of ['local_workflow', 'monitor']) {
expect(
readClaudeSubagentTaskFrame(
system('task_started', { task_id: `task-${taskType}`, task_type: taskType })
)
).toMatchObject({ announcesSubagent: false, excluded: true })
}
})
it('caps a subagent_type label the way a description is capped', () => {
const frame = readClaudeSubagentTaskFrame(
system('task_started', { task_id: 'task-1', subagent_type: 'a'.repeat(900) })
)
// The roster stores this label verbatim, so nothing downstream bounds it.
expect(frame?.label).toHaveLength(512)
})
it('falls back to subagent_type only when the release sends no task_type', () => {
expect(
readClaudeSubagentTaskFrame(
system('task_started', { task_id: 'task-old', subagent_type: 'explorer' })
)
).toMatchObject({ announcesSubagent: true, label: 'explorer' })
expect(
readClaudeSubagentTaskFrame(system('task_started', { task_id: 'task-bare' }))
).toMatchObject({ announcesSubagent: false, excluded: true })
// A type this build does not recognise is not an agent on subagent_type's word.
expect(
readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-new',
task_type: 'local_something_new',
subagent_type: 'explorer'
})
)
).toMatchObject({ announcesSubagent: false, excluded: true })
})
it('excludes ambient housekeeping tasks', () => {
for (const suppression of [{ skip_transcript: true }, { ambient: true }]) {
expect(
readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-ambient',
task_type: 'local_agent',
subagent_type: 'watcher',
...suppression
})
)
).toMatchObject({ announcesSubagent: false, excluded: true })
}
})
})
describe('status', () => {
it('collapses every in-flight status to working', () => {
for (const status of ['pending', 'running', 'paused']) {
expect(
readClaudeSubagentTaskFrame(
system('task_updated', { task_id: 'task-1', patch: { status } })
)
).toMatchObject({ state: 'working' })
}
})
it('maps the settled statuses onto the carrier vocabulary', () => {
const mapped: [string, string][] = [
['completed', 'completed'],
['failed', 'failed'],
['killed', 'stopped'],
['stopped', 'stopped']
]
for (const [status, state] of mapped) {
expect(
readClaudeSubagentTaskFrame(
system('task_updated', { task_id: 'task-1', patch: { status } })
)
).toMatchObject({ state })
}
})
it('reports no state for a status it cannot map', () => {
for (const status of ['__proto__', 'toString', 'invented', 7, null]) {
expect(
readClaudeSubagentTaskFrame(
system('task_updated', { task_id: 'task-1', patch: { status } })
)
).toMatchObject({ state: null })
}
})
it('treats progress as no lifecycle verdict', () => {
for (const subtype of ['task_progress']) {
expect(
readClaudeSubagentTaskFrame(
system(subtype, { task_id: 'task-1', status: 'completed', patch: { status: 'failed' } })
)
).toMatchObject({ state: null })
}
})
})
it('reads the notification verdict from its top-level status', () => {
for (const state of ['completed', 'failed', 'stopped']) {
expect(
readClaudeSubagentTaskFrame(
system('task_notification', {
task_id: 'task-1',
status: state,
patch: { status: 'running' }
})
)
).toMatchObject({ state })
}
})
it('reads the backgrounded flag from the frame or its patch', () => {
expect(
readClaudeSubagentTaskFrame(
system('task_started', {
task_id: 'task-1',
task_type: 'local_agent',
is_backgrounded: true
})
)
).toMatchObject({ backgrounded: true })
expect(
readClaudeSubagentTaskFrame(
system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } })
)
).toMatchObject({ backgrounded: true })
expect(
readClaudeSubagentTaskFrame(system('task_updated', { task_id: 'task-1', patch: {} }))
).toMatchObject({ backgrounded: null })
})
it('collapses a multi-line description into one bounded label', () => {
expect(
readClaudeSubagentTaskFrame(
system('task_updated', {
task_id: 'task-1',
patch: { description: ' audit\n the lockfile ' }
})
)
).toMatchObject({ label: 'audit the lockfile' })
})
})
@@ -0,0 +1,123 @@
// Claude's declarative task protocol, read as subagent roster events.
//
// `local_agent`, `local_workflow` and `local_bash` tasks all arrive on the same
// `message:system:task_*` channel and ALL carry a `tool_use_id`, so id presence
// discriminates nothing: filtering on it alone puts a backgrounded `sleep 20` in
// the subagent roster. `task_type` is the discriminator, with `subagent_type`
// covering CLI releases that predate it.
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import {
classifyClaudeBackgroundTaskKind,
claudeTaskDescription,
claudeTaskId,
isBoundedClaudeTaskId
} from './claude-background-task-tracker'
import { claudeRecord, claudeText } from './claude-structured-item-translation'
const TASK_SUBTYPES: ReadonlySet<string> = new Set([
'task_started',
'task_updated',
'task_progress',
'task_notification'
])
/** Provider status → the carrier's vocabulary. `killed` and `stopped` both mean
* the task was deliberately ended, which the carrier calls `stopped`; every
* in-flight status collapses to `working`. A Map, not an object, so a payload
* carrying `__proto__` as its status cannot resolve to an inherited value. */
const TASK_STATES: ReadonlyMap<string, NativeChatSubagentState> = new Map([
['pending', 'working'],
['running', 'working'],
['paused', 'working'],
['completed', 'completed'],
['failed', 'failed'],
['killed', 'stopped'],
['stopped', 'stopped']
] satisfies [string, NativeChatSubagentState][])
export type ClaudeSubagentTaskFrame = {
/** Canonical, resume-stable id — the roster key. */
taskId: string
/** Re-minted when Claude re-announces a resumed task, so it is only an alias. */
toolUseId: string | null
label: string | null
/** null when the frame reported no lifecycle status. */
state: NativeChatSubagentState | null
backgrounded: boolean | null
/** Any `task_started`, subagent or not. Proof this CLI declares its tasks. */
announcement: boolean
/** `task_started` for a task the roster should show. Only an announcement
* creates an entry: an update carries no `task_type`, so honouring one for an
* unknown id would roster whatever else shares this channel. */
announcesSubagent: boolean
/** Ambient housekeeping, or a task that is not a subagent at all. Its ids must
* never reach the roster, by this frame or by later child traffic. */
excluded: boolean
}
/** True when the task Claude announced is a subagent rather than a backgrounded
* shell command or a workflow. */
export function isClaudeSubagentTask(message: Record<string, unknown>): boolean {
if (classifyClaudeBackgroundTaskKind(message.task_type) === 'agent') {
return true
}
// Releases predating `task_type` still name the child in `subagent_type`. A
// task_type Orca does not recognise is NOT covered: it is a type this build
// has no reason to believe is an agent.
return (
(message.task_type === undefined || message.task_type === null) &&
claudeText(message.subagent_type) !== null
)
}
function taskState(value: unknown): NativeChatSubagentState | null {
return typeof value === 'string' ? (TASK_STATES.get(value) ?? null) : null
}
export function readClaudeSubagentTaskFrame(
message: Record<string, unknown>
): ClaudeSubagentTaskFrame | null {
if (message.type !== 'system') {
return null
}
const subtype = claudeText(message.subtype)
if (!subtype || !TASK_SUBTYPES.has(subtype)) {
return null
}
const taskId = claudeTaskId(message)
if (!taskId) {
return null
}
const patch = claudeRecord(message.patch)
const toolUseId = claudeText(message.tool_use_id) ?? claudeText(patch?.tool_use_id)
const announcement = subtype === 'task_started'
// Housekeeping Claude runs for itself; the user never asked for it.
const suppressed = message.ambient === true || message.skip_transcript === true
const subagent = announcement && !suppressed && isClaudeSubagentTask(message)
return {
taskId,
toolUseId: toolUseId && isBoundedClaudeTaskId(toolUseId) ? toolUseId : null,
label:
claudeTaskDescription(message.description) ??
claudeTaskDescription(patch?.description) ??
// Bounded like a description: the roster stores whatever this returns.
(announcement ? (claudeTaskDescription(message.subagent_type) ?? null) : null),
// Notifications carry terminal evidence; progress carries usage only.
state:
subtype === 'task_notification'
? taskState(message.status)
: announcement || subtype === 'task_updated'
? taskState(patch?.status ?? message.status)
: null,
backgrounded:
typeof patch?.is_backgrounded === 'boolean'
? patch.is_backgrounded
: typeof message.is_backgrounded === 'boolean'
? message.is_backgrounded
: null,
announcement,
announcesSubagent: subagent,
excluded: announcement && !subagent
}
}
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { CodexBackgroundCommandTracker } from './codex-background-command-tracker'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import type { CodexStructuredSessionEvent } from './codex-structured-session-state'
function notification(
method: string,
params: Record<string, unknown>
): Extract<CodexStructuredSessionEvent, { type: 'notification' }> {
return {
type: 'notification',
sessionId: 'session',
threadId: 'root',
method,
params: { threadId: 'root', turnId: 'turn', ...params }
}
}
function command(method: string, id = 'exec', threadId = 'root') {
return {
...notification(method, {
item: {
type: 'commandExecution',
id,
command: 'sleep 30',
source: 'unifiedExecStartup',
status: method === 'item/completed' ? 'completed' : 'inProgress',
exitCode: method === 'item/completed' ? 0 : null
}
}),
threadId
}
}
describe('persistent command ownership', () => {
it('preflights finite metadata capacity and admits work again after process completion', () => {
const tracker = new CodexBackgroundCommandTracker('root', 700)
const first = command('item/started', 'first')
const second = command('item/started', 'second')
expect(tracker.canObserve(first)).toBe(true)
tracker.observe(first)
expect(tracker.canObserve(second)).toBe(false)
expect(() => tracker.observe(second)).toThrow('not admitted')
expect(tracker.tasks()).toHaveLength(1)
expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700)
tracker.observe(command('item/completed', 'first'))
expect(tracker.canObserve(second)).toBe(true)
tracker.observe(second)
expect(tracker.tasks()).toHaveLength(1)
expect(tracker.retainedMetadataBytes).toBeLessThanOrEqual(700)
tracker.clear()
expect(tracker.retainedMetadataBytes).toBe(0)
})
it('keeps the journal running across turn completion and accepts late output and exit', () => {
const rows: { key: string; body: AgentJournalItemBody }[] = []
const translator = createCodexJournalTranslator({
primaryThreadId: () => 'root',
sink: {
appendItem: (identity, body) => rows.push({ key: agentJournalItemKey(identity), body }),
appendTombstone: () => {},
publish: () => {}
}
})
const tracker = new CodexBackgroundCommandTracker('root')
const deliver = (event: Extract<CodexStructuredSessionEvent, { type: 'notification' }>) => {
expect(translator.handle(event)).toEqual({ accepted: true })
tracker.observe(event)
}
deliver(notification('turn/started', { turn: { id: 'turn' } }))
deliver(command('item/started'))
const originalKey = rows.find(({ body }) => body.kind === 'tool-call')?.key
deliver(notification('turn/completed', { turn: { id: 'turn' } }))
expect(rows.filter(({ body }) => body.kind === 'tool-call').map(({ body }) => body)).toEqual([
expect.objectContaining({ state: 'running' })
])
expect(tracker.tasks()).toHaveLength(1)
deliver(
notification('item/commandExecution/outputDelta', { itemId: 'exec', delta: 'late output' })
)
translator.flush()
expect(rows.at(-1)).toMatchObject({ key: originalKey, body: { state: 'running' } })
deliver(command('item/completed'))
expect(rows.at(-1)).toMatchObject({ key: originalKey, body: { state: 'completed' } })
expect(tracker.tasks()).toEqual([])
translator.dispose()
})
it('counts child shells only after the child stops covering them, without resurrecting exits', () => {
const tracker = new CodexBackgroundCommandTracker('root')
tracker.observe(command('item/started', 'child-exec', 'child'))
tracker.observe(
notification('item/started', {
item: {
type: 'commandExecution',
id: 'poll',
source: 'unifiedExecInteraction',
status: 'inProgress'
}
})
)
expect(tracker.tasks(new Set(['child']))).toEqual([])
expect(tracker.tasks()).toEqual([
{ id: 'codex-command:thread:child:child-exec', kind: 'command', description: 'sleep 30' }
])
tracker.observe(command('item/completed', 'child-exec', 'child'))
tracker.observe(command('item/started'))
tracker.observe(command('item/completed'))
tracker.observe(command('item/started'))
expect(tracker.tasks()).toEqual([])
})
it('retains live commands while recycling bounded settled history', () => {
const tracker = new CodexBackgroundCommandTracker('root')
tracker.observe(command('item/started', 'long-lived'))
for (let index = 0; index < 300; index += 1) {
tracker.observe(command('item/started', `short-${index}`))
tracker.observe(command('item/completed', `short-${index}`))
}
expect(tracker.tasks()).toEqual([
{ id: 'codex-command:primary:long-lived', kind: 'command', description: 'sleep 30' }
])
tracker.clear()
expect(tracker.tasks()).toEqual([])
})
})
@@ -0,0 +1,150 @@
import type { AgentSessionBackgroundTask } from '../../shared/agent-session-wire'
import type { CodexBackgroundTaskEvent } from './codex-background-task-frames'
import { codexCommandOutlivesTurn } from './codex-command-lifecycle'
import { readRecord, readString } from './codex-item-field-readers'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { MAX_CODEX_ITEM_STREAM_METADATA_BYTES } from './codex-item-stream-retention'
const MAX_SETTLED_COMMANDS = 128
const MAX_DESCRIPTION_CHARS = 512
type Command = { threadId: string; task: AgentSessionBackgroundTask; bytes: number }
/** Stays within the retained bound, so read-time qualification cannot outgrow admission. */
function qualifiedDescription(label: string, description: string | undefined): string {
return (description ? `${label} — ${description}` : label).slice(0, MAX_DESCRIPTION_CHARS)
}
export class CodexBackgroundCommandTracker {
private readonly commands = new Map<string, Command>()
private readonly settled = new Map<string, number>()
private liveBytes = 0
private settledBytes = 0
constructor(
private readonly primaryThreadId: string,
private readonly maxMetadataBytes = MAX_CODEX_ITEM_STREAM_METADATA_BYTES
) {}
get retainedMetadataBytes(): number {
return this.liveBytes + this.settledBytes
}
canObserve(event: CodexBackgroundTaskEvent): boolean {
const parsed = this.parse(event)
return (
!parsed ||
parsed.completed ||
this.commands.has(parsed.key) ||
this.settled.has(parsed.key) ||
this.liveBytes + parsed.command.bytes <= this.maxMetadataBytes
)
}
observe(event: CodexBackgroundTaskEvent): void {
const parsed = this.parse(event)
if (!parsed || this.settled.has(parsed.key)) {
return
}
const { key, command, completed } = parsed
const existing = this.commands.get(key)
if (completed) {
if (existing) {
this.liveBytes -= existing.bytes
this.commands.delete(key)
}
const bytes = Buffer.byteLength(key, 'utf8') + 256
if (this.liveBytes + bytes <= this.maxMetadataBytes) {
this.settled.set(key, bytes)
this.settledBytes += bytes
}
this.trimSettled()
return
}
if (existing) {
return
}
if (this.liveBytes + command.bytes > this.maxMetadataBytes) {
throw new Error('Codex command metadata was not admitted before observation')
}
this.commands.set(key, command)
this.liveBytes += command.bytes
this.trimSettled()
}
tasks(
coveredThreads?: ReadonlySet<string>,
childLabel?: (threadId: string) => string | null
): AgentSessionBackgroundTask[] {
return [...this.commands.values()]
.filter((command) => !coveredThreads?.has(command.threadId))
.map(({ threadId, task }) => {
// The agent row carrying the child's name is gone by the time this row shows;
// unqualified it reads as a bare shell string with no owner. Resolved on read so
// a label registered after the command still lands.
const label = threadId === this.primaryThreadId ? null : childLabel?.(threadId)
return label
? { ...task, description: qualifiedDescription(label, task.description) }
: task
})
}
clear(): void {
this.commands.clear()
this.settled.clear()
this.liveBytes = 0
this.settledBytes = 0
}
private trimSettled(): void {
while (
this.settled.size > MAX_SETTLED_COMMANDS ||
this.retainedMetadataBytes > this.maxMetadataBytes
) {
const oldest = this.settled.entries().next().value
if (!oldest) {
break
}
this.settled.delete(oldest[0])
this.settledBytes -= oldest[1]
}
}
private parse(
event: CodexBackgroundTaskEvent
): { key: string; command: Command; completed: boolean } | null {
if (event.method !== 'item/started' && event.method !== 'item/completed') {
return null
}
const item = readCodexThreadItem(readRecord(event.params).item)
if (!item || !codexCommandOutlivesTurn(item)) {
return null
}
const key = JSON.stringify([event.threadId, item.id])
const completed = event.method === 'item/completed' || item.status !== 'inProgress'
const description = readString(item, 'command')
?.slice(0, MAX_DESCRIPTION_CHARS)
.replace(/\s+/g, ' ')
.trim()
const value = {
threadId: event.threadId,
task: {
id:
event.threadId === this.primaryThreadId
? `codex-command:primary:${encodeURIComponent(item.id)}`
: `codex-command:thread:${encodeURIComponent(event.threadId)}:${encodeURIComponent(item.id)}`,
kind: 'command' as const,
...(description ? { description } : {})
}
}
return {
key,
completed,
command: {
...value,
bytes:
Buffer.byteLength(key, 'utf8') + Buffer.byteLength(JSON.stringify(value), 'utf8') + 256
}
}
}
}
@@ -0,0 +1,72 @@
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import {
codexSubagentLabel,
isCodexRootAgentActivity,
readCodexSubagentActivity
} from './codex-subagent-activity'
import { codexChildTurnState } from './codex-subagent-executions'
import { readRecord } from './codex-item-field-readers'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { readCodexTurnId } from './codex-structured-thread-facts'
export type CodexBackgroundTaskFrame =
| {
kind: 'subagent'
agentThreadId: string
label: string | null
parentTurnId: string | null | undefined
}
| {
kind: 'turn'
threadId: string
turnId: string
state: NativeChatSubagentState
}
export type CodexBackgroundTaskEvent = {
method: string
threadId: string
params: unknown
}
export function readCodexBackgroundTaskFrame(
event: CodexBackgroundTaskEvent,
primaryThreadId: string
): CodexBackgroundTaskFrame | null {
if (event.method === 'turn/started' || event.method === 'turn/completed') {
const turnId = readCodexTurnId(event.params)
if (turnId === null) {
return null
}
return {
kind: 'turn',
threadId: event.threadId,
turnId,
state:
event.method === 'turn/started'
? 'working'
: codexChildTurnState(readRecord(readRecord(event.params).turn).status)
}
}
if (event.method !== 'item/started' && event.method !== 'item/completed') {
return null
}
const item = readCodexThreadItem(readRecord(event.params).item)
const activity = item && readCodexSubagentActivity(item)
if (
!activity ||
activity.agentThreadId === primaryThreadId ||
isCodexRootAgentActivity(activity)
) {
return null
}
return {
kind: 'subagent',
agentThreadId: activity.agentThreadId,
label: codexSubagentLabel(activity),
parentTurnId:
activity.kind === 'started' || activity.kind === 'interacted'
? readCodexTurnId(event.params)
: undefined
}
}
@@ -0,0 +1,280 @@
import { describe, expect, it } from 'vitest'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import {
readCodexBackgroundTaskFrame,
type CodexBackgroundTaskEvent
} from './codex-background-task-frames'
const PRIMARY = 'parent-thread'
const PARENT_TURN = 'parent-turn'
const CHILD = 'child-thread'
const CHILD_TURN = 'child-turn'
function turn(
method: 'turn/started' | 'turn/completed',
threadId: string,
turnId: string,
status = 'completed'
): CodexBackgroundTaskEvent {
return { method, threadId, params: { threadId, turn: { id: turnId, status } } }
}
function activity(
kind = 'started',
parentTurn = PARENT_TURN,
child = CHILD
): CodexBackgroundTaskEvent {
return {
method: 'item/started',
threadId: PRIMARY,
params: {
threadId: PRIMARY,
turnId: parentTurn,
item: {
type: 'subAgentActivity',
id: `activity-${kind}`,
kind,
agentThreadId: child,
agentPath: '/root/count_a'
}
}
}
}
function runningChild(): CodexBackgroundTaskTracker {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
tracker.observe(activity())
return tracker
}
function command(threadId = PRIMARY, method = 'item/started'): CodexBackgroundTaskEvent {
return {
method,
threadId,
params: {
threadId,
turnId: PARENT_TURN,
item: {
type: 'commandExecution',
id: 'exec-1',
processId: '71831',
source: 'unifiedExecStartup',
command: 'sleep 90',
status: method === 'item/started' ? 'inProgress' : 'completed'
}
}
}
}
describe('readCodexBackgroundTaskFrame', () => {
it('reads activity as child metadata without inferring execution state', () => {
expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({
kind: 'subagent',
agentThreadId: CHILD,
label: 'count_a',
parentTurnId: PARENT_TURN
})
})
it('reads a child turn with its own execution identity', () => {
expect(readCodexBackgroundTaskFrame(turn('turn/started', CHILD, CHILD_TURN), PRIMARY)).toEqual({
kind: 'turn',
threadId: CHILD,
turnId: CHILD_TURN,
state: 'working'
})
})
it('does not register the primary thread even when its activity path is missing', () => {
const event = activity('interacted', PARENT_TURN, PRIMARY)
;(event.params as { item: { agentPath?: string } }).item.agentPath = undefined
expect(readCodexBackgroundTaskFrame(event, PRIMARY)).toBeNull()
})
})
describe('CodexBackgroundTaskTracker child execution ownership', () => {
it('does not claim work from an activity item without a child turn', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(activity())
tracker.observe(activity('interacted'))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.state).toBeNull()
})
it('reports an executing child only after the foreground turn ends', () => {
const tracker = runningChild()
expect(tracker.state).toBeNull()
expect(tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))).toBe(true)
expect(tracker.state).toEqual({
state: 'monitoring',
supportsStopAll: false,
tasks: [{ id: `codex-agent:${CHILD}`, kind: 'agent', description: 'count_a' }]
})
})
it('never settles a child when a primary turn ends', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
for (let index = 0; index < 300; index++) {
expect(tracker.observe(turn('turn/completed', PRIMARY, `later-${index}`))).toBe(false)
}
expect(tracker.state?.tasks).toHaveLength(1)
})
it.each(['completed', 'interrupted', 'failed'])(
'settles on the matching child turn %s',
(status) => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, status))).toBe(true)
expect(tracker.state).toBeNull()
}
)
it('does not mistake late activity completion for the current child execution', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(activity('completed'))
expect(tracker.state?.tasks).toHaveLength(1)
})
it.each([PARENT_TURN, 'followup-parent'])(
'reports follow-up work in %s using the new child turn',
(parentTurn) => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(turn('turn/started', PRIMARY, parentTurn))
tracker.observe(activity('interacted', parentTurn))
expect(tracker.state).toBeNull()
tracker.observe(turn('turn/started', CHILD, 'followup-child-turn'))
tracker.observe(turn('turn/completed', PRIMARY, parentTurn))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
tracker.observe(activity('completed'))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, 'followup-child-turn'))
expect(tracker.state).toBeNull()
}
)
it('keeps idle send_message activity out of the strip', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(activity('interacted', 'message-parent'))
tracker.observe(turn('turn/completed', PRIMARY, 'message-parent'))
expect(tracker.state).toBeNull()
})
it('does not invent another execution for a message to a working child', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(activity('interacted', 'message-parent'))
tracker.observe(turn('turn/completed', PRIMARY, 'message-parent'))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
expect(tracker.state).toBeNull()
})
it('retains completion delivered before child registration', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
tracker.observe(activity())
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.state).toBeNull()
})
it('publishes no extra state for duplicate owner or metadata events', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.observe(turn('turn/started', CHILD, CHILD_TURN))).toBe(false)
expect(tracker.observe({ ...activity(), method: 'item/completed' })).toBe(false)
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(true)
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(false)
})
it('bounds retained child history while allowing repeated completed runs', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(activity())
for (let index = 0; index < 300; index++) {
const id = `child-turn-${index}`
tracker.observe(turn('turn/started', CHILD, id))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, id))
expect(tracker.state).toBeNull()
}
})
it('clears the roster at session teardown', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.clear()).toBe(true)
expect(tracker.state).toBeNull()
expect(tracker.clear()).toBe(false)
})
})
describe('CodexBackgroundTaskTracker command integration', () => {
it('keeps a primary shell visible after the turn until its own completion', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
tracker.observe(command())
expect(tracker.state).toBeNull()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.state?.tasks).toEqual([
{ id: 'codex-command:primary:exec-1', kind: 'command', description: 'sleep 90' }
])
tracker.observe(command(PRIMARY, 'item/completed'))
expect(tracker.state).toBeNull()
})
it('reveals a child shell only after the child execution finishes', () => {
const tracker = runningChild()
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(command(CHILD))
expect(tracker.state?.tasks).toHaveLength(1)
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, 'interrupted'))
expect(tracker.state?.tasks).toEqual([
{
id: `codex-command:thread:${CHILD}:exec-1`,
kind: 'command',
description: 'count_a — sleep 90'
}
])
tracker.observe(command(CHILD, 'item/completed'))
expect(tracker.state).toBeNull()
})
it('leaves a primary shell unqualified', () => {
const tracker = runningChild()
tracker.observe(command(PRIMARY))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
expect(tracker.state?.tasks).toContainEqual({
id: 'codex-command:primary:exec-1',
kind: 'command',
description: 'sleep 90'
})
})
it('names a child shell whose label only arrives after the command', () => {
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
tracker.observe(command(CHILD))
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
tracker.observe(activity())
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
expect(tracker.state?.tasks).toEqual([
{
id: `codex-command:thread:${CHILD}:exec-1`,
kind: 'command',
description: 'count_a — sleep 90'
}
])
})
})
@@ -0,0 +1,96 @@
import type {
AgentSessionBackgroundTask,
AgentSessionBackgroundTaskState
} from '../../shared/agent-session-wire'
import {
readCodexBackgroundTaskFrame,
type CodexBackgroundTaskEvent
} from './codex-background-task-frames'
import { CodexSubagentExecutions } from './codex-subagent-executions'
import { CodexBackgroundCommandTracker } from './codex-background-command-tracker'
import { boundSubagentField } from './codex-subagent-group-body'
/** Projects the same child execution facts the durable roster consumes. */
export class CodexBackgroundTaskTracker {
private primaryTurnId: string | null = null
private publishedFingerprint = '[]'
private publishedState: AgentSessionBackgroundTaskState | null = null
private readonly commands: CodexBackgroundCommandTracker
constructor(
private readonly primaryThreadId: string,
private readonly executions = new CodexSubagentExecutions()
) {
this.commands = new CodexBackgroundCommandTracker(primaryThreadId)
}
get state(): AgentSessionBackgroundTaskState | null {
// Journal admission precedes observe; readers must not see its pending facts.
return this.publishedState
}
canObserve(event: CodexBackgroundTaskEvent): boolean {
return this.commands.canObserve(event)
}
observe(event: CodexBackgroundTaskEvent): boolean {
const itemEvent = event.method === 'item/started' || event.method === 'item/completed'
if (itemEvent) {
this.commands.observe(event)
}
const frame = readCodexBackgroundTaskFrame(event, this.primaryThreadId)
if (!frame) {
return itemEvent ? this.refresh() : false
}
if (frame.kind === 'subagent') {
this.executions.register(frame.agentThreadId, frame.label, frame.parentTurnId)
} else if (frame.threadId === this.primaryThreadId) {
if (frame.state === 'working') {
this.primaryTurnId = frame.turnId
} else if (frame.turnId === this.primaryTurnId) {
this.primaryTurnId = null
}
} else {
this.executions.observeTurn(frame.threadId, frame.turnId, frame.state)
}
return this.refresh()
}
clear(): boolean {
this.executions.clear()
this.commands.clear()
this.primaryTurnId = null
return this.refresh()
}
private tasks(): AgentSessionBackgroundTask[] {
if (this.primaryTurnId !== null) {
return []
}
const children = this.executions.workingChildren()
const agents: AgentSessionBackgroundTask[] = children.map((child, index) => ({
id: `codex-agent:${child.agentThreadId}`,
kind: 'agent',
...(child.label ? { description: boundSubagentField(child.label, index) } : {})
}))
return [
...agents,
...this.commands.tasks(new Set(children.map((child) => child.agentThreadId)), (threadId) =>
this.executions.label(threadId)
)
]
}
private refresh(): boolean {
const tasks = this.tasks()
const fingerprint = JSON.stringify(tasks)
if (fingerprint === this.publishedFingerprint) {
return false
}
this.publishedFingerprint = fingerprint
this.publishedState = tasks.length
? { state: 'monitoring', tasks, supportsStopAll: false }
: null
return true
}
}
@@ -0,0 +1,6 @@
import type { CodexThreadItem } from './codex-structured-item-translation'
/** Persistent exec has its own process-exit notification, independent of a turn. */
export function codexCommandOutlivesTurn(item: CodexThreadItem): boolean {
return item.type === 'commandExecution' && item.source === 'unifiedExecStartup'
}
@@ -0,0 +1,107 @@
import { codexCommandOutlivesTurn } from './codex-command-lifecycle'
import {
MAX_CODEX_ITEM_STREAM_ITEM_BYTES,
MAX_CODEX_ITEM_STREAM_STATES
} from './codex-structured-item-stream-bounds'
import type { CodexItemStreamState } from './codex-structured-item-stream-contracts'
// Preserve the previous metadata ceiling while letting small live commands share it.
export const MAX_CODEX_ITEM_STREAM_METADATA_BYTES =
MAX_CODEX_ITEM_STREAM_STATES * MAX_CODEX_ITEM_STREAM_ITEM_BYTES
type RetainedState = { state: CodexItemStreamState; bytes: number; persistent: boolean }
export class CodexItemStreamRetention {
private readonly states = new Map<string, RetainedState>()
private bytes = 0
private persistentBytes = 0
private persistentCount = 0
constructor(private readonly maxBytes = MAX_CODEX_ITEM_STREAM_METADATA_BYTES) {}
get retainedBytes(): number {
return this.bytes
}
get size(): number {
return this.states.size
}
get persistentSize(): number {
return this.persistentCount
}
get overCapacity(): boolean {
return (
this.bytes > this.maxBytes ||
this.states.size - this.persistentCount > MAX_CODEX_ITEM_STREAM_STATES
)
}
get(key: string): CodexItemStreamState | undefined {
return this.states.get(key)?.state
}
isPersistent(key: string): boolean {
return this.states.get(key)?.persistent === true
}
canRetain(key: string, state: CodexItemStreamState): boolean {
const previous = this.states.get(key)
return (
this.persistentBytes -
(previous?.persistent ? previous.bytes : 0) +
this.stateBytes(key, state) <=
this.maxBytes
)
}
retain(key: string, state: CodexItemStreamState): boolean {
if (!this.canRetain(key, state)) {
return false
}
this.forget(key)
const bytes = this.stateBytes(key, state)
const persistent = codexCommandOutlivesTurn(state.item)
this.states.set(key, { state, bytes, persistent })
this.bytes += bytes
if (persistent) {
this.persistentBytes += bytes
this.persistentCount += 1
}
return true
}
oldestEvictable(): string | undefined {
for (const [key, entry] of this.states) {
if (!entry.persistent) {
return key
}
}
return undefined
}
forget(key: string): void {
const entry = this.states.get(key)
if (!entry) {
return
}
this.bytes -= entry.bytes
if (entry.persistent) {
this.persistentBytes -= entry.bytes
this.persistentCount -= 1
}
this.states.delete(key)
}
clear(): void {
this.states.clear()
this.bytes = 0
this.persistentBytes = 0
this.persistentCount = 0
}
private stateBytes(key: string, state: CodexItemStreamState): number {
return Buffer.byteLength(key, 'utf8') + Buffer.byteLength(JSON.stringify(state), 'utf8') + 256
}
}
@@ -0,0 +1,219 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { CodexBackgroundCommandTracker } from './codex-background-command-tracker'
import {
CodexItemStreamRetention,
MAX_CODEX_ITEM_STREAM_METADATA_BYTES
} from './codex-item-stream-retention'
import { CodexJournalItems } from './codex-structured-journal-items'
import { settleCodexJournalTurn } from './codex-structured-journal-settlement'
function command(threadId: string, id: string, method = 'item/started') {
return {
threadId,
method,
params: {
turnId: 'turn',
item: {
type: 'commandExecution',
id,
source: 'unifiedExecStartup',
command: `sleep 30 # ${threadId}/${id}`,
cwd: '/workspace',
status: method === 'item/completed' ? 'completed' : 'inProgress',
...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: 'BEFORE\nAFTER\n' } : {})
}
}
}
}
function fixture(maxMetadataBytes?: number) {
const rows = new Map<string, AgentJournalItemBody>()
const scheduled = new Set<() => void>()
const sink = {
appendItem: (
identity: Parameters<typeof agentJournalItemKey>[0],
body: AgentJournalItemBody
) => {
rows.set(agentJournalItemKey(identity), body)
},
appendTombstone() {},
publish() {}
}
const items = new CodexJournalItems(
{
sink,
maxMetadataBytes,
schedule: (run) => {
scheduled.add(run)
return () => {
scheduled.delete(run)
}
}
},
() => 'turn',
() => {}
)
return { items, sink, rows, scheduled }
}
describe('persistent command retention', () => {
it('does not rebuild unchanged persistent output on every later lifecycle flush', () => {
const { items } = fixture()
const event = command('root', 'quiet')
items.handle(event)
items.streams.handle('root', 'item/commandExecution/outputDelta', {
itemId: 'quiet',
delta: 'retained-prefix'
})
items.streams.flush()
const originalJoin = Array.prototype.join
let retainedJoins = 0
const spy = vi
.spyOn(Array.prototype, 'join')
.mockImplementation(function (this: unknown[], separator) {
if (this[0] === 'retained-prefix') {
retainedJoins += 1
}
return originalJoin.call(this, separator)
})
try {
for (let index = 0; index < 100; index += 1) {
items.streams.flush()
}
} finally {
spy.mockRestore()
items.dispose()
}
expect(retainedJoins).toBe(0)
})
it('retains 448 live commands through completed turns, late output, and process completion', () => {
const { items, sink, rows, scheduled } = fixture()
const tracker = new CodexBackgroundCommandTracker('thread-0')
const events = Array.from({ length: 7 }, (_, thread) =>
Array.from({ length: 64 }, (_, index) => command(`thread-${thread}`, `exec-${index}`))
).flat()
for (const event of events) {
expect(tracker.canObserve(event)).toBe(true)
expect(items.handle(event)).toMatchObject({ admission: { accepted: true } })
tracker.observe(event)
expect(
items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', {
turnId: 'turn',
itemId: event.params.item.id,
delta: 'BEFORE\n'
}).admission
).toEqual({ accepted: true })
}
for (let thread = 0; thread < 7; thread += 1) {
expect(
settleCodexJournalTurn({
sessionId: 'session',
threadId: `thread-${thread}`,
turnId: 'turn',
sink,
streams: items.streams,
activeItems: items.activeItems
})
).toEqual({ accepted: true })
}
expect(items.activeItems.size).toBe(448)
expect(items.streams.persistentCount).toBe(448)
expect(tracker.tasks()).toHaveLength(448)
expect(tracker.retainedMetadataBytes).toBeLessThan(256 * 1024)
for (const event of events) {
items.streams.handle(event.threadId, 'item/commandExecution/outputDelta', {
turnId: 'turn',
itemId: event.params.item.id,
delta: 'AFTER\n'
})
}
expect(items.streams.flush()).toBe(true)
for (const event of events) {
const key = agentJournalItemKey({
provider: 'orca',
clientMessageId: `codex-item:${event.threadId}:${event.params.item.id}`
})
expect(rows.get(key)).toMatchObject({
state: 'running',
input: { command: event.params.item.command, cwd: '/workspace' },
output: { head: 'BEFORE\nAFTER\n' }
})
const completed = command(event.threadId, event.params.item.id, 'item/completed')
expect(items.handle(completed)).toMatchObject({ admission: { accepted: true } })
tracker.observe(completed)
expect(rows.get(key)).toMatchObject({
state: 'completed',
output: { head: 'BEFORE\nAFTER\n' }
})
expect(items.streams.snapshot(event.threadId, event.params.item.id)).toBeNull()
}
expect(items.activeItems.size).toBe(0)
expect(items.streams.persistentCount).toBe(0)
expect(tracker.tasks()).toEqual([])
expect(tracker.retainedMetadataBytes).toBeLessThan(64 * 1024)
items.dispose()
tracker.clear()
expect(tracker.retainedMetadataBytes).toBe(0)
expect(scheduled.size).toBe(0)
})
it('rejects command metadata exhaustion before appending or evicting live state and frees it on completion', () => {
const { items, rows } = fixture(800)
const first = command('root', 'first')
const second = command('root', 'second')
expect(items.handle(first)).toMatchObject({ admission: { accepted: true } })
const prior = [...rows]
expect(items.handle(second)).toMatchObject({ admission: { accepted: false, reason: 'failed' } })
expect([...rows]).toEqual(prior)
expect(items.activeItems.size).toBe(1)
expect(items.handle(command('root', 'first', 'item/completed'))).toMatchObject({
admission: { accepted: true }
})
expect(items.handle(second)).toMatchObject({ admission: { accepted: true } })
items.dispose()
})
it('accounts metadata bytes instead of interpreting the item count as liveness', () => {
const retention = new CodexItemStreamRetention()
for (let index = 0; index < 448; index += 1) {
const item = command('root', `exec-${index}`).params.item
expect(
retention.retain(item.id, {
item,
identity: { provider: 'orca', clientMessageId: item.id }
})
).toBe(true)
}
expect(retention.size).toBe(448)
expect(retention.retainedBytes).toBeLessThan(256 * 1024)
expect(retention.retainedBytes).toBeLessThan(MAX_CODEX_ITEM_STREAM_METADATA_BYTES)
expect(retention.overCapacity).toBe(false)
expect(retention.oldestEvictable()).toBeUndefined()
retention.clear()
expect(retention.retainedBytes).toBe(0)
expect(retention.persistentSize).toBe(0)
})
it('retains startup provenance when large command metadata is bounded', () => {
const { items, sink } = fixture()
const event = command('root', 'large')
event.params.item.command = 'x'.repeat(128 * 1024)
expect(items.handle(event)).toMatchObject({ admission: { accepted: true } })
expect(
settleCodexJournalTurn({
sessionId: 'session',
threadId: 'root',
turnId: 'turn',
sink,
streams: items.streams,
activeItems: items.activeItems
})
).toEqual({ accepted: true })
expect(items.activeItems.size).toBe(1)
expect(items.streams.persistentCount).toBe(1)
items.dispose()
})
})
@@ -30,6 +30,7 @@ export function boundStreamItem(item: Record<string, unknown>): Record<string, u
return {
type: item.type,
id: item.id,
...(typeof item.source === 'string' ? { source: item.source } : {}),
...(typeof item.command === 'string' ? { command: item.command.slice(0, 4096) } : {}),
...(typeof item.cwd === 'string' ? { cwd: item.cwd.slice(0, 4096) } : {}),
...(typeof item.status === 'string' ? { status: item.status } : {}),
@@ -13,6 +13,7 @@ export type CodexItemStreamDeps = {
coalesceMs?: number
maxRetainedBytes?: number
maxTotalRetainedBytes?: number
maxMetadataBytes?: number
schedule?: AgentSessionDeltaCoalescerDeps['schedule']
}
@@ -36,7 +37,9 @@ export type CodexStructuredItemStreamHandleResult = {
}
export type CodexStructuredItemStreams = {
track: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => void
readonly persistentCount: number
canTrack: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => boolean
track: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => boolean
handle: (
threadId: string,
method: string,
+40 -28
View File
@@ -1,5 +1,6 @@
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { createAgentSessionDeltaCoalescer } from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
import { CodexItemStreamRetention } from './codex-item-stream-retention'
import {
codexJournalItem,
codexStreamingJournalItem,
@@ -10,7 +11,6 @@ import {
MAX_CODEX_ITEM_STREAM_PENDING_PATCHES,
MAX_CODEX_ITEM_STREAM_PENDING_PATCH_BYTES,
MAX_CODEX_ITEM_STREAM_RETAINED_BYTES,
MAX_CODEX_ITEM_STREAM_STATES,
boundStreamItem,
pendingPatchBytes
} from './codex-structured-item-stream-bounds'
@@ -47,8 +47,9 @@ export {
export function createCodexStructuredItemStreams(
deps: CodexItemStreamDeps
): CodexStructuredItemStreams {
const states = new Map<string, CodexItemStreamState>()
const states = new CodexItemStreamRetention(deps.maxMetadataBytes)
const checkpointLengths = new Map<string, number>()
const pendingCheckpoints = new Set<string>()
// Patch updates are authoritative item snapshots. Keep the latest rejected
// snapshot until the journal admits it; unlike streamed deltas, there is no
// coalescer timer to retry these events for us.
@@ -57,8 +58,9 @@ export function createCodexStructuredItemStreams(
const forgetState = (key: string): void => {
coalescer.forget(key)
states.delete(key)
states.forget(key)
checkpointLengths.delete(key)
pendingCheckpoints.delete(key)
const pending = pendingPatches.get(key)
if (pending) {
retainedPatchBytes = Math.max(0, retainedPatchBytes - pendingPatchBytes(pending))
@@ -67,8 +69,8 @@ export function createCodexStructuredItemStreams(
}
const trimStates = (): void => {
while (states.size > MAX_CODEX_ITEM_STREAM_STATES) {
const oldest = states.keys().next().value
while (states.overCapacity) {
const oldest = states.oldestEvictable()
if (typeof oldest !== 'string') {
break
}
@@ -124,6 +126,7 @@ export function createCodexStructuredItemStreams(
const state = states.get(key)
if (state && append(state, text)) {
checkpointLengths.set(key, text.length)
pendingCheckpoints.delete(key)
return true
}
return false
@@ -133,6 +136,7 @@ export function createCodexStructuredItemStreams(
windowMs: deps.coalesceMs,
maxRetainedBytes: deps.maxRetainedBytes,
maxTotalRetainedBytes: deps.maxTotalRetainedBytes,
isProtected: (key) => states.isPersistent(key),
schedule: deps.schedule,
emit: (key, text) => {
return persist(key, text, false)
@@ -144,7 +148,7 @@ export function createCodexStructuredItemStreams(
itemId: string,
type: string,
params: unknown
): CodexItemStreamState => {
): CodexItemStreamState | null => {
const key = codexStructuredItemKey(threadId, itemId)
const existing = states.get(key)
if (existing) {
@@ -152,36 +156,25 @@ export function createCodexStructuredItemStreams(
}
const item = { type, id: itemId }
const state = { item, identity: deps.identityFor(threadId, params, item) }
states.set(key, state)
if (!states.retain(key, state)) {
return null
}
trimStates()
return state
}
const flush = (): boolean => {
let flushed = coalescer.flushAll()
for (const key of states.keys()) {
for (const key of pendingCheckpoints) {
const snapshot = coalescer.snapshot(key)
if (snapshot && checkpointLengths.get(key) !== snapshot.text.length) {
flushed = persist(key, snapshot.text, true) && flushed
} else {
pendingCheckpoints.delete(key)
}
}
for (const [key, pending] of pendingPatches) {
const admission = deps.sink.tryAppendItem
? deps.sink.tryAppendItem(pending.identity, pending.body)
: (deps.sink.appendItem(pending.identity, pending.body), { accepted: true as const })
if (!admission.accepted) {
flushed = false
continue
}
const published = deps.sink.tryPublish
? deps.sink.tryPublish()
: (deps.sink.publish(), { accepted: true as const })
if (!published.accepted) {
flushed = false
continue
}
retainedPatchBytes = Math.max(0, retainedPatchBytes - pendingPatchBytes(pending))
pendingPatches.delete(key)
for (const key of pendingPatches.keys()) {
flushed = flushPatch(key).accepted && flushed
}
return flushed
}
@@ -209,11 +202,21 @@ export function createCodexStructuredItemStreams(
}
return {
get persistentCount() {
return states.persistentSize
},
canTrack: (threadId, item, identity) =>
states.canRetain(codexStructuredItemKey(threadId, item.id), {
item: boundStreamItem(item) as CodexThreadItem,
identity
}),
track: (threadId, item, identity) => {
const key = codexStructuredItemKey(threadId, item.id)
states.delete(key)
states.set(key, { item: boundStreamItem(item) as CodexThreadItem, identity })
if (!states.retain(key, { item: boundStreamItem(item) as CodexThreadItem, identity })) {
return false
}
trimStates()
return true
},
handle: (threadId, method, params) => {
const paramsRecord = readCodexItemStreamRecord(params)
@@ -230,6 +233,9 @@ export function createCodexStructuredItemStreams(
const key = codexStructuredItemKey(threadId, itemId)
const streamFlushed = coalescer.flush(key)
const state = ensureState(threadId, itemId, 'fileChange', params)
if (!state) {
return { handled: true, admission: { accepted: false, reason: 'failed' } }
}
state.item = { ...state.item, changes: paramsRecord.changes }
const translated = codexJournalItem(state.item)
if (translated.body) {
@@ -269,9 +275,14 @@ export function createCodexStructuredItemStreams(
return { handled: true, admission: { accepted: true } }
}
const state = ensureState(threadId, itemId, type ?? 'reasoning', params)
if (!state) {
return { handled: true, admission: { accepted: false, reason: 'failed' } }
}
const delta = method === REASONING_PART_METHOD ? '\n' : paramsRecord.delta
if (typeof delta === 'string') {
const accepted = coalescer.append(codexStructuredItemKey(threadId, state.item.id), delta)
const key = codexStructuredItemKey(threadId, state.item.id)
pendingCheckpoints.add(key)
const accepted = coalescer.append(key, delta)
if (!accepted) {
return { handled: true, admission: { accepted: false, reason: 'backpressure' } }
}
@@ -287,6 +298,7 @@ export function createCodexStructuredItemStreams(
coalescer.dispose()
states.clear()
checkpointLengths.clear()
pendingCheckpoints.clear()
pendingPatches.clear()
retainedPatchBytes = 0
},
@@ -584,6 +584,16 @@ describe('codex item bodies', () => {
})
})
it('preserves plan prose documents byte-for-byte as status text', () => {
const text =
' # Implementation plan\r\n\r\n- [ ] Preserve prose\r\n- [x] Keep café → 日本語\r\n\r\n```ts\r\nconst task = "pending"\r\n```\r\n '
expect(codexJournalItem({ type: 'plan', id: 'plan-document', text })).toEqual({
body: { kind: 'status', text, presentation: 'plan-document' },
handled: true
})
})
it('renders reasoning as status and exposes an unknown item as a provider frame', () => {
expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({
kind: 'status',
@@ -1,11 +1,13 @@
import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
import type { CodexSubagentExecutions } from './codex-subagent-executions'
export type CodexJournalTranslatorDeps = {
sink: StructuredAgentSessionEventSink
bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void
primaryThreadId?: () => string | null
subagentExecutions?: CodexSubagentExecutions
coalesceMs?: number
maxRetainedBytes?: number
schedule?: AgentSessionDeltaCoalescerDeps['schedule']
@@ -11,7 +11,8 @@ import {
type CodexThreadItem
} from './codex-structured-item-translation'
import { createCodexStructuredItemStreams } from './codex-structured-item-streams'
import { codexStructuredItemKey } from './codex-structured-item-stream-bounds'
import { boundStreamItem, codexStructuredItemKey } from './codex-structured-item-stream-bounds'
import { codexCommandOutlivesTurn } from './codex-command-lifecycle'
import type {
CodexItemTranslation,
CodexJournalTranslationAdmission,
@@ -40,7 +41,7 @@ export class CodexJournalItems {
private readonly deps: Pick<
CodexJournalTranslatorDeps,
'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule'
>,
> & { maxMetadataBytes?: number },
private readonly activeTurn: (threadId: string) => string | null,
private readonly suppress: (threadId: string, turnId: string) => void
) {
@@ -49,6 +50,7 @@ export class CodexJournalItems {
coalesceMs: deps.coalesceMs,
maxRetainedBytes: deps.maxRetainedBytes,
schedule: deps.schedule,
maxMetadataBytes: deps.maxMetadataBytes,
identityFor: (threadId, params, item) => {
const turnId = readCodexTurnId(params) ?? this.activeTurn(threadId)
return this.identityFor(threadId, turnId, item)
@@ -81,6 +83,12 @@ export class CodexJournalItems {
if (item.type === 'contextCompaction' && event.method === 'item/started') {
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
if (
event.method !== 'item/completed' &&
!this.streams.canTrack(event.threadId, item, identity)
) {
return { handled: true, admission: { accepted: false, reason: 'failed' } }
}
const translated = codexJournalItem(item)
const command = readCodexJournalString(item, 'command')
if (command) {
@@ -157,12 +165,15 @@ export class CodexJournalItems {
item: CodexThreadItem,
identity: AgentJournalItemIdentity
): void {
this.streams.track(threadId, item, identity)
const retainedItem = codexCommandOutlivesTurn(item)
? (boundStreamItem(item) as CodexThreadItem)
: item
this.streams.track(threadId, retainedItem, identity)
this.activeItems.set(codexStructuredItemKey(threadId, item.id), {
threadId,
turnId,
identity,
item
item: retainedItem
})
}
@@ -194,8 +205,10 @@ export class CodexJournalItems {
}
private trimActiveState(): CodexJournalTranslationAdmission {
while (this.activeItems.size > MAX_CODEX_ACTIVE_ITEMS) {
const oldest = this.activeItems.keys().next().value
while (this.activeItems.size - this.streams.persistentCount > MAX_CODEX_ACTIVE_ITEMS) {
const oldest = [...this.activeItems].find(
([, active]) => !codexCommandOutlivesTurn(active.item)
)?.[0]
if (typeof oldest !== 'string') {
break
}
@@ -20,6 +20,7 @@ import {
} from './codex-structured-item-translation'
import type { CodexStructuredItemStreams } from './codex-structured-item-streams'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
import { codexCommandOutlivesTurn } from './codex-command-lifecycle'
export type CodexActiveJournalItem = {
threadId: string
@@ -118,6 +119,9 @@ export function settleCodexJournalTurn(input: {
if (active.threadId !== input.threadId || active.turnId !== input.turnId) {
continue
}
if (codexCommandOutlivesTurn(active.item)) {
continue
}
const streamed = input.streams.snapshot(active.threadId, active.item.id)
const translated = streamed
? codexStreamingJournalItem(active.item, streamed.text)
@@ -6,6 +6,8 @@
* than as the shape checks each arm performs.
*/
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
import type { CodexJournalItems } from './codex-structured-journal-items'
import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts'
import { settleCodexOversizedNotification } from './codex-structured-journal-settlement'
import {
@@ -41,3 +43,23 @@ export function settleCodexOversizedNotificationFrame(input: {
})
: null
}
export function createCodexOversizedNotificationSettler(
deps: { sink: OversizedInput['sink'] },
items: Pick<CodexJournalItems, 'streams' | 'activeItems'>
) {
return settleOversizedNotification
/** Settles the item a notification the transport refused to carry left
* mid-flight; null when the frame is not one. */
function settleOversizedNotification(
event: Extract<CodexStructuredSessionEvent, { type: 'provider-frame' }>
): CodexJournalTranslationAdmission | null {
return settleCodexOversizedNotificationFrame({
...event,
sink: deps.sink,
streams: items.streams,
activeItems: items.activeItems
})
}
}
@@ -59,6 +59,22 @@ function deliverActivity(
translator: ReturnType<typeof createCodexJournalTranslator>,
params: unknown
): void {
const item = (params as { item: { kind: string; agentThreadId: string } }).item
if (item.kind === 'started' || item.kind === 'completed') {
translator.handle({
type: 'notification',
sessionId: SESSION_ID,
threadId: item.agentThreadId,
method: item.kind === 'started' ? 'turn/started' : 'turn/completed',
params: {
threadId: item.agentThreadId,
turn: {
id: `execution:${item.agentThreadId}`,
status: item.kind === 'started' ? 'inProgress' : 'completed'
}
}
})
}
translator.handle(notification('item/started', params))
translator.handle(notification('item/completed', params))
}
@@ -19,7 +19,7 @@ import {
settleCodexJournalSession,
settleCodexJournalTurn
} from './codex-structured-journal-settlement'
import { settleCodexOversizedNotificationFrame } from './codex-structured-journal-translation-frames'
import { createCodexOversizedNotificationSettler } from './codex-structured-journal-translation-frames'
import { restoreCodexJournalThread } from './codex-structured-journal-translation-restore'
import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state'
import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns'
@@ -58,13 +58,15 @@ export function createCodexJournalTranslator(
(threadId) => activeTurns.current(threadId),
(threadId, turnId) => genericFrames.suppress(threadId, turnId)
)
const settleOversizedNotification = createCodexOversizedNotificationSettler(deps, items)
const prompts = new CodexJournalPrompts(deps, (threadId, itemId) =>
items.detailFor(threadId, itemId)
)
const subagents = new CodexSubagentRoster({
sink: deps.sink,
primaryThreadId: () => deps.primaryThreadId?.() ?? null,
activeTurn: (threadId) => activeTurns.current(threadId)
activeTurn: (threadId) => activeTurns.current(threadId),
...(deps.subagentExecutions ? { executions: deps.subagentExecutions } : {})
})
const flushStreams = (): CodexJournalTranslationAdmission =>
items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' }
@@ -174,16 +176,17 @@ export function createCodexJournalTranslator(
}
return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId)
}
if (event.method === 'turn/started') {
return startTurn(event)
if (event.method === 'turn/started' || event.method === 'turn/completed') {
const childAdmission = subagents.handleTurnEvent(event)
if (!childAdmission.accepted) {
return childAdmission
}
return event.method === 'turn/started' ? startTurn(event) : completeTurn(event)
}
const compaction = compactions.handle(event)
if (compaction) {
return publishActivity(event, compaction)
}
if (event.method === 'turn/completed') {
return completeTurn(event)
}
if (event.method === CODEX_TOKEN_USAGE_METHOD) {
// Classified `status-chrome`, so the generic-frame path swallows it
// before the journal. The roster consumes it as a typed notification.
@@ -240,19 +243,6 @@ export function createCodexJournalTranslator(
}
}
/** Settles the item a notification the transport refused to carry left
* mid-flight; null when the frame is not one. */
function settleOversizedNotification(
event: Extract<CodexStructuredSessionEvent, { type: 'provider-frame' }>
): CodexJournalTranslationAdmission | null {
return settleCodexOversizedNotificationFrame({
...event,
sink: deps.sink,
streams: items.streams,
activeItems: items.activeItems
})
}
function startTurn(
event: Extract<CodexStructuredSessionEvent, { type: 'notification' }>
): CodexJournalTranslationAdmission {
@@ -8,6 +8,8 @@ import {
closeFailedCodexAcquisition,
stopSupersededCodexAcquisition
} from './codex-structured-acquisition-lifecycle'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import { CodexSubagentExecutions } from './codex-subagent-executions'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity'
@@ -74,10 +76,12 @@ export async function acquireCodexStructuredSession(input: {
acquireInput.identity.providerHandle.kind === 'codex'
? acquireInput.identity.providerHandle.threadId
: null
const subagentExecutions = new CodexSubagentExecutions()
const translator = acquireInput.events
? createCodexJournalTranslator({
sink: acquireInput.events,
primaryThreadId: () => primaryThreadId,
subagentExecutions,
bindPromptItemId: (journalItemId, threadId, promptKey) =>
acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey)
})
@@ -138,6 +142,7 @@ export async function acquireCodexStructuredSession(input: {
connection: acquisition.connection,
error,
prompts: acquisition.prompts,
onBackgroundTasksChanged: deps.onBackgroundTasksChanged,
...(deps.onEvent ? { onEvent: deps.onEvent } : {})
})
} finally {
@@ -199,6 +204,7 @@ export async function acquireCodexStructuredSession(input: {
reportedOptions: reportedCodexThreadOptions(opened),
turnIdWaiters: [],
translator,
backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions),
forceCloseUnexpected: (reason) =>
input.forceCloseUnexpected(
sessionId,
@@ -16,11 +16,7 @@ import type { CodexJournalTranslationAdmission } from './codex-structured-journa
import { answerCodexPrompt } from './codex-structured-prompt-replies'
import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start'
import { supportsCodexStructuredLocation } from './codex-structured-location-support'
import {
closeAllCodexSessions,
closeCodexPublishedSession,
closeCodexSession
} from './codex-structured-session-close'
import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown'
import {
applyCodexStructuredSessionOption,
readLiveCodexSessionOptions
@@ -54,6 +50,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
private readonly acquisitions = new CodexAcquisitionRegistry()
private readonly turnCancellation: CodexStructuredTurnCancellation
private readonly notificationRetries: ReturnType<typeof createCodexStructuredNotificationRetry>
private readonly teardown: CodexStructuredSessionTeardown
constructor(private readonly deps: CodexStructuredSessionAdapterDeps) {
this.notificationRetries = createCodexStructuredNotificationRetry({
@@ -61,6 +58,15 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
translate: (sessionId, session, method, params) =>
this.translateNotification(sessionId, session, method, params)
})
this.teardown = new CodexStructuredSessionTeardown({
sessions: this.sessions,
acquisitions: this.acquisitions,
...(deps.onEvent ? { onEvent: deps.onEvent } : {}),
...(deps.onBackgroundTasksChanged
? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged }
: {}),
forgetNotificationRetries: (sessionId) => this.notificationRetries.clear(sessionId, null)
})
this.turnCancellation = new CodexStructuredTurnCancellation({
captureTurnProcesses: deps.captureTurnProcesses,
terminateTurnProcesses: deps.terminateTurnProcesses,
@@ -92,7 +98,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
handleUnhandledFrame: (sessionId, kind, payload) =>
this.handleUnhandledFrame(sessionId, kind, payload),
forceCloseUnexpected: (sessionId, fence, acquisitionGeneration, reason) =>
this.forceCloseUnexpected(sessionId, fence, acquisitionGeneration, reason)
this.teardown.forceCloseUnexpected(sessionId, fence, acquisitionGeneration, reason)
})
/** Buffers pre-publication events and drops events from superseded children. */
@@ -134,12 +140,20 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
session: CodexSession,
event: CodexStructuredSessionEvent
): CodexJournalTranslationAdmission {
if (event.type === 'notification' && !session.backgroundTasks.canObserve(event)) {
return { accepted: false, reason: 'failed' }
}
const admission = session.translator?.handle(event) ?? { accepted: true }
if (!admission.accepted) {
return admission
}
if (event.type === 'notification') {
this.compactions.codex(event.sessionId, event.method, event.params)
// After the admission check, so a refused frame is observed by the strip
// only on the retry that also reaches the journal.
if (session.backgroundTasks.observe(event)) {
this.deps.onBackgroundTasksChanged?.(event.sessionId, session.backgroundTasks.state)
}
}
if (event.type === 'ended') {
this.compactions.ended(event.sessionId)
@@ -167,6 +181,10 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
)
}
backgroundTaskState: NonNullable<StructuredAgentSessionAdapter['backgroundTaskState']> = (
sessionId
) => this.sessions.get(sessionId)?.backgroundTasks.state
bindPromptItemId = (sessionId: string, journalItemId: string, promptKey: string): void =>
this.sessions
.get(sessionId)
@@ -267,59 +285,12 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
identity: AgentSessionJournalIdentity
}): Promise<string | null> => this.sessions.get(input.identity.sessionId)?.historyPath ?? null
closeSession = async (sessionId: string): Promise<boolean> => {
const closed = await closeCodexSession(
sessionId,
this.sessions,
this.acquisitions,
this.deps.onEvent
)
if (closed) {
this.notificationRetries.clear(sessionId, null)
}
return closed
}
forceCloseSession = async (sessionId: string): Promise<boolean> => {
const closed = await closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent, {
allowFailedSettlement: true,
requestedClose: false
})
if (closed) {
this.notificationRetries.clear(sessionId, null)
}
return closed
}
private forceCloseUnexpected(
sessionId: string,
fence: number,
acquisitionGeneration: string,
reason: Error
): Promise<boolean> {
const session = this.sessions.get(sessionId)
if (
!session ||
session.ended ||
session.fence !== fence ||
session.acquisitionGeneration !== acquisitionGeneration
) {
return Promise.resolve(false)
}
return closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent, {
allowFailedSettlement: true,
requestedClose: false,
expectedFence: fence,
expectedAcquisitionGeneration: acquisitionGeneration,
unexpectedReason: reason
})
}
disposeSession = (sessionId: string): Promise<boolean> => this.closeSession(sessionId)
closeAll = (): Promise<void> =>
closeAllCodexSessions(this.sessions, this.acquisitions, (sessionId) =>
this.disposeSession(sessionId)
)
closeSession = (sessionId: string): Promise<boolean> => this.teardown.close(sessionId)
forceCloseSession = (sessionId: string): Promise<boolean> => this.teardown.forceClose(sessionId)
disposeSession = (sessionId: string): Promise<boolean> => this.teardown.close(sessionId)
closeAll = (): Promise<void> => this.teardown.closeAll()
releaseAcquisition = (input: { sessionId: string }): Promise<boolean> =>
this.closeSession(input.sessionId)
this.teardown.close(input.sessionId)
private session(sessionId: string): CodexSession {
return requireLiveCodexSession(this.sessions, sessionId)
@@ -0,0 +1,258 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
import type {
CodexAppServerConnection,
CodexAppServerConnectionHandlers,
openCodexAppServerConnection
} from './codex-app-server-connection'
import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import type { CodexStructuredSessionEvent } from './codex-structured-session-state'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
// Proves the strip is actually REACHED from provider traffic: the tracker is
// unit-tested separately, and a producer that is correct but unwired publishes
// nothing while every one of its own tests stays green.
const THREAD_ID = '01a07d54-3785-71d0-b065-82c8ebbc572a'
const PARENT_TURN = '01a07d54-37be-72e1-8206-8f0c23dd2cef'
const CHILD_ID = '01a07d54-5523-78a3-91f5-e0acb1dab065'
/** A three-route stand-in, deliberately smaller than the full adapter harness:
* this suite only needs a thread and a notification pipe. */
function fakeCodex(close: () => Promise<boolean> = async () => true): {
handlers: () => CodexAppServerConnectionHandlers
openConnection: typeof openCodexAppServerConnection
} {
let live: CodexAppServerConnectionHandlers = {}
const openConnection = (async (_launch, handlers = {}) => {
live = handlers
const connection: CodexAppServerConnection = {
pid: 4321,
closed: false,
request: async (method) =>
method === 'thread/start' ? { thread: { id: THREAD_ID, path: null } } : {},
notify: () => {},
respond: () => {},
respondWithError: () => {},
close
} as unknown as CodexAppServerConnection
return connection
}) as typeof openCodexAppServerConnection
return { handlers: () => live, openConnection }
}
function identity(sessionId: string): AgentSessionJournalIdentity {
return {
sessionId,
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: THREAD_ID }
}
}
function subagentNotification(kind: string): { method: string; params: unknown } {
return {
method: 'item/started',
params: {
item: {
type: 'subAgentActivity',
id: 'call_1',
kind,
agentThreadId: CHILD_ID,
agentPath: '/root/count_a'
},
threadId: THREAD_ID,
turnId: PARENT_TURN
}
}
}
const TURN_COMPLETED = {
method: 'turn/completed',
params: { threadId: THREAD_ID, turn: { id: PARENT_TURN, status: 'completed' } }
}
async function adapterWithSession(
published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[],
events?: StructuredAgentSessionEventSink,
onEvent?: (event: CodexStructuredSessionEvent) => void,
close?: () => Promise<boolean>
): Promise<{ adapter: CodexStructuredSessionAdapter; codex: ReturnType<typeof fakeCodex> }> {
const codex = fakeCodex(close)
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: null
}),
openConnection: codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
onEvent,
onBackgroundTasksChanged: (sessionId, state) => published.push({ sessionId, state })
})
await adapter.acquire({
identity: identity('session-1'),
fence: 7,
spawnToken: 'spawn-9',
events
})
codex.handlers().onNotification?.('turn/started', {
threadId: THREAD_ID,
turn: { id: PARENT_TURN, status: 'inProgress' }
})
codex.handlers().onNotification?.('turn/started', {
threadId: CHILD_ID,
turn: { id: 'child-turn', status: 'inProgress' }
})
return { adapter, codex }
}
describe('codex background tasks reach the strip', () => {
it('clears natural-exit state before lifecycle observers can read it', async () => {
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
const onEvent = vi.fn()
const { adapter, codex } = await adapterWithSession(published, undefined, onEvent)
const spawn = subagentNotification('started')
codex.handlers().onNotification?.(spawn.method, spawn.params)
codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params)
expect(adapter.backgroundTaskState('session-1')?.tasks).toHaveLength(1)
published.length = 0
onEvent.mockImplementation((event: CodexStructuredSessionEvent) => {
if (event.type === 'ended') {
expect(adapter.backgroundTaskState('session-1')).toBeNull()
}
})
codex.handlers().onExit?.(new Error('provider exited'))
expect(adapter.backgroundTaskState('session-1')).toBeNull()
expect(published).toEqual([{ sessionId: 'session-1', state: null }])
await adapter.closeSession('session-1')
})
it('keeps live tasks when close is refused', async () => {
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
const close = vi.fn(async () => false)
const { adapter, codex } = await adapterWithSession(published, undefined, undefined, close)
const spawn = subagentNotification('started')
codex.handlers().onNotification?.(spawn.method, spawn.params)
codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params)
const before = adapter.backgroundTaskState('session-1')
published.length = 0
expect(await adapter.closeSession('session-1')).toBe(false)
expect(adapter.backgroundTaskState('session-1')).toEqual(before)
expect(published).toEqual([])
close.mockResolvedValue(true)
await adapter.closeSession('session-1')
})
it('does not let an old exit callback clear a replacement roster', async () => {
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
const { adapter, codex } = await adapterWithSession(published)
const oldExit = codex.handlers().onExit
await adapter.acquire({ identity: identity('session-1'), fence: 8, spawnToken: 'spawn-10' })
codex.handlers().onNotification?.('turn/started', {
threadId: CHILD_ID,
turn: { id: 'replacement-child-turn' }
})
const spawn = subagentNotification('started')
codex.handlers().onNotification?.(spawn.method, spawn.params)
const before = adapter.backgroundTaskState('session-1')
expect(before?.tasks).toHaveLength(1)
published.length = 0
oldExit?.(new Error('old provider exited late'))
expect(adapter.backgroundTaskState('session-1')).toEqual(before)
expect(published).toEqual([])
await adapter.closeSession('session-1')
})
it('recovers the exact provider generation when command metadata cannot be admitted', async () => {
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
const observed: CodexStructuredSessionEvent[] = []
const appendItem = vi.fn()
const { adapter, codex } = await adapterWithSession(
published,
{ appendItem, appendTombstone: () => {}, publish: () => {} },
(event) => observed.push(event)
)
appendItem.mockClear()
observed.length = 0
const admission = vi
.spyOn(CodexBackgroundTaskTracker.prototype, 'canObserve')
.mockReturnValue(false)
try {
codex.handlers().onNotification?.('item/started', {
threadId: THREAD_ID,
turnId: PARENT_TURN,
item: {
type: 'commandExecution',
id: 'over-budget',
command: 'sleep 1',
source: 'unifiedExecStartup',
status: 'inProgress'
}
})
await vi.waitFor(() => expect(adapter.backgroundTaskState('session-1')).toBeUndefined())
expect(appendItem.mock.calls.map((call) => call[1])).toEqual([
{ kind: 'status', text: 'Provider exited: notification admission failed (failed)' }
])
expect(observed).toEqual([
expect.objectContaining({
type: 'ended',
cause: 'unexpected-exit',
fence: 7,
acquisitionGeneration: expect.any(String),
reason: 'notification admission failed (failed)'
})
])
expect(published).toEqual([{ sessionId: 'session-1', state: null }])
} finally {
admission.mockRestore()
await adapter.closeSession('session-1')
}
})
it('publishes the orphaned fan-out once the spawning turn completes', async () => {
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
const { adapter, codex } = await adapterWithSession(published)
const spawn = subagentNotification('started')
codex.handlers().onNotification?.(spawn.method, spawn.params)
// The child is still inside the turn, so the strip stays silent.
expect(published).toEqual([])
expect(adapter.backgroundTaskState('session-1')).toBeNull()
codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params)
expect(published).toEqual([
{
sessionId: 'session-1',
state: {
state: 'monitoring',
supportsStopAll: false,
tasks: [{ id: `codex-agent:${CHILD_ID}`, kind: 'agent', description: 'count_a' }]
}
}
])
expect(adapter.backgroundTaskState('session-1')).toEqual(published[0].state)
})
it('clears the strip when the session closes', async () => {
const published: { sessionId: string; state: AgentSessionBackgroundTaskState | null }[] = []
const { adapter, codex } = await adapterWithSession(published)
const spawn = subagentNotification('started')
codex.handlers().onNotification?.(spawn.method, spawn.params)
codex.handlers().onNotification?.(TURN_COMPLETED.method, TURN_COMPLETED.params)
published.length = 0
expect(await adapter.closeSession('session-1')).toBe(true)
// Explicit null, not silence: the reader answers `undefined` once the
// session is gone, which every channel treats as "unchanged".
expect(published).toEqual([{ sessionId: 'session-1', state: null }])
expect(adapter.backgroundTaskState('session-1')).toBeUndefined()
})
})
@@ -10,6 +10,7 @@ import {
type CodexStructuredSessionEvent
} from './codex-structured-session-adapter'
import { handleCodexSessionExit } from './codex-structured-session-close'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import type { CodexSession } from './codex-structured-session-state'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
@@ -90,6 +91,7 @@ describe('Codex structured session close lifecycle', () => {
} as unknown as NonNullable<CodexSession['translator']>
const session = {
connection,
backgroundTasks: new CodexBackgroundTaskTracker('thread-1'),
ended: false,
requestedClose: false,
fence: 7,
@@ -4,6 +4,7 @@ import {
cancelCodexAcquisitionAttempt,
type CodexAcquisitionRegistry,
type CodexSession,
type CodexStructuredSessionAdapterDeps,
type CodexStructuredSessionEvent
} from './codex-structured-session-state'
import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
@@ -16,6 +17,7 @@ export function handleCodexSessionExit(input: {
prompts?: CodexSession['prompts']
allowFailedSettlement?: boolean
onEvent?: (event: CodexStructuredSessionEvent) => void
onBackgroundTasksChanged?: CodexStructuredSessionAdapterDeps['onBackgroundTasksChanged']
}): boolean {
const session = input.sessions.get(input.sessionId)
if (!session || session.connection !== input.connection || session.ended) {
@@ -43,6 +45,8 @@ export function handleCodexSessionExit(input: {
event.settlementRetryRequired = true
}
session.ended = true
session.backgroundTasks.clear()
input.onBackgroundTasksChanged?.(input.sessionId, null)
session.unbindReadingControl?.()
input.onEvent?.(event)
session.prompts.clear()
@@ -7,6 +7,7 @@ import {
reportedCodexThreadOptions,
restoredCodexSessionOptions
} from './codex-structured-session-options'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import type { CodexSession } from './codex-structured-session-state'
function optionSession(request: CodexAppServerConnection['request']): CodexSession {
@@ -20,6 +21,7 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi
respondWithError: () => {},
close: async () => true
},
backgroundTasks: new CodexBackgroundTaskTracker('thread-1'),
ended: false,
requestedClose: false,
fence: 1,

Some files were not shown because too many files have changed in this diff Show More