fix(codex): ignore subagents in transcript fallback

This commit is contained in:
weishu
2026-07-22 09:20:00 +08:00
parent a9115715f8
commit 3208f139b5
5 changed files with 125 additions and 30 deletions
+56 -16
View File
@@ -361,7 +361,7 @@ describe('codexLocalLauncher', () => {
});
});
it('falls back to fresh transcript activity when SessionStart does not arrive', async () => {
it('falls back to the top-level review transcript when a review subagent is active', async () => {
const originalCodexHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = tempDir;
const now = new Date();
@@ -373,8 +373,9 @@ describe('codexLocalLauncher', () => {
String(now.getUTCDate()).padStart(2, '0')
);
await mkdir(sessionDirectory, { recursive: true });
const transcriptPath = join(sessionDirectory, 'rollout-fallback-thread.jsonl');
const { session, userMessages } = createSessionStub(
const transcriptPath = join(sessionDirectory, 'rollout-review-primary.jsonl');
const reviewSubagentPath = join(sessionDirectory, 'rollout-review-subagent.jsonl');
const { session, userMessages, agentMessages } = createSessionStub(
'default',
['--cd', '/tmp/effective-codex-cwd'],
'/tmp/worktree',
@@ -392,27 +393,66 @@ describe('codexLocalLauncher', () => {
await vi.waitFor(() => expect(harness.launches).toHaveLength(1));
expect(session.sessionId).toBeNull();
await writeFile(transcriptPath, [
JSON.stringify({
type: 'session_meta',
payload: { id: 'fallback-thread', cwd: '/tmp/effective-codex-cwd' }
}),
JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'user_message', message: 'fallback prompt' }
})
].join('\n') + '\n');
await Promise.all([
writeFile(transcriptPath, [
JSON.stringify({
type: 'session_meta',
payload: {
id: 'review-primary',
cwd: '/tmp/effective-codex-cwd',
source: 'cli'
}
}),
JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'user_message', message: '/review' }
}),
JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'agent_message', message: 'final review result' }
})
].join('\n') + '\n'),
writeFile(reviewSubagentPath, [
JSON.stringify({
type: 'session_meta',
payload: {
id: 'review-subagent',
cwd: '/tmp/effective-codex-cwd',
source: { subagent: 'review' }
}
}),
JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'user_message', message: 'review instructions' }
}),
JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'agent_message', message: 'internal review work' }
})
].join('\n') + '\n')
]);
await vi.waitFor(
() => expect(session.sessionId).toBe('fallback-thread'),
() => expect(session.sessionId).toBe('review-primary'),
{ timeout: 3_000, interval: 50 }
);
if (releaseRunBarrier) releaseRunBarrier();
await launcherPromise;
expect(session.transcriptPath).toBe(transcriptPath);
expect(userMessages).toContain('fallback prompt');
expect(userMessages).toContain('/review');
expect(agentMessages).toContainEqual(expect.objectContaining({
type: 'message',
message: 'final review result'
}));
expect(agentMessages).not.toContainEqual(expect.objectContaining({
type: 'message',
message: 'internal review work'
}));
} finally {
if (releaseRunBarrier) releaseRunBarrier();
if (originalCodexHome === undefined) {
@@ -0,0 +1,6 @@
export function isCodexSubagentSource(value: unknown): boolean {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
return Object.prototype.hasOwnProperty.call(value, 'subagent');
}
@@ -129,6 +129,42 @@ describe('codexTranscriptLocator', () => {
expect(new Set(ambiguous[0])).toEqual(new Set([first, second]));
});
it('ignores a review subagent and waits for the top-level review transcript', async () => {
const located: string[] = [];
const ambiguous: string[][] = [];
locator = createCodexTranscriptLocator({
cwd: '/tmp/project',
startupTimestampMs: Date.now(),
intervalMs: 25,
settlementMs: 50,
onLocated: (result) => located.push(result.transcriptPath),
onAmbiguous: (paths) => ambiguous.push(paths)
});
await locator.ready;
const reviewSubagent = await createTranscript(
'thread-review-subagent',
'/tmp/project',
{ subagent: 'review' }
);
const userEvent = (message: string) => `${JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
payload: { type: 'user_message', message }
})}\n`;
await appendFile(reviewSubagent, userEvent('review instructions'));
await wait(100);
expect(located).toEqual([]);
expect(ambiguous).toEqual([]);
const primary = await createTranscript('thread-review-primary', '/tmp/project', 'cli');
await appendFile(primary, userEvent('/review'));
await wait(150);
expect(located).toEqual([primary]);
expect(ambiguous).toEqual([]);
});
it('rejects candidates whose activity arrives in adjacent polling cycles', async () => {
const located: string[] = [];
const ambiguous: string[][] = [];
@@ -208,9 +244,13 @@ describe('codexTranscriptLocator', () => {
expect(located).toEqual([]);
});
it('polls only the exact resume transcript once it is found', async () => {
it('polls only the exact resume transcript once it is found, including a subagent', async () => {
const unrelated = await createTranscript('thread-unrelated', '/tmp/project');
const target = await createTranscript('thread-resume', '/tmp/original-project');
const target = await createTranscript(
'thread-resume',
'/tmp/original-project',
{ subagent: 'review' }
);
await appendFile(unrelated, `${JSON.stringify({
timestamp: new Date().toISOString(),
type: 'event_msg',
@@ -239,11 +279,15 @@ describe('codexTranscriptLocator', () => {
expect(ambiguous).toEqual([]);
});
async function createTranscript(sessionId: string, cwd: string): Promise<string> {
async function createTranscript(sessionId: string, cwd: string, source?: unknown): Promise<string> {
const transcriptPath = join(sessionDirectory, `rollout-${sessionId}.jsonl`);
await writeFile(transcriptPath, `${JSON.stringify({
type: 'session_meta',
payload: { id: sessionId, cwd }
payload: {
id: sessionId,
cwd,
...(source === undefined ? {} : { source })
}
})}\n`);
return transcriptPath;
}
+13 -4
View File
@@ -3,6 +3,7 @@ import { join, resolve } from 'node:path';
import { open, readdir, stat } from 'node:fs/promises';
import { logger } from '@/ui/logger';
import { convertCodexEvent, type CodexSessionEvent } from './codexEventConverter';
import { isCodexSubagentSource } from './codexSessionMetadata';
export type LocatedCodexTranscript = {
sessionId: string;
@@ -21,6 +22,7 @@ type TranscriptState = {
ino: number;
sessionId: string | null;
cwd: string | null;
isSubagent: boolean;
};
type CodexTranscriptLocatorOptions = {
@@ -170,7 +172,8 @@ class CodexTranscriptLocatorImpl {
mtimeMs: 0,
ino: fileStats.ino,
sessionId: null,
cwd: null
cwd: null,
isSubagent: false
};
const replaced = previous && previous.ino !== fileStats.ino;
@@ -186,7 +189,8 @@ class CodexTranscriptLocatorImpl {
mtimeMs: 0,
ino: fileStats.ino,
sessionId: null,
cwd: null
cwd: null,
isSubagent: false
};
} else if (previous
&& fileStats.size === previous.size
@@ -238,6 +242,9 @@ class CodexTranscriptLocatorImpl {
state.sessionId = asString(metadata?.id) ?? state.sessionId;
const eventCwd = asString(metadata?.cwd);
state.cwd = eventCwd ? normalizePath(eventCwd) : state.cwd;
if (metadata && Object.prototype.hasOwnProperty.call(metadata, 'source')) {
state.isSubagent = isCodexSubagentSource(metadata.source);
}
}
if (convertCodexEvent(event)?.userActivity) {
@@ -263,8 +270,10 @@ class CodexTranscriptLocatorImpl {
if (state.sessionId !== this.resumeSessionId) {
return null;
}
} else if (state.cwd !== this.targetCwd) {
return null;
} else {
if (state.isSubagent || state.cwd !== this.targetCwd) {
return null;
}
}
return { sessionId: state.sessionId, transcriptPath };
+2 -6
View File
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'
import { basename, dirname, join, relative } from 'node:path'
import { homedir } from 'node:os'
import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol'
import { isCodexSubagentSource } from '@/codex/utils/codexSessionMetadata'
const DEFAULT_CODEX_SESSION_SCAN_LIMIT = 200
@@ -78,11 +79,6 @@ function shouldIgnoreSyntheticUserMessage(text: string): boolean {
return normalized.startsWith('# AGENTS.md instructions') || normalized.startsWith('<environment_context>')
}
function isSubagentSource(value: unknown): boolean {
const record = asRecord(value)
return Boolean(record && Object.prototype.hasOwnProperty.call(record, 'subagent'))
}
function inferSessionIdFromFileName(filePath: string): string | null {
return /([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/.exec(filePath)?.[1] ?? null
}
@@ -338,7 +334,7 @@ function parseCodexLocalSession(
if (!record) continue
if (record.type === 'session_meta') {
const payload = asRecord(record.payload)
if (isSubagentSource(payload?.source)) return null
if (isCodexSubagentSource(payload?.source)) return null
if (!sessionId && typeof payload?.id === 'string') sessionId = payload.id
if (!cwd && typeof payload?.cwd === 'string') cwd = payload.cwd
if (!originator && typeof payload?.originator === 'string') originator = payload.originator