mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
Merge branch 'main' into brennanb2025/unify-agent-session-launch
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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) ??
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user