From ca2557e1f6cd483f4192fb678fb38f654a48322c Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 07:31:08 +0800 Subject: [PATCH 01/82] docs: add codex resume recovery design --- .../specs/2026-04-02-codex-resume-design.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-02-codex-resume-design.md diff --git a/docs/superpowers/specs/2026-04-02-codex-resume-design.md b/docs/superpowers/specs/2026-04-02-codex-resume-design.md new file mode 100644 index 000000000..1d1b66ba0 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-codex-resume-design.md @@ -0,0 +1,294 @@ +# Codex Resume Deterministic Recovery Design + +## Summary + +Improve HAPI Codex resume so explicit `hapi codex resume ` behaves deterministically and only reports success when both transcript recovery and remote thread reattachment succeed. + +This design intentionally targets the first high-value fix only: +- deterministic transcript resolution by `sessionId` +- strict remote reattach to the original Codex thread +- no fuzzy adoption when user explicitly requested a session id + +Out of scope for this design: +- injecting parsed history into app-server `thread/resume(history)` +- improving UI rendering for compaction/token/context events +- broad refactors of Codex event conversion + +## Problem + +Current Codex resume mixes two different concerns: + +1. finding the correct local transcript file +2. reattaching remote control to the original live thread + +Today, explicit resume still flows through scanner logic designed for ambiguous adoption: +- scanner walks `~/.codex/sessions/YYYY/MM/DD` +- matching depends on `cwd`, timestamps, time window, recent activity +- date filtering can exclude older session files entirely + +This is too conservative for explicit user intent. A known `sessionId` should not be treated like an unknown session. + +Current remote behavior is also too permissive: +- remote launcher attempts `thread/resume(threadId)` +- if it fails, it silently falls back to `thread/start` + +That creates false success semantics: +- history may appear +- a new live thread may be created +- user thinks the original session was resumed, but it was not + +## Goals + +### Functional goals +- Explicit resume resolves the Codex transcript file directly from `sessionId` +- Transcript recovery is deterministic; no fuzzy matching in explicit resume mode +- Remote attach must reconnect to the original thread id +- If remote reattach fails, overall resume fails +- No silent fallback to a new thread during explicit resume + +### Success criteria +An explicit Codex resume is only considered successful if all are true: +- exactly one matching transcript file is resolved +- transcript history is replayed into HAPI +- remote `thread/resume` succeeds for the same session/thread id +- subsequent live turns continue on that same thread + +### Non-goals +- perfect recovery for sessions with corrupted or missing local transcript files +- redesigning Codex app-server protocol usage +- solving all missing Codex event/UI fidelity issues + +## Reference: why Claude is more reliable + +Claude has two properties Codex currently lacks: + +1. deterministic file path derivation + - `cwd -> project dir` + - `sessionId -> exact jsonl path` +2. explicit runtime session continuity updates + - hook-based session notifications + - SDK/init path updates HAPI when session id changes + +Codex cannot copy this 1:1 today because HAPI does not have an equivalent Codex session-start hook path. But it can adopt the key reliability principle: +- file-first deterministic recovery for explicit resume +- strict remote continuation semantics + +## Proposed architecture + +Split explicit Codex resume into two independent but coordinated stages. + +### Stage A: deterministic transcript attach + +For explicit `resumeSessionId`: +- resolve `CODEX_HOME|~/.codex/sessions/**/*-.jsonl` +- require exactly one match +- parse first line `session_meta` +- validate `session_meta.payload.id === resumeSessionId` +- use that file directly for history replay and incremental watch + +Behavioral rules: +- do not run fuzzy `cwd + timestamp + recent activity` adoption +- do not use session date prefix narrowing for explicit resume +- do not adopt a different session if lookup fails + +### Stage B: strict remote thread attach + +For explicit `resumeSessionId` in remote mode: +- call `thread/resume({ threadId: resumeSessionId, ...threadParams })` +- if success, continue normal turn flow +- if failure, explicit resume fails immediately +- do not fall back to `thread/start` + +Behavioral rules: +- explicit resume means attach original thread or fail +- new thread creation is allowed only for non-resume sessions + +## Components and file responsibilities + +### New: deterministic resolver +**File:** `cli/src/codex/utils/resolveCodexSessionFile.ts` + +Responsibility: +- find transcript file(s) for a specific Codex session id +- classify result as `found`, `not_found`, or `ambiguous` +- validate `session_meta` +- return structured metadata needed by launcher/scanner + +Proposed return shape: +- `status: 'found' | 'not_found' | 'ambiguous' | 'invalid'` +- `sessionId` +- `filePath?` +- `cwd?` +- `timestamp?` +- `matches?` +- `reason?` + +Why separate helper: +- avoids embedding lookup policy inside scanner internals +- enables focused unit tests +- reusable by launchers and future diagnostics + +### Change: Codex session scanner +**File:** `cli/src/codex/utils/codexSessionScanner.ts` + +Responsibility after change: +- support two modes + 1. explicit deterministic mode + 2. existing fallback adoption mode + +In explicit deterministic mode: +- scanner receives resolved `filePath` and `sessionId` +- only scans/watches the target file +- skips fuzzy candidate selection, date prefix filtering, recent-activity heuristics +- replays transcript and follows appended events from that file + +In fallback adoption mode: +- preserve current behavior for non-resume sessions or unknown-session adoption + +### Change: local launcher +**File:** `cli/src/codex/codexLocalLauncher.ts` + +Responsibility after change: +- if `resumeSessionId` exists, resolve transcript before building scanner +- pass deterministic resolution into scanner +- emit explicit user-visible failure if deterministic resolution fails + +Local launcher still launches `codex resume ` for Codex CLI, but HAPI no longer treats scanner fuzzy matching as acceptable recovery for explicit resume. + +### Change: remote launcher +**File:** `cli/src/codex/codexRemoteLauncher.ts` + +Responsibility after change: +- explicit resume uses strict `thread/resume` +- if `thread/resume` fails, abort explicit resume path +- remove silent `resume -> startThread` fallback for explicit resume +- `thread/start` remains valid only when there is no explicit resume session id + +## State model + +### Explicit resume state machine +1. receive `resumeSessionId` +2. resolve transcript file +3. if resolve fails -> overall resume failure +4. initialize deterministic scanner on resolved file +5. start transcript replay/watch +6. remote launcher calls `thread/resume(resumeSessionId)` +7. if remote attach fails -> overall resume failure +8. if remote attach succeeds -> live turns proceed normally + +### Failure semantics + +#### Transcript resolve failure +Examples: +- no matching file +- multiple matching files +- invalid first line +- `session_meta.payload.id` mismatch + +Result: +- explicit resume fails +- no fuzzy adoption fallback +- message explains exact cause + +#### Transcript ok, remote attach fails +Result: +- overall explicit resume fails +- message should make clear that history may be present but original live thread was not reattached +- no fallback new thread creation + +#### Remote attach ok, transcript missing +Result: +- overall explicit resume fails under this design +- success requires both transcript recovery and remote reattach + +## User-visible behavior + +For explicit resume, user-visible messaging should reflect hard truth, not best-effort ambiguity. + +Examples: +- `Resolved Codex transcript for session : ` +- `Failed to resolve Codex transcript for session : not found` +- `Failed to reattach Codex remote thread ; explicit resume aborted` + +Important: +- do not emit messages that imply success before both stages are complete +- do not silently continue on a replacement thread + +## Testing strategy + +Write necessary tests only. + +### 1. Resolver unit tests +**File:** `cli/src/codex/utils/resolveCodexSessionFile.test.ts` + +Cover: +- one matching file -> found +- zero matching files -> not_found +- multiple matching files -> ambiguous +- invalid first line / invalid json -> invalid +- `session_meta` missing or wrong id -> invalid + +### 2. Scanner tests +**File:** `cli/src/codex/utils/codexSessionScanner.test.ts` (new or existing test coverage extension) + +Cover: +- explicit deterministic mode scans only the resolved file +- explicit mode replays history from resolved file +- explicit mode watches increments on same file +- explicit mode does not use fuzzy adoption when resolution fails +- fallback adoption mode remains unchanged for non-resume flows + +### 3. Remote launcher tests +Target file: +- `cli/src/codex/codexRemoteLauncher.ts` behavior tests + +Cover: +- explicit resume + `resumeThread` success -> no `startThread` +- explicit resume + `resumeThread` failure -> overall failure, no `startThread` +- no explicit resume -> `startThread` path still works + +## Risks and mitigations + +### Risk: strictness may surface failures that were previously hidden +Mitigation: +- this is intended +- false success is worse than explicit failure for resume semantics + +### Risk: old sessions with missing local files cannot be resumed +Mitigation: +- acceptable in this phase +- future work can explore history injection or alternate recovery paths + +### Risk: scanner changes break non-resume adoption +Mitigation: +- isolate deterministic mode from existing fallback mode +- add tests to preserve current non-resume behavior + +## Later extensions + +Not part of this design, but compatible with it: +- parse transcript into app-server `history` for `thread/resume` +- better conversion of compaction/context/token events +- richer diagnostics command for Codex session lookup + +## Implementation recommendation + +Implement in this order: +1. add deterministic resolver helper and tests +2. thread resolver into local launcher + scanner explicit mode +3. enforce strict remote `thread/resume` semantics +4. add remote behavior tests +5. manually validate with known real session ids + +## Manual validation targets + +Use known dedicated sessions only. Current examples from notes: +- `019d3c3f-ba61-71f1-9316-c6d73e4c0aa4` +- `019d49c4-18fa-73b2-a9f9-202fa9c1966c` +- `019d4482-4e6b-7b90-be84-f4b400b7b69d` + +Validation expectations: +- transcript path resolves directly by id +- HAPI history appears from that transcript +- remote attaches to same thread id +- no new replacement thread is created on resume failure From 6c9b1d23018d7702c68b3927695383d61139694b Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 07:39:46 +0800 Subject: [PATCH 02/82] Add Codex session file resolver --- .../utils/resolveCodexSessionFile.test.ts | 215 ++++++++++++++++++ .../codex/utils/resolveCodexSessionFile.ts | 177 ++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 cli/src/codex/utils/resolveCodexSessionFile.test.ts create mode 100644 cli/src/codex/utils/resolveCodexSessionFile.ts diff --git a/cli/src/codex/utils/resolveCodexSessionFile.test.ts b/cli/src/codex/utils/resolveCodexSessionFile.test.ts new file mode 100644 index 000000000..fe69d2f6e --- /dev/null +++ b/cli/src/codex/utils/resolveCodexSessionFile.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveCodexSessionFile } from './resolveCodexSessionFile'; + +describe('resolveCodexSessionFile', () => { + let testDir: string; + let sessionsDir: string; + let originalCodexHome: string | undefined; + + beforeEach(async () => { + testDir = join(tmpdir(), `codex-session-resolver-${Date.now()}`); + sessionsDir = join(testDir, 'sessions', '2026', '04', '02'); + await mkdir(sessionsDir, { recursive: true }); + + originalCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = testDir; + }); + + afterEach(async () => { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + + if (existsSync(testDir)) { + await rm(testDir, { recursive: true, force: true }); + } + }); + + it('finds a unique matching transcript file', async () => { + const sessionId = 'session-unique'; + const filePath = join(sessionsDir, `codex-${sessionId}.jsonl`); + await writeFile( + filePath, + [ + JSON.stringify({ + type: 'session_meta', + payload: { id: sessionId, cwd: '/work/unique', timestamp: '2026-04-02T01:02:03.000Z' } + }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'hello' } }) + ].join('\n') + '\n' + ); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'found', + filePath, + cwd: '/work/unique', + timestamp: Date.parse('2026-04-02T01:02:03.000Z') + }); + }); + + it('succeeds when session_meta is missing cwd', async () => { + const sessionId = 'session-missing-cwd'; + const filePath = join(sessionsDir, `codex-${sessionId}.jsonl`); + await writeFile( + filePath, + JSON.stringify({ + type: 'session_meta', + payload: { id: sessionId, timestamp: '2026-04-02T01:02:03.000Z' } + }) + '\n' + ); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'found', + filePath, + cwd: null, + timestamp: Date.parse('2026-04-02T01:02:03.000Z') + }); + }); + + it('succeeds when session_meta is missing timestamp', async () => { + const sessionId = 'session-missing-timestamp'; + const filePath = join(sessionsDir, `codex-${sessionId}.jsonl`); + await writeFile( + filePath, + JSON.stringify({ + type: 'session_meta', + payload: { id: sessionId, cwd: '/work/missing-timestamp' } + }) + '\n' + ); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'found', + filePath, + cwd: '/work/missing-timestamp', + timestamp: null + }); + }); + + it('returns not_found when no transcript matches', async () => { + const result = await resolveCodexSessionFile('session-missing'); + + expect(result).toEqual({ + status: 'not_found' + }); + }); + + it('returns ambiguous when multiple files match the same session id suffix', async () => { + const sessionId = 'session-ambiguous'; + const firstFile = join(sessionsDir, `codex-${sessionId}.jsonl`); + const secondDir = join(testDir, 'sessions', '2026', '04', '01'); + await mkdir(secondDir, { recursive: true }); + const secondFile = join(secondDir, `codex-${sessionId}.jsonl`); + + const meta = JSON.stringify({ + type: 'session_meta', + payload: { id: sessionId, cwd: '/work/ambiguous', timestamp: '2026-04-02T01:02:03.000Z' } + }); + await writeFile(firstFile, meta + '\n'); + await writeFile(secondFile, meta + '\n'); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'ambiguous', + filePaths: [secondFile, firstFile] + }); + }); + + it('returns invalid for an invalid first line', async () => { + const sessionId = 'session-invalid-first-line'; + const filePath = join(sessionsDir, `codex-${sessionId}.jsonl`); + await writeFile(filePath, JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message' } }) + '\n'); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'invalid', + filePath, + reason: 'invalid_session_meta' + }); + }); + + it('returns invalid when the first line is session_meta but fields are invalid', async () => { + const sessionId = 'session-invalid-meta'; + const filePath = join(sessionsDir, `codex-${sessionId}.jsonl`); + await writeFile( + filePath, + JSON.stringify({ + type: 'session_meta', + payload: { cwd: '/work/invalid-meta', timestamp: '2026-04-02T01:02:03.000Z' } + }) + '\n' + ); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'invalid', + filePath, + reason: 'invalid_session_meta' + }); + }); + + it('returns invalid when session_meta payload id mismatches the requested session id', async () => { + const sessionId = 'session-requested'; + const filePath = join(sessionsDir, `codex-${sessionId}.jsonl`); + await writeFile( + filePath, + JSON.stringify({ + type: 'session_meta', + payload: { id: 'session-other', cwd: '/work/mismatch', timestamp: '2026-04-02T01:02:03.000Z' } + }) + '\n' + ); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'invalid', + filePath, + reason: 'session_id_mismatch' + }); + }); + + it('resolves to the valid transcript when a corrupt duplicate suffix also exists', async () => { + const sessionId = 'session-mixed'; + const validFile = join(sessionsDir, `codex-${sessionId}.jsonl`); + const invalidDir = join(testDir, 'sessions', '2026', '04', '01'); + await mkdir(invalidDir, { recursive: true }); + const invalidFile = join(invalidDir, `codex-${sessionId}.jsonl`); + + await writeFile( + validFile, + JSON.stringify({ + type: 'session_meta', + payload: { id: sessionId, cwd: '/work/mixed', timestamp: '2026-04-02T01:02:03.000Z' } + }) + '\n' + ); + await writeFile( + invalidFile, + JSON.stringify({ + type: 'session_meta', + payload: { id: 'session-other', cwd: '/work/corrupt', timestamp: '2026-04-02T01:02:03.000Z' } + }) + '\n' + ); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'found', + filePath: validFile, + cwd: '/work/mixed', + timestamp: Date.parse('2026-04-02T01:02:03.000Z') + }); + }); +}); diff --git a/cli/src/codex/utils/resolveCodexSessionFile.ts b/cli/src/codex/utils/resolveCodexSessionFile.ts new file mode 100644 index 000000000..2c602d6d9 --- /dev/null +++ b/cli/src/codex/utils/resolveCodexSessionFile.ts @@ -0,0 +1,177 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { readdir, readFile } from 'node:fs/promises'; + +export type ResolveCodexSessionFileResult = + | { + status: 'found'; + filePath: string; + cwd: string | null; + timestamp: number | null; + } + | { + status: 'not_found'; + } + | { + status: 'ambiguous'; + filePaths: string[]; + } + | { + status: 'invalid'; + filePath: string; + reason: 'invalid_session_meta' | 'session_id_mismatch'; + }; + +export async function resolveCodexSessionFile(sessionId: string): Promise { + const sessionsRoot = getCodexSessionsRoot(); + const suffix = `-${sessionId}.jsonl`; + const files = (await collectJsonlFiles(sessionsRoot)) + .filter((filePath) => filePath.endsWith(suffix)) + .sort((a, b) => a.localeCompare(b)); + + if (files.length === 0) { + return { status: 'not_found' }; + } + + const candidates = await Promise.all(files.map(async (filePath) => validateSessionMeta(filePath, sessionId))); + const validCandidates = candidates.filter((candidate): candidate is ValidSessionFileCandidate => candidate.status === 'found'); + const invalidCandidates = candidates.filter((candidate): candidate is InvalidSessionFileCandidate => candidate.status === 'invalid'); + + if (validCandidates.length === 1) { + return validCandidates[0]; + } + + if (validCandidates.length > 1) { + return { + status: 'ambiguous', + filePaths: validCandidates.map((candidate) => candidate.filePath) + }; + } + + if (files.length === 1) { + return invalidCandidates[0] ?? { status: 'invalid', filePath: files[0], reason: 'invalid_session_meta' }; + } + + return { + status: 'ambiguous', + filePaths: files + }; +} + +function getCodexSessionsRoot(): string { + const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'); + return join(codexHome, 'sessions'); +} + +async function collectJsonlFiles(root: string): Promise { + try { + const entries = await readdir(root, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = join(root, entry.name); + if (entry.isDirectory()) { + files.push(...await collectJsonlFiles(fullPath)); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } + } + + return files; + } catch { + return []; + } +} + +type ValidSessionFileCandidate = { + status: 'found'; + filePath: string; + cwd: string | null; + timestamp: number | null; +}; + +type InvalidSessionFileCandidate = { + status: 'invalid'; + filePath: string; + reason: 'invalid_session_meta' | 'session_id_mismatch'; +}; + +async function validateSessionMeta(filePath: string, sessionId: string): Promise { + let content: string; + try { + content = await readFile(filePath, 'utf-8'); + } catch { + return { status: 'invalid', filePath, reason: 'invalid_session_meta' }; + } + + const firstLine = content.split(/\r?\n/, 1)[0]?.trim(); + if (!firstLine) { + return { status: 'invalid', filePath, reason: 'invalid_session_meta' }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(firstLine); + } catch { + return { status: 'invalid', filePath, reason: 'invalid_session_meta' }; + } + + if (!isSessionMeta(parsed)) { + return { status: 'invalid', filePath, reason: 'invalid_session_meta' }; + } + + const payload = parsed.payload; + if (payload.id !== sessionId) { + return { status: 'invalid', filePath, reason: 'session_id_mismatch' }; + } + + return { + status: 'found', + filePath, + cwd: parseOptionalString(payload.cwd), + timestamp: parseOptionalTimestamp(payload.timestamp) + }; +} + +function isSessionMeta(value: unknown): value is { type: 'session_meta'; payload: { id: string; cwd?: unknown; timestamp?: unknown } } { + if (!value || typeof value !== 'object') { + return false; + } + + const record = value as Record; + if (record.type !== 'session_meta') { + return false; + } + + const payload = record.payload; + if (!payload || typeof payload !== 'object') { + return false; + } + + const payloadRecord = payload as Record; + return typeof payloadRecord.id === 'string' && payloadRecord.id.length > 0; +} + +function parseTimestamp(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.length > 0) { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + + return null; +} + +function parseOptionalString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function parseOptionalTimestamp(value: unknown): number | null { + if (value === undefined || value === null || value === '') { + return null; + } + return parseTimestamp(value); +} From 4a99709a34fd093e8f6eabee4d1cb382b119870d Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 07:52:38 +0800 Subject: [PATCH 03/82] Add explicit Codex resume scanner mode --- .../codex/utils/codexSessionScanner.test.ts | 118 ++++++++++++++++++ cli/src/codex/utils/codexSessionScanner.ts | 74 ++++++++++- 2 files changed, 188 insertions(+), 4 deletions(-) diff --git a/cli/src/codex/utils/codexSessionScanner.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts index cca21df38..f3d327d54 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { existsSync } from 'node:fs'; import { createCodexSessionScanner } from './codexSessionScanner'; +import type { ResolveCodexSessionFileResult } from './resolveCodexSessionFile'; import type { CodexSessionEvent } from './codexEventConverter'; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -149,6 +150,123 @@ describe('codexSessionScanner', () => { expect(events).toHaveLength(0); }); + it('explicit resume scans only the resolved file and ignores stray matching cwd files', async () => { + const targetCwd = '/data/github/happy/hapi'; + const resolvedSessionId = 'session-explicit-resolved'; + const straySessionId = 'session-explicit-stray'; + const resolvedFile = join(sessionsDir, `codex-${resolvedSessionId}.jsonl`); + const strayFile = join(sessionsDir, `codex-${straySessionId}.jsonl`); + const resolvedResult: ResolveCodexSessionFileResult = { + status: 'found', + filePath: resolvedFile, + cwd: targetCwd, + timestamp: Date.parse('2025-12-22T00:00:00.000Z') + }; + + await writeFile( + resolvedFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: resolvedSessionId, cwd: targetCwd, timestamp: '2025-12-22T00:00:00.000Z' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'resolved-initial' } }) + ].join('\n') + '\n' + ); + await writeFile( + strayFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: straySessionId, cwd: targetCwd, timestamp: '2025-12-22T00:00:00.000Z' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'stray-initial' } }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: resolvedSessionId, + cwd: targetCwd, + resolvedSessionFile: resolvedResult, + onEvent: (event) => events.push(event) + }); + + await wait(200); + expect(events).toHaveLength(2); + expect(events[0].type).toBe('session_meta'); + expect(events[1].type).toBe('event_msg'); + expect((events[1].payload as Record).message).toBe('resolved-initial'); + + await appendFile( + strayFile, + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'Tool', call_id: 'call-stray', arguments: '{}' } + }) + '\n' + ); + await appendFile( + resolvedFile, + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'Tool', call_id: 'call-resolved', arguments: '{}' } + }) + '\n' + ); + + await wait(2300); + expect(events).toHaveLength(3); + expect(events[2].type).toBe('response_item'); + expect((events[2].payload as Record).call_id).toBe('call-resolved'); + }); + + it('explicit resume failure does not adopt another session', async () => { + const targetCwd = '/data/github/happy/hapi'; + const requestedSessionId = 'session-explicit-missing'; + const fallbackSessionId = 'session-fallback-candidate'; + const fallbackFile = join(sessionsDir, `codex-${fallbackSessionId}.jsonl`); + const resolverFailureResult: ResolveCodexSessionFileResult = { + status: 'not_found' + }; + + await writeFile( + fallbackFile, + JSON.stringify({ + type: 'session_meta', + payload: { + id: fallbackSessionId, + cwd: targetCwd, + timestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString() + } + }) + '\n' + ); + + let failureMessage: string | null = null; + let matchedSessionId: string | null = null; + scanner = await createCodexSessionScanner({ + sessionId: requestedSessionId, + cwd: targetCwd, + resolvedSessionFile: resolverFailureResult, + onEvent: (event) => events.push(event), + onSessionFound: (sessionId) => { + matchedSessionId = sessionId; + }, + onSessionMatchFailed: (message) => { + failureMessage = message; + } + }); + + await wait(200); + expect(failureMessage).not.toBeNull(); + expect(matchedSessionId).toBeNull(); + expect(events).toHaveLength(0); + + await appendFile( + fallbackFile, + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'Tool', call_id: 'call-fallback', arguments: '{}' } + }) + '\n' + ); + + await wait(2300); + expect(failureMessage).not.toBeNull(); + expect(matchedSessionId).toBeNull(); + expect(events).toHaveLength(0); + }); + it('adopts a reused older session file when fresh matching activity appears after startup', async () => { const reusedSessionId = 'session-reused-old-file'; const targetCwd = '/data/github/happy/hapi'; diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts index fd8f45b0a..64411569c 100644 --- a/cli/src/codex/utils/codexSessionScanner.ts +++ b/cli/src/codex/utils/codexSessionScanner.ts @@ -3,6 +3,7 @@ import { logger } from "@/ui/logger"; import { join, relative, resolve, sep } from "node:path"; import { homedir } from "node:os"; import { readFile, readdir, stat } from "node:fs/promises"; +import type { ResolveCodexSessionFileResult } from "./resolveCodexSessionFile"; import type { CodexSessionEvent } from "./codexEventConverter"; interface CodexSessionScannerOptions { @@ -10,6 +11,7 @@ interface CodexSessionScannerOptions { onEvent: (event: CodexSessionEvent) => void; onSessionFound?: (sessionId: string) => void; onSessionMatchFailed?: (message: string) => void; + resolvedSessionFile?: ResolveCodexSessionFileResult | null; cwd?: string; startupTimestampMs?: number; sessionStartWindowMs?: number; @@ -34,6 +36,31 @@ const DEFAULT_SESSION_START_WINDOW_MS = 2 * 60 * 1000; export async function createCodexSessionScanner(opts: CodexSessionScannerOptions): Promise { const targetCwd = opts.cwd && opts.cwd.trim().length > 0 ? normalizePath(opts.cwd) : null; + const resolvedSessionFile = opts.resolvedSessionFile ?? null; + + if (resolvedSessionFile) { + if (resolvedSessionFile.status !== 'found') { + const message = `Explicit Codex session resolution failed with status ${resolvedSessionFile.status}; refusing fallback.`; + logger.warn(`[CODEX_SESSION_SCANNER] ${message}`); + opts.onSessionMatchFailed?.(message); + return { + cleanup: async () => {}, + onNewSession: () => {} + }; + } + + const scanner = new CodexSessionScannerImpl(opts, targetCwd, resolvedSessionFile.filePath); + await scanner.start(); + + return { + cleanup: async () => { + await scanner.cleanup(); + }, + onNewSession: (sessionId: string) => { + scanner.onNewSession(sessionId); + } + }; + } if (!targetCwd && !opts.sessionId) { const message = 'No cwd provided for Codex session matching; refusing to fallback.'; @@ -74,6 +101,8 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private readonly sessionStartWindowMs: number; private readonly matchDeadlineMs: number; private readonly sessionDatePrefixes: Set | null; + private readonly explicitResolvedFilePath: string | null; + private readonly explicitResumeMode: boolean; private activeSessionId: string | null; private reportedSessionId: string | null; @@ -84,7 +113,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private readonly firstRecentActivitySessionIds = new Set(); private loggedAmbiguousRecentActivity = false; - constructor(opts: CodexSessionScannerOptions, targetCwd: string | null) { + constructor(opts: CodexSessionScannerOptions, targetCwd: string | null, explicitResolvedFilePath: string | null = null) { super({ intervalMs: 2000 }); const codexHomeDir = process.env.CODEX_HOME || join(homedir(), '.codex'); this.sessionsRoot = join(codexHomeDir, 'sessions'); @@ -97,14 +126,19 @@ class CodexSessionScannerImpl extends BaseSessionScanner { this.referenceTimestampMs = opts.startupTimestampMs ?? Date.now(); this.sessionStartWindowMs = opts.sessionStartWindowMs ?? DEFAULT_SESSION_START_WINDOW_MS; this.matchDeadlineMs = this.referenceTimestampMs + this.sessionStartWindowMs; + this.explicitResolvedFilePath = explicitResolvedFilePath ? normalizePath(explicitResolvedFilePath) : null; + this.explicitResumeMode = this.explicitResolvedFilePath !== null; this.sessionDatePrefixes = this.targetCwd - ? getSessionDatePrefixes(this.referenceTimestampMs, this.sessionStartWindowMs) + ? (this.explicitResumeMode ? null : getSessionDatePrefixes(this.referenceTimestampMs, this.sessionStartWindowMs)) : null; logger.debug(`[CODEX_SESSION_SCANNER] Init: targetCwd=${this.targetCwd ?? 'none'} startupTs=${new Date(this.referenceTimestampMs).toISOString()} windowMs=${this.sessionStartWindowMs}`); } public onNewSession(sessionId: string): void { + if (this.explicitResumeMode) { + return; + } if (this.activeSessionId === sessionId) { return; } @@ -118,6 +152,9 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } protected shouldWatchFile(filePath: string): boolean { + if (this.explicitResolvedFilePath) { + return normalizePath(filePath) === this.explicitResolvedFilePath; + } if (!this.activeSessionId) { if (!this.targetCwd) { return false; @@ -132,7 +169,15 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } protected async initialize(): Promise { - const files = await this.listSessionFiles(this.sessionsRoot); + const files = await this.getSessionFilesForScan(); + if (this.explicitResolvedFilePath) { + for (const filePath of files) { + if (this.shouldWatchFile(filePath)) { + this.ensureWatcher(filePath); + } + } + return; + } for (const filePath of files) { const { nextCursor } = await this.readSessionFile(filePath, 0); this.setCursor(filePath, nextCursor); @@ -148,7 +193,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } protected async findSessionFiles(): Promise { - const files = await this.listSessionFiles(this.sessionsRoot); + const files = await this.getSessionFilesForScan(); return sortFilesByMtime(files); } @@ -169,6 +214,14 @@ class CodexSessionScannerImpl extends BaseSessionScanner { const filePath = stats.filePath; const fileSessionId = this.sessionIdByFile.get(filePath) ?? null; + if (this.explicitResolvedFilePath) { + const emittedForFile = this.emitEvents(stats.events, fileSessionId); + if (emittedForFile > 0) { + logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emittedForFile} new events from ${filePath}`); + } + return; + } + if (!this.activeSessionId && this.targetCwd) { this.appendPendingEvents(filePath, stats.events, fileSessionId); const candidate = this.getCandidateForFile(filePath); @@ -194,6 +247,9 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } protected async afterScan(): Promise { + if (this.explicitResolvedFilePath) { + return; + } if (!this.activeSessionId && this.targetCwd) { if (this.bestWithinWindow) { logger.debug(`[CODEX_SESSION_SCANNER] Selected session ${this.bestWithinWindow.sessionId} within start window`); @@ -243,6 +299,9 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } private shouldSkipFile(filePath: string): boolean { + if (this.explicitResolvedFilePath) { + return normalizePath(filePath) !== this.explicitResolvedFilePath; + } if (!this.activeSessionId) { return false; } @@ -302,6 +361,13 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } } + private async getSessionFilesForScan(): Promise { + if (this.explicitResolvedFilePath) { + return [this.explicitResolvedFilePath]; + } + return this.listSessionFiles(this.sessionsRoot); + } + private async readSessionFile(filePath: string, startLine: number): Promise> { let content: string; try { From b0540903eb105a1ca73f56f76a2c9ff4067a771c Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 07:59:21 +0800 Subject: [PATCH 04/82] Wire Codex resume transcript resolver --- cli/src/codex/codexLocalLauncher.test.ts | 72 +++++++++++++++++++++++- cli/src/codex/codexLocalLauncher.ts | 10 +++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 5a72c1792..8dcda252e 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -3,6 +3,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const harness = vi.hoisted(() => ({ launches: [] as Array>, sessionScannerCalls: [] as Array>, + resolverCalls: [] as Array, + resolverResult: { + status: 'found' as const, + filePath: '/tmp/codex-session-resume.jsonl', + cwd: '/tmp/worktree', + timestamp: 1234567890 + }, scannerFailureMessage: 'No Codex session found within 120000ms for cwd c:\\workspace\\project; refusing fallback.' })); @@ -22,6 +29,13 @@ vi.mock('./utils/buildHapiMcpBridge', () => ({ }) })); +vi.mock('./utils/resolveCodexSessionFile', () => ({ + resolveCodexSessionFile: async (sessionId: string) => { + harness.resolverCalls.push(sessionId); + return harness.resolverResult; + } +})); + vi.mock('./utils/codexSessionScanner', () => ({ createCodexSessionScanner: async (opts: { onSessionMatchFailed?: (message: string) => void; @@ -62,13 +76,18 @@ function createQueueStub() { }; } -function createSessionStub(permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo', codexArgs?: string[], path = '/tmp/worktree') { +function createSessionStub( + permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo', + codexArgs?: string[], + path = '/tmp/worktree', + sessionId: string | null = null +) { const sessionEvents: Array<{ type: string; message?: string }> = []; let localLaunchFailure: { message: string; exitReason: 'switch' | 'exit' } | null = null; return { session: { - sessionId: null, + sessionId, path, startedBy: 'terminal' as const, startingMode: 'local' as const, @@ -99,6 +118,55 @@ describe('codexLocalLauncher', () => { afterEach(() => { harness.launches = []; harness.sessionScannerCalls = []; + harness.resolverCalls = []; + harness.resolverResult = { + status: 'found', + filePath: '/tmp/codex-session-resume.jsonl', + cwd: '/tmp/worktree', + timestamp: 1234567890 + }; + }); + + it('resolves the resume transcript before creating the scanner', async () => { + const { session } = createSessionStub('default', undefined, '/tmp/worktree', 'session-resume'); + + await codexLocalLauncher(session as never); + + expect(harness.resolverCalls).toEqual(['session-resume']); + expect(harness.sessionScannerCalls).toHaveLength(1); + expect(harness.sessionScannerCalls[0]?.resolvedSessionFile).toEqual({ + status: 'found', + filePath: '/tmp/codex-session-resume.jsonl', + cwd: '/tmp/worktree', + timestamp: 1234567890 + }); + }); + + it('uses an accurate warning when explicit resume resolution failed before launch', async () => { + harness.resolverResult = { + status: 'not_found' + }; + const { session, sessionEvents } = createSessionStub('default', undefined, '/tmp/worktree', 'session-resume'); + + await codexLocalLauncher(session as never); + + const scannerCall = harness.sessionScannerCalls[0] as { onSessionMatchFailed?: (message: string) => void } | undefined; + scannerCall?.onSessionMatchFailed?.('Explicit Codex session resolution failed with status not_found; refusing fallback.'); + + expect(harness.resolverCalls).toEqual(['session-resume']); + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Explicit Codex session resolution failed with status not_found; refusing fallback. Keeping local Codex running; remote transcript sync is unavailable for this launch.' + }); + }); + + it('does not call the resolver for fresh launches without a session id', async () => { + const { session } = createSessionStub('default'); + + await codexLocalLauncher(session as never); + + expect(harness.resolverCalls).toEqual([]); + expect(harness.sessionScannerCalls[0]?.resolvedSessionFile).toBeNull(); }); it('rebuilds approval and sandbox args from yolo mode', async () => { diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 9ef714a9c..6b3f797d3 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -6,6 +6,7 @@ import { convertCodexEvent } from './utils/codexEventConverter'; import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge'; import { stripCodexCliOverrides } from './utils/codexCliOverrides'; import { buildCodexPermissionModeCliArgs } from './utils/permissionModeConfig'; +import { resolveCodexSessionFile } from './utils/resolveCodexSessionFile'; import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> { @@ -31,6 +32,9 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch scanner?.onNewSession(sessionId); }; + const resolvedSessionFile = resumeSessionId ? await resolveCodexSessionFile(resumeSessionId) : null; + const isExplicitResumeResolutionFailure = resumeSessionId !== null && resolvedSessionFile?.status !== 'found'; + const launcher = new BaseLocalLauncher({ label: 'codex-local', failureLabel: 'Local Codex process failed', @@ -60,9 +64,12 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch const handleSessionMatchFailed = (message: string) => { logger.warn(`[codex-local]: ${message}`); + const syncStatusMessage = isExplicitResumeResolutionFailure + ? 'remote transcript sync is unavailable for this launch.' + : 'remote transcript sync may be unavailable for this launch.'; session.sendSessionEvent({ type: 'message', - message: `${message} Keeping local Codex running; remote transcript sync may be unavailable for this launch.` + message: `${message} Keeping local Codex running; ${syncStatusMessage}` }); }; @@ -74,6 +81,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch onSessionFound: (sessionId) => { session.onSessionFound(sessionId); }, + resolvedSessionFile, onEvent: (event) => { const converted = convertCodexEvent(event); if (converted?.sessionId) { From 9d5cb098d1533fec35c78c18fbea726e7402072e Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 08:10:32 +0800 Subject: [PATCH 05/82] Make remote Codex resume strict --- cli/src/codex/codexRemoteLauncher.test.ts | 127 ++++++++++++++++++++-- cli/src/codex/codexRemoteLauncher.ts | 49 +++++---- 2 files changed, 143 insertions(+), 33 deletions(-) diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 6d1b2c570..afa560ed5 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -5,7 +5,15 @@ import type { EnhancedMode } from './loop'; const harness = vi.hoisted(() => ({ notifications: [] as Array<{ method: string; params: unknown }>, registerRequestCalls: [] as string[], - initializeCalls: [] as unknown[] + initializeCalls: [] as unknown[], + startThreadCalls: [] as unknown[], + resumeThreadCalls: [] as unknown[], + startTurnCalls: [] as unknown[], + startThreadError: null as Error | null, + resumeThreadError: null as Error | null, + startTurnError: null as Error | null, + startThreadResponse: { thread: { id: 'thread-started' }, model: 'gpt-5.4' }, + resumeThreadResponse: { thread: { id: 'thread-resumed' }, model: 'gpt-5.4' } })); vi.mock('./codexAppServerClient', () => { @@ -27,15 +35,27 @@ vi.mock('./codexAppServerClient', () => { harness.registerRequestCalls.push(method); } - async startThread(): Promise<{ thread: { id: string }; model: string }> { - return { thread: { id: 'thread-anonymous' }, model: 'gpt-5.4' }; + async startThread(params: unknown): Promise<{ thread: { id: string }; model: string }> { + harness.startThreadCalls.push(params); + if (harness.startThreadError) { + throw harness.startThreadError; + } + return harness.startThreadResponse; } - async resumeThread(): Promise<{ thread: { id: string }; model: string }> { - return { thread: { id: 'thread-anonymous' }, model: 'gpt-5.4' }; + async resumeThread(params: unknown): Promise<{ thread: { id: string }; model: string }> { + harness.resumeThreadCalls.push(params); + if (harness.resumeThreadError) { + throw harness.resumeThreadError; + } + return harness.resumeThreadResponse; } - async startTurn(): Promise<{ turn: Record }> { + async startTurn(params: unknown): Promise<{ turn: Record }> { + harness.startTurnCalls.push(params); + if (harness.startTurnError) { + throw harness.startTurnError; + } const started = { turn: {} }; harness.notifications.push({ method: 'turn/started', params: started }); this.notificationHandler?.('turn/started', started); @@ -80,7 +100,7 @@ function createMode(): EnhancedMode { }; } -function createSessionStub() { +function createSessionStub(overrides?: { sessionId?: string | null }) { const queue = new MessageQueue2((mode) => JSON.stringify(mode)); queue.push('hello from launcher test', createMode()); queue.close(); @@ -121,7 +141,7 @@ function createSessionStub() { queue, codexArgs: undefined, codexCliOverrides: undefined, - sessionId: null as string | null, + sessionId: overrides?.sessionId ?? null, thinking: false, getPermissionMode() { return 'default' as const; @@ -168,22 +188,103 @@ describe('codexRemoteLauncher', () => { harness.notifications = []; harness.registerRequestCalls = []; harness.initializeCalls = []; + harness.startThreadCalls = []; + harness.resumeThreadCalls = []; + harness.startTurnCalls = []; + harness.startThreadError = null; + harness.resumeThreadError = null; + harness.startTurnError = null; + harness.startThreadResponse = { thread: { id: 'thread-started' }, model: 'gpt-5.4' }; + harness.resumeThreadResponse = { thread: { id: 'thread-resumed' }, model: 'gpt-5.4' }; }); - it('finishes a turn and emits ready when task lifecycle events omit turn_id', async () => { + it('uses resumeThread only for explicit remote resume success', async () => { const { session, sessionEvents, thinkingChanges, foundSessionIds, getModel - } = createSessionStub(); + } = createSessionStub({ sessionId: 'resume-thread-123' }); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.resumeThreadCalls).toHaveLength(1); + expect(harness.resumeThreadCalls[0]).toMatchObject({ threadId: 'resume-thread-123' }); + expect(harness.startThreadCalls).toEqual([]); + expect(foundSessionIds).toContain('thread-resumed'); + expect(getModel()).toBe('gpt-5.4'); + expect(harness.notifications.map((entry) => entry.method)).toEqual(['turn/started', 'turn/completed']); + expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1); + expect(thinkingChanges).toContain(true); + expect(session.thinking).toBe(false); + }); + + it('does not report explicit resume failure when resume succeeds but turn startup fails', async () => { + harness.startTurnError = new Error('turn start failed'); + const { + session, + sessionEvents, + foundSessionIds, + getModel, + thinkingChanges + } = createSessionStub({ sessionId: 'resume-thread-123' }); const exitReason = await codexRemoteLauncher(session as never); expect(exitReason).toBe('exit'); - expect(foundSessionIds).toContain('thread-anonymous'); + expect(harness.resumeThreadCalls).toHaveLength(1); + expect(harness.startThreadCalls).toEqual([]); + expect(harness.startTurnCalls).toHaveLength(1); + expect(foundSessionIds).toEqual(['thread-resumed']); expect(getModel()).toBe('gpt-5.4'); + expect(sessionEvents).toContainEqual({ type: 'message', message: 'Process exited unexpectedly' }); + expect(sessionEvents).not.toContainEqual({ + type: 'message', + message: 'Explicit remote resume failed for thread resume-thread-123' + }); + expect(thinkingChanges).toEqual([false]); + expect(session.thinking).toBe(false); + }); + + it('surfaces explicit remote resume failure without startThread fallback', async () => { + harness.resumeThreadError = new Error('resume failed hard'); + const { + session, + sessionEvents, + foundSessionIds, + getModel, + thinkingChanges + } = createSessionStub({ sessionId: 'resume-thread-123' }); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.resumeThreadCalls).toHaveLength(1); + expect(harness.startThreadCalls).toEqual([]); + expect(foundSessionIds).toEqual([]); + expect(getModel()).toBeUndefined(); + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Explicit remote resume failed for thread resume-thread-123' + }); + expect(thinkingChanges).toEqual([false]); + expect(session.thinking).toBe(false); + }); + + it('starts a new thread for non-resume sessions and preserves lifecycle signals', async () => { + const { + session, + sessionEvents, + foundSessionIds, + getModel, + thinkingChanges + } = createSessionStub(); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); expect(harness.initializeCalls).toEqual([{ clientInfo: { name: 'hapi-codex-client', @@ -193,6 +294,10 @@ describe('codexRemoteLauncher', () => { experimentalApi: true } }]); + expect(harness.resumeThreadCalls).toEqual([]); + expect(harness.startThreadCalls).toHaveLength(1); + expect(foundSessionIds).toContain('thread-started'); + expect(getModel()).toBe('gpt-5.4'); expect(harness.notifications.map((entry) => entry.method)).toEqual(['turn/started', 'turn/completed']); expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1); expect(thinkingChanges).toContain(true); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index be648d65d..3f450ffe0 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -567,6 +567,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { }; while (!this.shouldExit) { + let explicitResumeFailureThreadId: string | null = null; logActiveHandles('loop-top'); let message: QueuedMessage | null = pending; pending = null; @@ -603,24 +604,23 @@ class CodexRemoteLauncher extends RemoteLauncherBase { let threadId: string | null = null; if (resumeCandidate) { - try { - const resumeResponse = await appServerClient.resumeThread({ - threadId: resumeCandidate, - ...threadParams - }, { - signal: this.abortController.signal - }); - const resumeRecord = asRecord(resumeResponse); - const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; - threadId = asString(resumeThread?.id) ?? resumeCandidate; - applyResolvedModel(resumeRecord?.model); - logger.debug(`[Codex] Resumed app-server thread ${threadId}`); - } catch (error) { - logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}, starting new thread`, error); + explicitResumeFailureThreadId = resumeCandidate; + const resumeResponse = await appServerClient.resumeThread({ + threadId: resumeCandidate, + ...threadParams + }, { + signal: this.abortController.signal + }); + const resumeRecord = asRecord(resumeResponse); + const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; + threadId = asString(resumeThread?.id) ?? resumeCandidate; + applyResolvedModel(resumeRecord?.model); + if (!threadId) { + throw new Error('app-server thread/resume did not return thread.id'); } - } - - if (!threadId) { + explicitResumeFailureThreadId = null; + logger.debug(`[Codex] Resumed app-server thread ${threadId}`); + } else { const threadResponse = await appServerClient.startThread(threadParams, { signal: this.abortController.signal }); @@ -633,10 +633,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } } - if (!threadId) { - throw new Error('app-server resume did not return thread.id'); - } - this.currentThreadId = threadId; session.onSessionFound(threadId); hasThread = true; @@ -673,16 +669,25 @@ class CodexRemoteLauncher extends RemoteLauncherBase { allowAnonymousTerminalEvent = true; } } catch (error) { - logger.warn('Error in codex session:', error); const isAbortError = error instanceof Error && error.name === 'AbortError'; turnInFlight = false; allowAnonymousTerminalEvent = false; this.currentTurnId = null; if (isAbortError) { + logger.warn('Error in codex session:', error); messageBuffer.addMessage('Aborted by user', 'status'); session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); + } else if (explicitResumeFailureThreadId) { + logger.warn(`[Codex] Explicit remote resume failed for thread ${explicitResumeFailureThreadId}:`, error); + const failureMessage = `Explicit remote resume failed for thread ${explicitResumeFailureThreadId}`; + messageBuffer.addMessage(failureMessage, 'status'); + session.sendSessionEvent({ type: 'message', message: failureMessage }); + this.currentTurnId = null; + this.currentThreadId = null; + hasThread = false; } else { + logger.warn('Error in codex session:', error); messageBuffer.addMessage('Process exited unexpectedly', 'status'); session.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); this.currentTurnId = null; From 16b7701eb33d204a20f5766f59e45d2e8a377513 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 08:25:30 +0800 Subject: [PATCH 06/82] test(codex): fix local launcher mock typing --- cli/src/codex/codexLocalLauncher.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 8dcda252e..e6fabb639 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ResolveCodexSessionFileResult } from './utils/resolveCodexSessionFile'; const harness = vi.hoisted(() => ({ launches: [] as Array>, @@ -9,7 +10,7 @@ const harness = vi.hoisted(() => ({ filePath: '/tmp/codex-session-resume.jsonl', cwd: '/tmp/worktree', timestamp: 1234567890 - }, + } as ResolveCodexSessionFileResult, scannerFailureMessage: 'No Codex session found within 120000ms for cwd c:\\workspace\\project; refusing fallback.' })); From 4e04552cc49eb382b7259bd300c94a0a253a363f Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 09:05:25 +0800 Subject: [PATCH 07/82] fix(codex): segment multilineage transcript replay --- .../codex/utils/codexSessionScanner.test.ts | 75 +++++++++++++++++++ cli/src/codex/utils/codexSessionScanner.ts | 73 ++++++++++++++---- .../common/session/BaseSessionScanner.ts | 4 + 3 files changed, 137 insertions(+), 15 deletions(-) diff --git a/cli/src/codex/utils/codexSessionScanner.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts index f3d327d54..349889e72 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -212,6 +212,81 @@ describe('codexSessionScanner', () => { expect((events[2].payload as Record).call_id).toBe('call-resolved'); }); + it('explicit resume replays a leading lineage block for the requested session', async () => { + const targetCwd = '/data/github/happy/hapi'; + const requestedSessionId = 'session-explicit-current'; + const ancestorSessionId = 'session-explicit-ancestor'; + const resolvedFile = join(sessionsDir, `codex-${requestedSessionId}.jsonl`); + const resolvedResult: ResolveCodexSessionFileResult = { + status: 'found', + filePath: resolvedFile, + cwd: targetCwd, + timestamp: Date.parse('2025-12-22T00:00:00.000Z') + }; + + await writeFile( + resolvedFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: requestedSessionId, cwd: targetCwd, timestamp: '2025-12-22T00:00:00.000Z' } }), + JSON.stringify({ type: 'session_meta', payload: { id: ancestorSessionId, cwd: targetCwd, timestamp: '2025-12-21T23:00:00.000Z' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'current-segment-message' } }), + JSON.stringify({ type: 'response_item', payload: { type: 'function_call', name: 'Tool', call_id: 'call-current', arguments: '{}' } }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: requestedSessionId, + cwd: targetCwd, + resolvedSessionFile: resolvedResult, + onEvent: (event) => events.push(event) + }); + + await wait(200); + expect(events).toHaveLength(3); + expect(events.map((event) => event.type)).toEqual(['session_meta', 'event_msg', 'response_item']); + expect((events[0].payload as Record).id).toBe(requestedSessionId); + expect((events[1].payload as Record).message).toBe('current-segment-message'); + expect((events[2].payload as Record).call_id).toBe('call-current'); + }); + + it('explicit resume emits only the matching segment when a later segment starts a new session', async () => { + const targetCwd = '/data/github/happy/hapi'; + const firstSessionId = 'session-explicit-first'; + const secondSessionId = 'session-explicit-second'; + const resolvedFile = join(sessionsDir, `codex-${secondSessionId}.jsonl`); + const resolvedResult: ResolveCodexSessionFileResult = { + status: 'found', + filePath: resolvedFile, + cwd: targetCwd, + timestamp: Date.parse('2025-12-22T01:00:00.000Z') + }; + + await writeFile( + resolvedFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: firstSessionId, cwd: targetCwd, timestamp: '2025-12-22T00:00:00.000Z' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'first-segment-message' } }), + JSON.stringify({ type: 'session_meta', payload: { id: secondSessionId, cwd: targetCwd, timestamp: '2025-12-22T01:00:00.000Z' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'second-segment-message' } }), + JSON.stringify({ type: 'response_item', payload: { type: 'function_call', name: 'Tool', call_id: 'call-second', arguments: '{}' } }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: secondSessionId, + cwd: targetCwd, + resolvedSessionFile: resolvedResult, + onEvent: (event) => events.push(event) + }); + + await wait(200); + expect(events).toHaveLength(3); + expect(events.map((event) => event.type)).toEqual(['session_meta', 'event_msg', 'response_item']); + expect((events[0].payload as Record).id).toBe(secondSessionId); + expect((events[1].payload as Record).message).toBe('second-segment-message'); + expect((events[2].payload as Record).call_id).toBe('call-second'); + }); + it('explicit resume failure does not adopt another session', async () => { const targetCwd = '/data/github/happy/hapi'; const requestedSessionId = 'session-explicit-missing'; diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts index 64411569c..7ca00036b 100644 --- a/cli/src/codex/utils/codexSessionScanner.ts +++ b/cli/src/codex/utils/codexSessionScanner.ts @@ -23,7 +23,7 @@ interface CodexSessionScanner { } type PendingEvents = { - events: CodexSessionEvent[]; + entries: SessionFileScanEntry[]; fileSessionId: string | null; }; @@ -93,6 +93,9 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private readonly sessionIdByFile = new Map(); private readonly sessionCwdByFile = new Map(); private readonly sessionTimestampByFile = new Map(); + private readonly eventOwnerSessionIdByFile = new Map>(); + private readonly currentSegmentOwnerByFile = new Map(); + private readonly inSessionMetaBlockByFile = new Map(); private readonly pendingEventsByFile = new Map(); private readonly sessionMetaParsed = new Set(); private readonly fileEpochByPath = new Map(); @@ -215,7 +218,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { const fileSessionId = this.sessionIdByFile.get(filePath) ?? null; if (this.explicitResolvedFilePath) { - const emittedForFile = this.emitEvents(stats.events, fileSessionId); + const emittedForFile = this.emitEvents(filePath, stats.entries, fileSessionId); if (emittedForFile > 0) { logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emittedForFile} new events from ${filePath}`); } @@ -223,7 +226,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } if (!this.activeSessionId && this.targetCwd) { - this.appendPendingEvents(filePath, stats.events, fileSessionId); + this.appendPendingEvents(filePath, stats.entries, fileSessionId); const candidate = this.getCandidateForFile(filePath); if (candidate) { if (!this.bestWithinWindow || candidate.score < this.bestWithinWindow.score) { @@ -240,7 +243,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { return; } - const emittedForFile = this.emitEvents(stats.events, fileSessionId); + const emittedForFile = this.emitEvents(filePath, stats.entries, fileSessionId); if (emittedForFile > 0) { logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emittedForFile} new events from ${filePath}`); } @@ -387,8 +390,24 @@ class CodexSessionScannerImpl extends BaseSessionScanner { this.fileEpochByPath.set(filePath, nextEpoch); } + if (effectiveStartLine === 0) { + this.sessionIdByFile.delete(filePath); + this.sessionCwdByFile.delete(filePath); + this.sessionTimestampByFile.delete(filePath); + this.currentSegmentOwnerByFile.delete(filePath); + this.inSessionMetaBlockByFile.delete(filePath); + this.eventOwnerSessionIdByFile.set(filePath, new Map()); + } + const hasSessionMeta = this.sessionMetaParsed.has(filePath); const parseFrom = hasSessionMeta ? effectiveStartLine : 0; + let currentSegmentOwner = this.currentSegmentOwnerByFile.get(filePath) ?? null; + let inSessionMetaBlock = this.inSessionMetaBlockByFile.get(filePath) ?? false; + let eventOwnerByLine = this.eventOwnerSessionIdByFile.get(filePath); + if (!eventOwnerByLine) { + eventOwnerByLine = new Map(); + this.eventOwnerSessionIdByFile.set(filePath, eventOwnerByLine); + } for (let index = parseFrom; index < lines.length; index += 1) { const trimmed = lines[index].trim(); @@ -400,21 +419,29 @@ class CodexSessionScannerImpl extends BaseSessionScanner { if (parsed?.type === 'session_meta') { const payload = asRecord(parsed.payload); const sessionId = payload ? asString(payload.id) : null; - if (sessionId) { + if (sessionId && !this.sessionIdByFile.has(filePath)) { this.sessionIdByFile.set(filePath, sessionId); } const sessionCwd = payload ? asString(payload.cwd) : null; const normalizedCwd = sessionCwd ? normalizePath(sessionCwd) : null; - if (normalizedCwd) { + if (normalizedCwd && !this.sessionCwdByFile.has(filePath)) { this.sessionCwdByFile.set(filePath, normalizedCwd); } const rawTimestamp = payload ? payload.timestamp : null; const sessionTimestamp = payload ? parseTimestamp(payload.timestamp) : null; - if (sessionTimestamp !== null) { + if (sessionTimestamp !== null && !this.sessionTimestampByFile.has(filePath)) { this.sessionTimestampByFile.set(filePath, sessionTimestamp); } + if (!inSessionMetaBlock && sessionId) { + currentSegmentOwner = sessionId; + } + inSessionMetaBlock = true; + eventOwnerByLine.set(index, sessionId); logger.debug(`[CODEX_SESSION_SCANNER] Session meta: file=${filePath} cwd=${sessionCwd ?? 'none'} normalizedCwd=${normalizedCwd ?? 'none'} timestamp=${rawTimestamp ?? 'none'} parsedTs=${sessionTimestamp ?? 'none'}`); this.sessionMetaParsed.add(filePath); + } else { + inSessionMetaBlock = false; + eventOwnerByLine.set(index, currentSegmentOwner); } if (index >= effectiveStartLine) { events.push({ event: parsed, lineIndex: index }); @@ -424,6 +451,9 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } } + this.currentSegmentOwnerByFile.set(filePath, currentSegmentOwner); + this.inSessionMetaBlockByFile.set(filePath, inSessionMetaBlock); + return { events, nextCursor: totalLines }; } @@ -493,30 +523,43 @@ class CodexSessionScannerImpl extends BaseSessionScanner { return this.getWatchedFiles().filter((filePath) => filePath.endsWith(suffix)); } - private appendPendingEvents(filePath: string, events: CodexSessionEvent[], fileSessionId: string | null): void { - if (events.length === 0) { + private appendPendingEvents( + filePath: string, + entries: SessionFileScanEntry[], + fileSessionId: string | null + ): void { + if (entries.length === 0) { return; } const existing = this.pendingEventsByFile.get(filePath); if (existing) { - existing.events.push(...events); + existing.entries.push(...entries); if (!existing.fileSessionId && fileSessionId) { existing.fileSessionId = fileSessionId; } return; } this.pendingEventsByFile.set(filePath, { - events: [...events], + entries: [...entries], fileSessionId }); } - private emitEvents(events: CodexSessionEvent[], fileSessionId: string | null): number { + private emitEvents( + filePath: string, + entries: SessionFileScanEntry[], + fileSessionId: string | null + ): number { let emittedForFile = 0; - for (const event of events) { + const eventOwnerByLine = this.eventOwnerSessionIdByFile.get(filePath); + for (const entry of entries) { + const event = entry.event; const payload = asRecord(event.payload); const payloadSessionId = payload ? asString(payload.id) : null; - const eventSessionId = payloadSessionId ?? fileSessionId ?? null; + const lineOwner = entry.lineIndex !== undefined + ? (eventOwnerByLine?.get(entry.lineIndex) ?? null) + : null; + const eventSessionId = payloadSessionId ?? lineOwner ?? fileSessionId ?? null; if (this.activeSessionId && eventSessionId && eventSessionId !== this.activeSessionId) { continue; @@ -539,7 +582,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { if (!matches) { continue; } - emitted += this.emitEvents(pending.events, pending.fileSessionId); + emitted += this.emitEvents(filePath, pending.entries, pending.fileSessionId); } this.pendingEventsByFile.clear(); if (emitted > 0) { diff --git a/cli/src/modules/common/session/BaseSessionScanner.ts b/cli/src/modules/common/session/BaseSessionScanner.ts index e19d0e751..754896354 100644 --- a/cli/src/modules/common/session/BaseSessionScanner.ts +++ b/cli/src/modules/common/session/BaseSessionScanner.ts @@ -13,6 +13,7 @@ export type SessionFileScanResult = { export type SessionFileScanStats = { filePath: string; + entries: SessionFileScanEntry[]; events: TEvent[]; parsedCount: number; newCount: number; @@ -156,6 +157,7 @@ export abstract class BaseSessionScanner { const cursor = this.getCursor(filePath); const { events, nextCursor } = await this.parseSessionFile(filePath, cursor); const newEvents: TEvent[] = []; + const newEntries: SessionFileScanEntry[] = []; const newKeys: string[] = []; for (const entry of events) { const key = this.generateEventKey(entry.event, { filePath, lineIndex: entry.lineIndex }); @@ -165,9 +167,11 @@ export abstract class BaseSessionScanner { } newKeys.push(key); newEvents.push(entry.event); + newEntries.push(entry); } await this.handleFileScan({ filePath, + entries: newEntries, events: newEvents, parsedCount: events.length, newCount: newEvents.length, From 892d83a1de9bac641fe868a460184fe47c89e1b0 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 11:21:09 +0800 Subject: [PATCH 08/82] feat(codex): normalize replay tool names for block rendering --- .../codex/utils/codexEventConverter.test.ts | 29 +++++++++++++++++++ cli/src/codex/utils/codexEventConverter.ts | 21 +++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/cli/src/codex/utils/codexEventConverter.test.ts b/cli/src/codex/utils/codexEventConverter.test.ts index 3abf77763..96cc9651e 100644 --- a/cli/src/codex/utils/codexEventConverter.test.ts +++ b/cli/src/codex/utils/codexEventConverter.test.ts @@ -75,6 +75,35 @@ describe('convertCodexEvent', () => { }); }); + it.each([ + ['exec_command', 'CodexBash'], + ['write_stdin', 'CodexWriteStdin'], + ['spawn_agent', 'CodexSpawnAgent'], + ['wait_agent', 'CodexWaitAgent'], + ['send_input', 'CodexSendInput'], + ['close_agent', 'CodexCloseAgent'], + ['update_plan', 'update_plan'], + ['mcp__hapi__change_title', 'mcp__hapi__change_title'], + ['unknown_tool', 'unknown_tool'] + ])('normalizes function_call tool name %s -> %s', (inputName, expectedName) => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'function_call', + name: inputName, + call_id: 'call-1', + arguments: '{"foo":"bar"}' + } + }); + + expect(result?.message).toMatchObject({ + type: 'tool-call', + name: expectedName, + callId: 'call-1', + input: { foo: 'bar' } + }); + }); + it('converts function_call_output items', () => { const result = convertCodexEvent({ type: 'response_item', diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index 24ecfd241..b10455ddd 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -72,6 +72,25 @@ function parseArguments(value: unknown): unknown { return value; } +function normalizeCodexToolName(name: string): string { + switch (name) { + case 'exec_command': + return 'CodexBash'; + case 'write_stdin': + return 'CodexWriteStdin'; + case 'spawn_agent': + return 'CodexSpawnAgent'; + case 'wait_agent': + return 'CodexWaitAgent'; + case 'send_input': + return 'CodexSendInput'; + case 'close_agent': + return 'CodexCloseAgent'; + default: + return name; + } +} + function extractCallId(payload: Record): string | null { const candidates = [ 'call_id', @@ -203,7 +222,7 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return { message: { type: 'tool-call', - name, + name: normalizeCodexToolName(name), callId, input: parseArguments(payloadRecord.arguments), id: randomUUID() From 129f410b0fc6d05a330f76db26c94a57088d7bac Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 11:22:59 +0800 Subject: [PATCH 09/82] feat(web): add codex-first tool presentations --- web/src/components/ToolCard/knownTools.tsx | 116 ++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 7289ec189..0cbb976eb 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -26,6 +26,16 @@ function formatChecklistCount(items: ChecklistItem[], noun: string): string | nu return `${items.length} ${noun}${items.length === 1 ? '' : 's'}` } +function getInputTextAny(input: unknown, keys: string[]): string | null { + if (!isObject(input)) return null + for (const key of keys) { + const value = input[key] + if (typeof value === 'string' && value.length > 0) return value + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + } + return null +} + function snakeToTitleWithSpaces(value: string): string { return value .split('_') @@ -157,17 +167,121 @@ export const knownTools: Record typeof part === 'string').join(' ') } + const cwd = getInputStringAny(opts.input, ['cwd']) + if (cwd) return cwd return null }, minimal: true }, + CodexWriteStdin: { + icon: () => , + title: (opts) => { + const interrupt = isObject(opts.input) && opts.input.interrupt === true + const chars = getInputStringAny(opts.input, ['chars', 'charsPreview']) + if (interrupt) return 'Interrupt' + if (chars && chars.length > 0) return 'Send input' + return 'Poll output' + }, + subtitle: (opts) => { + const chars = getInputStringAny(opts.input, ['charsPreview', 'chars']) + if (chars && chars.length > 0) return truncate(chars.replace(/\r?\n/g, ' ↩ '), 80) + const target = getInputTextAny(opts.input, ['target', 'session_id', 'sessionId']) + return target ? `target: ${target}` : 'poll' + }, + minimal: true + }, + CodexSpawnAgent: { + icon: () => , + title: (opts) => { + const name = getInputStringAny(opts.input, ['name', 'agent_name', 'nickname']) + return name ? `Agent: ${name}` : 'Spawn agent' + }, + subtitle: (opts) => { + const message = getInputStringAny(opts.input, ['messagePreview', 'message', 'prompt', 'description']) + if (message) return truncate(message, 120) + const model = getInputStringAny(opts.input, ['model']) + const effort = getInputStringAny(opts.input, ['reasoning_effort']) + const parts = [model, effort ? `effort: ${effort}` : null].filter((part): part is string => typeof part === 'string' && part.length > 0) + return parts.length > 0 ? parts.join(' • ') : null + }, + minimal: true + }, + CodexWaitAgent: { + icon: () => , + title: (opts) => { + const targets = isObject(opts.input) && Array.isArray(opts.input.targets) + ? opts.input.targets.filter((target): target is string => typeof target === 'string' && target.length > 0) + : [] + return targets.length > 1 ? 'Wait for agents' : 'Wait for agent' + }, + subtitle: (opts) => { + const targets = isObject(opts.input) && Array.isArray(opts.input.targets) + ? opts.input.targets.filter((target): target is string => typeof target === 'string' && target.length > 0) + : [] + const timeout = getInputTextAny(opts.input, ['timeout_ms', 'timeout']) + const parts: string[] = [] + if (targets.length > 0) parts.push(`${targets.length} target${targets.length === 1 ? '' : 's'}`) + if (timeout) parts.push(`timeout: ${timeout}`) + return parts.length > 0 ? parts.join(' • ') : null + }, + minimal: true + }, + CodexSendInput: { + icon: () => , + title: (opts) => { + const target = getInputTextAny(opts.input, ['target']) + return target ? `Message: ${target}` : 'Message agent' + }, + subtitle: (opts) => { + const interrupt = isObject(opts.input) && opts.input.interrupt === true + const message = getInputStringAny(opts.input, ['messagePreview', 'message']) + if (message) return truncate(message, 120) + return interrupt ? 'interrupt' : null + }, + minimal: true + }, + CodexCloseAgent: { + icon: () => , + title: () => 'Close agent', + subtitle: (opts) => getInputTextAny(opts.input, ['target', 'agent_id', 'agentId']) ?? null, + minimal: true + }, CodexPermission: { icon: () => , title: (opts) => { const tool = getInputStringAny(opts.input, ['tool']) return tool ? `Permission: ${tool}` : 'Permission request' }, - subtitle: (opts) => getInputStringAny(opts.input, ['message', 'command']) ?? null, + subtitle: (opts) => getInputStringAny(opts.input, ['message', 'command', 'cwd']) ?? null, + minimal: true + }, + BashOutput: { + icon: () => , + title: () => 'Command output', + subtitle: (opts) => { + const text = typeof opts.result === 'string' + ? opts.result + : getInputStringAny(opts.result, ['stdout', 'stderr', 'output', 'text', 'content']) + return text ? truncate(text.replace(/\r?\n/g, ' ↩ '), 120) : null + }, + minimal: true + }, + KillBash: { + icon: () => , + title: () => 'Stop command', + subtitle: (opts) => getInputTextAny(opts.input, ['target', 'pid', 'process_id']) ?? null, + minimal: true + }, + TodoRead: { + icon: () => , + title: () => 'Read todo list', + subtitle: (opts) => formatChecklistCount(extractTodoChecklist(opts.input, opts.result), 'item'), + minimal: true + }, + EnterWorktree: { + icon: () => , + title: () => 'Enter worktree', + subtitle: (opts) => getInputStringAny(opts.input, ['path', 'worktreePath', 'worktree_path']) ?? null, minimal: true }, shell_command: { From 2dc21f540f61389973d9d7746bed6476f05db011 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 11:24:35 +0800 Subject: [PATCH 10/82] feat(web): render codex replay execution blocks --- .../ToolCard/views/_results.test.tsx | 99 ++++++++++++++++++- .../components/ToolCard/views/_results.tsx | 10 ++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/web/src/components/ToolCard/views/_results.test.tsx b/web/src/components/ToolCard/views/_results.test.tsx index 05158961e..25bf1e0ac 100644 --- a/web/src/components/ToolCard/views/_results.test.tsx +++ b/web/src/components/ToolCard/views/_results.test.tsx @@ -1,5 +1,50 @@ import { describe, expect, it } from 'vitest' -import { extractTextFromResult, getMutationResultRenderMode, getToolResultViewComponent } from '@/components/ToolCard/views/_results' +import { render, screen } from '@testing-library/react' +import type { ToolCallBlock } from '@/chat/types' +import { extractTextFromResult, getMutationResultRenderMode, getToolResultViewComponent, toolResultViewRegistry } from '@/components/ToolCard/views/_results' +import { I18nProvider } from '@/lib/i18n-context' + +function makeToolBlock(name: string, result: unknown, input: unknown = {}): ToolCallBlock { + return { + kind: 'tool-call', + id: `${name}-block`, + localId: null, + createdAt: 0, + tool: { + id: `${name}-tool`, + name, + state: 'completed', + input, + createdAt: 0, + startedAt: 0, + completedAt: 0, + description: null, + result + }, + children: [] + } +} + +function renderWithProviders(ui: React.ReactElement) { + if (typeof window !== 'undefined' && !window.matchMedia) { + window.matchMedia = () => ({ + matches: false, + media: '', + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false + }) + } + + return render( + + {ui} + + ) +} describe('extractTextFromResult', () => { it('returns string directly', () => { @@ -96,4 +141,56 @@ describe('getToolResultViewComponent registry', () => { // Both should fall back to GenericResultView expect(mcpView).toBe(unknownView) }) + + it('routes Codex aliases to dedicated result views', () => { + expect(toolResultViewRegistry.CodexBash).toBeDefined() + expect(toolResultViewRegistry.CodexWriteStdin).toBeDefined() + expect(toolResultViewRegistry.CodexSpawnAgent).toBeDefined() + expect(toolResultViewRegistry.CodexWaitAgent).toBeDefined() + expect(toolResultViewRegistry.CodexSendInput).toBeDefined() + expect(toolResultViewRegistry.CodexCloseAgent).toBeDefined() + }) + + it('routes Claude parity tool names to expected result views', () => { + expect(getToolResultViewComponent('BashOutput')).toBe(getToolResultViewComponent('Bash')) + expect(getToolResultViewComponent('KillBash')).toBe(getToolResultViewComponent('SomeUnknownTool')) + expect(getToolResultViewComponent('TodoRead')).toBe(getToolResultViewComponent('TodoWrite')) + expect(getToolResultViewComponent('EnterWorktree')).toBe(getToolResultViewComponent('SomeUnknownTool')) + }) +}) + +describe('Codex alias result rendering', () => { + it('renders CodexBash object stdout and stderr output', () => { + const View = getToolResultViewComponent('CodexBash') + + renderWithProviders( + + ) + + expect(screen.getByText('command ok')).toBeInTheDocument() + expect(screen.getByText('warning output')).toBeInTheDocument() + }) + + it('renders TodoRead checklist entries through parity routing', () => { + const View = getToolResultViewComponent('TodoRead') + + renderWithProviders( + + ) + + expect(screen.getByText(/Ship web parity/)).toBeInTheDocument() + }) }) diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx index cb3e25364..acf899026 100644 --- a/web/src/components/ToolCard/views/_results.tsx +++ b/web/src/components/ToolCard/views/_results.tsx @@ -551,6 +551,7 @@ const GenericResultView: ToolViewComponent = (props: ToolViewProps) => { export const toolResultViewRegistry: Record = { Task: MarkdownResultView, Bash: BashResultView, + BashOutput: BashResultView, Glob: LineListResultView, Grep: LineListResultView, LS: LineListResultView, @@ -563,9 +564,18 @@ export const toolResultViewRegistry: Record = { NotebookRead: ReadResultView, NotebookEdit: MutationResultView, TodoWrite: TodoWriteResultView, + TodoRead: TodoWriteResultView, CodexReasoning: CodexReasoningResultView, + CodexBash: BashResultView, + CodexWriteStdin: GenericResultView, + CodexSpawnAgent: GenericResultView, + CodexWaitAgent: GenericResultView, + CodexSendInput: GenericResultView, + CodexCloseAgent: GenericResultView, CodexPatch: CodexPatchResultView, CodexDiff: CodexDiffResultView, + KillBash: GenericResultView, + EnterWorktree: GenericResultView, AskUserQuestion: AskUserQuestionResultView, ExitPlanMode: MarkdownResultView, ask_user_question: AskUserQuestionResultView, From 941c64911b81c0a02acd0d656fe5ba77855aaa3a Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 13:19:52 +0800 Subject: [PATCH 11/82] docs(spec): design codex subagent nesting --- ...026-04-02-codex-subagent-nesting-design.md | 432 ++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-02-codex-subagent-nesting-design.md diff --git a/docs/superpowers/specs/2026-04-02-codex-subagent-nesting-design.md b/docs/superpowers/specs/2026-04-02-codex-subagent-nesting-design.md new file mode 100644 index 000000000..fcaa03ef5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-codex-subagent-nesting-design.md @@ -0,0 +1,432 @@ +# Codex In-Session Subagent Nesting Design + +## Summary + +Add **Codex-first in-session subagent nesting** to HAPI. + +Scope: +- no child-session model +- no parent/child session schema +- no SessionList tree +- no standalone subagent page + +Goal: +- keep everything inside the parent session +- render Codex subagent workflow as a nested conversation under the parent tool block +- align Codex behavior with the existing Claude-style `ToolCallBlock.children` model where practical + +This design is an **incremental follow-up** to the existing Codex block-support work: +- `docs/superpowers/specs/2026-04-02-codex-claude-block-support-design.md` +- `docs/superpowers/plans/2026-04-02-codex-block-support-implementation.md` + +That earlier work fixes semantic tool names and block rendering. This new design fixes the missing **subagent conversation nesting**. + +## Problem + +Current HAPI can show Codex subagent-related tools as flat cards: +- `CodexSpawnAgent` +- `CodexWaitAgent` +- `CodexSendInput` +- `CodexCloseAgent` + +But it does **not** yet attach the subagent conversation itself under the parent tool block. + +Current Claude path already has a message-level nesting model: +- `tracer.ts` +- `reduceTimeline.ts` +- `ToolCallBlock.children` + +Claude sidechain messages can be grouped under a parent `Task` tool call. + +Codex currently lacks an equivalent mechanism. + +Result: +- subagent workflow appears as flat tool cards + flat summary text +- the user cannot visually follow the child agent conversation as a nested flow +- the UI feels incomplete even though the underlying transcript contains enough clues to reconstruct nesting + +## Evidence from real Codex transcripts + +Recent local Codex transcripts show the parent/child relationship is partially observable already. + +### Parent transcript signals + +A parent session transcript commonly contains: +- `spawn_agent` function call +- `spawn_agent` function_call_output with: + - `agent_id` + - `nickname` +- `wait_agent` call using that `agent_id` +- `wait_agent` function_call_output with a `status[agent_id]` payload +- a later `...` user-message injected back into the parent transcript + +### Inline child span signals + +Some parent transcripts also inline the child run directly after `spawn_agent`, including: +- `turn_context` +- child `user_message` +- child `agent_message` +- child `task_complete` + +This means Codex subagent content is not always hidden in a separate file. In at least some real cases, it already exists in the same transcript and can be grouped. + +## Goals + +### Functional goals +- Detect Codex subagent spans inside a parent transcript +- Attach child messages to the parent `CodexSpawnAgent` tool block as `children` +- Keep nested rendering inside the existing parent session chat page +- Preserve current flat block rendering for tool cards themselves +- Preserve current Claude nesting behavior + +### UX goals +- A `CodexSpawnAgent` block should expand into a readable nested child conversation when child content is present +- The nested child flow should show: + - child prompt + - child replies + - child tool activity if available in the same transcript +- `wait_agent` / `send_input` / `close_agent` should remain visible as normal tool blocks, but child conversational content should no longer appear as unrelated flat messages + +### Success criteria +After this work, when a parent Codex transcript contains inline child activity: +- the child prompt and child replies render under the matching `CodexSpawnAgent` block +- those child messages do not also remain duplicated in the parent root timeline +- normal parent conversation remains in the root timeline + +## Non-goals + +Out of scope: +- child session pages +- session-level parent/child schema +- SessionList nesting +- loading a separate child transcript file by `agent_id` +- reconstructing every possible Codex subagent lifecycle edge case +- redesigning TeamPanel +- changing Claude provider logic beyond compatibility-safe reuse of existing nesting machinery + +## Current architecture constraints + +### 1. Session model is flat +Files: +- `shared/src/schemas.ts` +- `shared/src/sessionSummary.ts` + +There is no: +- `parentSessionId` +- `rootSessionId` +- `sessionKind` + +So nesting must remain **message-level**, not session-level. + +### 2. Web nesting already exists for Claude sidechain +Files: +- `web/src/chat/tracer.ts` +- `web/src/chat/reducer.ts` +- `web/src/chat/reducerTimeline.ts` + +Current model: +- `traceMessages()` adds `sidechainId` +- `reduceChatBlocks()` groups traced messages by `sidechainId` +- `reduceTimeline()` attaches grouped child blocks to the parent tool block via `block.children` + +This is the right target abstraction to reuse. + +### 3. Codex transcript conversion currently lacks sidechain semantics +Files: +- `cli/src/codex/utils/codexEventConverter.ts` +- `web/src/chat/normalizeAgent.ts` + +Today Codex gives semantic tool names, but not explicit nesting metadata. + +## Recommended approach + +### Option A — web-only heuristic grouping +Infer nesting only in web reducer by looking at flat `CodexSpawnAgent` / `CodexWaitAgent` / notification patterns. + +Pros: +- smaller change set + +Cons: +- poor access to raw transcript structure +- fragile grouping rules +- hard to attach inline child messages cleanly + +### Option B — Codex transcript sidechain normalization at the CLI boundary (recommended) +Teach the Codex transcript conversion path to emit enough metadata for the existing web nesting pipeline to work. + +Pros: +- matches HAPI architecture better +- keeps transcript interpretation close to the source +- lets web reuse existing nesting pipeline + +Cons: +- requires coordinated CLI + web changes + +### Option C — load child transcript files by `agent_id` +Use `spawn_agent.output.agent_id` to resolve a separate child file and replay it under the parent block. + +Pros: +- potentially most complete + +Cons: +- much broader than needed +- touches scanner/resume/file-resolution logic again +- unnecessary for the first useful version + +## Scope decision + +Use **Option B**. + +## Proposed design + +## 1. Introduce Codex sidechain metadata in normalized chat messages + +### Files +- `web/src/chat/types.ts` +- `web/src/chat/normalizeAgent.ts` +- possibly small helper extraction file under `web/src/chat/` + +### Change +Extend normalized message metadata so Codex child messages can point to a parent tool block. + +Recommended shape: +- keep existing `isSidechain: boolean` +- add optional `sidechainKey?: string` + +Meaning: +- `isSidechain` = this message belongs to nested child flow +- `sidechainKey` = parent tool-call id that should own the nested messages + +This preserves current Claude semantics while letting Codex produce nesting without pretending it is the same UUID-based sidechain model. + +## 2. Add a Codex transcript nesting extractor before web reduction + +### Files +- `web/src/chat/reducer.ts` +- new helper: `web/src/chat/codexSidechain.ts` + +### Responsibility +Walk normalized messages and detect Codex subagent spans. + +Inputs available already: +- `CodexSpawnAgent` tool-call +- its tool-result containing `agent_id` +- inline child messages in the same transcript +- `CodexWaitAgent` call targeting that `agent_id` +- `` round-trip messages + +### Core rule +Treat `CodexSpawnAgent` as the parent block anchor. + +When child inline transcript content appears after a `CodexSpawnAgent` result and before control clearly returns to the parent flow, mark those child messages with: +- `isSidechain = true` +- `sidechainKey = ` + +Then the reducer can group them exactly like Claude sidechain messages. + +## 3. Codex child-span detection model + +Use a conservative sequential detector. + +### Parent anchor +A `CodexSpawnAgent` tool-call becomes nestable only after its matching tool-result resolves an `agent_id`. + +### Child span start +A child span begins when one of these appears after the resolved spawn result: +- inline child `turn_context` +- inline child `user_message` +- inline child `agent_message` +- inline child child-tool activity clearly belonging to the spawned run + +### Child span end +A child span ends when one of these occurs: +- parent `CodexWaitAgent` result completes and parent summary resumes +- parent root assistant answer starts +- a new unrelated parent tool chain clearly begins +- transcript ends + +### Conservative bias +If ownership is ambiguous, keep the message in the parent root timeline. +Never hide uncertain content inside a child group. + +## 4. Parent/child binding rule + +### Binding key +Use the parent `CodexSpawnAgent` tool-call id as the `sidechainKey`. + +Why: +- `ToolCallBlock.children` is already keyed by tool-call block id +- avoids introducing session-level ids into the web reducer +- mirrors Claude’s existing parent-tool attachment model + +### Agent id tracking +Keep a temporary runtime map while scanning normalized messages: +- `agentId -> spawnToolCallId` + +This supports: +- `wait_agent.targets` +- future `send_input.target` +- correlation of notification text if needed + +## 5. Message classes that may become nested + +Candidate nested content for the first version: +- child user prompt +- child agent text +- child reasoning +- child tool-call / tool-result when inline in the same transcript +- child ready/task-complete events if they are normalized into visible blocks + +Do **not** nest these by default in v1: +- parent `CodexWaitAgent` block itself +- parent `CodexSendInput` block itself +- parent `CodexCloseAgent` block itself + +Those remain root-level workflow controls. + +## 6. Handling `` + +### First version rule +Keep `` return messages in the root timeline. +Do not attempt to move them into the child span in v1. + +Reason: +- they are explicit parent-visible summaries +- they often act as the bridge back into the parent reply +- moving them could make the parent flow harder to follow + +Possible future refinement: +- dual rendering or compact summary under the child block while preserving parent root text + +But not in this scope. + +## 7. Reuse existing reducer pipeline + +### Reducer change +Today: +- `traceMessages()` creates groups only from Claude-style sidechain tracing + +After change: +- produce traced/grouped messages from two sources: + 1. existing Claude tracer output + 2. Codex sidechain extraction output + +Recommended shape: +- keep `traceMessages()` for Claude path +- add a Codex-specific pass that annotates `sidechainKey` +- update `reduceChatBlocks()` to group by either: + - `msg.sidechainId` for Claude + - `msg.sidechainKey` for Codex + +This avoids forcing Codex into Claude’s UUID prompt-matching logic. + +## 8. Data flow after the change + +### Codex replay/resume path +1. transcript scanner emits normalized Codex tool/messages +2. block-support aliases already normalize raw names to `Codex*` +3. Codex sidechain extractor scans normalized messages +4. inline child messages get `isSidechain + sidechainKey` +5. reducer groups those child messages under the matching `CodexSpawnAgent` block +6. UI renders nested child conversation via existing `ToolCallBlock.children` + +### Claude path +Unchanged: +- existing `tracer.ts` continues to drive Claude nested sidechains + +## 9. Failure handling + +### Missing `agent_id` +If `CodexSpawnAgent` result has no `agent_id`: +- keep flat rendering only +- do not attempt nesting + +### No inline child content +If the parent transcript only has: +- `spawn_agent` +- `wait_agent` +- notification summary +and no inline child conversation: +- keep flat workflow cards +- do not synthesize fake children + +### Ambiguous ownership +If a message cannot be confidently associated with one active spawned agent: +- keep it at root +- prefer false negative over false positive nesting + +## Testing strategy + +Write necessary tests only. + +### 1. Codex sidechain extraction tests +New file: +- `web/src/chat/codexSidechain.test.ts` + +Cover: +- `CodexSpawnAgent` + result with `agent_id` + inline child user/agent messages => child messages get `sidechainKey` +- ambiguous root message remains root +- no `agent_id` => no nesting +- multiple sequential spawns => messages bind to the correct parent + +### 2. Reducer integration tests +File: +- `web/src/chat/reducer.test.ts` or nearby existing reducer tests if present + +Cover: +- child messages end up in `ToolCallBlock.children` under the matching `CodexSpawnAgent` +- child messages do not remain duplicated in root blocks +- parent `wait_agent` stays root-level + +### 3. Manual transcript verification +Use a real Codex transcript known to contain: +- `spawn_agent` +- `wait_agent` +- inline child messages + +Verify in dev web: +- `CodexSpawnAgent` expands to nested conversation +- root timeline no longer shows those child messages twice +- parent summary remains readable + +## Risks and mitigations + +### Risk: over-grouping parent messages as child messages +Mitigation: +- conservative detector +- require resolved spawn result first +- keep ambiguous messages at root + +### Risk: transcript formats vary across Codex versions +Mitigation: +- v1 supports only the real patterns observed locally +- unsupported patterns degrade to flat rendering, not broken rendering + +### Risk: too much coupling to current transcript order +Mitigation: +- isolate detector in one helper file +- test exact observed orderings +- keep reducer contract simple: annotate messages, then group normally + +## Recommended execution order + +1. Finish the outstanding block-support plan gap: + - dedicated result views for Codex subagent tools + - real replay verification +2. Add `sidechainKey` support to normalized/traced messages +3. Implement `codexSidechain.ts` detector +4. Wire reducer grouping for Codex nested children +5. Add focused tests +6. Run manual replay verification on a real subagent transcript + +## Final recommendation + +Implement Codex subagent nesting as a **message-level grouping feature**, not as a session model. + +That gives the behavior you want now: +- parent session only +- nested child conversation inside the chat page +- no schema churn +- strong reuse of the Claude-era reducer/UI pipeline + +It also composes cleanly with the earlier Codex block-support work instead of replacing it. From 585369ae32aadc9b353e596da90847b171c186a5 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 13:51:22 +0800 Subject: [PATCH 12/82] feat(web): finish codex subagent block renderers --- .../components/ToolCard/checklist.test.tsx | 16 ++ web/src/components/ToolCard/knownTools.tsx | 3 +- .../ToolCard/views/_results.test.tsx | 110 ++++++++++++- .../components/ToolCard/views/_results.tsx | 148 +++++++++++++++++- 4 files changed, 270 insertions(+), 7 deletions(-) diff --git a/web/src/components/ToolCard/checklist.test.tsx b/web/src/components/ToolCard/checklist.test.tsx index 73898ba89..2d10561ea 100644 --- a/web/src/components/ToolCard/checklist.test.tsx +++ b/web/src/components/ToolCard/checklist.test.tsx @@ -133,6 +133,22 @@ describe('update_plan tool presentation', () => { }) }) +describe('Codex tool presentation', () => { + it('shows CodexWaitAgent target details in presentation', () => { + const presentation = getToolPresentation({ + toolName: 'CodexWaitAgent', + input: { targets: ['agent-1'], timeout_ms: 30000 }, + result: undefined, + childrenCount: 0, + description: null, + metadata: null + }) + + expect(presentation.title).toBe('Wait for agent') + expect(presentation.subtitle).toContain('agent-1') + }) +}) + describe('UpdatePlanView', () => { it('renders checklist rows with status styling', () => { render( diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 0cbb976eb..0554843bb 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -220,7 +220,8 @@ export const knownTools: Record 0) parts.push(`${targets.length} target${targets.length === 1 ? '' : 's'}`) + if (targets.length === 1) parts.push(`target: ${targets[0]}`) + else if (targets.length > 1) parts.push(`${targets.length} targets`) if (timeout) parts.push(`timeout: ${timeout}`) return parts.length > 0 ? parts.join(' • ') : null }, diff --git a/web/src/components/ToolCard/views/_results.test.tsx b/web/src/components/ToolCard/views/_results.test.tsx index 25bf1e0ac..a2ebc7644 100644 --- a/web/src/components/ToolCard/views/_results.test.tsx +++ b/web/src/components/ToolCard/views/_results.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { render, screen } from '@testing-library/react' +import { afterEach } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' import type { ToolCallBlock } from '@/chat/types' import { extractTextFromResult, getMutationResultRenderMode, getToolResultViewComponent, toolResultViewRegistry } from '@/components/ToolCard/views/_results' import { I18nProvider } from '@/lib/i18n-context' @@ -46,6 +47,10 @@ function renderWithProviders(ui: React.ReactElement) { ) } +afterEach(() => { + cleanup() +}) + describe('extractTextFromResult', () => { it('returns string directly', () => { expect(extractTextFromResult('hello')).toBe('hello') @@ -151,6 +156,16 @@ describe('getToolResultViewComponent registry', () => { expect(toolResultViewRegistry.CodexCloseAgent).toBeDefined() }) + it('routes Codex subagent tools away from GenericResultView', () => { + const generic = getToolResultViewComponent('SomeUnknownTool') + + expect(getToolResultViewComponent('CodexWriteStdin')).not.toBe(generic) + expect(getToolResultViewComponent('CodexSpawnAgent')).not.toBe(generic) + expect(getToolResultViewComponent('CodexWaitAgent')).not.toBe(generic) + expect(getToolResultViewComponent('CodexSendInput')).not.toBe(generic) + expect(getToolResultViewComponent('CodexCloseAgent')).not.toBe(generic) + }) + it('routes Claude parity tool names to expected result views', () => { expect(getToolResultViewComponent('BashOutput')).toBe(getToolResultViewComponent('Bash')) expect(getToolResultViewComponent('KillBash')).toBe(getToolResultViewComponent('SomeUnknownTool')) @@ -193,4 +208,97 @@ describe('Codex alias result rendering', () => { expect(screen.getByText(/Ship web parity/)).toBeInTheDocument() }) + + it('renders CodexWriteStdin sent input preview', () => { + const View = getToolResultViewComponent('CodexWriteStdin') + + renderWithProviders( + + ) + + expect(screen.getByText(/Sent:/)).toBeInTheDocument() + expect(screen.getByText(/ls/)).toBeInTheDocument() + }) + + it('renders CodexSpawnAgent result metadata', () => { + const View = getToolResultViewComponent('CodexSpawnAgent') + + renderWithProviders( + + ) + + expect(screen.getByText('Agent ID: agent-1')).toBeInTheDocument() + expect(screen.getByText('Nickname: Pauli')).toBeInTheDocument() + expect(screen.getByText('Prompt: Search GitHub trending')).toBeInTheDocument() + }) + + it('renders CodexWaitAgent target and timeout details', () => { + const View = getToolResultViewComponent('CodexWaitAgent') + + renderWithProviders( + + ) + + expect(screen.getByText('Targets: agent-1')).toBeInTheDocument() + expect(screen.getByText('Timeout: 30000')).toBeInTheDocument() + expect(screen.getByText('agent finished')).toBeInTheDocument() + }) + + it('renders CodexSendInput target and message preview', () => { + const View = getToolResultViewComponent('CodexSendInput') + + renderWithProviders( + + ) + + expect(screen.getByText(/Target: agent-1/)).toBeInTheDocument() + expect(screen.getByText(/continue with tests/)).toBeInTheDocument() + expect(screen.getByText(/Interrupt/)).toBeInTheDocument() + }) + + it('renders CodexCloseAgent target details', () => { + const View = getToolResultViewComponent('CodexCloseAgent') + + renderWithProviders( + + ) + + expect(screen.getByText('Target: agent-1')).toBeInTheDocument() + expect(screen.getByText(/closed/)).toBeInTheDocument() + }) }) diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx index acf899026..37e7d8019 100644 --- a/web/src/components/ToolCard/views/_results.tsx +++ b/web/src/components/ToolCard/views/_results.tsx @@ -143,6 +143,34 @@ function placeholderForState(state: ToolViewProps['block']['tool']['state']): st return '(no output)' } +function getStringList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string' && item.length > 0) +} + +function getInputString(input: unknown, keys: string[]): string | null { + if (!isObject(input)) return null + for (const key of keys) { + const value = input[key] + if (typeof value === 'string' && value.length > 0) return value + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + } + return null +} + +function renderToolMetaLines(lines: string[]) { + const visible = lines.filter((line) => line.length > 0) + if (visible.length === 0) return null + + return ( +
+ {visible.map((line) => ( +
{line}
+ ))} +
+ ) +} + function RawJsonDevOnly(props: { value: unknown }) { if (!import.meta.env.DEV) return null if (props.value === null || props.value === undefined) return null @@ -497,6 +525,116 @@ const CodexDiffResultView: ToolViewComponent = (props: ToolViewProps) => { ) } +const CodexWriteStdinResultView: ToolViewComponent = (props: ToolViewProps) => { + const input = isObject(props.block.tool.input) ? props.block.tool.input : null + const result = props.block.tool.result + const chars = input && typeof input.chars === 'string' ? input.chars : null + const target = getInputString(props.block.tool.input, ['target', 'session_id', 'sessionId']) + const text = extractTextFromResult(result) + + return ( +
+ {renderToolMetaLines([ + chars && chars.length > 0 + ? `Sent: ${chars.replace(/\r?\n/g, ' ↩ ')}` + : target ? `Poll target: ${target}` : 'Poll output' + ])} + {text + ? renderText(text, { mode: 'code', language: 'text' }) + :
{placeholderForState(props.block.tool.state)}
} + +
+ ) +} + +const CodexSpawnAgentResultView: ToolViewComponent = (props: ToolViewProps) => { + const input = isObject(props.block.tool.input) ? props.block.tool.input : null + const result = isObject(props.block.tool.result) ? props.block.tool.result : null + const text = extractTextFromResult(props.block.tool.result) + const agentId = result && typeof result.agent_id === 'string' ? result.agent_id : null + const nickname = result && typeof result.nickname === 'string' ? result.nickname : getInputString(input, ['nickname', 'name', 'agent_name']) + const message = getInputString(input, ['message', 'messagePreview', 'prompt', 'description']) + const model = getInputString(input, ['model']) + + return ( +
+ {renderToolMetaLines([ + agentId ? `Agent ID: ${agentId}` : '', + nickname ? `Nickname: ${nickname}` : '', + model ? `Model: ${model}` : '', + message ? `Prompt: ${message}` : '' + ])} + {text + ? renderText(text, { mode: 'code', language: 'text' }) + :
{placeholderForState(props.block.tool.state)}
} + +
+ ) +} + +const CodexWaitAgentResultView: ToolViewComponent = (props: ToolViewProps) => { + const input = isObject(props.block.tool.input) ? props.block.tool.input : null + const result = props.block.tool.result + const text = extractTextFromResult(result) + const targets = getStringList(input?.targets) + const timeout = getInputString(input, ['timeout_ms', 'timeout']) + + return ( +
+ {renderToolMetaLines([ + targets.length > 0 ? `Targets: ${targets.join(', ')}` : '', + timeout ? `Timeout: ${timeout}` : '' + ])} + {text + ? renderText(text, { mode: 'code', language: 'text' }) + :
{placeholderForState(props.block.tool.state)}
} + +
+ ) +} + +const CodexSendInputResultView: ToolViewComponent = (props: ToolViewProps) => { + const input = isObject(props.block.tool.input) ? props.block.tool.input : null + const result = props.block.tool.result + const text = extractTextFromResult(result) + const target = getInputString(input, ['target']) + const message = getInputString(input, ['message', 'messagePreview']) + const interrupt = input?.interrupt === true + + return ( +
+ {renderToolMetaLines([ + target ? `Target: ${target}` : '', + interrupt ? 'Interrupt' : '', + message ? `Message: ${message}` : '' + ])} + {text + ? renderText(text, { mode: 'code', language: 'text' }) + :
{placeholderForState(props.block.tool.state)}
} + +
+ ) +} + +const CodexCloseAgentResultView: ToolViewComponent = (props: ToolViewProps) => { + const input = isObject(props.block.tool.input) ? props.block.tool.input : null + const result = props.block.tool.result + const text = extractTextFromResult(result) + const target = getInputString(input, ['target', 'agent_id', 'agentId']) + + return ( +
+ {renderToolMetaLines([ + target ? `Target: ${target}` : '' + ])} + {text + ? renderText(text, { mode: 'code', language: 'text' }) + :
{placeholderForState(props.block.tool.state)}
} + +
+ ) +} + const TodoWriteResultView: ToolViewComponent = (props: ToolViewProps) => { const todos = extractTodoChecklist(props.block.tool.input, props.block.tool.result) if (todos.length === 0) { @@ -567,11 +705,11 @@ export const toolResultViewRegistry: Record = { TodoRead: TodoWriteResultView, CodexReasoning: CodexReasoningResultView, CodexBash: BashResultView, - CodexWriteStdin: GenericResultView, - CodexSpawnAgent: GenericResultView, - CodexWaitAgent: GenericResultView, - CodexSendInput: GenericResultView, - CodexCloseAgent: GenericResultView, + CodexWriteStdin: CodexWriteStdinResultView, + CodexSpawnAgent: CodexSpawnAgentResultView, + CodexWaitAgent: CodexWaitAgentResultView, + CodexSendInput: CodexSendInputResultView, + CodexCloseAgent: CodexCloseAgentResultView, CodexPatch: CodexPatchResultView, CodexDiff: CodexDiffResultView, KillBash: GenericResultView, From 99fda93d36fc40a45566dd4102bf862f8f506564 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 13:51:22 +0800 Subject: [PATCH 13/82] feat(web): detect codex inline subagent sidechains --- web/src/chat/codexSidechain.test.ts | 158 ++++++++++++++++++++++++++++ web/src/chat/codexSidechain.ts | 112 ++++++++++++++++++++ web/src/chat/types.ts | 1 + 3 files changed, 271 insertions(+) create mode 100644 web/src/chat/codexSidechain.test.ts create mode 100644 web/src/chat/codexSidechain.ts diff --git a/web/src/chat/codexSidechain.test.ts b/web/src/chat/codexSidechain.test.ts new file mode 100644 index 000000000..bd1bedd84 --- /dev/null +++ b/web/src/chat/codexSidechain.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import type { NormalizedMessage } from './types' +import { annotateCodexSidechains } from './codexSidechain' + +function agentToolCall( + id: string, + name: string, + input: unknown, + createdAt: number +): NormalizedMessage { + return { + id: `msg-${id}`, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id, + name, + input, + description: null, + uuid: `uuid-${id}`, + parentUUID: null + }] + } +} + +function agentToolResult( + toolUseId: string, + content: unknown, + createdAt: number +): NormalizedMessage { + return { + id: `msg-${toolUseId}-result`, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: toolUseId, + content, + is_error: false, + uuid: `uuid-${toolUseId}-result`, + parentUUID: null + }] + } +} + +function agentText(id: string, text: string, createdAt: number): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'text', + text, + uuid: `uuid-${id}`, + parentUUID: null + }] + } +} + +function agentReasoning(id: string, text: string, createdAt: number): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'reasoning', + text, + uuid: `uuid-${id}`, + parentUUID: null + }] + } +} + +function userText(id: string, text: string, createdAt: number): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'user', + isSidechain: false, + content: { type: 'text', text } + } +} + +describe('annotateCodexSidechains', () => { + it('marks inline child messages under the matching CodexSpawnAgent', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'Search GitHub trending' }, 1), + agentToolResult('spawn-1', { agent_id: 'agent-1', nickname: 'Pauli' }, 2), + userText('child-user', 'child prompt', 3), + agentText('child-agent', 'child answer', 4), + agentReasoning('child-reasoning', 'child thought', 5), + agentToolCall('child-tool', 'CodexSendInput', { target: 'agent-1', message: 'ping' }, 6), + agentToolResult('child-tool', { ok: true }, 7), + userText('notification', ' done', 8), + agentToolCall('wait-1', 'CodexWaitAgent', { targets: ['agent-1'], timeout_ms: 120000 }, 9) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[2]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[3]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[4]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[5]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[6]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[7]).toMatchObject({ isSidechain: false }) + expect(result[8]).toMatchObject({ isSidechain: false }) + }) + + it('keeps messages root-level when the spawn result has no agent_id', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'Search GitHub trending' }, 1), + agentToolResult('spawn-1', { nickname: 'Pauli' }, 2), + userText('child-user', 'child prompt', 3), + agentText('child-agent', 'child answer', 4), + agentToolCall('wait-1', 'CodexWaitAgent', { targets: ['agent-1'], timeout_ms: 120000 }, 5) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[2]).toMatchObject({ isSidechain: false }) + expect(result[3]).toMatchObject({ isSidechain: false }) + expect(result[4]).toMatchObject({ isSidechain: false }) + }) + + it('binds sequential spawns to the correct spawn key', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'First' }, 1), + agentToolResult('spawn-1', { agent_id: 'agent-1' }, 2), + userText('child-1-user', 'first child prompt', 3), + agentText('child-1-agent', 'first child answer', 4), + agentToolCall('wait-1', 'CodexWaitAgent', { targets: ['agent-1'] }, 5), + agentToolCall('spawn-2', 'CodexSpawnAgent', { message: 'Second' }, 6), + agentToolResult('spawn-2', { agent_id: 'agent-2' }, 7), + userText('child-2-user', 'second child prompt', 8), + agentText('child-2-agent', 'second child answer', 9), + agentToolCall('wait-2', 'CodexWaitAgent', { targets: ['agent-2'] }, 10) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[2]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[3]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[7]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-2' }) + expect(result[8]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-2' }) + expect(result[4]).toMatchObject({ isSidechain: false }) + expect(result[9]).toMatchObject({ isSidechain: false }) + }) +}) diff --git a/web/src/chat/codexSidechain.ts b/web/src/chat/codexSidechain.ts new file mode 100644 index 000000000..95dd0dc8c --- /dev/null +++ b/web/src/chat/codexSidechain.ts @@ -0,0 +1,112 @@ +import type { NormalizedAgentContent, NormalizedMessage } from '@/chat/types' +import { isObject } from '@hapi/protocol' + +const SUBAGENT_NOTIFICATION_PREFIX = '' + +function getToolCallBlocks(message: NormalizedMessage): Extract[] { + if (message.role !== 'agent') return [] + return message.content.filter((content): content is Extract => content.type === 'tool-call') +} + +function getToolResultBlocks(message: NormalizedMessage): Extract[] { + if (message.role !== 'agent') return [] + return message.content.filter((content): content is Extract => content.type === 'tool-result') +} + +function extractSpawnAgentId( + message: NormalizedMessage, + toolNameByToolUseId: Map +): { agentId: string; spawnToolUseId: string } | null { + for (const result of getToolResultBlocks(message)) { + const toolName = toolNameByToolUseId.get(result.tool_use_id) + if (toolName !== 'CodexSpawnAgent') continue + if (!isObject(result.content)) continue + + const agentId = typeof result.content.agent_id === 'string' ? result.content.agent_id : null + if (!agentId || agentId.length === 0) continue + + return { agentId, spawnToolUseId: result.tool_use_id } + } + + return null +} + +function extractWaitTargets(message: NormalizedMessage): string[] { + for (const toolCall of getToolCallBlocks(message)) { + if (toolCall.name !== 'CodexWaitAgent') continue + if (!isObject(toolCall.input) || !Array.isArray(toolCall.input.targets)) continue + + return toolCall.input.targets.filter((target): target is string => typeof target === 'string' && target.length > 0) + } + + return [] +} + +function messageLooksLikeInlineChildConversation(message: NormalizedMessage): boolean { + if (message.role === 'user') { + return message.content.type === 'text' && !message.content.text.trimStart().startsWith(SUBAGENT_NOTIFICATION_PREFIX) + } + + if (message.role !== 'agent') return false + if (message.content.length === 0) return false + + let sawNestableContent = false + for (const block of message.content) { + if (block.type === 'summary' || block.type === 'sidechain') return false + if (block.type === 'text') { + if (block.text.trimStart().startsWith(SUBAGENT_NOTIFICATION_PREFIX)) return false + sawNestableContent = true + continue + } + if (block.type === 'reasoning' || block.type === 'tool-call' || block.type === 'tool-result') { + sawNestableContent = true + continue + } + return false + } + + return sawNestableContent +} + +export function annotateCodexSidechains(messages: NormalizedMessage[]): NormalizedMessage[] { + const toolNameByToolUseId = new Map() + let activeSpawnToolUseId: string | null = null + let activeAgentId: string | null = null + + const result: NormalizedMessage[] = [] + + for (const message of messages) { + for (const toolCall of getToolCallBlocks(message)) { + toolNameByToolUseId.set(toolCall.id, toolCall.name) + } + + const spawn = extractSpawnAgentId(message, toolNameByToolUseId) + if (spawn) { + activeSpawnToolUseId = spawn.spawnToolUseId + activeAgentId = spawn.agentId + result.push({ ...message }) + continue + } + + const waitTargets: string[] = activeAgentId !== null ? extractWaitTargets(message) : [] + if (activeSpawnToolUseId !== null && activeAgentId !== null && waitTargets.includes(activeAgentId)) { + activeSpawnToolUseId = null + activeAgentId = null + result.push({ ...message }) + continue + } + + if (activeSpawnToolUseId !== null && messageLooksLikeInlineChildConversation(message)) { + result.push({ + ...message, + isSidechain: true, + sidechainKey: activeSpawnToolUseId + }) + continue + } + + result.push({ ...message }) + } + + return result +} diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index fbaa5b417..618108c99 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -80,6 +80,7 @@ export type NormalizedMessage = ({ localId: string | null createdAt: number isSidechain: boolean + sidechainKey?: string meta?: unknown usage?: UsageData status?: MessageStatus From 9cad1db5309077492a359cd1e4a622cd29e3d4f4 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 14:02:05 +0800 Subject: [PATCH 14/82] feat(web): nest codex inline subagent conversations --- web/src/chat/codexSidechain.test.ts | 20 +++++ web/src/chat/codexSidechain.ts | 24 ++++-- web/src/chat/reducer.test.ts | 117 ++++++++++++++++++++++++++++ web/src/chat/reducer.ts | 63 ++++++++++++--- 4 files changed, 204 insertions(+), 20 deletions(-) create mode 100644 web/src/chat/reducer.test.ts diff --git a/web/src/chat/codexSidechain.test.ts b/web/src/chat/codexSidechain.test.ts index bd1bedd84..e023f7c16 100644 --- a/web/src/chat/codexSidechain.test.ts +++ b/web/src/chat/codexSidechain.test.ts @@ -155,4 +155,24 @@ describe('annotateCodexSidechains', () => { expect(result[4]).toMatchObject({ isSidechain: false }) expect(result[9]).toMatchObject({ isSidechain: false }) }) + + it('keeps earlier spawned agents active when a newer spawn closes first', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'First' }, 1), + agentToolResult('spawn-1', { agent_id: 'agent-1' }, 2), + agentToolCall('spawn-2', 'CodexSpawnAgent', { message: 'Second' }, 3), + agentToolResult('spawn-2', { agent_id: 'agent-2' }, 4), + agentToolCall('wait-2', 'CodexWaitAgent', { targets: ['agent-2'] }, 5), + userText('child-1-user', 'first child prompt resumes', 6), + agentText('child-1-agent', 'first child answer resumes', 7), + agentToolCall('wait-1', 'CodexWaitAgent', { targets: ['agent-1'] }, 8) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[4]).toMatchObject({ isSidechain: false }) + expect(result[5]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[6]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[7]).toMatchObject({ isSidechain: false }) + }) }) diff --git a/web/src/chat/codexSidechain.ts b/web/src/chat/codexSidechain.ts index 95dd0dc8c..c3e7c52cc 100644 --- a/web/src/chat/codexSidechain.ts +++ b/web/src/chat/codexSidechain.ts @@ -68,10 +68,16 @@ function messageLooksLikeInlineChildConversation(message: NormalizedMessage): bo return sawNestableContent } +function removeActiveAgents(activeAgentIds: string[], targets: string[]): string[] { + if (targets.length === 0) return activeAgentIds + const closed = new Set(targets) + return activeAgentIds.filter((agentId) => !closed.has(agentId)) +} + export function annotateCodexSidechains(messages: NormalizedMessage[]): NormalizedMessage[] { const toolNameByToolUseId = new Map() - let activeSpawnToolUseId: string | null = null - let activeAgentId: string | null = null + const agentIdToSpawnToolUseId = new Map() + let activeAgentIds: string[] = [] const result: NormalizedMessage[] = [] @@ -82,20 +88,22 @@ export function annotateCodexSidechains(messages: NormalizedMessage[]): Normaliz const spawn = extractSpawnAgentId(message, toolNameByToolUseId) if (spawn) { - activeSpawnToolUseId = spawn.spawnToolUseId - activeAgentId = spawn.agentId + agentIdToSpawnToolUseId.set(spawn.agentId, spawn.spawnToolUseId) + activeAgentIds = removeActiveAgents(activeAgentIds, [spawn.agentId]) + activeAgentIds.push(spawn.agentId) result.push({ ...message }) continue } - const waitTargets: string[] = activeAgentId !== null ? extractWaitTargets(message) : [] - if (activeSpawnToolUseId !== null && activeAgentId !== null && waitTargets.includes(activeAgentId)) { - activeSpawnToolUseId = null - activeAgentId = null + const waitTargets = extractWaitTargets(message) + if (waitTargets.length > 0) { + activeAgentIds = removeActiveAgents(activeAgentIds, waitTargets) result.push({ ...message }) continue } + const activeAgentId = activeAgentIds.at(-1) ?? null + const activeSpawnToolUseId = activeAgentId ? agentIdToSpawnToolUseId.get(activeAgentId) ?? null : null if (activeSpawnToolUseId !== null && messageLooksLikeInlineChildConversation(message)) { result.push({ ...message, diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts new file mode 100644 index 000000000..6a7e2c6f8 --- /dev/null +++ b/web/src/chat/reducer.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { reduceChatBlocks } from './reducer' +import type { NormalizedMessage, ToolCallBlock } from './types' + +function agentToolCall( + messageId: string, + toolUseId: string, + name: string, + input: unknown, + createdAt: number +): NormalizedMessage { + return { + id: messageId, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: toolUseId, + name, + input, + description: null, + uuid: `${messageId}-uuid`, + parentUUID: null + }] + } +} + +function agentToolResult( + messageId: string, + toolUseId: string, + content: unknown, + createdAt: number +): NormalizedMessage { + return { + id: messageId, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: toolUseId, + content, + is_error: false, + uuid: `${messageId}-uuid`, + parentUUID: null + }] + } +} + +function userText(id: string, text: string, createdAt: number): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'user', + isSidechain: false, + content: { type: 'text', text } + } +} + +function agentText(id: string, text: string, createdAt: number): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'text', + text, + uuid: `${id}-uuid`, + parentUUID: null + }] + } +} + +describe('reduceChatBlocks', () => { + it('groups Codex child messages under the matching spawn tool block', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-spawn-call', 'spawn-1', 'CodexSpawnAgent', { message: 'Search GitHub trending' }, 1), + agentToolResult('msg-spawn-result', 'spawn-1', { agent_id: 'agent-1', nickname: 'Pauli' }, 2), + userText('child-user', 'child prompt', 3), + agentText('child-agent', 'child answer', 4), + userText('notification', ' child update', 5), + agentToolCall('msg-wait-call', 'wait-1', 'CodexWaitAgent', { targets: ['agent-1'], timeout_ms: 120000 }, 6) + ] + + const reduced = reduceChatBlocks(messages, null) + const spawnBlock = reduced.blocks.find( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.name === 'CodexSpawnAgent' + ) + + expect(spawnBlock).toBeDefined() + expect(spawnBlock?.children.map((child) => child.kind)).toEqual(['user-text', 'agent-text']) + expect(spawnBlock?.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: 'child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) + ]) + ) + expect(reduced.blocks).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: 'child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) + ]) + ) + expect(reduced.blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: ' child update' }), + expect.objectContaining({ kind: 'tool-call', tool: expect.objectContaining({ name: 'CodexWaitAgent' }) }) + ]) + ) + }) +}) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 798499c67..5ac607b2a 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -1,5 +1,6 @@ import type { AgentState } from '@/types/api' import type { ChatBlock, NormalizedMessage, UsageData } from '@/chat/types' +import { annotateCodexSidechains } from '@/chat/codexSidechain' import { traceMessages, type TracedMessage } from '@/chat/tracer' import { dedupeAgentEvents, foldApiErrorEvents } from '@/chat/reducerEvents' import { collectTitleChanges, collectToolIdsFromMessages, ensureToolBlock, getPermissions } from '@/chat/reducerTools' @@ -10,6 +11,45 @@ function calculateContextSize(usage: UsageData): number { return (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + usage.input_tokens } +function groupMessagesBySidechain(messages: TracedMessage[]): { groups: Map; root: TracedMessage[] } { + const groups = new Map() + const root: TracedMessage[] = [] + + for (const msg of messages) { + const groupId = msg.sidechainId ?? msg.sidechainKey + if (groupId) { + const existing = groups.get(groupId) ?? [] + existing.push(msg) + groups.set(groupId, existing) + continue + } + + root.push(msg) + } + + return { groups, root } +} + +function attachCodexSpawnChildren( + blocks: ChatBlock[], + groups: Map, + consumedGroupIds: Set, + reduceGroup: (groupId: string) => ChatBlock[] +): void { + for (const block of blocks) { + if (block.kind !== 'tool-call') continue + + if (block.tool.name === 'CodexSpawnAgent' && groups.has(block.tool.id) && !consumedGroupIds.has(block.tool.id)) { + consumedGroupIds.add(block.tool.id) + block.children = reduceGroup(block.tool.id) + } + + if (block.children.length > 0) { + attachCodexSpawnChildren(block.children, groups, consumedGroupIds, reduceGroup) + } + } +} + export type LatestUsage = { inputTokens: number outputTokens: number @@ -28,18 +68,8 @@ export function reduceChatBlocks( const titleChangesByToolUseId = collectTitleChanges(normalized) const traced = traceMessages(normalized) - const groups = new Map() - const root: TracedMessage[] = [] - - for (const msg of traced) { - if (msg.sidechainId) { - const existing = groups.get(msg.sidechainId) ?? [] - existing.push(msg) - groups.set(msg.sidechainId, existing) - } else { - root.push(msg) - } - } + const annotated = annotateCodexSidechains(traced) + const { groups, root } = groupMessagesBySidechain(annotated) const consumedGroupIds = new Set() const emittedTitleChangeToolUseIds = new Set() @@ -47,6 +77,15 @@ export function reduceChatBlocks( const rootResult = reduceTimeline(root, reducerContext) let hasReadyEvent = rootResult.hasReadyEvent + const reduceGroup = (groupId: string): ChatBlock[] => { + const sidechain = groups.get(groupId) ?? [] + const child = reduceTimeline(sidechain, reducerContext) + hasReadyEvent = hasReadyEvent || child.hasReadyEvent + return child.blocks + } + + attachCodexSpawnChildren(rootResult.blocks, groups, consumedGroupIds, reduceGroup) + // Only create permission-only tool cards when there is no tool call/result in the transcript. // Also skip if the permission is older than the oldest message in the current view, // to avoid mixing old tool cards with newer messages when paginating. From 51de846723ae324207fdd797f44ebb322ac02bd2 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 14:11:18 +0800 Subject: [PATCH 15/82] fix(web): preserve codex structured result fallback --- .../ToolCard/views/_results.test.tsx | 66 ++++++++++++++++++- .../components/ToolCard/views/_results.tsx | 41 ++++++------ 2 files changed, 86 insertions(+), 21 deletions(-) diff --git a/web/src/components/ToolCard/views/_results.test.tsx b/web/src/components/ToolCard/views/_results.test.tsx index a2ebc7644..41d32dae0 100644 --- a/web/src/components/ToolCard/views/_results.test.tsx +++ b/web/src/components/ToolCard/views/_results.test.tsx @@ -246,6 +246,70 @@ describe('Codex alias result rendering', () => { expect(screen.getByText('Prompt: Search GitHub trending')).toBeInTheDocument() }) + it('renders CodexCloseAgent structured status instead of no output placeholder', () => { + const View = getToolResultViewComponent('CodexCloseAgent') + + renderWithProviders( + + ) + + expect(screen.getByText('Target: agent-9')).toBeInTheDocument() + expect(screen.queryByText('(no output)')).not.toBeInTheDocument() + expect(screen.getAllByText(/closed/).length).toBeGreaterThan(0) + }) + + it('renders CodexSendInput structured ack instead of no output placeholder', () => { + const View = getToolResultViewComponent('CodexSendInput') + + renderWithProviders( + + ) + + expect(screen.getByText('Target: agent-4')).toBeInTheDocument() + expect(screen.getByText('Message: continue')).toBeInTheDocument() + expect(screen.queryByText('(no output)')).not.toBeInTheDocument() + expect(screen.getAllByText(/true/).length).toBeGreaterThan(0) + }) + + it('renders CodexWaitAgent structured status map instead of no output placeholder', () => { + const View = getToolResultViewComponent('CodexWaitAgent') + + renderWithProviders( + + ) + + expect(screen.getByText('Targets: agent-1, agent-2')).toBeInTheDocument() + expect(screen.queryByText('(no output)')).not.toBeInTheDocument() + expect(screen.getAllByText(/completed/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/running/).length).toBeGreaterThan(0) + }) + it('renders CodexWaitAgent target and timeout details', () => { const View = getToolResultViewComponent('CodexWaitAgent') @@ -299,6 +363,6 @@ describe('Codex alias result rendering', () => { ) expect(screen.getByText('Target: agent-1')).toBeInTheDocument() - expect(screen.getByText(/closed/)).toBeInTheDocument() + expect(screen.getAllByText(/closed/).length).toBeGreaterThan(0) }) }) diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx index 37e7d8019..f3e37f827 100644 --- a/web/src/components/ToolCard/views/_results.tsx +++ b/web/src/components/ToolCard/views/_results.tsx @@ -137,6 +137,22 @@ function renderText(text: string, opts: { mode: 'markdown' | 'code' | 'auto'; la return } +function renderCodexStructuredResult( + result: unknown, + state: ToolViewProps['block']['tool']['state'] +): React.ReactNode { + const text = extractTextFromResult(result) + if (text) { + return renderText(text, { mode: 'code', language: 'text' }) + } + + if (result !== null && result !== undefined && typeof result === 'object') { + return + } + + return
{placeholderForState(state)}
+} + function placeholderForState(state: ToolViewProps['block']['tool']['state']): string { if (state === 'pending') return 'Waiting for permission…' if (state === 'running') return 'Running…' @@ -530,7 +546,6 @@ const CodexWriteStdinResultView: ToolViewComponent = (props: ToolViewProps) => { const result = props.block.tool.result const chars = input && typeof input.chars === 'string' ? input.chars : null const target = getInputString(props.block.tool.input, ['target', 'session_id', 'sessionId']) - const text = extractTextFromResult(result) return (
@@ -539,9 +554,7 @@ const CodexWriteStdinResultView: ToolViewComponent = (props: ToolViewProps) => { ? `Sent: ${chars.replace(/\r?\n/g, ' ↩ ')}` : target ? `Poll target: ${target}` : 'Poll output' ])} - {text - ? renderText(text, { mode: 'code', language: 'text' }) - :
{placeholderForState(props.block.tool.state)}
} + {renderCodexStructuredResult(result, props.block.tool.state)}
) @@ -550,7 +563,6 @@ const CodexWriteStdinResultView: ToolViewComponent = (props: ToolViewProps) => { const CodexSpawnAgentResultView: ToolViewComponent = (props: ToolViewProps) => { const input = isObject(props.block.tool.input) ? props.block.tool.input : null const result = isObject(props.block.tool.result) ? props.block.tool.result : null - const text = extractTextFromResult(props.block.tool.result) const agentId = result && typeof result.agent_id === 'string' ? result.agent_id : null const nickname = result && typeof result.nickname === 'string' ? result.nickname : getInputString(input, ['nickname', 'name', 'agent_name']) const message = getInputString(input, ['message', 'messagePreview', 'prompt', 'description']) @@ -564,9 +576,7 @@ const CodexSpawnAgentResultView: ToolViewComponent = (props: ToolViewProps) => { model ? `Model: ${model}` : '', message ? `Prompt: ${message}` : '' ])} - {text - ? renderText(text, { mode: 'code', language: 'text' }) - :
{placeholderForState(props.block.tool.state)}
} + {renderCodexStructuredResult(props.block.tool.result, props.block.tool.state)} ) @@ -575,7 +585,6 @@ const CodexSpawnAgentResultView: ToolViewComponent = (props: ToolViewProps) => { const CodexWaitAgentResultView: ToolViewComponent = (props: ToolViewProps) => { const input = isObject(props.block.tool.input) ? props.block.tool.input : null const result = props.block.tool.result - const text = extractTextFromResult(result) const targets = getStringList(input?.targets) const timeout = getInputString(input, ['timeout_ms', 'timeout']) @@ -585,9 +594,7 @@ const CodexWaitAgentResultView: ToolViewComponent = (props: ToolViewProps) => { targets.length > 0 ? `Targets: ${targets.join(', ')}` : '', timeout ? `Timeout: ${timeout}` : '' ])} - {text - ? renderText(text, { mode: 'code', language: 'text' }) - :
{placeholderForState(props.block.tool.state)}
} + {renderCodexStructuredResult(result, props.block.tool.state)} ) @@ -596,7 +603,6 @@ const CodexWaitAgentResultView: ToolViewComponent = (props: ToolViewProps) => { const CodexSendInputResultView: ToolViewComponent = (props: ToolViewProps) => { const input = isObject(props.block.tool.input) ? props.block.tool.input : null const result = props.block.tool.result - const text = extractTextFromResult(result) const target = getInputString(input, ['target']) const message = getInputString(input, ['message', 'messagePreview']) const interrupt = input?.interrupt === true @@ -608,9 +614,7 @@ const CodexSendInputResultView: ToolViewComponent = (props: ToolViewProps) => { interrupt ? 'Interrupt' : '', message ? `Message: ${message}` : '' ])} - {text - ? renderText(text, { mode: 'code', language: 'text' }) - :
{placeholderForState(props.block.tool.state)}
} + {renderCodexStructuredResult(result, props.block.tool.state)} ) @@ -619,7 +623,6 @@ const CodexSendInputResultView: ToolViewComponent = (props: ToolViewProps) => { const CodexCloseAgentResultView: ToolViewComponent = (props: ToolViewProps) => { const input = isObject(props.block.tool.input) ? props.block.tool.input : null const result = props.block.tool.result - const text = extractTextFromResult(result) const target = getInputString(input, ['target', 'agent_id', 'agentId']) return ( @@ -627,9 +630,7 @@ const CodexCloseAgentResultView: ToolViewComponent = (props: ToolViewProps) => { {renderToolMetaLines([ target ? `Target: ${target}` : '' ])} - {text - ? renderText(text, { mode: 'code', language: 'text' }) - :
{placeholderForState(props.block.tool.state)}
} + {renderCodexStructuredResult(result, props.block.tool.state)} ) From b5eb089884f9efc1778e82e6df69090d93c74b11 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 14:38:53 +0800 Subject: [PATCH 16/82] Add Codex sidechain normalization --- web/src/chat/normalize.test.ts | 79 ++++++++++++++++++++++++++++++++++ web/src/chat/normalizeAgent.ts | 14 ++++-- web/src/chat/normalizeUser.ts | 17 +++++++- 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index f6f38a0dd..63a7a3398 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -13,6 +13,85 @@ function makeMessage(content: unknown): DecryptedMessage { } describe('normalizeDecryptedMessage', () => { + it('maps Codex parentToolCallId to sidechainKey on sidechain agent payloads', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + callId: 'tool-call-1', + id: 'tool-use-1', + name: 'spawn', + input: { prompt: 'hi' }, + isSidechain: true, + parentToolCallId: 'spawn-1' + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'agent', + isSidechain: true, + sidechainKey: 'spawn-1', + content: [ + { + type: 'tool-call', + id: 'tool-call-1' + } + ] + }) + }) + + it('keeps normal Codex payloads root-level when parentToolCallId is absent', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + callId: 'tool-call-1', + id: 'tool-use-1', + name: 'spawn', + input: { prompt: 'hi' } + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'agent', + isSidechain: false + }) + expect(normalizeDecryptedMessage(message)?.sidechainKey).toBeUndefined() + }) + + it('preserves user sidechain metadata from record meta', () => { + const message = makeMessage({ + role: 'user', + content: { + type: 'text', + text: 'child transcript prompt' + }, + meta: { + isSidechain: true, + sidechainKey: 'spawn-1' + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'user', + isSidechain: true, + sidechainKey: 'spawn-1', + content: { + type: 'text', + text: 'child transcript prompt' + } + }) + }) + it('drops unsupported Claude system output records', () => { const message = makeMessage({ role: 'agent', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 18886e989..abb743d11 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -314,6 +314,8 @@ export function normalizeAgentRecord( if (content.type === AGENT_MESSAGE_PAYLOAD_TYPE) { const data = isObject(content.data) ? content.data : null if (!data || typeof data.type !== 'string') return null + const isSidechain = Boolean(data.isSidechain) + const sidechainKey = asString(data.parentToolCallId) ?? undefined if (data.type === 'message' && typeof data.message === 'string') { return { @@ -321,7 +323,8 @@ export function normalizeAgentRecord( localId, createdAt, role: 'agent', - isSidechain: false, + isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: [{ type: 'text', text: data.message, uuid: messageId, parentUUID: null }], meta } @@ -333,7 +336,8 @@ export function normalizeAgentRecord( localId, createdAt, role: 'agent', - isSidechain: false, + isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: [{ type: 'reasoning', text: data.message, uuid: messageId, parentUUID: null }], meta } @@ -346,7 +350,8 @@ export function normalizeAgentRecord( localId, createdAt, role: 'agent', - isSidechain: false, + isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: [{ type: 'tool-call', id: data.callId, @@ -367,7 +372,8 @@ export function normalizeAgentRecord( localId, createdAt, role: 'agent', - isSidechain: false, + isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: [{ type: 'tool-result', tool_use_id: data.callId, diff --git a/web/src/chat/normalizeUser.ts b/web/src/chat/normalizeUser.ts index 3785c8f61..15a87815f 100644 --- a/web/src/chat/normalizeUser.ts +++ b/web/src/chat/normalizeUser.ts @@ -2,6 +2,15 @@ import type { NormalizedMessage } from '@/chat/types' import type { AttachmentMetadata } from '@/types/api' import { isObject } from '@hapi/protocol' +function normalizeSidechainMeta(meta: unknown): Pick | null { + if (!isObject(meta)) return null + if (meta.isSidechain !== true || typeof meta.sidechainKey !== 'string') return null + return { + isSidechain: true, + sidechainKey: meta.sidechainKey + } +} + function parseAttachments(raw: unknown): AttachmentMetadata[] | undefined { if (!Array.isArray(raw)) return undefined const attachments: AttachmentMetadata[] = [] @@ -35,26 +44,30 @@ export function normalizeUserRecord( meta?: unknown ): NormalizedMessage | null { if (typeof content === 'string') { + const sidechain = normalizeSidechainMeta(meta) return { id: messageId, localId, createdAt, role: 'user', content: { type: 'text', text: content }, - isSidechain: false, + isSidechain: sidechain?.isSidechain ?? false, + ...(sidechain ? { sidechainKey: sidechain.sidechainKey } : {}), meta } } if (isObject(content) && content.type === 'text' && typeof content.text === 'string') { const attachments = parseAttachments(content.attachments) + const sidechain = normalizeSidechainMeta(meta) return { id: messageId, localId, createdAt, role: 'user', content: { type: 'text', text: content.text, attachments }, - isSidechain: false, + isSidechain: sidechain?.isSidechain ?? false, + ...(sidechain ? { sidechainKey: sidechain.sidechainKey } : {}), meta } } From c2ad568731571c510d86b25786570a213ef707db Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 14:47:36 +0800 Subject: [PATCH 17/82] Restrict Codex sidechain keys to sidechains --- web/src/chat/normalize.test.ts | 24 ++++++++++++++++++++++++ web/src/chat/normalizeAgent.ts | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 63a7a3398..10f8521d5 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -67,6 +67,30 @@ describe('normalizeDecryptedMessage', () => { expect(normalizeDecryptedMessage(message)?.sidechainKey).toBeUndefined() }) + it('keeps Codex payloads root-level when parentToolCallId is present without isSidechain', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + callId: 'tool-call-1', + id: 'tool-use-1', + name: 'spawn', + input: { prompt: 'hi' }, + parentToolCallId: 'spawn-1' + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'agent', + isSidechain: false + }) + expect(normalizeDecryptedMessage(message)?.sidechainKey).toBeUndefined() + }) + it('preserves user sidechain metadata from record meta', () => { const message = makeMessage({ role: 'user', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index abb743d11..fd4570292 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -315,7 +315,7 @@ export function normalizeAgentRecord( const data = isObject(content.data) ? content.data : null if (!data || typeof data.type !== 'string') return null const isSidechain = Boolean(data.isSidechain) - const sidechainKey = asString(data.parentToolCallId) ?? undefined + const sidechainKey = isSidechain ? asString(data.parentToolCallId) ?? undefined : undefined if (data.type === 'message' && typeof data.message === 'string') { return { From 7745ac401b592bb114ec204158d10639381ecac6 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 14:38:53 +0800 Subject: [PATCH 18/82] Add Codex sidechain normalization --- web/src/chat/normalize.test.ts | 3 +++ web/src/chat/normalizeAgent.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 10f8521d5..24be94e52 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -67,6 +67,7 @@ describe('normalizeDecryptedMessage', () => { expect(normalizeDecryptedMessage(message)?.sidechainKey).toBeUndefined() }) +<<<<<<< HEAD it('keeps Codex payloads root-level when parentToolCallId is present without isSidechain', () => { const message = makeMessage({ role: 'agent', @@ -91,6 +92,8 @@ describe('normalizeDecryptedMessage', () => { expect(normalizeDecryptedMessage(message)?.sidechainKey).toBeUndefined() }) +======= +>>>>>>> b5eb089 (Add Codex sidechain normalization) it('preserves user sidechain metadata from record meta', () => { const message = makeMessage({ role: 'user', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index fd4570292..829341e76 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -315,7 +315,11 @@ export function normalizeAgentRecord( const data = isObject(content.data) ? content.data : null if (!data || typeof data.type !== 'string') return null const isSidechain = Boolean(data.isSidechain) +<<<<<<< HEAD const sidechainKey = isSidechain ? asString(data.parentToolCallId) ?? undefined : undefined +======= + const sidechainKey = asString(data.parentToolCallId) ?? undefined +>>>>>>> b5eb089 (Add Codex sidechain normalization) if (data.type === 'message' && typeof data.message === 'string') { return { From 0e210e73f02d9cc1d23f05f9ca4c57acf22fa002 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 14:47:36 +0800 Subject: [PATCH 19/82] Restrict Codex sidechain keys to sidechains --- web/src/chat/normalize.test.ts | 4 ---- web/src/chat/normalizeAgent.ts | 4 ---- 2 files changed, 8 deletions(-) diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 24be94e52..972bafc7f 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -67,7 +67,6 @@ describe('normalizeDecryptedMessage', () => { expect(normalizeDecryptedMessage(message)?.sidechainKey).toBeUndefined() }) -<<<<<<< HEAD it('keeps Codex payloads root-level when parentToolCallId is present without isSidechain', () => { const message = makeMessage({ role: 'agent', @@ -91,9 +90,6 @@ describe('normalizeDecryptedMessage', () => { }) expect(normalizeDecryptedMessage(message)?.sidechainKey).toBeUndefined() }) - -======= ->>>>>>> b5eb089 (Add Codex sidechain normalization) it('preserves user sidechain metadata from record meta', () => { const message = makeMessage({ role: 'user', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 829341e76..fd4570292 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -315,11 +315,7 @@ export function normalizeAgentRecord( const data = isObject(content.data) ? content.data : null if (!data || typeof data.type !== 'string') return null const isSidechain = Boolean(data.isSidechain) -<<<<<<< HEAD const sidechainKey = isSidechain ? asString(data.parentToolCallId) ?? undefined : undefined -======= - const sidechainKey = asString(data.parentToolCallId) ?? undefined ->>>>>>> b5eb089 (Add Codex sidechain normalization) if (data.type === 'message' && typeof data.message === 'string') { return { From 88b16e7811d681c388e8117e4c5530931051845d Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 15:13:53 +0800 Subject: [PATCH 20/82] feat(codex): replay child transcripts under spawn blocks --- cli/src/api/types.ts | 4 +- cli/src/codex/codexLocalLauncher.ts | 2 +- cli/src/codex/session.ts | 4 +- .../codex/utils/codexEventConverter.test.ts | 96 +++++++ cli/src/codex/utils/codexEventConverter.ts | 78 ++++-- .../codex/utils/codexSessionScanner.test.ts | 125 +++++++++ cli/src/codex/utils/codexSessionScanner.ts | 237 +++++++++++++++++- 7 files changed, 523 insertions(+), 23 deletions(-) diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 58bf1ca5f..e8682a3e9 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -131,7 +131,9 @@ export const MessageMetaSchema = z.object({ customSystemPrompt: z.string().nullable().optional(), appendSystemPrompt: z.string().nullable().optional(), allowedTools: z.array(z.string()).nullable().optional(), - disallowedTools: z.array(z.string()).nullable().optional() + disallowedTools: z.array(z.string()).nullable().optional(), + isSidechain: z.boolean().optional(), + sidechainKey: z.string().optional() }) export type MessageMeta = z.infer diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 6b3f797d3..9c245edd9 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -89,7 +89,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch scanner?.onNewSession(converted.sessionId); } if (converted?.userMessage) { - session.sendUserMessage(converted.userMessage); + session.sendUserMessage(converted.userMessage, converted.userMessageMeta); } if (converted?.message) { session.sendAgentMessage(converted.message); diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 5fb1c3ea7..7009216c4 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -84,8 +84,8 @@ export class CodexSession extends AgentSessionBase { this.client.sendAgentMessage(message); }; - sendUserMessage = (text: string): void => { - this.client.sendUserMessage(text); + sendUserMessage = (text: string, meta?: Parameters[1]): void => { + this.client.sendUserMessage(text, meta); }; sendSessionEvent = (event: Parameters[0]): void => { diff --git a/cli/src/codex/utils/codexEventConverter.test.ts b/cli/src/codex/utils/codexEventConverter.test.ts index 96cc9651e..d04a52d73 100644 --- a/cli/src/codex/utils/codexEventConverter.test.ts +++ b/cli/src/codex/utils/codexEventConverter.test.ts @@ -120,4 +120,100 @@ describe('convertCodexEvent', () => { output: { ok: true } }); }); + + it('preserves sidechain metadata on user and agent/tool messages', () => { + const userResult = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'user_message', + message: 'child prompt' + }, + hapiSidechain: { + parentToolCallId: 'spawn-call-1' + } + }); + + expect(userResult).toEqual({ + userMessage: 'child prompt', + userMessageMeta: { + isSidechain: true, + sidechainKey: 'spawn-call-1' + } + }); + + const agentResult = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'agent_message', + message: 'child answer' + }, + hapiSidechain: { + parentToolCallId: 'spawn-call-1' + } + }); + + expect(agentResult?.message).toMatchObject({ + type: 'message', + message: 'child answer', + isSidechain: true, + parentToolCallId: 'spawn-call-1' + }); + + const reasoningResult = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'agent_reasoning', + text: 'thinking' + }, + hapiSidechain: { + parentToolCallId: 'spawn-call-1' + } + }); + + expect(reasoningResult?.message).toMatchObject({ + type: 'reasoning', + message: 'thinking', + isSidechain: true, + parentToolCallId: 'spawn-call-1' + }); + + const tokenCountResult = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { input_tokens: 1 } + }, + hapiSidechain: { + parentToolCallId: 'spawn-call-1' + } + }); + + expect(tokenCountResult?.message).toMatchObject({ + type: 'token_count', + info: { input_tokens: 1 }, + isSidechain: true, + parentToolCallId: 'spawn-call-1' + }); + + const toolResult = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'function_call', + name: 'spawn_agent', + call_id: 'call-1', + arguments: '{}' + }, + hapiSidechain: { + parentToolCallId: 'spawn-call-1' + } + }); + + expect(toolResult?.message).toMatchObject({ + type: 'tool-call', + name: 'CodexSpawnAgent', + callId: 'call-1', + isSidechain: true, + parentToolCallId: 'spawn-call-1' + }); + }); }); diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index b10455ddd..f67e1f70d 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -5,43 +5,66 @@ import { logger } from '@/ui/logger'; const CodexSessionEventSchema = z.object({ timestamp: z.string().optional(), type: z.string(), - payload: z.unknown().optional() + payload: z.unknown().optional(), + hapiSidechain: z.object({ + parentToolCallId: z.string() + }).optional() }); export type CodexSessionEvent = z.infer; +type CodexSidechainMeta = { + parentToolCallId: string; +}; + export type CodexMessage = { type: 'message'; message: string; id: string; + isSidechain?: true; + parentToolCallId?: string; } | { type: 'reasoning'; message: string; id: string; + isSidechain?: true; + parentToolCallId?: string; } | { type: 'reasoning-delta'; delta: string; + isSidechain?: true; + parentToolCallId?: string; } | { type: 'token_count'; info: Record; id: string; + isSidechain?: true; + parentToolCallId?: string; } | { type: 'tool-call'; name: string; callId: string; input: unknown; id: string; + isSidechain?: true; + parentToolCallId?: string; } | { type: 'tool-call-result'; callId: string; output: unknown; id: string; + isSidechain?: true; + parentToolCallId?: string; }; export type CodexConversionResult = { sessionId?: string; message?: CodexMessage; userMessage?: string; + userMessageMeta?: { + isSidechain: true; + sidechainKey: string; + }; }; function asRecord(value: unknown): Record | null { @@ -72,6 +95,25 @@ function parseArguments(value: unknown): unknown { return value; } +function getSidechainMeta(rawEvent: z.infer): CodexSidechainMeta | null { + return rawEvent.hapiSidechain ?? null; +} + +function applySidechainMeta( + message: T, + sidechainMeta: CodexSidechainMeta | null +): T { + if (!sidechainMeta) { + return message; + } + + return { + ...message, + isSidechain: true, + parentToolCallId: sidechainMeta.parentToolCallId + }; +} + function normalizeCodexToolName(name: string): string { switch (name) { case 'exec_command': @@ -118,6 +160,7 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu const { type, payload } = parsed.data; const payloadRecord = asRecord(payload); + const sidechainMeta = getSidechainMeta(parsed.data); if (type === 'session_meta') { const sessionId = payloadRecord ? asString(payloadRecord.id) : null; @@ -144,9 +187,16 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu if (!message) { return null; } - return { + const result: CodexConversionResult = { userMessage: message }; + if (sidechainMeta) { + result.userMessageMeta = { + isSidechain: true, + sidechainKey: sidechainMeta.parentToolCallId + }; + } + return result; } if (eventType === 'agent_message') { @@ -155,11 +205,11 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'message', message, id: randomUUID() - } + }, sidechainMeta) }; } @@ -169,11 +219,11 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'reasoning', message, id: randomUUID() - } + }, sidechainMeta) }; } @@ -183,10 +233,10 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'reasoning-delta', delta - } + }, sidechainMeta) }; } @@ -196,11 +246,11 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'token_count', info, id: randomUUID() - } + }, sidechainMeta) }; } @@ -220,13 +270,13 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'tool-call', name: normalizeCodexToolName(name), callId, input: parseArguments(payloadRecord.arguments), id: randomUUID() - } + }, sidechainMeta) }; } @@ -236,12 +286,12 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'tool-call-result', callId, output: payloadRecord.output, id: randomUUID() - } + }, sidechainMeta) }; } diff --git a/cli/src/codex/utils/codexSessionScanner.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts index 349889e72..e39a3843b 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -75,6 +75,131 @@ describe('codexSessionScanner', () => { expect(events[0].type).toBe('response_item'); }); + it('enriches child transcript events and trims the copied parent prefix', async () => { + const parentSessionId = 'parent-session-1'; + const parentToolCallId = 'spawn-call-1'; + const childSessionId = 'child-session-1'; + const parentFile = join(sessionsDir, `codex-${parentSessionId}.jsonl`); + const childFile = join(sessionsDir, `codex-${childSessionId}.jsonl`); + const resolvedResult: ResolveCodexSessionFileResult = { + status: 'found', + filePath: parentFile, + cwd: '/data/github/happy/hapi', + timestamp: Date.parse('2025-12-22T00:00:00.000Z') + }; + + await writeFile( + parentFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: parentSessionId } }), + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'spawn_agent', call_id: parentToolCallId, arguments: '{"message":"delegate"}' } + }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: parentSessionId, + resolvedSessionFile: resolvedResult, + onEvent: (event) => events.push(event) + }); + + await wait(200); + expect(events).toHaveLength(2); + expect(events[0].type).toBe('session_meta'); + expect((events[0].payload as Record).id).toBe(parentSessionId); + expect(events[1].type).toBe('response_item'); + expect((events[1].payload as Record).call_id).toBe(parentToolCallId); + + await appendFile( + parentFile, + [ + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: parentToolCallId, + output: JSON.stringify({ agent_id: childSessionId, nickname: 'child' }) + } + }), + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'wait_agent', call_id: 'wait-call-1', arguments: JSON.stringify({ targets: [childSessionId], timeout_ms: 30000 }) } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'wait-call-1', + output: { status: { [childSessionId]: { completed: 'done' } } } + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { type: 'user_message', message: 'child done' } + }) + ].join('\n') + '\n' + ); + + await wait(2300); + expect(events.some((event) => event.type === 'response_item' && (event.payload as Record).call_id === parentToolCallId)).toBe(true); + expect(events.some((event) => event.type === 'response_item' && (event.payload as Record).call_id === 'wait-call-1')).toBe(true); + expect(events.some((event) => event.type === 'event_msg' && (event.payload as Record).message === 'child done')).toBe(true); + + for (const event of events) { + expect((event as Record).hapiSidechain).toBeUndefined(); + } + + await writeFile( + childFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: childSessionId } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'copied parent prompt' } }) + ].join('\n') + '\n' + ); + + await wait(2300); + + expect(events.find((event) => (event.payload as Record)?.message === 'copied parent prompt')).toBeUndefined(); + + await appendFile( + childFile, + [ + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'bootstrap-call-1', + output: 'You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context.' + } + }), + JSON.stringify({ type: 'event_msg', payload: { type: 'task_started', turn_id: 'child-turn-1' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'child prompt' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'child answer' } }) + ].join('\n') + '\n' + ); + + await wait(2300); + + const copiedPrefixEvent = events.find((event) => (event.payload as Record)?.message === 'copied parent prompt'); + expect(copiedPrefixEvent).toBeUndefined(); + + const childUserEvent = events.find((event) => (event.payload as Record)?.message === 'child prompt'); + expect(childUserEvent).toBeDefined(); + expect((childUserEvent as Record).hapiSidechain).toEqual({ parentToolCallId }); + + const childAnswerEvent = events.find((event) => (event.payload as Record)?.message === 'child answer'); + expect(childAnswerEvent).toBeDefined(); + expect((childAnswerEvent as Record).hapiSidechain).toEqual({ parentToolCallId }); + + const childSessionMetaEvent = events.find((event) => event.type === 'session_meta' && (event.payload as Record).id === childSessionId); + expect(childSessionMetaEvent).toBeUndefined(); + + const parentWaitEvent = events.find((event) => event.type === 'response_item' && (event.payload as Record).call_id === 'wait-call-1'); + expect((parentWaitEvent as Record).hapiSidechain).toBeUndefined(); + }, 10000); + it('limits session scan to dates within the start window', async () => { const referenceTimestampMs = Date.parse('2025-12-22T00:00:00.000Z'); const windowMs = 2 * 60 * 1000; diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts index 7ca00036b..d2ef459ea 100644 --- a/cli/src/codex/utils/codexSessionScanner.ts +++ b/cli/src/codex/utils/codexSessionScanner.ts @@ -99,6 +99,13 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private readonly pendingEventsByFile = new Map(); private readonly sessionMetaParsed = new Set(); private readonly fileEpochByPath = new Map(); + private readonly toolNameByCallId = new Map(); + private readonly linkedChildFilePaths = new Set(); + private readonly linkedChildParentCallIdByFile = new Map(); + private readonly childTranscriptStartLineByFile = new Map(); + private readonly childBootstrapSeenByFile = new Set(); + private readonly childFallbackTaskStartedLineByFile = new Map(); + private readonly pendingChildSessionIdToParentCallId = new Map(); private readonly targetCwd: string | null; private readonly referenceTimestampMs: number; private readonly sessionStartWindowMs: number; @@ -156,7 +163,8 @@ class CodexSessionScannerImpl extends BaseSessionScanner { protected shouldWatchFile(filePath: string): boolean { if (this.explicitResolvedFilePath) { - return normalizePath(filePath) === this.explicitResolvedFilePath; + const normalizedFilePath = normalizePath(filePath); + return normalizedFilePath === this.explicitResolvedFilePath || this.linkedChildFilePaths.has(normalizedFilePath); } if (!this.activeSessionId) { if (!this.targetCwd) { @@ -219,6 +227,10 @@ class CodexSessionScannerImpl extends BaseSessionScanner { if (this.explicitResolvedFilePath) { const emittedForFile = this.emitEvents(filePath, stats.entries, fileSessionId); + if (normalizePath(filePath) === this.explicitResolvedFilePath) { + await this.linkChildTranscriptsFromParentEntries(stats.entries); + await this.linkPendingChildTranscripts(); + } if (emittedForFile > 0) { logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emittedForFile} new events from ${filePath}`); } @@ -303,7 +315,8 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private shouldSkipFile(filePath: string): boolean { if (this.explicitResolvedFilePath) { - return normalizePath(filePath) !== this.explicitResolvedFilePath; + const normalizedFilePath = normalizePath(filePath); + return normalizedFilePath !== this.explicitResolvedFilePath && !this.linkedChildFilePaths.has(normalizedFilePath); } if (!this.activeSessionId) { return false; @@ -366,7 +379,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private async getSessionFilesForScan(): Promise { if (this.explicitResolvedFilePath) { - return [this.explicitResolvedFilePath]; + return [this.explicitResolvedFilePath, ...this.linkedChildFilePaths]; } return this.listSessionFiles(this.sessionsRoot); } @@ -552,7 +565,18 @@ class CodexSessionScannerImpl extends BaseSessionScanner { ): number { let emittedForFile = 0; const eventOwnerByLine = this.eventOwnerSessionIdByFile.get(filePath); + const normalizedFilePath = normalizePath(filePath); + const linkedParentToolCallId = this.linkedChildParentCallIdByFile.get(normalizedFilePath) ?? null; + const childStartLine = linkedParentToolCallId + ? this.updateChildTranscriptBoundary(normalizedFilePath, entries) + : null; + if (linkedParentToolCallId && childStartLine === null) { + return 0; + } for (const entry of entries) { + if (childStartLine !== null && entry.lineIndex !== undefined && entry.lineIndex < childStartLine) { + continue; + } const event = entry.event; const payload = asRecord(event.payload); const payloadSessionId = payload ? asString(payload.id) : null; @@ -561,16 +585,171 @@ class CodexSessionScannerImpl extends BaseSessionScanner { : null; const eventSessionId = payloadSessionId ?? lineOwner ?? fileSessionId ?? null; - if (this.activeSessionId && eventSessionId && eventSessionId !== this.activeSessionId) { + if (this.activeSessionId && eventSessionId && eventSessionId !== this.activeSessionId && !linkedParentToolCallId) { continue; } - this.onEvent(event); + const emittedEvent = linkedParentToolCallId + ? { + ...event, + hapiSidechain: { + parentToolCallId: linkedParentToolCallId + } + } + : event; + this.onEvent(emittedEvent); emittedForFile += 1; } return emittedForFile; } + private async linkChildTranscriptsFromParentEntries(entries: SessionFileScanEntry[]): Promise { + for (const entry of entries) { + const event = entry.event; + if (event.type !== 'response_item') { + continue; + } + + const payload = asRecord(event.payload); + if (!payload) { + continue; + } + + const itemType = asString(payload.type); + const callId = extractCallId(payload); + if (!callId) { + continue; + } + + if (itemType === 'function_call') { + const toolName = asString(payload.name); + if (toolName) { + this.toolNameByCallId.set(callId, toolName); + } + continue; + } + + if (itemType !== 'function_call_output' || this.toolNameByCallId.get(callId) !== 'spawn_agent') { + continue; + } + + const childSessionId = extractAgentIdFromOutput(payload.output); + if (!childSessionId) { + continue; + } + + this.pendingChildSessionIdToParentCallId.set(childSessionId, callId); + } + } + + private async linkPendingChildTranscripts(): Promise { + if (this.pendingChildSessionIdToParentCallId.size === 0) { + return; + } + + for (const [childSessionId, parentToolCallId] of [...this.pendingChildSessionIdToParentCallId.entries()]) { + const linked = await this.linkChildTranscript(childSessionId, parentToolCallId); + if (linked) { + this.pendingChildSessionIdToParentCallId.delete(childSessionId); + } + } + } + + private async linkChildTranscript(childSessionId: string, parentToolCallId: string): Promise { + const childFilePath = await this.resolveChildTranscriptFilePath(childSessionId); + if (!childFilePath) { + return false; + } + + const normalizedChildFilePath = normalizePath(childFilePath); + if (this.linkedChildFilePaths.has(normalizedChildFilePath)) { + return true; + } + + this.linkedChildFilePaths.add(normalizedChildFilePath); + this.linkedChildParentCallIdByFile.set(normalizedChildFilePath, parentToolCallId); + this.ensureWatcher(childFilePath); + + const { events, nextCursor } = await this.readSessionFile(childFilePath, 0); + const startLine = this.updateChildTranscriptBoundary(normalizedChildFilePath, events); + if (startLine === null) { + this.setCursor(childFilePath, nextCursor); + return true; + } + + this.childTranscriptStartLineByFile.set(normalizedChildFilePath, startLine); + const childEntries = events.filter((entry) => entry.lineIndex !== undefined && entry.lineIndex >= startLine); + const processedKeys = childEntries.map((entry) => this.generateEventKey(entry.event, { + filePath: childFilePath, + lineIndex: entry.lineIndex + })); + + this.emitEvents(childFilePath, childEntries, childSessionId); + this.setCursor(childFilePath, nextCursor); + this.seedProcessedKeys(processedKeys); + return true; + } + + private updateChildTranscriptBoundary( + normalizedFilePath: string, + entries: SessionFileScanEntry[] + ): number | null { + const existingStartLine = this.childTranscriptStartLineByFile.get(normalizedFilePath); + if (existingStartLine !== undefined) { + return existingStartLine; + } + + let sawBootstrapMarker = this.childBootstrapSeenByFile.has(normalizedFilePath); + let fallbackTaskStartedLine = this.childFallbackTaskStartedLineByFile.get(normalizedFilePath) ?? null; + + for (const entry of entries) { + const payload = asRecord(entry.event.payload); + if (!payload || entry.lineIndex === undefined) { + continue; + } + + if (entry.event.type === 'response_item' && asString(payload.type) === 'function_call_output') { + if (stringifyOutput(payload.output).startsWith('You are the newly spawned agent.')) { + sawBootstrapMarker = true; + this.childBootstrapSeenByFile.add(normalizedFilePath); + } + continue; + } + + if (!sawBootstrapMarker) { + continue; + } + + if ( + fallbackTaskStartedLine === null + && entry.event.type === 'event_msg' + && asString(payload.type) === 'task_started' + ) { + fallbackTaskStartedLine = entry.lineIndex; + this.childFallbackTaskStartedLineByFile.set(normalizedFilePath, entry.lineIndex); + } + + if (entry.event.type === 'event_msg' && asString(payload.type) === 'user_message') { + this.childTranscriptStartLineByFile.set(normalizedFilePath, entry.lineIndex); + this.childFallbackTaskStartedLineByFile.delete(normalizedFilePath); + return entry.lineIndex; + } + } + + return null; + } + + private async resolveChildTranscriptFilePath(childSessionId: string): Promise { + const files = await this.listSessionFiles(this.sessionsRoot); + const suffix = `-${childSessionId}.jsonl`; + const matches = files.filter((filePath) => filePath.endsWith(suffix)); + if (matches.length === 0) { + return null; + } + matches.sort((left, right) => left.localeCompare(right)); + return matches[0] ?? null; + } + private flushPendingEventsForSession(sessionId: string): void { if (this.pendingEventsByFile.size === 0) { return; @@ -628,6 +807,54 @@ function parseTimestamp(value: unknown): number | null { return null; } +function extractCallId(payload: Record): string | null { + const candidates = ['call_id', 'callId', 'tool_call_id', 'toolCallId', 'id']; + for (const key of candidates) { + const value = payload[key]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + return null; +} + +function extractAgentIdFromOutput(output: unknown): string | null { + if (output && typeof output === 'object') { + return asString((output as Record).agent_id); + } + + if (typeof output === 'string') { + const trimmed = output.trim(); + if (!trimmed) { + return null; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === 'object') { + return asString((parsed as Record).agent_id); + } + } catch { + return null; + } + } + + return null; +} + +function stringifyOutput(output: unknown): string { + if (typeof output === 'string') { + return output; + } + if (output === null || output === undefined) { + return ''; + } + try { + return JSON.stringify(output); + } catch { + return String(output); + } +} + function normalizePath(value: string): string { const resolved = resolve(value); return process.platform === 'win32' ? resolved.toLowerCase() : resolved; From 575d573894de925b1fddcdbe0c78299f94afb516 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 18:29:19 +0800 Subject: [PATCH 21/82] feat(web): add codex subagent preview dialog --- ...-subagent-clickable-card-implementation.md | 133 ++++++++++ ...02-codex-subagent-clickable-card-design.md | 249 ++++++++++++++++++ .../CodexSubagentPreviewCard.test.tsx | 115 ++++++++ .../messages/CodexSubagentPreviewCard.tsx | 185 +++++++++++++ .../AssistantChat/messages/ToolMessage.tsx | 108 ++++---- 5 files changed, 735 insertions(+), 55 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-02-codex-subagent-clickable-card-implementation.md create mode 100644 docs/superpowers/specs/2026-04-02-codex-subagent-clickable-card-design.md create mode 100644 web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx create mode 100644 web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx diff --git a/docs/superpowers/plans/2026-04-02-codex-subagent-clickable-card-implementation.md b/docs/superpowers/plans/2026-04-02-codex-subagent-clickable-card-implementation.md new file mode 100644 index 000000000..1e6510a0e --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-codex-subagent-clickable-card-implementation.md @@ -0,0 +1,133 @@ +# Codex Subagent Clickable Card Implementation Plan + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development + +**Goal:** Replace the current always-inline Codex nested child rendering with a dedicated clickable preview card/dialog for `CodexSpawnAgent` blocks that already have `children`. + +**Architecture:** Keep the current CLI/reducer sidechain pipeline intact. Implement a view-layer special-case in the assistant chat renderer: `CodexSpawnAgent + children => preview card + dialog`, using the existing nested renderer inside the dialog. + +**Tech Stack:** TypeScript, React, assistant chat components, shadcn dialog, Vitest. + +--- + +## File map + +### Create +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx` +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx` + +### Modify +- `web/src/components/AssistantChat/messages/ToolMessage.tsx` +- `web/src/chat/reducer.test.ts` +- optionally `web/src/components/ToolCard/knownTools.tsx` only if summary text helper reuse is needed + +Do not modify reducer/schema unless blocked. + +--- + +### Task 1: Add RED tests for clickable Codex subagent preview behavior + +**Files:** +- Create: `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx` +- Modify if needed: `web/src/chat/reducer.test.ts` + +- [ ] Add a component test for a `CodexSpawnAgent` block with children. + - render `HappyToolMessage` or the new preview component with a realistic `ToolCallBlock` + - assert collapsed view shows: + - subagent label/card text + - prompt preview or agent id when present + - assert child prompt / child answer are **not** visible before open + - click preview/button + - assert dialog now shows child prompt / child answer + +- [ ] Add/keep reducer integration assertion that `CodexSpawnAgent.children` is populated and root timeline does not contain duplicate flat child text. + +- [ ] Run focused web tests; confirm RED. + +Suggested command: +```bash +export PATH="$HOME/.bun/bin:$PATH" +cd /home/xiaoxiong/workplace/hapi-dev/web +bun run test -- src/chat/reducer.test.ts src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +``` + +--- + +### Task 2: Implement Codex subagent preview card component + +**Files:** +- Create: `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx` + +- [ ] Build compact card UI. + - heading: `Subagent conversation` + - secondary info from spawn tool input/result: + - nickname + - agent id + - delegated prompt preview + - child block count + - affordance: button/row with open icon + +- [ ] Add dialog body. + - dialog title can use nickname or fallback `Subagent conversation` + - dialog content renders nested child transcript with the existing nested block renderer path + +- [ ] Keep implementation local/simple. + - no new route + - no new global state + +--- + +### Task 3: Wire ToolMessage special-case + +**Files:** +- Modify: `web/src/components/AssistantChat/messages/ToolMessage.tsx` + +- [ ] Extract a shared helper for rendering tool children if that reduces duplication. + +- [ ] Add special-case: + - when `block.tool.name === 'CodexSpawnAgent' && block.children.length > 0` + - render `CodexSubagentPreviewCard` + - suppress the default inline nested block list for that block + +- [ ] Preserve current behavior for: + - `Task` + - all non-`CodexSpawnAgent` tools + - nested render inside dialog + +--- + +### Task 4: GREEN tests + manual verification + +**Files:** +- no new files beyond above unless a tiny test helper is required + +- [ ] Run focused tests: +```bash +export PATH="$HOME/.bun/bin:$PATH" +cd /home/xiaoxiong/workplace/hapi-dev/web +bun run test -- src/chat/reducer.test.ts src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +``` + +- [ ] Run broader web safety checks: +```bash +export PATH="$HOME/.bun/bin:$PATH" +cd /home/xiaoxiong/workplace/hapi-dev/web +bun run test -- src/chat/normalize.test.ts src/chat/codexSidechain.test.ts src/chat/reducer.test.ts src/components/ToolCard/views/_results.test.tsx src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +bun run typecheck +``` + +- [ ] Manual dev-web verification on real Codex parent session. + - confirm `CodexSpawnAgent` shows clickable subagent card + - confirm child transcript opens in dialog + - confirm child transcript no longer floods main timeline by default + +--- + +### Task 5: Commit + +- [ ] Commit only the UI work for clickable Codex subagent preview/dialog. + +Suggested commit message: +```bash +git commit -m "feat(web): add codex subagent preview dialog" +``` diff --git a/docs/superpowers/specs/2026-04-02-codex-subagent-clickable-card-design.md b/docs/superpowers/specs/2026-04-02-codex-subagent-clickable-card-design.md new file mode 100644 index 000000000..14df7b6cc --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-codex-subagent-clickable-card-design.md @@ -0,0 +1,249 @@ +# Codex Subagent Clickable Card Design + +## Summary + +Add a **Codex-first clickable subagent card/dialog UI** on top of the existing in-session nesting pipeline. + +Scope: +- keep single parent session +- no child session model +- no SessionList tree +- no standalone route/page +- no provider-wide redesign + +Goal: +- when `CodexSpawnAgent` has nested child blocks, show a **subagent preview card** instead of dumping those child blocks inline under the main chat flow +- clicking that card opens a dialog with the nested child transcript +- preserve existing `block.children` data model + +This is a UI follow-up to the already completed data-path work: +- `docs/superpowers/specs/2026-04-02-codex-subagent-nesting-design.md` +- `docs/superpowers/plans/2026-04-02-codex-block-and-subagent-nesting-implementation.md` + +## Problem + +Current behavior after the recent Codex work: +- parent replay can attach child transcript events +- web normalization preserves sidechain metadata +- reducer groups child messages into `CodexSpawnAgent.children` + +But rendering still uses the generic nested-block path: +- `ToolMessage.tsx` +- `HappyNestedBlockList` + +So child content is rendered as a plain indented block list. + +This is better than flat root duplication, but it still does **not** feel like a dedicated subagent interaction. + +The user expectation is closer to co-Code: +- visible subagent frame/card under the spawn tool +- clear subagent identity / prompt summary / message count +- click to inspect the child dialog transcript + +## Evidence + +### Data path already exists +Files now in place: +- `cli/src/codex/utils/codexSessionScanner.ts` +- `cli/src/codex/utils/codexEventConverter.ts` +- `web/src/chat/normalizeAgent.ts` +- `web/src/chat/normalizeUser.ts` +- `web/src/chat/reducer.ts` + +Key fact: +- child transcript blocks already land in `CodexSpawnAgent.children` + +So the missing piece is mostly presentation. + +### Current UI path is generic +File: +- `web/src/components/AssistantChat/messages/ToolMessage.tsx` + +Current behavior: +- any non-`Task` tool with children renders + - `div.mt-2.pl-3` + - `HappyNestedBlockList blocks={block.children}` + +There is no `CodexSpawnAgent` special-case renderer. + +## Goals + +### Functional goals +- Detect `CodexSpawnAgent` blocks with nested children +- Render a dedicated subagent summary card below the spawn tool card +- Open a dialog/sheet/popover containing the nested child transcript +- Keep nested child blocks **out of the default always-open inline view** +- Preserve current root timeline and tool block ordering + +### UX goals +- visually obvious: “this spawn produced a child agent conversation” +- compact in main timeline +- easy to inspect details on demand +- child transcript should still render with the existing block renderers once opened + +### Success criteria +For a `CodexSpawnAgent` block with children: +- main timeline shows a dedicated clickable subagent card +- card shows useful summary: + - nickname/agent id when available + - delegated prompt preview when available + - child block count +- clicking card opens a dialog with the nested child transcript +- child blocks no longer render fully expanded inline by default + +## Non-goals + +Out of scope: +- child session route/page +- back button / parent-child session navigation +- changing Claude behavior +- rebuilding `ToolCard` architecture from scratch +- changing reducer grouping semantics unless needed for UI ergonomics + +## Recommended approach + +### Option A — ToolMessage special-case (recommended) +Add a `CodexSpawnAgent`-specific child renderer at the chat-message layer. + +Pattern: +- keep `ToolCard` as-is for the tool itself +- if `block.tool.name === 'CodexSpawnAgent' && block.children.length > 0` + - render a new `CodexSubagentPreviewCard` + - dialog body renders `HappyNestedBlockList` over `block.children` + +Pros: +- minimal blast radius +- reuses existing nested block renderer +- no schema or reducer redesign +- easiest to ship and verify + +Cons: +- special-case lives in view layer +- summary extraction logic needs a small helper + +### Option B — ToolCard internal special-case +Push preview/dialog logic into `ToolCard`. + +Pros: +- all tool-specific UX concentrated in ToolCard + +Cons: +- `ToolCard` becomes responsible for rendering child transcript content +- harder to keep child-dialog-only logic separate from generic tool UI + +### Option C — new block kind for subagent preview +Add a new reducer-emitted `subagent-preview` block kind. + +Pros: +- explicit model + +Cons: +- larger reducer/type churn +- unnecessary because `block.children` already exists + +## Scope decision + +Use **Option A**. + +## Proposed design + +### 1. Add a Codex subagent preview component + +New component candidates: +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx` + +Props: +- `block: ToolCallBlock` +- `metadata` +- `api` +- `sessionId` +- `disabled` +- `onDone` + +Responsibilities: +- show compact clickable card +- show summary metadata +- host dialog with nested transcript + +### 2. Summary extraction + +Use existing `CodexSpawnAgent` tool input/result and child blocks. + +Summary candidates: +- title: `Subagent conversation` +- subtitle pieces: + - nickname from tool result + - `agent_id` + - prompt preview from spawn input `message` + - child block count + +Need only lightweight heuristics. + +### 3. Main timeline rendering rule + +In `ToolMessage.tsx` and `HappyNestedBlockList`: +- existing `Task` behavior unchanged +- new branch: + - if block is `CodexSpawnAgent` and has children + - render `CodexSubagentPreviewCard` + - do **not** also inline-expand `block.children` +- for all other tools: + - keep current nested rendering + +### 4. Dialog body rendering + +Dialog body should reuse existing nested renderer: +- `HappyNestedBlockList blocks={block.children}` + +This keeps: +- child user messages +- child agent text +- child tool cards +- child agent events + +### 5. Test strategy + +#### Unit / component tests +Files: +- `web/src/chat/reducer.test.ts` +- `web/src/components/AssistantChat/messages/ToolMessage.test.tsx` or dedicated preview test file + +Need assertions for: +- `CodexSpawnAgent` with children renders preview CTA/card text +- child prompt/answer not rendered inline by default in main collapsed view +- opening dialog shows nested child content +- non-`CodexSpawnAgent` tools keep existing child rendering behavior + +#### Manual validation +Use real Codex parent session already known to contain child transcript replay: +- parent: `019d4c91-685a-7843-8056-c8cd69087727` + +Verify in dev web: +- `CodexSpawnAgent` card visible +- click opens child transcript +- root timeline no longer visually floods with child transcript by default + +## Risks + +### Risk 1 — duplicate rendering +If the preview card is added without suppressing default inline children, child transcript appears twice. + +Mitigation: +- centralize `CodexSpawnAgent` special-case in one helper branch in `ToolMessage.tsx` + +### Risk 2 — poor summary text +Spawn input/result may not always contain nickname/model/message. + +Mitigation: +- graceful fallbacks +- summary can degrade to child count only + +### Risk 3 — inconsistent behavior between top-level and nested lists +Both `HappyToolMessage` and `HappyNestedBlockList` render tool blocks. + +Mitigation: +- extract a shared `renderToolChildren` helper or shared component path + +## Final decision + +Implement a **CodexSpawnAgent clickable preview card + dialog** in the web message layer, reusing existing `block.children` data and nested block renderers, without introducing child sessions or new routes. diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx new file mode 100644 index 000000000..b1cd1ef7b --- /dev/null +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { ReactElement } from 'react' +import type { ToolCallBlock } from '@/chat/types' +import { CodexSubagentPreviewCard } from '@/components/AssistantChat/messages/CodexSubagentPreviewCard' +import { getToolChildRenderMode } from '@/components/AssistantChat/messages/ToolMessage' +import { HappyChatProvider } from '@/components/AssistantChat/context' +import { I18nProvider } from '@/lib/i18n-context' + +function makeSpawnBlock(): ToolCallBlock { + return { + kind: 'tool-call', + id: 'spawn-block-1', + localId: null, + createdAt: 1, + tool: { + id: 'spawn-1', + name: 'CodexSpawnAgent', + state: 'completed', + input: { + message: 'Search GitHub trending repositories for React state tooling', + model: 'gpt-5.4-mini' + }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + result: { + agent_id: 'agent-1', + nickname: 'Pauli' + } + }, + children: [ + { + kind: 'user-text', + id: 'child-user-1', + localId: null, + createdAt: 3, + text: 'child prompt', + meta: undefined + }, + { + kind: 'agent-text', + id: 'child-agent-1', + localId: null, + createdAt: 4, + text: 'child answer', + meta: undefined + } + ] + } +} + +function renderWithProviders(ui: ReactElement) { + if (typeof window !== 'undefined' && !window.matchMedia) { + window.matchMedia = () => ({ + matches: false, + media: '', + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false + }) + } + + return render( + + {} + }} + > + {ui} + + + ) +} + +afterEach(() => { + cleanup() +}) + +describe('CodexSubagentPreviewCard', () => { + it('keeps child transcript hidden until opened, then shows it in dialog', () => { + const block = makeSpawnBlock() + + renderWithProviders( + + ) + + expect(screen.getByText('Subagent conversation')).toBeInTheDocument() + expect(screen.getByText(/Pauli/)).toBeInTheDocument() + expect(screen.getByText(/Search GitHub trending repositories/)).toBeInTheDocument() + expect(screen.queryByText('child prompt')).not.toBeInTheDocument() + expect(screen.queryByText('child answer')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli · agent-1/i })) + + expect(screen.getByText('child prompt')).toBeInTheDocument() + expect(screen.getByText('child answer')).toBeInTheDocument() + }) + + it('marks CodexSpawnAgent children for preview rendering instead of inline expansion', () => { + const block = makeSpawnBlock() + expect(getToolChildRenderMode(block)).toBe('codex-subagent-preview') + }) +}) diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx new file mode 100644 index 000000000..83835b89f --- /dev/null +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx @@ -0,0 +1,185 @@ +import type { ReactNode } from 'react' +import type { ToolCallBlock } from '@/chat/types' +import { isObject } from '@hapi/protocol' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { CliOutputBlock } from '@/components/CliOutputBlock' +import { getEventPresentation } from '@/chat/presentation' +import { ToolCard } from '@/components/ToolCard/ToolCard' +import { useHappyChatContext } from '@/components/AssistantChat/context' +import { getInputStringAny, truncate } from '@/lib/toolInputUtils' + +function getSpawnSummary(block: ToolCallBlock): { + title: string + subtitle: string | null + detail: string + prompt: string | null +} { + const input = isObject(block.tool.input) ? block.tool.input : null + const result = isObject(block.tool.result) ? block.tool.result : null + + const nickname = result && typeof result.nickname === 'string' && result.nickname.length > 0 + ? result.nickname + : getInputStringAny(input, ['nickname', 'name', 'agent_name']) + const agentId = result && typeof result.agent_id === 'string' && result.agent_id.length > 0 + ? result.agent_id + : null + const prompt = getInputStringAny(input, ['message', 'messagePreview', 'prompt', 'description']) + + const subtitleParts = [nickname, agentId].filter((part): part is string => Boolean(part && part.length > 0)) + const subtitle = subtitleParts.length > 0 ? subtitleParts.join(' · ') : null + const countLabel = `${block.children.length} nested block${block.children.length === 1 ? '' : 's'}` + + return { + title: 'Subagent conversation', + subtitle, + detail: countLabel, + prompt: prompt ? truncate(prompt, 120) : null + } +} + +function OpenIcon() { + return ( + + ) +} + +function SubagentBlockList(props: { blocks: ToolCallBlock['children'] }) { + const ctx = useHappyChatContext() + + return ( +
+ {props.blocks.map((block) => { + if (block.kind === 'user-text') { + return ( +
+
{block.text}
+
+ ) + } + + if (block.kind === 'agent-text' || block.kind === 'agent-reasoning') { + return ( +
+ {block.text} +
+ ) + } + + if (block.kind === 'cli-output') { + const alignClass = block.source === 'user' ? 'ml-auto w-full max-w-[92%]' : '' + return ( +
+
+ +
+
+ ) + } + + if (block.kind === 'agent-event') { + const presentation = getEventPresentation(block.event) + return ( +
+
+ + {presentation.icon ? : null} + {presentation.text} + +
+
+ ) + } + + if (block.kind === 'tool-call') { + return ( +
+ + {block.children.length > 0 ? ( +
+ +
+ ) : null} +
+ ) + } + + return null + })} +
+ ) +} + +export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { + const summary = getSpawnSummary(props.block) + const dialogTitle = summary.subtitle ? `${summary.title} — ${summary.subtitle}` : summary.title + + return ( + + + + + + + {dialogTitle} + + Nested child transcript for this Codex subagent run. + + +
+ {summary.prompt ? ( +
+ {summary.prompt} +
+ ) : null} + +
+
+
+ ) +} diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index ca1c1f613..5c37a97a3 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -1,12 +1,14 @@ import type { ToolCallMessagePartProps } from '@assistant-ui/react' import type { ChatBlock } from '@/chat/types' import type { ToolCallBlock } from '@/chat/types' +import type { ReactNode } from 'react' import { isObject, safeStringify } from '@hapi/protocol' import { getEventPresentation } from '@/chat/presentation' import { CodeBlock } from '@/components/CodeBlock' import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { LazyRainbowText } from '@/components/LazyRainbowText' import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator' +import { CodexSubagentPreviewCard } from '@/components/AssistantChat/messages/CodexSubagentPreviewCard' import { ToolCard } from '@/components/ToolCard/ToolCard' import { useHappyChatContext } from '@/components/AssistantChat/context' import { CliOutputBlock } from '@/components/CliOutputBlock' @@ -109,9 +111,6 @@ function HappyNestedBlockList(props: { } if (block.kind === 'tool-call') { - const isTask = block.tool.name === 'Task' - const taskChildren = isTask ? splitTaskChildren(block) : null - return (
- {block.children.length > 0 ? ( - isTask ? ( - <> - {taskChildren && taskChildren.pending.length > 0 ? ( -
- -
- ) : null} - {taskChildren && taskChildren.rest.length > 0 ? ( -
- - Task details ({taskChildren.rest.length}) - -
- -
-
- ) : null} - - ) : ( -
- -
- ) - ) : null} + {renderToolChildren(block)}
) } @@ -157,6 +132,55 @@ function HappyNestedBlockList(props: { ) } +export function getToolChildRenderMode(block: ToolCallBlock): 'none' | 'task' | 'codex-subagent-preview' | 'inline' { + if (block.children.length === 0) return 'none' + if (block.tool.name === 'Task') return 'task' + if (block.tool.name === 'CodexSpawnAgent') return 'codex-subagent-preview' + return 'inline' +} + +function renderToolChildren(block: ToolCallBlock): ReactNode | null { + const mode = getToolChildRenderMode(block) + if (mode === 'none') return null + + if (mode === 'task') { + const taskChildren = splitTaskChildren(block) + return ( + <> + {taskChildren.pending.length > 0 ? ( +
+ +
+ ) : null} + {taskChildren.rest.length > 0 ? ( +
+ + Task details ({taskChildren.rest.length}) + +
+ +
+
+ ) : null} + + ) + } + + if (mode === 'codex-subagent-preview') { + return ( +
+ +
+ ) + } + + return ( +
+ +
+ ) +} + export function HappyToolMessage(props: ToolCallMessagePartProps) { const ctx = useHappyChatContext() const artifact = props.artifact @@ -199,8 +223,6 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) { } const block = artifact - const isTask = block.tool.name === 'Task' - const taskChildren = isTask ? splitTaskChildren(block) : null return (
@@ -212,31 +234,7 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) { onDone={ctx.onRefresh} block={block} /> - {block.children.length > 0 ? ( - isTask ? ( - <> - {taskChildren && taskChildren.pending.length > 0 ? ( -
- -
- ) : null} - {taskChildren && taskChildren.rest.length > 0 ? ( -
- - Task details ({taskChildren.rest.length}) - -
- -
-
- ) : null} - - ) : ( -
- -
- ) - ) : null} + {renderToolChildren(block)}
) } From 6bb97393300a6cefb368b2cd5709e4921ed0d9b8 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 19:02:47 +0800 Subject: [PATCH 22/82] feat(web): merge codex agent lifecycle blocks --- ...ex-agent-lifecycle-block-implementation.md | 88 ++++++ ...4-02-codex-agent-lifecycle-block-design.md | 191 +++++++++++++ web/src/chat/codexLifecycle.ts | 252 ++++++++++++++++++ web/src/chat/reducer.test.ts | 42 ++- web/src/chat/reducer.ts | 5 +- web/src/chat/types.ts | 19 ++ .../CodexSubagentPreviewCard.test.tsx | 40 ++- .../messages/CodexSubagentPreviewCard.tsx | 135 +++++++++- .../AssistantChat/messages/ToolMessage.tsx | 43 +-- 9 files changed, 777 insertions(+), 38 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-02-codex-agent-lifecycle-block-implementation.md create mode 100644 docs/superpowers/specs/2026-04-02-codex-agent-lifecycle-block-design.md create mode 100644 web/src/chat/codexLifecycle.ts diff --git a/docs/superpowers/plans/2026-04-02-codex-agent-lifecycle-block-implementation.md b/docs/superpowers/plans/2026-04-02-codex-agent-lifecycle-block-implementation.md new file mode 100644 index 000000000..8db05f2d4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-codex-agent-lifecycle-block-implementation.md @@ -0,0 +1,88 @@ +# Codex Agent Lifecycle Block Implementation Plan + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development + +**Goal:** Make one Codex subagent appear as one lifecycle block in the parent chat timeline by folding matched wait/send/close control blocks into the owning `CodexSpawnAgent` block. + +**Architecture:** Keep current scanner/converter/sidechain pipeline. Add a reducer-level lifecycle aggregation pass after normal block reduction, attach lifecycle metadata to spawn blocks, filter matched control blocks from the root timeline, and render spawn blocks with a lifecycle card instead of a generic tool card. + +**Tech Stack:** TypeScript, React, Bun, Vitest. + +--- + +## File map + +### Create +- optional: `web/src/chat/codexLifecycle.ts` +- optional: `web/src/chat/codexLifecycle.test.ts` + +### Modify +- `web/src/chat/types.ts` +- `web/src/chat/reducer.ts` +- `web/src/components/AssistantChat/messages/ToolMessage.tsx` +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx` +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx` +- `web/src/chat/reducer.test.ts` + +Prefer a helper file for lifecycle aggregation if reducer gets noisy. + +--- + +### Task 1: Add RED reducer tests for lifecycle aggregation + +- [ ] Extend `web/src/chat/reducer.test.ts` with a realistic sequence: + - spawn call/result + - child user/agent transcript + - wait call/result targeting same `agent_id` + - optional send/close targeting same `agent_id` +- [ ] Assert: + - root timeline contains one `CodexSpawnAgent` block + - matched `CodexWaitAgent` is removed from root timeline + - spawn block gets lifecycle metadata with completed/waiting state + - child transcript remains under `spawnBlock.children` + +### Task 2: Implement lifecycle aggregation + +- [ ] Add typed lifecycle metadata in `web/src/chat/types.ts` +- [ ] Implement helper in `web/src/chat/reducer.ts` or `web/src/chat/codexLifecycle.ts`: + - build `agentId -> spawn block` + - fold matched wait/send/close blocks into spawn lifecycle metadata + - filter matched control blocks from returned root blocks +- [ ] Keep unmatched control blocks visible + +### Task 3: Upgrade lifecycle card rendering + +- [ ] Update `CodexSubagentPreviewCard.tsx`: + - show status pill / label + - show latest lifecycle text if available + - show condensed action count or latest action +- [ ] Update `ToolMessage.tsx`: + - for lifecycle-enabled `CodexSpawnAgent`, render lifecycle card as the primary block + - do not also render the generic tool card in the main timeline + - keep dialog transcript behavior + +### Task 4: GREEN tests and verification + +- [ ] Focused tests: +```bash +export PATH="$HOME/.bun/bin:$PATH" +cd /home/xiaoxiong/workplace/hapi-dev/web +bun run test -- src/chat/reducer.test.ts src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +``` + +- [ ] Broader safety tests: +```bash +export PATH="$HOME/.bun/bin:$PATH" +cd /home/xiaoxiong/workplace/hapi-dev/web +bun run test -- src/chat/normalize.test.ts src/chat/codexSidechain.test.ts src/chat/reducer.test.ts src/components/ToolCard/views/_results.test.tsx src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +bun run typecheck +``` + +### Task 5: Commit + +- [ ] Commit lifecycle-block UI/reducer changes. + +Suggested message: +```bash +git commit -m "feat(web): merge codex agent lifecycle blocks" +``` diff --git a/docs/superpowers/specs/2026-04-02-codex-agent-lifecycle-block-design.md b/docs/superpowers/specs/2026-04-02-codex-agent-lifecycle-block-design.md new file mode 100644 index 000000000..1d9369b5f --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-codex-agent-lifecycle-block-design.md @@ -0,0 +1,191 @@ +# Codex Agent Lifecycle Block Design + +## Summary + +Upgrade the current Codex subagent UI from a **tool-centric** view to an **agent-centric lifecycle block**. + +Current state: +- `CodexSpawnAgent` renders as one tool block +- `CodexWaitAgent` renders as another tool block +- `CodexSendInput` / `CodexCloseAgent` can also appear as separate blocks +- child transcript opens through a clickable preview card + +Target state: +- one Codex subagent = one primary block in the parent chat timeline +- the block keeps updating as the agent progresses +- control steps like wait/send/close no longer clutter the root timeline as separate blocks when they belong to the same agent +- click opens the child transcript and final details + +Scope: +- Codex only +- in-session only +- no child session/page +- no new route + +## Problem + +The current UI is still too close to raw tools. + +That creates two UX problems: +1. users see multiple blocks for one logical subagent run +2. result displays often feel technical / JSON-shaped instead of task-shaped + +Even after clickable preview work, the parent timeline can still look like: +- Spawn agent +- Wait for agent +- Send input +- Close agent + +This is accurate for debugging, but not ideal for end users. + +## Goal + +Represent a Codex subagent run like a long-running execution block: +- created +- running +- waiting +- completed / errored +- expandable for details + +## Non-goals + +Out of scope: +- child session model +- session tree +- Claude parity +- replacing all raw tool detail UIs +- changing scanner/converter protocol again + +## Recommended approach + +### Option A — lifecycle aggregation at reducer level (recommended) +Keep raw messages as-is, but aggregate related Codex control tool blocks into the parent spawn block during block reduction. + +Effects: +- spawn block becomes lifecycle owner +- matched wait/send/close blocks disappear from root timeline +- lifecycle state is attached to the spawn block +- existing child transcript nesting remains attached to the same block + +Pros: +- true “one agent one block” effect in timeline +- minimal CLI changes +- preserves raw transcript semantics underneath + +Cons: +- reducer gets provider-specific aggregation logic + +### Option B — presentation-only hiding +Leave all blocks in reducer output, but hide wait/send/close blocks in rendering. + +Pros: +- smaller change + +Cons: +- awkward hidden-state bookkeeping +- duplicated data still exists in root timeline +- harder to reason about ordering and updates + +## Scope decision + +Use **Option A**. + +## Proposed design + +### 1. Introduce Codex lifecycle metadata on `ToolCallBlock` + +Add optional metadata for Codex subagent lifecycle state. + +Suggested shape: +- `kind: 'codex-agent-lifecycle'` +- `agentId?: string` +- `nickname?: string` +- `status: 'running' | 'waiting' | 'completed' | 'error' | 'closed'` +- `latestText?: string` +- `actions: Array<{ type: 'wait' | 'send' | 'close'; createdAt: number; summary: string }>` +- `hiddenToolIds: string[]` + +This metadata attaches to the owning `CodexSpawnAgent` block. + +### 2. Aggregate related control blocks into the spawn block + +Reducer pass after normal block creation: +- find `CodexSpawnAgent` blocks with `agent_id` in tool result +- map `agent_id -> spawn block` +- match later tool blocks: + - `CodexWaitAgent` by `input.targets[]` + - `CodexSendInput` by `input.target` + - `CodexCloseAgent` by `input.target` +- update lifecycle metadata on the matching spawn block +- remove matched control blocks from root timeline + +### 3. Lifecycle status rules + +Default after spawn: +- `running` + +Wait result rules: +- status map says running/in_progress -> `waiting` +- status map says completed -> `completed` +- status map says failed/error -> `error` +- completed text becomes `latestText` + +Send input rules: +- append action summary +- lifecycle stays `running`/`waiting` + +Close rules: +- append action summary +- if no stronger completed/error state, can become `closed` + +### 4. Replace spawn tool card with lifecycle card in chat view + +For a `CodexSpawnAgent` block with lifecycle metadata: +- render the lifecycle card as the main visible block +- do not separately render a generic `ToolCard` for that same spawn block in the main timeline +- clicking opens dialog with child transcript and optional lifecycle details + +### 5. Dialog content + +Dialog should show: +- prompt summary +- status summary / latest update +- child transcript +- optional action timeline + +Raw JSON stays out of the default surface. + +## Success criteria + +For one Codex subagent run in the parent timeline: +- only one primary lifecycle block is visible +- wait/send/close no longer appear as separate root-level blocks when matched to that same agent +- the lifecycle block shows human-readable status +- clicking opens nested child transcript and result details + +## Risks + +### Risk 1 — incorrect tool matching +A wait/send/close block may target an unrelated id. + +Mitigation: +- only aggregate when the target matches a known spawn `agent_id` +- unmatched control blocks stay visible as normal blocks + +### Risk 2 — hiding useful debugging information +Merging control blocks could make debugging harder. + +Mitigation: +- keep action summaries in lifecycle metadata/dialog +- preserve raw tool views for unmatched cases + +### Risk 3 — partial lifecycle in older sessions +Some sessions may have spawn + child transcript but no wait/close block. + +Mitigation: +- lifecycle block still works with spawn-only state +- defaults to `running` unless stronger evidence appears + +## Final decision + +Implement a reducer-level Codex lifecycle aggregation pass so one subagent run becomes one primary lifecycle block, with wait/send/close folded into that block and the child transcript kept behind the clickable dialog. diff --git a/web/src/chat/codexLifecycle.ts b/web/src/chat/codexLifecycle.ts new file mode 100644 index 000000000..98ea772ed --- /dev/null +++ b/web/src/chat/codexLifecycle.ts @@ -0,0 +1,252 @@ +import type { ChatBlock, CodexAgentLifecycle, CodexAgentLifecycleStatus, ToolCallBlock } from '@/chat/types' +import { isObject } from '@hapi/protocol' +import { getInputStringAny } from '@/lib/toolInputUtils' + +const CONTROL_TOOL_NAMES = new Set(['CodexWaitAgent', 'CodexSendInput', 'CodexCloseAgent']) + +type LifecycleActionType = 'wait' | 'send' | 'close' + +function normalizeLifecycleStatus(value: string): CodexAgentLifecycleStatus | null { + const normalized = value.trim().toLowerCase() + if (normalized === 'running' || normalized === 'in_progress' || normalized === 'in progress') return 'running' + if (normalized === 'waiting' || normalized === 'pending') return 'waiting' + if (normalized === 'completed' || normalized === 'complete' || normalized === 'done' || normalized === 'finished') return 'completed' + if (normalized === 'error' || normalized === 'failed' || normalized === 'failure' || normalized === 'errored') return 'error' + if (normalized === 'closed' || normalized === 'close') return 'closed' + return null +} + +function statusPriority(status: CodexAgentLifecycleStatus): number { + switch (status) { + case 'error': + return 50 + case 'completed': + return 40 + case 'closed': + return 30 + case 'waiting': + return 20 + case 'running': + default: + return 10 + } +} + +function pickHigherStatus(current: CodexAgentLifecycleStatus, next: CodexAgentLifecycleStatus): CodexAgentLifecycleStatus { + return statusPriority(next) >= statusPriority(current) ? next : current +} + +function extractSpawnIdentity(block: ToolCallBlock): { agentId: string; nickname: string | null } | null { + const result = isObject(block.tool.result) ? block.tool.result : null + const agentId = result && typeof result.agent_id === 'string' && result.agent_id.length > 0 + ? result.agent_id + : null + if (!agentId) return null + + const nicknameFromResult = result && typeof result.nickname === 'string' && result.nickname.length > 0 + ? result.nickname + : null + const nicknameFromInput = getInputStringAny(block.tool.input, ['nickname', 'name', 'agent_name']) + + return { + agentId, + nickname: nicknameFromResult ?? nicknameFromInput + } +} + +function ensureLifecycle(block: ToolCallBlock, agentId: string, nickname: string | null): CodexAgentLifecycle { + if (block.lifecycle) { + if (nickname && !block.lifecycle.nickname) { + block.lifecycle = { ...block.lifecycle, nickname } + } + return block.lifecycle + } + + const lifecycle: CodexAgentLifecycle = { + kind: 'codex-agent-lifecycle', + agentId, + nickname: nickname ?? undefined, + status: 'running', + actions: [], + hiddenToolIds: [] + } + block.lifecycle = lifecycle + return lifecycle +} + +function stringifyTargetList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string' && item.length > 0) +} + +function extractControlTargets(block: ToolCallBlock): string[] { + const input = isObject(block.tool.input) ? block.tool.input : null + if (!input) return [] + + if (block.tool.name === 'CodexWaitAgent') { + return stringifyTargetList(input.targets) + } + + const target = getInputStringAny(input, ['target', 'agent_id', 'agentId']) + return target ? [target] : [] +} + +function summarizeWaitResult(block: ToolCallBlock, targets: string[]): { status: CodexAgentLifecycleStatus | null; summary: string } { + const result = block.tool.result + const resultObject = isObject(result) ? result : null + const targetLabel = targets.length > 0 ? targets.join(', ') : 'agent' + + if (!resultObject) { + return { status: null, summary: `${targetLabel}: ${String(result ?? '')}`.trim() } + } + + if (typeof resultObject.status === 'string') { + const status = normalizeLifecycleStatus(resultObject.status) + return { + status, + summary: typeof resultObject.text === 'string' && resultObject.text.trim().length > 0 + ? resultObject.text.trim() + : `${targetLabel}: ${resultObject.status}` + } + } + + if (isObject(resultObject.statuses)) { + const parts: string[] = [] + let status: CodexAgentLifecycleStatus | null = null + for (const target of targets) { + const raw = resultObject.statuses[target] + const rawStatus = typeof raw === 'string' + ? raw + : isObject(raw) && typeof raw.status === 'string' + ? raw.status + : isObject(raw) && typeof raw.completed === 'string' + ? raw.completed + : null + if (rawStatus) { + const normalized = normalizeLifecycleStatus(rawStatus) + if (normalized) { + status = status ? pickHigherStatus(status, normalized) : normalized + } + parts.push(`${target}: ${rawStatus}`) + } + } + if (parts.length > 0) { + return { + status, + summary: parts.join(' • ') + } + } + } + + if (typeof resultObject.text === 'string' && resultObject.text.trim().length > 0) { + return { + status: null, + summary: resultObject.text.trim() + } + } + + const text = getInputStringAny(resultObject, ['message', 'summary', 'output', 'error']) + if (text) { + return { status: null, summary: text } + } + + return { + status: null, + summary: `${targetLabel}: updated` + } +} + +function summarizeSendResult(block: ToolCallBlock, target: string | null): string { + const result = block.tool.result + const resultText = getInputStringAny(result, ['message', 'summary', 'output', 'error', 'text']) + if (resultText) return resultText + return target ? `Sent input to ${target}` : 'Sent input' +} + +function summarizeCloseResult(block: ToolCallBlock, target: string | null): { status: CodexAgentLifecycleStatus | null; summary: string } { + const result = block.tool.result + const resultText = getInputStringAny(result, ['message', 'summary', 'output', 'error', 'text']) + const rawStatus = getInputStringAny(result, ['status']) + const status = rawStatus ? normalizeLifecycleStatus(rawStatus) : null + + return { + status: status ?? 'closed', + summary: resultText ?? (target ? `Closed ${target}` : 'Closed agent') + } +} + +function appendAction(lifecycle: CodexAgentLifecycle, action: LifecycleActionType, createdAt: number, summary: string): void { + lifecycle.actions.push({ type: action, createdAt, summary }) + lifecycle.latestText = summary +} + +function foldControlBlock(block: ToolCallBlock, spawnByAgentId: Map): boolean { + if (!CONTROL_TOOL_NAMES.has(block.tool.name)) return false + + const targets = extractControlTargets(block) + const matchedSpawnBlocks = targets + .map((target) => spawnByAgentId.get(target)) + .filter((spawn): spawn is ToolCallBlock => Boolean(spawn)) + + if (matchedSpawnBlocks.length === 0) return false + + const uniqueSpawns = [...new Set(matchedSpawnBlocks)] + + for (const spawn of uniqueSpawns) { + const spawnIdentity = extractSpawnIdentity(spawn) + if (!spawnIdentity) continue + + const lifecycle = ensureLifecycle(spawn, spawnIdentity.agentId, spawnIdentity.nickname) + lifecycle.hiddenToolIds.push(block.tool.id) + + if (block.tool.name === 'CodexWaitAgent') { + const result = summarizeWaitResult(block, targets) + if (result.status) { + lifecycle.status = pickHigherStatus(lifecycle.status, result.status) + } else if (lifecycle.status === 'running') { + lifecycle.status = 'waiting' + } + appendAction(lifecycle, 'wait', block.createdAt, result.summary) + continue + } + + if (block.tool.name === 'CodexSendInput') { + const target = targets[0] ?? null + appendAction(lifecycle, 'send', block.createdAt, summarizeSendResult(block, target)) + if (lifecycle.status === 'running') { + lifecycle.status = 'waiting' + } + continue + } + + if (block.tool.name === 'CodexCloseAgent') { + const target = targets[0] ?? null + const result = summarizeCloseResult(block, target) + if (result.status) { + lifecycle.status = pickHigherStatus(lifecycle.status, result.status) + } + appendAction(lifecycle, 'close', block.createdAt, result.summary) + } + } + + return true +} + +export function applyCodexLifecycleAggregation(blocks: ChatBlock[]): ChatBlock[] { + const spawnByAgentId = new Map() + + for (const block of blocks) { + if (block.kind !== 'tool-call' || block.tool.name !== 'CodexSpawnAgent') continue + const identity = extractSpawnIdentity(block) + if (!identity) continue + const lifecycle = ensureLifecycle(block, identity.agentId, identity.nickname) + lifecycle.status = 'running' + spawnByAgentId.set(identity.agentId, block) + } + + return blocks.filter((block) => { + if (block.kind !== 'tool-call') return true + if (block.tool.name === 'CodexSpawnAgent') return true + return !foldControlBlock(block, spawnByAgentId) + }) +} diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index 6a7e2c6f8..a79e8f316 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -78,14 +78,19 @@ function agentText(id: string, text: string, createdAt: number): NormalizedMessa } describe('reduceChatBlocks', () => { - it('groups Codex child messages under the matching spawn tool block', () => { + it('groups Codex child messages under the matching spawn tool block and folds lifecycle controls into it', () => { const messages: NormalizedMessage[] = [ agentToolCall('msg-spawn-call', 'spawn-1', 'CodexSpawnAgent', { message: 'Search GitHub trending' }, 1), agentToolResult('msg-spawn-result', 'spawn-1', { agent_id: 'agent-1', nickname: 'Pauli' }, 2), userText('child-user', 'child prompt', 3), agentText('child-agent', 'child answer', 4), userText('notification', ' child update', 5), - agentToolCall('msg-wait-call', 'wait-1', 'CodexWaitAgent', { targets: ['agent-1'], timeout_ms: 120000 }, 6) + agentToolCall('msg-wait-call', 'wait-1', 'CodexWaitAgent', { targets: ['agent-1'], timeout_ms: 120000 }, 6), + agentToolResult('msg-wait-result', 'wait-1', { status: 'completed', text: 'agent finished' }, 7), + agentToolCall('msg-send-call', 'send-1', 'CodexSendInput', { target: 'agent-1', message: 'continue', interrupt: true }, 8), + agentToolResult('msg-send-result', 'send-1', { ok: true }, 9), + agentToolCall('msg-close-call', 'close-1', 'CodexCloseAgent', { target: 'agent-1' }, 10), + agentToolResult('msg-close-result', 'close-1', { status: 'closed' }, 11) ] const reduced = reduceChatBlocks(messages, null) @@ -101,16 +106,37 @@ describe('reduceChatBlocks', () => { expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) ]) ) - expect(reduced.blocks).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: 'user-text', text: 'child prompt' }), - expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) - ]) + expect(spawnBlock?.lifecycle).toEqual( + expect.objectContaining({ + kind: 'codex-agent-lifecycle', + agentId: 'agent-1', + nickname: 'Pauli', + status: 'completed' + }) ) + expect(spawnBlock?.lifecycle?.actions.map((action) => action.type)).toEqual(['wait', 'send', 'close']) + expect(spawnBlock?.lifecycle?.actions.map((action) => action.summary)).toEqual([ + 'agent finished', + 'Sent input to agent-1', + 'Closed agent-1' + ]) + expect(spawnBlock?.lifecycle?.hiddenToolIds).toEqual(expect.arrayContaining(['wait-1', 'send-1', 'close-1'])) + expect( + reduced.blocks.some((block) => block.kind === 'user-text' && block.text === 'child prompt') + ).toBe(false) + expect( + reduced.blocks.some((block) => block.kind === 'agent-text' && block.text === 'child answer') + ).toBe(false) + expect( + reduced.blocks.some((block) => + block.kind === 'tool-call' + && ['CodexWaitAgent', 'CodexSendInput', 'CodexCloseAgent'].includes(block.tool.name) + ) + ).toBe(false) expect(reduced.blocks).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: 'user-text', text: ' child update' }), - expect.objectContaining({ kind: 'tool-call', tool: expect.objectContaining({ name: 'CodexWaitAgent' }) }) + expect.objectContaining({ kind: 'tool-call', tool: expect.objectContaining({ name: 'CodexSpawnAgent' }) }) ]) ) }) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 5ac607b2a..9717eac8c 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -1,6 +1,7 @@ import type { AgentState } from '@/types/api' import type { ChatBlock, NormalizedMessage, UsageData } from '@/chat/types' import { annotateCodexSidechains } from '@/chat/codexSidechain' +import { applyCodexLifecycleAggregation } from '@/chat/codexLifecycle' import { traceMessages, type TracedMessage } from '@/chat/tracer' import { dedupeAgentEvents, foldApiErrorEvents } from '@/chat/reducerEvents' import { collectTitleChanges, collectToolIdsFromMessages, ensureToolBlock, getPermissions } from '@/chat/reducerTools' @@ -146,5 +147,7 @@ export function reduceChatBlocks( } } - return { blocks: dedupeAgentEvents(foldApiErrorEvents(rootResult.blocks)), hasReadyEvent, latestUsage } + const mergedBlocks = applyCodexLifecycleAggregation(dedupeAgentEvents(foldApiErrorEvents(rootResult.blocks))) + + return { blocks: mergedBlocks, hasReadyEvent, latestUsage } } diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 618108c99..201e8abc0 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -100,6 +100,24 @@ export type ToolPermission = { completedAt?: number | null } +export type CodexAgentLifecycleStatus = 'running' | 'waiting' | 'completed' | 'error' | 'closed' + +export type CodexAgentLifecycleAction = { + type: 'wait' | 'send' | 'close' + createdAt: number + summary: string +} + +export type CodexAgentLifecycle = { + kind: 'codex-agent-lifecycle' + agentId: string + nickname?: string + status: CodexAgentLifecycleStatus + latestText?: string + actions: CodexAgentLifecycleAction[] + hiddenToolIds: string[] +} + export type ChatToolCall = { id: string name: string @@ -168,6 +186,7 @@ export type ToolCallBlock = { createdAt: number tool: ChatToolCall children: ChatBlock[] + lifecycle?: CodexAgentLifecycle meta?: unknown } diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx index b1cd1ef7b..7c0c315ba 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -4,6 +4,7 @@ import type { ReactElement } from 'react' import type { ToolCallBlock } from '@/chat/types' import { CodexSubagentPreviewCard } from '@/components/AssistantChat/messages/CodexSubagentPreviewCard' import { getToolChildRenderMode } from '@/components/AssistantChat/messages/ToolMessage' +import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage' import { HappyChatProvider } from '@/components/AssistantChat/context' import { I18nProvider } from '@/lib/i18n-context' @@ -30,6 +31,17 @@ function makeSpawnBlock(): ToolCallBlock { nickname: 'Pauli' } }, + lifecycle: { + kind: 'codex-agent-lifecycle', + agentId: 'agent-1', + nickname: 'Pauli', + status: 'waiting', + latestText: 'Waiting for child agent to finish', + hiddenToolIds: ['wait-1'], + actions: [ + { type: 'wait', createdAt: 4, summary: 'Waiting for child agent to finish' } + ] + }, children: [ { kind: 'user-text', @@ -97,8 +109,9 @@ describe('CodexSubagentPreviewCard', () => { ) expect(screen.getByText('Subagent conversation')).toBeInTheDocument() + expect(screen.getByText('Waiting')).toBeInTheDocument() expect(screen.getByText(/Pauli/)).toBeInTheDocument() - expect(screen.getByText(/Search GitHub trending repositories/)).toBeInTheDocument() + expect(screen.getByText(/Waiting for child agent to finish/)).toBeInTheDocument() expect(screen.queryByText('child prompt')).not.toBeInTheDocument() expect(screen.queryByText('child answer')).not.toBeInTheDocument() @@ -108,6 +121,31 @@ describe('CodexSubagentPreviewCard', () => { expect(screen.getByText('child answer')).toBeInTheDocument() }) + it('renders HappyToolMessage as the lifecycle card for CodexSpawnAgent', () => { + const block = makeSpawnBlock() + const props: any = { + artifact: block, + toolName: 'CodexSpawnAgent', + argsText: '{}', + result: block.tool.result, + isError: false, + status: { type: 'complete' } + } + + renderWithProviders( + + ) + + expect(screen.getByText('Subagent conversation')).toBeInTheDocument() + expect(screen.getByText('Waiting')).toBeInTheDocument() + expect(screen.queryByText('child prompt')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli · agent-1/i })) + + expect(screen.getByText('child prompt')).toBeInTheDocument() + expect(screen.getByText('child answer')).toBeInTheDocument() + }) + it('marks CodexSpawnAgent children for preview rendering instead of inline expansion', () => { const block = makeSpawnBlock() expect(getToolChildRenderMode(block)).toBe('codex-subagent-preview') diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx index 83835b89f..c815fe97a 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx @@ -38,6 +38,89 @@ function getSpawnSummary(block: ToolCallBlock): { } } +type LifecycleAction = { + type?: string + createdAt?: number + summary?: string +} + +type LifecycleSnapshot = { + status: 'running' | 'waiting' | 'completed' | 'error' | 'closed' + latestText: string | null + agentId: string | null + nickname: string | null + actions: LifecycleAction[] +} + +function isLifecycleStatus(value: unknown): value is LifecycleSnapshot['status'] { + return value === 'running' || value === 'waiting' || value === 'completed' || value === 'error' || value === 'closed' +} + +function getLifecycleCandidate(block: ToolCallBlock): unknown { + if (isObject(block.lifecycle)) return block.lifecycle + const meta = block.meta + if (!isObject(meta)) return null + if (isObject(meta.codexLifecycle)) return meta.codexLifecycle + if (isObject(meta.lifecycle)) return meta.lifecycle + if (isObject(meta.codexAgentLifecycle)) return meta.codexAgentLifecycle + return meta +} + +function getLifecycleSnapshot(block: ToolCallBlock): LifecycleSnapshot { + const meta = getLifecycleCandidate(block) + const agentIdFromMeta = isObject(meta) && typeof meta.agentId === 'string' ? meta.agentId : null + const nicknameFromMeta = isObject(meta) && typeof meta.nickname === 'string' ? meta.nickname : null + const statusFromMeta = isObject(meta) && isLifecycleStatus(meta.status) ? meta.status : null + const latestTextFromMeta = isObject(meta) && typeof meta.latestText === 'string' + ? meta.latestText + : isObject(meta) && typeof meta.latest === 'string' + ? meta.latest + : isObject(meta) && typeof meta.message === 'string' + ? meta.message + : null + const actionsFromMeta = isObject(meta) && Array.isArray(meta.actions) ? meta.actions : [] + const prompt = getInputStringAny(isObject(block.tool.input) ? block.tool.input : null, ['message', 'messagePreview', 'prompt', 'description']) + const result = isObject(block.tool.result) ? block.tool.result : null + const agentIdFromResult = result && typeof result.agent_id === 'string' ? result.agent_id : null + const nicknameFromResult = result && typeof result.nickname === 'string' ? result.nickname : null + + const status: LifecycleSnapshot['status'] = statusFromMeta ?? ( + block.tool.state === 'completed' + ? 'completed' + : block.tool.state === 'error' + ? 'error' + : block.tool.state === 'pending' + ? 'waiting' + : 'running' + ) + + const latestText = latestTextFromMeta ?? (prompt ? truncate(prompt, 120) : null) + + return { + status, + latestText, + agentId: agentIdFromMeta ?? agentIdFromResult, + nickname: nicknameFromMeta ?? nicknameFromResult, + actions: actionsFromMeta.filter((action): action is LifecycleAction => isObject(action)) + } +} + +function getLifecycleStatusLabel(status: LifecycleSnapshot['status']): string { + if (status === 'waiting') return 'Waiting' + if (status === 'completed') return 'Completed' + if (status === 'error') return 'Error' + if (status === 'closed') return 'Closed' + return 'Running' +} + +function getLifecycleStatusClass(status: LifecycleSnapshot['status']): string { + if (status === 'completed') return 'bg-emerald-100 text-emerald-700 border-emerald-200' + if (status === 'error') return 'bg-red-100 text-red-700 border-red-200' + if (status === 'closed') return 'bg-slate-100 text-slate-700 border-slate-200' + if (status === 'waiting') return 'bg-amber-100 text-amber-700 border-amber-200' + return 'bg-blue-100 text-blue-700 border-blue-200' +} + function OpenIcon() { return (
- - {summary.title} - +
+ + {summary.title} + + + {getLifecycleStatusLabel(lifecycle.status)} + +
{summary.subtitle ? ( {summary.subtitle} @@ -151,13 +241,25 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) {
- {summary.prompt ? ( + {lifecycle.latestText ? ( +
+ {lifecycle.latestText} +
+ ) : summary.prompt ? (
{summary.prompt}
) : null} -
- View nested transcript · {summary.detail} +
+ View transcript + · + {summary.detail} + {actionCount > 0 ? ( + <> + · + {actionCount} action{actionCount === 1 ? '' : 's'} + + ) : null}
@@ -172,11 +274,24 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) {
- {summary.prompt ? ( -
- {summary.prompt} +
+
+ + {getLifecycleStatusLabel(lifecycle.status)} + + {lifecycle.agentId ? Agent ID: {lifecycle.agentId} : null} + {actionCount > 0 ? {actionCount} actions : null}
- ) : null} + {lifecycle.latestText ? ( +
+ {lifecycle.latestText} +
+ ) : summary.prompt ? ( +
+ {summary.prompt} +
+ ) : null} +
diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 5c37a97a3..38e54c269 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -113,15 +113,7 @@ function HappyNestedBlockList(props: { if (block.kind === 'tool-call') { return (
- - {renderToolChildren(block)} + {renderToolBlock(block, ctx)}
) } @@ -139,6 +131,29 @@ export function getToolChildRenderMode(block: ToolCallBlock): 'none' | 'task' | return 'inline' } +function renderToolBlock( + block: ToolCallBlock, + ctx: ReturnType +): ReactNode { + if (block.tool.name === 'CodexSpawnAgent') { + return + } + + return ( + <> + + {renderToolChildren(block)} + + ) +} + function renderToolChildren(block: ToolCallBlock): ReactNode | null { const mode = getToolChildRenderMode(block) if (mode === 'none') return null @@ -226,15 +241,7 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) { return (
- - {renderToolChildren(block)} + {renderToolBlock(block, ctx)}
) } From 896082f51a8b150277e4c0e77aa13eed39bc3116 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 19:18:24 +0800 Subject: [PATCH 23/82] feat(web): polish codex subagent dialog --- ...x-subagent-dialog-polish-implementation.md | 36 +++++++ ...-02-codex-subagent-dialog-polish-design.md | 93 +++++++++++++++++++ .../CodexSubagentPreviewCard.test.tsx | 47 +++++++--- .../messages/CodexSubagentPreviewCard.tsx | 53 ++++++++++- 4 files changed, 213 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-02-codex-subagent-dialog-polish-implementation.md create mode 100644 docs/superpowers/specs/2026-04-02-codex-subagent-dialog-polish-design.md diff --git a/docs/superpowers/plans/2026-04-02-codex-subagent-dialog-polish-implementation.md b/docs/superpowers/plans/2026-04-02-codex-subagent-dialog-polish-implementation.md new file mode 100644 index 000000000..e95520160 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-codex-subagent-dialog-polish-implementation.md @@ -0,0 +1,36 @@ +# Codex Subagent Dialog Polish Implementation Plan + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development + +**Goal:** Polish the Codex lifecycle/subagent dialog by removing duplicated prompt display, improving transcript rendering, and adding explicit close affordance. + +**Architecture:** Keep lifecycle aggregation unchanged. Adjust only the lifecycle card/dialog rendering layer and its focused tests. + +## Files + +### Modify +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx` +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx` +- optionally `web/src/components/AssistantChat/messages/ToolMessage.tsx` only if required by the close flow + +## Tasks + +### Task 1: RED tests +- add test for delegated prompt dedupe +- add test for explicit close button +- add test that markdown child agent text renders through markdown path + +### Task 2: Implement polish +- dedupe first repeated user prompt block in dialog transcript +- render child `agent-text` via `MarkdownRenderer` +- add explicit `Close` button in dialog footer +- keep lifecycle summary as the single wait/completed surface + +### Task 3: Verify +```bash +export PATH="$HOME/.bun/bin:$PATH" +cd /home/xiaoxiong/workplace/hapi-dev/web +bun run test -- src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx src/chat/reducer.test.ts +bun run test -- src/chat/normalize.test.ts src/chat/codexSidechain.test.ts src/chat/reducer.test.ts src/components/ToolCard/views/_results.test.tsx src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +bun run typecheck +``` diff --git a/docs/superpowers/specs/2026-04-02-codex-subagent-dialog-polish-design.md b/docs/superpowers/specs/2026-04-02-codex-subagent-dialog-polish-design.md new file mode 100644 index 000000000..94470d26c --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-codex-subagent-dialog-polish-design.md @@ -0,0 +1,93 @@ +# Codex Subagent Dialog Polish Design + +## Summary + +Polish the new Codex lifecycle/subagent UI so it feels user-facing rather than debug-facing. + +This is a narrow follow-up to: +- clickable subagent preview dialog +- lifecycle block aggregation + +Scope: +- Codex only +- web only +- no data-model rewrite + +## Problems + +### 1. Wait block feels redundant +After lifecycle aggregation, the main lifecycle card already communicates waiting/completed state. +Showing a second details-heavy wait surface is unnecessary. + +### 2. Delegated prompt appears twice +The dialog currently shows: +- a summary/prompt section at the top +- then the child transcript starts with the same delegated prompt + +That duplication feels clumsy. + +### 3. Child transcript rendering is weaker than main chat +Subagent transcript currently renders some content as plain text instead of using the richer Markdown path used in the main chat. + +### 4. Dialog lacks an explicit close/return affordance +Relying on overlay/escape/default affordances is not enough. + +## Goals + +- keep one clear lifecycle card in the parent timeline +- simplify dialog surface +- remove duplicated delegated prompt +- make child agent text render like normal chat +- add explicit close button + +## Non-goals + +- no new reducer changes unless required for dedupe heuristics +- no child session routes +- no provider parity work + +## Proposed changes + +### A. Wait block downgrade +For matched Codex lifecycle controls: +- continue folding `CodexWaitAgent` into lifecycle state +- do not expose a separate clickable/details block in the main timeline +- rely on lifecycle card status + latest update instead + +This is already mostly true after lifecycle aggregation; polish just ensures no parallel details surface competes with the lifecycle card. + +### B. Prompt dedupe in dialog +Use a simple heuristic: +- if dialog summary prompt exists +- and first child transcript block is a `user-text` +- and normalized text matches or one contains the other after trim +- hide that first child prompt block inside the dialog transcript view + +This keeps the summary prompt once, not twice. + +### C. Child transcript rendering parity +In the dialog transcript renderer: +- `agent-text` should use `MarkdownRenderer` +- `agent-reasoning` can remain visually quieter, but still preserve formatting cleanly +- keep existing user bubble / tool card rendering + +### D. Explicit close button +Add a footer/button inside the dialog: +- label: `Close` +- closes the dialog directly + +Optional extra copy: +- `Back to conversation` + +Prefer simple `Close`. + +## Success criteria + +- no obvious duplicate delegated prompt in dialog +- child agent response supports Markdown rendering +- explicit close button exists +- lifecycle card remains the single primary entry point + +## Final decision + +Implement a small web-only polish batch in `CodexSubagentPreviewCard.tsx` and its tests, keeping lifecycle aggregation as-is. diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx index 7c0c315ba..a0a4b7e67 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { ReactElement } from 'react' import type { ToolCallBlock } from '@/chat/types' @@ -8,7 +8,19 @@ import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessag import { HappyChatProvider } from '@/components/AssistantChat/context' import { I18nProvider } from '@/lib/i18n-context' +vi.mock('@/components/MarkdownRenderer', () => ({ + MarkdownRenderer: ({ content }: { content: string }) => { + const linkMatch = content.match(/\[([^\]]+)\]\(([^)]+)\)/) + if (linkMatch) { + return {linkMatch[1]} + } + return
{content}
+ } +})) + function makeSpawnBlock(): ToolCallBlock { + const delegatedPrompt = 'Search GitHub trending repositories for React state tooling' + return { kind: 'tool-call', id: 'spawn-block-1', @@ -19,7 +31,7 @@ function makeSpawnBlock(): ToolCallBlock { name: 'CodexSpawnAgent', state: 'completed', input: { - message: 'Search GitHub trending repositories for React state tooling', + message: delegatedPrompt, model: 'gpt-5.4-mini' }, createdAt: 1, @@ -48,7 +60,7 @@ function makeSpawnBlock(): ToolCallBlock { id: 'child-user-1', localId: null, createdAt: 3, - text: 'child prompt', + text: delegatedPrompt, meta: undefined }, { @@ -56,7 +68,7 @@ function makeSpawnBlock(): ToolCallBlock { id: 'child-agent-1', localId: null, createdAt: 4, - text: 'child answer', + text: 'See [repo](https://github.com/example/repo)', meta: undefined } ] @@ -112,13 +124,12 @@ describe('CodexSubagentPreviewCard', () => { expect(screen.getByText('Waiting')).toBeInTheDocument() expect(screen.getByText(/Pauli/)).toBeInTheDocument() expect(screen.getByText(/Waiting for child agent to finish/)).toBeInTheDocument() - expect(screen.queryByText('child prompt')).not.toBeInTheDocument() - expect(screen.queryByText('child answer')).not.toBeInTheDocument() + expect(screen.queryByText('See [repo](https://github.com/example/repo)')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli · agent-1/i })) - expect(screen.getByText('child prompt')).toBeInTheDocument() - expect(screen.getByText('child answer')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'repo' })).toHaveAttribute('href', 'https://github.com/example/repo') + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() }) it('renders HappyToolMessage as the lifecycle card for CodexSpawnAgent', () => { @@ -138,12 +149,26 @@ describe('CodexSubagentPreviewCard', () => { expect(screen.getByText('Subagent conversation')).toBeInTheDocument() expect(screen.getByText('Waiting')).toBeInTheDocument() - expect(screen.queryByText('child prompt')).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'repo' })).not.toBeInTheDocument() + expect(screen.queryByText('Search GitHub trending repositories for React state tooling')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli · agent-1/i })) + + expect(screen.queryByText('Search GitHub trending repositories for React state tooling')).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() + }) + + it('closes the dialog via the explicit close button', () => { + const block = makeSpawnBlock() + + renderWithProviders() fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli · agent-1/i })) + expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Close' })) - expect(screen.getByText('child prompt')).toBeInTheDocument() - expect(screen.getByText('child answer')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'repo' })).not.toBeInTheDocument() }) it('marks CodexSpawnAgent children for preview rendering instead of inline expansion', () => { diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx index c815fe97a..8069602f2 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx @@ -1,12 +1,14 @@ -import type { ReactNode } from 'react' +import { useMemo, useState, type ReactNode } from 'react' import type { ToolCallBlock } from '@/chat/types' import { isObject } from '@hapi/protocol' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { CliOutputBlock } from '@/components/CliOutputBlock' import { getEventPresentation } from '@/chat/presentation' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { ToolCard } from '@/components/ToolCard/ToolCard' import { useHappyChatContext } from '@/components/AssistantChat/context' +import { Button } from '@/components/ui/button' import { getInputStringAny, truncate } from '@/lib/toolInputUtils' function getSpawnSummary(block: ToolCallBlock): { @@ -129,6 +131,29 @@ function OpenIcon() { ) } +function normalizePromptForCompare(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +function dedupeLeadingPrompt( + blocks: ToolCallBlock['children'], + prompt: string | null +): ToolCallBlock['children'] { + if (!prompt || blocks.length === 0) return blocks + const [first, ...rest] = blocks + if (first.kind !== 'user-text') return blocks + + const promptNorm = normalizePromptForCompare(prompt) + const firstNorm = normalizePromptForCompare(first.text) + if (!promptNorm || !firstNorm) return blocks + + if (promptNorm === firstNorm || promptNorm.includes(firstNorm) || firstNorm.includes(promptNorm)) { + return rest + } + + return blocks +} + function SubagentBlockList(props: { blocks: ToolCallBlock['children'] }) { const ctx = useHappyChatContext() @@ -143,9 +168,17 @@ function SubagentBlockList(props: { blocks: ToolCallBlock['children'] }) { ) } - if (block.kind === 'agent-text' || block.kind === 'agent-reasoning') { + if (block.kind === 'agent-text') { + return ( +
+ +
+ ) + } + + if (block.kind === 'agent-reasoning') { return ( -
+
{block.text}
) @@ -207,9 +240,14 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { const lifecycle = getLifecycleSnapshot(props.block) const dialogTitle = summary.subtitle ? `${summary.title} — ${summary.subtitle}` : summary.title const actionCount = lifecycle.actions.length + const [open, setOpen] = useState(false) + const dialogBlocks = useMemo( + () => dedupeLeadingPrompt(props.block.children, summary.prompt), + [props.block.children, summary.prompt] + ) return ( - +
- + +
+ +
From c042cd9abe0c18df60d8c720167706e17da21af7 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 19:32:34 +0800 Subject: [PATCH 24/82] fix(codex): support live subagent cards and mobile dialog --- .../codex/utils/codexSessionScanner.test.ts | 77 ++++++++++++++++++ cli/src/codex/utils/codexSessionScanner.ts | 12 +++ web/src/chat/codexSidechain.test.ts | 17 ++++ web/src/chat/codexSidechain.ts | 33 +++++++- .../CodexSubagentPreviewCard.test.tsx | 13 +++ .../messages/CodexSubagentPreviewCard.tsx | 80 ++++++++++++------- web/src/components/ui/dialog.tsx | 1 + 7 files changed, 201 insertions(+), 32 deletions(-) diff --git a/cli/src/codex/utils/codexSessionScanner.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts index e39a3843b..870d0dd94 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -521,6 +521,83 @@ describe('codexSessionScanner', () => { expect(events[0].type).toBe('response_item'); }); + it('links child transcripts in non-explicit live session path', async () => { + const parentSessionId = 'parent-live-1'; + const parentToolCallId = 'spawn-live-1'; + const childSessionId = 'child-live-1'; + const parentFile = join(sessionsDir, `codex-${parentSessionId}.jsonl`); + const childFile = join(sessionsDir, `codex-${childSessionId}.jsonl`); + + await writeFile( + parentFile, + JSON.stringify({ type: 'session_meta', payload: { id: parentSessionId } }) + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: parentSessionId, + onEvent: (event) => events.push(event) + }); + + await wait(200); + + await appendFile( + parentFile, + [ + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'spawn_agent', call_id: parentToolCallId, arguments: '{"message":"delegate"}' } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: parentToolCallId, + output: JSON.stringify({ agent_id: childSessionId, nickname: 'child' }) + } + }) + ].join('\n') + '\n' + ); + + await wait(2500); + + await writeFile( + childFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: childSessionId } }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'bootstrap-1', + output: 'You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context.' + } + }), + JSON.stringify({ type: 'event_msg', payload: { type: 'task_started', turn_id: 'child-turn-1' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'child prompt live' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'child answer live' } }) + ].join('\n') + '\n' + ); + + await wait(4500); + + const childUserEvent = events.find((event) => (event.payload as Record)?.message === 'child prompt live'); + expect(childUserEvent).toBeDefined(); + expect((childUserEvent as Record).hapiSidechain).toEqual({ parentToolCallId }); + + const childAnswerEvent = events.find((event) => (event.payload as Record)?.message === 'child answer live'); + expect(childAnswerEvent).toBeDefined(); + expect((childAnswerEvent as Record).hapiSidechain).toEqual({ parentToolCallId }); + + // Parent spawn call should not have sidechain metadata + const spawnCall = events.find((event) => + event.type === 'response_item' + && (event.payload as Record)?.type === 'function_call' + && (event.payload as Record)?.call_id === parentToolCallId + ); + expect(spawnCall).toBeDefined(); + expect((spawnCall as Record).hapiSidechain).toBeUndefined(); + }, 15000); + it('does not adopt a reused session when first fresh matching activity is ambiguous', async () => { const targetCwd = '/data/github/happy/hapi'; const startupTimestampMs = Date.now(); diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts index d2ef459ea..9b3deb93b 100644 --- a/cli/src/codex/utils/codexSessionScanner.ts +++ b/cli/src/codex/utils/codexSessionScanner.ts @@ -172,6 +172,9 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } return this.getCandidateForFile(filePath) !== null; } + if (this.linkedChildFilePaths.has(normalizePath(filePath))) { + return true; + } const fileSessionId = this.sessionIdByFile.get(filePath); if (fileSessionId) { return fileSessionId === this.activeSessionId; @@ -259,6 +262,11 @@ class CodexSessionScannerImpl extends BaseSessionScanner { if (emittedForFile > 0) { logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emittedForFile} new events from ${filePath}`); } + const normalizedFilePath = normalizePath(filePath); + if (!this.linkedChildFilePaths.has(normalizedFilePath)) { + await this.linkChildTranscriptsFromParentEntries(stats.entries); + await this.linkPendingChildTranscripts(); + } } protected async afterScan(): Promise { @@ -321,6 +329,10 @@ class CodexSessionScannerImpl extends BaseSessionScanner { if (!this.activeSessionId) { return false; } + const normalizedFilePath = normalizePath(filePath); + if (this.linkedChildFilePaths.has(normalizedFilePath)) { + return false; + } const fileSessionId = this.sessionIdByFile.get(filePath); if (fileSessionId && fileSessionId !== this.activeSessionId) { return true; diff --git a/web/src/chat/codexSidechain.test.ts b/web/src/chat/codexSidechain.test.ts index e023f7c16..d48ac5ce7 100644 --- a/web/src/chat/codexSidechain.test.ts +++ b/web/src/chat/codexSidechain.test.ts @@ -175,4 +175,21 @@ describe('annotateCodexSidechains', () => { expect(result[6]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) expect(result[7]).toMatchObject({ isSidechain: false }) }) + + it('marks live inline child messages after a valid spawn call before the spawn result lands', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'Delegate task' }, 1), + userText('child-user', 'live child prompt', 2), + agentText('child-agent', 'live child answer', 3), + agentToolResult('spawn-1', { agent_id: 'agent-1', nickname: 'Pauli' }, 4), + agentToolCall('wait-1', 'CodexWaitAgent', { targets: ['agent-1'] }, 5) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[1]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[2]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[3]).toMatchObject({ isSidechain: false }) + expect(result[4]).toMatchObject({ isSidechain: false }) + }) }) diff --git a/web/src/chat/codexSidechain.ts b/web/src/chat/codexSidechain.ts index c3e7c52cc..ebcb33d79 100644 --- a/web/src/chat/codexSidechain.ts +++ b/web/src/chat/codexSidechain.ts @@ -75,19 +75,45 @@ function removeActiveAgents(activeAgentIds: string[], targets: string[]): string } export function annotateCodexSidechains(messages: NormalizedMessage[]): NormalizedMessage[] { + // Pass 1: Identify which spawn calls have valid results (with agent_id) const toolNameByToolUseId = new Map() + for (const message of messages) { + for (const toolCall of getToolCallBlocks(message)) { + toolNameByToolUseId.set(toolCall.id, toolCall.name) + } + } + const validSpawnToolUseIds = new Set() + for (const message of messages) { + for (const result of getToolResultBlocks(message)) { + const toolName = toolNameByToolUseId.get(result.tool_use_id) + if (toolName !== 'CodexSpawnAgent') continue + if (!isObject(result.content)) continue + const agentId = typeof result.content.agent_id === 'string' ? result.content.agent_id : null + if (agentId && agentId.length > 0) { + validSpawnToolUseIds.add(result.tool_use_id) + } + } + } + + // Pass 2: Annotate const agentIdToSpawnToolUseId = new Map() let activeAgentIds: string[] = [] + let pendingSpawnToolUseId: string | null = null const result: NormalizedMessage[] = [] for (const message of messages) { + let hasCodexSpawnToolCall = false for (const toolCall of getToolCallBlocks(message)) { - toolNameByToolUseId.set(toolCall.id, toolCall.name) + if (toolCall.name === 'CodexSpawnAgent' && validSpawnToolUseIds.has(toolCall.id)) { + pendingSpawnToolUseId = toolCall.id + hasCodexSpawnToolCall = true + } } const spawn = extractSpawnAgentId(message, toolNameByToolUseId) if (spawn) { + pendingSpawnToolUseId = null agentIdToSpawnToolUseId.set(spawn.agentId, spawn.spawnToolUseId) activeAgentIds = removeActiveAgents(activeAgentIds, [spawn.agentId]) activeAgentIds.push(spawn.agentId) @@ -103,7 +129,10 @@ export function annotateCodexSidechains(messages: NormalizedMessage[]): Normaliz } const activeAgentId = activeAgentIds.at(-1) ?? null - const activeSpawnToolUseId = activeAgentId ? agentIdToSpawnToolUseId.get(activeAgentId) ?? null : null + let activeSpawnToolUseId = activeAgentId ? agentIdToSpawnToolUseId.get(activeAgentId) ?? null : null + if (!activeSpawnToolUseId && pendingSpawnToolUseId && !hasCodexSpawnToolCall) { + activeSpawnToolUseId = pendingSpawnToolUseId + } if (activeSpawnToolUseId !== null && messageLooksLikeInlineChildConversation(message)) { result.push({ ...message, diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx index a0a4b7e67..c6a3cd754 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -171,6 +171,19 @@ describe('CodexSubagentPreviewCard', () => { expect(screen.queryByRole('link', { name: 'repo' })).not.toBeInTheDocument() }) + it('closes the dialog via the top close icon button', () => { + const block = makeSpawnBlock() + + renderWithProviders() + + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli · agent-1/i })) + expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Close dialog' })) + + expect(screen.queryByRole('link', { name: 'repo' })).not.toBeInTheDocument() + }) + it('marks CodexSpawnAgent children for preview rendering instead of inline expansion', () => { const block = makeSpawnBlock() expect(getToolChildRenderMode(block)).toBe('codex-subagent-preview') diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx index 8069602f2..bfd9cd924 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react' import type { ToolCallBlock } from '@/chat/types' import { isObject } from '@hapi/protocol' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { CliOutputBlock } from '@/components/CliOutputBlock' import { getEventPresentation } from '@/chat/presentation' import { MarkdownRenderer } from '@/components/MarkdownRenderer' @@ -131,6 +131,14 @@ function OpenIcon() { ) } +function CloseIcon() { + return ( + + ) +} + function normalizePromptForCompare(text: string): string { return text.replace(/\s+/g, ' ').trim() } @@ -304,37 +312,49 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { - - - {dialogTitle} - - Nested child transcript for this Codex subagent run. - - -
-
-
- - {getLifecycleStatusLabel(lifecycle.status)} - - {lifecycle.agentId ? Agent ID: {lifecycle.agentId} : null} - {actionCount > 0 ? {actionCount} actions : null} -
- {lifecycle.latestText ? ( -
- {lifecycle.latestText} -
- ) : summary.prompt ? ( -
- {summary.prompt} + + + + +
+ + {dialogTitle} + + Nested child transcript for this Codex subagent run. + + +
+
+
+
+ + {getLifecycleStatusLabel(lifecycle.status)} + + {lifecycle.agentId ? Agent ID: {lifecycle.agentId} : null} + {actionCount > 0 ? {actionCount} actions : null} +
+ {lifecycle.latestText ? ( +
+ {lifecycle.latestText} +
+ ) : summary.prompt ? ( +
+ {summary.prompt} +
+ ) : null}
- ) : null} + +
- -
- +
+
+ +
diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 18a8f4fbe..e85bc196e 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils' export const Dialog = DialogPrimitive.Root export const DialogTrigger = DialogPrimitive.Trigger +export const DialogClose = DialogPrimitive.Close export const DialogContent = React.forwardRef< HTMLDivElement, From 078b71a32b5714614c7fe3d108be9a0bea15efc8 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 19:42:47 +0800 Subject: [PATCH 25/82] refactor(web): remove redundant subagent dialog footer --- .../messages/CodexSubagentPreviewCard.test.tsx | 15 +-------------- .../messages/CodexSubagentPreviewCard.tsx | 8 -------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx index c6a3cd754..2b7fac21a 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -129,7 +129,7 @@ describe('CodexSubagentPreviewCard', () => { fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli · agent-1/i })) expect(screen.getByRole('link', { name: 'repo' })).toHaveAttribute('href', 'https://github.com/example/repo') - expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument() }) it('renders HappyToolMessage as the lifecycle card for CodexSpawnAgent', () => { @@ -158,19 +158,6 @@ describe('CodexSubagentPreviewCard', () => { expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() }) - it('closes the dialog via the explicit close button', () => { - const block = makeSpawnBlock() - - renderWithProviders() - - fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli · agent-1/i })) - expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'Close' })) - - expect(screen.queryByRole('link', { name: 'repo' })).not.toBeInTheDocument() - }) - it('closes the dialog via the top close icon button', () => { const block = makeSpawnBlock() diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx index bfd9cd924..80a195cf4 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx @@ -8,7 +8,6 @@ import { getEventPresentation } from '@/chat/presentation' import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { ToolCard } from '@/components/ToolCard/ToolCard' import { useHappyChatContext } from '@/components/AssistantChat/context' -import { Button } from '@/components/ui/button' import { getInputStringAny, truncate } from '@/lib/toolInputUtils' function getSpawnSummary(block: ToolCallBlock): { @@ -349,13 +348,6 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) {
-
-
- -
-
From 4209e20133e3f232aed56843802871d4a1e74cf5 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 19:56:29 +0800 Subject: [PATCH 26/82] fix(codex): support live subagent events --- cli/src/codex/codexRemoteLauncher.ts | 54 +++++- .../utils/appServerEventConverter.test.ts | 155 ++++++++++++++++++ .../codex/utils/appServerEventConverter.ts | 128 ++++++++++++++- cli/src/codex/utils/systemPrompt.ts | 2 + 4 files changed, 336 insertions(+), 3 deletions(-) diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 3f450ffe0..2f741a9b9 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -350,10 +350,26 @@ class CodexRemoteLauncher extends RemoteLauncherBase { if (msgType === 'agent_message') { const message = asString(msg.message); if (message) { - session.sendAgentMessage({ + const payload: Record = { type: 'message', message, id: randomUUID() + }; + const parentToolCallId = asString(msg.parent_tool_call_id ?? msg.parentToolCallId); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); + } + } + if (msgType === 'user_message') { + const message = asString(msg.message); + const parentToolCallId = asString(msg.parent_tool_call_id ?? msg.parentToolCallId); + if (message && parentToolCallId) { + session.sendUserMessage(message, { + isSidechain: true, + sidechainKey: parentToolCallId }); } } @@ -396,6 +412,42 @@ class CodexRemoteLauncher extends RemoteLauncherBase { id: randomUUID() }); } + if (msgType === 'tool_call') { + const callId = asString(msg.call_id ?? msg.callId); + const name = asString(msg.name); + if (callId && name) { + const payload: Record = { + type: 'tool-call', + name, + callId, + input: msg.input ?? {}, + id: randomUUID() + }; + const parentToolCallId = asString(msg.parent_tool_call_id ?? msg.parentToolCallId); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); + } + } + if (msgType === 'tool_call_result') { + const callId = asString(msg.call_id ?? msg.callId); + if (callId) { + const payload: Record = { + type: 'tool-call-result', + callId, + output: msg.output, + id: randomUUID() + }; + const parentToolCallId = asString(msg.parent_tool_call_id ?? msg.parentToolCallId); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); + } + } if (msgType === 'patch_apply_begin') { const callId = asString(msg.call_id ?? msg.callId); if (callId) { diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 272769260..69fb12c42 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -85,6 +85,161 @@ describe('AppServerEventConverter', () => { }]); }); + it('maps live collab spawn/wait calls and annotates child-thread messages', () => { + const converter = new AppServerEventConverter(); + + const spawnStarted = converter.handleNotification('item/started', { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + prompt: 'delegate work', + model: 'gpt-5.4-mini', + reasoningEffort: 'low' + } + }); + expect(spawnStarted).toEqual([{ + type: 'tool_call', + call_id: 'spawn-1', + name: 'CodexSpawnAgent', + input: { + message: 'delegate work', + model: 'gpt-5.4-mini', + reasoningEffort: 'low' + } + }]); + + const spawnCompleted = converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['child-thread-1'], + agentsStates: { + 'child-thread-1': { + status: 'pendingInit' + } + } + } + }); + expect(spawnCompleted).toEqual([{ + type: 'tool_call_result', + call_id: 'spawn-1', + output: { + agent_id: 'child-thread-1', + agent_ids: ['child-thread-1'], + agentsStates: { + 'child-thread-1': { + status: 'pendingInit' + } + } + } + }]); + + const childUser = converter.handleNotification('item/completed', { + threadId: 'child-thread-1', + item: { + id: 'child-user-1', + type: 'userMessage', + content: [{ type: 'text', text: 'child prompt' }] + } + }); + expect(childUser).toEqual([{ + type: 'user_message', + message: 'child prompt', + parent_tool_call_id: 'spawn-1' + }]); + + const childAgent = converter.handleNotification('item/completed', { + threadId: 'child-thread-1', + item: { + id: 'child-agent-1', + type: 'agentMessage', + content: [{ type: 'text', text: 'child answer' }] + } + }); + expect(childAgent).toEqual([{ + type: 'agent_message', + message: 'child answer', + parent_tool_call_id: 'spawn-1' + }]); + + const waitStarted = converter.handleNotification('item/started', { + threadId: 'parent-thread', + item: { + id: 'wait-1', + type: 'collabAgentToolCall', + tool: 'wait', + receiverThreadIds: ['child-thread-1'] + } + }); + expect(waitStarted).toEqual([{ + type: 'tool_call', + call_id: 'wait-1', + name: 'CodexWaitAgent', + input: { + targets: ['child-thread-1'] + } + }]); + + const waitCompleted = converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'wait-1', + type: 'collabAgentToolCall', + tool: 'wait', + receiverThreadIds: ['child-thread-1'], + agentsStates: { + 'child-thread-1': { + status: 'completed', + message: 'done' + } + } + } + }); + expect(waitCompleted).toEqual([{ + type: 'tool_call_result', + call_id: 'wait-1', + output: { + statuses: { + 'child-thread-1': { + status: 'completed', + message: 'done' + } + } + } + }]); + }); + + it('ignores child-thread hapi change_title tool calls in live mode', () => { + const converter = new AppServerEventConverter(); + + converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['child-thread-1'] + } + }); + + const events = converter.handleNotification('item/completed', { + threadId: 'child-thread-1', + item: { + id: 'title-1', + type: 'mcpToolCall', + server: 'hapi', + tool: 'change_title', + arguments: { title: 'child title' } + } + }); + + expect(events).toEqual([]); + }); + it('maps reasoning deltas', () => { const converter = new AppServerEventConverter(); diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index 08a957630..78378712c 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -135,6 +135,26 @@ export class AppServerEventConverter { private readonly lastAgentMessageDeltaByItemId = new Map(); private readonly lastReasoningDeltaByItemId = new Map(); private readonly lastCommandOutputDeltaByItemId = new Map(); + private readonly childThreadIdToParentToolCallId = new Map(); + + private addSidechainMeta( + event: ConvertedEvent, + threadId: string | null + ): ConvertedEvent { + if (!threadId) { + return event; + } + + const parentToolCallId = this.childThreadIdToParentToolCallId.get(threadId); + if (!parentToolCallId) { + return event; + } + + return { + ...event, + parent_tool_call_id: parentToolCallId + }; + } private handleWrappedCodexEvent(paramsRecord: Record): ConvertedEvent[] | null { const msg = asRecord(paramsRecord.msg); @@ -384,11 +404,114 @@ export class AppServerEventConverter { const itemType = normalizeItemType(item.type ?? item.itemType ?? item.kind); const itemId = extractItemId(paramsRecord) ?? asString(item.id ?? item.itemId ?? item.item_id); + const threadId = asString(paramsRecord.threadId ?? paramsRecord.thread_id); if (!itemType || !itemId) { return events; } + if (itemType === 'collabagenttoolcall') { + const tool = normalizeItemType(item.tool); + if (tool === 'spawnagent') { + if (method === 'item/started') { + events.push({ + type: 'tool_call', + call_id: itemId, + name: 'CodexSpawnAgent', + input: { + message: asString(item.prompt), + model: asString(item.model), + reasoningEffort: asString(item.reasoningEffort ?? item.reasoning_effort) + } + }); + } + + if (method === 'item/completed') { + const receiverThreadIds = Array.isArray(item.receiverThreadIds) + ? item.receiverThreadIds.filter((value): value is string => typeof value === 'string' && value.length > 0) + : []; + for (const receiverThreadId of receiverThreadIds) { + this.childThreadIdToParentToolCallId.set(receiverThreadId, itemId); + } + + events.push({ + type: 'tool_call_result', + call_id: itemId, + output: { + agent_id: receiverThreadIds[0] ?? null, + agent_ids: receiverThreadIds, + agentsStates: item.agentsStates + } + }); + } + + return events; + } + + if (tool === 'wait') { + const receiverThreadIds = Array.isArray(item.receiverThreadIds) + ? item.receiverThreadIds.filter((value): value is string => typeof value === 'string' && value.length > 0) + : []; + + if (method === 'item/started') { + events.push({ + type: 'tool_call', + call_id: itemId, + name: 'CodexWaitAgent', + input: { + targets: receiverThreadIds + } + }); + } + + if (method === 'item/completed') { + const agentsStates = asRecord(item.agentsStates) ?? {}; + const statuses: Record = {}; + for (const [receiverThreadId, rawState] of Object.entries(agentsStates)) { + const stateRecord = asRecord(rawState) ?? {}; + const status = asString(stateRecord.status) ?? 'completed'; + const message = asString(stateRecord.message); + statuses[receiverThreadId] = message ? { status, message } : { status }; + } + + events.push({ + type: 'tool_call_result', + call_id: itemId, + output: { + statuses + } + }); + } + + return events; + } + } + + if (itemType === 'usermessage') { + if (method === 'item/completed') { + const text = extractItemText(item); + const parentToolCallId = threadId ? this.childThreadIdToParentToolCallId.get(threadId) : null; + if (text && parentToolCallId) { + events.push({ + type: 'user_message', + message: text, + parent_tool_call_id: parentToolCallId + }); + } + } + return events; + } + + if (itemType === 'mcptoolcall') { + const server = asString(item.server); + const tool = asString(item.tool); + const parentToolCallId = threadId ? this.childThreadIdToParentToolCallId.get(threadId) : null; + + if (server === 'hapi' && tool === 'change_title' && parentToolCallId) { + return events; + } + } + if (itemType === 'agentmessage') { if (method === 'item/completed') { if (this.completedAgentMessageItems.has(itemId)) { @@ -396,7 +519,7 @@ export class AppServerEventConverter { } const text = extractItemText(item) ?? this.agentMessageBuffers.get(itemId); if (text) { - events.push({ type: 'agent_message', message: text }); + events.push(this.addSidechainMeta({ type: 'agent_message', message: text }, threadId)); this.completedAgentMessageItems.add(itemId); this.agentMessageBuffers.delete(itemId); } @@ -412,7 +535,7 @@ export class AppServerEventConverter { } const text = extractReasoningText(item) ?? this.reasoningBuffers.get(itemId); if (text) { - events.push({ type: 'agent_reasoning', text }); + events.push(this.addSidechainMeta({ type: 'agent_reasoning', text }, threadId)); this.completedReasoningItems.add(itemId); this.reasoningBuffers.delete(itemId); } @@ -520,5 +643,6 @@ export class AppServerEventConverter { this.lastAgentMessageDeltaByItemId.clear(); this.lastReasoningDeltaByItemId.clear(); this.lastCommandOutputDeltaByItemId.clear(); + this.childThreadIdToParentToolCallId.clear(); } } diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index c8be66202..87e0be5b9 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -17,6 +17,8 @@ export const TITLE_INSTRUCTION = trimIdent(` Prefer calling functions.hapi__change_title. If that exact tool name is unavailable, call an equivalent alias such as hapi__change_title, mcp__hapi__change_title, or hapi_change_title. If the task focus changes significantly later, call the title tool again with a better title. + If you are a spawned subagent, delegated agent, or child task working inside another conversation, do NOT call the title tool. + Never rename the parent conversation from a subagent. `); /** From fd8812a4598f6045055610b2a45a3e17511d1a09 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 20:09:31 +0800 Subject: [PATCH 27/82] fix(codex): separate live child cards and preserve root title --- cli/src/claude/utils/startHappyServer.ts | 29 ++++++++++++++----- cli/src/codex/codexRemoteLauncher.ts | 22 ++++++++++++++ .../utils/appServerEventConverter.test.ts | 26 ++++++++++++++++- .../codex/utils/appServerEventConverter.ts | 20 ++++++++++++- cli/src/codex/utils/buildHapiMcpBridge.ts | 4 ++- web/src/chat/codexLifecycle.ts | 16 +++++++++- web/src/chat/codexSidechain.test.ts | 14 +++++++++ web/src/chat/codexSidechain.ts | 10 ++++++- web/src/chat/reducer.test.ts | 27 +++++++++++++++++ 9 files changed, 156 insertions(+), 12 deletions(-) diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 7383f54b3..870798d9e 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -12,17 +12,32 @@ import { logger } from "@/ui/logger"; import { ApiSessionClient } from "@/api/apiSession"; import { randomUUID } from "node:crypto"; -export async function startHappyServer(client: ApiSessionClient) { +export async function startHappyServer( + client: ApiSessionClient, + options?: { + emitSummaryMessage?: boolean; + } +) { + const emitSummaryMessage = options?.emitSummaryMessage ?? true; // Handler that sends title updates via the client const handler = async (title: string) => { logger.debug('[hapiMCP] Changing title to:', title); try { - // Send title as a summary message, similar to title generator - client.sendClaudeSessionMessage({ - type: 'summary', - summary: title, - leafUuid: randomUUID() - }); + if (emitSummaryMessage) { + client.sendClaudeSessionMessage({ + type: 'summary', + summary: title, + leafUuid: randomUUID() + }); + } else { + client.updateMetadata((metadata) => ({ + ...metadata, + summary: { + text: title, + updatedAt: Date.now() + } + })); + } return { success: true }; } catch (error) { diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 2f741a9b9..8a54f6fdf 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -228,6 +228,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { let clearReadyAfterTurnTimer: (() => void) | null = null; let turnInFlight = false; let allowAnonymousTerminalEvent = false; + let lastRootSessionTitle: string | null = null; const handleCodexEvent = (msg: Record) => { const msgType = asString(msg.type); @@ -244,6 +245,27 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return; } + if (msgType === 'session_title_change') { + const title = asString(msg.title); + if (title) { + lastRootSessionTitle = title; + } + return; + } + + if (msgType === 'subagent_title_change') { + if (lastRootSessionTitle) { + session.client.updateMetadata((metadata) => ({ + ...metadata, + summary: { + text: lastRootSessionTitle!, + updatedAt: Date.now() + } + })); + } + return; + } + if (msgType === 'task_started') { const turnId = eventTurnId; if (turnId) { diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 69fb12c42..9138d7239 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -237,7 +237,31 @@ describe('AppServerEventConverter', () => { } }); - expect(events).toEqual([]); + expect(events).toEqual([{ + type: 'subagent_title_change', + title: 'child title', + parent_tool_call_id: 'spawn-1' + }]); + }); + + it('emits root session title changes for parent-thread hapi change_title calls', () => { + const converter = new AppServerEventConverter(); + + const events = converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'title-1', + type: 'mcpToolCall', + server: 'hapi', + tool: 'change_title', + arguments: { title: 'root title' } + } + }); + + expect(events).toEqual([{ + type: 'session_title_change', + title: 'root title' + }]); }); it('maps reasoning deltas', () => { diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index 78378712c..cd13b96f2 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -506,8 +506,26 @@ export class AppServerEventConverter { const server = asString(item.server); const tool = asString(item.tool); const parentToolCallId = threadId ? this.childThreadIdToParentToolCallId.get(threadId) : null; + const title = asString(asRecord(item.arguments)?.title); + + if (server === 'hapi' && tool === 'change_title') { + if (parentToolCallId) { + if (title) { + events.push({ + type: 'subagent_title_change', + title, + parent_tool_call_id: parentToolCallId + }); + } + return events; + } - if (server === 'hapi' && tool === 'change_title' && parentToolCallId) { + if (title) { + events.push({ + type: 'session_title_change', + title + }); + } return events; } } diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 8520ae22e..b3bb96cc0 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -43,7 +43,9 @@ export interface HapiMcpBridge { * used by both local and remote launchers. */ export async function buildHapiMcpBridge(client: ApiSessionClient): Promise { - const happyServer = await startHappyServer(client); + const happyServer = await startHappyServer(client, { + emitSummaryMessage: false + }); const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]); return { diff --git a/web/src/chat/codexLifecycle.ts b/web/src/chat/codexLifecycle.ts index 98ea772ed..77b95944a 100644 --- a/web/src/chat/codexLifecycle.ts +++ b/web/src/chat/codexLifecycle.ts @@ -79,6 +79,20 @@ function stringifyTargetList(value: unknown): string[] { return value.filter((item): item is string => typeof item === 'string' && item.length > 0) } +function extractResolvedWaitTargets(block: ToolCallBlock): string[] | null { + if (block.tool.name !== 'CodexWaitAgent') { + return null + } + + const result = isObject(block.tool.result) ? block.tool.result : null + if (!result || !isObject(result.statuses)) { + return null + } + + const resolvedTargets = Object.keys(result.statuses).filter((target) => target.length > 0) + return resolvedTargets.length > 0 ? resolvedTargets : null +} + function extractControlTargets(block: ToolCallBlock): string[] { const input = isObject(block.tool.input) ? block.tool.input : null if (!input) return [] @@ -183,7 +197,7 @@ function appendAction(lifecycle: CodexAgentLifecycle, action: LifecycleActionTyp function foldControlBlock(block: ToolCallBlock, spawnByAgentId: Map): boolean { if (!CONTROL_TOOL_NAMES.has(block.tool.name)) return false - const targets = extractControlTargets(block) + const targets = extractResolvedWaitTargets(block) ?? extractControlTargets(block) const matchedSpawnBlocks = targets .map((target) => spawnByAgentId.get(target)) .filter((spawn): spawn is ToolCallBlock => Boolean(spawn)) diff --git a/web/src/chat/codexSidechain.test.ts b/web/src/chat/codexSidechain.test.ts index d48ac5ce7..e62c2b3db 100644 --- a/web/src/chat/codexSidechain.test.ts +++ b/web/src/chat/codexSidechain.test.ts @@ -192,4 +192,18 @@ describe('annotateCodexSidechains', () => { expect(result[3]).toMatchObject({ isSidechain: false }) expect(result[4]).toMatchObject({ isSidechain: false }) }) + + it('does not nest a later CodexSpawnAgent tool call under the currently active child', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'First child' }, 1), + agentToolResult('spawn-1', { agent_id: 'agent-1' }, 2), + agentToolCall('spawn-2', 'CodexSpawnAgent', { message: 'Second child' }, 3), + agentToolResult('spawn-2', { agent_id: 'agent-2' }, 4) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[2]).toMatchObject({ isSidechain: false }) + expect(result[3]).toMatchObject({ isSidechain: false }) + }) }) diff --git a/web/src/chat/codexSidechain.ts b/web/src/chat/codexSidechain.ts index ebcb33d79..a9e83fc6d 100644 --- a/web/src/chat/codexSidechain.ts +++ b/web/src/chat/codexSidechain.ts @@ -68,6 +68,10 @@ function messageLooksLikeInlineChildConversation(message: NormalizedMessage): bo return sawNestableContent } +function messageContainsSpawnToolCall(message: NormalizedMessage): boolean { + return getToolCallBlocks(message).some((toolCall) => toolCall.name === 'CodexSpawnAgent') +} + function removeActiveAgents(activeAgentIds: string[], targets: string[]): string[] { if (targets.length === 0) return activeAgentIds const closed = new Set(targets) @@ -133,7 +137,11 @@ export function annotateCodexSidechains(messages: NormalizedMessage[]): Normaliz if (!activeSpawnToolUseId && pendingSpawnToolUseId && !hasCodexSpawnToolCall) { activeSpawnToolUseId = pendingSpawnToolUseId } - if (activeSpawnToolUseId !== null && messageLooksLikeInlineChildConversation(message)) { + if ( + activeSpawnToolUseId !== null + && !messageContainsSpawnToolCall(message) + && messageLooksLikeInlineChildConversation(message) + ) { result.push({ ...message, isSidechain: true, diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index a79e8f316..1948ba783 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -140,4 +140,31 @@ describe('reduceChatBlocks', () => { ]) ) }) + + it('does not mark unresolved sibling spawn blocks completed from a partial multi-target wait result', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-spawn-1-call', 'spawn-1', 'CodexSpawnAgent', { message: 'First child' }, 1), + agentToolResult('msg-spawn-1-result', 'spawn-1', { agent_id: 'agent-1', nickname: 'First' }, 2), + agentToolCall('msg-spawn-2-call', 'spawn-2', 'CodexSpawnAgent', { message: 'Second child' }, 3), + agentToolResult('msg-spawn-2-result', 'spawn-2', { agent_id: 'agent-2', nickname: 'Second' }, 4), + agentToolCall('msg-wait-call', 'wait-1', 'CodexWaitAgent', { targets: ['agent-1', 'agent-2'] }, 5), + agentToolResult('msg-wait-result', 'wait-1', { + statuses: { + 'agent-1': { + status: 'completed', + message: 'done' + } + } + }, 6) + ] + + const reduced = reduceChatBlocks(messages, null) + const spawnBlocks = reduced.blocks.filter( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.name === 'CodexSpawnAgent' + ) + + expect(spawnBlocks).toHaveLength(2) + expect(spawnBlocks.find((block) => block.tool.id === 'spawn-1')?.lifecycle?.status).toBe('completed') + expect(spawnBlocks.find((block) => block.tool.id === 'spawn-2')?.lifecycle?.status).toBe('running') + }) }) From cfae3bf3fad68c70dda891f64f4f09fc2ada493a Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 20:17:12 +0800 Subject: [PATCH 28/82] fix(codex): tighten child replies and compact lifecycle cards --- .../utils/appServerEventConverter.test.ts | 68 +++++++++++++++++-- .../codex/utils/appServerEventConverter.ts | 23 ++++++- .../messages/CodexSubagentPreviewCard.tsx | 58 ++++++++-------- 3 files changed, 112 insertions(+), 37 deletions(-) diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 9138d7239..9e40af1ac 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -199,18 +199,72 @@ describe('AppServerEventConverter', () => { } } }); - expect(waitCompleted).toEqual([{ - type: 'tool_call_result', - call_id: 'wait-1', - output: { - statuses: { + expect(waitCompleted).toEqual([ + { + type: 'agent_message', + message: 'done', + parent_tool_call_id: 'spawn-1' + }, + { + type: 'tool_call_result', + call_id: 'wait-1', + output: { + statuses: { + 'child-thread-1': { + status: 'completed', + message: 'done' + } + } + } + } + ]); + }); + + it('backfills missing child agent messages from wait results without duplicating later completions', () => { + const converter = new AppServerEventConverter(); + + converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['child-thread-1'] + } + }); + + const waitCompleted = converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'wait-1', + type: 'collabAgentToolCall', + tool: 'wait', + receiverThreadIds: ['child-thread-1'], + agentsStates: { 'child-thread-1': { status: 'completed', - message: 'done' + message: 'fallback child answer' } } } - }]); + }); + + expect(waitCompleted[0]).toEqual({ + type: 'agent_message', + message: 'fallback child answer', + parent_tool_call_id: 'spawn-1' + }); + + const childCompleted = converter.handleNotification('item/completed', { + threadId: 'child-thread-1', + item: { + id: 'child-agent-1', + type: 'agentMessage', + content: [{ type: 'text', text: 'fallback child answer' }] + } + }); + + expect(childCompleted).toEqual([]); }); it('ignores child-thread hapi change_title tool calls in live mode', () => { diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index cd13b96f2..81933e715 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -136,6 +136,7 @@ export class AppServerEventConverter { private readonly lastReasoningDeltaByItemId = new Map(); private readonly lastCommandOutputDeltaByItemId = new Map(); private readonly childThreadIdToParentToolCallId = new Map(); + private readonly lastDeliveredChildAgentMessageByThreadId = new Map(); private addSidechainMeta( event: ConvertedEvent, @@ -472,6 +473,16 @@ export class AppServerEventConverter { const status = asString(stateRecord.status) ?? 'completed'; const message = asString(stateRecord.message); statuses[receiverThreadId] = message ? { status, message } : { status }; + const parentToolCallId = this.childThreadIdToParentToolCallId.get(receiverThreadId); + const lastDeliveredMessage = this.lastDeliveredChildAgentMessageByThreadId.get(receiverThreadId); + if (message && parentToolCallId && lastDeliveredMessage !== message) { + this.lastDeliveredChildAgentMessageByThreadId.set(receiverThreadId, message); + events.push({ + type: 'agent_message', + message, + parent_tool_call_id: parentToolCallId + }); + } } events.push({ @@ -537,7 +548,16 @@ export class AppServerEventConverter { } const text = extractItemText(item) ?? this.agentMessageBuffers.get(itemId); if (text) { - events.push(this.addSidechainMeta({ type: 'agent_message', message: text }, threadId)); + const event = this.addSidechainMeta({ type: 'agent_message', message: text }, threadId); + const parentToolCallId = asString((event as Record).parent_tool_call_id); + if (threadId && parentToolCallId) { + const lastDeliveredMessage = this.lastDeliveredChildAgentMessageByThreadId.get(threadId); + if (lastDeliveredMessage === text) { + return events; + } + this.lastDeliveredChildAgentMessageByThreadId.set(threadId, text); + } + events.push(event); this.completedAgentMessageItems.add(itemId); this.agentMessageBuffers.delete(itemId); } @@ -662,5 +682,6 @@ export class AppServerEventConverter { this.lastReasoningDeltaByItemId.clear(); this.lastCommandOutputDeltaByItemId.clear(); this.childThreadIdToParentToolCallId.clear(); + this.lastDeliveredChildAgentMessageByThreadId.clear(); } } diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx index 80a195cf4..9540b30ba 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx @@ -15,6 +15,7 @@ function getSpawnSummary(block: ToolCallBlock): { subtitle: string | null detail: string prompt: string | null + promptPreview: string | null } { const input = isObject(block.tool.input) ? block.tool.input : null const result = isObject(block.tool.result) ? block.tool.result : null @@ -35,7 +36,8 @@ function getSpawnSummary(block: ToolCallBlock): { title: 'Subagent conversation', subtitle, detail: countLabel, - prompt: prompt ? truncate(prompt, 120) : null + prompt: prompt ?? null, + promptPreview: prompt ? truncate(prompt, 72) : null } } @@ -288,11 +290,11 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) {
{lifecycle.latestText ? (
- {lifecycle.latestText} + {truncate(lifecycle.latestText, 96)}
- ) : summary.prompt ? ( -
- {summary.prompt} + ) : summary.promptPreview ? ( +
+ {summary.promptPreview}
) : null}
@@ -311,42 +313,40 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { - + -
- + {dialogTitle} Nested child transcript for this Codex subagent run. - -
-
-
-
- - {getLifecycleStatusLabel(lifecycle.status)} - - {lifecycle.agentId ? Agent ID: {lifecycle.agentId} : null} - {actionCount > 0 ? {actionCount} actions : null} -
- {lifecycle.latestText ? ( -
- {lifecycle.latestText} -
- ) : summary.prompt ? ( -
- {summary.prompt} -
- ) : null} + +
+
+
+
+ + {getLifecycleStatusLabel(lifecycle.status)} + + {lifecycle.agentId ? Agent ID: {lifecycle.agentId} : null} + {actionCount > 0 ? {actionCount} actions : null}
- + {lifecycle.latestText ? ( +
+ {lifecycle.latestText} +
+ ) : summary.promptPreview ? ( +
+ {summary.promptPreview} +
+ ) : null}
+
From b16d8442e6db2beb983f898207c8fe9a169b4f71 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 20:27:35 +0800 Subject: [PATCH 29/82] fix(web): preserve live codex child routing --- web/src/chat/codexSidechain.test.ts | 36 +++++++++++++++++++++++++++++ web/src/chat/codexSidechain.ts | 5 ++++ 2 files changed, 41 insertions(+) diff --git a/web/src/chat/codexSidechain.test.ts b/web/src/chat/codexSidechain.test.ts index e62c2b3db..4e0ce07c2 100644 --- a/web/src/chat/codexSidechain.test.ts +++ b/web/src/chat/codexSidechain.test.ts @@ -206,4 +206,40 @@ describe('annotateCodexSidechains', () => { expect(result[2]).toMatchObject({ isSidechain: false }) expect(result[3]).toMatchObject({ isSidechain: false }) }) + + it('preserves explicit sidechain keys for parallel live child streams', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'First child' }, 1), + agentToolResult('spawn-1', { agent_id: 'agent-1' }, 2), + agentToolCall('spawn-2', 'CodexSpawnAgent', { message: 'Second child' }, 3), + agentToolResult('spawn-2', { agent_id: 'agent-2' }, 4), + { + ...userText('child-1-user', 'first child prompt', 5), + isSidechain: true, + sidechainKey: 'spawn-1' + }, + { + ...userText('child-2-user', 'second child prompt', 6), + isSidechain: true, + sidechainKey: 'spawn-2' + }, + { + ...agentText('child-1-agent', 'first child answer', 7), + isSidechain: true, + sidechainKey: 'spawn-1' + }, + { + ...agentText('child-2-agent', 'second child answer', 8), + isSidechain: true, + sidechainKey: 'spawn-2' + } + ] + + const result = annotateCodexSidechains(messages) + + expect(result[4]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[5]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-2' }) + expect(result[6]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[7]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-2' }) + }) }) diff --git a/web/src/chat/codexSidechain.ts b/web/src/chat/codexSidechain.ts index a9e83fc6d..c98079f6e 100644 --- a/web/src/chat/codexSidechain.ts +++ b/web/src/chat/codexSidechain.ts @@ -107,6 +107,11 @@ export function annotateCodexSidechains(messages: NormalizedMessage[]): Normaliz const result: NormalizedMessage[] = [] for (const message of messages) { + if (message.isSidechain === true && typeof message.sidechainKey === 'string' && message.sidechainKey.length > 0) { + result.push({ ...message }) + continue + } + let hasCodexSpawnToolCall = false for (const toolCall of getToolCallBlocks(message)) { if (toolCall.name === 'CodexSpawnAgent' && validSpawnToolUseIds.has(toolCall.id)) { From 231296e1d1d41d94d75637d68c7aa39fe1f4da95 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 20:43:55 +0800 Subject: [PATCH 30/82] fix(codex): preserve child replies and hide agent ids --- .../utils/appServerEventConverter.test.ts | 29 +++++++++++++++++++ .../codex/utils/appServerEventConverter.ts | 10 ++++++- .../CodexSubagentPreviewCard.test.tsx | 7 +++-- .../messages/CodexSubagentPreviewCard.tsx | 9 ++---- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 9e40af1ac..16ab4da92 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -220,6 +220,35 @@ describe('AppServerEventConverter', () => { ]); }); + it('reads child thread id from the item payload when top-level threadId is missing', () => { + const converter = new AppServerEventConverter(); + + converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['child-thread-1'] + } + }); + + const childAgent = converter.handleNotification('item/completed', { + item: { + id: 'child-agent-1', + type: 'agentMessage', + threadId: 'child-thread-1', + content: [{ type: 'text', text: 'hello from child' }] + } + }); + + expect(childAgent).toEqual([{ + type: 'agent_message', + message: 'hello from child', + parent_tool_call_id: 'spawn-1' + }]); + }); + it('backfills missing child agent messages from wait results without duplicating later completions', () => { const converter = new AppServerEventConverter(); diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index 81933e715..2fdcd24aa 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -405,7 +405,15 @@ export class AppServerEventConverter { const itemType = normalizeItemType(item.type ?? item.itemType ?? item.kind); const itemId = extractItemId(paramsRecord) ?? asString(item.id ?? item.itemId ?? item.item_id); - const threadId = asString(paramsRecord.threadId ?? paramsRecord.thread_id); + const threadId = asString( + paramsRecord.threadId + ?? paramsRecord.thread_id + ?? item.threadId + ?? item.thread_id + ?? asRecord(item.thread)?.id + ?? asRecord(item.thread)?.threadId + ?? asRecord(item.thread)?.thread_id + ); if (!itemType || !itemId) { return events; diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx index 2b7fac21a..9c8fc1b20 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -123,10 +123,11 @@ describe('CodexSubagentPreviewCard', () => { expect(screen.getByText('Subagent conversation')).toBeInTheDocument() expect(screen.getByText('Waiting')).toBeInTheDocument() expect(screen.getByText(/Pauli/)).toBeInTheDocument() + expect(screen.queryByText(/agent-1/i)).not.toBeInTheDocument() expect(screen.getByText(/Waiting for child agent to finish/)).toBeInTheDocument() expect(screen.queryByText('See [repo](https://github.com/example/repo)')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli · agent-1/i })) + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) expect(screen.getByRole('link', { name: 'repo' })).toHaveAttribute('href', 'https://github.com/example/repo') expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument() @@ -152,7 +153,7 @@ describe('CodexSubagentPreviewCard', () => { expect(screen.queryByRole('link', { name: 'repo' })).not.toBeInTheDocument() expect(screen.queryByText('Search GitHub trending repositories for React state tooling')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli · agent-1/i })) + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) expect(screen.queryByText('Search GitHub trending repositories for React state tooling')).not.toBeInTheDocument() expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() @@ -163,7 +164,7 @@ describe('CodexSubagentPreviewCard', () => { renderWithProviders() - fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli · agent-1/i })) + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Close dialog' })) diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx index 9540b30ba..e0593140a 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx @@ -23,13 +23,9 @@ function getSpawnSummary(block: ToolCallBlock): { const nickname = result && typeof result.nickname === 'string' && result.nickname.length > 0 ? result.nickname : getInputStringAny(input, ['nickname', 'name', 'agent_name']) - const agentId = result && typeof result.agent_id === 'string' && result.agent_id.length > 0 - ? result.agent_id - : null const prompt = getInputStringAny(input, ['message', 'messagePreview', 'prompt', 'description']) - const subtitleParts = [nickname, agentId].filter((part): part is string => Boolean(part && part.length > 0)) - const subtitle = subtitleParts.length > 0 ? subtitleParts.join(' · ') : null + const subtitle = nickname && nickname.length > 0 ? nickname : null const countLabel = `${block.children.length} nested block${block.children.length === 1 ? '' : 's'}` return { @@ -276,7 +272,7 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) {
{summary.subtitle ? ( - + {summary.subtitle} ) : null} @@ -333,7 +329,6 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { {getLifecycleStatusLabel(lifecycle.status)} - {lifecycle.agentId ? Agent ID: {lifecycle.agentId} : null} {actionCount > 0 ? {actionCount} actions : null}
{lifecycle.latestText ? ( From 3078f38ec6fbecbcc88def0bf3d2a390e8af8572 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 20:53:00 +0800 Subject: [PATCH 31/82] refactor(web): simplify codex subagent card chrome --- .../CodexSubagentPreviewCard.test.tsx | 23 +++++----- .../messages/CodexSubagentPreviewCard.tsx | 43 +++++-------------- 2 files changed, 22 insertions(+), 44 deletions(-) diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx index 9c8fc1b20..56cd529c2 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -120,17 +120,17 @@ describe('CodexSubagentPreviewCard', () => { /> ) - expect(screen.getByText('Subagent conversation')).toBeInTheDocument() + expect(screen.getByText('Pauli')).toBeInTheDocument() expect(screen.getByText('Waiting')).toBeInTheDocument() - expect(screen.getByText(/Pauli/)).toBeInTheDocument() expect(screen.queryByText(/agent-1/i)).not.toBeInTheDocument() expect(screen.getByText(/Waiting for child agent to finish/)).toBeInTheDocument() expect(screen.queryByText('See [repo](https://github.com/example/repo)')).not.toBeInTheDocument() + expect(screen.queryByText('Subagent conversation')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) + fireEvent.click(screen.getByRole('button', { name: /Pauli/i })) expect(screen.getByRole('link', { name: 'repo' })).toHaveAttribute('href', 'https://github.com/example/repo') - expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Close dialog' })).not.toBeInTheDocument() }) it('renders HappyToolMessage as the lifecycle card for CodexSpawnAgent', () => { @@ -148,28 +148,27 @@ describe('CodexSubagentPreviewCard', () => { ) - expect(screen.getByText('Subagent conversation')).toBeInTheDocument() + expect(screen.getByText('Pauli')).toBeInTheDocument() expect(screen.getByText('Waiting')).toBeInTheDocument() expect(screen.queryByRole('link', { name: 'repo' })).not.toBeInTheDocument() expect(screen.queryByText('Search GitHub trending repositories for React state tooling')).not.toBeInTheDocument() + expect(screen.queryByText('Subagent conversation')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) + fireEvent.click(screen.getByRole('button', { name: /Pauli/i })) expect(screen.queryByText('Search GitHub trending repositories for React state tooling')).not.toBeInTheDocument() expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() }) - it('closes the dialog via the top close icon button', () => { + it('uses the nickname as the dialog title and does not render an explicit close icon', () => { const block = makeSpawnBlock() renderWithProviders() - fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) + fireEvent.click(screen.getByRole('button', { name: /Pauli/i })) expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'Close dialog' })) - - expect(screen.queryByRole('link', { name: 'repo' })).not.toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Pauli' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Close dialog' })).not.toBeInTheDocument() }) it('marks CodexSpawnAgent children for preview rendering instead of inline expansion', () => { diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx index e0593140a..694a46088 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react' import type { ToolCallBlock } from '@/chat/types' import { isObject } from '@hapi/protocol' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { CliOutputBlock } from '@/components/CliOutputBlock' import { getEventPresentation } from '@/chat/presentation' import { MarkdownRenderer } from '@/components/MarkdownRenderer' @@ -29,8 +29,8 @@ function getSpawnSummary(block: ToolCallBlock): { const countLabel = `${block.children.length} nested block${block.children.length === 1 ? '' : 's'}` return { - title: 'Subagent conversation', - subtitle, + title: subtitle ?? 'Subagent', + subtitle: null, detail: countLabel, prompt: prompt ?? null, promptPreview: prompt ? truncate(prompt, 72) : null @@ -128,14 +128,6 @@ function OpenIcon() { ) } -function CloseIcon() { - return ( - - ) -} - function normalizePromptForCompare(text: string): string { return text.replace(/\s+/g, ' ').trim() } @@ -243,7 +235,7 @@ function SubagentBlockList(props: { blocks: ToolCallBlock['children'] }) { export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { const summary = getSpawnSummary(props.block) const lifecycle = getLifecycleSnapshot(props.block) - const dialogTitle = summary.subtitle ? `${summary.title} — ${summary.subtitle}` : summary.title + const dialogTitle = summary.title const actionCount = lifecycle.actions.length const [open, setOpen] = useState(false) const dialogBlocks = useMemo( @@ -259,30 +251,23 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { className="w-full text-left rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]" aria-label={dialogTitle} > - - + +
- + {summary.title} {getLifecycleStatusLabel(lifecycle.status)}
- {summary.subtitle ? ( - - {summary.subtitle} - - ) : null}
-
-
{lifecycle.latestText ? (
@@ -305,24 +290,18 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { ) : null}
-
+
- - - - - + + {dialogTitle} Nested child transcript for this Codex subagent run. -
+
From 1af9ff73e469dccc9f6e70b9e1eb885772b55206 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 21:01:18 +0800 Subject: [PATCH 32/82] Revert "refactor(web): simplify codex subagent card chrome" This reverts commit 3078f38ec6fbecbcc88def0bf3d2a390e8af8572. --- .../CodexSubagentPreviewCard.test.tsx | 23 +++++----- .../messages/CodexSubagentPreviewCard.tsx | 43 ++++++++++++++----- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx index 56cd529c2..9c8fc1b20 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -120,17 +120,17 @@ describe('CodexSubagentPreviewCard', () => { /> ) - expect(screen.getByText('Pauli')).toBeInTheDocument() + expect(screen.getByText('Subagent conversation')).toBeInTheDocument() expect(screen.getByText('Waiting')).toBeInTheDocument() + expect(screen.getByText(/Pauli/)).toBeInTheDocument() expect(screen.queryByText(/agent-1/i)).not.toBeInTheDocument() expect(screen.getByText(/Waiting for child agent to finish/)).toBeInTheDocument() expect(screen.queryByText('See [repo](https://github.com/example/repo)')).not.toBeInTheDocument() - expect(screen.queryByText('Subagent conversation')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /Pauli/i })) + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) expect(screen.getByRole('link', { name: 'repo' })).toHaveAttribute('href', 'https://github.com/example/repo') - expect(screen.queryByRole('button', { name: 'Close dialog' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument() }) it('renders HappyToolMessage as the lifecycle card for CodexSpawnAgent', () => { @@ -148,27 +148,28 @@ describe('CodexSubagentPreviewCard', () => { ) - expect(screen.getByText('Pauli')).toBeInTheDocument() + expect(screen.getByText('Subagent conversation')).toBeInTheDocument() expect(screen.getByText('Waiting')).toBeInTheDocument() expect(screen.queryByRole('link', { name: 'repo' })).not.toBeInTheDocument() expect(screen.queryByText('Search GitHub trending repositories for React state tooling')).not.toBeInTheDocument() - expect(screen.queryByText('Subagent conversation')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /Pauli/i })) + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) expect(screen.queryByText('Search GitHub trending repositories for React state tooling')).not.toBeInTheDocument() expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() }) - it('uses the nickname as the dialog title and does not render an explicit close icon', () => { + it('closes the dialog via the top close icon button', () => { const block = makeSpawnBlock() renderWithProviders() - fireEvent.click(screen.getByRole('button', { name: /Pauli/i })) + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'Pauli' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Close dialog' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Close dialog' })) + + expect(screen.queryByRole('link', { name: 'repo' })).not.toBeInTheDocument() }) it('marks CodexSpawnAgent children for preview rendering instead of inline expansion', () => { diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx index 694a46088..e0593140a 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react' import type { ToolCallBlock } from '@/chat/types' import { isObject } from '@hapi/protocol' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { CliOutputBlock } from '@/components/CliOutputBlock' import { getEventPresentation } from '@/chat/presentation' import { MarkdownRenderer } from '@/components/MarkdownRenderer' @@ -29,8 +29,8 @@ function getSpawnSummary(block: ToolCallBlock): { const countLabel = `${block.children.length} nested block${block.children.length === 1 ? '' : 's'}` return { - title: subtitle ?? 'Subagent', - subtitle: null, + title: 'Subagent conversation', + subtitle, detail: countLabel, prompt: prompt ?? null, promptPreview: prompt ? truncate(prompt, 72) : null @@ -128,6 +128,14 @@ function OpenIcon() { ) } +function CloseIcon() { + return ( + + ) +} + function normalizePromptForCompare(text: string): string { return text.replace(/\s+/g, ' ').trim() } @@ -235,7 +243,7 @@ function SubagentBlockList(props: { blocks: ToolCallBlock['children'] }) { export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { const summary = getSpawnSummary(props.block) const lifecycle = getLifecycleSnapshot(props.block) - const dialogTitle = summary.title + const dialogTitle = summary.subtitle ? `${summary.title} — ${summary.subtitle}` : summary.title const actionCount = lifecycle.actions.length const [open, setOpen] = useState(false) const dialogBlocks = useMemo( @@ -251,23 +259,30 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { className="w-full text-left rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]" aria-label={dialogTitle} > - - + +
- + {summary.title} {getLifecycleStatusLabel(lifecycle.status)}
+ {summary.subtitle ? ( + + {summary.subtitle} + + ) : null}
+
+
{lifecycle.latestText ? (
@@ -290,18 +305,24 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { ) : null}
-
+
- - + + + + + {dialogTitle} Nested child transcript for this Codex subagent run. -
+
From 6943b053e4a1ae0c14bd484451f195acf9b5066b Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 21:19:01 +0800 Subject: [PATCH 33/82] fix(web): keep parallel codex child replies separated --- web/src/chat/codexSidechain.test.ts | 16 ++++++++ web/src/chat/codexSidechain.ts | 2 +- web/src/chat/reducer.test.ts | 60 +++++++++++++++++++++++++++++ web/src/chat/reducer.ts | 52 ++++++++++++++++++++++++- 4 files changed, 128 insertions(+), 2 deletions(-) diff --git a/web/src/chat/codexSidechain.test.ts b/web/src/chat/codexSidechain.test.ts index 4e0ce07c2..bed1ae4d8 100644 --- a/web/src/chat/codexSidechain.test.ts +++ b/web/src/chat/codexSidechain.test.ts @@ -242,4 +242,20 @@ describe('annotateCodexSidechains', () => { expect(result[6]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) expect(result[7]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-2' }) }) + + it('does not heuristically assign untagged messages when multiple child agents are active', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'First child' }, 1), + agentToolResult('spawn-1', { agent_id: 'agent-1' }, 2), + agentToolCall('spawn-2', 'CodexSpawnAgent', { message: 'Second child' }, 3), + agentToolResult('spawn-2', { agent_id: 'agent-2' }, 4), + agentText('root-parent-progress', 'Parent progress update', 5), + agentText('stray-child-reply', 'First child answer', 6) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[4]).toMatchObject({ isSidechain: false }) + expect(result[5]).toMatchObject({ isSidechain: false }) + }) }) diff --git a/web/src/chat/codexSidechain.ts b/web/src/chat/codexSidechain.ts index c98079f6e..d09b6f608 100644 --- a/web/src/chat/codexSidechain.ts +++ b/web/src/chat/codexSidechain.ts @@ -137,7 +137,7 @@ export function annotateCodexSidechains(messages: NormalizedMessage[]): Normaliz continue } - const activeAgentId = activeAgentIds.at(-1) ?? null + const activeAgentId = activeAgentIds.length === 1 ? activeAgentIds[0] : null let activeSpawnToolUseId = activeAgentId ? agentIdToSpawnToolUseId.get(activeAgentId) ?? null : null if (!activeSpawnToolUseId && pendingSpawnToolUseId && !hasCodexSpawnToolCall) { activeSpawnToolUseId = pendingSpawnToolUseId diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index 1948ba783..569e22a1d 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -167,4 +167,64 @@ describe('reduceChatBlocks', () => { expect(spawnBlocks.find((block) => block.tool.id === 'spawn-1')?.lifecycle?.status).toBe('completed') expect(spawnBlocks.find((block) => block.tool.id === 'spawn-2')?.lifecycle?.status).toBe('running') }) + + it('reassigns a stray root child reply to the matching spawn card using wait status messages', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-spawn-1-call', 'spawn-1', 'CodexSpawnAgent', { message: 'First child prompt' }, 1), + agentToolResult('msg-spawn-1-result', 'spawn-1', { agent_id: 'agent-1', nickname: 'First' }, 2), + agentToolCall('msg-spawn-2-call', 'spawn-2', 'CodexSpawnAgent', { message: 'Second child prompt' }, 3), + agentToolResult('msg-spawn-2-result', 'spawn-2', { agent_id: 'agent-2', nickname: 'Second' }, 4), + { + ...userText('child-2-user', 'Second child prompt', 5), + isSidechain: true, + sidechainKey: 'spawn-2' + }, + { + ...agentText('child-2-agent', 'Second child answer', 6), + isSidechain: true, + sidechainKey: 'spawn-2' + }, + agentText('child-1-root-agent', 'First child answer', 7), + agentText('parent-agent', 'Parent progress update', 8), + agentToolCall('msg-wait-call', 'wait-1', 'CodexWaitAgent', { targets: ['agent-1', 'agent-2'] }, 9), + agentToolResult('msg-wait-result', 'wait-1', { + statuses: { + 'agent-1': { + status: 'completed', + message: 'First child answer' + }, + 'agent-2': { + status: 'completed', + message: 'Second child answer' + } + } + }, 10) + ] + + const reduced = reduceChatBlocks(messages, null) + const spawnBlocks = reduced.blocks.filter( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.name === 'CodexSpawnAgent' + ) + + const firstSpawn = spawnBlocks.find((block) => block.tool.id === 'spawn-1') + const secondSpawn = spawnBlocks.find((block) => block.tool.id === 'spawn-2') + + expect(firstSpawn?.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'agent-text', text: 'First child answer' }) + ]) + ) + expect(secondSpawn?.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: 'Second child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'Second child answer' }) + ]) + ) + expect( + reduced.blocks.some((block) => block.kind === 'agent-text' && block.text === 'First child answer') + ).toBe(false) + expect( + reduced.blocks.some((block) => block.kind === 'agent-text' && block.text === 'Parent progress update') + ).toBe(true) + }) }) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 9717eac8c..66728b037 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -1,11 +1,12 @@ import type { AgentState } from '@/types/api' -import type { ChatBlock, NormalizedMessage, UsageData } from '@/chat/types' +import type { ChatBlock, NormalizedMessage, ToolCallBlock, UsageData } from '@/chat/types' import { annotateCodexSidechains } from '@/chat/codexSidechain' import { applyCodexLifecycleAggregation } from '@/chat/codexLifecycle' import { traceMessages, type TracedMessage } from '@/chat/tracer' import { dedupeAgentEvents, foldApiErrorEvents } from '@/chat/reducerEvents' import { collectTitleChanges, collectToolIdsFromMessages, ensureToolBlock, getPermissions } from '@/chat/reducerTools' import { reduceTimeline } from '@/chat/reducerTimeline' +import { isObject } from '@hapi/protocol' // Calculate context size from usage data function calculateContextSize(usage: UsageData): number { @@ -51,6 +52,54 @@ function attachCodexSpawnChildren( } } +function extractSpawnAgentId(block: ToolCallBlock): string | null { + const result = isObject(block.tool.result) ? block.tool.result : null + return result && typeof result.agent_id === 'string' && result.agent_id.length > 0 + ? result.agent_id + : null +} + +function reattachWaitBackfilledChildReplies(blocks: ChatBlock[]): void { + const spawnByAgentId = new Map() + + for (const block of blocks) { + if (block.kind !== 'tool-call' || block.tool.name !== 'CodexSpawnAgent') continue + const agentId = extractSpawnAgentId(block) + if (agentId) { + spawnByAgentId.set(agentId, block) + } + } + + for (const block of [...blocks]) { + if (block.kind !== 'tool-call' || block.tool.name !== 'CodexWaitAgent') continue + const result = isObject(block.tool.result) ? block.tool.result : null + const statuses = result && isObject(result.statuses) ? result.statuses : null + if (!statuses) continue + + for (const [agentId, rawState] of Object.entries(statuses)) { + const spawn = spawnByAgentId.get(agentId) + const state = isObject(rawState) ? rawState : null + const message = state && typeof state.message === 'string' && state.message.trim().length > 0 + ? state.message.trim() + : null + if (!spawn || !message) continue + + const alreadyNested = spawn.children.some( + (child) => child.kind === 'agent-text' && child.text.trim() === message + ) + if (alreadyNested) continue + + const strayIndex = blocks.findIndex( + (candidate) => candidate.kind === 'agent-text' && candidate.text.trim() === message + ) + if (strayIndex === -1) continue + + const [stray] = blocks.splice(strayIndex, 1) + spawn.children.push(stray) + } + } +} + export type LatestUsage = { inputTokens: number outputTokens: number @@ -86,6 +135,7 @@ export function reduceChatBlocks( } attachCodexSpawnChildren(rootResult.blocks, groups, consumedGroupIds, reduceGroup) + reattachWaitBackfilledChildReplies(rootResult.blocks) // Only create permission-only tool cards when there is no tool call/result in the transcript. // Also skip if the permission is older than the oldest message in the current view, From c9e4ceb2f1e511cc35ef7bcf7cf72150c3f729f2 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 22:03:43 +0800 Subject: [PATCH 34/82] fix(codex): nest live child tool events --- .../utils/appServerEventConverter.test.ts | 52 +++++++++++++++++++ .../codex/utils/appServerEventConverter.ts | 16 +++--- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 16ab4da92..5740669f9 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -296,6 +296,58 @@ describe('AppServerEventConverter', () => { expect(childCompleted).toEqual([]); }); + it('annotates child command execution events with the parent spawn id', () => { + const converter = new AppServerEventConverter(); + + converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['child-thread-1'] + } + }); + + const started = converter.handleNotification('item/started', { + threadId: 'child-thread-1', + item: { + id: 'cmd-1', + type: 'commandExecution', + command: 'ls' + } + }); + expect(started).toEqual([{ + type: 'exec_command_begin', + call_id: 'cmd-1', + command: 'ls', + parent_tool_call_id: 'spawn-1' + }]); + + converter.handleNotification('item/commandExecution/outputDelta', { + threadId: 'child-thread-1', + itemId: 'cmd-1', + delta: 'child output' + }); + + const completed = converter.handleNotification('item/completed', { + threadId: 'child-thread-1', + item: { + id: 'cmd-1', + type: 'commandExecution', + exitCode: 0 + } + }); + expect(completed).toEqual([{ + type: 'exec_command_end', + call_id: 'cmd-1', + command: 'ls', + output: 'child output', + exit_code: 0, + parent_tool_call_id: 'spawn-1' + }]); + }); + it('ignores child-thread hapi change_title tool calls in live mode', () => { const converter = new AppServerEventConverter(); diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index 2fdcd24aa..e72a74e4d 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -601,11 +601,11 @@ export class AppServerEventConverter { if (autoApproved !== null) meta.auto_approved = autoApproved; this.commandMeta.set(itemId, meta); - events.push({ + events.push(this.addSidechainMeta({ type: 'exec_command_begin', call_id: itemId, ...meta - }); + }, threadId)); } if (method === 'item/completed') { @@ -616,7 +616,7 @@ export class AppServerEventConverter { const exitCode = asNumber(item.exitCode ?? item.exit_code ?? item.exitcode); const status = asString(item.status); - events.push({ + events.push(this.addSidechainMeta({ type: 'exec_command_end', call_id: itemId, ...meta, @@ -625,7 +625,7 @@ export class AppServerEventConverter { ...(error ? { error } : {}), ...(exitCode !== null ? { exit_code: exitCode } : {}), ...(status ? { status } : {}) - }); + }, threadId)); this.commandMeta.delete(itemId); this.commandOutputBuffers.delete(itemId); @@ -644,11 +644,11 @@ export class AppServerEventConverter { if (autoApproved !== null) meta.auto_approved = autoApproved; this.fileChangeMeta.set(itemId, meta); - events.push({ + events.push(this.addSidechainMeta({ type: 'patch_apply_begin', call_id: itemId, ...meta - }); + }, threadId)); } if (method === 'item/completed') { @@ -657,14 +657,14 @@ export class AppServerEventConverter { const stderr = asString(item.stderr); const success = asBoolean(item.success ?? item.ok ?? item.applied ?? item.status === 'completed'); - events.push({ + events.push(this.addSidechainMeta({ type: 'patch_apply_end', call_id: itemId, ...meta, ...(stdout ? { stdout } : {}), ...(stderr ? { stderr } : {}), success: success ?? false - }); + }, threadId)); this.fileChangeMeta.delete(itemId); } From 00b036c946aebd5d4f8250d8dfb65cb9296d0639 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 22:14:52 +0800 Subject: [PATCH 35/82] fix(codex): promote live child tool sidechains --- cli/src/codex/codexRemoteLauncher.test.ts | 65 +++++++++++++++++++++++ cli/src/codex/codexRemoteLauncher.ts | 59 +++++++++++++++++--- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index afa560ed5..b19384c9b 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -4,6 +4,7 @@ import type { EnhancedMode } from './loop'; const harness = vi.hoisted(() => ({ notifications: [] as Array<{ method: string; params: unknown }>, + extraNotifications: [] as Array<{ method: string; params: unknown }>, registerRequestCalls: [] as string[], initializeCalls: [] as unknown[], startThreadCalls: [] as unknown[], @@ -64,6 +65,11 @@ vi.mock('./codexAppServerClient', () => { harness.notifications.push({ method: 'turn/completed', params: completed }); this.notificationHandler?.('turn/completed', completed); + for (const notification of harness.extraNotifications) { + harness.notifications.push(notification); + this.notificationHandler?.(notification.method, notification.params); + } + return { turn: {} }; } @@ -186,6 +192,7 @@ function createSessionStub(overrides?: { sessionId?: string | null }) { describe('codexRemoteLauncher', () => { afterEach(() => { harness.notifications = []; + harness.extraNotifications = []; harness.registerRequestCalls = []; harness.initializeCalls = []; harness.startThreadCalls = []; @@ -303,4 +310,62 @@ describe('codexRemoteLauncher', () => { expect(thinkingChanges).toContain(true); expect(session.thinking).toBe(false); }); + + it('promotes nested parent_tool_call_id from exec command payloads into top-level sidechain metadata', async () => { + harness.extraNotifications = [ + { + method: 'item/completed', + params: { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['child-thread-1'] + } + } + }, + { + method: 'item/started', + params: { + threadId: 'child-thread-1', + item: { + id: 'cmd-1', + type: 'commandExecution', + command: 'ls' + } + } + }, + { + method: 'item/completed', + params: { + threadId: 'child-thread-1', + item: { + id: 'cmd-1', + type: 'commandExecution', + exitCode: 0 + } + } + } + ]; + + const { session, codexMessages } = createSessionStub(); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(codexMessages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'tool-call', + name: 'CodexBash', + isSidechain: true, + parentToolCallId: 'spawn-1' + }), + expect.objectContaining({ + type: 'tool-call-result', + isSidechain: true, + parentToolCallId: 'spawn-1' + }) + ])); + }); }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 8a54f6fdf..be89f7dbd 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -140,6 +140,21 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return typeof value === 'string' && value.length > 0 ? value : null; }; + const extractParentToolCallId = (value: unknown): string | null => { + const record = asRecord(value); + if (!record) { + return asString(value); + } + return asString( + record.parent_tool_call_id + ?? record.parentToolCallId + ?? asRecord(record.input)?.parent_tool_call_id + ?? asRecord(record.input)?.parentToolCallId + ?? asRecord(record.output)?.parent_tool_call_id + ?? asRecord(record.output)?.parentToolCallId + ); + }; + const applyResolvedModel = (value: unknown): string | undefined => { const resolvedModel = asString(value) ?? undefined; if (!resolvedModel) { @@ -402,14 +417,22 @@ class CodexRemoteLauncher extends RemoteLauncherBase { delete inputs.type; delete inputs.call_id; delete inputs.callId; + delete inputs.parent_tool_call_id; + delete inputs.parentToolCallId; - session.sendAgentMessage({ + const payload: Record = { type: 'tool-call', name: 'CodexBash', callId: callId, input: inputs, id: randomUUID() - }); + }; + const parentToolCallId = extractParentToolCallId(msg); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); } } if (msgType === 'exec_command_end') { @@ -419,13 +442,21 @@ class CodexRemoteLauncher extends RemoteLauncherBase { delete output.type; delete output.call_id; delete output.callId; + delete output.parent_tool_call_id; + delete output.parentToolCallId; - session.sendAgentMessage({ + const payload: Record = { type: 'tool-call-result', callId: callId, output, id: randomUUID() - }); + }; + const parentToolCallId = extractParentToolCallId(msg); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); } } if (msgType === 'token_count') { @@ -478,7 +509,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const filesMsg = changeCount === 1 ? '1 file' : `${changeCount} files`; messageBuffer.addMessage(`Modifying ${filesMsg}...`, 'tool'); - session.sendAgentMessage({ + const payload: Record = { type: 'tool-call', name: 'CodexPatch', callId: callId, @@ -487,7 +518,13 @@ class CodexRemoteLauncher extends RemoteLauncherBase { changes }, id: randomUUID() - }); + }; + const parentToolCallId = extractParentToolCallId(msg); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); } } if (msgType === 'patch_apply_end') { @@ -505,7 +542,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { messageBuffer.addMessage(`Error: ${errorMsg.substring(0, 200)}`, 'result'); } - session.sendAgentMessage({ + const payload: Record = { type: 'tool-call-result', callId: callId, output: { @@ -514,7 +551,13 @@ class CodexRemoteLauncher extends RemoteLauncherBase { success }, id: randomUUID() - }); + }; + const parentToolCallId = extractParentToolCallId(msg); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); } } if (msgType === 'mcp_tool_call_begin') { From dcd729a9567ccc8f2d035f323b016f917fb38ac9 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Thu, 2 Apr 2026 22:27:17 +0800 Subject: [PATCH 36/82] fix(web): improve codex subagent lifecycle summaries --- web/src/chat/codexLifecycle.ts | 13 +++++++++++ web/src/chat/reducer.test.ts | 23 +++++++++++++++++++ .../CodexSubagentPreviewCard.test.tsx | 3 ++- .../messages/CodexSubagentPreviewCard.tsx | 9 ++++++-- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/web/src/chat/codexLifecycle.ts b/web/src/chat/codexLifecycle.ts index 77b95944a..bf8822f19 100644 --- a/web/src/chat/codexLifecycle.ts +++ b/web/src/chat/codexLifecycle.ts @@ -127,6 +127,7 @@ function summarizeWaitResult(block: ToolCallBlock, targets: string[]): { status: if (isObject(resultObject.statuses)) { const parts: string[] = [] let status: CodexAgentLifecycleStatus | null = null + let singleTargetMessage: string | null = null for (const target of targets) { const raw = resultObject.statuses[target] const rawStatus = typeof raw === 'string' @@ -136,6 +137,9 @@ function summarizeWaitResult(block: ToolCallBlock, targets: string[]): { status: : isObject(raw) && typeof raw.completed === 'string' ? raw.completed : null + const rawMessage = isObject(raw) && typeof raw.message === 'string' && raw.message.trim().length > 0 + ? raw.message.trim() + : null if (rawStatus) { const normalized = normalizeLifecycleStatus(rawStatus) if (normalized) { @@ -143,6 +147,15 @@ function summarizeWaitResult(block: ToolCallBlock, targets: string[]): { status: } parts.push(`${target}: ${rawStatus}`) } + if (targets.length === 1 && rawMessage) { + singleTargetMessage = rawMessage + } + } + if (singleTargetMessage) { + return { + status, + summary: singleTargetMessage + } } if (parts.length > 0) { return { diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index 569e22a1d..c83ce2388 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -227,4 +227,27 @@ describe('reduceChatBlocks', () => { reduced.blocks.some((block) => block.kind === 'agent-text' && block.text === 'Parent progress update') ).toBe(true) }) + + it('uses the completed child message as lifecycle latest text for single-target waits', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-spawn-call', 'spawn-1', 'CodexSpawnAgent', { message: 'Delegate task' }, 1), + agentToolResult('msg-spawn-result', 'spawn-1', { agent_id: 'agent-1', nickname: 'Solo' }, 2), + agentToolCall('msg-wait-call', 'wait-1', 'CodexWaitAgent', { targets: ['agent-1'] }, 3), + agentToolResult('msg-wait-result', 'wait-1', { + statuses: { + 'agent-1': { + status: 'completed', + message: 'Final child answer' + } + } + }, 4) + ] + + const reduced = reduceChatBlocks(messages, null) + const spawnBlock = reduced.blocks.find( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.name === 'CodexSpawnAgent' + ) + + expect(spawnBlock?.lifecycle?.latestText).toBe('Final child answer') + }) }) diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx index 9c8fc1b20..a3baabc9c 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -131,6 +131,7 @@ describe('CodexSubagentPreviewCard', () => { expect(screen.getByRole('link', { name: 'repo' })).toHaveAttribute('href', 'https://github.com/example/repo') expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument() + expect(screen.getAllByText('Search GitHub trending repositories for React state tooling').length).toBeGreaterThan(0) }) it('renders HappyToolMessage as the lifecycle card for CodexSpawnAgent', () => { @@ -155,7 +156,7 @@ describe('CodexSubagentPreviewCard', () => { fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) - expect(screen.queryByText('Search GitHub trending repositories for React state tooling')).not.toBeInTheDocument() + expect(screen.getAllByText('Search GitHub trending repositories for React state tooling').length).toBeGreaterThan(0) expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() }) diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx index e0593140a..c1093e20b 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx @@ -331,11 +331,16 @@ export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { {actionCount > 0 ? {actionCount} actions : null}
- {lifecycle.latestText ? ( + {summary.prompt ? (
+ {summary.prompt} +
+ ) : null} + {lifecycle.latestText ? ( +
{lifecycle.latestText}
- ) : summary.promptPreview ? ( + ) : !summary.prompt && summary.promptPreview ? (
{summary.promptPreview}
From 2553dd2041372d16075a85f028c5c831ea7c1a39 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Fri, 3 Apr 2026 20:07:45 +0800 Subject: [PATCH 37/82] feat: add importable session rpc contracts --- cli/src/modules/common/rpcTypes.ts | 20 +++++++++++++++ hub/src/sync/rpcGateway.test.ts | 41 ++++++++++++++++++++++++++++++ hub/src/sync/rpcGateway.ts | 27 ++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 hub/src/sync/rpcGateway.test.ts diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 3b1755903..f86868348 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -18,3 +18,23 @@ export type SpawnSessionResult = | { type: 'success'; sessionId: string } | { type: 'requestToApproveDirectoryCreation'; directory: string } | { type: 'error'; errorMessage: string } + +export type ImportableSessionAgent = 'codex' + +export type ImportableCodexSessionSummary = { + agent: 'codex' + externalSessionId: string + cwd: string | null + timestamp: number | null + transcriptPath: string + previewTitle: string | null + previewPrompt: string | null +} + +export type RpcListImportableSessionsRequest = { + agent: ImportableSessionAgent +} + +export type RpcListImportableSessionsResponse = { + sessions: ImportableCodexSessionSummary[] +} diff --git a/hub/src/sync/rpcGateway.test.ts b/hub/src/sync/rpcGateway.test.ts new file mode 100644 index 000000000..52a368111 --- /dev/null +++ b/hub/src/sync/rpcGateway.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'bun:test' +import { RpcGateway } from './rpcGateway' +import { RpcRegistry } from '../socket/rpcRegistry' + +describe('RpcGateway', () => { + it('sends list-importable-sessions rpc requests', async () => { + const registry = new RpcRegistry() + const captured: Array<{ event: string; payload: { method: string; params: string } }> = [] + + const socket = { + id: 'socket-1', + timeout: () => ({ + emitWithAck: async (event: string, payload: { method: string; params: string }) => { + captured.push({ event, payload }) + return { sessions: [] } + } + }) + } + + registry.register(socket as never, 'machine-1:list-importable-sessions') + + const io = { + of: () => ({ + sockets: new Map([['socket-1', socket]]) + }) + } + + const gateway = new RpcGateway(io as never, registry) + + await expect(gateway.listImportableSessions('machine-1', { agent: 'codex' })).resolves.toEqual({ sessions: [] }) + expect(captured).toEqual([ + { + event: 'rpc-request', + payload: { + method: 'machine-1:list-importable-sessions', + params: JSON.stringify({ agent: 'codex' }) + } + } + ]) + }) +}) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index d59ff3b6d..7c54fb3f4 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -2,6 +2,26 @@ import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/type import type { Server } from 'socket.io' import type { RpcRegistry } from '../socket/rpcRegistry' +export type ImportableSessionAgent = 'codex' + +export type ImportableCodexSessionSummary = { + agent: 'codex' + externalSessionId: string + cwd: string | null + timestamp: number | null + transcriptPath: string + previewTitle: string | null + previewPrompt: string | null +} + +export type RpcListImportableSessionsRequest = { + agent: ImportableSessionAgent +} + +export type RpcListImportableSessionsResponse = { + sessions: ImportableCodexSessionSummary[] +} + export type RpcCommandResponse = { success: boolean stdout?: string @@ -230,6 +250,13 @@ export class RpcGateway { } } + async listImportableSessions( + machineId: string, + request: RpcListImportableSessionsRequest + ): Promise { + return await this.machineRpc(machineId, 'list-importable-sessions', request) as RpcListImportableSessionsResponse + } + private async sessionRpc(sessionId: string, method: string, params: unknown): Promise { return await this.rpcCall(`${sessionId}:${method}`, params) } From 34d88446640fbbbb052e2c4985d2ecdbd1bf8681 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Fri, 3 Apr 2026 20:17:38 +0800 Subject: [PATCH 38/82] fix: share importable session rpc types --- cli/src/modules/common/rpcTypes.ts | 27 ++++-------- hub/src/sync/rpcGateway.test.ts | 67 ++++++++++++++++++++++++++++-- hub/src/sync/rpcGateway.ts | 38 ++++++++--------- shared/package.json | 1 + shared/src/index.ts | 1 + shared/src/rpcTypes.ts | 19 +++++++++ 6 files changed, 111 insertions(+), 42 deletions(-) create mode 100644 shared/src/rpcTypes.ts diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index f86868348..9c4308e0d 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -1,3 +1,10 @@ +export type { + ImportableCodexSessionSummary, + ImportableSessionAgent, + RpcListImportableSessionsRequest, + RpcListImportableSessionsResponse +} from '@hapi/protocol/rpcTypes' + export interface SpawnSessionOptions { machineId?: string directory: string @@ -18,23 +25,3 @@ export type SpawnSessionResult = | { type: 'success'; sessionId: string } | { type: 'requestToApproveDirectoryCreation'; directory: string } | { type: 'error'; errorMessage: string } - -export type ImportableSessionAgent = 'codex' - -export type ImportableCodexSessionSummary = { - agent: 'codex' - externalSessionId: string - cwd: string | null - timestamp: number | null - transcriptPath: string - previewTitle: string | null - previewPrompt: string | null -} - -export type RpcListImportableSessionsRequest = { - agent: ImportableSessionAgent -} - -export type RpcListImportableSessionsResponse = { - sessions: ImportableCodexSessionSummary[] -} diff --git a/hub/src/sync/rpcGateway.test.ts b/hub/src/sync/rpcGateway.test.ts index 52a368111..8eabc0cde 100644 --- a/hub/src/sync/rpcGateway.test.ts +++ b/hub/src/sync/rpcGateway.test.ts @@ -3,7 +3,7 @@ import { RpcGateway } from './rpcGateway' import { RpcRegistry } from '../socket/rpcRegistry' describe('RpcGateway', () => { - it('sends list-importable-sessions rpc requests', async () => { + it('sends list-importable-sessions rpc requests and parses the response shape', async () => { const registry = new RpcRegistry() const captured: Array<{ event: string; payload: { method: string; params: string } }> = [] @@ -12,7 +12,21 @@ describe('RpcGateway', () => { timeout: () => ({ emitWithAck: async (event: string, payload: { method: string; params: string }) => { captured.push({ event, payload }) - return { sessions: [] } + return { + sessions: [ + { + agent: 'codex', + externalSessionId: 'session-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/session-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt', + ignoredField: 'strip-me' + } + ], + ignoredResponseField: true + } } }) } @@ -27,7 +41,19 @@ describe('RpcGateway', () => { const gateway = new RpcGateway(io as never, registry) - await expect(gateway.listImportableSessions('machine-1', { agent: 'codex' })).resolves.toEqual({ sessions: [] }) + await expect(gateway.listImportableSessions('machine-1', { agent: 'codex' })).resolves.toEqual({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'session-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/session-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt' + } + ] + }) expect(captured).toEqual([ { event: 'rpc-request', @@ -38,4 +64,39 @@ describe('RpcGateway', () => { } ]) }) + + it('rejects malformed list-importable-sessions responses', async () => { + const registry = new RpcRegistry() + + const socket = { + id: 'socket-1', + timeout: () => ({ + emitWithAck: async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 123, + cwd: '/tmp/project', + timestamp: 'not-a-number', + transcriptPath: '/tmp/project/.codex/sessions/session-1.jsonl', + previewTitle: null, + previewPrompt: null + } + ] + }) + }) + } + + registry.register(socket as never, 'machine-1:list-importable-sessions') + + const io = { + of: () => ({ + sockets: new Map([['socket-1', socket]]) + }) + } + + const gateway = new RpcGateway(io as never, registry) + + await expect(gateway.listImportableSessions('machine-1', { agent: 'codex' })).rejects.toThrow() + }) }) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 7c54fb3f4..21e423a9b 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -1,26 +1,25 @@ import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' +import type { + RpcListImportableSessionsRequest, + RpcListImportableSessionsResponse +} from '@hapi/protocol/rpcTypes' import type { Server } from 'socket.io' +import { z } from 'zod' import type { RpcRegistry } from '../socket/rpcRegistry' -export type ImportableSessionAgent = 'codex' +const importableCodexSessionSummarySchema = z.object({ + agent: z.literal('codex'), + externalSessionId: z.string(), + cwd: z.string().nullable(), + timestamp: z.number().nullable(), + transcriptPath: z.string(), + previewTitle: z.string().nullable(), + previewPrompt: z.string().nullable() +}) -export type ImportableCodexSessionSummary = { - agent: 'codex' - externalSessionId: string - cwd: string | null - timestamp: number | null - transcriptPath: string - previewTitle: string | null - previewPrompt: string | null -} - -export type RpcListImportableSessionsRequest = { - agent: ImportableSessionAgent -} - -export type RpcListImportableSessionsResponse = { - sessions: ImportableCodexSessionSummary[] -} +const listImportableSessionsResponseSchema = z.object({ + sessions: z.array(importableCodexSessionSummarySchema) +}) export type RpcCommandResponse = { success: boolean @@ -254,7 +253,8 @@ export class RpcGateway { machineId: string, request: RpcListImportableSessionsRequest ): Promise { - return await this.machineRpc(machineId, 'list-importable-sessions', request) as RpcListImportableSessionsResponse + const response = await this.machineRpc(machineId, 'list-importable-sessions', request) + return listImportableSessionsResponseSchema.parse(response) } private async sessionRpc(sessionId: string, method: string, params: unknown): Promise { diff --git a/shared/package.json b/shared/package.json index ca7ba8204..835bd2100 100644 --- a/shared/package.json +++ b/shared/package.json @@ -9,6 +9,7 @@ ".": "./src/index.ts", "./messages": "./src/messages.ts", "./modes": "./src/modes.ts", + "./rpcTypes": "./src/rpcTypes.ts", "./schemas": "./src/schemas.ts", "./types": "./src/types.ts", "./voice": "./src/voice.ts" diff --git a/shared/src/index.ts b/shared/src/index.ts index e9e6e3501..bfbd4cd4a 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -4,4 +4,5 @@ export * from './socket' export * from './sessionSummary' export * from './utils' export * from './version' +export type * from './rpcTypes' export type * from './types' diff --git a/shared/src/rpcTypes.ts b/shared/src/rpcTypes.ts new file mode 100644 index 000000000..75df9610e --- /dev/null +++ b/shared/src/rpcTypes.ts @@ -0,0 +1,19 @@ +export type ImportableSessionAgent = 'codex' + +export type ImportableCodexSessionSummary = { + agent: 'codex' + externalSessionId: string + cwd: string | null + timestamp: number | null + transcriptPath: string + previewTitle: string | null + previewPrompt: string | null +} + +export type RpcListImportableSessionsRequest = { + agent: ImportableSessionAgent +} + +export type RpcListImportableSessionsResponse = { + sessions: ImportableCodexSessionSummary[] +} From 8de7b11e54e52423399a5080c48736a2d73e7cc7 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Fri, 3 Apr 2026 20:29:18 +0800 Subject: [PATCH 39/82] feat: scan importable codex sessions --- .../utils/listImportableCodexSessions.test.ts | 178 +++++++++ .../utils/listImportableCodexSessions.ts | 353 ++++++++++++++++++ 2 files changed, 531 insertions(+) create mode 100644 cli/src/codex/utils/listImportableCodexSessions.test.ts create mode 100644 cli/src/codex/utils/listImportableCodexSessions.ts diff --git a/cli/src/codex/utils/listImportableCodexSessions.test.ts b/cli/src/codex/utils/listImportableCodexSessions.test.ts new file mode 100644 index 000000000..230dd67de --- /dev/null +++ b/cli/src/codex/utils/listImportableCodexSessions.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { listImportableCodexSessions } from './listImportableCodexSessions'; + +describe('listImportableCodexSessions', () => { + let testDir: string; + let sessionsRoot: string; + + beforeEach(async () => { + testDir = join(tmpdir(), `codex-importable-sessions-${Date.now()}`); + sessionsRoot = join(testDir, 'sessions'); + await mkdir(sessionsRoot, { recursive: true }); + }); + + afterEach(async () => { + if (existsSync(testDir)) { + await rm(testDir, { recursive: true, force: true }); + } + }); + + it('filters child sessions, prefers the latest root title change, and sorts recent-first', async () => { + const olderDir = join(sessionsRoot, '2026', '04', '03'); + const newerDir = join(sessionsRoot, '2026', '04', '04'); + await mkdir(olderDir, { recursive: true }); + await mkdir(newerDir, { recursive: true }); + + const olderSessionId = 'main-old-session'; + const olderFile = join(olderDir, `codex-${olderSessionId}.jsonl`); + await writeFile( + olderFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: olderSessionId, + cwd: '/work/alpha', + timestamp: '2026-04-03T10:00:00.000Z' + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'user_message', + message: ' build the alpha tools ' + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'session_title_change', + title: 'Alpha draft title' + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', + name: 'mcp__hapi__change_title', + call_id: 'title-call-1', + arguments: JSON.stringify({ title: 'Alpha final title' }) + } + }) + ].join('\n') + '\n' + ); + + const childSessionId = 'child-session'; + const childFile = join(olderDir, `codex-${childSessionId}.jsonl`); + await writeFile( + childFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: childSessionId, + cwd: '/work/alpha', + timestamp: '2026-04-03T11:00:00.000Z', + source: { + subagent: { + thread_spawn: { + parent_thread_id: 'parent-thread-1' + } + } + } + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'user_message', + message: 'delegate this' + } + }) + ].join('\n') + '\n' + ); + + const newerSessionId = 'main-new-session'; + const newerFile = join(newerDir, `codex-${newerSessionId}.jsonl`); + await writeFile( + newerFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: newerSessionId, + cwd: '/work/beta/project', + timestamp: '2026-04-04T08:15:00.000Z' + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'user_message', + message: 'What should we build?' + } + }) + ].join('\n') + '\n' + ); + + const fallbackSessionId = 'fallback-session'; + const fallbackFile = join(newerDir, `codex-${fallbackSessionId}.jsonl`); + await writeFile( + fallbackFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: fallbackSessionId, + cwd: '/work/gamma', + timestamp: '2026-04-02T09:30:00.000Z' + } + }) + ].join('\n') + '\n' + ); + + const result = await listImportableCodexSessions({ rootDir: sessionsRoot }); + + expect(result.sessions.map((session) => session.externalSessionId)).toEqual([ + newerSessionId, + olderSessionId, + fallbackSessionId + ]); + + expect(result.sessions[0]).toMatchObject({ + agent: 'codex', + externalSessionId: newerSessionId, + cwd: '/work/beta/project', + timestamp: Date.parse('2026-04-04T08:15:00.000Z'), + transcriptPath: newerFile, + previewTitle: 'What should we build?', + previewPrompt: 'What should we build?' + }); + + expect(result.sessions[1]).toMatchObject({ + agent: 'codex', + externalSessionId: olderSessionId, + cwd: '/work/alpha', + timestamp: Date.parse('2026-04-03T10:00:00.000Z'), + transcriptPath: olderFile, + previewTitle: 'Alpha final title', + previewPrompt: 'build the alpha tools' + }); + + expect(result.sessions[2]).toMatchObject({ + agent: 'codex', + externalSessionId: fallbackSessionId, + cwd: '/work/gamma', + timestamp: Date.parse('2026-04-02T09:30:00.000Z'), + transcriptPath: fallbackFile, + previewTitle: 'gamma', + previewPrompt: null + }); + + expect(result.sessions.find((session) => session.externalSessionId === childSessionId)).toBeUndefined(); + }); +}); diff --git a/cli/src/codex/utils/listImportableCodexSessions.ts b/cli/src/codex/utils/listImportableCodexSessions.ts new file mode 100644 index 000000000..be6eccb33 --- /dev/null +++ b/cli/src/codex/utils/listImportableCodexSessions.ts @@ -0,0 +1,353 @@ +import { homedir } from 'node:os'; +import { basename, join } from 'node:path'; +import { readdir, readFile } from 'node:fs/promises'; + +export type ImportableCodexSessionSummary = { + agent: 'codex'; + externalSessionId: string; + cwd: string | null; + timestamp: number | null; + transcriptPath: string; + previewTitle: string | null; + previewPrompt: string | null; +}; + +export type ListImportableCodexSessionsOptions = { + rootDir?: string; +}; + +export async function listImportableCodexSessions( + opts: ListImportableCodexSessionsOptions = {} +): Promise<{ sessions: ImportableCodexSessionSummary[] }> { + const sessionsRoot = opts.rootDir?.trim() ? opts.rootDir : getCodexSessionsRoot(); + const transcriptPaths = (await collectJsonlFiles(sessionsRoot)).sort((a, b) => a.localeCompare(b)); + const summaries = (await Promise.all(transcriptPaths.map(async (transcriptPath) => scanCodexTranscript(transcriptPath)))) + .filter((summary): summary is ImportableCodexSessionSummary => summary !== null); + + summaries.sort(compareImportableCodexSessions); + + return { sessions: summaries }; +} + +async function scanCodexTranscript(transcriptPath: string): Promise { + let content: string; + try { + content = await readFile(transcriptPath, 'utf-8'); + } catch { + return null; + } + + const lines = content.split(/\r?\n/); + const firstNonEmptyLineIndex = lines.findIndex((line) => line.trim().length > 0); + if (firstNonEmptyLineIndex === -1) { + return null; + } + + const sessionMeta = parseJsonLine(lines[firstNonEmptyLineIndex]); + if (!isSessionMetaRecord(sessionMeta)) { + return null; + } + + const payload = getRecord(sessionMeta.payload); + const externalSessionId = getString(payload?.id); + if (!externalSessionId) { + return null; + } + + if (isChildCodexSession(payload)) { + return null; + } + + const cwd = getString(payload?.cwd); + const timestamp = parseTimestamp(payload?.timestamp); + + let latestRootTitleChange: string | null = null; + let firstRootPrompt: string | null = null; + + for (let index = firstNonEmptyLineIndex + 1; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (!line) { + continue; + } + + const record = parseJsonLine(line); + if (!record) { + continue; + } + + if (isRootTitleChangeRecord(record)) { + const title = extractTitleFromRecord(record); + if (title) { + latestRootTitleChange = title; + } + continue; + } + + const prompt = extractRootPromptFromRecord(record); + if (prompt && !firstRootPrompt) { + firstRootPrompt = prompt; + } + } + + const previewPrompt = firstRootPrompt; + const previewTitle = latestRootTitleChange + ?? firstRootPrompt + ?? deriveCwdPreview(cwd) + ?? shortExternalSessionId(externalSessionId); + + return { + agent: 'codex', + externalSessionId, + cwd, + timestamp, + transcriptPath, + previewTitle, + previewPrompt + }; +} + +function getCodexSessionsRoot(): string { + const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'); + return join(codexHome, 'sessions'); +} + +async function collectJsonlFiles(root: string): Promise { + try { + const entries = await readdir(root, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = join(root, entry.name); + if (entry.isDirectory()) { + files.push(...await collectJsonlFiles(fullPath)); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } + } + + return files; + } catch { + return []; + } +} + +function parseJsonLine(line: string): Record | null { + try { + const parsed = JSON.parse(line) as unknown; + return getRecord(parsed); + } catch { + return null; + } +} + +function isSessionMetaRecord(value: Record | null): value is Record { + return getString(value?.type) === 'session_meta' && getRecord(value?.payload) !== null; +} + +function isChildCodexSession(payload: Record | null): boolean { + return hasNestedValue(payload, ['source', 'subagent', 'thread_spawn', 'parent_thread_id']); +} + +function isRootTitleChangeRecord(record: Record): boolean { + if (isSidechainRecord(record)) { + return false; + } + + if (getString(record.type) === 'session_title_change') { + return true; + } + + const payload = getRecord(record.payload); + if (!payload) { + return false; + } + + const payloadType = getString(payload.type); + if (payloadType === 'session_title_change') { + return true; + } + + if (payloadType !== 'function_call' && payloadType !== 'mcpToolCall') { + return false; + } + + const toolName = getString(payload.name ?? payload.tool); + return typeof toolName === 'string' && toolName.endsWith('change_title'); +} + +function extractTitleFromRecord(record: Record): string | null { + const payload = getRecord(record.payload); + if (!payload) { + return getString(record.title) ?? null; + } + + const payloadType = getString(payload.type); + if (payloadType === 'session_title_change') { + return getString(payload.title); + } + + if (payloadType === 'function_call' || payloadType === 'mcpToolCall') { + const argumentsValue = payload.arguments ?? payload.arguments_json ?? payload.input; + const argumentsRecord = parseMaybeJson(argumentsValue); + const title = getString(argumentsRecord?.title); + if (title) { + return normalizePreviewText(title); + } + } + + return getString(payload.title) ?? getString(record.title) ?? null; +} + +function extractRootPromptFromRecord(record: Record): string | null { + if (isSidechainRecord(record)) { + return null; + } + + const type = getString(record.type); + const payload = getRecord(record.payload); + + if (type === 'event_msg' || type === 'event') { + const eventType = getString(payload?.type); + if (eventType === 'user_message') { + return extractMessageFromValue(payload); + } + } + + if (type === 'user_message') { + return extractMessageFromValue(record); + } + + if (type === 'response_item' || type === 'item') { + const itemType = getString(payload?.type); + if (itemType === 'user_message') { + return extractMessageFromValue(payload); + } + } + + return null; +} + +function extractMessageFromValue(value: Record | null): string | null { + if (!value) { + return null; + } + + const message = getString(value.message) ?? getString(value.text) ?? getString(value.content); + return message ? normalizePreviewText(message) : null; +} + +function isSidechainRecord(record: Record): boolean { + if (record.hapiSidechain && typeof record.hapiSidechain === 'object') { + return true; + } + + const payload = getRecord(record.payload); + if (!payload) { + return false; + } + + if (payload.parent_tool_call_id || payload.parentToolCallId || payload.isSidechain) { + return true; + } + + return hasNestedValue(payload, ['hapiSidechain']); +} + +function parseMaybeJson(value: unknown): Record | null { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === 'object') { + return getRecord(value); + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + try { + return getRecord(JSON.parse(trimmed)); + } catch { + return null; + } + } + + return null; +} + +function parseTimestamp(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + + return null; +} + +function deriveCwdPreview(cwd: string | null): string | null { + if (!cwd) { + return null; + } + + const trimmed = cwd.trim(); + if (!trimmed) { + return null; + } + + const segment = basename(trimmed); + return segment.length > 0 ? normalizePreviewText(segment) : null; +} + +function shortExternalSessionId(externalSessionId: string): string { + return externalSessionId.length > 8 ? externalSessionId.slice(0, 8) : externalSessionId; +} + +function normalizePreviewText(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function compareImportableCodexSessions( + left: ImportableCodexSessionSummary, + right: ImportableCodexSessionSummary +): number { + const leftTimestamp = left.timestamp ?? Number.NEGATIVE_INFINITY; + const rightTimestamp = right.timestamp ?? Number.NEGATIVE_INFINITY; + + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp - leftTimestamp; + } + + return left.transcriptPath.localeCompare(right.transcriptPath); +} + +function getRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object') { + return null; + } + + return value as Record; +} + +function getString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function hasNestedValue(value: Record | null, path: string[]): boolean { + let current: unknown = value; + + for (const segment of path) { + if (!current || typeof current !== 'object') { + return false; + } + + current = (current as Record)[segment]; + } + + return current !== undefined && current !== null && (!(typeof current === 'string') || current.length > 0); +} From d0f0262d5c3b0b5ca29b9d2be3f936aa8e041a30 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Fri, 3 Apr 2026 20:43:09 +0800 Subject: [PATCH 40/82] Add machine RPC for importable Codex sessions --- cli/src/api/apiMachine.test.ts | 85 ++++++++++++++++++++++++++++++++++ cli/src/api/apiMachine.ts | 19 +++++++- 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 cli/src/api/apiMachine.test.ts diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts new file mode 100644 index 000000000..eb6dca664 --- /dev/null +++ b/cli/src/api/apiMachine.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const listImportableCodexSessionsMock = vi.hoisted(() => vi.fn()) +const importableSessionsResponse = { + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-session-1', + cwd: '/work/project', + timestamp: 1712131200000, + transcriptPath: '/sessions/codex-session-1.jsonl', + previewTitle: 'Project draft', + previewPrompt: 'Build the project' + } + ] +} + +vi.mock('@/codex/utils/listImportableCodexSessions', () => ({ + listImportableCodexSessions: listImportableCodexSessionsMock +})) + +vi.mock('@/modules/common/registerCommonHandlers', () => ({ + registerCommonHandlers: vi.fn() +})) + +vi.mock('@/utils/invokedCwd', () => ({ + getInvokedCwd: vi.fn(() => '/workspace') +})) + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn() + } +})) + +import { ApiMachineClient } from './apiMachine' + +describe('ApiMachineClient list-importable-sessions RPC', () => { + beforeEach(() => { + listImportableCodexSessionsMock.mockReset() + listImportableCodexSessionsMock.mockResolvedValue(importableSessionsResponse) + }) + + it('registers the RPC and returns codex scanner results only for codex', async () => { + const machine = { + id: 'machine-1', + metadata: null, + metadataVersion: 0, + runnerState: null, + runnerStateVersion: 0 + } as never + + const client = new ApiMachineClient('token', machine) + client.setRPCHandlers({ + spawnSession: vi.fn(), + stopSession: vi.fn(), + requestShutdown: vi.fn() + }) + + const rpcManager = client as unknown as { + rpcHandlerManager: { + hasHandler: (method: string) => boolean + handleRequest: (request: { method: string; params: string }) => Promise + } + } + + expect(rpcManager.rpcHandlerManager.hasHandler('list-importable-sessions')).toBe(true) + + await expect( + rpcManager.rpcHandlerManager.handleRequest({ + method: 'machine-1:list-importable-sessions', + params: JSON.stringify({ agent: 'codex' }) + }) + ).resolves.toBe(JSON.stringify(importableSessionsResponse)) + + await expect( + rpcManager.rpcHandlerManager.handleRequest({ + method: 'machine-1:list-importable-sessions', + params: JSON.stringify({ agent: 'claude' }) + }) + ).resolves.toBe(JSON.stringify({ sessions: [] })) + + expect(listImportableCodexSessionsMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 42a25f835..41bf2b97a 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -13,7 +13,13 @@ import { backoff } from '@/utils/time' import { getInvokedCwd } from '@/utils/invokedCwd' import { RpcHandlerManager } from './rpc/RpcHandlerManager' import { registerCommonHandlers } from '../modules/common/registerCommonHandlers' -import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' +import type { + RpcListImportableSessionsRequest, + RpcListImportableSessionsResponse, + SpawnSessionOptions, + SpawnSessionResult +} from '../modules/common/rpcTypes' +import { listImportableCodexSessions } from '@/codex/utils/listImportableCodexSessions' import { applyVersionedAck } from './versionedUpdate' interface ServerToRunnerEvents { @@ -101,6 +107,17 @@ export class ApiMachineClient { } setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { + this.rpcHandlerManager.registerHandler( + 'list-importable-sessions', + async (params) => { + if (params?.agent !== 'codex') { + return { sessions: [] } + } + + return await listImportableCodexSessions() + } + ) + this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, token, sessionType, worktreeName } = params || {} From 868aa691819f39c6cb580aff16c5f0b712865b74 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Fri, 3 Apr 2026 20:54:38 +0800 Subject: [PATCH 41/82] fix: harden codex importable session scan --- .../utils/listImportableCodexSessions.test.ts | 90 ++++++++++-- .../utils/listImportableCodexSessions.ts | 136 ++++++++++++------ 2 files changed, 168 insertions(+), 58 deletions(-) diff --git a/cli/src/codex/utils/listImportableCodexSessions.test.ts b/cli/src/codex/utils/listImportableCodexSessions.test.ts index 230dd67de..1c5ce1f9c 100644 --- a/cli/src/codex/utils/listImportableCodexSessions.test.ts +++ b/cli/src/codex/utils/listImportableCodexSessions.test.ts @@ -21,21 +21,43 @@ describe('listImportableCodexSessions', () => { } }); - it('filters child sessions, prefers the latest root title change, and sorts recent-first', async () => { + it('filters child lineage blocks based on the current main segment and sorts recent-first', async () => { const olderDir = join(sessionsRoot, '2026', '04', '03'); const newerDir = join(sessionsRoot, '2026', '04', '04'); await mkdir(olderDir, { recursive: true }); await mkdir(newerDir, { recursive: true }); - const olderSessionId = 'main-old-session'; - const olderFile = join(olderDir, `codex-${olderSessionId}.jsonl`); + const currentMainSessionId = 'main-current-session'; + const currentMainFile = join(olderDir, `codex-${currentMainSessionId}.jsonl`); await writeFile( - olderFile, + currentMainFile, [ JSON.stringify({ type: 'session_meta', payload: { - id: olderSessionId, + id: 'child-lineage-session', + cwd: '/work/alpha', + timestamp: '2026-04-03T09:00:00.000Z', + source: { + subagent: { + thread_spawn: { + parent_thread_id: 'parent-thread-1' + } + } + } + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'user_message', + message: 'ignored child prompt' + } + }), + JSON.stringify({ + type: 'session_meta', + payload: { + id: currentMainSessionId, cwd: '/work/alpha', timestamp: '2026-04-03T10:00:00.000Z' } @@ -44,7 +66,10 @@ describe('listImportableCodexSessions', () => { type: 'event_msg', payload: { type: 'user_message', - message: ' build the alpha tools ' + content: [ + { type: 'text', text: ' build the alpha tools ' }, + { type: 'text', text: 'now' } + ] } }), JSON.stringify({ @@ -60,7 +85,39 @@ describe('listImportableCodexSessions', () => { type: 'function_call', name: 'mcp__hapi__change_title', call_id: 'title-call-1', - arguments: JSON.stringify({ title: 'Alpha final title' }) + arguments: JSON.stringify({ + title: [ + { type: 'text', text: 'Alpha final' }, + { type: 'text', text: 'title' } + ] + }) + } + }) + ].join('\n') + '\n' + ); + + const malformedLeadingLineSessionId = 'malformed-leading-line-session'; + const malformedLeadingLineFile = join(olderDir, `codex-${malformedLeadingLineSessionId}.jsonl`); + await writeFile( + malformedLeadingLineFile, + [ + '{not valid json', + JSON.stringify({ + type: 'session_meta', + payload: { + id: malformedLeadingLineSessionId, + cwd: '/work/malformed', + timestamp: '2026-04-03T09:30:00.000Z' + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'user_message', + message: [ + { type: 'text', text: 'recoverable' }, + { type: 'text', text: 'transcript' } + ] } }) ].join('\n') + '\n' @@ -139,7 +196,8 @@ describe('listImportableCodexSessions', () => { expect(result.sessions.map((session) => session.externalSessionId)).toEqual([ newerSessionId, - olderSessionId, + currentMainSessionId, + malformedLeadingLineSessionId, fallbackSessionId ]); @@ -155,15 +213,25 @@ describe('listImportableCodexSessions', () => { expect(result.sessions[1]).toMatchObject({ agent: 'codex', - externalSessionId: olderSessionId, + externalSessionId: currentMainSessionId, cwd: '/work/alpha', timestamp: Date.parse('2026-04-03T10:00:00.000Z'), - transcriptPath: olderFile, + transcriptPath: currentMainFile, previewTitle: 'Alpha final title', - previewPrompt: 'build the alpha tools' + previewPrompt: 'build the alpha tools now' }); expect(result.sessions[2]).toMatchObject({ + agent: 'codex', + externalSessionId: malformedLeadingLineSessionId, + cwd: '/work/malformed', + timestamp: Date.parse('2026-04-03T09:30:00.000Z'), + transcriptPath: malformedLeadingLineFile, + previewTitle: 'recoverable transcript', + previewPrompt: 'recoverable transcript' + }); + + expect(result.sessions[3]).toMatchObject({ agent: 'codex', externalSessionId: fallbackSessionId, cwd: '/work/gamma', diff --git a/cli/src/codex/utils/listImportableCodexSessions.ts b/cli/src/codex/utils/listImportableCodexSessions.ts index be6eccb33..5b0e3d3a1 100644 --- a/cli/src/codex/utils/listImportableCodexSessions.ts +++ b/cli/src/codex/utils/listImportableCodexSessions.ts @@ -1,16 +1,7 @@ import { homedir } from 'node:os'; import { basename, join } from 'node:path'; import { readdir, readFile } from 'node:fs/promises'; - -export type ImportableCodexSessionSummary = { - agent: 'codex'; - externalSessionId: string; - cwd: string | null; - timestamp: number | null; - transcriptPath: string; - previewTitle: string | null; - previewPrompt: string | null; -}; +import type { ImportableCodexSessionSummary } from '@hapi/protocol/rpcTypes'; export type ListImportableCodexSessionsOptions = { rootDir?: string; @@ -38,17 +29,27 @@ async function scanCodexTranscript(transcriptPath: string): Promise line.trim().length > 0); - if (firstNonEmptyLineIndex === -1) { + const records = lines + .map((line, lineIndex) => ({ + lineIndex, + record: parseJsonLine(line) + })) + .filter((entry): entry is { lineIndex: number; record: Record } => entry.record !== null); + + const sessionMetaEntries = records.filter((entry) => isSessionMetaRecord(entry.record)); + if (sessionMetaEntries.length === 0) { return null; } - const sessionMeta = parseJsonLine(lines[firstNonEmptyLineIndex]); - if (!isSessionMetaRecord(sessionMeta)) { + const sessionMetaEntry = [...sessionMetaEntries].reverse().find((entry) => { + const payload = getRecord(entry.record.payload); + return getString(payload?.id) !== null; + }); + if (!sessionMetaEntry) { return null; } - const payload = getRecord(sessionMeta.payload); + const payload = getRecord(sessionMetaEntry.record.payload); const externalSessionId = getString(payload?.id); if (!externalSessionId) { return null; @@ -64,26 +65,20 @@ async function scanCodexTranscript(transcriptPath: string): Promise): boolean { function extractTitleFromRecord(record: Record): string | null { const payload = getRecord(record.payload); if (!payload) { - return getString(record.title) ?? null; + return extractTextValue(record.title); } const payloadType = getString(payload.type); if (payloadType === 'session_title_change') { - return getString(payload.title); + return extractTextValue(payload.title); } if (payloadType === 'function_call' || payloadType === 'mcpToolCall') { const argumentsValue = payload.arguments ?? payload.arguments_json ?? payload.input; - const argumentsRecord = parseMaybeJson(argumentsValue); - const title = getString(argumentsRecord?.title); + const argumentsValueRecord = parseMaybeJson(argumentsValue); + const title = extractTextValue(argumentsValueRecord?.title ?? argumentsValueRecord); if (title) { - return normalizePreviewText(title); + return title; } } - return getString(payload.title) ?? getString(record.title) ?? null; + return extractTextValue(payload.title) ?? extractTextValue(record.title); } function extractRootPromptFromRecord(record: Record): string | null { @@ -205,37 +200,40 @@ function extractRootPromptFromRecord(record: Record): string | const type = getString(record.type); const payload = getRecord(record.payload); + const promptSources = [ + payload?.message, + payload?.text, + payload?.content, + payload?.input, + payload?.body, + record.message, + record.text, + record.content, + record.input, + record.body + ]; if (type === 'event_msg' || type === 'event') { const eventType = getString(payload?.type); - if (eventType === 'user_message') { - return extractMessageFromValue(payload); + if (eventType === 'user_message' || eventType === 'userMessage') { + return extractTextValue(promptSources); } } - if (type === 'user_message') { - return extractMessageFromValue(record); + if (type === 'user_message' || type === 'userMessage') { + return extractTextValue(promptSources); } if (type === 'response_item' || type === 'item') { const itemType = getString(payload?.type); - if (itemType === 'user_message') { - return extractMessageFromValue(payload); + if (itemType === 'user_message' || itemType === 'userMessage') { + return extractTextValue(promptSources); } } return null; } -function extractMessageFromValue(value: Record | null): string | null { - if (!value) { - return null; - } - - const message = getString(value.message) ?? getString(value.text) ?? getString(value.content); - return message ? normalizePreviewText(message) : null; -} - function isSidechainRecord(record: Record): boolean { if (record.hapiSidechain && typeof record.hapiSidechain === 'object') { return true; @@ -277,6 +275,50 @@ function parseMaybeJson(value: unknown): Record | null { return null; } +function extractTextValue(value: unknown): string | null { + const chunks = extractTextChunks(value); + if (chunks.length === 0) { + return null; + } + + return normalizePreviewText(chunks.join(' ')); +} + +function extractTextChunks(value: unknown): string[] { + if (typeof value === 'string') { + const normalized = normalizePreviewText(value); + return normalized ? [normalized] : []; + } + + if (Array.isArray(value)) { + const chunks: string[] = []; + for (const entry of value) { + chunks.push(...extractTextChunks(entry)); + } + return chunks; + } + + const record = getRecord(value); + if (!record) { + return []; + } + + const directKeys = ['title', 'message', 'text', 'content', 'input', 'body'] as const; + + for (const key of directKeys) { + const entryValue = record[key]; + if (entryValue === undefined || entryValue === null) { + continue; + } + const chunks = extractTextChunks(entryValue); + if (chunks.length > 0) { + return chunks; + } + } + + return []; +} + function parseTimestamp(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) { return value; From 12ed6ccac1c26fde33ed67017a426abff5de63ac Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Fri, 3 Apr 2026 20:57:07 +0800 Subject: [PATCH 42/82] fix: register machine rpc during connect --- cli/src/api/apiMachine.test.ts | 108 +++++++++++++++++++++++++-------- cli/src/api/apiMachine.ts | 63 ++++++++++--------- 2 files changed, 116 insertions(+), 55 deletions(-) diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index eb6dca664..e989ad62a 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -1,6 +1,39 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +type FakeSocket = { + handlers: Map void> + emitted: Array<{ event: string, payload: unknown }> + on: (event: string, handler: (...args: any[]) => void) => FakeSocket + emit: (event: string, payload: unknown) => void + emitWithAck: (event: string, payload: unknown) => Promise + close: () => void +} + const listImportableCodexSessionsMock = vi.hoisted(() => vi.fn()) +const fakeSocket = vi.hoisted(() => ({ + handlers: new Map(), + emitted: [], + on(event, handler) { + this.handlers.set(event, handler) + return this + }, + emit(event, payload) { + this.emitted.push({ event, payload }) + }, + emitWithAck: vi.fn(async (event: string) => { + if (event === 'machine-update-state') { + return { result: 'success', version: 1, runnerState: null } + } + + if (event === 'machine-update-metadata') { + return { result: 'success', version: 1, metadata: null } + } + + return { result: 'success', version: 1 } + }), + close() {} +})) + const importableSessionsResponse = { sessions: [ { @@ -15,6 +48,10 @@ const importableSessionsResponse = { ] } +vi.mock('socket.io-client', () => ({ + io: vi.fn(() => fakeSocket) +})) + vi.mock('@/codex/utils/listImportableCodexSessions', () => ({ listImportableCodexSessions: listImportableCodexSessionsMock })) @@ -37,11 +74,14 @@ import { ApiMachineClient } from './apiMachine' describe('ApiMachineClient list-importable-sessions RPC', () => { beforeEach(() => { + fakeSocket.handlers.clear() + fakeSocket.emitted.length = 0 + vi.mocked(fakeSocket.emitWithAck).mockClear() listImportableCodexSessionsMock.mockReset() listImportableCodexSessionsMock.mockResolvedValue(importableSessionsResponse) }) - it('registers the RPC and returns codex scanner results only for codex', async () => { + it('registers the RPC during connect and returns codex scanner results only for codex', async () => { const machine = { id: 'machine-1', metadata: null, @@ -51,35 +91,53 @@ describe('ApiMachineClient list-importable-sessions RPC', () => { } as never const client = new ApiMachineClient('token', machine) - client.setRPCHandlers({ - spawnSession: vi.fn(), - stopSession: vi.fn(), - requestShutdown: vi.fn() - }) + client.connect() - const rpcManager = client as unknown as { - rpcHandlerManager: { - hasHandler: (method: string) => boolean - handleRequest: (request: { method: string; params: string }) => Promise - } - } + const connectHandler = fakeSocket.handlers.get('connect') + expect(connectHandler).toBeTypeOf('function') + connectHandler?.() + + expect(fakeSocket.emitted).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: 'rpc-register', + payload: { method: 'machine-1:path-exists' } + }), + expect.objectContaining({ + event: 'rpc-register', + payload: { method: 'machine-1:list-importable-sessions' } + }) + ]) + ) - expect(rpcManager.rpcHandlerManager.hasHandler('list-importable-sessions')).toBe(true) + const rpcRequestHandler = fakeSocket.handlers.get('rpc-request') + expect(rpcRequestHandler).toBeTypeOf('function') - await expect( - rpcManager.rpcHandlerManager.handleRequest({ - method: 'machine-1:list-importable-sessions', - params: JSON.stringify({ agent: 'codex' }) - }) - ).resolves.toBe(JSON.stringify(importableSessionsResponse)) + const codexResponse = await new Promise((resolve) => { + rpcRequestHandler?.( + { + method: 'machine-1:list-importable-sessions', + params: JSON.stringify({ agent: 'codex' }) + }, + resolve + ) + }) + + expect(codexResponse).toBe(JSON.stringify(importableSessionsResponse)) - await expect( - rpcManager.rpcHandlerManager.handleRequest({ - method: 'machine-1:list-importable-sessions', - params: JSON.stringify({ agent: 'claude' }) - }) - ).resolves.toBe(JSON.stringify({ sessions: [] })) + const missingAgentResponse = await new Promise((resolve) => { + rpcRequestHandler?.( + { + method: 'machine-1:list-importable-sessions', + params: JSON.stringify({}) + }, + resolve + ) + }) + expect(missingAgentResponse).toBe(JSON.stringify({ sessions: [] })) expect(listImportableCodexSessionsMock).toHaveBeenCalledTimes(1) + + client.shutdown() }) }) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 41bf2b97a..e6c48116e 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -85,39 +85,10 @@ export class ApiMachineClient { }) registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd()) - - this.rpcHandlerManager.registerHandler('path-exists', async (params) => { - const rawPaths = Array.isArray(params?.paths) ? params.paths : [] - const uniquePaths = Array.from(new Set(rawPaths.filter((path): path is string => typeof path === 'string'))) - const exists: Record = {} - - await Promise.all(uniquePaths.map(async (path) => { - const trimmed = path.trim() - if (!trimmed) return - try { - const stats = await stat(trimmed) - exists[trimmed] = stats.isDirectory() - } catch { - exists[trimmed] = false - } - })) - - return { exists } - }) + this.registerMachineHandlers() } setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { - this.rpcHandlerManager.registerHandler( - 'list-importable-sessions', - async (params) => { - if (params?.agent !== 'codex') { - return { sessions: [] } - } - - return await listImportableCodexSessions() - } - ) - this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, token, sessionType, worktreeName } = params || {} @@ -171,6 +142,38 @@ export class ApiMachineClient { }) } + private registerMachineHandlers(): void { + this.rpcHandlerManager.registerHandler('path-exists', async (params) => { + const rawPaths = Array.isArray(params?.paths) ? params.paths : [] + const uniquePaths = Array.from(new Set(rawPaths.filter((path): path is string => typeof path === 'string'))) + const exists: Record = {} + + await Promise.all(uniquePaths.map(async (path) => { + const trimmed = path.trim() + if (!trimmed) return + try { + const stats = await stat(trimmed) + exists[trimmed] = stats.isDirectory() + } catch { + exists[trimmed] = false + } + })) + + return { exists } + }) + + this.rpcHandlerManager.registerHandler( + 'list-importable-sessions', + async (params) => { + if (params?.agent !== 'codex') { + return { sessions: [] } + } + + return await listImportableCodexSessions() + } + ) + } + async updateMachineMetadata(handler: (metadata: MachineMetadata | null) => MachineMetadata): Promise { await backoff(async () => { const updated = handler(this.machine.metadata) From d5c10d5fe3a411029e407c905ab3e23631fecdb7 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Fri, 3 Apr 2026 22:59:08 +0800 Subject: [PATCH 43/82] feat: import existing codex sessions in web --- cli/src/codex/codexRemoteLauncher.test.ts | 65 +- cli/src/codex/codexRemoteLauncher.ts | 38 ++ hub/src/store/index.ts | 12 + hub/src/store/messages.ts | 7 +- hub/src/sync/sessionCache.ts | 135 +++-- hub/src/sync/syncEngine.test.ts | 565 ++++++++++++++++++ hub/src/sync/syncEngine.ts | 338 ++++++++++- hub/src/web/routes/importableSessions.test.ts | 166 +++++ hub/src/web/routes/importableSessions.ts | 86 +++ hub/src/web/server.ts | 2 + web/src/api/client.ts | 21 + .../NewSession/ImportExistingModal.test.tsx | 146 +++++ .../NewSession/ImportExistingModal.tsx | 160 +++++ .../NewSession/ImportableSessionList.tsx | 137 +++++ web/src/components/NewSession/index.tsx | 22 + web/src/components/ui/dialog.tsx | 2 +- .../mutations/useImportableSessionActions.ts | 55 ++ .../hooks/queries/useImportableSessions.ts | 33 + web/src/lib/locales/en.ts | 22 + web/src/lib/locales/zh-CN.ts | 22 + web/src/lib/query-keys.ts | 1 + web/src/types/api.ts | 21 + 22 files changed, 1973 insertions(+), 83 deletions(-) create mode 100644 hub/src/sync/syncEngine.test.ts create mode 100644 hub/src/web/routes/importableSessions.test.ts create mode 100644 hub/src/web/routes/importableSessions.ts create mode 100644 web/src/components/NewSession/ImportExistingModal.test.tsx create mode 100644 web/src/components/NewSession/ImportExistingModal.tsx create mode 100644 web/src/components/NewSession/ImportableSessionList.tsx create mode 100644 web/src/hooks/mutations/useImportableSessionActions.ts create mode 100644 web/src/hooks/queries/useImportableSessions.ts diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index b19384c9b..12e36f66c 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -7,6 +7,9 @@ const harness = vi.hoisted(() => ({ extraNotifications: [] as Array<{ method: string; params: unknown }>, registerRequestCalls: [] as string[], initializeCalls: [] as unknown[], + resolveSessionFileCalls: [] as string[], + sessionScannerCalls: [] as Array>, + replayEvents: [] as unknown[], startThreadCalls: [] as unknown[], resumeThreadCalls: [] as unknown[], startTurnCalls: [] as unknown[], @@ -92,6 +95,33 @@ vi.mock('./utils/buildHapiMcpBridge', () => ({ }) })); +vi.mock('./utils/resolveCodexSessionFile', () => ({ + resolveCodexSessionFile: async (sessionId: string) => { + harness.resolveSessionFileCalls.push(sessionId); + return { + status: 'found', + filePath: `/tmp/${sessionId}.jsonl`, + cwd: '/tmp/hapi-update', + timestamp: 1234567890 + }; + } +})); + +vi.mock('./utils/codexSessionScanner', () => ({ + createCodexSessionScanner: async (opts: { + onEvent: (event: unknown) => void; + }) => { + harness.sessionScannerCalls.push(opts as Record); + for (const event of harness.replayEvents) { + opts.onEvent(event); + } + return { + cleanup: async () => {}, + onNewSession: () => {} + }; + } +})); + import { codexRemoteLauncher } from './codexRemoteLauncher'; type FakeAgentState = { @@ -113,6 +143,7 @@ function createSessionStub(overrides?: { sessionId?: string | null }) { const sessionEvents: Array<{ type: string; [key: string]: unknown }> = []; const codexMessages: unknown[] = []; + const userMessages: Array<{ text: string; meta?: unknown }> = []; const thinkingChanges: boolean[] = []; const foundSessionIds: string[] = []; let currentModel: string | null | undefined; @@ -134,7 +165,9 @@ function createSessionStub(overrides?: { sessionId?: string | null }) { sendAgentMessage(message: unknown) { codexMessages.push(message); }, - sendUserMessage(_text: string) {}, + sendUserMessage(text: string, meta?: unknown) { + userMessages.push({ text, meta }); + }, sendSessionEvent(event: { type: string; [key: string]: unknown }) { sessionEvents.push(event); } @@ -172,8 +205,8 @@ function createSessionStub(overrides?: { sessionId?: string | null }) { sendSessionEvent(event: { type: string; [key: string]: unknown }) { client.sendSessionEvent(event); }, - sendUserMessage(text: string) { - client.sendUserMessage(text); + sendUserMessage(text: string, meta?: unknown) { + client.sendUserMessage(text, meta); } }; @@ -181,6 +214,7 @@ function createSessionStub(overrides?: { sessionId?: string | null }) { session, sessionEvents, codexMessages, + userMessages, thinkingChanges, foundSessionIds, rpcHandlers, @@ -195,6 +229,9 @@ describe('codexRemoteLauncher', () => { harness.extraNotifications = []; harness.registerRequestCalls = []; harness.initializeCalls = []; + harness.resolveSessionFileCalls = []; + harness.sessionScannerCalls = []; + harness.replayEvents = []; harness.startThreadCalls = []; harness.resumeThreadCalls = []; harness.startTurnCalls = []; @@ -228,6 +265,28 @@ describe('codexRemoteLauncher', () => { expect(session.thinking).toBe(false); }); + it('replays transcript history during explicit remote resume before live turns', async () => { + harness.replayEvents = [ + { type: 'event_msg', payload: { type: 'user_message', message: 'existing user prompt' } }, + { type: 'event_msg', payload: { type: 'agent_message', message: 'existing assistant reply' } } + ]; + const { + session, + codexMessages, + userMessages + } = createSessionStub({ sessionId: 'resume-thread-123' }); + + await codexRemoteLauncher(session as never); + + expect(harness.resolveSessionFileCalls).toEqual(['resume-thread-123']); + expect(harness.sessionScannerCalls).toHaveLength(1); + expect(userMessages).toContainEqual({ text: 'existing user prompt', meta: undefined }); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'message', + message: 'existing assistant reply' + })); + }); + it('does not report explicit resume failure when resume succeeds but turn startup fails', async () => { harness.startTurnError = new Error('turn start failed'); const { diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index be89f7dbd..2c01f0e6d 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -16,6 +16,9 @@ import { AppServerEventConverter } from './utils/appServerEventConverter'; import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig'; import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard'; +import { createCodexSessionScanner } from './utils/codexSessionScanner'; +import { convertCodexEvent } from './utils/codexEventConverter'; +import { resolveCodexSessionFile } from './utils/resolveCodexSessionFile'; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, @@ -117,6 +120,39 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const appServerClient = this.appServerClient; const appServerEventConverter = new AppServerEventConverter(); + const replayExplicitResumeTranscript = async (): Promise => { + const resumeSessionId = session.sessionId; + if (!resumeSessionId) { + return; + } + + const resolvedSessionFile = await resolveCodexSessionFile(resumeSessionId); + if (resolvedSessionFile.status !== 'found') { + logger.debug(`[Codex] No transcript replay available for explicit remote resume ${resumeSessionId} (${resolvedSessionFile.status})`); + return; + } + + const scanner = await createCodexSessionScanner({ + sessionId: resumeSessionId, + cwd: session.path, + startupTimestampMs: Date.now(), + resolvedSessionFile, + onEvent: (event) => { + const converted = convertCodexEvent(event); + if (converted?.sessionId) { + session.onSessionFound(converted.sessionId); + } + if (converted?.userMessage) { + session.sendUserMessage(converted.userMessage, converted.userMessageMeta); + } + if (converted?.message) { + session.sendAgentMessage(converted.message); + } + } + }); + await scanner.cleanup(); + }; + const normalizeCommand = (value: unknown): string | undefined => { if (typeof value === 'string') { const trimmed = value.trim(); @@ -245,6 +281,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { let allowAnonymousTerminalEvent = false; let lastRootSessionTitle: string | null = null; + await replayExplicitResumeTranscript(); + const handleCodexEvent = (msg: Record) => { const msgType = asString(msg.type); if (!msgType) return; diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index f70b3db25..3466feb06 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -83,6 +83,18 @@ export class Store { this.push = new PushStore(this.db) } + runInTransaction(fn: () => T): T { + try { + this.db.exec('BEGIN') + const result = fn() + this.db.exec('COMMIT') + return result + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + private initSchema(): void { const currentVersion = this.getUserVersion() if (currentVersion === 0) { diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index bb850c0c3..4c357e707 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -126,7 +126,7 @@ export function mergeSessionMessages( const newMaxSeq = getMaxSeq(db, toSessionId) try { - db.exec('BEGIN') + db.exec('SAVEPOINT merge_session_messages') if (newMaxSeq > 0 && oldMaxSeq > 0) { db.prepare( @@ -154,10 +154,11 @@ export function mergeSessionMessages( 'UPDATE messages SET session_id = ? WHERE session_id = ?' ).run(toSessionId, fromSessionId) - db.exec('COMMIT') + db.exec('RELEASE SAVEPOINT merge_session_messages') return { moved: result.changes, oldMaxSeq, newMaxSeq } } catch (error) { - db.exec('ROLLBACK') + db.exec('ROLLBACK TO SAVEPOINT merge_session_messages') + db.exec('RELEASE SAVEPOINT merge_session_messages') throw error } } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 618447c7d..8e45c2802 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -36,6 +36,21 @@ export class SessionCache { return session } + findSessionByExternalCodexSessionId(namespace: string, externalSessionId: string): { sessionId: string } | null { + for (const stored of this.store.sessions.getSessionsByNamespace(namespace)) { + const metadata = MetadataSchema.safeParse(stored.metadata) + if (!metadata.success) { + continue + } + + if (metadata.data.codexSessionId === externalSessionId) { + return { sessionId: stored.id } + } + } + + return null + } + resolveSessionAccess( sessionId: string, namespace: string @@ -360,75 +375,77 @@ export class SessionCache { return } - const oldStored = this.store.sessions.getSessionByNamespace(oldSessionId, namespace) - const newStored = this.store.sessions.getSessionByNamespace(newSessionId, namespace) - if (!oldStored || !newStored) { - throw new Error('Session not found for merge') - } - - this.store.messages.mergeSessionMessages(oldSessionId, newSessionId) + this.store.runInTransaction(() => { + const oldStored = this.store.sessions.getSessionByNamespace(oldSessionId, namespace) + const newStored = this.store.sessions.getSessionByNamespace(newSessionId, namespace) + if (!oldStored || !newStored) { + throw new Error('Session not found for merge') + } - const mergedMetadata = this.mergeSessionMetadata(oldStored.metadata, newStored.metadata) - if (mergedMetadata !== null && mergedMetadata !== newStored.metadata) { - for (let attempt = 0; attempt < 2; attempt += 1) { - const latest = this.store.sessions.getSessionByNamespace(newSessionId, namespace) - if (!latest) break - const result = this.store.sessions.updateSessionMetadata( - newSessionId, - mergedMetadata, - latest.metadataVersion, - namespace, - { touchUpdatedAt: false } - ) - if (result.result === 'success') { - break - } - if (result.result === 'error') { - break + this.store.messages.mergeSessionMessages(oldSessionId, newSessionId) + + const mergedMetadata = this.mergeSessionMetadata(oldStored.metadata, newStored.metadata) + if (mergedMetadata !== null && mergedMetadata !== newStored.metadata) { + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = this.store.sessions.getSessionByNamespace(newSessionId, namespace) + if (!latest) break + const result = this.store.sessions.updateSessionMetadata( + newSessionId, + mergedMetadata, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + break + } + if (result.result === 'error') { + break + } } } - } - if (newStored.model === null && oldStored.model !== null) { - const updated = this.store.sessions.setSessionModel(newSessionId, oldStored.model, namespace, { - touchUpdatedAt: false - }) - if (!updated) { - throw new Error('Failed to preserve session model during merge') + if (newStored.model === null && oldStored.model !== null) { + const updated = this.store.sessions.setSessionModel(newSessionId, oldStored.model, namespace, { + touchUpdatedAt: false + }) + if (!updated) { + throw new Error('Failed to preserve session model during merge') + } } - } - if (newStored.effort === null && oldStored.effort !== null) { - const updated = this.store.sessions.setSessionEffort(newSessionId, oldStored.effort, namespace, { - touchUpdatedAt: false - }) - if (!updated) { - throw new Error('Failed to preserve session effort during merge') + if (newStored.effort === null && oldStored.effort !== null) { + const updated = this.store.sessions.setSessionEffort(newSessionId, oldStored.effort, namespace, { + touchUpdatedAt: false + }) + if (!updated) { + throw new Error('Failed to preserve session effort during merge') + } } - } - if (oldStored.todos !== null && oldStored.todosUpdatedAt !== null) { - this.store.sessions.setSessionTodos( - newSessionId, - oldStored.todos, - oldStored.todosUpdatedAt, - namespace - ) - } + if (oldStored.todos !== null && oldStored.todosUpdatedAt !== null) { + this.store.sessions.setSessionTodos( + newSessionId, + oldStored.todos, + oldStored.todosUpdatedAt, + namespace + ) + } - if (oldStored.teamState !== null && oldStored.teamStateUpdatedAt !== null) { - this.store.sessions.setSessionTeamState( - newSessionId, - oldStored.teamState, - oldStored.teamStateUpdatedAt, - namespace - ) - } + if (oldStored.teamState !== null && oldStored.teamStateUpdatedAt !== null) { + this.store.sessions.setSessionTeamState( + newSessionId, + oldStored.teamState, + oldStored.teamStateUpdatedAt, + namespace + ) + } - const deleted = this.store.sessions.deleteSession(oldSessionId, namespace) - if (!deleted) { - throw new Error('Failed to delete old session during merge') - } + const deleted = this.store.sessions.deleteSession(oldSessionId, namespace) + if (!deleted) { + throw new Error('Failed to delete old session during merge') + } + }) const existed = this.sessions.delete(oldSessionId) if (existed) { diff --git a/hub/src/sync/syncEngine.test.ts b/hub/src/sync/syncEngine.test.ts new file mode 100644 index 000000000..f9adc3621 --- /dev/null +++ b/hub/src/sync/syncEngine.test.ts @@ -0,0 +1,565 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import { SyncEngine } from './syncEngine' + +describe('SyncEngine codex import orchestration', () => { + it('returns the existing hapi session when the external codex session is already imported', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'import-existing', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + + const result = await engine.importExternalCodexSession('codex-thread-1', 'default') + + expect(result).toEqual({ + type: 'success', + sessionId: session.id + }) + } finally { + engine.stop() + } + }) + + it('imports a codex session by resuming the external session id on an online machine', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + let capturedSpawnArgs: unknown[] | null = null + ;(engine as any).rpcGateway.listImportableSessions = async (_machineId: string, request: { agent: string }) => { + expect(request).toEqual({ agent: 'codex' }) + return { + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/codex-thread-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt' + } + ] + } + } + ;(engine as any).rpcGateway.spawnSession = async (...args: unknown[]) => { + capturedSpawnArgs = args + return { type: 'success', sessionId: 'spawned-session' } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.importExternalCodexSession('codex-thread-1', 'default') + + expect(result).toEqual({ + type: 'success', + sessionId: 'spawned-session' + }) + if (capturedSpawnArgs === null) { + throw new Error('spawn args were not captured') + } + const importSpawnArgs = capturedSpawnArgs as unknown[] + if (importSpawnArgs.length !== 10) { + throw new Error(`unexpected spawn args length: ${importSpawnArgs.length}`) + } + if (importSpawnArgs[0] !== 'machine-1') { + throw new Error(`unexpected spawn target: ${String(importSpawnArgs[0])}`) + } + if (importSpawnArgs[1] !== '/tmp/project') { + throw new Error(`unexpected spawn directory: ${String(importSpawnArgs[1])}`) + } + if (importSpawnArgs[2] !== 'codex') { + throw new Error(`unexpected spawn agent: ${String(importSpawnArgs[2])}`) + } + if (importSpawnArgs[8] !== 'codex-thread-1') { + throw new Error(`unexpected resume session id: ${String(importSpawnArgs[8])}`) + } + } finally { + engine.stop() + } + }) + + it('preserves the Codex preview title in imported session metadata', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/codex-thread-1.jsonl', + previewTitle: 'Useful imported title', + previewPrompt: 'Fallback prompt' + } + ] + }) + ;(engine as any).rpcGateway.spawnSession = async (...args: unknown[]) => { + const imported = engine.getOrCreateSession( + 'spawned-import-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: imported.id } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.importExternalCodexSession('codex-thread-1', 'default') + + expect(result.type).toBe('success') + if (result.type !== 'success') { + throw new Error(result.message) + } + const imported = engine.getSession(result.sessionId) + expect(imported?.metadata?.name).toBe('Useful imported title') + } finally { + engine.stop() + } + }) + + it('removes a spawned session when import fails to become active', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/codex-thread-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt' + } + ] + }) + ;(engine as any).rpcGateway.spawnSession = async () => { + const spawned = engine.getOrCreateSession( + 'spawned-import-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: spawned.id } + } + ;(engine as any).waitForSessionActive = async () => false + + const result = await engine.importExternalCodexSession('codex-thread-1', 'default') + + expect(result).toEqual({ + type: 'error', + message: 'Session failed to become active', + code: 'import_failed' + }) + expect(engine.findSessionByExternalCodexSessionId('default', 'codex-thread-1')).toBeNull() + expect(engine.getSessionsByNamespace('default')).toHaveLength(0) + } finally { + engine.stop() + } + }) + + it('returns session_not_found when the requested external codex session is missing', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ sessions: [] }) + + const result = await engine.importExternalCodexSession('missing-codex-thread', 'default') + + expect(result).toEqual({ + type: 'error', + message: 'Importable Codex session not found', + code: 'session_not_found' + }) + } finally { + engine.stop() + } + }) + + it('refreshes an imported codex session in place', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + const imported = engine.getOrCreateSession( + 'imported-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + + let capturedSpawnArgs: unknown[] | null = null + let capturedMergeArgs: unknown[] | null = null + ;(engine as any).rpcGateway.spawnSession = async (...args: unknown[]) => { + capturedSpawnArgs = args + return { type: 'success', sessionId: 'spawned-codex-session' } + } + ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).sessionCache.mergeSessions = async (...args: unknown[]) => { + capturedMergeArgs = args + } + + const result = await engine.refreshExternalCodexSession('codex-thread-1', 'default') + + expect(result).toEqual({ + type: 'success', + sessionId: imported.id + }) + if (capturedSpawnArgs === null) { + throw new Error('spawn args were not captured') + } + const refreshSpawnArgs = capturedSpawnArgs as unknown[] + if (refreshSpawnArgs.length !== 10) { + throw new Error(`unexpected spawn args length: ${refreshSpawnArgs.length}`) + } + if (refreshSpawnArgs[0] !== 'machine-1') { + throw new Error(`unexpected spawn target: ${String(refreshSpawnArgs[0])}`) + } + if (refreshSpawnArgs[1] !== '/tmp/project') { + throw new Error(`unexpected spawn directory: ${String(refreshSpawnArgs[1])}`) + } + if (refreshSpawnArgs[2] !== 'codex') { + throw new Error(`unexpected spawn agent: ${String(refreshSpawnArgs[2])}`) + } + if (refreshSpawnArgs[8] !== 'codex-thread-1') { + throw new Error(`unexpected resume session id: ${String(refreshSpawnArgs[8])}`) + } + if (capturedMergeArgs === null) { + throw new Error('merge args were not captured') + } + const refreshMergeArgs = capturedMergeArgs as unknown[] + if (refreshMergeArgs.length !== 3) { + throw new Error(`unexpected merge args length: ${refreshMergeArgs.length}`) + } + if (refreshMergeArgs[0] !== 'spawned-codex-session') { + throw new Error(`unexpected merge old session id: ${String(refreshMergeArgs[0])}`) + } + if (refreshMergeArgs[1] !== imported.id) { + throw new Error(`unexpected merge new session id: ${String(refreshMergeArgs[1])}`) + } + if (refreshMergeArgs[2] !== 'default') { + throw new Error(`unexpected merge namespace: ${String(refreshMergeArgs[2])}`) + } + expect(engine.findSessionByExternalCodexSessionId('default', 'codex-thread-1')).toEqual({ + sessionId: imported.id + }) + expect(engine.getSession(imported.id)).toBeDefined() + } finally { + engine.stop() + } + }) + + it('falls back to the Codex preview prompt when refreshing imported session metadata', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + const imported = engine.getOrCreateSession( + 'imported-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/codex-thread-1.jsonl', + previewTitle: null, + previewPrompt: 'Prompt fallback title' + } + ] + }) + ;(engine as any).rpcGateway.spawnSession = async () => { + const spawned = engine.getOrCreateSession( + 'spawned-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: spawned.id } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.refreshExternalCodexSession('codex-thread-1', 'default') + + expect(result).toEqual({ + type: 'success', + sessionId: imported.id + }) + expect(engine.getSession(imported.id)?.metadata?.name).toBe('Prompt fallback title') + } finally { + engine.stop() + } + }) + + it('keeps the existing imported mapping when refresh merge fails', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + const imported = engine.getOrCreateSession( + 'imported-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + + ;(engine as any).rpcGateway.spawnSession = async () => { + const spawned = engine.getOrCreateSession( + 'spawned-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: spawned.id } + } + ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).sessionCache.mergeSessions = async () => { + throw new Error('merge failed') + } + + const result = await engine.refreshExternalCodexSession('codex-thread-1', 'default') + + expect(result).toEqual({ + type: 'error', + message: 'merge failed', + code: 'refresh_failed' + }) + expect(engine.findSessionByExternalCodexSessionId('default', 'codex-thread-1')).toEqual({ + sessionId: imported.id + }) + expect(engine.getSessionsByNamespace('default')).toHaveLength(1) + } finally { + engine.stop() + } + }) + + it('rolls back partial merge work when refresh merge fails mid-transaction', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + const imported = engine.getOrCreateSession( + 'imported-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default', + 'gpt-5.4' + ) + store.messages.addMessage(imported.id, { text: 'existing message' }) + + const originalDeleteSession = store.sessions.deleteSession.bind(store.sessions) + ;(engine as any).rpcGateway.spawnSession = async () => { + const spawned = engine.getOrCreateSession( + 'spawned-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + store.messages.addMessage(spawned.id, { text: 'new message' }) + return { type: 'success', sessionId: spawned.id } + } + ;(engine as any).waitForSessionActive = async () => true + store.sessions.deleteSession = () => false + + const result = await engine.refreshExternalCodexSession('codex-thread-1', 'default') + + expect(result).toEqual({ + type: 'error', + message: 'Failed to delete old session during merge', + code: 'refresh_failed' + }) + expect(engine.getMessagesPage(imported.id, { limit: 10, beforeSeq: null }).messages).toHaveLength(1) + + store.sessions.deleteSession = originalDeleteSession + } finally { + engine.stop() + } + }) +}) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 6b5be2f1c..f8ad4764f 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -8,6 +8,7 @@ */ import type { CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' +import type { RpcListImportableSessionsResponse } from '@hapi/protocol/rpcTypes' import type { Server } from 'socket.io' import type { Store } from '../store' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -42,8 +43,21 @@ export type ResumeSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } +export type ImportExternalCodexSessionResult = + | { type: 'success'; sessionId: string } + | { type: 'error'; message: string; code: 'session_not_found' | 'no_machine_online' | 'import_failed' } + +export type RefreshExternalCodexSessionResult = + | { type: 'success'; sessionId: string } + | { type: 'error'; message: string; code: 'session_not_found' | 'no_machine_online' | 'resume_unavailable' | 'refresh_failed' } + +export type ListImportableCodexSessionsResult = + | { type: 'success'; machineId: string; sessions: RpcListImportableSessionsResponse['sessions'] } + | { type: 'error'; message: string; code: 'no_machine_online' | 'importable_sessions_failed' } + export class SyncEngine { private readonly eventPublisher: EventPublisher + private readonly store: Store private readonly sessionCache: SessionCache private readonly machineCache: MachineCache private readonly messageService: MessageService @@ -56,6 +70,7 @@ export class SyncEngine { rpcRegistry: RpcRegistry, sseManager: SSEManager ) { + this.store = store this.eventPublisher = new EventPublisher(sseManager, (event) => this.resolveNamespace(event)) this.sessionCache = new SessionCache(store, this.eventPublisher) this.machineCache = new MachineCache(store, this.eventPublisher) @@ -145,6 +160,159 @@ export class SyncEngine { return this.machineCache.getOnlineMachinesByNamespace(namespace) } + findSessionByExternalCodexSessionId(namespace: string, externalSessionId: string): { sessionId: string } | null { + return this.sessionCache.findSessionByExternalCodexSessionId(namespace, externalSessionId) + } + + async importExternalCodexSession(externalSessionId: string, namespace: string): Promise { + const existing = this.findSessionByExternalCodexSessionId(namespace, externalSessionId) + if (existing) { + return { type: 'success', sessionId: existing.sessionId } + } + + const sourceResult = await this.findImportableCodexSessionSource(namespace, externalSessionId) + if (sourceResult.type === 'error') { + return { + type: 'error', + message: sourceResult.message, + code: sourceResult.code === 'no_machine_online' || sourceResult.code === 'session_not_found' + ? sourceResult.code + : 'import_failed' + } + } + + const cwd = sourceResult.session.cwd + if (typeof cwd !== 'string' || cwd.length === 0) { + return { + type: 'error', + message: 'Importable Codex session is missing cwd', + code: 'import_failed' + } + } + + const spawnResult = await this.rpcGateway.spawnSession( + sourceResult.machineId, + cwd, + 'codex', + undefined, + undefined, + undefined, + undefined, + undefined, + externalSessionId, + undefined + ) + + if (spawnResult.type !== 'success') { + return { + type: 'error', + message: spawnResult.message, + code: 'import_failed' + } + } + + if (!(await this.waitForSessionActive(spawnResult.sessionId))) { + this.discardSpawnedSession(spawnResult.sessionId, namespace) + return { + type: 'error', + message: 'Session failed to become active', + code: 'import_failed' + } + } + + const importedTitle = this.getBestImportableCodexSessionTitle(sourceResult.session) + await this.applyImportableCodexSessionTitle(spawnResult.sessionId, importedTitle) + + return { type: 'success', sessionId: spawnResult.sessionId } + } + + async refreshExternalCodexSession(externalSessionId: string, namespace: string): Promise { + const existing = this.findSessionByExternalCodexSessionId(namespace, externalSessionId) + if (!existing) { + return { + type: 'error', + message: 'Imported session not found', + code: 'session_not_found' + } + } + + const access = this.sessionCache.resolveSessionAccess(existing.sessionId, namespace) + if (!access.ok) { + return { + type: 'error', + message: access.reason === 'access-denied' ? 'Session access denied' : 'Imported session not found', + code: access.reason === 'access-denied' ? 'session_not_found' : 'session_not_found' + } + } + + const session = access.session + const metadata = session.metadata + if (!metadata || typeof metadata.path !== 'string') { + return { + type: 'error', + message: 'Session metadata missing path', + code: 'resume_unavailable' + } + } + + const targetMachine = this.selectOnlineMachine(namespace, metadata) + if (!targetMachine) { + return { + type: 'error', + message: 'No machine online', + code: 'no_machine_online' + } + } + + const spawnResult = await this.rpcGateway.spawnSession( + targetMachine.id, + metadata.path, + 'codex', + session.model ?? undefined, + undefined, + undefined, + undefined, + undefined, + externalSessionId, + session.effort ?? undefined + ) + + if (spawnResult.type !== 'success') { + return { + type: 'error', + message: spawnResult.message, + code: 'refresh_failed' + } + } + + if (!(await this.waitForSessionActive(spawnResult.sessionId))) { + this.discardSpawnedSession(spawnResult.sessionId, namespace) + return { + type: 'error', + message: 'Session failed to become active', + code: 'refresh_failed' + } + } + + const importedTitle = await this.resolveImportableCodexSessionTitle(namespace, externalSessionId) + await this.applyImportableCodexSessionTitle(spawnResult.sessionId, importedTitle) + + if (spawnResult.sessionId !== access.sessionId) { + try { + await this.sessionCache.mergeSessions(spawnResult.sessionId, access.sessionId, namespace) + } catch (error) { + this.discardSpawnedSession(spawnResult.sessionId, namespace) + return { + type: 'error', + message: error instanceof Error ? error.message : 'Failed to refresh imported session', + code: 'refresh_failed' + } + } + } + + return { type: 'success', sessionId: access.sessionId } + } + getMessagesPage(sessionId: string, options: { limit: number; beforeSeq: number | null }): { messages: DecryptedMessage[] page: { @@ -378,23 +546,7 @@ export class SyncEngine { return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' } } - const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) - if (onlineMachines.length === 0) { - return { type: 'error', message: 'No machine online', code: 'no_machine_online' } - } - - const targetMachine = (() => { - if (metadata.machineId) { - const exact = onlineMachines.find((machine) => machine.id === metadata.machineId) - if (exact) return exact - } - if (metadata.host) { - const hostMatch = onlineMachines.find((machine) => machine.metadata?.host === metadata.host) - if (hostMatch) return hostMatch - } - return null - })() - + const targetMachine = this.selectOnlineMachine(namespace, metadata) if (!targetMachine) { return { type: 'error', message: 'No machine online', code: 'no_machine_online' } } @@ -433,6 +585,33 @@ export class SyncEngine { return { type: 'success', sessionId: spawnResult.sessionId } } + async listImportableCodexSessions(namespace: string): Promise { + const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) + const targetMachine = onlineMachines[0] + if (!targetMachine) { + return { + type: 'error', + message: 'No machine online', + code: 'no_machine_online' + } + } + + try { + const response = await this.rpcGateway.listImportableSessions(targetMachine.id, { agent: 'codex' }) + return { + type: 'success', + machineId: targetMachine.id, + sessions: response.sessions + } + } catch (error) { + return { + type: 'error', + message: error instanceof Error ? error.message : 'Failed to list importable sessions', + code: 'importable_sessions_failed' + } + } + } + async waitForSessionActive(sessionId: string, timeoutMs: number = 15_000): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { @@ -445,6 +624,131 @@ export class SyncEngine { return false } + private selectOnlineMachine(namespace: string, metadata?: Session['metadata']): Machine | null { + const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) + if (onlineMachines.length === 0) { + return null + } + + if (metadata?.machineId) { + const exact = onlineMachines.find((machine) => machine.id === metadata.machineId) + if (exact) { + return exact + } + } + + if (metadata?.host) { + const hostMatch = onlineMachines.find((machine) => machine.metadata?.host === metadata.host) + if (hostMatch) { + return hostMatch + } + } + + return metadata ? null : onlineMachines[0] ?? null + } + + private async findImportableCodexSessionSource( + namespace: string, + externalSessionId: string + ): Promise< + | { type: 'success'; machineId: string; session: RpcListImportableSessionsResponse['sessions'][number] } + | { type: 'error'; message: string; code: 'session_not_found' | 'no_machine_online' | 'importable_sessions_failed' } + > { + const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) + if (onlineMachines.length === 0) { + return { + type: 'error', + message: 'No machine online', + code: 'no_machine_online' + } + } + + let lastError: string | null = null + for (const machine of onlineMachines) { + try { + const response = await this.rpcGateway.listImportableSessions(machine.id, { agent: 'codex' }) + const session = response.sessions.find((item) => item.externalSessionId === externalSessionId) + if (session) { + if (typeof session.cwd !== 'string' || session.cwd.length === 0) { + return { + type: 'error', + message: 'Importable Codex session is missing cwd', + code: 'importable_sessions_failed' + } + } + return { + type: 'success', + machineId: machine.id, + session + } + } + } catch (error) { + lastError = error instanceof Error ? error.message : 'Failed to list importable sessions' + } + } + + return { + type: 'error', + message: lastError ?? 'Importable Codex session not found', + code: lastError ? 'importable_sessions_failed' : 'session_not_found' + } + } + + private async resolveImportableCodexSessionTitle( + namespace: string, + externalSessionId: string + ): Promise { + const sourceResult = await this.findImportableCodexSessionSource(namespace, externalSessionId) + if (sourceResult.type !== 'success') { + return null + } + return this.getBestImportableCodexSessionTitle(sourceResult.session) + } + + private getBestImportableCodexSessionTitle( + session: RpcListImportableSessionsResponse['sessions'][number] + ): string | null { + const previewTitle = typeof session.previewTitle === 'string' ? session.previewTitle.trim() : '' + if (previewTitle.length > 0) { + return previewTitle + } + + const previewPrompt = typeof session.previewPrompt === 'string' ? session.previewPrompt.trim() : '' + if (previewPrompt.length > 0) { + return previewPrompt + } + + return null + } + + private async applyImportableCodexSessionTitle(sessionId: string, title: string | null): Promise { + if (!title) { + return + } + + const session = this.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) + if (!session) { + return + } + + if (session.metadata?.name === title) { + return + } + + try { + await this.sessionCache.renameSession(sessionId, title) + } catch { + // Best effort. Import/refresh must not fail just because the title write raced. + } + } + + private discardSpawnedSession(sessionId: string, namespace: string): void { + const deleted = this.store.sessions.deleteSession(sessionId, namespace) + if (deleted) { + this.sessionCache.refreshSession(sessionId) + } + } + async checkPathsExist(machineId: string, paths: string[]): Promise> { return await this.rpcGateway.checkPathsExist(machineId, paths) } diff --git a/hub/src/web/routes/importableSessions.test.ts b/hub/src/web/routes/importableSessions.test.ts new file mode 100644 index 000000000..1615a1e98 --- /dev/null +++ b/hub/src/web/routes/importableSessions.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createImportableSessionsRoutes } from './importableSessions' + +function createApp(engine: Partial) { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createImportableSessionsRoutes(() => engine as SyncEngine)) + return app +} + +describe('importable sessions routes', () => { + it('lists codex importable sessions with imported status', async () => { + const engine = { + listImportableCodexSessions: async () => ({ + type: 'success' as const, + machineId: 'machine-1', + sessions: [ + { + agent: 'codex' as const, + externalSessionId: 'external-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/external-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt' + }, + { + agent: 'codex' as const, + externalSessionId: 'external-2', + cwd: '/tmp/project-2', + timestamp: 456, + transcriptPath: '/tmp/project-2/.codex/sessions/external-2.jsonl', + previewTitle: null, + previewPrompt: null + } + ] + }), + findSessionByExternalCodexSessionId: (_namespace: string, externalSessionId: string) => { + if (externalSessionId === 'external-1') { + return { sessionId: 'hapi-123' } + } + return null + } + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions?agent=codex') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'external-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/external-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt', + alreadyImported: true, + importedHapiSessionId: 'hapi-123' + }, + { + agent: 'codex', + externalSessionId: 'external-2', + cwd: '/tmp/project-2', + timestamp: 456, + transcriptPath: '/tmp/project-2/.codex/sessions/external-2.jsonl', + previewTitle: null, + previewPrompt: null, + alreadyImported: false, + importedHapiSessionId: null + } + ] + }) + }) + + it('returns a sensible error when no machine is online', async () => { + const engine = { + listImportableCodexSessions: async () => ({ + type: 'error' as const, + code: 'no_machine_online' as const, + message: 'No machine online' + }) + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions?agent=codex') + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: 'No machine online', + code: 'no_machine_online' + }) + }) + + it('imports an external codex session', async () => { + const captured: Array<{ externalSessionId: string; namespace: string }> = [] + const engine = { + importExternalCodexSession: async (externalSessionId: string, namespace: string) => { + captured.push({ externalSessionId, namespace }) + return { + type: 'success' as const, + sessionId: 'hapi-123' + } + } + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions/codex/external-1/import', { + method: 'POST' + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + type: 'success', + sessionId: 'hapi-123' + }) + expect(captured).toEqual([ + { + externalSessionId: 'external-1', + namespace: 'default' + } + ]) + }) + + it('refreshes an external codex session in place', async () => { + const captured: Array<{ externalSessionId: string; namespace: string }> = [] + const engine = { + refreshExternalCodexSession: async (externalSessionId: string, namespace: string) => { + captured.push({ externalSessionId, namespace }) + return { + type: 'success' as const, + sessionId: 'hapi-123' + } + } + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions/codex/external-1/refresh', { + method: 'POST' + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + type: 'success', + sessionId: 'hapi-123' + }) + expect(captured).toEqual([ + { + externalSessionId: 'external-1', + namespace: 'default' + } + ]) + }) +}) diff --git a/hub/src/web/routes/importableSessions.ts b/hub/src/web/routes/importableSessions.ts new file mode 100644 index 000000000..0fd295da8 --- /dev/null +++ b/hub/src/web/routes/importableSessions.ts @@ -0,0 +1,86 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { requireSyncEngine } from './guards' + +const querySchema = z.object({ + agent: z.literal('codex') +}) + +export function createImportableSessionsRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() + + function mapActionErrorStatus(code: string): number { + if (code === 'no_machine_online') return 503 + if (code === 'session_not_found') return 404 + if (code === 'access_denied') return 403 + return 500 + } + + app.get('/importable-sessions', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const parsed = querySchema.safeParse({ + agent: c.req.query('agent') + }) + if (!parsed.success) { + return c.json({ error: 'Invalid agent' }, 400) + } + + const namespace = c.get('namespace') + const result = await engine.listImportableCodexSessions(namespace) + if (result.type === 'error') { + const status = result.code === 'no_machine_online' ? 503 : 500 + return c.json({ error: result.message, code: result.code }, status) + } + + const sessions = result.sessions.map((session) => { + const existing = engine.findSessionByExternalCodexSessionId(namespace, session.externalSessionId) + return { + ...session, + alreadyImported: Boolean(existing), + importedHapiSessionId: existing?.sessionId ?? null + } + }) + + return c.json({ sessions }) + }) + + app.post('/importable-sessions/codex/:externalSessionId/import', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const namespace = c.get('namespace') + const externalSessionId = c.req.param('externalSessionId') + const result = await engine.importExternalCodexSession(externalSessionId, namespace) + if (result.type === 'error') { + return c.json({ error: result.message, code: result.code }, mapActionErrorStatus(result.code) as never) + } + + return c.json({ type: 'success', sessionId: result.sessionId }) + }) + + app.post('/importable-sessions/codex/:externalSessionId/refresh', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const namespace = c.get('namespace') + const externalSessionId = c.req.param('externalSessionId') + const result = await engine.refreshExternalCodexSession(externalSessionId, namespace) + if (result.type === 'error') { + return c.json({ error: result.message, code: result.code }, mapActionErrorStatus(result.code) as never) + } + + return c.json({ type: 'success', sessionId: result.sessionId }) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 08800fc72..e820c2517 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -12,6 +12,7 @@ import { createAuthRoutes } from './routes/auth' import { createBindRoutes } from './routes/bind' import { createEventsRoutes } from './routes/events' import { createSessionsRoutes } from './routes/sessions' +import { createImportableSessionsRoutes } from './routes/importableSessions' import { createMessagesRoutes } from './routes/messages' import { createPermissionsRoutes } from './routes/permissions' import { createMachinesRoutes } from './routes/machines' @@ -91,6 +92,7 @@ function createWebApp(options: { app.use('/api/*', createAuthMiddleware(options.jwtSecret)) app.route('/api', createEventsRoutes(options.getSseManager, options.getSyncEngine, options.getVisibilityTracker)) app.route('/api', createSessionsRoutes(options.getSyncEngine)) + app.route('/api', createImportableSessionsRoutes(options.getSyncEngine)) app.route('/api', createMessagesRoutes(options.getSyncEngine)) app.route('/api', createPermissionsRoutes(options.getSyncEngine)) app.route('/api', createMachinesRoutes(options.getSyncEngine)) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 163eb206d..8a1198d63 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3,10 +3,12 @@ import type { AuthResponse, CodexCollaborationMode, DeleteUploadResponse, + ExternalSessionActionResponse, ListDirectoryResponse, FileReadResponse, FileSearchResponse, GitCommandResponse, + ImportableSessionsResponse, MachinePathsExistsResponse, MachinesResponse, MessagesResponse, @@ -160,6 +162,25 @@ export class ApiClient { return await this.request('/api/sessions') } + async listImportableSessions(agent: 'codex'): Promise { + const params = new URLSearchParams({ agent }) + return await this.request(`/api/importable-sessions?${params.toString()}`) + } + + async importExternalSession(agent: 'codex', externalSessionId: string): Promise { + return await this.request( + `/api/importable-sessions/${agent}/${encodeURIComponent(externalSessionId)}/import`, + { method: 'POST' } + ) + } + + async refreshExternalSession(agent: 'codex', externalSessionId: string): Promise { + return await this.request( + `/api/importable-sessions/${agent}/${encodeURIComponent(externalSessionId)}/refresh`, + { method: 'POST' } + ) + } + async getPushVapidPublicKey(): Promise { return await this.request('/api/push/vapid-public-key') } diff --git a/web/src/components/NewSession/ImportExistingModal.test.tsx b/web/src/components/NewSession/ImportExistingModal.test.tsx new file mode 100644 index 000000000..a2c2a5199 --- /dev/null +++ b/web/src/components/NewSession/ImportExistingModal.test.tsx @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { ImportExistingModal } from './ImportExistingModal' + +const useImportableSessionsMock = vi.fn() +const useImportableSessionActionsMock = vi.fn() + +vi.mock('@/hooks/queries/useImportableSessions', () => ({ + useImportableSessions: (...args: unknown[]) => useImportableSessionsMock(...args), +})) + +vi.mock('@/hooks/mutations/useImportableSessionActions', () => ({ + useImportableSessionActions: (...args: unknown[]) => useImportableSessionActionsMock(...args), +})) + +function renderModal(props?: { onOpenSession?: (sessionId: string) => void }) { + return render( + + + + ) +} + +describe('ImportExistingModal', () => { + beforeEach(() => { + vi.clearAllMocks() + useImportableSessionsMock.mockReturnValue({ + sessions: [], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + useImportableSessionActionsMock.mockReturnValue({ + importSession: vi.fn(), + refreshSession: vi.fn(), + importingSessionId: null, + refreshingSessionId: null, + error: null, + }) + }) + + it('shows imported-session actions and disables the Claude tab', () => { + useImportableSessionsMock.mockReturnValue({ + sessions: [{ + agent: 'codex', + externalSessionId: 'external-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/session.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Prompt preview', + alreadyImported: true, + importedHapiSessionId: 'hapi-123', + }], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + + renderModal() + + expect(screen.getByRole('button', { name: 'Open in HAPI' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Refresh from source' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Claude' })).toBeDisabled() + }) + + it('shows import action for not-yet-imported sessions', () => { + const importSession = vi.fn().mockResolvedValue({ + type: 'success', + sessionId: 'hapi-imported-0', + }) + useImportableSessionsMock.mockReturnValue({ + sessions: [{ + agent: 'codex', + externalSessionId: 'external-2', + cwd: '/tmp/project-2', + timestamp: 456, + transcriptPath: '/tmp/project-2/session.jsonl', + previewTitle: null, + previewPrompt: 'Prompt preview', + alreadyImported: false, + importedHapiSessionId: null, + }], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + useImportableSessionActionsMock.mockReturnValue({ + importSession, + refreshSession: vi.fn(), + importingSessionId: null, + refreshingSessionId: null, + error: null, + }) + + renderModal() + fireEvent.click(screen.getByRole('button', { name: 'Import into HAPI' })) + + expect(importSession).toHaveBeenCalledWith('external-2') + }) + + it('opens the imported HAPI session immediately after import succeeds', async () => { + const onOpenSession = vi.fn() + const importSession = vi.fn().mockResolvedValue({ + type: 'success', + sessionId: 'hapi-imported-1', + }) + + useImportableSessionsMock.mockReturnValue({ + sessions: [{ + agent: 'codex', + externalSessionId: 'external-3', + cwd: '/tmp/project-3', + timestamp: 789, + transcriptPath: '/tmp/project-3/session.jsonl', + previewTitle: 'Imported later', + previewPrompt: 'Prompt preview', + alreadyImported: false, + importedHapiSessionId: null, + }], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + useImportableSessionActionsMock.mockReturnValue({ + importSession, + refreshSession: vi.fn(), + importingSessionId: null, + refreshingSessionId: null, + error: null, + }) + + renderModal({ onOpenSession }) + fireEvent.click(screen.getByRole('button', { name: 'Import into HAPI' })) + + await vi.waitFor(() => { + expect(onOpenSession).toHaveBeenCalledWith('hapi-imported-1') + }) + }) +}) diff --git a/web/src/components/NewSession/ImportExistingModal.tsx b/web/src/components/NewSession/ImportExistingModal.tsx new file mode 100644 index 000000000..7ca0ae787 --- /dev/null +++ b/web/src/components/NewSession/ImportExistingModal.tsx @@ -0,0 +1,160 @@ +import { useEffect, useMemo, useState } from 'react' +import type { ApiClient } from '@/api/client' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useImportableSessionActions } from '@/hooks/mutations/useImportableSessionActions' +import { useImportableSessions } from '@/hooks/queries/useImportableSessions' +import { useTranslation } from '@/lib/use-translation' +import { ImportableSessionList } from './ImportableSessionList' + +export function ImportExistingModal(props: { + api: ApiClient + open: boolean + onOpenChange: (open: boolean) => void + onOpenSession: (sessionId: string) => void +}) { + const { t } = useTranslation() + const [activeTab, setActiveTab] = useState<'codex' | 'claude'>('codex') + const [search, setSearch] = useState('') + const { sessions, isLoading, error, refetch } = useImportableSessions(props.api, 'codex', props.open && activeTab === 'codex') + const { + importSession, + refreshSession, + importingSessionId, + refreshingSessionId, + error: actionError, + } = useImportableSessionActions(props.api, 'codex') + const [selectedExternalSessionId, setSelectedExternalSessionId] = useState(null) + + const filteredSessions = useMemo(() => { + const query = search.trim().toLowerCase() + if (!query) { + return sessions + } + + return sessions.filter((session) => { + const haystacks = [ + session.previewTitle, + session.previewPrompt, + session.cwd, + session.externalSessionId, + ] + return haystacks.some((value) => value?.toLowerCase().includes(query)) + }) + }, [search, sessions]) + + useEffect(() => { + if (!props.open) { + setSearch('') + setSelectedExternalSessionId(null) + setActiveTab('codex') + return + } + + if (!filteredSessions.find((session) => session.externalSessionId === selectedExternalSessionId)) { + setSelectedExternalSessionId(filteredSessions[0]?.externalSessionId ?? null) + } + }, [filteredSessions, props.open, selectedExternalSessionId]) + + const handleImport = async (externalSessionId: string) => { + const result = await importSession(externalSessionId) + props.onOpenSession(result.sessionId) + } + + return ( + + +
+ + {t('newSession.import.title')} + + {t('newSession.import.description')} + + + +
+
+ + +
+
+ +
+ {activeTab === 'claude' ? ( +
+ {t('newSession.import.claudeSoon')} +
+ ) : ( +
+
+ setSearch(event.target.value)} + placeholder={t('newSession.import.searchPlaceholder')} + className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]" + /> + +
+ + {isLoading ? ( +
+ {t('newSession.import.loading')} +
+ ) : error ? ( +
+
{error}
+ +
+ ) : filteredSessions.length === 0 ? ( +
+ {sessions.length === 0 + ? t('newSession.import.empty') + : t('newSession.import.emptySearch')} +
+ ) : ( + void handleImport(externalSessionId)} + onRefresh={(externalSessionId) => void refreshSession(externalSessionId)} + onOpen={props.onOpenSession} + /> + )} + + {actionError ? ( +
+ {actionError} +
+ ) : null} +
+ )} +
+
+
+
+ ) +} diff --git a/web/src/components/NewSession/ImportableSessionList.tsx b/web/src/components/NewSession/ImportableSessionList.tsx new file mode 100644 index 000000000..0579eb36b --- /dev/null +++ b/web/src/components/NewSession/ImportableSessionList.tsx @@ -0,0 +1,137 @@ +import type { ImportableSessionView } from '@/types/api' +import { Button } from '@/components/ui/button' +import { useTranslation } from '@/lib/use-translation' + +function formatTimestamp(timestamp: number | null): string { + if (!timestamp) { + return 'Unknown time' + } + + try { + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(timestamp) + } catch { + return 'Unknown time' + } +} + +export function ImportableSessionList(props: { + sessions: ImportableSessionView[] + selectedExternalSessionId: string | null + importingSessionId: string | null + refreshingSessionId: string | null + onSelect: (externalSessionId: string) => void + onImport: (externalSessionId: string) => void + onRefresh: (externalSessionId: string) => void + onOpen: (sessionId: string) => void +}) { + const { t } = useTranslation() + const selectedSession = props.sessions.find((session) => session.externalSessionId === props.selectedExternalSessionId) + ?? props.sessions[0] + ?? null + + return ( +
+
+
+ {props.sessions.map((session) => { + const selected = session.externalSessionId === selectedSession?.externalSessionId + return ( + + ) + })} +
+
+ +
+ {selectedSession ? ( + <> +
+
+ {selectedSession.previewTitle ?? selectedSession.previewPrompt ?? selectedSession.externalSessionId} +
+
{selectedSession.cwd ?? t('newSession.import.unknownDirectory')}
+
{formatTimestamp(selectedSession.timestamp)}
+
+ +
+
+
{t('newSession.import.preview')}
+
+ {selectedSession.previewPrompt ?? t('newSession.import.noPreview')} +
+
+
+
{t('newSession.import.transcript')}
+
{selectedSession.transcriptPath}
+
+
+ +
+ {selectedSession.alreadyImported && selectedSession.importedHapiSessionId ? ( + <> + + + + ) : ( + + )} +
+ + ) : null} +
+
+ ) +} diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index e0d1dd1c1..dd8f8c260 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -25,7 +25,9 @@ import { } from './preferences' import { SessionTypeSelector } from './SessionTypeSelector' import { YoloToggle } from './YoloToggle' +import { ImportExistingModal } from './ImportExistingModal' import { formatRunnerSpawnError } from '../../utils/formatRunnerSpawnError' +import { Button } from '@/components/ui/button' export function NewSession(props: { api: ApiClient @@ -53,6 +55,7 @@ export function NewSession(props: { const [sessionType, setSessionType] = useState('simple') const [worktreeName, setWorktreeName] = useState('') const [directoryCreationConfirmed, setDirectoryCreationConfirmed] = useState(false) + const [isImportOpen, setIsImportOpen] = useState(false) const [error, setError] = useState(null) const worktreeInputRef = useRef(null) @@ -353,6 +356,18 @@ export function NewSession(props: {
) : null} +
+ +
+ + +
) } diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index e85bc196e..30d69a2d2 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -15,7 +15,7 @@ export const DialogContent = React.forwardRef< Promise + refreshSession: (externalSessionId: string) => Promise + importingSessionId: string | null + refreshingSessionId: string | null + error: string | null +} { + const queryClient = useQueryClient() + + const invalidate = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), + queryClient.invalidateQueries({ queryKey: queryKeys.importableSessions(agent) }), + ]) + } + + const importMutation = useMutation({ + mutationFn: async (externalSessionId: string) => { + if (!api) { + throw new Error('API unavailable') + } + return await api.importExternalSession(agent, externalSessionId) + }, + onSuccess: invalidate, + }) + + const refreshMutation = useMutation({ + mutationFn: async (externalSessionId: string) => { + if (!api) { + throw new Error('API unavailable') + } + return await api.refreshExternalSession(agent, externalSessionId) + }, + onSuccess: invalidate, + }) + + return { + importSession: importMutation.mutateAsync, + refreshSession: refreshMutation.mutateAsync, + importingSessionId: importMutation.isPending ? importMutation.variables ?? null : null, + refreshingSessionId: refreshMutation.isPending ? refreshMutation.variables ?? null : null, + error: importMutation.error instanceof Error + ? importMutation.error.message + : refreshMutation.error instanceof Error + ? refreshMutation.error.message + : importMutation.error || refreshMutation.error + ? 'Failed to update importable session' + : null, + } +} diff --git a/web/src/hooks/queries/useImportableSessions.ts b/web/src/hooks/queries/useImportableSessions.ts new file mode 100644 index 000000000..523e75831 --- /dev/null +++ b/web/src/hooks/queries/useImportableSessions.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { ImportableSessionView } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useImportableSessions( + api: ApiClient | null, + agent: 'codex', + enabled: boolean +): { + sessions: ImportableSessionView[] + isLoading: boolean + error: string | null + refetch: () => Promise +} { + const query = useQuery({ + queryKey: queryKeys.importableSessions(agent), + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + return await api.listImportableSessions(agent) + }, + enabled: Boolean(api) && enabled, + }) + + return { + sessions: query.data?.sessions ?? [], + isLoading: query.isLoading || query.isFetching, + error: query.error instanceof Error ? query.error.message : query.error ? 'Failed to load importable sessions' : null, + refetch: query.refetch, + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index f26126109..9ec79d2b6 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -111,6 +111,28 @@ export default { 'newSession.yolo.desc': 'Uses dangerous agent flags when spawning.', 'newSession.create': 'Create', 'newSession.creating': 'Creating…', + 'newSession.import.entry': 'Import Existing', + 'newSession.import.title': 'Import Existing', + 'newSession.import.description': 'Browse local Codex sessions and import or refresh them without leaving HAPI.', + 'newSession.import.tabs.claude': 'Claude', + 'newSession.import.claudeSoon': 'Claude import is coming soon.', + 'newSession.import.searchPlaceholder': 'Search imported titles, prompts, or paths', + 'newSession.import.refreshList': 'Refresh', + 'newSession.import.loading': 'Loading importable Codex sessions...', + 'newSession.import.retry': 'Retry', + 'newSession.import.empty': 'No importable Codex sessions were found on the connected machine.', + 'newSession.import.emptySearch': 'No sessions match your search.', + 'newSession.import.badgeImported': 'Imported', + 'newSession.import.badgeReady': 'Ready', + 'newSession.import.unknownDirectory': 'Unknown directory', + 'newSession.import.preview': 'Preview', + 'newSession.import.noPreview': 'No prompt preview available.', + 'newSession.import.transcript': 'Transcript', + 'newSession.import.open': 'Open in HAPI', + 'newSession.import.refresh': 'Refresh from source', + 'newSession.import.refreshing': 'Refreshing...', + 'newSession.import.cta': 'Import into HAPI', + 'newSession.import.importing': 'Importing...', // Spawn session (old component) 'spawn.title': 'Create Session', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index ea220f5a7..233ffb865 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -113,6 +113,28 @@ export default { 'newSession.yolo.desc': '启动时使用危险的代理标志。', 'newSession.create': '创建', 'newSession.creating': '创建中…', + 'newSession.import.entry': '导入现有会话', + 'newSession.import.title': '导入现有会话', + 'newSession.import.description': '浏览本地 Codex 会话,并在不离开 HAPI 的情况下导入或刷新它们。', + 'newSession.import.tabs.claude': 'Claude', + 'newSession.import.claudeSoon': 'Claude 导入即将支持。', + 'newSession.import.searchPlaceholder': '搜索标题、提示词或路径', + 'newSession.import.refreshList': '刷新', + 'newSession.import.loading': '正在加载可导入的 Codex 会话...', + 'newSession.import.retry': '重试', + 'newSession.import.empty': '当前连接机器上没有可导入的 Codex 会话。', + 'newSession.import.emptySearch': '没有匹配搜索条件的会话。', + 'newSession.import.badgeImported': '已导入', + 'newSession.import.badgeReady': '可导入', + 'newSession.import.unknownDirectory': '未知目录', + 'newSession.import.preview': '预览', + 'newSession.import.noPreview': '没有可用的提示词预览。', + 'newSession.import.transcript': '转录文件', + 'newSession.import.open': '在 HAPI 中打开', + 'newSession.import.refresh': '从源刷新', + 'newSession.import.refreshing': '刷新中...', + 'newSession.import.cta': '导入到 HAPI', + 'newSession.import.importing': '导入中...', // Spawn session (old component) 'spawn.title': '创建会话', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index a00b5512b..ae3911e97 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -3,6 +3,7 @@ export const queryKeys = { session: (sessionId: string) => ['session', sessionId] as const, messages: (sessionId: string) => ['messages', sessionId] as const, machines: ['machines'] as const, + importableSessions: (agent: 'codex') => ['importable-sessions', agent] as const, gitStatus: (sessionId: string) => ['git-status', sessionId] as const, sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const, sessionDirectory: (sessionId: string, path: string) => ['session-directory', sessionId, path] as const, diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0a2b01b14..d5765cbda 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -95,6 +95,27 @@ export type MessagesResponse = { export type MachinesResponse = { machines: Machine[] } export type MachinePathsExistsResponse = { exists: Record } +export type ImportableSessionView = { + agent: 'codex' + externalSessionId: string + cwd: string | null + timestamp: number | null + transcriptPath: string + previewTitle: string | null + previewPrompt: string | null + alreadyImported: boolean + importedHapiSessionId: string | null +} + +export type ImportableSessionsResponse = { + sessions: ImportableSessionView[] +} + +export type ExternalSessionActionResponse = { + type: 'success' + sessionId: string +} + export type SpawnResponse = | { type: 'success'; sessionId: string } | { type: 'error'; message: string } From 491b329b2658fa7cdc5887ab4f677f00099b8e95 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Fri, 3 Apr 2026 23:26:27 +0800 Subject: [PATCH 44/82] feat: widen importable session contracts for claude --- hub/src/sync/rpcGateway.test.ts | 47 +++++++++++++++++++++++++++++++++ hub/src/sync/rpcGateway.ts | 2 +- shared/src/rpcTypes.ts | 10 ++++--- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/hub/src/sync/rpcGateway.test.ts b/hub/src/sync/rpcGateway.test.ts index 8eabc0cde..217994987 100644 --- a/hub/src/sync/rpcGateway.test.ts +++ b/hub/src/sync/rpcGateway.test.ts @@ -65,6 +65,53 @@ describe('RpcGateway', () => { ]) }) + it('parses claude list-importable-sessions responses', async () => { + const registry = new RpcRegistry() + + const socket = { + id: 'socket-1', + timeout: () => ({ + emitWithAck: async () => ({ + sessions: [ + { + agent: 'claude', + externalSessionId: 'claude-session-1', + cwd: '/work/project', + timestamp: 1712131200000, + transcriptPath: '/tmp/claude-session-1.jsonl', + previewTitle: 'Fix the API', + previewPrompt: 'Please fix the API' + } + ] + }) + }) + } + + registry.register(socket as never, 'machine-1:list-importable-sessions') + + const io = { + of: () => ({ + sockets: new Map([['socket-1', socket]]) + }) + } + + const gateway = new RpcGateway(io as never, registry) + + await expect(gateway.listImportableSessions('machine-1', { agent: 'claude' })).resolves.toEqual({ + sessions: [ + { + agent: 'claude', + externalSessionId: 'claude-session-1', + cwd: '/work/project', + timestamp: 1712131200000, + transcriptPath: '/tmp/claude-session-1.jsonl', + previewTitle: 'Fix the API', + previewPrompt: 'Please fix the API' + } + ] + }) + }) + it('rejects malformed list-importable-sessions responses', async () => { const registry = new RpcRegistry() diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 21e423a9b..cbf1df6b4 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -8,7 +8,7 @@ import { z } from 'zod' import type { RpcRegistry } from '../socket/rpcRegistry' const importableCodexSessionSummarySchema = z.object({ - agent: z.literal('codex'), + agent: z.union([z.literal('codex'), z.literal('claude')]), externalSessionId: z.string(), cwd: z.string().nullable(), timestamp: z.number().nullable(), diff --git a/shared/src/rpcTypes.ts b/shared/src/rpcTypes.ts index 75df9610e..aac495bd0 100644 --- a/shared/src/rpcTypes.ts +++ b/shared/src/rpcTypes.ts @@ -1,7 +1,7 @@ -export type ImportableSessionAgent = 'codex' +export type ImportableSessionAgent = 'codex' | 'claude' -export type ImportableCodexSessionSummary = { - agent: 'codex' +export type ImportableSessionSummary = { + agent: ImportableSessionAgent externalSessionId: string cwd: string | null timestamp: number | null @@ -10,10 +10,12 @@ export type ImportableCodexSessionSummary = { previewPrompt: string | null } +export type ImportableCodexSessionSummary = ImportableSessionSummary + export type RpcListImportableSessionsRequest = { agent: ImportableSessionAgent } export type RpcListImportableSessionsResponse = { - sessions: ImportableCodexSessionSummary[] + sessions: ImportableSessionSummary[] } From 5b0c82b8dbf9d74d943183b56aacd88e6b924aab Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Fri, 3 Apr 2026 23:35:37 +0800 Subject: [PATCH 45/82] fix: tighten importable session rpc agent checks --- hub/src/sync/rpcGateway.test.ts | 37 +++++++++++++++++++++++++++++++++ hub/src/sync/rpcGateway.ts | 12 ++++++++--- shared/src/rpcTypes.ts | 18 +++++++++++++--- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/hub/src/sync/rpcGateway.test.ts b/hub/src/sync/rpcGateway.test.ts index 217994987..7bc6f6603 100644 --- a/hub/src/sync/rpcGateway.test.ts +++ b/hub/src/sync/rpcGateway.test.ts @@ -146,4 +146,41 @@ describe('RpcGateway', () => { await expect(gateway.listImportableSessions('machine-1', { agent: 'codex' })).rejects.toThrow() }) + + it('rejects list-importable-sessions responses whose session agent does not match the request agent', async () => { + const registry = new RpcRegistry() + + const socket = { + id: 'socket-1', + timeout: () => ({ + emitWithAck: async () => ({ + sessions: [ + { + agent: 'claude', + externalSessionId: 'claude-session-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/session-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt' + } + ] + }) + }) + } + + registry.register(socket as never, 'machine-1:list-importable-sessions') + + const io = { + of: () => ({ + sockets: new Map([['socket-1', socket]]) + }) + } + + const gateway = new RpcGateway(io as never, registry) + + await expect(gateway.listImportableSessions('machine-1', { agent: 'codex' })).rejects.toThrow( + 'Unexpected importable session agent "claude" for request "codex"' + ) + }) }) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index cbf1df6b4..8fe9920c3 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -7,7 +7,7 @@ import type { Server } from 'socket.io' import { z } from 'zod' import type { RpcRegistry } from '../socket/rpcRegistry' -const importableCodexSessionSummarySchema = z.object({ +const importableSessionSummarySchema = z.object({ agent: z.union([z.literal('codex'), z.literal('claude')]), externalSessionId: z.string(), cwd: z.string().nullable(), @@ -18,7 +18,7 @@ const importableCodexSessionSummarySchema = z.object({ }) const listImportableSessionsResponseSchema = z.object({ - sessions: z.array(importableCodexSessionSummarySchema) + sessions: z.array(importableSessionSummarySchema) }) export type RpcCommandResponse = { @@ -254,7 +254,13 @@ export class RpcGateway { request: RpcListImportableSessionsRequest ): Promise { const response = await this.machineRpc(machineId, 'list-importable-sessions', request) - return listImportableSessionsResponseSchema.parse(response) + const parsed = listImportableSessionsResponseSchema.parse(response) + for (const session of parsed.sessions) { + if (session.agent !== request.agent) { + throw new Error(`Unexpected importable session agent "${session.agent}" for request "${request.agent}"`) + } + } + return parsed } private async sessionRpc(sessionId: string, method: string, params: unknown): Promise { diff --git a/shared/src/rpcTypes.ts b/shared/src/rpcTypes.ts index aac495bd0..5101fe29f 100644 --- a/shared/src/rpcTypes.ts +++ b/shared/src/rpcTypes.ts @@ -1,7 +1,17 @@ export type ImportableSessionAgent = 'codex' | 'claude' -export type ImportableSessionSummary = { - agent: ImportableSessionAgent +export type ImportableCodexSessionSummary = { + agent: 'codex' + externalSessionId: string + cwd: string | null + timestamp: number | null + transcriptPath: string + previewTitle: string | null + previewPrompt: string | null +} + +export type ImportableClaudeSessionSummary = { + agent: 'claude' externalSessionId: string cwd: string | null timestamp: number | null @@ -10,7 +20,9 @@ export type ImportableSessionSummary = { previewPrompt: string | null } -export type ImportableCodexSessionSummary = ImportableSessionSummary +export type ImportableSessionSummary = + | ImportableCodexSessionSummary + | ImportableClaudeSessionSummary export type RpcListImportableSessionsRequest = { agent: ImportableSessionAgent From e00bc3ad2f29c36b76868f85f2068877667c6ca7 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Fri, 3 Apr 2026 23:50:13 +0800 Subject: [PATCH 46/82] feat: scan importable claude sessions --- cli/src/api/apiMachine.test.ts | 38 +- cli/src/api/apiMachine.ts | 11 +- .../listImportableClaudeSessions.test.ts | 142 +++++++ .../utils/listImportableClaudeSessions.ts | 366 ++++++++++++++++++ 4 files changed, 553 insertions(+), 4 deletions(-) create mode 100644 cli/src/claude/utils/listImportableClaudeSessions.test.ts create mode 100644 cli/src/claude/utils/listImportableClaudeSessions.ts diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index e989ad62a..ec39b552c 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -10,6 +10,7 @@ type FakeSocket = { } const listImportableCodexSessionsMock = vi.hoisted(() => vi.fn()) +const listImportableClaudeSessionsMock = vi.hoisted(() => vi.fn()) const fakeSocket = vi.hoisted(() => ({ handlers: new Map(), emitted: [], @@ -48,6 +49,20 @@ const importableSessionsResponse = { ] } +const importableClaudeSessionsResponse = { + sessions: [ + { + agent: 'claude', + externalSessionId: 'claude-session-1', + cwd: '/work/project', + timestamp: 1712131200000, + transcriptPath: '/sessions/claude-session-1.jsonl', + previewTitle: 'Continue the refactor', + previewPrompt: 'Continue the refactor' + } + ] +} + vi.mock('socket.io-client', () => ({ io: vi.fn(() => fakeSocket) })) @@ -56,6 +71,10 @@ vi.mock('@/codex/utils/listImportableCodexSessions', () => ({ listImportableCodexSessions: listImportableCodexSessionsMock })) +vi.mock('@/claude/utils/listImportableClaudeSessions', () => ({ + listImportableClaudeSessions: listImportableClaudeSessionsMock +})) + vi.mock('@/modules/common/registerCommonHandlers', () => ({ registerCommonHandlers: vi.fn() })) @@ -78,10 +97,12 @@ describe('ApiMachineClient list-importable-sessions RPC', () => { fakeSocket.emitted.length = 0 vi.mocked(fakeSocket.emitWithAck).mockClear() listImportableCodexSessionsMock.mockReset() + listImportableClaudeSessionsMock.mockReset() listImportableCodexSessionsMock.mockResolvedValue(importableSessionsResponse) + listImportableClaudeSessionsMock.mockResolvedValue(importableClaudeSessionsResponse) }) - it('registers the RPC during connect and returns codex scanner results only for codex', async () => { + it('registers the RPC during connect and returns scanner results by agent', async () => { const machine = { id: 'machine-1', metadata: null, @@ -138,6 +159,21 @@ describe('ApiMachineClient list-importable-sessions RPC', () => { expect(missingAgentResponse).toBe(JSON.stringify({ sessions: [] })) expect(listImportableCodexSessionsMock).toHaveBeenCalledTimes(1) + const claudeResponse = await new Promise((resolve) => { + rpcRequestHandler?.( + { + method: 'machine-1:list-importable-sessions', + params: JSON.stringify({ agent: 'claude' }) + }, + resolve + ) + }) + + expect(JSON.parse(claudeResponse)).toEqual({ + sessions: [expect.objectContaining({ agent: 'claude' })] + }) + expect(listImportableClaudeSessionsMock).toHaveBeenCalledTimes(1) + client.shutdown() }) }) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index e6c48116e..33819b530 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -19,6 +19,7 @@ import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' +import { listImportableClaudeSessions } from '@/claude/utils/listImportableClaudeSessions' import { listImportableCodexSessions } from '@/codex/utils/listImportableCodexSessions' import { applyVersionedAck } from './versionedUpdate' @@ -165,11 +166,15 @@ export class ApiMachineClient { this.rpcHandlerManager.registerHandler( 'list-importable-sessions', async (params) => { - if (params?.agent !== 'codex') { - return { sessions: [] } + if (params?.agent === 'codex') { + return await listImportableCodexSessions() } - return await listImportableCodexSessions() + if (params?.agent === 'claude') { + return await listImportableClaudeSessions() + } + + return { sessions: [] } } ) } diff --git a/cli/src/claude/utils/listImportableClaudeSessions.test.ts b/cli/src/claude/utils/listImportableClaudeSessions.test.ts new file mode 100644 index 000000000..2c48610bc --- /dev/null +++ b/cli/src/claude/utils/listImportableClaudeSessions.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { listImportableClaudeSessions } from './listImportableClaudeSessions' + +describe('listImportableClaudeSessions', () => { + let testDir: string + + beforeEach(async () => { + testDir = join(tmpdir(), `claude-importable-sessions-${Date.now()}`) + await mkdir(testDir, { recursive: true }) + }) + + afterEach(async () => { + if (existsSync(testDir)) { + await rm(testDir, { recursive: true, force: true }) + } + }) + + it('derives Claude importable session summaries from project jsonl files', async () => { + const olderDir = join(testDir, '2026', '04', '03') + const newerDir = join(testDir, '2026', '04', '04') + await mkdir(olderDir, { recursive: true }) + await mkdir(newerDir, { recursive: true }) + + const olderSessionId = 'session-a' + const olderFile = join(olderDir, `${olderSessionId}.jsonl`) + await writeFile( + olderFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: olderSessionId, + cwd: '/work/project-a', + timestamp: '2026-04-03T09:00:00.000Z' + } + }), + JSON.stringify({ + type: 'assistant', + uuid: 'assistant-older-1', + cwd: '/work/project-a', + timestamp: '2026-04-03T09:00:01.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Acknowledged' }] + } + }) + ].join('\n') + '\n' + ) + + const newerSessionId = 'session-b' + const newerFile = join(newerDir, 'project-b-transcript.jsonl') + await writeFile( + newerFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: newerSessionId, + cwd: '/work/project-b', + timestamp: '2026-04-04T12:00:00.000Z' + } + }), + JSON.stringify({ + type: 'user', + uuid: 'user-1', + cwd: '/work/project-b', + timestamp: '2026-04-04T12:00:01.000Z', + message: { + role: 'user', + content: [ + { type: 'text', text: 'Continue the' }, + { type: 'text', text: 'refactor' } + ] + } + }), + JSON.stringify({ + type: 'assistant', + uuid: 'assistant-1', + cwd: '/work/project-b', + timestamp: '2026-04-04T12:00:02.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Working on it' }] + } + }) + ].join('\n') + '\n' + ) + + const ignoredSessionFile = join(newerDir, 'ignored.jsonl') + await writeFile( + ignoredSessionFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: 'ignored-session', + cwd: '/work/ignored', + timestamp: '2026-04-04T13:00:00.000Z' + } + }), + JSON.stringify({ + type: 'system', + subtype: 'init', + uuid: 'system-ignored' + }) + ].join('\n') + '\n' + ) + + const result = await listImportableClaudeSessions({ rootDir: testDir }) + + expect(result.sessions.map((session) => session.externalSessionId)).toEqual([ + newerSessionId, + olderSessionId + ]) + + expect(result.sessions[0]).toMatchObject({ + agent: 'claude', + externalSessionId: newerSessionId, + cwd: '/work/project-b', + timestamp: Date.parse('2026-04-04T12:00:00.000Z'), + transcriptPath: newerFile, + previewPrompt: 'Continue the refactor', + previewTitle: 'Continue the refactor' + }) + + expect(result.sessions[1]).toMatchObject({ + agent: 'claude', + externalSessionId: olderSessionId, + cwd: '/work/project-a', + timestamp: Date.parse('2026-04-03T09:00:00.000Z'), + transcriptPath: olderFile, + previewPrompt: null, + previewTitle: 'project-a' + }) + + expect(result.sessions.find((session) => session.externalSessionId === 'ignored-session')).toBeUndefined() + }) +}) diff --git a/cli/src/claude/utils/listImportableClaudeSessions.ts b/cli/src/claude/utils/listImportableClaudeSessions.ts new file mode 100644 index 000000000..4090e2678 --- /dev/null +++ b/cli/src/claude/utils/listImportableClaudeSessions.ts @@ -0,0 +1,366 @@ +import { homedir } from 'node:os' +import { basename, join } from 'node:path' +import { readdir, readFile } from 'node:fs/promises' +import type { ImportableClaudeSessionSummary } from '@hapi/protocol/rpcTypes' +import { RawJSONLinesSchema, type RawJSONLines } from '@/claude/types' +import { isClaudeChatVisibleMessage } from './chatVisibility' + +export type ListImportableClaudeSessionsOptions = { + rootDir?: string +} + +const SYSTEM_INJECTION_PREFIXES = [ + '', + '', + '', + '' +] + +export async function listImportableClaudeSessions( + opts: ListImportableClaudeSessionsOptions = {} +): Promise<{ sessions: ImportableClaudeSessionSummary[] }> { + const sessionsRoot = opts.rootDir?.trim() ? opts.rootDir : getClaudeSessionsRoot() + const transcriptPaths = (await collectJsonlFiles(sessionsRoot)).sort((a, b) => a.localeCompare(b)) + const summaries = (await Promise.all(transcriptPaths.map(async (transcriptPath) => scanClaudeTranscript(transcriptPath)))) + .filter((summary): summary is ImportableClaudeSessionSummary => summary !== null) + + summaries.sort(compareImportableClaudeSessions) + + return { sessions: summaries } +} + +async function scanClaudeTranscript(transcriptPath: string): Promise { + let content: string + try { + content = await readFile(transcriptPath, 'utf-8') + } catch { + return null + } + + const lines = content.split(/\r?\n/) + let sessionId: string | null = null + let cwd: string | null = null + let timestamp: number | null = null + let explicitTitle: string | null = null + let previewPrompt: string | null = null + let hasVisibleMessage = false + + for (const line of lines) { + const parsedLine = parseJsonLine(line) + if (!parsedLine) { + continue + } + + const sessionMeta = extractSessionMeta(parsedLine) + if (sessionId === null && sessionMeta.sessionId !== null) { + sessionId = sessionMeta.sessionId + } + if (cwd === null && sessionMeta.cwd !== null) { + cwd = sessionMeta.cwd + } + if (timestamp === null && sessionMeta.timestamp !== null) { + timestamp = sessionMeta.timestamp + } + if (explicitTitle === null && sessionMeta.explicitTitle !== null) { + explicitTitle = sessionMeta.explicitTitle + } + + const rawMessage = parseRawClaudeMessage(parsedLine) + if (!rawMessage) { + continue + } + + if (!isClaudeChatVisibleMessage(rawMessage)) { + continue + } + + hasVisibleMessage = true + if (!previewPrompt && isRealClaudeUserMessage(rawMessage)) { + previewPrompt = extractUserPrompt(rawMessage) + } + } + + if (!hasVisibleMessage) { + return null + } + + const externalSessionId = sessionId ?? basename(transcriptPath, '.jsonl') + const previewTitle = explicitTitle + ?? previewPrompt + ?? deriveCwdPreview(cwd) + ?? externalSessionId + + return { + agent: 'claude', + externalSessionId, + cwd, + timestamp, + transcriptPath, + previewTitle, + previewPrompt + } +} + +function getClaudeSessionsRoot(): string { + const claudeHome = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude') + return join(claudeHome, 'projects') +} + +async function collectJsonlFiles(root: string): Promise { + try { + const entries = await readdir(root, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + const fullPath = join(root, entry.name) + if (entry.isDirectory()) { + files.push(...await collectJsonlFiles(fullPath)) + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(fullPath) + } + } + + return files + } catch { + return [] + } +} + +function parseJsonLine(line: string): Record | null { + if (line.trim().length === 0) { + return null + } + + try { + const parsed = JSON.parse(line) as unknown + return getRecord(parsed) + } catch { + return null + } +} + +function parseRawClaudeMessage(record: Record): RawJSONLines | null { + const parsed = RawJSONLinesSchema.safeParse(record) + return parsed.success ? parsed.data : null +} + +function extractSessionMeta(record: Record): { + sessionId: string | null + cwd: string | null + timestamp: number | null + explicitTitle: string | null +} { + const payload = getRecord(record.payload) + const sessionId = getString(record.sessionId) + ?? getString(record.session_id) + ?? getString(payload?.sessionId) + ?? getString(payload?.session_id) + ?? getString(payload?.id) + ?? getString(record.id) + + const cwd = getString(record.cwd) + ?? getString(payload?.cwd) + + const timestamp = parseTimestamp(record.timestamp) ?? parseTimestamp(payload?.timestamp) + + const explicitTitle = extractExplicitTitleFromRecord(record) ?? extractExplicitTitleFromRecord(payload) + + if (sessionId || cwd || timestamp !== null || explicitTitle) { + return { sessionId, cwd, timestamp, explicitTitle } + } + + return { + sessionId: null, + cwd: null, + timestamp: null, + explicitTitle: null + } +} + +function extractExplicitTitleFromRecord(record: Record | null): string | null { + if (!record) { + return null + } + + const type = getString(record.type) + if (type === 'session_title_change') { + return extractTextValue(record.title ?? record.summary ?? record.text) + } + + const payload = getRecord(record.payload) + if (payload) { + const payloadType = getString(payload.type) + if (payloadType === 'session_title_change') { + return extractTextValue(payload.title ?? payload.summary ?? payload.text) + } + } + + return extractTextValue(record.title ?? record.summary ?? record.name) +} + +function extractUserPrompt(message: RawJSONLines): string | null { + if (message.type !== 'user') { + return null + } + + return extractUserMessageText(message.message?.content) +} + +function isRealClaudeUserMessage(message: RawJSONLines): message is Extract { + if (message.type !== 'user') { + return false + } + + if (message.isSidechain === true || message.isMeta === true || message.isCompactSummary === true) { + return false + } + + const prompt = extractTextValue(message.message?.content) + if (!prompt) { + return false + } + + const trimmed = prompt.trimStart() + for (const prefix of SYSTEM_INJECTION_PREFIXES) { + if (trimmed.startsWith(prefix)) { + return false + } + } + + return true +} + +function extractTextValue(value: unknown): string | null { + const chunks = extractTextChunks(value) + if (chunks.length === 0) { + return null + } + + return normalizePreviewText(chunks.join(' ')) +} + +function extractUserMessageText(value: unknown): string | null { + if (typeof value === 'string') { + const normalized = normalizePreviewText(value) + return normalized ? normalized : null + } + + if (!Array.isArray(value)) { + return null + } + + const chunks: string[] = [] + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + continue + } + + const item = entry as Record + if (item.type !== 'text') { + continue + } + + const text = getString(item.text) + if (text) { + chunks.push(normalizePreviewText(text)) + } + } + + if (chunks.length === 0) { + return null + } + + return normalizePreviewText(chunks.join(' ')) +} + +function extractTextChunks(value: unknown): string[] { + if (typeof value === 'string') { + const normalized = normalizePreviewText(value) + return normalized ? [normalized] : [] + } + + if (Array.isArray(value)) { + const chunks: string[] = [] + for (const entry of value) { + chunks.push(...extractTextChunks(entry)) + } + return chunks + } + + const record = getRecord(value) + if (!record) { + return [] + } + + const directKeys = ['title', 'message', 'text', 'content', 'input', 'body'] as const + for (const key of directKeys) { + const entryValue = record[key] + if (entryValue === undefined || entryValue === null) { + continue + } + + const chunks = extractTextChunks(entryValue) + if (chunks.length > 0) { + return chunks + } + } + + return [] +} + +function deriveCwdPreview(cwd: string | null): string | null { + if (!cwd) { + return null + } + + const trimmed = cwd.trim() + if (!trimmed) { + return null + } + + const segment = basename(trimmed) + return segment.length > 0 ? normalizePreviewText(segment) : null +} + +function compareImportableClaudeSessions( + left: ImportableClaudeSessionSummary, + right: ImportableClaudeSessionSummary +): number { + const leftTimestamp = left.timestamp ?? Number.NEGATIVE_INFINITY + const rightTimestamp = right.timestamp ?? Number.NEGATIVE_INFINITY + + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp - leftTimestamp + } + + return right.transcriptPath.localeCompare(left.transcriptPath) +} + +function parseTimestamp(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? null : parsed + } + + return null +} + +function normalizePreviewText(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +function getRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object') { + return null + } + + return value as Record +} + +function getString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} From 7c3bb7a8bde82ebe492bfeace46218a69eae852e Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Fri, 3 Apr 2026 23:54:38 +0800 Subject: [PATCH 47/82] test: tighten claude import assertions --- cli/src/api/apiMachine.test.ts | 4 +--- .../listImportableClaudeSessions.test.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index ec39b552c..84f9438d2 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -169,9 +169,7 @@ describe('ApiMachineClient list-importable-sessions RPC', () => { ) }) - expect(JSON.parse(claudeResponse)).toEqual({ - sessions: [expect.objectContaining({ agent: 'claude' })] - }) + expect(JSON.parse(claudeResponse)).toEqual(importableClaudeSessionsResponse) expect(listImportableClaudeSessionsMock).toHaveBeenCalledTimes(1) client.shutdown() diff --git a/cli/src/claude/utils/listImportableClaudeSessions.test.ts b/cli/src/claude/utils/listImportableClaudeSessions.test.ts index 2c48610bc..dc215b9a1 100644 --- a/cli/src/claude/utils/listImportableClaudeSessions.test.ts +++ b/cli/src/claude/utils/listImportableClaudeSessions.test.ts @@ -64,6 +64,16 @@ describe('listImportableClaudeSessions', () => { timestamp: '2026-04-04T12:00:00.000Z' } }), + JSON.stringify({ + type: 'user', + uuid: 'user-0', + cwd: '/work/project-b', + timestamp: '2026-04-04T12:00:00.500Z', + message: { + role: 'user', + content: ' internal Claude injection' + } + }), JSON.stringify({ type: 'user', uuid: 'user-1', @@ -77,6 +87,16 @@ describe('listImportableClaudeSessions', () => { ] } }), + JSON.stringify({ + type: 'user', + uuid: 'user-2', + cwd: '/work/project-b', + timestamp: '2026-04-04T12:00:03.000Z', + message: { + role: 'user', + content: 'Ignore this later user prompt' + } + }), JSON.stringify({ type: 'assistant', uuid: 'assistant-1', From fe59fdf8aca9916545da76dd346567bebf803ef9 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 00:02:29 +0800 Subject: [PATCH 48/82] fix: select resumed claude import session --- .../listImportableClaudeSessions.test.ts | 28 +++++- .../utils/listImportableClaudeSessions.ts | 94 +++++++++++++------ 2 files changed, 90 insertions(+), 32 deletions(-) diff --git a/cli/src/claude/utils/listImportableClaudeSessions.test.ts b/cli/src/claude/utils/listImportableClaudeSessions.test.ts index dc215b9a1..b941c7eb2 100644 --- a/cli/src/claude/utils/listImportableClaudeSessions.test.ts +++ b/cli/src/claude/utils/listImportableClaudeSessions.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { existsSync } from 'node:fs' -import { mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { listImportableClaudeSessions } from './listImportableClaudeSessions' @@ -19,6 +19,32 @@ describe('listImportableClaudeSessions', () => { } }) + it('uses the resumed root Claude session from the real fixture instead of carried-over history', async () => { + const fixtureContent = await readFile(join(__dirname, '__fixtures__', '1-continue-run-ls-tool.jsonl'), 'utf-8') + const sessionsRoot = join(testDir, 'sessions') + await mkdir(sessionsRoot, { recursive: true }) + + const fixturePath = join(sessionsRoot, '1-continue-run-ls-tool.jsonl') + await writeFile(fixturePath, fixtureContent) + + const result = await listImportableClaudeSessions({ rootDir: sessionsRoot }) + + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'claude', + externalSessionId: '789e105f-ae33-486d-9271-0696266f072d', + cwd: '/Users/kirilldubovitskiy/projects/happy/handy-cli/notes/test-project', + timestamp: Date.parse('2025-07-19T22:32:32.898Z'), + transcriptPath: fixturePath, + previewPrompt: 'run ls tool', + previewTitle: 'run ls tool' + }) + + expect(result.sessions[0].externalSessionId).not.toBe('93a9705e-bc6a-406d-8dce-8acc014dedbd') + expect(result.sessions[0].previewPrompt).not.toBe('say lol') + expect(result.sessions[0].previewTitle).not.toBe('Casual Chat: Simple Greeting Exchange') + }) + it('derives Claude importable session summaries from project jsonl files', async () => { const olderDir = join(testDir, '2026', '04', '03') const newerDir = join(testDir, '2026', '04', '04') diff --git a/cli/src/claude/utils/listImportableClaudeSessions.ts b/cli/src/claude/utils/listImportableClaudeSessions.ts index 4090e2678..a12d50a2e 100644 --- a/cli/src/claude/utils/listImportableClaudeSessions.ts +++ b/cli/src/claude/utils/listImportableClaudeSessions.ts @@ -38,23 +38,32 @@ async function scanClaudeTranscript(transcriptPath: string): Promise ({ + lineIndex, + record: parseJsonLine(line) + })) + .filter((entry): entry is { lineIndex: number; record: Record } => entry.record !== null) + + const rootSessionId = findRootSessionId(records) + if (!rootSessionId) { + return null + } + + const rootStartIndex = findRootSessionStartIndex(records, rootSessionId) + let cwd: string | null = null let timestamp: number | null = null let explicitTitle: string | null = null let previewPrompt: string | null = null let hasVisibleMessage = false - for (const line of lines) { - const parsedLine = parseJsonLine(line) - if (!parsedLine) { + for (const entry of records) { + if (entry.lineIndex < rootStartIndex) { continue } - const sessionMeta = extractSessionMeta(parsedLine) - if (sessionId === null && sessionMeta.sessionId !== null) { - sessionId = sessionMeta.sessionId - } + const sessionMeta = extractSessionMeta(entry.record) if (cwd === null && sessionMeta.cwd !== null) { cwd = sessionMeta.cwd } @@ -65,7 +74,7 @@ async function scanClaudeTranscript(transcriptPath: string): Promise): RawJSONLines | return parsed.success ? parsed.data : null } +function findRootSessionId(records: Array<{ lineIndex: number; record: Record }>): string | null { + for (let index = records.length - 1; index >= 0; index -= 1) { + const sessionId = extractSessionIdCandidate(records[index].record) + if (sessionId) { + return sessionId + } + } + + return null +} + +function findRootSessionStartIndex(records: Array<{ lineIndex: number; record: Record }>, rootSessionId: string): number { + const match = records.find((entry) => extractSessionIdCandidate(entry.record) === rootSessionId) + return match?.lineIndex ?? 0 +} + function extractSessionMeta(record: Record): { - sessionId: string | null cwd: string | null timestamp: number | null explicitTitle: string | null } { const payload = getRecord(record.payload) - const sessionId = getString(record.sessionId) - ?? getString(record.session_id) - ?? getString(payload?.sessionId) - ?? getString(payload?.session_id) - ?? getString(payload?.id) - ?? getString(record.id) const cwd = getString(record.cwd) ?? getString(payload?.cwd) @@ -165,15 +182,10 @@ function extractSessionMeta(record: Record): { const explicitTitle = extractExplicitTitleFromRecord(record) ?? extractExplicitTitleFromRecord(payload) - if (sessionId || cwd || timestamp !== null || explicitTitle) { - return { sessionId, cwd, timestamp, explicitTitle } - } - return { - sessionId: null, - cwd: null, - timestamp: null, - explicitTitle: null + cwd, + timestamp, + explicitTitle } } @@ -184,18 +196,28 @@ function extractExplicitTitleFromRecord(record: Record | null): const type = getString(record.type) if (type === 'session_title_change') { - return extractTextValue(record.title ?? record.summary ?? record.text) + return extractTextValue(record.title ?? record.text) } const payload = getRecord(record.payload) if (payload) { const payloadType = getString(payload.type) if (payloadType === 'session_title_change') { - return extractTextValue(payload.title ?? payload.summary ?? payload.text) + return extractTextValue(payload.title ?? payload.text) } } - return extractTextValue(record.title ?? record.summary ?? record.name) + const topLevelTitle = getString(record.title) + if (topLevelTitle) { + return extractTextValue(topLevelTitle) + } + + const payloadTitle = getString(getRecord(record.payload)?.title) + if (payloadTitle) { + return extractTextValue(payloadTitle) + } + + return null } function extractUserPrompt(message: RawJSONLines): string | null { @@ -215,7 +237,7 @@ function isRealClaudeUserMessage(message: RawJSONLines): message is Extract): string | null { + const payload = getRecord(record.payload) + return getString(record.sessionId) + ?? getString(record.session_id) + ?? getString(payload?.sessionId) + ?? getString(payload?.session_id) + ?? getString(payload?.id) + ?? getString(record.id) +} + function extractTextChunks(value: unknown): string[] { if (typeof value === 'string') { const normalized = normalizePreviewText(value) From ee6f01098df684bb23d10faeccc49ee2c9945887 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 00:14:07 +0800 Subject: [PATCH 49/82] fix: replay claude transcript history on import resume --- cli/src/claude/claudeRemoteLauncher.test.ts | 193 ++++++++++++++++++++ cli/src/claude/claudeRemoteLauncher.ts | 35 ++++ cli/src/claude/utils/sessionScanner.ts | 17 +- 3 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 cli/src/claude/claudeRemoteLauncher.test.ts diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts new file mode 100644 index 000000000..a604fa7d4 --- /dev/null +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const harness = vi.hoisted(() => ({ + replayMessages: [] as Array>, + scannerCalls: [] as Array>, + remoteCalls: [] as Array>, + rpcHandlers: new Map Promise | unknown>(), +})) + +vi.mock('./claudeRemote', () => ({ + claudeRemote: async (opts: { + onMessage: (message: Record) => void + }) => { + harness.remoteCalls.push(opts as Record) + opts.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'live assistant reply' }] + } + }) + void harness.rpcHandlers.get('switch')?.({}) + } +})) + +vi.mock('./utils/sessionScanner', () => ({ + createSessionScanner: async (opts: { + onMessage: (message: Record) => void + }) => { + harness.scannerCalls.push(opts as Record) + for (const message of harness.replayMessages) { + opts.onMessage(message) + } + return { + cleanup: async () => {}, + onNewSession: () => {} + } + } +})) + +vi.mock('./utils/permissionHandler', () => ({ + PermissionHandler: class { + constructor() {} + setOnPermissionRequest(): void {} + onMessage(): void {} + getResponses(): Map { + return new Map() + } + handleToolCall(): Promise<{ behavior: 'allow' }> { + return Promise.resolve({ behavior: 'allow' }) + } + isAborted(): boolean { + return false + } + handleModeChange(): void {} + reset(): void {} + } +})) + +vi.mock('./utils/OutgoingMessageQueue', () => ({ + OutgoingMessageQueue: class { + constructor(private readonly send: (message: Record) => void) {} + enqueue(message: Record): void { + this.send(message) + } + releaseToolCall(): void {} + async flush(): Promise {} + destroy(): void {} + } +})) + +vi.mock('@/ui/messageFormatterInk', () => ({ + formatClaudeMessageForInk: () => {} +})) + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: () => {}, + debugLargeJson: () => {} + } +})) + +import { claudeRemoteLauncher } from './claudeRemoteLauncher' + +function createSessionStub() { + const sentClaudeMessages: Array> = [] + const sessionEvents: Array> = [] + const sessionFoundCallbacks = new Set<(sessionId: string) => void>() + + const session: { + sessionId: string | null; + path: string; + logPath: string; + startedBy: 'runner'; + startingMode: 'remote'; + claudeEnvVars: Record; + claudeArgs: string[]; + mcpServers: Record; + allowedTools: string[]; + hookSettingsPath: string; + queue: { + size: () => number; + waitForMessagesAndGetAsString: () => Promise; + }; + client: { + sendClaudeSessionMessage: (message: Record) => void; + sendSessionEvent: (event: Record) => void; + rpcHandlerManager: { + registerHandler: (method: string, handler: (params?: unknown) => Promise | unknown) => void; + }; + }; + addSessionFoundCallback: (callback: (sessionId: string) => void) => void; + removeSessionFoundCallback: (callback: (sessionId: string) => void) => void; + onSessionFound: (sessionId: string) => void; + onThinkingChange: () => void; + clearSessionId: () => void; + consumeOneTimeFlags: () => void; + } = { + sessionId: 'resume-session-123', + path: '/tmp/hapi-update', + logPath: '/tmp/hapi-update/test.log', + startedBy: 'runner' as const, + startingMode: 'remote' as const, + claudeEnvVars: {}, + claudeArgs: ['--resume', 'resume-session-123'], + mcpServers: {}, + allowedTools: [], + hookSettingsPath: '/tmp/hapi-update/hooks.json', + queue: { + size: () => 0, + waitForMessagesAndGetAsString: async () => null, + }, + client: { + sendClaudeSessionMessage: (message: Record) => { + sentClaudeMessages.push(message) + }, + sendSessionEvent: (event: Record) => { + sessionEvents.push(event) + }, + rpcHandlerManager: { + registerHandler(method: string, handler: (params?: unknown) => Promise | unknown) { + harness.rpcHandlers.set(method, handler) + } + } + }, + addSessionFoundCallback(callback: (sessionId: string) => void) { + sessionFoundCallbacks.add(callback) + }, + removeSessionFoundCallback(callback: (sessionId: string) => void) { + sessionFoundCallbacks.delete(callback) + }, + onSessionFound(sessionId: string) { + session.sessionId = sessionId + for (const callback of sessionFoundCallbacks) { + callback(sessionId) + } + }, + onThinkingChange: () => {}, + clearSessionId: () => { + session.sessionId = null + }, + consumeOneTimeFlags: () => {}, + } + + return { + session, + sentClaudeMessages, + sessionEvents + } +} + +describe('claudeRemoteLauncher', () => { + afterEach(() => { + harness.replayMessages = [] + harness.scannerCalls = [] + harness.remoteCalls = [] + harness.rpcHandlers = new Map() + }) + + it('replays transcript history during explicit Claude remote resume', async () => { + harness.replayMessages = [ + { type: 'user', uuid: 'u1', message: { content: 'existing user prompt' } }, + { type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'existing assistant reply' }] } } + ] + + const { session, sentClaudeMessages } = createSessionStub() + + await claudeRemoteLauncher(session as never) + + expect(sentClaudeMessages).toContainEqual(expect.objectContaining({ type: 'user' })) + expect(sentClaudeMessages).toContainEqual(expect.objectContaining({ type: 'assistant' })) + }) +}) diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index c5f4a327f..c0c200e1f 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -11,6 +11,8 @@ import { SDKToLogConverter } from "./utils/sdkToLogConverter"; import { PLAN_FAKE_REJECT } from "./sdk/prompts"; import { EnhancedMode } from "./loop"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; +import { createSessionScanner } from "./utils/sessionScanner"; +import { isClaudeChatVisibleMessage } from "./utils/chatVisibility"; import type { ClaudePermissionMode } from "@hapi/protocol/types"; import { RemoteLauncherBase, @@ -31,6 +33,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { private abortFuture: Future | null = null; private permissionHandler: PermissionHandler | null = null; private handleSessionFound: ((sessionId: string) => void) | null = null; + private didReplayExplicitResumeTranscript = false; constructor(session: Session) { super(process.env.DEBUG ? session.logPath : undefined); @@ -108,6 +111,36 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { version: process.env.npm_package_version }, permissionHandler.getResponses()); + const replayExplicitResumeTranscript = async (): Promise => { + if (this.didReplayExplicitResumeTranscript || session.startingMode !== 'remote') { + return; + } + + const resumeSessionId = session.sessionId; + if (!resumeSessionId) { + return; + } + + this.didReplayExplicitResumeTranscript = true; + + const scanner = await createSessionScanner({ + sessionId: resumeSessionId, + workingDirectory: session.path, + replayExistingMessages: true, + onMessage: (message) => { + if (message.type === 'summary' || message.isMeta || message.isCompactSummary) { + return; + } + if (!isClaudeChatVisibleMessage(message)) { + return; + } + session.client.sendClaudeSessionMessage(message); + } + }); + + await scanner.cleanup(); + }; + const handleSessionFound = (sessionId: string) => { sdkToLogConverter.updateSessionId(sessionId); }; @@ -277,6 +310,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { logger.debug('[remote]: launch'); messageBuffer.addMessage('═'.repeat(40), 'status'); + await replayExplicitResumeTranscript(); + const isNewSession = session.sessionId !== previousSessionId; if (isNewSession) { messageBuffer.addMessage('Starting new Claude session...', 'status'); diff --git a/cli/src/claude/utils/sessionScanner.ts b/cli/src/claude/utils/sessionScanner.ts index d97dd2230..01e4e6de7 100644 --- a/cli/src/claude/utils/sessionScanner.ts +++ b/cli/src/claude/utils/sessionScanner.ts @@ -20,11 +20,13 @@ export async function createSessionScanner(opts: { sessionId: string | null; workingDirectory: string; onMessage: (message: RawJSONLines) => void; + replayExistingMessages?: boolean; }) { const scanner = new ClaudeSessionScanner({ sessionId: opts.sessionId, workingDirectory: opts.workingDirectory, - onMessage: opts.onMessage + onMessage: opts.onMessage, + replayExistingMessages: opts.replayExistingMessages }); await scanner.start(); @@ -49,12 +51,19 @@ class ClaudeSessionScanner extends BaseSessionScanner { private readonly pendingSessions = new Set(); private currentSessionId: string | null; private readonly scannedSessions = new Set(); - - constructor(opts: { sessionId: string | null; workingDirectory: string; onMessage: (message: RawJSONLines) => void }) { + private readonly replayExistingMessages: boolean; + + constructor(opts: { + sessionId: string | null; + workingDirectory: string; + onMessage: (message: RawJSONLines) => void; + replayExistingMessages?: boolean; + }) { super({ intervalMs: 3000 }); this.projectDir = getProjectPath(opts.workingDirectory); this.onMessage = opts.onMessage; this.currentSessionId = opts.sessionId; + this.replayExistingMessages = opts.replayExistingMessages ?? false; } public onNewSession(sessionId: string): void { @@ -79,7 +88,7 @@ class ClaudeSessionScanner extends BaseSessionScanner { } protected async initialize(): Promise { - if (!this.currentSessionId) { + if (!this.currentSessionId || this.replayExistingMessages) { return; } const sessionFile = this.sessionFilePath(this.currentSessionId); From 3f014435cb47672b1ec6f722f59e0b48ec5e7d16 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 00:28:14 +0800 Subject: [PATCH 50/82] fix: tighten claude import replay gating --- cli/src/claude/claudeRemoteLauncher.test.ts | 55 +++++++++++++++++++-- cli/src/claude/claudeRemoteLauncher.ts | 7 +-- cli/src/claude/session.ts | 44 ++++++++++++++++- 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts index a604fa7d4..8f397a621 100644 --- a/cli/src/claude/claudeRemoteLauncher.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -86,6 +86,7 @@ function createSessionStub() { const sentClaudeMessages: Array> = [] const sessionEvents: Array> = [] const sessionFoundCallbacks = new Set<(sessionId: string) => void>() + let explicitResumeReplayConsumed = false const session: { sessionId: string | null; @@ -114,6 +115,7 @@ function createSessionStub() { onSessionFound: (sessionId: string) => void; onThinkingChange: () => void; clearSessionId: () => void; + consumeExplicitRemoteResumeReplaySessionId: () => string | null; consumeOneTimeFlags: () => void; } = { sessionId: 'resume-session-123', @@ -159,6 +161,13 @@ function createSessionStub() { clearSessionId: () => { session.sessionId = null }, + consumeExplicitRemoteResumeReplaySessionId: () => { + if (explicitResumeReplayConsumed) { + return null + } + explicitResumeReplayConsumed = true + return 'resume-session-123' + }, consumeOneTimeFlags: () => {}, } @@ -177,7 +186,7 @@ describe('claudeRemoteLauncher', () => { harness.rpcHandlers = new Map() }) - it('replays transcript history during explicit Claude remote resume', async () => { + it('replays transcript history before live remote Claude messages', async () => { harness.replayMessages = [ { type: 'user', uuid: 'u1', message: { content: 'existing user prompt' } }, { type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'existing assistant reply' }] } } @@ -187,7 +196,47 @@ describe('claudeRemoteLauncher', () => { await claudeRemoteLauncher(session as never) - expect(sentClaudeMessages).toContainEqual(expect.objectContaining({ type: 'user' })) - expect(sentClaudeMessages).toContainEqual(expect.objectContaining({ type: 'assistant' })) + expect(sentClaudeMessages.slice(0, 3)).toEqual([ + expect.objectContaining({ + type: 'user', + message: expect.objectContaining({ content: 'existing user prompt' }) + }), + expect.objectContaining({ + type: 'assistant', + message: expect.objectContaining({ + content: [{ type: 'text', text: 'existing assistant reply' }] + }) + }), + expect.objectContaining({ + type: 'assistant', + message: expect.objectContaining({ + content: [{ type: 'text', text: 'live assistant reply' }] + }) + }) + ]) + }) + + it('replays transcript history only once for an explicit Claude remote resume session', async () => { + harness.replayMessages = [ + { type: 'user', uuid: 'u1', message: { content: 'existing user prompt' } }, + { type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'existing assistant reply' }] } } + ] + + const { session, sentClaudeMessages } = createSessionStub() + + await claudeRemoteLauncher(session as never) + const firstLaunchCount = sentClaudeMessages.length + + await claudeRemoteLauncher(session as never) + + expect(harness.scannerCalls).toHaveLength(1) + expect(sentClaudeMessages.slice(firstLaunchCount)).toEqual([ + expect.objectContaining({ + type: 'assistant', + message: expect.objectContaining({ + content: [{ type: 'text', text: 'live assistant reply' }] + }) + }) + ]) }) }) diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index c0c200e1f..1edf9b025 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -33,7 +33,6 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { private abortFuture: Future | null = null; private permissionHandler: PermissionHandler | null = null; private handleSessionFound: ((sessionId: string) => void) | null = null; - private didReplayExplicitResumeTranscript = false; constructor(session: Session) { super(process.env.DEBUG ? session.logPath : undefined); @@ -112,17 +111,15 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { }, permissionHandler.getResponses()); const replayExplicitResumeTranscript = async (): Promise => { - if (this.didReplayExplicitResumeTranscript || session.startingMode !== 'remote') { + if (session.startingMode !== 'remote') { return; } - const resumeSessionId = session.sessionId; + const resumeSessionId = session.consumeExplicitRemoteResumeReplaySessionId(); if (!resumeSessionId) { return; } - this.didReplayExplicitResumeTranscript = true; - const scanner = await createSessionScanner({ sessionId: resumeSessionId, workingDirectory: session.path, diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index 975bcb2da..90e21bada 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -21,6 +21,7 @@ export class Session extends AgentSessionBase { readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; localLaunchFailure: LocalLaunchFailure | null = null; + private explicitRemoteResumeReplayConsumed = false; constructor(opts: { api: ApiClient; @@ -98,6 +99,21 @@ export class Session extends AgentSessionBase { logger.debug('[Session] Session ID cleared'); }; + consumeExplicitRemoteResumeReplaySessionId = (): string | null => { + if (this.explicitRemoteResumeReplayConsumed) { + return null; + } + + const resumeSessionId = extractExplicitResumeSessionId(this.claudeArgs); + if (!resumeSessionId) { + return null; + } + + this.explicitRemoteResumeReplayConsumed = true; + logger.debug(`[Session] Consumed explicit remote resume replay session ID: ${resumeSessionId}`); + return resumeSessionId; + }; + /** * Consume one-time Claude flags from claudeArgs after Claude spawn * Currently handles: --resume (with or without session ID) @@ -111,8 +127,7 @@ export class Session extends AgentSessionBase { // Check if next arg looks like a UUID (contains dashes and alphanumeric) if (i + 1 < this.claudeArgs.length) { const nextArg = this.claudeArgs[i + 1]; - // Simple UUID pattern check - contains dashes and is not another flag - if (!nextArg.startsWith('-') && nextArg.includes('-')) { + if (isExplicitResumeSessionId(nextArg)) { // Skip both --resume and the UUID i++; // Skip the UUID logger.debug(`[Session] Consumed --resume flag with session ID: ${nextArg}`); @@ -133,3 +148,28 @@ export class Session extends AgentSessionBase { logger.debug(`[Session] Consumed one-time flags, remaining args:`, this.claudeArgs); }; } + +function extractExplicitResumeSessionId(args?: string[]): string | null { + if (!args) { + return null; + } + + for (let i = 0; i < args.length; i++) { + if (args[i] !== '--resume') { + continue; + } + + if (i + 1 >= args.length) { + return null; + } + + const nextArg = args[i + 1]; + return isExplicitResumeSessionId(nextArg) ? nextArg : null; + } + + return null; +} + +function isExplicitResumeSessionId(value: string): boolean { + return !value.startsWith('-') && value.includes('-'); +} From 20f455bad7089537e167d91892c51eedf8b11174 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 00:42:06 +0800 Subject: [PATCH 51/82] test: harden claude import replay contract --- cli/src/claude/claudeRemote.ts | 28 ++++++--------------- cli/src/claude/claudeRemoteLauncher.test.ts | 16 ++++++++++-- cli/src/claude/session.ts | 26 +------------------ cli/src/claude/utils/explicitResume.ts | 24 ++++++++++++++++++ 4 files changed, 46 insertions(+), 48 deletions(-) create mode 100644 cli/src/claude/utils/explicitResume.ts diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 038b037fd..2ce6c5f4b 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -11,6 +11,7 @@ import { systemPrompt } from "./utils/systemPrompt"; import { PermissionResult } from "./sdk/types"; import { getHapiBlobsDir } from "@/constants/uploadPaths"; import { getDefaultClaudeCodePath } from "./sdk/utils"; +import { extractExplicitResumeSessionId } from "./utils/explicitResume"; export async function claudeRemote(opts: { @@ -47,27 +48,12 @@ export async function claudeRemote(opts: { // Extract --resume from claudeArgs if present (for first spawn) if (!startFrom && opts.claudeArgs) { - for (let i = 0; i < opts.claudeArgs.length; i++) { - if (opts.claudeArgs[i] === '--resume') { - // Check if next arg exists and looks like a session ID - if (i + 1 < opts.claudeArgs.length) { - const nextArg = opts.claudeArgs[i + 1]; - // If next arg doesn't start with dash and contains dashes, it's likely a UUID - if (!nextArg.startsWith('-') && nextArg.includes('-')) { - startFrom = nextArg; - logger.debug(`[claudeRemote] Found --resume with session ID: ${startFrom}`); - break; - } else { - // Just --resume without UUID - SDK doesn't support this - logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode'); - break; - } - } else { - // --resume at end of args - SDK doesn't support this - logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode'); - break; - } - } + const explicitResumeSessionId = extractExplicitResumeSessionId(opts.claudeArgs); + if (explicitResumeSessionId) { + startFrom = explicitResumeSessionId; + logger.debug(`[claudeRemote] Found --resume with session ID: ${startFrom}`); + } else if (opts.claudeArgs.includes('--resume')) { + logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode'); } } diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts index 8f397a621..a255bc70b 100644 --- a/cli/src/claude/claudeRemoteLauncher.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -5,6 +5,7 @@ const harness = vi.hoisted(() => ({ scannerCalls: [] as Array>, remoteCalls: [] as Array>, rpcHandlers: new Map Promise | unknown>(), + expectedReplaySessionId: 'resume-session-123', })) vi.mock('./claudeRemote', () => ({ @@ -25,9 +26,13 @@ vi.mock('./claudeRemote', () => ({ vi.mock('./utils/sessionScanner', () => ({ createSessionScanner: async (opts: { + sessionId: string | null; + replayExistingMessages?: boolean; onMessage: (message: Record) => void }) => { harness.scannerCalls.push(opts as Record) + expect(opts.sessionId).toBe(harness.expectedReplaySessionId) + expect(opts.replayExistingMessages).toBe(true) for (const message of harness.replayMessages) { opts.onMessage(message) } @@ -118,7 +123,7 @@ function createSessionStub() { consumeExplicitRemoteResumeReplaySessionId: () => string | null; consumeOneTimeFlags: () => void; } = { - sessionId: 'resume-session-123', + sessionId: null, path: '/tmp/hapi-update', logPath: '/tmp/hapi-update/test.log', startedBy: 'runner' as const, @@ -166,7 +171,7 @@ function createSessionStub() { return null } explicitResumeReplayConsumed = true - return 'resume-session-123' + return session.claudeArgs[1] ?? null }, consumeOneTimeFlags: () => {}, } @@ -184,6 +189,7 @@ describe('claudeRemoteLauncher', () => { harness.scannerCalls = [] harness.remoteCalls = [] harness.rpcHandlers = new Map() + harness.expectedReplaySessionId = 'resume-session-123' }) it('replays transcript history before live remote Claude messages', async () => { @@ -196,6 +202,12 @@ describe('claudeRemoteLauncher', () => { await claudeRemoteLauncher(session as never) + expect(harness.scannerCalls).toEqual([ + expect.objectContaining({ + sessionId: 'resume-session-123', + replayExistingMessages: true + }) + ]) expect(sentClaudeMessages.slice(0, 3)).toEqual([ expect.objectContaining({ type: 'user', diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index 90e21bada..7203f0f44 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -6,6 +6,7 @@ import type { SessionEffort, SessionModel } from '@/api/types'; import type { EnhancedMode } from './loop'; import type { PermissionMode } from './loop'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; +import { extractExplicitResumeSessionId, isExplicitResumeSessionId } from './utils/explicitResume'; type LocalLaunchFailure = { message: string; @@ -148,28 +149,3 @@ export class Session extends AgentSessionBase { logger.debug(`[Session] Consumed one-time flags, remaining args:`, this.claudeArgs); }; } - -function extractExplicitResumeSessionId(args?: string[]): string | null { - if (!args) { - return null; - } - - for (let i = 0; i < args.length; i++) { - if (args[i] !== '--resume') { - continue; - } - - if (i + 1 >= args.length) { - return null; - } - - const nextArg = args[i + 1]; - return isExplicitResumeSessionId(nextArg) ? nextArg : null; - } - - return null; -} - -function isExplicitResumeSessionId(value: string): boolean { - return !value.startsWith('-') && value.includes('-'); -} diff --git a/cli/src/claude/utils/explicitResume.ts b/cli/src/claude/utils/explicitResume.ts new file mode 100644 index 000000000..02c122e2a --- /dev/null +++ b/cli/src/claude/utils/explicitResume.ts @@ -0,0 +1,24 @@ +export function extractExplicitResumeSessionId(args?: string[]): string | null { + if (!args) { + return null; + } + + for (let i = 0; i < args.length; i++) { + if (args[i] !== '--resume') { + continue; + } + + if (i + 1 >= args.length) { + return null; + } + + const nextArg = args[i + 1]; + return isExplicitResumeSessionId(nextArg) ? nextArg : null; + } + + return null; +} + +export function isExplicitResumeSessionId(value: string): boolean { + return !value.startsWith('-') && value.includes('-'); +} From 4fe878c77577284c819d7638b80fe739ae068100 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 00:50:54 +0800 Subject: [PATCH 52/82] feat: add claude import and refresh orchestration --- hub/src/sync/sessionCache.ts | 14 +- hub/src/sync/syncEngine.test.ts | 219 ++++++++++ hub/src/sync/syncEngine.ts | 398 ++++++++++-------- hub/src/web/routes/importableSessions.test.ts | 104 +++++ hub/src/web/routes/importableSessions.ts | 42 +- 5 files changed, 605 insertions(+), 172 deletions(-) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 8e45c2802..5fd2d2741 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -37,13 +37,25 @@ export class SessionCache { } findSessionByExternalCodexSessionId(namespace: string, externalSessionId: string): { sessionId: string } | null { + return this.findSessionByMetadataSessionId(namespace, 'codexSessionId', externalSessionId) + } + + findSessionByExternalClaudeSessionId(namespace: string, externalSessionId: string): { sessionId: string } | null { + return this.findSessionByMetadataSessionId(namespace, 'claudeSessionId', externalSessionId) + } + + private findSessionByMetadataSessionId( + namespace: string, + key: 'codexSessionId' | 'claudeSessionId', + externalSessionId: string + ): { sessionId: string } | null { for (const stored of this.store.sessions.getSessionsByNamespace(namespace)) { const metadata = MetadataSchema.safeParse(stored.metadata) if (!metadata.success) { continue } - if (metadata.data.codexSessionId === externalSessionId) { + if (metadata.data[key] === externalSessionId) { return { sessionId: stored.id } } } diff --git a/hub/src/sync/syncEngine.test.ts b/hub/src/sync/syncEngine.test.ts index f9adc3621..da72a3aca 100644 --- a/hub/src/sync/syncEngine.test.ts +++ b/hub/src/sync/syncEngine.test.ts @@ -563,3 +563,222 @@ describe('SyncEngine codex import orchestration', () => { } }) }) + +describe('SyncEngine claude import orchestration', () => { + it('returns the existing hapi session when the external claude session is already imported', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'import-existing', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'claude', + claudeSessionId: 'claude-thread-1' + }, + null, + 'default' + ) + + const result = await engine.importExternalClaudeSession('claude-thread-1', 'default') + + expect(result).toEqual({ + type: 'success', + sessionId: session.id + }) + } finally { + engine.stop() + } + }) + + it('imports a claude session by resuming the external session id on an online machine', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + let capturedSpawnArgs: unknown[] | null = null + ;(engine as any).rpcGateway.listImportableSessions = async (_machineId: string, request: { agent: string }) => { + expect(request).toEqual({ agent: 'claude' }) + return { + sessions: [ + { + agent: 'claude', + externalSessionId: 'claude-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.claude/projects/project/claude-thread-1.jsonl', + previewTitle: 'Imported Claude title', + previewPrompt: 'Imported Claude prompt' + } + ] + } + } + ;(engine as any).rpcGateway.spawnSession = async (...args: unknown[]) => { + capturedSpawnArgs = args + const imported = engine.getOrCreateSession( + 'spawned-claude-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'claude', + claudeSessionId: 'claude-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: imported.id } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.importExternalClaudeSession('claude-thread-1', 'default') + + expect(result.type).toBe('success') + if (result.type !== 'success') { + throw new Error(result.message) + } + if (capturedSpawnArgs === null) { + throw new Error('spawn args were not captured') + } + const importSpawnArgs = capturedSpawnArgs as unknown[] + if (importSpawnArgs.length !== 10) { + throw new Error(`unexpected spawn args length: ${importSpawnArgs.length}`) + } + if (importSpawnArgs[0] !== 'machine-1') { + throw new Error(`unexpected spawn target: ${String(importSpawnArgs[0])}`) + } + if (importSpawnArgs[1] !== '/tmp/project') { + throw new Error(`unexpected spawn directory: ${String(importSpawnArgs[1])}`) + } + if (importSpawnArgs[2] !== 'claude') { + throw new Error(`unexpected spawn agent: ${String(importSpawnArgs[2])}`) + } + if (importSpawnArgs[8] !== 'claude-thread-1') { + throw new Error(`unexpected resume session id: ${String(importSpawnArgs[8])}`) + } + expect(engine.getSession(result.sessionId)?.metadata?.name).toBe('Imported Claude title') + } finally { + engine.stop() + } + }) + + it('refreshes an imported claude session in place', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + const imported = engine.getOrCreateSession( + 'imported-claude-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'claude', + claudeSessionId: 'claude-thread-1' + }, + null, + 'default' + ) + + let capturedSpawnArgs: unknown[] | null = null + ;(engine as any).rpcGateway.listImportableSessions = async (_machineId: string, request: { agent: string }) => { + expect(request).toEqual({ agent: 'claude' }) + return { + sessions: [ + { + agent: 'claude', + externalSessionId: 'claude-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.claude/projects/project/claude-thread-1.jsonl', + previewTitle: null, + previewPrompt: 'Prompt fallback title' + } + ] + } + } + ;(engine as any).rpcGateway.spawnSession = async (...args: unknown[]) => { + capturedSpawnArgs = args + const spawned = engine.getOrCreateSession( + 'spawned-claude-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'claude', + claudeSessionId: 'claude-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: spawned.id } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.refreshExternalClaudeSession('claude-thread-1', 'default') + + expect(result).toEqual({ + type: 'success', + sessionId: imported.id + }) + if (capturedSpawnArgs === null) { + throw new Error('spawn args were not captured') + } + const refreshSpawnArgs = capturedSpawnArgs as unknown[] + if (refreshSpawnArgs.length !== 10) { + throw new Error(`unexpected spawn args length: ${refreshSpawnArgs.length}`) + } + if (refreshSpawnArgs[0] !== 'machine-1') { + throw new Error(`unexpected spawn target: ${String(refreshSpawnArgs[0])}`) + } + if (refreshSpawnArgs[1] !== '/tmp/project') { + throw new Error(`unexpected spawn directory: ${String(refreshSpawnArgs[1])}`) + } + if (refreshSpawnArgs[2] !== 'claude') { + throw new Error(`unexpected spawn agent: ${String(refreshSpawnArgs[2])}`) + } + if (refreshSpawnArgs[8] !== 'claude-thread-1') { + throw new Error(`unexpected resume session id: ${String(refreshSpawnArgs[8])}`) + } + expect(engine.findSessionByExternalClaudeSessionId('default', 'claude-thread-1')).toEqual({ + sessionId: imported.id + }) + expect(engine.getSession(imported.id)?.metadata?.name).toBe('Prompt fallback title') + } finally { + engine.stop() + } + }) +}) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index f8ad4764f..0d23241e8 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -43,18 +43,25 @@ export type ResumeSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } -export type ImportExternalCodexSessionResult = +type ImportExternalAgentSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'no_machine_online' | 'import_failed' } -export type RefreshExternalCodexSessionResult = +type RefreshExternalAgentSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'no_machine_online' | 'resume_unavailable' | 'refresh_failed' } -export type ListImportableCodexSessionsResult = +type ListImportableAgentSessionsResult = | { type: 'success'; machineId: string; sessions: RpcListImportableSessionsResponse['sessions'] } | { type: 'error'; message: string; code: 'no_machine_online' | 'importable_sessions_failed' } +export type ImportExternalCodexSessionResult = ImportExternalAgentSessionResult +export type ImportExternalClaudeSessionResult = ImportExternalAgentSessionResult +export type RefreshExternalCodexSessionResult = RefreshExternalAgentSessionResult +export type RefreshExternalClaudeSessionResult = RefreshExternalAgentSessionResult +export type ListImportableCodexSessionsResult = ListImportableAgentSessionsResult +export type ListImportableClaudeSessionsResult = ListImportableAgentSessionsResult + export class SyncEngine { private readonly eventPublisher: EventPublisher private readonly store: Store @@ -164,153 +171,24 @@ export class SyncEngine { return this.sessionCache.findSessionByExternalCodexSessionId(namespace, externalSessionId) } - async importExternalCodexSession(externalSessionId: string, namespace: string): Promise { - const existing = this.findSessionByExternalCodexSessionId(namespace, externalSessionId) - if (existing) { - return { type: 'success', sessionId: existing.sessionId } - } - - const sourceResult = await this.findImportableCodexSessionSource(namespace, externalSessionId) - if (sourceResult.type === 'error') { - return { - type: 'error', - message: sourceResult.message, - code: sourceResult.code === 'no_machine_online' || sourceResult.code === 'session_not_found' - ? sourceResult.code - : 'import_failed' - } - } - - const cwd = sourceResult.session.cwd - if (typeof cwd !== 'string' || cwd.length === 0) { - return { - type: 'error', - message: 'Importable Codex session is missing cwd', - code: 'import_failed' - } - } - - const spawnResult = await this.rpcGateway.spawnSession( - sourceResult.machineId, - cwd, - 'codex', - undefined, - undefined, - undefined, - undefined, - undefined, - externalSessionId, - undefined - ) - - if (spawnResult.type !== 'success') { - return { - type: 'error', - message: spawnResult.message, - code: 'import_failed' - } - } - - if (!(await this.waitForSessionActive(spawnResult.sessionId))) { - this.discardSpawnedSession(spawnResult.sessionId, namespace) - return { - type: 'error', - message: 'Session failed to become active', - code: 'import_failed' - } - } + findSessionByExternalClaudeSessionId(namespace: string, externalSessionId: string): { sessionId: string } | null { + return this.sessionCache.findSessionByExternalClaudeSessionId(namespace, externalSessionId) + } - const importedTitle = this.getBestImportableCodexSessionTitle(sourceResult.session) - await this.applyImportableCodexSessionTitle(spawnResult.sessionId, importedTitle) + async importExternalCodexSession(externalSessionId: string, namespace: string): Promise { + return await this.importExternalSession(externalSessionId, namespace, 'codex') + } - return { type: 'success', sessionId: spawnResult.sessionId } + async importExternalClaudeSession(externalSessionId: string, namespace: string): Promise { + return await this.importExternalSession(externalSessionId, namespace, 'claude') } async refreshExternalCodexSession(externalSessionId: string, namespace: string): Promise { - const existing = this.findSessionByExternalCodexSessionId(namespace, externalSessionId) - if (!existing) { - return { - type: 'error', - message: 'Imported session not found', - code: 'session_not_found' - } - } - - const access = this.sessionCache.resolveSessionAccess(existing.sessionId, namespace) - if (!access.ok) { - return { - type: 'error', - message: access.reason === 'access-denied' ? 'Session access denied' : 'Imported session not found', - code: access.reason === 'access-denied' ? 'session_not_found' : 'session_not_found' - } - } - - const session = access.session - const metadata = session.metadata - if (!metadata || typeof metadata.path !== 'string') { - return { - type: 'error', - message: 'Session metadata missing path', - code: 'resume_unavailable' - } - } - - const targetMachine = this.selectOnlineMachine(namespace, metadata) - if (!targetMachine) { - return { - type: 'error', - message: 'No machine online', - code: 'no_machine_online' - } - } - - const spawnResult = await this.rpcGateway.spawnSession( - targetMachine.id, - metadata.path, - 'codex', - session.model ?? undefined, - undefined, - undefined, - undefined, - undefined, - externalSessionId, - session.effort ?? undefined - ) - - if (spawnResult.type !== 'success') { - return { - type: 'error', - message: spawnResult.message, - code: 'refresh_failed' - } - } - - if (!(await this.waitForSessionActive(spawnResult.sessionId))) { - this.discardSpawnedSession(spawnResult.sessionId, namespace) - return { - type: 'error', - message: 'Session failed to become active', - code: 'refresh_failed' - } - } - - const importedTitle = await this.resolveImportableCodexSessionTitle(namespace, externalSessionId) - await this.applyImportableCodexSessionTitle(spawnResult.sessionId, importedTitle) - - if (spawnResult.sessionId !== access.sessionId) { - try { - await this.sessionCache.mergeSessions(spawnResult.sessionId, access.sessionId, namespace) - } catch (error) { - this.discardSpawnedSession(spawnResult.sessionId, namespace) - return { - type: 'error', - message: error instanceof Error ? error.message : 'Failed to refresh imported session', - code: 'refresh_failed' - } - } - } + return await this.refreshExternalSession(externalSessionId, namespace, 'codex') + } - return { type: 'success', sessionId: access.sessionId } + async refreshExternalClaudeSession(externalSessionId: string, namespace: string): Promise { + return await this.refreshExternalSession(externalSessionId, namespace, 'claude') } getMessagesPage(sessionId: string, options: { limit: number; beforeSeq: number | null }): { @@ -586,6 +464,200 @@ export class SyncEngine { } async listImportableCodexSessions(namespace: string): Promise { + return await this.listImportableSessionsByAgent(namespace, 'codex') + } + + async listImportableClaudeSessions(namespace: string): Promise { + return await this.listImportableSessionsByAgent(namespace, 'claude') + } + + async waitForSessionActive(sessionId: string, timeoutMs: number = 15_000): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const session = this.getSession(sessionId) + if (session?.active) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + return false + } + + private getImportableAgentLabel(agent: 'codex' | 'claude'): 'Codex' | 'Claude' { + return agent === 'codex' ? 'Codex' : 'Claude' + } + + private findSessionByExternalSessionId( + namespace: string, + externalSessionId: string, + agent: 'codex' | 'claude' + ): { sessionId: string } | null { + return agent === 'codex' + ? this.findSessionByExternalCodexSessionId(namespace, externalSessionId) + : this.findSessionByExternalClaudeSessionId(namespace, externalSessionId) + } + + private async importExternalSession( + externalSessionId: string, + namespace: string, + agent: 'codex' | 'claude' + ): Promise { + const existing = this.findSessionByExternalSessionId(namespace, externalSessionId, agent) + if (existing) { + return { type: 'success', sessionId: existing.sessionId } + } + + const sourceResult = await this.findImportableSessionSource(namespace, externalSessionId, agent) + if (sourceResult.type === 'error') { + return { + type: 'error', + message: sourceResult.message, + code: sourceResult.code === 'no_machine_online' || sourceResult.code === 'session_not_found' + ? sourceResult.code + : 'import_failed' + } + } + + const cwd = sourceResult.session.cwd + if (typeof cwd !== 'string' || cwd.length === 0) { + return { + type: 'error', + message: `Importable ${this.getImportableAgentLabel(agent)} session is missing cwd`, + code: 'import_failed' + } + } + + const spawnResult = await this.rpcGateway.spawnSession( + sourceResult.machineId, + cwd, + agent, + undefined, + undefined, + undefined, + undefined, + undefined, + externalSessionId, + undefined + ) + + if (spawnResult.type !== 'success') { + return { + type: 'error', + message: spawnResult.message, + code: 'import_failed' + } + } + + if (!(await this.waitForSessionActive(spawnResult.sessionId))) { + this.discardSpawnedSession(spawnResult.sessionId, namespace) + return { + type: 'error', + message: 'Session failed to become active', + code: 'import_failed' + } + } + + const importedTitle = this.getBestImportableSessionTitle(sourceResult.session) + await this.applyImportableSessionTitle(spawnResult.sessionId, importedTitle) + + return { type: 'success', sessionId: spawnResult.sessionId } + } + + private async refreshExternalSession( + externalSessionId: string, + namespace: string, + agent: 'codex' | 'claude' + ): Promise { + const existing = this.findSessionByExternalSessionId(namespace, externalSessionId, agent) + if (!existing) { + return { + type: 'error', + message: 'Imported session not found', + code: 'session_not_found' + } + } + + const access = this.sessionCache.resolveSessionAccess(existing.sessionId, namespace) + if (!access.ok) { + return { + type: 'error', + message: access.reason === 'access-denied' ? 'Session access denied' : 'Imported session not found', + code: access.reason === 'access-denied' ? 'session_not_found' : 'session_not_found' + } + } + + const session = access.session + const metadata = session.metadata + if (!metadata || typeof metadata.path !== 'string') { + return { + type: 'error', + message: 'Session metadata missing path', + code: 'resume_unavailable' + } + } + + const targetMachine = this.selectOnlineMachine(namespace, metadata) + if (!targetMachine) { + return { + type: 'error', + message: 'No machine online', + code: 'no_machine_online' + } + } + + const spawnResult = await this.rpcGateway.spawnSession( + targetMachine.id, + metadata.path, + agent, + session.model ?? undefined, + undefined, + undefined, + undefined, + undefined, + externalSessionId, + session.effort ?? undefined + ) + + if (spawnResult.type !== 'success') { + return { + type: 'error', + message: spawnResult.message, + code: 'refresh_failed' + } + } + + if (!(await this.waitForSessionActive(spawnResult.sessionId))) { + this.discardSpawnedSession(spawnResult.sessionId, namespace) + return { + type: 'error', + message: 'Session failed to become active', + code: 'refresh_failed' + } + } + + const importedTitle = await this.resolveImportableSessionTitle(namespace, externalSessionId, agent) + await this.applyImportableSessionTitle(spawnResult.sessionId, importedTitle) + + if (spawnResult.sessionId !== access.sessionId) { + try { + await this.sessionCache.mergeSessions(spawnResult.sessionId, access.sessionId, namespace) + } catch (error) { + this.discardSpawnedSession(spawnResult.sessionId, namespace) + return { + type: 'error', + message: error instanceof Error ? error.message : 'Failed to refresh imported session', + code: 'refresh_failed' + } + } + } + + return { type: 'success', sessionId: access.sessionId } + } + + private async listImportableSessionsByAgent( + namespace: string, + agent: 'codex' | 'claude' + ): Promise { const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) const targetMachine = onlineMachines[0] if (!targetMachine) { @@ -597,7 +669,7 @@ export class SyncEngine { } try { - const response = await this.rpcGateway.listImportableSessions(targetMachine.id, { agent: 'codex' }) + const response = await this.rpcGateway.listImportableSessions(targetMachine.id, { agent }) return { type: 'success', machineId: targetMachine.id, @@ -612,18 +684,6 @@ export class SyncEngine { } } - async waitForSessionActive(sessionId: string, timeoutMs: number = 15_000): Promise { - const start = Date.now() - while (Date.now() - start < timeoutMs) { - const session = this.getSession(sessionId) - if (session?.active) { - return true - } - await new Promise((resolve) => setTimeout(resolve, 250)) - } - return false - } - private selectOnlineMachine(namespace: string, metadata?: Session['metadata']): Machine | null { const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) if (onlineMachines.length === 0) { @@ -647,9 +707,10 @@ export class SyncEngine { return metadata ? null : onlineMachines[0] ?? null } - private async findImportableCodexSessionSource( + private async findImportableSessionSource( namespace: string, - externalSessionId: string + externalSessionId: string, + agent: 'codex' | 'claude' ): Promise< | { type: 'success'; machineId: string; session: RpcListImportableSessionsResponse['sessions'][number] } | { type: 'error'; message: string; code: 'session_not_found' | 'no_machine_online' | 'importable_sessions_failed' } @@ -666,13 +727,13 @@ export class SyncEngine { let lastError: string | null = null for (const machine of onlineMachines) { try { - const response = await this.rpcGateway.listImportableSessions(machine.id, { agent: 'codex' }) + const response = await this.rpcGateway.listImportableSessions(machine.id, { agent }) const session = response.sessions.find((item) => item.externalSessionId === externalSessionId) if (session) { if (typeof session.cwd !== 'string' || session.cwd.length === 0) { return { type: 'error', - message: 'Importable Codex session is missing cwd', + message: `Importable ${this.getImportableAgentLabel(agent)} session is missing cwd`, code: 'importable_sessions_failed' } } @@ -689,23 +750,24 @@ export class SyncEngine { return { type: 'error', - message: lastError ?? 'Importable Codex session not found', + message: lastError ?? `Importable ${this.getImportableAgentLabel(agent)} session not found`, code: lastError ? 'importable_sessions_failed' : 'session_not_found' } } - private async resolveImportableCodexSessionTitle( + private async resolveImportableSessionTitle( namespace: string, - externalSessionId: string + externalSessionId: string, + agent: 'codex' | 'claude' ): Promise { - const sourceResult = await this.findImportableCodexSessionSource(namespace, externalSessionId) + const sourceResult = await this.findImportableSessionSource(namespace, externalSessionId, agent) if (sourceResult.type !== 'success') { return null } - return this.getBestImportableCodexSessionTitle(sourceResult.session) + return this.getBestImportableSessionTitle(sourceResult.session) } - private getBestImportableCodexSessionTitle( + private getBestImportableSessionTitle( session: RpcListImportableSessionsResponse['sessions'][number] ): string | null { const previewTitle = typeof session.previewTitle === 'string' ? session.previewTitle.trim() : '' @@ -721,7 +783,7 @@ export class SyncEngine { return null } - private async applyImportableCodexSessionTitle(sessionId: string, title: string | null): Promise { + private async applyImportableSessionTitle(sessionId: string, title: string | null): Promise { if (!title) { return } diff --git a/hub/src/web/routes/importableSessions.test.ts b/hub/src/web/routes/importableSessions.test.ts index 1615a1e98..0bafe2e03 100644 --- a/hub/src/web/routes/importableSessions.test.ts +++ b/hub/src/web/routes/importableSessions.test.ts @@ -102,6 +102,48 @@ describe('importable sessions routes', () => { }) }) + it('lists claude importable sessions with imported status', async () => { + const engine = { + listImportableClaudeSessions: async () => ({ + type: 'success' as const, + machineId: 'machine-1', + sessions: [ + { + agent: 'claude' as const, + externalSessionId: 'external-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.claude/projects/project/external-1.jsonl', + previewTitle: 'Imported Claude title', + previewPrompt: 'Imported Claude prompt' + } + ] + }), + findSessionByExternalClaudeSessionId: () => ({ sessionId: 'hapi-claude-123' }) + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions?agent=claude') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + sessions: [ + { + agent: 'claude', + externalSessionId: 'external-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.claude/projects/project/external-1.jsonl', + previewTitle: 'Imported Claude title', + previewPrompt: 'Imported Claude prompt', + alreadyImported: true, + importedHapiSessionId: 'hapi-claude-123' + } + ] + }) + }) + it('imports an external codex session', async () => { const captured: Array<{ externalSessionId: string; namespace: string }> = [] const engine = { @@ -163,4 +205,66 @@ describe('importable sessions routes', () => { } ]) }) + + it('imports an external claude session', async () => { + const captured: Array<{ externalSessionId: string; namespace: string }> = [] + const engine = { + importExternalClaudeSession: async (externalSessionId: string, namespace: string) => { + captured.push({ externalSessionId, namespace }) + return { + type: 'success' as const, + sessionId: 'hapi-claude-123' + } + } + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions/claude/external-1/import', { + method: 'POST' + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + type: 'success', + sessionId: 'hapi-claude-123' + }) + expect(captured).toEqual([ + { + externalSessionId: 'external-1', + namespace: 'default' + } + ]) + }) + + it('refreshes an external claude session in place', async () => { + const captured: Array<{ externalSessionId: string; namespace: string }> = [] + const engine = { + refreshExternalClaudeSession: async (externalSessionId: string, namespace: string) => { + captured.push({ externalSessionId, namespace }) + return { + type: 'success' as const, + sessionId: 'hapi-claude-123' + } + } + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions/claude/external-1/refresh', { + method: 'POST' + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + type: 'success', + sessionId: 'hapi-claude-123' + }) + expect(captured).toEqual([ + { + externalSessionId: 'external-1', + namespace: 'default' + } + ]) + }) }) diff --git a/hub/src/web/routes/importableSessions.ts b/hub/src/web/routes/importableSessions.ts index 0fd295da8..6c5e45b96 100644 --- a/hub/src/web/routes/importableSessions.ts +++ b/hub/src/web/routes/importableSessions.ts @@ -5,7 +5,7 @@ import type { WebAppEnv } from '../middleware/auth' import { requireSyncEngine } from './guards' const querySchema = z.object({ - agent: z.literal('codex') + agent: z.union([z.literal('codex'), z.literal('claude')]) }) export function createImportableSessionsRoutes(getSyncEngine: () => SyncEngine | null): Hono { @@ -32,14 +32,18 @@ export function createImportableSessionsRoutes(getSyncEngine: () => SyncEngine | } const namespace = c.get('namespace') - const result = await engine.listImportableCodexSessions(namespace) + const result = parsed.data.agent === 'codex' + ? await engine.listImportableCodexSessions(namespace) + : await engine.listImportableClaudeSessions(namespace) if (result.type === 'error') { const status = result.code === 'no_machine_online' ? 503 : 500 return c.json({ error: result.message, code: result.code }, status) } const sessions = result.sessions.map((session) => { - const existing = engine.findSessionByExternalCodexSessionId(namespace, session.externalSessionId) + const existing = parsed.data.agent === 'codex' + ? engine.findSessionByExternalCodexSessionId(namespace, session.externalSessionId) + : engine.findSessionByExternalClaudeSessionId(namespace, session.externalSessionId) return { ...session, alreadyImported: Boolean(existing), @@ -82,5 +86,37 @@ export function createImportableSessionsRoutes(getSyncEngine: () => SyncEngine | return c.json({ type: 'success', sessionId: result.sessionId }) }) + app.post('/importable-sessions/claude/:externalSessionId/import', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const namespace = c.get('namespace') + const externalSessionId = c.req.param('externalSessionId') + const result = await engine.importExternalClaudeSession(externalSessionId, namespace) + if (result.type === 'error') { + return c.json({ error: result.message, code: result.code }, mapActionErrorStatus(result.code) as never) + } + + return c.json({ type: 'success', sessionId: result.sessionId }) + }) + + app.post('/importable-sessions/claude/:externalSessionId/refresh', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const namespace = c.get('namespace') + const externalSessionId = c.req.param('externalSessionId') + const result = await engine.refreshExternalClaudeSession(externalSessionId, namespace) + if (result.type === 'error') { + return c.json({ error: result.message, code: result.code }, mapActionErrorStatus(result.code) as never) + } + + return c.json({ type: 'success', sessionId: result.sessionId }) + }) + return app } From b0c9132ab36962cd93419930ef7fded7e4c01986 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 01:17:36 +0800 Subject: [PATCH 53/82] feat: enable claude import existing sessions --- web/src/api/client.ts | 9 +- .../NewSession/ImportExistingModal.test.tsx | 51 +++++++- .../NewSession/ImportExistingModal.tsx | 114 +++++++++--------- .../mutations/useImportableSessionActions.ts | 6 +- .../hooks/queries/useImportableSessions.ts | 6 +- web/src/lib/locales/en.ts | 7 +- web/src/lib/locales/zh-CN.ts | 7 +- web/src/types/api.ts | 4 +- 8 files changed, 126 insertions(+), 78 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 8a1198d63..de252c753 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -22,7 +22,8 @@ import type { UploadFileResponse, VisibilityPayload, SessionResponse, - SessionsResponse + SessionsResponse, + ImportableSessionAgent, } from '@/types/api' type ApiClientOptions = { @@ -162,19 +163,19 @@ export class ApiClient { return await this.request('/api/sessions') } - async listImportableSessions(agent: 'codex'): Promise { + async listImportableSessions(agent: ImportableSessionAgent): Promise { const params = new URLSearchParams({ agent }) return await this.request(`/api/importable-sessions?${params.toString()}`) } - async importExternalSession(agent: 'codex', externalSessionId: string): Promise { + async importExternalSession(agent: ImportableSessionAgent, externalSessionId: string): Promise { return await this.request( `/api/importable-sessions/${agent}/${encodeURIComponent(externalSessionId)}/import`, { method: 'POST' } ) } - async refreshExternalSession(agent: 'codex', externalSessionId: string): Promise { + async refreshExternalSession(agent: ImportableSessionAgent, externalSessionId: string): Promise { return await this.request( `/api/importable-sessions/${agent}/${encodeURIComponent(externalSessionId)}/refresh`, { method: 'POST' } diff --git a/web/src/components/NewSession/ImportExistingModal.test.tsx b/web/src/components/NewSession/ImportExistingModal.test.tsx index a2c2a5199..552f1a968 100644 --- a/web/src/components/NewSession/ImportExistingModal.test.tsx +++ b/web/src/components/NewSession/ImportExistingModal.test.tsx @@ -45,7 +45,7 @@ describe('ImportExistingModal', () => { }) }) - it('shows imported-session actions and disables the Claude tab', () => { + it('shows imported-session actions for Codex by default', () => { useImportableSessionsMock.mockReturnValue({ sessions: [{ agent: 'codex', @@ -67,7 +67,8 @@ describe('ImportExistingModal', () => { expect(screen.getByRole('button', { name: 'Open in HAPI' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Refresh from source' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Claude' })).toBeDisabled() + expect(useImportableSessionsMock).toHaveBeenCalledWith(expect.anything(), 'codex', true) + expect(useImportableSessionActionsMock).toHaveBeenCalledWith(expect.anything(), 'codex') }) it('shows import action for not-yet-imported sessions', () => { @@ -143,4 +144,50 @@ describe('ImportExistingModal', () => { expect(onOpenSession).toHaveBeenCalledWith('hapi-imported-1') }) }) + + it('switches to the Claude tab and loads Claude sessions with the same action model', () => { + const refreshSession = vi.fn() + const onOpenSession = vi.fn() + + useImportableSessionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ + sessions: agent === 'claude' + ? [{ + agent: 'claude', + externalSessionId: 'claude-external-1', + cwd: '/tmp/claude-project', + timestamp: 321, + transcriptPath: '/tmp/claude-project/session.jsonl', + previewTitle: 'Claude imported title', + previewPrompt: 'Claude prompt preview', + alreadyImported: true, + importedHapiSessionId: 'hapi-claude-1', + }] + : [], + isLoading: false, + error: null, + refetch: vi.fn(), + })) + useImportableSessionActionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ + importSession: vi.fn(), + refreshSession: agent === 'claude' ? refreshSession : vi.fn(), + importingSessionId: null, + refreshingSessionId: null, + error: null, + })) + + renderModal({ onOpenSession }) + + fireEvent.click(screen.getByRole('button', { name: 'Claude' })) + + expect(useImportableSessionsMock).toHaveBeenLastCalledWith(expect.anything(), 'claude', true) + expect(useImportableSessionActionsMock).toHaveBeenLastCalledWith(expect.anything(), 'claude') + expect(screen.getByRole('button', { name: 'Open in HAPI' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Refresh from source' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Open in HAPI' })) + expect(onOpenSession).toHaveBeenCalledWith('hapi-claude-1') + + fireEvent.click(screen.getByRole('button', { name: 'Refresh from source' })) + expect(refreshSession).toHaveBeenCalledWith('claude-external-1') + }) }) diff --git a/web/src/components/NewSession/ImportExistingModal.tsx b/web/src/components/NewSession/ImportExistingModal.tsx index 7ca0ae787..bc26bd1f9 100644 --- a/web/src/components/NewSession/ImportExistingModal.tsx +++ b/web/src/components/NewSession/ImportExistingModal.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useImportableSessionActions } from '@/hooks/mutations/useImportableSessionActions' import { useImportableSessions } from '@/hooks/queries/useImportableSessions' +import type { ImportableSessionAgent } from '@/types/api' import { useTranslation } from '@/lib/use-translation' import { ImportableSessionList } from './ImportableSessionList' @@ -14,16 +15,17 @@ export function ImportExistingModal(props: { onOpenSession: (sessionId: string) => void }) { const { t } = useTranslation() - const [activeTab, setActiveTab] = useState<'codex' | 'claude'>('codex') + const [activeTab, setActiveTab] = useState('codex') const [search, setSearch] = useState('') - const { sessions, isLoading, error, refetch } = useImportableSessions(props.api, 'codex', props.open && activeTab === 'codex') + const activeAgent = activeTab + const { sessions, isLoading, error, refetch } = useImportableSessions(props.api, activeAgent, props.open) const { importSession, refreshSession, importingSessionId, refreshingSessionId, error: actionError, - } = useImportableSessionActions(props.api, 'codex') + } = useImportableSessionActions(props.api, activeAgent) const [selectedExternalSessionId, setSelectedExternalSessionId] = useState(null) const filteredSessions = useMemo(() => { @@ -87,8 +89,12 @@ export function ImportExistingModal(props: { @@ -96,62 +102,56 @@ export function ImportExistingModal(props: {
- {activeTab === 'claude' ? ( -
- {t('newSession.import.claudeSoon')} +
+
+ setSearch(event.target.value)} + placeholder={t('newSession.import.searchPlaceholder')} + className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]" + /> +
- ) : ( -
-
- setSearch(event.target.value)} - placeholder={t('newSession.import.searchPlaceholder')} - className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]" - /> -
+ ) : filteredSessions.length === 0 ? ( +
+ {sessions.length === 0 + ? t('newSession.import.empty') + : t('newSession.import.emptySearch')} +
+ ) : ( + void handleImport(externalSessionId)} + onRefresh={(externalSessionId) => void refreshSession(externalSessionId)} + onOpen={props.onOpenSession} + /> + )} - {isLoading ? ( -
- {t('newSession.import.loading')} -
- ) : error ? ( -
-
{error}
- -
- ) : filteredSessions.length === 0 ? ( -
- {sessions.length === 0 - ? t('newSession.import.empty') - : t('newSession.import.emptySearch')} -
- ) : ( - void handleImport(externalSessionId)} - onRefresh={(externalSessionId) => void refreshSession(externalSessionId)} - onOpen={props.onOpenSession} - /> - )} - - {actionError ? ( -
- {actionError} -
- ) : null} -
- )} + {actionError ? ( +
+ {actionError} +
+ ) : null} +
diff --git a/web/src/hooks/mutations/useImportableSessionActions.ts b/web/src/hooks/mutations/useImportableSessionActions.ts index a1f6f7518..35f6bcadb 100644 --- a/web/src/hooks/mutations/useImportableSessionActions.ts +++ b/web/src/hooks/mutations/useImportableSessionActions.ts @@ -1,9 +1,9 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import type { ApiClient } from '@/api/client' -import type { ExternalSessionActionResponse } from '@/types/api' +import type { ExternalSessionActionResponse, ImportableSessionAgent } from '@/types/api' import { queryKeys } from '@/lib/query-keys' -export function useImportableSessionActions(api: ApiClient | null, agent: 'codex'): { +export function useImportableSessionActions(api: ApiClient | null, agent: ImportableSessionAgent): { importSession: (externalSessionId: string) => Promise refreshSession: (externalSessionId: string) => Promise importingSessionId: string | null @@ -15,7 +15,7 @@ export function useImportableSessionActions(api: ApiClient | null, agent: 'codex const invalidate = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), - queryClient.invalidateQueries({ queryKey: queryKeys.importableSessions(agent) }), + queryClient.invalidateQueries({ queryKey: ['importable-sessions', agent] as const }), ]) } diff --git a/web/src/hooks/queries/useImportableSessions.ts b/web/src/hooks/queries/useImportableSessions.ts index 523e75831..ad616e003 100644 --- a/web/src/hooks/queries/useImportableSessions.ts +++ b/web/src/hooks/queries/useImportableSessions.ts @@ -1,11 +1,11 @@ import { useQuery } from '@tanstack/react-query' import type { ApiClient } from '@/api/client' -import type { ImportableSessionView } from '@/types/api' +import type { ImportableSessionAgent, ImportableSessionView } from '@/types/api' import { queryKeys } from '@/lib/query-keys' export function useImportableSessions( api: ApiClient | null, - agent: 'codex', + agent: ImportableSessionAgent, enabled: boolean ): { sessions: ImportableSessionView[] @@ -14,7 +14,7 @@ export function useImportableSessions( refetch: () => Promise } { const query = useQuery({ - queryKey: queryKeys.importableSessions(agent), + queryKey: ['importable-sessions', agent] as const, queryFn: async () => { if (!api) { throw new Error('API unavailable') diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 9ec79d2b6..d4d9eb519 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -113,14 +113,13 @@ export default { 'newSession.creating': 'Creating…', 'newSession.import.entry': 'Import Existing', 'newSession.import.title': 'Import Existing', - 'newSession.import.description': 'Browse local Codex sessions and import or refresh them without leaving HAPI.', + 'newSession.import.description': 'Browse local Codex or Claude sessions and import or refresh them without leaving HAPI.', 'newSession.import.tabs.claude': 'Claude', - 'newSession.import.claudeSoon': 'Claude import is coming soon.', 'newSession.import.searchPlaceholder': 'Search imported titles, prompts, or paths', 'newSession.import.refreshList': 'Refresh', - 'newSession.import.loading': 'Loading importable Codex sessions...', + 'newSession.import.loading': 'Loading importable sessions...', 'newSession.import.retry': 'Retry', - 'newSession.import.empty': 'No importable Codex sessions were found on the connected machine.', + 'newSession.import.empty': 'No importable sessions were found on the connected machine.', 'newSession.import.emptySearch': 'No sessions match your search.', 'newSession.import.badgeImported': 'Imported', 'newSession.import.badgeReady': 'Ready', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 233ffb865..bfab71a78 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -115,14 +115,13 @@ export default { 'newSession.creating': '创建中…', 'newSession.import.entry': '导入现有会话', 'newSession.import.title': '导入现有会话', - 'newSession.import.description': '浏览本地 Codex 会话,并在不离开 HAPI 的情况下导入或刷新它们。', + 'newSession.import.description': '浏览本地 Codex 或 Claude 会话,并在不离开 HAPI 的情况下导入或刷新它们。', 'newSession.import.tabs.claude': 'Claude', - 'newSession.import.claudeSoon': 'Claude 导入即将支持。', 'newSession.import.searchPlaceholder': '搜索标题、提示词或路径', 'newSession.import.refreshList': '刷新', - 'newSession.import.loading': '正在加载可导入的 Codex 会话...', + 'newSession.import.loading': '正在加载可导入的会话...', 'newSession.import.retry': '重试', - 'newSession.import.empty': '当前连接机器上没有可导入的 Codex 会话。', + 'newSession.import.empty': '当前连接机器上没有可导入的会话。', 'newSession.import.emptySearch': '没有匹配搜索条件的会话。', 'newSession.import.badgeImported': '已导入', 'newSession.import.badgeReady': '可导入', diff --git a/web/src/types/api.ts b/web/src/types/api.ts index d5765cbda..5f289d081 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -95,8 +95,10 @@ export type MessagesResponse = { export type MachinesResponse = { machines: Machine[] } export type MachinePathsExistsResponse = { exists: Record } +export type ImportableSessionAgent = 'codex' | 'claude' + export type ImportableSessionView = { - agent: 'codex' + agent: ImportableSessionAgent externalSessionId: string cwd: string | null timestamp: number | null From 5bb12d791da4d500c227091304b0e2c53c5f5b97 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 01:34:59 +0800 Subject: [PATCH 54/82] fix: isolate import existing state per agent --- .../NewSession/ImportExistingModal.test.tsx | 83 ++++++++++ .../NewSession/ImportExistingModal.tsx | 147 +++++++++++------- .../mutations/useImportableSessionActions.ts | 8 +- .../hooks/queries/useImportableSessions.ts | 2 +- web/src/lib/query-keys.ts | 4 +- 5 files changed, 181 insertions(+), 63 deletions(-) diff --git a/web/src/components/NewSession/ImportExistingModal.test.tsx b/web/src/components/NewSession/ImportExistingModal.test.tsx index 552f1a968..117a03662 100644 --- a/web/src/components/NewSession/ImportExistingModal.test.tsx +++ b/web/src/components/NewSession/ImportExistingModal.test.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import { I18nProvider } from '@/lib/i18n-context' @@ -190,4 +191,86 @@ describe('ImportExistingModal', () => { fireEvent.click(screen.getByRole('button', { name: 'Refresh from source' })) expect(refreshSession).toHaveBeenCalledWith('claude-external-1') }) + + it('does not leak Codex action state into the Claude tab', () => { + useImportableSessionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ + sessions: [{ + agent, + externalSessionId: `${agent}-external-1`, + cwd: `/tmp/${agent}-project`, + timestamp: 111, + transcriptPath: `/tmp/${agent}-project/session.jsonl`, + previewTitle: `${agent} title`, + previewPrompt: `${agent} prompt`, + alreadyImported: false, + importedHapiSessionId: null, + }], + isLoading: false, + error: null, + refetch: vi.fn(), + })) + useImportableSessionActionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => { + const [error] = useState(agent === 'codex' ? 'Codex failed' : null) + return { + importSession: vi.fn(), + refreshSession: vi.fn(), + importingSessionId: null, + refreshingSessionId: null, + error, + } + }) + + renderModal() + + expect(screen.getByText('Codex failed')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Claude' })) + + expect(screen.queryByText('Codex failed')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Import into HAPI' })).toBeInTheDocument() + }) + + it('imports a Claude session and opens it immediately after success', async () => { + const onOpenSession = vi.fn() + const importSession = vi.fn().mockResolvedValue({ + type: 'success', + sessionId: 'hapi-claude-imported-1', + }) + + useImportableSessionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ + sessions: agent === 'claude' + ? [{ + agent: 'claude', + externalSessionId: 'claude-external-2', + cwd: '/tmp/claude-project-2', + timestamp: 654, + transcriptPath: '/tmp/claude-project-2/session.jsonl', + previewTitle: 'Claude import later', + previewPrompt: 'Claude prompt preview', + alreadyImported: false, + importedHapiSessionId: null, + }] + : [], + isLoading: false, + error: null, + refetch: vi.fn(), + })) + useImportableSessionActionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ + importSession: agent === 'claude' ? importSession : vi.fn(), + refreshSession: vi.fn(), + importingSessionId: null, + refreshingSessionId: null, + error: null, + })) + + renderModal({ onOpenSession }) + fireEvent.click(screen.getByRole('button', { name: 'Claude' })) + fireEvent.click(screen.getByRole('button', { name: 'Import into HAPI' })) + + expect(importSession).toHaveBeenCalledWith('claude-external-2') + + await vi.waitFor(() => { + expect(onOpenSession).toHaveBeenCalledWith('hapi-claude-imported-1') + }) + }) }) diff --git a/web/src/components/NewSession/ImportExistingModal.tsx b/web/src/components/NewSession/ImportExistingModal.tsx index bc26bd1f9..d9f84fddf 100644 --- a/web/src/components/NewSession/ImportExistingModal.tsx +++ b/web/src/components/NewSession/ImportExistingModal.tsx @@ -8,28 +8,26 @@ import type { ImportableSessionAgent } from '@/types/api' import { useTranslation } from '@/lib/use-translation' import { ImportableSessionList } from './ImportableSessionList' -export function ImportExistingModal(props: { +function ImportExistingAgentPanel(props: { api: ApiClient + agent: ImportableSessionAgent open: boolean - onOpenChange: (open: boolean) => void + search: string onOpenSession: (sessionId: string) => void }) { const { t } = useTranslation() - const [activeTab, setActiveTab] = useState('codex') - const [search, setSearch] = useState('') - const activeAgent = activeTab - const { sessions, isLoading, error, refetch } = useImportableSessions(props.api, activeAgent, props.open) + const { sessions, isLoading, error, refetch } = useImportableSessions(props.api, props.agent, props.open) const { importSession, refreshSession, importingSessionId, refreshingSessionId, error: actionError, - } = useImportableSessionActions(props.api, activeAgent) + } = useImportableSessionActions(props.api, props.agent) const [selectedExternalSessionId, setSelectedExternalSessionId] = useState(null) const filteredSessions = useMemo(() => { - const query = search.trim().toLowerCase() + const query = props.search.trim().toLowerCase() if (!query) { return sessions } @@ -43,13 +41,11 @@ export function ImportExistingModal(props: { ] return haystacks.some((value) => value?.toLowerCase().includes(query)) }) - }, [search, sessions]) + }, [props.search, sessions]) useEffect(() => { if (!props.open) { - setSearch('') setSelectedExternalSessionId(null) - setActiveTab('codex') return } @@ -63,6 +59,70 @@ export function ImportExistingModal(props: { props.onOpenSession(result.sessionId) } + return ( +
+
+ +
+ + {isLoading ? ( +
+ {t('newSession.import.loading')} +
+ ) : error ? ( +
+
{error}
+ +
+ ) : filteredSessions.length === 0 ? ( +
+ {sessions.length === 0 + ? t('newSession.import.empty') + : t('newSession.import.emptySearch')} +
+ ) : ( + void handleImport(externalSessionId)} + onRefresh={(externalSessionId) => void refreshSession(externalSessionId)} + onOpen={props.onOpenSession} + /> + )} + + {actionError ? ( +
+ {actionError} +
+ ) : null} +
+ ) +} + +export function ImportExistingModal(props: { + api: ApiClient + open: boolean + onOpenChange: (open: boolean) => void + onOpenSession: (sessionId: string) => void +}) { + const { t } = useTranslation() + const [activeTab, setActiveTab] = useState('codex') + const [search, setSearch] = useState('') + + useEffect(() => { + if (!props.open) { + setSearch('') + setActiveTab('codex') + } + }, [props.open]) + return ( @@ -102,56 +162,23 @@ export function ImportExistingModal(props: {
-
-
- setSearch(event.target.value)} - placeholder={t('newSession.import.searchPlaceholder')} - className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]" - /> - -
- - {isLoading ? ( -
- {t('newSession.import.loading')} -
- ) : error ? ( -
-
{error}
- -
- ) : filteredSessions.length === 0 ? ( -
- {sessions.length === 0 - ? t('newSession.import.empty') - : t('newSession.import.emptySearch')} -
- ) : ( - void handleImport(externalSessionId)} - onRefresh={(externalSessionId) => void refreshSession(externalSessionId)} - onOpen={props.onOpenSession} - /> - )} - - {actionError ? ( -
- {actionError} -
- ) : null} +
+ setSearch(event.target.value)} + placeholder={t('newSession.import.searchPlaceholder')} + className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]" + />
+
diff --git a/web/src/hooks/mutations/useImportableSessionActions.ts b/web/src/hooks/mutations/useImportableSessionActions.ts index 35f6bcadb..df70e65be 100644 --- a/web/src/hooks/mutations/useImportableSessionActions.ts +++ b/web/src/hooks/mutations/useImportableSessionActions.ts @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import type { ApiClient } from '@/api/client' import type { ExternalSessionActionResponse, ImportableSessionAgent } from '@/types/api' @@ -15,7 +16,7 @@ export function useImportableSessionActions(api: ApiClient | null, agent: Import const invalidate = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), - queryClient.invalidateQueries({ queryKey: ['importable-sessions', agent] as const }), + queryClient.invalidateQueries({ queryKey: queryKeys.importableSessions(agent) }), ]) } @@ -39,6 +40,11 @@ export function useImportableSessionActions(api: ApiClient | null, agent: Import onSuccess: invalidate, }) + useEffect(() => { + importMutation.reset() + refreshMutation.reset() + }, [agent]) + return { importSession: importMutation.mutateAsync, refreshSession: refreshMutation.mutateAsync, diff --git a/web/src/hooks/queries/useImportableSessions.ts b/web/src/hooks/queries/useImportableSessions.ts index ad616e003..d68226005 100644 --- a/web/src/hooks/queries/useImportableSessions.ts +++ b/web/src/hooks/queries/useImportableSessions.ts @@ -14,7 +14,7 @@ export function useImportableSessions( refetch: () => Promise } { const query = useQuery({ - queryKey: ['importable-sessions', agent] as const, + queryKey: queryKeys.importableSessions(agent), queryFn: async () => { if (!api) { throw new Error('API unavailable') diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index ae3911e97..493033645 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -1,9 +1,11 @@ +import type { ImportableSessionAgent } from '@/types/api' + export const queryKeys = { sessions: ['sessions'] as const, session: (sessionId: string) => ['session', sessionId] as const, messages: (sessionId: string) => ['messages', sessionId] as const, machines: ['machines'] as const, - importableSessions: (agent: 'codex') => ['importable-sessions', agent] as const, + importableSessions: (agent: ImportableSessionAgent) => ['importable-sessions', agent] as const, gitStatus: (sessionId: string) => ['git-status', sessionId] as const, sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const, sessionDirectory: (sessionId: string, path: string) => ['session-directory', sessionId, path] as const, From d57f3a480078513ea327d35a4e154687c6a86236 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 02:41:56 +0800 Subject: [PATCH 55/82] docs: add claude subagent parity design --- ...026-04-04-claude-subagent-parity-design.md | 349 ++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-04-claude-subagent-parity-design.md diff --git a/docs/superpowers/specs/2026-04-04-claude-subagent-parity-design.md b/docs/superpowers/specs/2026-04-04-claude-subagent-parity-design.md new file mode 100644 index 000000000..703d7b60b --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-claude-subagent-parity-design.md @@ -0,0 +1,349 @@ +# Claude Subagent Parity With Codex UX + +## Goal + +Bring Claude subagent behavior in HAPI up to near-Codex UX parity without forcing Claude to imitate Codex's raw protocol. The product goal is a unified user experience for: + +- nested subagent chat visibility +- parent/child lineage +- child transcript replay on resume/import +- subagent title and lifecycle status +- team/task extraction and visualization + +The technical goal is to preserve each agent's native interfaces while normalizing them into one HAPI semantic layer that hub and web can consume consistently. + +## Non-Goals + +This work does not: + +- replace Claude native `Task` semantics with Codex `spawn_agent` semantics +- redesign the entire chat UI +- invent a new team/task product model beyond the current `TeamState` +- remove Codex-specific capabilities that already work + +## Current State + +### Codex + +Codex already has a mature HAPI adaptation layer: + +- sidechain metadata is normalized from raw event data +- spawn/wait/send/close subagent tools are converted into stable HAPI-visible tool calls +- child transcript linking exists in the Codex scanner +- explicit remote resume can replay prior transcript state +- web has Codex-oriented sidechain annotation and a Codex-specific subagent preview card + +This gives Codex a coherent nested-subagent experience across CLI, hub, and web. + +### Claude + +Claude already has partial building blocks: + +- `Task` tool is recognized and rendered +- Claude SDK messages with `parent_tool_use_id` can be mapped into sidechain messages +- remote resume replay now exists for explicit import/resume flows +- hub team/task extraction can already read Claude assistant `tool_use` blocks in some cases + +But the overall behavior is still weaker than Codex: + +- lineage is not normalized into a stable cross-layer subagent model +- child transcript linking is not first-class +- lifecycle/title extraction is shallow +- web subagent rendering is Codex-specific +- team/task state derived from Claude remains opportunistic rather than intentional + +## Design Principles + +1. Preserve native agent semantics. +Claude stays Claude. Codex stays Codex. + +2. Normalize at the HAPI semantic layer. +Hub and web should consume agent-neutral subagent concepts rather than raw Claude/Codex event shapes. + +3. Keep product UX aligned. +Users should see similar concepts and interaction quality across Codex and Claude even if implementation differs underneath. + +4. Improve Claude without regressing Codex. +Codex remains the stronger implementation baseline and should be used as the reference UX. + +## Proposed Architecture + +Introduce a unified internal subagent semantic layer with two adapters: + +- Codex subagent adapter +- Claude subagent adapter + +Each adapter maps agent-native events and transcript structures into the same semantic outputs. + +### Unified Subagent Semantics + +The normalized layer should support these concepts: + +- `subagent_spawn` +- `subagent_prompt` +- `subagent_message` +- `subagent_status` +- `subagent_title` +- `subagent_lineage` +- `team_delta` + +These are internal HAPI semantics, not new user-facing protocol names that external tools must emit directly. + +### Layer Responsibilities + +#### CLI + +Responsible for deriving normalized subagent semantics from native agent streams and transcript files. + +#### Hub + +Responsible for storing/merging session state and deriving stable `TeamState` updates from normalized semantics. + +#### Web + +Responsible for rendering nested conversation, lifecycle preview, and team/task visualization from normalized data, without Codex-only assumptions. + +## Claude CLI Design + +Claude needs four concrete upgrades. + +### 1. Stable Sidechain Identity + +Current Claude flow relies mainly on `parent_tool_use_id` and transient SDK conversion state. That is enough for simple nested display, but not enough for durable lineage across replay and cross-layer rendering. + +Claude adapter should derive a stable `sidechainKey` from the parent `Task` tool use identity and preserve it through: + +- live SDK conversion +- replay conversion +- interrupted tool result synthesis +- web normalization + +The result should be equivalent in product behavior to Codex's `parentToolCallId` usage. + +### 2. Child Transcript Linking + +Claude should gain an explicit child transcript linking path similar in effect to Codex, but driven by Claude-native evidence. + +The adapter should: + +- detect when a `Task` launch corresponds to a child conversation +- discover child transcript files using Claude-native session/transcript metadata +- attach child transcript events back to the parent subagent chain +- preserve enough lineage metadata for replay and UI grouping + +Linking must be conservative. If a child transcript cannot be linked confidently, HAPI should prefer incomplete linkage over incorrect lineage. + +### 3. Lifecycle Extraction + +Claude adapter should derive lifecycle snapshots for each subagent: + +- waiting +- running +- completed +- error +- closed + +Lifecycle should come from a combination of: + +- `Task` tool use/result pairs +- Claude SDK/system/result events +- transcript evidence when replaying/resuming + +This lifecycle model should match the existing Codex preview card capabilities as closely as possible. + +### 4. Title Extraction + +Claude subagents should have a stable display title with fallback order: + +1. explicit subagent/tool-provided title if available +2. prompt-derived title +3. short session identifier + +This title is for UI clarity and lineage display, not for mutating Claude's own underlying protocol. + +## Codex CLI Design + +Codex should not be reworked functionally. Instead, current Codex behavior should be reorganized behind the same semantic interface used by Claude. + +Expected Codex changes are limited to: + +- extracting current subagent/lifecycle logic into the shared semantic boundary +- leaving existing scanner/replay/preview behavior intact +- ensuring Codex and Claude emit comparable semantic outputs + +## Hub Design + +### Team/Task Extraction + +Current `hub/src/sync/teams.ts` already acts like a cross-agent extraction layer, but it is still partly shaped around raw tool names and partial per-agent assumptions. + +This should become an intentional semantic ingestion layer: + +- CLI-originated normalized subagent/team/task semantics are the primary input +- raw tool-block fallback remains allowed for backward compatibility inside the repo, but should be secondary +- `Task` in Claude and `CodexSpawnAgent`-family semantics in Codex should converge into the same `TeamState` mutation model + +### TeamState Semantics + +The current `TeamState` model remains the product surface: + +- `members` +- `tasks` +- `messages` +- `updatedAt` + +But extraction becomes more reliable: + +- spawned subagent becomes a `member` +- subagent work prompt/description becomes a `task` +- lifecycle transitions update task/member state +- cross-agent coordination messages continue to appear in `messages` + +### Merge/Replay Safety + +Session import/refresh/resume must preserve normalized subagent state consistently. + +Rules: + +- replay should not duplicate already-materialized subagent events +- merge should preserve team/task state integrity +- import/refresh failure cleanup should not leave partial lineage/team state artifacts + +## Web Design + +### 1. Agent-Neutral Sidechain Model + +Current web chat logic still has clear Codex-specific behavior, especially in: + +- `web/src/chat/codexSidechain.ts` +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx` +- `ToolMessage.tsx` render-mode branching + +These should be refactored into agent-neutral subagent rendering primitives. + +Planned shape: + +- generic sidechain/subagent annotation module +- generic subagent preview card +- agent-specific formatting only where presentation genuinely differs + +### 2. Nested Transcript Rendering + +Both Codex and Claude should render nested child work with the same product behavior: + +- root tool call remains visible +- nested child conversation is grouped under that tool call +- task prompt can be summarized while full transcript remains inspectable +- lifecycle badges and recent status remain visible + +### 3. TeamPanel Reliability + +`TeamPanel` should not need major redesign. The improvement should come from stronger data quality. + +Expected result: + +- Claude sessions with subagent activity reliably populate `TeamPanel` +- task/member state feels comparable to Codex sessions +- no special-case Claude panel is introduced + +## Data Flow + +### Live Flow + +1. Agent emits native SDK/app-server/tool/transcript signals +2. Agent adapter converts them to normalized subagent semantics +3. CLI emits HAPI-visible messages/state updates +4. Hub updates session/team state +5. Web normalizes and renders nested subagent and team/task views + +### Replay/Resume Flow + +1. Resume/import identifies prior session/transcript +2. Agent adapter replays transcript through the same semantic conversion layer +3. Duplicate-safe normalization prevents repeated or mis-grouped child events +4. Hub/web consume replayed semantics the same way as live flow + +## Error Handling + +### Claude Lineage Ambiguity + +If Claude child lineage cannot be linked confidently: + +- do not fabricate parent/child linkage +- keep content visible as root-level or minimally grouped content +- avoid wrong grouping over aggressive grouping + +### Partial Lifecycle Evidence + +If lifecycle cannot be fully inferred: + +- preserve last known status +- prefer `running` or `completed` only when evidence is clear +- never mark a subagent `completed` from weak heuristics alone + +### Replay Duplication + +Replay and live streams must share a clear deduplication boundary: + +- dedupe by stable event/message identity where available +- otherwise dedupe by normalized semantic key plus transcript position +- avoid masking real repeated child outputs that are semantically distinct + +## Testing Strategy + +### CLI + +- Claude adapter unit tests for spawn/prompt/status/title extraction +- Claude child transcript linking tests +- Claude replay tests covering nested sidechains +- Codex parity regression tests to ensure no behavior loss + +### Hub + +- normalized team/task extraction tests for Claude and Codex +- replay/import/refresh merge integrity tests +- regression tests for `TeamState` updates from both agents + +### Web + +- sidechain annotation tests for Claude and Codex +- generic subagent preview card tests +- reducer/timeline tests for mixed nested transcripts +- `TeamPanel` rendering tests for Claude-derived state + +## Migration Strategy + +Implement in stages: + +1. define shared internal subagent semantic contracts +2. move Codex logic behind those contracts without changing behavior +3. upgrade Claude adapter to emit the same semantics +4. generalize web subagent rendering from Codex-only to agent-neutral +5. strengthen hub team/task extraction to prefer normalized semantics + +This ordering minimizes risk because Codex remains the reference implementation while Claude catches up. + +## Success Criteria + +This work is successful when: + +- Claude nested subagent conversations are visible and grouped as reliably as Codex +- Claude replay/import/resume preserves child conversation context without obvious duplication +- Claude subagents show useful title and lifecycle state +- Claude sessions populate `TeamPanel` with meaningful member/task state +- web no longer depends on Codex-specific preview/rendering for subagent UX +- Codex behavior does not regress + +## Open Tradeoff Decisions Resolved + +### Why not make Claude emit fake Codex events? + +Because that would optimize for short-term implementation convenience while increasing long-term drift risk. HAPI should normalize semantics, not erase protocol differences. + +### Why keep TeamState instead of inventing a new model? + +Because current product needs are already served by `TeamState`, and the real issue is extraction quality, not missing schema surface. + +### Why keep Codex as the UX reference? + +Because Codex is already the stronger implementation in this area, and users explicitly want Claude to feel as good as that path. From 40d8531db6ab5e0bb8893cd5a100b5636216d1ef Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 02:57:45 +0800 Subject: [PATCH 56/82] feat: add normalized subagent semantic layer --- .../codex/utils/codexEventConverter.test.ts | 25 ++++++++++++ cli/src/codex/utils/codexEventConverter.ts | 40 ++++++++++++++++++- cli/src/subagents/normalize.ts | 27 +++++++++++++ cli/src/subagents/types.ts | 16 ++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 cli/src/subagents/normalize.ts create mode 100644 cli/src/subagents/types.ts diff --git a/cli/src/codex/utils/codexEventConverter.test.ts b/cli/src/codex/utils/codexEventConverter.test.ts index d04a52d73..f0ea2c375 100644 --- a/cli/src/codex/utils/codexEventConverter.test.ts +++ b/cli/src/codex/utils/codexEventConverter.test.ts @@ -121,6 +121,31 @@ describe('convertCodexEvent', () => { }); }); + it('normalizes Codex spawn_agent events into subagent metadata', () => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'function_call', + name: 'spawn_agent', + call_id: 'spawn-1', + arguments: '{"message":"delegate search task"}' + } + }); + + expect(result?.message).toMatchObject({ + type: 'tool-call', + name: 'CodexSpawnAgent', + callId: 'spawn-1', + meta: expect.objectContaining({ + subagent: { + kind: 'spawn', + sidechainKey: 'spawn-1', + prompt: 'delegate search task' + } + }) + }); + }); + it('preserves sidechain metadata on user and agent/tool messages', () => { const userResult = convertCodexEvent({ type: 'event_msg', diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index f67e1f70d..85f6bad35 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { logger } from '@/ui/logger'; +import { createSpawnMeta } from '@/subagents/normalize'; +import type { NormalizedSubagentMeta } from '@/subagents/types'; const CodexSessionEventSchema = z.object({ timestamp: z.string().optional(), @@ -17,27 +19,35 @@ type CodexSidechainMeta = { parentToolCallId: string; }; +type CodexMessageMeta = { + subagent?: NormalizedSubagentMeta; +}; + export type CodexMessage = { type: 'message'; message: string; id: string; + meta?: CodexMessageMeta; isSidechain?: true; parentToolCallId?: string; } | { type: 'reasoning'; message: string; id: string; + meta?: CodexMessageMeta; isSidechain?: true; parentToolCallId?: string; } | { type: 'reasoning-delta'; delta: string; + meta?: CodexMessageMeta; isSidechain?: true; parentToolCallId?: string; } | { type: 'token_count'; info: Record; id: string; + meta?: CodexMessageMeta; isSidechain?: true; parentToolCallId?: string; } | { @@ -46,6 +56,7 @@ export type CodexMessage = { callId: string; input: unknown; id: string; + meta?: CodexMessageMeta; isSidechain?: true; parentToolCallId?: string; } | { @@ -53,6 +64,7 @@ export type CodexMessage = { callId: string; output: unknown; id: string; + meta?: CodexMessageMeta; isSidechain?: true; parentToolCallId?: string; }; @@ -95,6 +107,19 @@ function parseArguments(value: unknown): unknown { return value; } +function extractSpawnPrompt(input: unknown): string | undefined { + if (!input || typeof input !== 'object') { + return undefined; + } + + const record = input as Record; + return asString(record.message) + ?? asString(record.prompt) + ?? asString(record.text) + ?? asString(record.content) + ?? undefined; +} + function getSidechainMeta(rawEvent: z.infer): CodexSidechainMeta | null { return rawEvent.hapiSidechain ?? null; } @@ -269,13 +294,24 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu if (!name || !callId) { return null; } + const input = parseArguments(payloadRecord.arguments); return { message: applySidechainMeta({ type: 'tool-call', name: normalizeCodexToolName(name), callId, - input: parseArguments(payloadRecord.arguments), - id: randomUUID() + input, + id: randomUUID(), + ...(name === 'spawn_agent' + ? { + meta: { + subagent: createSpawnMeta({ + sidechainKey: callId, + prompt: extractSpawnPrompt(input) + }) + } + } + : {}) }, sidechainMeta) }; } diff --git a/cli/src/subagents/normalize.ts b/cli/src/subagents/normalize.ts new file mode 100644 index 000000000..f895d2949 --- /dev/null +++ b/cli/src/subagents/normalize.ts @@ -0,0 +1,27 @@ +import type { NormalizedSubagentLifecycleStatus, NormalizedSubagentMeta } from './types'; + +export function createSpawnMeta(input: { + sidechainKey: string; + prompt?: string; +}): NormalizedSubagentMeta { + return { + kind: 'spawn', + sidechainKey: input.sidechainKey, + ...(input.prompt ? { prompt: input.prompt } : {}) + }; +} + +export function createStatusMeta(input: { + sidechainKey: string; + status: NormalizedSubagentLifecycleStatus; + agentId?: string; + nickname?: string; +}): NormalizedSubagentMeta { + return { + kind: 'status', + sidechainKey: input.sidechainKey, + status: input.status, + ...(input.agentId ? { agentId: input.agentId } : {}), + ...(input.nickname ? { nickname: input.nickname } : {}) + }; +} diff --git a/cli/src/subagents/types.ts b/cli/src/subagents/types.ts new file mode 100644 index 000000000..3bfd387c4 --- /dev/null +++ b/cli/src/subagents/types.ts @@ -0,0 +1,16 @@ +export type NormalizedSubagentLifecycleStatus = + | 'waiting' + | 'running' + | 'completed' + | 'error' + | 'closed' + +export type NormalizedSubagentMeta = { + sidechainKey: string + kind: 'spawn' | 'message' | 'status' | 'title' + prompt?: string + title?: string + status?: NormalizedSubagentLifecycleStatus + agentId?: string + nickname?: string +} From 021b1d141a80de99825698835cc50a69d973161e Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 03:07:44 +0800 Subject: [PATCH 57/82] refactor: align codex lineage with normalized subagent semantics --- .../codex/utils/appServerEventConverter.ts | 7 +- .../codex/utils/codexSessionScanner.test.ts | 86 +++++++++++++++++++ cli/src/codex/utils/codexSessionScanner.ts | 30 +------ 3 files changed, 92 insertions(+), 31 deletions(-) diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index e72a74e4d..dc39b6d82 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -530,11 +530,10 @@ export class AppServerEventConverter { if (server === 'hapi' && tool === 'change_title') { if (parentToolCallId) { if (title) { - events.push({ + events.push(this.addSidechainMeta({ type: 'subagent_title_change', - title, - parent_tool_call_id: parentToolCallId - }); + title + }, threadId)); } return events; } diff --git a/cli/src/codex/utils/codexSessionScanner.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts index 870d0dd94..cf4ff827f 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -200,6 +200,92 @@ describe('codexSessionScanner', () => { expect((parentWaitEvent as Record).hapiSidechain).toBeUndefined(); }, 10000); + it('links child Codex lifecycle entries using normalized sidechain metadata', async () => { + const parentSessionId = 'parent-session-2'; + const parentToolCallId = 'spawn-call-2'; + const childSessionId = 'child-session-2'; + const parentFile = join(sessionsDir, `codex-${parentSessionId}.jsonl`); + const childFile = join(sessionsDir, `codex-${childSessionId}.jsonl`); + const resolvedResult: ResolveCodexSessionFileResult = { + status: 'found', + filePath: parentFile, + cwd: '/data/github/happy/hapi', + timestamp: Date.parse('2025-12-22T00:00:00.000Z') + }; + + await writeFile( + parentFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: parentSessionId } }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', + name: 'spawn_agent', + call_id: parentToolCallId, + arguments: '{"message":"delegate"}' + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: parentToolCallId, + output: JSON.stringify({ agent_id: childSessionId, nickname: 'child' }) + } + }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: parentSessionId, + resolvedSessionFile: resolvedResult, + onEvent: (event) => events.push(event) + }); + + await wait(200); + + await writeFile( + childFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: childSessionId } }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'bootstrap-call-2', + output: 'You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context.' + } + }), + JSON.stringify({ + type: 'subagent_title_change', + title: 'child title' + }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'child prompt' } }) + ].join('\n') + '\n' + ); + + await wait(2300); + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'subagent_title_change', + title: 'child title', + hapiSidechain: { parentToolCallId } + }) + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'event_msg', + payload: expect.objectContaining({ + type: 'user_message', + message: 'child prompt' + }), + hapiSidechain: { parentToolCallId } + }) + ); + }, 10000); + it('limits session scan to dates within the start window', async () => { const referenceTimestampMs = Date.parse('2025-12-22T00:00:00.000Z'); const windowMs = 2 * 60 * 1000; diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts index 9b3deb93b..3a25f8468 100644 --- a/cli/src/codex/utils/codexSessionScanner.ts +++ b/cli/src/codex/utils/codexSessionScanner.ts @@ -103,8 +103,6 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private readonly linkedChildFilePaths = new Set(); private readonly linkedChildParentCallIdByFile = new Map(); private readonly childTranscriptStartLineByFile = new Map(); - private readonly childBootstrapSeenByFile = new Set(); - private readonly childFallbackTaskStartedLineByFile = new Map(); private readonly pendingChildSessionIdToParentCallId = new Map(); private readonly targetCwd: string | null; private readonly referenceTimestampMs: number; @@ -711,9 +709,6 @@ class CodexSessionScannerImpl extends BaseSessionScanner { return existingStartLine; } - let sawBootstrapMarker = this.childBootstrapSeenByFile.has(normalizedFilePath); - let fallbackTaskStartedLine = this.childFallbackTaskStartedLineByFile.get(normalizedFilePath) ?? null; - for (const entry of entries) { const payload = asRecord(entry.event.payload); if (!payload || entry.lineIndex === undefined) { @@ -722,29 +717,10 @@ class CodexSessionScannerImpl extends BaseSessionScanner { if (entry.event.type === 'response_item' && asString(payload.type) === 'function_call_output') { if (stringifyOutput(payload.output).startsWith('You are the newly spawned agent.')) { - sawBootstrapMarker = true; - this.childBootstrapSeenByFile.add(normalizedFilePath); + const startLine = entry.lineIndex + 1; + this.childTranscriptStartLineByFile.set(normalizedFilePath, startLine); + return startLine; } - continue; - } - - if (!sawBootstrapMarker) { - continue; - } - - if ( - fallbackTaskStartedLine === null - && entry.event.type === 'event_msg' - && asString(payload.type) === 'task_started' - ) { - fallbackTaskStartedLine = entry.lineIndex; - this.childFallbackTaskStartedLineByFile.set(normalizedFilePath, entry.lineIndex); - } - - if (entry.event.type === 'event_msg' && asString(payload.type) === 'user_message') { - this.childTranscriptStartLineByFile.set(normalizedFilePath, entry.lineIndex); - this.childFallbackTaskStartedLineByFile.delete(normalizedFilePath); - return entry.lineIndex; } } From 7ba1189211639eb16f3c11878feb0bb5c6d0daa3 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 03:13:06 +0800 Subject: [PATCH 58/82] fix: wire codex child lifecycle sidechain metadata --- .../codex/utils/appServerEventConverter.ts | 3 ++- .../codex/utils/codexSessionScanner.test.ts | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index dc39b6d82..f529b1e18 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -186,6 +186,7 @@ export class AppServerEventConverter { msgType === 'turn_aborted' || msgType === 'task_failed' ) { + const threadId = asString(msg.thread_id ?? msg.threadId); const turnId = asString(msg.turn_id ?? msg.turnId); if ((msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed') && !turnId) { logger.debug('[AppServerEventConverter] Ignoring wrapped terminal event without turn_id', { msgType }); @@ -202,7 +203,7 @@ export class AppServerEventConverter { event.error = error; } } - return [event]; + return [this.addSidechainMeta(event, threadId)]; } if (msgType === 'agent_message_delta' || msgType === 'agent_message_content_delta') { diff --git a/cli/src/codex/utils/codexSessionScanner.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts index cf4ff827f..61d6dcb64 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -257,16 +257,38 @@ describe('codexSessionScanner', () => { output: 'You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context.' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'task_started', turn_id: 'child-turn-2' } }), JSON.stringify({ type: 'subagent_title_change', title: 'child title' }), + JSON.stringify({ type: 'event_msg', payload: { type: 'task_complete', turn_id: 'child-turn-2' } }), JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'child prompt' } }) ].join('\n') + '\n' ); await wait(2300); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'event_msg', + payload: expect.objectContaining({ + type: 'task_started', + turn_id: 'child-turn-2' + }), + hapiSidechain: { parentToolCallId } + }) + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'event_msg', + payload: expect.objectContaining({ + type: 'task_complete', + turn_id: 'child-turn-2' + }), + hapiSidechain: { parentToolCallId } + }) + ); expect(events).toContainEqual( expect.objectContaining({ type: 'subagent_title_change', From ed8fa4d04f51305b1cf4c07a35b9e291b2bb1d23 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 03:27:26 +0800 Subject: [PATCH 59/82] feat: normalize claude subagent metadata --- cli/src/claude/claudeRemoteLauncher.test.ts | 120 ++++++++++++++++- cli/src/claude/claudeRemoteLauncher.ts | 51 ++++++++ .../utils/claudeSubagentAdapter.test.ts | 115 +++++++++++++++++ cli/src/claude/utils/claudeSubagentAdapter.ts | 122 ++++++++++++++++++ cli/src/claude/utils/sdkToLogConverter.ts | 36 +++++- 5 files changed, 430 insertions(+), 14 deletions(-) create mode 100644 cli/src/claude/utils/claudeSubagentAdapter.test.ts create mode 100644 cli/src/claude/utils/claudeSubagentAdapter.ts diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts index a255bc70b..b7f1da1d8 100644 --- a/cli/src/claude/claudeRemoteLauncher.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -2,8 +2,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' const harness = vi.hoisted(() => ({ replayMessages: [] as Array>, + remoteMessages: [] as Array>, scannerCalls: [] as Array>, remoteCalls: [] as Array>, + metadataUpdates: [] as Array>, + sessionEvents: [] as Array>, rpcHandlers: new Map Promise | unknown>(), expectedReplaySessionId: 'resume-session-123', })) @@ -13,13 +16,18 @@ vi.mock('./claudeRemote', () => ({ onMessage: (message: Record) => void }) => { harness.remoteCalls.push(opts as Record) - opts.onMessage({ - type: 'assistant', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'live assistant reply' }] - } - }) + const messages = harness.remoteMessages.length > 0 + ? harness.remoteMessages + : [{ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'live assistant reply' }] + } + }] + for (const message of messages) { + opts.onMessage(message) + } void harness.rpcHandlers.get('switch')?.({}) } })) @@ -111,6 +119,7 @@ function createSessionStub() { client: { sendClaudeSessionMessage: (message: Record) => void; sendSessionEvent: (event: Record) => void; + updateMetadata: (handler: (metadata: Record) => Record) => void; rpcHandlerManager: { registerHandler: (method: string, handler: (params?: unknown) => Promise | unknown) => void; }; @@ -143,6 +152,13 @@ function createSessionStub() { }, sendSessionEvent: (event: Record) => { sessionEvents.push(event) + harness.sessionEvents.push(event) + }, + updateMetadata: (handler: (metadata: Record) => Record) => { + const next = handler({ + summary: null + }) + harness.metadataUpdates.push(next) }, rpcHandlerManager: { registerHandler(method: string, handler: (params?: unknown) => Promise | unknown) { @@ -186,8 +202,11 @@ function createSessionStub() { describe('claudeRemoteLauncher', () => { afterEach(() => { harness.replayMessages = [] + harness.remoteMessages = [] harness.scannerCalls = [] harness.remoteCalls = [] + harness.metadataUpdates = [] + harness.sessionEvents = [] harness.rpcHandlers = new Map() harness.expectedReplaySessionId = 'resume-session-123' }) @@ -251,4 +270,91 @@ describe('claudeRemoteLauncher', () => { }) ]) }) + + it('forwards Claude subagent metadata from live messages and replayed transcript entries', async () => { + harness.remoteMessages = [ + { + type: 'assistant', + parent_tool_use_id: 'task-1', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Investigate test failure' } + }] + } + }, + { + type: 'result', + subtype: 'success', + result: 'done', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'task-1' + } + ] + + harness.replayMessages = [ + { + type: 'assistant', + uuid: 'a1', + meta: { + subagent: { + kind: 'title', + sidechainKey: 'task-1', + title: 'Replay title' + } + }, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'replayed assistant reply' }] + } + } + ] + + const { session, sentClaudeMessages } = createSessionStub() + + await claudeRemoteLauncher(session as never) + + expect(harness.metadataUpdates).toEqual([ + expect.objectContaining({ + summary: expect.objectContaining({ + text: 'Replay title' + }) + }), + expect.objectContaining({ + summary: expect.objectContaining({ + text: 'Investigate test failure' + }) + }), + expect.objectContaining({ + summary: expect.objectContaining({ + text: 'done' + }) + }) + ]) + + expect(harness.sessionEvents).toContainEqual({ + type: 'subagent_status_change', + sidechainKey: 'task-1', + status: 'completed' + }) + + expect(sentClaudeMessages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'assistant', + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + kind: 'message', + sidechainKey: 'task-1' + }) + }) + }) + ])) + }) }) diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index 1edf9b025..061ea2719 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -13,12 +13,14 @@ import { EnhancedMode } from "./loop"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; import { createSessionScanner } from "./utils/sessionScanner"; import { isClaudeChatVisibleMessage } from "./utils/chatVisibility"; +import { extractClaudeSubagentMeta } from "./utils/claudeSubagentAdapter"; import type { ClaudePermissionMode } from "@hapi/protocol/types"; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from "@/modules/common/remote/RemoteLauncherBase"; +import type { NormalizedSubagentMeta } from "@/subagents/types"; interface PermissionsField { date: number; @@ -81,6 +83,40 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { }); } + private forwardSubagentMeta(meta: NormalizedSubagentMeta[]): void { + for (const item of meta) { + if (item.kind === 'spawn') { + this.session.client.updateMetadata((metadata) => ({ + ...metadata, + summary: { + text: item.prompt ?? item.sidechainKey, + updatedAt: Date.now() + } + })); + continue; + } + + if (item.kind === 'title') { + this.session.client.updateMetadata((metadata) => ({ + ...metadata, + summary: { + text: item.title ?? item.sidechainKey, + updatedAt: Date.now() + } + })); + continue; + } + + if (item.kind === 'status') { + this.session.client.sendSessionEvent({ + type: 'subagent_status_change', + sidechainKey: item.sidechainKey, + status: item.status + } as any); + } + } + } + protected async runMainLoop(): Promise { logger.debug('[claudeRemoteLauncher] Starting remote launcher'); logger.debug(`[claudeRemoteLauncher] TTY available: ${this.hasTTY}`); @@ -110,6 +146,10 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { version: process.env.npm_package_version }, permissionHandler.getResponses()); + const forwardSubagentMeta = (meta: NormalizedSubagentMeta[]): void => { + this.forwardSubagentMeta(meta); + }; + const replayExplicitResumeTranscript = async (): Promise => { if (session.startingMode !== 'remote') { return; @@ -125,6 +165,16 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { workingDirectory: session.path, replayExistingMessages: true, onMessage: (message) => { + const subagentMeta = (message as Record).meta as { + subagent?: NormalizedSubagentMeta | NormalizedSubagentMeta[] + } | undefined; + + if (subagentMeta?.subagent) { + forwardSubagentMeta(Array.isArray(subagentMeta.subagent) + ? subagentMeta.subagent + : [subagentMeta.subagent]); + } + if (message.type === 'summary' || message.isMeta || message.isCompactSummary) { return; } @@ -150,6 +200,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { function onMessage(message: SDKMessage) { formatClaudeMessageForInk(message, messageBuffer); permissionHandler.onMessage(message); + forwardSubagentMeta(extractClaudeSubagentMeta(message)); if (message.type === 'assistant') { let umessage = message as SDKAssistantMessage; diff --git a/cli/src/claude/utils/claudeSubagentAdapter.test.ts b/cli/src/claude/utils/claudeSubagentAdapter.test.ts new file mode 100644 index 000000000..3f309b413 --- /dev/null +++ b/cli/src/claude/utils/claudeSubagentAdapter.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { extractClaudeSubagentMeta } from './claudeSubagentAdapter' + +describe('claudeSubagentAdapter', () => { + it('derives normalized Claude subagent spawn metadata from Task tool use', () => { + const meta = extractClaudeSubagentMeta({ + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Investigate test failure' } + }] + } + } as any) + + expect(meta).toEqual([{ + kind: 'spawn', + sidechainKey: 'task-1', + prompt: 'Investigate test failure' + }]) + }) + + it('preserves the same sidechain key for assistant and user sidechain messages', () => { + expect(extractClaudeSubagentMeta({ + type: 'assistant', + parent_tool_use_id: 'task-1', + message: { + content: [{ type: 'text', text: 'working the sidechain' }] + } + } as any)).toEqual([{ + kind: 'message', + sidechainKey: 'task-1' + }]) + + expect(extractClaudeSubagentMeta({ + type: 'user', + parent_tool_use_id: 'task-1', + message: { + role: 'user', + content: 'sidechain user reply' + } + } as any)).toEqual([{ + kind: 'message', + sidechainKey: 'task-1' + }]) + }) + + it('maps Claude-native completion and error results to normalized lifecycle status', () => { + expect(extractClaudeSubagentMeta({ + type: 'result', + subtype: 'success', + result: 'done', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'task-1' + } as any)).toContainEqual({ + kind: 'status', + sidechainKey: 'task-1', + status: 'completed' + }) + + expect(extractClaudeSubagentMeta({ + type: 'result', + subtype: 'error_during_execution', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: true, + session_id: 'task-1' + } as any)).toContainEqual({ + kind: 'status', + sidechainKey: 'task-1', + status: 'error' + }) + }) + + it('falls back to prompt or session id when no explicit title exists', () => { + expect(extractClaudeSubagentMeta({ + type: 'result', + subtype: 'success', + result: 'Investigate test failure', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'task-1' + } as any)).toContainEqual({ + kind: 'title', + sidechainKey: 'task-1', + title: 'Investigate test failure' + }) + + expect(extractClaudeSubagentMeta({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'task-2' + } as any)).toContainEqual({ + kind: 'title', + sidechainKey: 'task-2', + title: 'task-2' + }) + }) +}) diff --git a/cli/src/claude/utils/claudeSubagentAdapter.ts b/cli/src/claude/utils/claudeSubagentAdapter.ts new file mode 100644 index 000000000..1efc6282b --- /dev/null +++ b/cli/src/claude/utils/claudeSubagentAdapter.ts @@ -0,0 +1,122 @@ +import { createSpawnMeta, createStatusMeta } from '@/subagents/normalize' +import type { NormalizedSubagentMeta } from '@/subagents/types' +import type { SDKAssistantMessage, SDKMessage } from '@/claude/sdk' + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? value as Record : null +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function extractPrompt(input: unknown): string | undefined { + const record = asRecord(input) + if (!record) { + return asString(input) ?? undefined + } + + return asString(record.prompt) + ?? asString(record.title) + ?? asString(record.message) + ?? asString(record.text) + ?? asString(record.content) + ?? undefined +} + +function getSidechainKey(message: SDKMessage): string | null { + return asString((message as SDKAssistantMessage).parent_tool_use_id) + ?? asString((message as Record).parentToolUseId) + ?? null +} + +function extractTitle(message: SDKMessage): string | null { + const explicitTitle = asString((message as Record).title) + if (explicitTitle) { + return explicitTitle + } + + const fallbackPrompt = asString((message as Record).prompt) + if (fallbackPrompt) { + return fallbackPrompt + } + + const resultText = asString((message as Record).result) + if (resultText) { + return resultText + } + + return asString((message as Record).session_id) + ?? asString((message as Record).sessionId) + ?? null +} + +export function extractClaudeSubagentMeta(message: SDKMessage): NormalizedSubagentMeta[] { + const metas: NormalizedSubagentMeta[] = [] + + if (message.type === 'assistant') { + const assistantMessage = message as SDKAssistantMessage + const content = assistantMessage.message.content + + if (Array.isArray(content)) { + for (const block of content) { + if (block.type !== 'tool_use' || block.name !== 'Task' || !block.id) { + continue + } + + metas.push(createSpawnMeta({ + sidechainKey: block.id, + prompt: extractPrompt(block.input) + })) + } + } + + const sidechainKey = getSidechainKey(message) + if (sidechainKey) { + metas.push({ + kind: 'message', + sidechainKey + }) + } + + return metas + } + + if (message.type === 'user') { + const sidechainKey = getSidechainKey(message) + if (sidechainKey) { + metas.push({ + kind: 'message', + sidechainKey + }) + } + + return metas + } + + if (message.type === 'result') { + const sidechainKey = asString((message as Record).parent_tool_use_id) + ?? asString((message as Record).session_id) + ?? asString((message as Record).sessionId) + + if (!sidechainKey) { + return metas + } + + metas.push(createStatusMeta({ + sidechainKey, + status: message.subtype === 'success' ? 'completed' : 'error' + })) + + const title = extractTitle(message) + if (title) { + metas.push({ + kind: 'title', + sidechainKey, + title + }) + } + } + + return metas +} diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index fac178cf4..6d8ae82af 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -13,6 +13,7 @@ import type { SDKResultMessage } from '@/claude/sdk' import type { RawJSONLines } from '@/claude/types' +import type { NormalizedSubagentMeta } from '@/subagents/types' import type { ClaudePermissionMode } from '@hapi/protocol/types' /** @@ -32,6 +33,12 @@ type PermissionResponse = { reason?: string } +type LogMessageWithSubagentMeta = RawJSONLines & { + meta?: { + subagent?: NormalizedSubagentMeta + } +} + /** * Get current git branch for the working directory */ @@ -94,12 +101,13 @@ export class SDKToLogConverter { const timestamp = new Date().toISOString() let parentUuid = this.lastUuid; let isSidechain = false; + const subagentKey = (sdkMessage as SDKAssistantMessage).parent_tool_use_id ?? (sdkMessage as any).parentToolUseId; if (sdkMessage.parent_tool_use_id) { isSidechain = true; parentUuid = this.sidechainLastUUID.get((sdkMessage as any).parent_tool_use_id) ?? null; this.sidechainLastUUID.set((sdkMessage as any).parent_tool_use_id!, uuid); } - const baseFields = { + const baseFields: Record = { parentUuid: parentUuid, isSidechain: isSidechain, userType: 'external' as const, @@ -110,6 +118,14 @@ export class SDKToLogConverter { uuid, timestamp } + if (subagentKey) { + baseFields.meta = { + subagent: { + kind: 'message', + sidechainKey: subagentKey + } + } + } let logMessage: RawJSONLines | null = null @@ -120,7 +136,7 @@ export class SDKToLogConverter { ...baseFields, type: 'user', message: userMsg.message - } + } as any // Check if this is a tool result and add mode if available if (Array.isArray(userMsg.message.content)) { @@ -146,7 +162,7 @@ export class SDKToLogConverter { message: assistantMsg.message, // Assistant messages often have additional fields requestId: (assistantMsg as any).requestId - } + } as any // if (assistantMsg.message.content && Array.isArray(assistantMsg.message.content)) { // for (const content of assistantMsg.message.content) { // if (content.type === 'tool_use' && content.id) { @@ -175,7 +191,7 @@ export class SDKToLogConverter { tools: systemMsg.tools, // Include all other fields ...(systemMsg as any) - } + } as any break } @@ -211,7 +227,7 @@ export class SDKToLogConverter { } } - logMessage = baseLogMessage + logMessage = baseLogMessage as any break } @@ -263,8 +279,14 @@ export class SDKToLogConverter { content: content }, uuid, - timestamp - } + timestamp, + meta: { + subagent: { + kind: 'message', + sidechainKey: toolUseId + } + } + } as LogMessageWithSubagentMeta } /** From e5bcbe342f8e8ddd09fb7f493fc11407a963dfbc Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 03:33:38 +0800 Subject: [PATCH 60/82] feat: normalize claude subagent metadata --- cli/src/claude/claudeRemoteLauncher.test.ts | 2 +- cli/src/claude/claudeRemoteLauncher.ts | 3 +- .../utils/claudeSubagentAdapter.test.ts | 87 ++++++++++++++----- cli/src/claude/utils/claudeSubagentAdapter.ts | 25 ++++-- 4 files changed, 86 insertions(+), 31 deletions(-) diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts index b7f1da1d8..ab4fbf0a8 100644 --- a/cli/src/claude/claudeRemoteLauncher.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -334,7 +334,7 @@ describe('claudeRemoteLauncher', () => { }), expect.objectContaining({ summary: expect.objectContaining({ - text: 'done' + text: 'Investigate test failure' }) }) ]) diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index 061ea2719..cf82fcaa7 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -13,7 +13,7 @@ import { EnhancedMode } from "./loop"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; import { createSessionScanner } from "./utils/sessionScanner"; import { isClaudeChatVisibleMessage } from "./utils/chatVisibility"; -import { extractClaudeSubagentMeta } from "./utils/claudeSubagentAdapter"; +import { extractClaudeSubagentMeta, resetClaudeSubagentAdapterState } from "./utils/claudeSubagentAdapter"; import type { ClaudePermissionMode } from "@hapi/protocol/types"; import { RemoteLauncherBase, @@ -357,6 +357,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { while (!this.exitReason) { logger.debug('[remote]: launch'); messageBuffer.addMessage('═'.repeat(40), 'status'); + resetClaudeSubagentAdapterState(); await replayExplicitResumeTranscript(); diff --git a/cli/src/claude/utils/claudeSubagentAdapter.test.ts b/cli/src/claude/utils/claudeSubagentAdapter.test.ts index 3f309b413..7c44cc739 100644 --- a/cli/src/claude/utils/claudeSubagentAdapter.test.ts +++ b/cli/src/claude/utils/claudeSubagentAdapter.test.ts @@ -1,8 +1,53 @@ import { describe, expect, it } from 'vitest' -import { extractClaudeSubagentMeta } from './claudeSubagentAdapter' +import { extractClaudeSubagentMeta, resetClaudeSubagentAdapterState } from './claudeSubagentAdapter' describe('claudeSubagentAdapter', () => { + it('retains the Task prompt for later lifecycle title fallback', () => { + resetClaudeSubagentAdapterState() + + expect(extractClaudeSubagentMeta({ + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Investigate test failure' } + }] + } + } as any)).toEqual([{ + kind: 'spawn', + sidechainKey: 'task-1', + prompt: 'Investigate test failure' + }]) + + expect(extractClaudeSubagentMeta({ + type: 'result', + subtype: 'success', + result: 'done', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'task-1' + } as any)).toEqual([ + { + kind: 'status', + sidechainKey: 'task-1', + status: 'completed' + }, + { + kind: 'title', + sidechainKey: 'task-1', + title: 'Investigate test failure' + } + ]) + }) + it('derives normalized Claude subagent spawn metadata from Task tool use', () => { + resetClaudeSubagentAdapterState() + const meta = extractClaudeSubagentMeta({ type: 'assistant', message: { @@ -23,6 +68,8 @@ describe('claudeSubagentAdapter', () => { }) it('preserves the same sidechain key for assistant and user sidechain messages', () => { + resetClaudeSubagentAdapterState() + expect(extractClaudeSubagentMeta({ type: 'assistant', parent_tool_use_id: 'task-1', @@ -48,6 +95,8 @@ describe('claudeSubagentAdapter', () => { }) it('maps Claude-native completion and error results to normalized lifecycle status', () => { + resetClaudeSubagentAdapterState() + expect(extractClaudeSubagentMeta({ type: 'result', subtype: 'success', @@ -80,36 +129,30 @@ describe('claudeSubagentAdapter', () => { }) }) - it('falls back to prompt or session id when no explicit title exists', () => { - expect(extractClaudeSubagentMeta({ - type: 'result', - subtype: 'success', - result: 'Investigate test failure', - num_turns: 1, - total_cost_usd: 0, - duration_ms: 1, - duration_api_ms: 1, - is_error: false, - session_id: 'task-1' - } as any)).toContainEqual({ - kind: 'title', - sidechainKey: 'task-1', - title: 'Investigate test failure' - }) + it('falls back to session id when no prompt is available', () => { + resetClaudeSubagentAdapterState() expect(extractClaudeSubagentMeta({ type: 'result', subtype: 'success', + result: 'done', num_turns: 1, total_cost_usd: 0, duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 'task-2' - } as any)).toContainEqual({ - kind: 'title', - sidechainKey: 'task-2', - title: 'task-2' - }) + } as any)).toEqual([ + { + kind: 'status', + sidechainKey: 'task-2', + status: 'completed' + }, + { + kind: 'title', + sidechainKey: 'task-2', + title: 'task-2' + } + ]) }) }) diff --git a/cli/src/claude/utils/claudeSubagentAdapter.ts b/cli/src/claude/utils/claudeSubagentAdapter.ts index 1efc6282b..bf02711a6 100644 --- a/cli/src/claude/utils/claudeSubagentAdapter.ts +++ b/cli/src/claude/utils/claudeSubagentAdapter.ts @@ -2,6 +2,8 @@ import { createSpawnMeta, createStatusMeta } from '@/subagents/normalize' import type { NormalizedSubagentMeta } from '@/subagents/types' import type { SDKAssistantMessage, SDKMessage } from '@/claude/sdk' +const promptBySidechainKey = new Map() + function asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? value as Record : null } @@ -41,16 +43,23 @@ function extractTitle(message: SDKMessage): string | null { return fallbackPrompt } - const resultText = asString((message as Record).result) - if (resultText) { - return resultText - } - return asString((message as Record).session_id) ?? asString((message as Record).sessionId) ?? null } +function rememberPrompt(sidechainKey: string, prompt: string | undefined): void { + if (!prompt) { + return + } + + promptBySidechainKey.set(sidechainKey, prompt) +} + +export function resetClaudeSubagentAdapterState(): void { + promptBySidechainKey.clear() +} + export function extractClaudeSubagentMeta(message: SDKMessage): NormalizedSubagentMeta[] { const metas: NormalizedSubagentMeta[] = [] @@ -64,9 +73,11 @@ export function extractClaudeSubagentMeta(message: SDKMessage): NormalizedSubage continue } + const prompt = extractPrompt(block.input) + rememberPrompt(block.id, prompt) metas.push(createSpawnMeta({ sidechainKey: block.id, - prompt: extractPrompt(block.input) + prompt })) } } @@ -108,7 +119,7 @@ export function extractClaudeSubagentMeta(message: SDKMessage): NormalizedSubage status: message.subtype === 'success' ? 'completed' : 'error' })) - const title = extractTitle(message) + const title = promptBySidechainKey.get(sidechainKey) ?? extractTitle(message) if (title) { metas.push({ kind: 'title', From 24aad76a304d33d8260b8db842d4eb0ee2f17da4 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 03:37:22 +0800 Subject: [PATCH 61/82] fix: preserve claude replay subagent meta --- cli/src/claude/claudeRemoteLauncher.test.ts | 7 ++++++- cli/src/claude/types.ts | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts index ab4fbf0a8..3aef8f6a7 100644 --- a/cli/src/claude/claudeRemoteLauncher.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { RawJSONLinesSchema } from './types' const harness = vi.hoisted(() => ({ replayMessages: [] as Array>, @@ -42,7 +43,11 @@ vi.mock('./utils/sessionScanner', () => ({ expect(opts.sessionId).toBe(harness.expectedReplaySessionId) expect(opts.replayExistingMessages).toBe(true) for (const message of harness.replayMessages) { - opts.onMessage(message) + const parsed = RawJSONLinesSchema.safeParse(message) + expect(parsed.success).toBe(true) + if (parsed.success) { + opts.onMessage(parsed.data) + } } return { cleanup: async () => {}, diff --git a/cli/src/claude/types.ts b/cli/src/claude/types.ts index 5a61d9482..0827218b7 100644 --- a/cli/src/claude/types.ts +++ b/cli/src/claude/types.ts @@ -32,6 +32,9 @@ const RawJSONLinesBaseSchema = z.object({ version: z.string().optional(), gitBranch: z.string().optional(), timestamp: z.string().optional(), + meta: z.object({ + subagent: z.unknown().optional(), + }).passthrough().optional(), }); // Main schema with validation for the fields used in the app From 77719ebffdfa4598ce61409310fe9666492b5c61 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 03:45:56 +0800 Subject: [PATCH 62/82] fix: persist claude result subagent meta --- cli/src/claude/claudeRemoteLauncher.test.ts | 71 ++++++++++++--------- cli/src/claude/claudeRemoteLauncher.ts | 5 ++ cli/src/claude/utils/sdkToLogConverter.ts | 30 +++++++-- 3 files changed, 72 insertions(+), 34 deletions(-) diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts index 3aef8f6a7..047213119 100644 --- a/cli/src/claude/claudeRemoteLauncher.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -8,6 +8,7 @@ const harness = vi.hoisted(() => ({ remoteCalls: [] as Array>, metadataUpdates: [] as Array>, sessionEvents: [] as Array>, + replayMetaMessages: [] as Array>, rpcHandlers: new Map Promise | unknown>(), expectedReplaySessionId: 'resume-session-123', })) @@ -212,6 +213,7 @@ describe('claudeRemoteLauncher', () => { harness.remoteCalls = [] harness.metadataUpdates = [] harness.sessionEvents = [] + harness.replayMetaMessages = [] harness.rpcHandlers = new Map() harness.expectedReplaySessionId = 'resume-session-123' }) @@ -222,9 +224,9 @@ describe('claudeRemoteLauncher', () => { { type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'existing assistant reply' }] } } ] - const { session, sentClaudeMessages } = createSessionStub() + const { session: liveSession, sentClaudeMessages } = createSessionStub() - await claudeRemoteLauncher(session as never) + await claudeRemoteLauncher(liveSession as never) expect(harness.scannerCalls).toEqual([ expect.objectContaining({ @@ -304,39 +306,47 @@ describe('claudeRemoteLauncher', () => { } ] - harness.replayMessages = [ - { - type: 'assistant', - uuid: 'a1', - meta: { - subagent: { + const { session, sentClaudeMessages } = createSessionStub() + + await claudeRemoteLauncher(session as never) + + const persistedReplayMeta = sentClaudeMessages.find((message) => { + if (message.type !== 'system' || message.isMeta !== true) { + return false + } + + const subagent = (message.meta as Record | undefined)?.subagent + return Array.isArray(subagent) + }) + + expect(persistedReplayMeta).toEqual(expect.objectContaining({ + type: 'system', + isMeta: true, + meta: expect.objectContaining({ + subagent: expect.arrayContaining([ + expect.objectContaining({ + kind: 'status', + sidechainKey: 'task-1', + status: 'completed' + }), + expect.objectContaining({ kind: 'title', sidechainKey: 'task-1', - title: 'Replay title' - } - }, - message: { - role: 'assistant', - content: [{ type: 'text', text: 'replayed assistant reply' }] - } - } - ] + title: 'Investigate test failure' + }) + ]) + }) + })) - const { session, sentClaudeMessages } = createSessionStub() + harness.replayMessages = [persistedReplayMeta as Record] + harness.metadataUpdates = [] + harness.sessionEvents = [] + harness.remoteMessages = [] + const { session: replaySession, sentClaudeMessages: replaySentClaudeMessages } = createSessionStub() - await claudeRemoteLauncher(session as never) + await claudeRemoteLauncher(replaySession as never) expect(harness.metadataUpdates).toEqual([ - expect.objectContaining({ - summary: expect.objectContaining({ - text: 'Replay title' - }) - }), - expect.objectContaining({ - summary: expect.objectContaining({ - text: 'Investigate test failure' - }) - }), expect.objectContaining({ summary: expect.objectContaining({ text: 'Investigate test failure' @@ -349,6 +359,7 @@ describe('claudeRemoteLauncher', () => { sidechainKey: 'task-1', status: 'completed' }) + expect(replaySentClaudeMessages.some((message) => message.type === 'system' && message.isMeta === true)).toBe(false) expect(sentClaudeMessages).toEqual(expect.arrayContaining([ expect.objectContaining({ @@ -361,5 +372,7 @@ describe('claudeRemoteLauncher', () => { }) }) ])) + + expect(harness.scannerCalls).toHaveLength(2) }) }) diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index cf82fcaa7..39483496b 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -270,6 +270,11 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { const logMessage = sdkToLogConverter.convert(msg); if (logMessage) { + if (logMessage.isMeta === true) { + session.client.sendClaudeSessionMessage(logMessage); + return; + } + if (logMessage.type === 'user' && logMessage.message?.content) { const content = Array.isArray(logMessage.message.content) ? logMessage.message.content diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index 6d8ae82af..99595e805 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -15,6 +15,7 @@ import type { import type { RawJSONLines } from '@/claude/types' import type { NormalizedSubagentMeta } from '@/subagents/types' import type { ClaudePermissionMode } from '@hapi/protocol/types' +import { extractClaudeSubagentMeta } from './claudeSubagentAdapter' /** * Context for converting SDK messages to log format @@ -35,7 +36,7 @@ type PermissionResponse = { type LogMessageWithSubagentMeta = RawJSONLines & { meta?: { - subagent?: NormalizedSubagentMeta + subagent?: NormalizedSubagentMeta | NormalizedSubagentMeta[] } } @@ -196,9 +197,28 @@ export class SDKToLogConverter { } case 'result': { - // Result messages are not converted to log messages - // They're SDK-specific messages that indicate session completion - // Not part of the actual conversation log + const subagentMeta = extractClaudeSubagentMeta(sdkMessage) + .filter((meta) => meta.kind === 'status' || meta.kind === 'title') + + if (subagentMeta.length === 0) { + break + } + + logMessage = { + type: 'system', + subtype: 'subagent_meta', + isMeta: true, + uuid, + parentUuid, + cwd: this.context.cwd, + sessionId: this.context.sessionId, + version: this.context.version, + gitBranch: this.context.gitBranch, + timestamp, + meta: { + subagent: subagentMeta + } + } as LogMessageWithSubagentMeta break } @@ -241,7 +261,7 @@ export class SDKToLogConverter { } // Update last UUID for parent tracking - if (logMessage && logMessage.type !== 'summary') { + if (logMessage && logMessage.type !== 'summary' && logMessage.isMeta !== true) { this.lastUuid = uuid } From 0bb503db9be63c2b88d20bcdf9b9d22fa2a9cfc0 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 03:51:29 +0800 Subject: [PATCH 63/82] test: align claude result meta converter coverage --- .../claude/utils/sdkToLogConverter.test.ts | 101 +++++++++++++++++- 1 file changed, 96 insertions(+), 5 deletions(-) diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index c265fa9d6..00782b2b6 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { SDKToLogConverter, convertSDKToLog } from './sdkToLogConverter' import type { SDKMessage, SDKUserMessage, SDKAssistantMessage, SDKSystemMessage, SDKResultMessage } from '@/claude/sdk' import type { ClaudePermissionMode } from '@hapi/protocol/types' +import { extractClaudeSubagentMeta, resetClaudeSubagentAdapterState } from './claudeSubagentAdapter' describe('SDKToLogConverter', () => { let converter: SDKToLogConverter @@ -17,6 +18,7 @@ describe('SDKToLogConverter', () => { } beforeEach(() => { + resetClaudeSubagentAdapterState() converter = new SDKToLogConverter(context) }) @@ -159,7 +161,20 @@ describe('SDKToLogConverter', () => { }) describe('Result messages', () => { - it('should not convert result messages', () => { + it('should convert result messages into replay-safe subagent meta logs', () => { + converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Investigate test failure' } + }] + } + } as SDKAssistantMessage) + const sdkMessage: SDKResultMessage = { type: 'result', subtype: 'success', @@ -178,10 +193,69 @@ describe('SDKToLogConverter', () => { const logMessage = converter.convert(sdkMessage) - expect(logMessage).toBeNull() + expect(logMessage).toEqual(expect.objectContaining({ + type: 'system', + subtype: 'subagent_meta', + isMeta: true, + meta: expect.objectContaining({ + subagent: expect.arrayContaining([ + expect.objectContaining({ + kind: 'status', + sidechainKey: 'result-session', + status: 'completed' + }), + expect.objectContaining({ + kind: 'title', + sidechainKey: 'result-session', + title: 'result-session' + }) + ]) + }) + })) }) - it('should not convert error results', () => { + it('should retain the Task prompt in persisted result meta when available', () => { + const taskSpawnMessage = { + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: { prompt: 'Investigate test failure' } + }] + } + } as SDKAssistantMessage + + extractClaudeSubagentMeta(taskSpawnMessage) + converter.convert(taskSpawnMessage) + + const logMessage = converter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0.1, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'task-2' + } as SDKResultMessage) + + expect(logMessage).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + subagent: expect.arrayContaining([ + expect.objectContaining({ + kind: 'title', + sidechainKey: 'task-2', + title: 'Investigate test failure' + }) + ]) + }) + })) + }) + + it('should convert error results into replay-safe subagent meta logs', () => { const sdkMessage: SDKResultMessage = { type: 'result', subtype: 'error_max_turns', @@ -195,8 +269,25 @@ describe('SDKToLogConverter', () => { const logMessage = converter.convert(sdkMessage) - // Error results are not converted to summaries - expect(logMessage).toBeFalsy() + expect(logMessage).toEqual(expect.objectContaining({ + type: 'system', + subtype: 'subagent_meta', + isMeta: true, + meta: expect.objectContaining({ + subagent: expect.arrayContaining([ + expect.objectContaining({ + kind: 'status', + sidechainKey: 'error-session', + status: 'error' + }), + expect.objectContaining({ + kind: 'title', + sidechainKey: 'error-session', + title: 'error-session' + }) + ]) + }) + })) }) }) From 713568a43c31c8581a04cb41317d41be1f0ae048 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 04:06:44 +0800 Subject: [PATCH 64/82] fix: isolate claude subagent state and events --- cli/src/claude/claudeRemoteLauncher.test.ts | 34 ++-- cli/src/claude/claudeRemoteLauncher.ts | 43 +++-- .../utils/claudeSubagentAdapter.test.ts | 170 +++++++++--------- cli/src/claude/utils/claudeSubagentAdapter.ts | 140 +++++++-------- .../claude/utils/sdkToLogConverter.test.ts | 83 +++++++-- cli/src/claude/utils/sdkToLogConverter.ts | 23 ++- 6 files changed, 285 insertions(+), 208 deletions(-) diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts index 047213119..1fc763bc2 100644 --- a/cli/src/claude/claudeRemoteLauncher.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -310,6 +310,20 @@ describe('claudeRemoteLauncher', () => { await claudeRemoteLauncher(session as never) + expect(harness.metadataUpdates).toEqual([]) + expect(harness.sessionEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'subagent_title_change', + sidechainKey: 'task-1', + title: 'Investigate test failure' + }), + expect.objectContaining({ + type: 'subagent_status_change', + sidechainKey: 'task-1', + status: 'completed' + }) + ])) + const persistedReplayMeta = sentClaudeMessages.find((message) => { if (message.type !== 'system' || message.isMeta !== true) { return false @@ -346,19 +360,19 @@ describe('claudeRemoteLauncher', () => { await claudeRemoteLauncher(replaySession as never) - expect(harness.metadataUpdates).toEqual([ + expect(harness.metadataUpdates).toEqual([]) + expect(harness.sessionEvents).toEqual([ expect.objectContaining({ - summary: expect.objectContaining({ - text: 'Investigate test failure' - }) + type: 'subagent_status_change', + sidechainKey: 'task-1', + status: 'completed' + }), + expect.objectContaining({ + type: 'subagent_title_change', + sidechainKey: 'task-1', + title: 'Investigate test failure' }) ]) - - expect(harness.sessionEvents).toContainEqual({ - type: 'subagent_status_change', - sidechainKey: 'task-1', - status: 'completed' - }) expect(replaySentClaudeMessages.some((message) => message.type === 'system' && message.isMeta === true)).toBe(false) expect(sentClaudeMessages).toEqual(expect.arrayContaining([ diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index 39483496b..3a26264c8 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -13,7 +13,7 @@ import { EnhancedMode } from "./loop"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; import { createSessionScanner } from "./utils/sessionScanner"; import { isClaudeChatVisibleMessage } from "./utils/chatVisibility"; -import { extractClaudeSubagentMeta, resetClaudeSubagentAdapterState } from "./utils/claudeSubagentAdapter"; +import { createClaudeSubagentAdapter } from "./utils/claudeSubagentAdapter"; import type { ClaudePermissionMode } from "@hapi/protocol/types"; import { RemoteLauncherBase, @@ -86,24 +86,22 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { private forwardSubagentMeta(meta: NormalizedSubagentMeta[]): void { for (const item of meta) { if (item.kind === 'spawn') { - this.session.client.updateMetadata((metadata) => ({ - ...metadata, - summary: { - text: item.prompt ?? item.sidechainKey, - updatedAt: Date.now() - } - })); + if (item.prompt) { + this.session.client.sendSessionEvent({ + type: 'subagent_title_change', + sidechainKey: item.sidechainKey, + title: item.prompt + } as any); + } continue; } if (item.kind === 'title') { - this.session.client.updateMetadata((metadata) => ({ - ...metadata, - summary: { - text: item.title ?? item.sidechainKey, - updatedAt: Date.now() - } - })); + this.session.client.sendSessionEvent({ + type: 'subagent_title_change', + sidechainKey: item.sidechainKey, + title: item.title ?? item.sidechainKey + } as any); continue; } @@ -140,11 +138,14 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { messageQueue.releaseToolCall(toolCallId); }); + const subagentAdapter = createClaudeSubagentAdapter(); + const replaySubagentAdapter = createClaudeSubagentAdapter(); + const sdkToLogConverter = new SDKToLogConverter({ sessionId: session.sessionId || 'unknown', cwd: session.path, version: process.env.npm_package_version - }, permissionHandler.getResponses()); + }, permissionHandler.getResponses(), subagentAdapter); const forwardSubagentMeta = (meta: NormalizedSubagentMeta[]): void => { this.forwardSubagentMeta(meta); @@ -175,6 +176,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { : [subagentMeta.subagent]); } + forwardSubagentMeta(replaySubagentAdapter.extract(message as SDKMessage)); + if (message.type === 'summary' || message.isMeta || message.isCompactSummary) { return; } @@ -200,7 +203,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { function onMessage(message: SDKMessage) { formatClaudeMessageForInk(message, messageBuffer); permissionHandler.onMessage(message); - forwardSubagentMeta(extractClaudeSubagentMeta(message)); + const subagentMeta = subagentAdapter.extract(message); + forwardSubagentMeta(subagentMeta); if (message.type === 'assistant') { let umessage = message as SDKAssistantMessage; @@ -268,7 +272,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { } } - const logMessage = sdkToLogConverter.convert(msg); + const logMessage = sdkToLogConverter.convert(msg, subagentMeta); if (logMessage) { if (logMessage.isMeta === true) { session.client.sendClaudeSessionMessage(logMessage); @@ -362,7 +366,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { while (!this.exitReason) { logger.debug('[remote]: launch'); messageBuffer.addMessage('═'.repeat(40), 'status'); - resetClaudeSubagentAdapterState(); + subagentAdapter.reset(); + replaySubagentAdapter.reset(); await replayExplicitResumeTranscript(); diff --git a/cli/src/claude/utils/claudeSubagentAdapter.test.ts b/cli/src/claude/utils/claudeSubagentAdapter.test.ts index 7c44cc739..613c0fc69 100644 --- a/cli/src/claude/utils/claudeSubagentAdapter.test.ts +++ b/cli/src/claude/utils/claudeSubagentAdapter.test.ts @@ -1,54 +1,11 @@ import { describe, expect, it } from 'vitest' -import { extractClaudeSubagentMeta, resetClaudeSubagentAdapterState } from './claudeSubagentAdapter' +import { createClaudeSubagentAdapter } from './claudeSubagentAdapter' describe('claudeSubagentAdapter', () => { - it('retains the Task prompt for later lifecycle title fallback', () => { - resetClaudeSubagentAdapterState() - - expect(extractClaudeSubagentMeta({ - type: 'assistant', - message: { - content: [{ - type: 'tool_use', - id: 'task-1', - name: 'Task', - input: { prompt: 'Investigate test failure' } - }] - } - } as any)).toEqual([{ - kind: 'spawn', - sidechainKey: 'task-1', - prompt: 'Investigate test failure' - }]) - - expect(extractClaudeSubagentMeta({ - type: 'result', - subtype: 'success', - result: 'done', - num_turns: 1, - total_cost_usd: 0, - duration_ms: 1, - duration_api_ms: 1, - is_error: false, - session_id: 'task-1' - } as any)).toEqual([ - { - kind: 'status', - sidechainKey: 'task-1', - status: 'completed' - }, - { - kind: 'title', - sidechainKey: 'task-1', - title: 'Investigate test failure' - } - ]) - }) - it('derives normalized Claude subagent spawn metadata from Task tool use', () => { - resetClaudeSubagentAdapterState() + const adapter = createClaudeSubagentAdapter() - const meta = extractClaudeSubagentMeta({ + expect(adapter.extract({ type: 'assistant', message: { content: [{ @@ -58,9 +15,7 @@ describe('claudeSubagentAdapter', () => { input: { prompt: 'Investigate test failure' } }] } - } as any) - - expect(meta).toEqual([{ + } as any)).toEqual([{ kind: 'spawn', sidechainKey: 'task-1', prompt: 'Investigate test failure' @@ -68,9 +23,9 @@ describe('claudeSubagentAdapter', () => { }) it('preserves the same sidechain key for assistant and user sidechain messages', () => { - resetClaudeSubagentAdapterState() + const adapter = createClaudeSubagentAdapter() - expect(extractClaudeSubagentMeta({ + expect(adapter.extract({ type: 'assistant', parent_tool_use_id: 'task-1', message: { @@ -81,7 +36,7 @@ describe('claudeSubagentAdapter', () => { sidechainKey: 'task-1' }]) - expect(extractClaudeSubagentMeta({ + expect(adapter.extract({ type: 'user', parent_tool_use_id: 'task-1', message: { @@ -94,54 +49,68 @@ describe('claudeSubagentAdapter', () => { }]) }) - it('maps Claude-native completion and error results to normalized lifecycle status', () => { - resetClaudeSubagentAdapterState() + it('retains the Task prompt for later lifecycle title fallback', () => { + const adapter = createClaudeSubagentAdapter() - expect(extractClaudeSubagentMeta({ + adapter.extract({ + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Investigate test failure' } + }] + } + } as any) + + expect(adapter.extract({ type: 'result', subtype: 'success', - result: 'done', num_turns: 1, total_cost_usd: 0, duration_ms: 1, duration_api_ms: 1, is_error: false, - session_id: 'task-1' - } as any)).toContainEqual({ - kind: 'status', - sidechainKey: 'task-1', - status: 'completed' - }) - - expect(extractClaudeSubagentMeta({ - type: 'result', - subtype: 'error_during_execution', - num_turns: 1, - total_cost_usd: 0, - duration_ms: 1, - duration_api_ms: 1, - is_error: true, - session_id: 'task-1' - } as any)).toContainEqual({ - kind: 'status', - sidechainKey: 'task-1', - status: 'error' - }) + session_id: 'claude-session-1' + } as any)).toEqual([ + { + kind: 'status', + sidechainKey: 'task-1', + status: 'completed' + }, + { + kind: 'title', + sidechainKey: 'task-1', + title: 'Investigate test failure' + } + ]) }) - it('falls back to session id when no prompt is available', () => { - resetClaudeSubagentAdapterState() + it('falls back to session id as title text when the task prompt is unavailable', () => { + const adapter = createClaudeSubagentAdapter() - expect(extractClaudeSubagentMeta({ + adapter.extract({ + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: {} + }] + } + } as any) + + expect(adapter.extract({ type: 'result', subtype: 'success', - result: 'done', num_turns: 1, total_cost_usd: 0, duration_ms: 1, duration_api_ms: 1, is_error: false, - session_id: 'task-2' + session_id: 'claude-session-2' } as any)).toEqual([ { kind: 'status', @@ -151,8 +120,43 @@ describe('claudeSubagentAdapter', () => { { kind: 'title', sidechainKey: 'task-2', - title: 'task-2' + title: 'claude-session-2' } ]) }) + + it('does not guess a sidechain key from result.session_id when multiple Task sidechains are active', () => { + const adapter = createClaudeSubagentAdapter() + + adapter.extract({ + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Task 1' } + }, + { + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: { prompt: 'Task 2' } + } + ] + } + } as any) + + expect(adapter.extract({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'claude-session-3' + } as any)).toEqual([]) + }) }) diff --git a/cli/src/claude/utils/claudeSubagentAdapter.ts b/cli/src/claude/utils/claudeSubagentAdapter.ts index bf02711a6..fa360aac3 100644 --- a/cli/src/claude/utils/claudeSubagentAdapter.ts +++ b/cli/src/claude/utils/claudeSubagentAdapter.ts @@ -2,8 +2,6 @@ import { createSpawnMeta, createStatusMeta } from '@/subagents/normalize' import type { NormalizedSubagentMeta } from '@/subagents/types' import type { SDKAssistantMessage, SDKMessage } from '@/claude/sdk' -const promptBySidechainKey = new Map() - function asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? value as Record : null } @@ -26,90 +24,75 @@ function extractPrompt(input: unknown): string | undefined { ?? undefined } -function getSidechainKey(message: SDKMessage): string | null { +function getParentToolUseId(message: SDKMessage): string | null { return asString((message as SDKAssistantMessage).parent_tool_use_id) ?? asString((message as Record).parentToolUseId) ?? null } -function extractTitle(message: SDKMessage): string | null { - const explicitTitle = asString((message as Record).title) - if (explicitTitle) { - return explicitTitle - } +export class ClaudeSubagentAdapter { + private readonly promptBySidechainKey = new Map() + private readonly activeTaskSidechainKeys = new Set() - const fallbackPrompt = asString((message as Record).prompt) - if (fallbackPrompt) { - return fallbackPrompt + reset(): void { + this.promptBySidechainKey.clear() + this.activeTaskSidechainKeys.clear() } - return asString((message as Record).session_id) - ?? asString((message as Record).sessionId) - ?? null -} - -function rememberPrompt(sidechainKey: string, prompt: string | undefined): void { - if (!prompt) { - return - } - - promptBySidechainKey.set(sidechainKey, prompt) -} - -export function resetClaudeSubagentAdapterState(): void { - promptBySidechainKey.clear() -} - -export function extractClaudeSubagentMeta(message: SDKMessage): NormalizedSubagentMeta[] { - const metas: NormalizedSubagentMeta[] = [] - - if (message.type === 'assistant') { - const assistantMessage = message as SDKAssistantMessage - const content = assistantMessage.message.content - - if (Array.isArray(content)) { - for (const block of content) { - if (block.type !== 'tool_use' || block.name !== 'Task' || !block.id) { - continue + extract(message: SDKMessage): NormalizedSubagentMeta[] { + const metas: NormalizedSubagentMeta[] = [] + + if (message.type === 'assistant') { + const assistantMessage = message as SDKAssistantMessage + const content = assistantMessage.message.content + + if (Array.isArray(content)) { + for (const block of content) { + if (block.type !== 'tool_use' || block.name !== 'Task' || !block.id) { + continue + } + + const prompt = extractPrompt(block.input) + if (prompt) { + this.promptBySidechainKey.set(block.id, prompt) + } + this.activeTaskSidechainKeys.add(block.id) + metas.push(createSpawnMeta({ + sidechainKey: block.id, + prompt + })) } + } - const prompt = extractPrompt(block.input) - rememberPrompt(block.id, prompt) - metas.push(createSpawnMeta({ - sidechainKey: block.id, - prompt - })) + const sidechainKey = getParentToolUseId(message) + if (sidechainKey) { + metas.push({ + kind: 'message', + sidechainKey + }) } - } - const sidechainKey = getSidechainKey(message) - if (sidechainKey) { - metas.push({ - kind: 'message', - sidechainKey - }) + return metas } - return metas - } + if (message.type === 'user') { + const sidechainKey = getParentToolUseId(message) + if (sidechainKey) { + metas.push({ + kind: 'message', + sidechainKey + }) + } - if (message.type === 'user') { - const sidechainKey = getSidechainKey(message) - if (sidechainKey) { - metas.push({ - kind: 'message', - sidechainKey - }) + return metas } - return metas - } - - if (message.type === 'result') { - const sidechainKey = asString((message as Record).parent_tool_use_id) - ?? asString((message as Record).session_id) - ?? asString((message as Record).sessionId) + if (message.type !== 'result') { + return metas + } + const explicitParentToolUseId = getParentToolUseId(message) + const sidechainKey = explicitParentToolUseId ?? this.getSafeImplicitResultSidechainKey() if (!sidechainKey) { return metas } @@ -119,7 +102,10 @@ export function extractClaudeSubagentMeta(message: SDKMessage): NormalizedSubage status: message.subtype === 'success' ? 'completed' : 'error' })) - const title = promptBySidechainKey.get(sidechainKey) ?? extractTitle(message) + const title = this.promptBySidechainKey.get(sidechainKey) + ?? asString((message as Record).session_id) + ?? asString((message as Record).sessionId) + if (title) { metas.push({ kind: 'title', @@ -127,7 +113,21 @@ export function extractClaudeSubagentMeta(message: SDKMessage): NormalizedSubage title }) } + + this.activeTaskSidechainKeys.delete(sidechainKey) + + return metas + } + + private getSafeImplicitResultSidechainKey(): string | null { + if (this.activeTaskSidechainKeys.size !== 1) { + return null + } + + return this.activeTaskSidechainKeys.values().next().value ?? null } +} - return metas +export function createClaudeSubagentAdapter(): ClaudeSubagentAdapter { + return new ClaudeSubagentAdapter() } diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index 00782b2b6..89292155e 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect, beforeEach } from 'vitest' import { SDKToLogConverter, convertSDKToLog } from './sdkToLogConverter' import type { SDKMessage, SDKUserMessage, SDKAssistantMessage, SDKSystemMessage, SDKResultMessage } from '@/claude/sdk' import type { ClaudePermissionMode } from '@hapi/protocol/types' -import { extractClaudeSubagentMeta, resetClaudeSubagentAdapterState } from './claudeSubagentAdapter' describe('SDKToLogConverter', () => { let converter: SDKToLogConverter @@ -18,7 +17,6 @@ describe('SDKToLogConverter', () => { } beforeEach(() => { - resetClaudeSubagentAdapterState() converter = new SDKToLogConverter(context) }) @@ -201,21 +199,21 @@ describe('SDKToLogConverter', () => { subagent: expect.arrayContaining([ expect.objectContaining({ kind: 'status', - sidechainKey: 'result-session', + sidechainKey: 'task-1', status: 'completed' }), expect.objectContaining({ kind: 'title', - sidechainKey: 'result-session', - title: 'result-session' + sidechainKey: 'task-1', + title: 'Investigate test failure' }) ]) }) })) }) - it('should retain the Task prompt in persisted result meta when available', () => { - const taskSpawnMessage = { + it('should fall back to session id as title text when a unique task has no prompt', () => { + converter.convert({ type: 'assistant', message: { role: 'assistant', @@ -223,13 +221,10 @@ describe('SDKToLogConverter', () => { type: 'tool_use', id: 'task-2', name: 'Task', - input: { prompt: 'Investigate test failure' } + input: {} }] } - } as SDKAssistantMessage - - extractClaudeSubagentMeta(taskSpawnMessage) - converter.convert(taskSpawnMessage) + } as SDKAssistantMessage) const logMessage = converter.convert({ type: 'result', @@ -239,23 +234,77 @@ describe('SDKToLogConverter', () => { duration_ms: 1, duration_api_ms: 1, is_error: false, - session_id: 'task-2' + session_id: 'claude-session-2' } as SDKResultMessage) expect(logMessage).toEqual(expect.objectContaining({ meta: expect.objectContaining({ subagent: expect.arrayContaining([ + expect.objectContaining({ + kind: 'status', + sidechainKey: 'task-2', + status: 'completed' + }), expect.objectContaining({ kind: 'title', sidechainKey: 'task-2', - title: 'Investigate test failure' + title: 'claude-session-2' }) ]) }) })) }) + it('should not convert result messages when the sidechain key cannot be derived safely', () => { + converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Task 1' } + }, + { + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: { prompt: 'Task 2' } + } + ] + } + } as SDKAssistantMessage) + + const logMessage = converter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0.1, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'claude-session-3' + } as SDKResultMessage) + + expect(logMessage).toBeNull() + }) + it('should convert error results into replay-safe subagent meta logs', () => { + converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-error', + name: 'Task', + input: { prompt: 'Investigate failure' } + }] + } + } as SDKAssistantMessage) + const sdkMessage: SDKResultMessage = { type: 'result', subtype: 'error_max_turns', @@ -277,13 +326,13 @@ describe('SDKToLogConverter', () => { subagent: expect.arrayContaining([ expect.objectContaining({ kind: 'status', - sidechainKey: 'error-session', + sidechainKey: 'task-error', status: 'error' }), expect.objectContaining({ kind: 'title', - sidechainKey: 'error-session', - title: 'error-session' + sidechainKey: 'task-error', + title: 'Investigate failure' }) ]) }) diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index 99595e805..6326dd926 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -15,7 +15,7 @@ import type { import type { RawJSONLines } from '@/claude/types' import type { NormalizedSubagentMeta } from '@/subagents/types' import type { ClaudePermissionMode } from '@hapi/protocol/types' -import { extractClaudeSubagentMeta } from './claudeSubagentAdapter' +import { createClaudeSubagentAdapter, type ClaudeSubagentAdapter } from './claudeSubagentAdapter' /** * Context for converting SDK messages to log format @@ -65,10 +65,12 @@ export class SDKToLogConverter { private context: ConversionContext private responses?: Map private sidechainLastUUID = new Map(); + private readonly subagentAdapter: ClaudeSubagentAdapter constructor( context: Omit, - responses?: Map + responses?: Map, + subagentAdapter: ClaudeSubagentAdapter = createClaudeSubagentAdapter() ) { this.context = { ...context, @@ -77,6 +79,7 @@ export class SDKToLogConverter { parentUuid: null } this.responses = responses + this.subagentAdapter = subagentAdapter } /** @@ -97,12 +100,17 @@ export class SDKToLogConverter { /** * Convert SDK message to log format */ - convert(sdkMessage: SDKMessage): RawJSONLines | null { + convert(sdkMessage: SDKMessage, subagentMeta?: NormalizedSubagentMeta[]): RawJSONLines | null { const uuid = randomUUID() const timestamp = new Date().toISOString() let parentUuid = this.lastUuid; let isSidechain = false; - const subagentKey = (sdkMessage as SDKAssistantMessage).parent_tool_use_id ?? (sdkMessage as any).parentToolUseId; + const resolvedSubagentMeta = subagentMeta ?? this.subagentAdapter.extract(sdkMessage) + const messageMeta = resolvedSubagentMeta.find((meta) => meta.kind === 'message') + const persistedResultMeta = resolvedSubagentMeta.filter((meta) => meta.kind === 'status' || meta.kind === 'title') + const subagentKey = messageMeta?.sidechainKey + ?? (sdkMessage as SDKAssistantMessage).parent_tool_use_id + ?? (sdkMessage as any).parentToolUseId; if (sdkMessage.parent_tool_use_id) { isSidechain = true; parentUuid = this.sidechainLastUUID.get((sdkMessage as any).parent_tool_use_id) ?? null; @@ -197,10 +205,7 @@ export class SDKToLogConverter { } case 'result': { - const subagentMeta = extractClaudeSubagentMeta(sdkMessage) - .filter((meta) => meta.kind === 'status' || meta.kind === 'title') - - if (subagentMeta.length === 0) { + if (persistedResultMeta.length === 0) { break } @@ -216,7 +221,7 @@ export class SDKToLogConverter { gitBranch: this.context.gitBranch, timestamp, meta: { - subagent: subagentMeta + subagent: persistedResultMeta } } as LogMessageWithSubagentMeta break From 3c8b5181c6b53848c58d5ce00ee29ee604423a44 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 04:21:04 +0800 Subject: [PATCH 65/82] feat: link claude child transcripts into sidechains --- cli/src/claude/claudeRemoteLauncher.test.ts | 33 ++- cli/src/claude/utils/sessionScanner.test.ts | 264 ++++++++++++++++++- cli/src/claude/utils/sessionScanner.ts | 276 +++++++++++++++++++- 3 files changed, 563 insertions(+), 10 deletions(-) diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts index 1fc763bc2..5e4f777ac 100644 --- a/cli/src/claude/claudeRemoteLauncher.test.ts +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -221,7 +221,22 @@ describe('claudeRemoteLauncher', () => { it('replays transcript history before live remote Claude messages', async () => { harness.replayMessages = [ { type: 'user', uuid: 'u1', message: { content: 'existing user prompt' } }, - { type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'existing assistant reply' }] } } + { type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'existing assistant reply' }] } }, + { + type: 'assistant', + uuid: 'child-a1', + isSidechain: true, + sessionId: 'child-session-1', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-1' + } + }, + message: { + content: [{ type: 'text', text: 'linked child replay reply' }] + } + } ] const { session: liveSession, sentClaudeMessages } = createSessionStub() @@ -234,7 +249,7 @@ describe('claudeRemoteLauncher', () => { replayExistingMessages: true }) ]) - expect(sentClaudeMessages.slice(0, 3)).toEqual([ + expect(sentClaudeMessages.slice(0, 4)).toEqual([ expect.objectContaining({ type: 'user', message: expect.objectContaining({ content: 'existing user prompt' }) @@ -245,6 +260,20 @@ describe('claudeRemoteLauncher', () => { content: [{ type: 'text', text: 'existing assistant reply' }] }) }), + expect.objectContaining({ + type: 'assistant', + isSidechain: true, + sessionId: 'child-session-1', + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + kind: 'message', + sidechainKey: 'task-1' + }) + }), + message: expect.objectContaining({ + content: [{ type: 'text', text: 'linked child replay reply' }] + }) + }), expect.objectContaining({ type: 'assistant', message: expect.objectContaining({ diff --git a/cli/src/claude/utils/sessionScanner.test.ts b/cli/src/claude/utils/sessionScanner.test.ts index 709b2152b..02f13133d 100644 --- a/cli/src/claude/utils/sessionScanner.test.ts +++ b/cli/src/claude/utils/sessionScanner.test.ts @@ -6,6 +6,32 @@ import { join } from 'node:path' import { tmpdir, homedir } from 'node:os' import { existsSync } from 'node:fs' +function getMessageText(message: RawJSONLines): string | null { + if (message.type === 'summary') { + return message.summary + } + + if (!message.message) { + return null + } + + const content = message.message.content + if (typeof content === 'string') { + return content + } + + if (!Array.isArray(content)) { + return null + } + + return content + .map((block) => block && typeof block === 'object' && 'text' in block && typeof block.text === 'string' + ? block.text + : null) + .filter((value): value is string => value !== null) + .join(' ') +} + describe('sessionScanner', () => { let testDir: string let projectDir: string @@ -37,6 +63,13 @@ describe('sessionScanner', () => { await rm(projectDir, { recursive: true, force: true }) } }) + + async function writeSessionLog(sessionId: string, messages: Array>): Promise { + await writeFile( + join(projectDir, `${sessionId}.jsonl`), + `${messages.map((message) => JSON.stringify(message)).join('\n')}\n` + ) + } it('should process initial session and resumed session correctly', async () => { // TEST SCENARIO: @@ -144,4 +177,233 @@ describe('sessionScanner', () => { expect(content).toContain('readme.md') } }) -}) \ No newline at end of file + + it('links child Claude transcript messages to the parent Task sidechain', async () => { + await writeSessionLog('parent-session', [ + { + type: 'assistant', + uuid: 'parent-task-call', + parentUuid: null, + sessionId: 'parent-session', + timestamp: '2026-04-04T00:00:00.000Z', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { + prompt: 'Investigate flaky test' + } + }] + } + } + ]) + + await writeSessionLog('child-session', [ + { + type: 'user', + uuid: 'child-user-1', + parentUuid: null, + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:01.000Z', + message: { + role: 'user', + content: 'Investigate flaky test' + } + }, + { + type: 'assistant', + uuid: 'child-assistant-1', + parentUuid: 'child-user-1', + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:02.000Z', + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Root cause is a stale retry cache key.' + }] + } + } + ]) + + scanner = await createSessionScanner({ + sessionId: 'parent-session', + workingDirectory: testDir, + onMessage: (message) => collectedMessages.push(message), + replayExistingMessages: true + }) + + await scanner.cleanup() + scanner = null + + const linkedMessages = collectedMessages.filter((message) => { + const subagent = message.meta?.subagent as Record | undefined + return subagent?.sidechainKey === 'task-1' + }) + + expect(linkedMessages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'user', + sessionId: 'child-session', + isSidechain: true, + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + kind: 'message', + sidechainKey: 'task-1' + }) + }) + }), + expect.objectContaining({ + type: 'assistant', + sessionId: 'child-session', + isSidechain: true, + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + kind: 'message', + sidechainKey: 'task-1' + }) + }) + }) + ])) + }) + + it('dedupes replayed linked child transcript messages that were already materialized in the parent transcript', async () => { + await writeSessionLog('parent-session', [ + { + type: 'assistant', + uuid: 'parent-task-call', + parentUuid: null, + sessionId: 'parent-session', + timestamp: '2026-04-04T00:00:00.000Z', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { + prompt: 'Investigate flaky test' + } + }] + } + }, + { + type: 'user', + uuid: 'materialized-child-user', + parentUuid: null, + sessionId: 'parent-session', + isSidechain: true, + timestamp: '2026-04-04T00:00:01.000Z', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-1' + } + }, + message: { + role: 'user', + content: 'Investigate flaky test' + } + }, + { + type: 'assistant', + uuid: 'materialized-child-assistant', + parentUuid: 'materialized-child-user', + sessionId: 'parent-session', + isSidechain: true, + timestamp: '2026-04-04T00:00:02.000Z', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-1' + } + }, + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Working on it now.' + }] + } + } + ]) + + await writeSessionLog('child-session', [ + { + type: 'user', + uuid: 'child-user-1', + parentUuid: null, + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:03.000Z', + message: { + role: 'user', + content: 'Investigate flaky test' + } + }, + { + type: 'assistant', + uuid: 'child-assistant-1', + parentUuid: 'child-user-1', + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:04.000Z', + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Working on it now.' + }] + } + }, + { + type: 'assistant', + uuid: 'child-assistant-2', + parentUuid: 'child-assistant-1', + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:05.000Z', + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Found the failing fixture.' + }] + } + } + ]) + + scanner = await createSessionScanner({ + sessionId: 'parent-session', + workingDirectory: testDir, + onMessage: (message) => collectedMessages.push(message), + replayExistingMessages: true + }) + + await scanner.cleanup() + scanner = null + + const taskMessages = collectedMessages.filter((message) => { + const subagent = message.meta?.subagent as Record | undefined + return subagent?.sidechainKey === 'task-1' + }) + + expect(taskMessages.filter((message) => getMessageText(message) === 'Investigate flaky test')).toHaveLength(1) + expect(taskMessages.filter((message) => getMessageText(message) === 'Working on it now.')).toHaveLength(1) + expect(taskMessages).toContainEqual(expect.objectContaining({ + type: 'assistant', + sessionId: 'child-session', + isSidechain: true, + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + sidechainKey: 'task-1' + }) + }), + message: expect.objectContaining({ + content: [{ + type: 'text', + text: 'Found the failing fixture.' + }] + }) + })) + }) +}) diff --git a/cli/src/claude/utils/sessionScanner.ts b/cli/src/claude/utils/sessionScanner.ts index 01e4e6de7..1daa5d5e0 100644 --- a/cli/src/claude/utils/sessionScanner.ts +++ b/cli/src/claude/utils/sessionScanner.ts @@ -1,6 +1,6 @@ import { RawJSONLines, RawJSONLinesSchema } from "../types"; import { basename, join } from "node:path"; -import { readFile } from "node:fs/promises"; +import { readFile, readdir } from "node:fs/promises"; import { logger } from "@/ui/logger"; import { getProjectPath } from "./path"; import { BaseSessionScanner, SessionFileScanEntry, SessionFileScanResult, SessionFileScanStats } from "@/modules/common/session/BaseSessionScanner"; @@ -43,6 +43,10 @@ export async function createSessionScanner(opts: { export type SessionScanner = ReturnType; +type ClaudeLinkedChild = { + sessionId: string + sidechainKey: string +} class ClaudeSessionScanner extends BaseSessionScanner { private readonly projectDir: string; @@ -52,6 +56,9 @@ class ClaudeSessionScanner extends BaseSessionScanner { private currentSessionId: string | null; private readonly scannedSessions = new Set(); private readonly replayExistingMessages: boolean; + private readonly linkedChildSessions = new Map(); + private readonly sidechainKeyByPrompt = new Map(); + private readonly knownEventKeys = new Set(); constructor(opts: { sessionId: string | null; @@ -93,9 +100,13 @@ class ClaudeSessionScanner extends BaseSessionScanner { } const sessionFile = this.sessionFilePath(this.currentSessionId); const { events, totalLines } = await readSessionLog(sessionFile, 0); + this.captureTaskSidechainCandidates(events.map((entry) => entry.event)); logger.debug(`[SESSION_SCANNER] Marking ${events.length} existing messages as processed from session ${this.currentSessionId}`); - const keys = events.map((entry) => messageKey(entry.event)); - this.seedProcessedKeys(keys); + const keys = events.map((entry) => this.generateEventKey(entry.event, { + filePath: sessionFile, + lineIndex: entry.lineIndex + })); + this.seedKnownProcessedKeys(keys); this.setCursor(sessionFile, totalLines); } @@ -111,6 +122,9 @@ class ClaudeSessionScanner extends BaseSessionScanner { if (this.currentSessionId && !this.pendingSessions.has(this.currentSessionId)) { files.add(this.sessionFilePath(this.currentSessionId)); } + for (const linkedChild of this.linkedChildSessions.values()) { + files.add(this.sessionFilePath(linkedChild.sessionId)); + } for (const watched of this.getWatchedFiles()) { files.add(watched); } @@ -123,22 +137,36 @@ class ClaudeSessionScanner extends BaseSessionScanner { this.scannedSessions.add(sessionId); } const { events, totalLines } = await readSessionLog(filePath, cursor); + const linkedChild = sessionId ? this.linkedChildSessions.get(sessionId) : undefined; return { - events, + events: linkedChild + ? events.map((entry) => ({ + ...entry, + event: linkChildMessage(entry.event, linkedChild.sidechainKey) + })) + : events, nextCursor: totalLines }; } - protected generateEventKey(event: RawJSONLines): string { - return messageKey(event); + protected generateEventKey(event: RawJSONLines, context: { filePath: string; lineIndex?: number }): string { + const sessionId = sessionIdFromPath(context.filePath); + const linkedChild = sessionId ? this.linkedChildSessions.get(sessionId) : undefined; + return messageKey(event, linkedChild?.sidechainKey ?? null); } protected async handleFileScan(stats: SessionFileScanStats): Promise { + this.captureTaskSidechainCandidates(stats.events); + this.seedKnownProcessedKeys(stats.entries.map((entry) => this.generateEventKey(entry.event, { + filePath: stats.filePath, + lineIndex: entry.lineIndex + }))); for (const message of stats.events) { const id = message.type === 'summary' ? message.leafUuid : message.uuid; logger.debug(`[SESSION_SCANNER] Sending new message: type=${message.type}, uuid=${id}`); this.onMessage(message); } + await this.linkChildSessionsFromPrompts(); if (stats.parsedCount > 0) { const sessionId = sessionIdFromPath(stats.filePath) ?? 'unknown'; logger.debug(`[SESSION_SCANNER] Session ${sessionId}: found=${stats.parsedCount}, skipped=${stats.skippedCount}, sent=${stats.newCount}`); @@ -157,13 +185,120 @@ class ClaudeSessionScanner extends BaseSessionScanner { private sessionFilePath(sessionId: string): string { return join(this.projectDir, `${sessionId}.jsonl`); } + + private seedKnownProcessedKeys(keys: Iterable): void { + for (const key of keys) { + this.knownEventKeys.add(key); + } + this.seedProcessedKeys(keys); + } + + private captureTaskSidechainCandidates(messages: RawJSONLines[]): void { + for (const message of messages) { + if (message.type !== 'assistant' || !message.message || !Array.isArray(message.message.content)) { + continue; + } + + for (const block of message.message.content) { + if (!block || typeof block !== 'object') { + continue; + } + if (block.type !== 'tool_use' || block.name !== 'Task' || typeof block.id !== 'string') { + continue; + } + + const prompt = extractPrompt(block.input); + if (!prompt) { + continue; + } + + this.sidechainKeyByPrompt.set(normalizePrompt(prompt), block.id); + } + } + } + + private async linkChildSessionsFromPrompts(): Promise { + if (this.sidechainKeyByPrompt.size === 0) { + return; + } + + const projectEntries = await readdir(this.projectDir, { withFileTypes: true }).catch(() => []); + for (const entry of projectEntries) { + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) { + continue; + } + + const childSessionId = entry.name.slice(0, -'.jsonl'.length); + if (!childSessionId || childSessionId === this.currentSessionId) { + continue; + } + if (this.pendingSessions.has(childSessionId) || this.finishedSessions.has(childSessionId)) { + continue; + } + if (this.linkedChildSessions.has(childSessionId)) { + continue; + } + + const childFilePath = this.sessionFilePath(childSessionId); + const { events, totalLines } = await readSessionLog(childFilePath, 0); + const prompt = extractFirstUserPrompt(events.map((scanEntry) => scanEntry.event)); + if (!prompt) { + continue; + } + + const sidechainKey = this.sidechainKeyByPrompt.get(normalizePrompt(prompt)); + if (!sidechainKey) { + continue; + } + + const linkedChild: ClaudeLinkedChild = { + sessionId: childSessionId, + sidechainKey + }; + this.linkedChildSessions.set(childSessionId, linkedChild); + this.ensureWatcher(childFilePath); + + const decoratedEntries = events.map((scanEntry) => ({ + ...scanEntry, + event: linkChildMessage(scanEntry.event, sidechainKey) + })); + + const newMessages: RawJSONLines[] = []; + const newKeys: string[] = []; + for (const decoratedEntry of decoratedEntries) { + const key = this.generateEventKey(decoratedEntry.event, { + filePath: childFilePath, + lineIndex: decoratedEntry.lineIndex + }); + if (this.knownEventKeys.has(key)) { + continue; + } + this.knownEventKeys.add(key); + newKeys.push(key); + newMessages.push(decoratedEntry.event); + } + + for (const message of newMessages) { + const id = message.type === 'summary' ? message.leafUuid : message.uuid; + logger.debug(`[SESSION_SCANNER] Sending linked child message: type=${message.type}, uuid=${id}, sidechain=${sidechainKey}`); + this.onMessage(message); + } + + this.seedProcessedKeys(newKeys); + this.setCursor(childFilePath, totalLines); + } + } } // // Helpers // -function messageKey(message: RawJSONLines): string { +function messageKey(message: RawJSONLines, linkedSidechainKey: string | null = null): string { + const sidechainKey = linkedSidechainKey ?? extractSidechainKey(message); + if (sidechainKey) { + return `sidechain:${sidechainKey}:${stableStringify(sidechainMessageFingerprint(message))}`; + } if (message.type === 'user') { return message.uuid; } else if (message.type === 'assistant') { @@ -233,3 +368,130 @@ function sessionIdFromPath(filePath: string): string | null { } return base.slice(0, -'.jsonl'.length); } + +function extractPrompt(input: unknown): string | null { + if (typeof input === 'string') { + return input; + } + if (!input || typeof input !== 'object') { + return null; + } + + const record = input as Record; + for (const key of ['prompt', 'title', 'message', 'text', 'content'] as const) { + const value = record[key]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + + return null; +} + +function extractFirstUserPrompt(messages: RawJSONLines[]): string | null { + for (const message of messages) { + if (message.type !== 'user') { + continue; + } + + const content = message.message.content; + if (typeof content === 'string' && content.length > 0) { + return content; + } + + if (!Array.isArray(content)) { + continue; + } + + const textBlocks = content + .map((block) => block && typeof block === 'object' && 'type' in block && block.type === 'text' && typeof block.text === 'string' + ? block.text + : null) + .filter((value): value is string => value !== null); + + if (textBlocks.length > 0) { + return textBlocks.join(' '); + } + } + + return null; +} + +function normalizePrompt(prompt: string): string { + return prompt.trim().replace(/\s+/g, ' '); +} + +function linkChildMessage(message: RawJSONLines, sidechainKey: string): RawJSONLines { + return { + ...message, + isSidechain: true, + meta: { + ...(message.meta ?? {}), + subagent: { + kind: 'message', + sidechainKey + } + } + }; +} + +function extractSidechainKey(message: RawJSONLines): string | null { + const subagent = message.meta?.subagent; + if (!subagent) { + return null; + } + if (Array.isArray(subagent)) { + for (const item of subagent) { + if (item && typeof item === 'object' && typeof (item as { sidechainKey?: unknown }).sidechainKey === 'string') { + return (item as { sidechainKey: string }).sidechainKey; + } + } + return null; + } + if (typeof subagent === 'object' && typeof (subagent as { sidechainKey?: unknown }).sidechainKey === 'string') { + return (subagent as { sidechainKey: string }).sidechainKey; + } + return null; +} + +function sidechainMessageFingerprint(message: RawJSONLines): Record { + if (message.type === 'summary') { + return { + type: message.type, + summary: message.summary, + leafUuid: message.leafUuid + }; + } + + if (message.type === 'system') { + return { + type: message.type, + subtype: message.subtype, + isMeta: message.isMeta === true, + error: message.error ?? null, + meta: message.meta?.subagent ?? null + }; + } + + return { + type: message.type, + content: message.message.content, + toolUseResult: message.type === 'user' ? message.toolUseResult ?? null : null + }; +} + +function stableStringify(value: unknown): string { + if (value === null || value === undefined) { + return JSON.stringify(value); + } + if (typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]`; + } + + const record = value as Record; + const keys = Object.keys(record).sort(); + return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(',')}}`; +} From ec155d4c59efc0ef33ef7d2cb3c431c9b494ea5e Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 08:56:31 +0800 Subject: [PATCH 66/82] fix: tighten claude child transcript linking --- cli/src/claude/utils/sessionScanner.test.ts | 194 +++++++++++++++++++ cli/src/claude/utils/sessionScanner.ts | 197 +++++++++++++++++--- 2 files changed, 369 insertions(+), 22 deletions(-) diff --git a/cli/src/claude/utils/sessionScanner.test.ts b/cli/src/claude/utils/sessionScanner.test.ts index 02f13133d..d960b5e05 100644 --- a/cli/src/claude/utils/sessionScanner.test.ts +++ b/cli/src/claude/utils/sessionScanner.test.ts @@ -406,4 +406,198 @@ describe('sessionScanner', () => { }) })) }) + + it('does not guess a child link when repeated Task prompts are ambiguous', async () => { + await writeSessionLog('parent-session', [ + { + type: 'assistant', + uuid: 'parent-task-call-1', + parentUuid: null, + sessionId: 'parent-session', + timestamp: '2026-04-04T00:00:00.000Z', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { + prompt: 'Investigate flaky test' + } + }] + } + }, + { + type: 'assistant', + uuid: 'parent-task-call-2', + parentUuid: 'parent-task-call-1', + sessionId: 'parent-session', + timestamp: '2026-04-04T00:00:01.000Z', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: { + prompt: 'Investigate flaky test' + } + }] + } + } + ]) + + await writeSessionLog('child-session', [ + { + type: 'user', + uuid: 'child-user-1', + parentUuid: null, + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:02.000Z', + message: { + role: 'user', + content: 'Investigate flaky test' + } + }, + { + type: 'assistant', + uuid: 'child-assistant-1', + parentUuid: 'child-user-1', + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:03.000Z', + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Potentially unrelated child transcript.' + }] + } + } + ]) + + scanner = await createSessionScanner({ + sessionId: 'parent-session', + workingDirectory: testDir, + onMessage: (message) => collectedMessages.push(message), + replayExistingMessages: true + }) + + await scanner.cleanup() + scanner = null + + expect(collectedMessages.some((message) => message.sessionId === 'child-session')).toBe(false) + expect(collectedMessages.some((message) => { + const subagent = message.meta?.subagent as Record | undefined + return subagent?.sidechainKey === 'task-1' || subagent?.sidechainKey === 'task-2' + })).toBe(false) + }) + + it('keeps repeated identical linked child transcript messages when they are distinct events', async () => { + await writeSessionLog('parent-session', [ + { + type: 'assistant', + uuid: 'parent-task-call', + parentUuid: null, + sessionId: 'parent-session', + timestamp: '2026-04-04T00:00:00.000Z', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { + prompt: 'Investigate flaky test' + } + }] + } + } + ]) + + await writeSessionLog('child-session', [ + { + type: 'user', + uuid: 'child-user-1', + parentUuid: null, + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:01.000Z', + message: { + role: 'user', + content: 'Investigate flaky test' + } + }, + { + type: 'assistant', + uuid: 'child-assistant-1', + parentUuid: 'child-user-1', + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:02.000Z', + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Still investigating.' + }] + } + }, + { + type: 'assistant', + uuid: 'child-assistant-2', + parentUuid: 'child-assistant-1', + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:03.000Z', + meta: { + subagent: { + kind: 'status', + status: 'running' + } + }, + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Still investigating.' + }] + } + } + ]) + + scanner = await createSessionScanner({ + sessionId: 'parent-session', + workingDirectory: testDir, + onMessage: (message) => collectedMessages.push(message), + replayExistingMessages: true + }) + + await scanner.cleanup() + scanner = null + + const repeatedMessages = collectedMessages.filter((message) => ( + message.sessionId === 'child-session' + && getMessageText(message) === 'Still investigating.' + )) + + expect(repeatedMessages).toHaveLength(2) + expect(repeatedMessages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + uuid: 'child-assistant-1', + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + kind: 'message', + sidechainKey: 'task-1' + }) + }) + }), + expect.objectContaining({ + uuid: 'child-assistant-2', + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + kind: 'status', + status: 'running', + sidechainKey: 'task-1' + }) + }) + }) + ])) + }) }) diff --git a/cli/src/claude/utils/sessionScanner.ts b/cli/src/claude/utils/sessionScanner.ts index 1daa5d5e0..8adfc1018 100644 --- a/cli/src/claude/utils/sessionScanner.ts +++ b/cli/src/claude/utils/sessionScanner.ts @@ -48,6 +48,13 @@ type ClaudeLinkedChild = { sidechainKey: string } +type ClaudeTaskCandidate = { + sidechainKey: string + prompt: string + sessionId: string | undefined + timestampMs: number +} + class ClaudeSessionScanner extends BaseSessionScanner { private readonly projectDir: string; private readonly onMessage: (message: RawJSONLines) => void; @@ -57,8 +64,9 @@ class ClaudeSessionScanner extends BaseSessionScanner { private readonly scannedSessions = new Set(); private readonly replayExistingMessages: boolean; private readonly linkedChildSessions = new Map(); - private readonly sidechainKeyByPrompt = new Map(); + private readonly taskCandidateBySidechainKey = new Map(); private readonly knownEventKeys = new Set(); + private readonly sidechainSyntheticReplayBudget = new Map(); constructor(opts: { sessionId: string | null; @@ -161,15 +169,34 @@ class ClaudeSessionScanner extends BaseSessionScanner { filePath: stats.filePath, lineIndex: entry.lineIndex }))); - for (const message of stats.events) { + const sessionId = sessionIdFromPath(stats.filePath); + const linkedChild = sessionId ? this.linkedChildSessions.get(sessionId) : undefined; + const emittedMessages = linkedChild + ? stats.events.filter((message) => !this.consumeSyntheticReplayBudget(message, linkedChild.sidechainKey)) + : stats.events; + + if (!linkedChild) { + for (const message of stats.events) { + const sidechainKey = extractSidechainKey(message); + if (!sidechainKey || message.isSidechain !== true) { + continue; + } + const aliasKey = sidechainSyntheticKey(message, sidechainKey); + if (!aliasKey) { + continue; + } + this.sidechainSyntheticReplayBudget.set(aliasKey, (this.sidechainSyntheticReplayBudget.get(aliasKey) ?? 0) + 1); + } + } + + for (const message of emittedMessages) { const id = message.type === 'summary' ? message.leafUuid : message.uuid; logger.debug(`[SESSION_SCANNER] Sending new message: type=${message.type}, uuid=${id}`); this.onMessage(message); } await this.linkChildSessionsFromPrompts(); if (stats.parsedCount > 0) { - const sessionId = sessionIdFromPath(stats.filePath) ?? 'unknown'; - logger.debug(`[SESSION_SCANNER] Session ${sessionId}: found=${stats.parsedCount}, skipped=${stats.skippedCount}, sent=${stats.newCount}`); + logger.debug(`[SESSION_SCANNER] Session ${sessionId ?? 'unknown'}: found=${stats.parsedCount}, skipped=${stats.skippedCount}, sent=${emittedMessages.length}`); } } @@ -212,16 +239,34 @@ class ClaudeSessionScanner extends BaseSessionScanner { continue; } - this.sidechainKeyByPrompt.set(normalizePrompt(prompt), block.id); + if (this.taskCandidateBySidechainKey.has(block.id)) { + continue; + } + + this.taskCandidateBySidechainKey.set(block.id, { + sidechainKey: block.id, + prompt: normalizePrompt(prompt), + sessionId: message.sessionId, + timestampMs: timestampMs(message.timestamp) + }); } } } private async linkChildSessionsFromPrompts(): Promise { - if (this.sidechainKeyByPrompt.size === 0) { + if (this.taskCandidateBySidechainKey.size === 0) { return; } + const childCandidateIdsByPrompt = new Map(); + const childCandidatesBySessionId = new Map[] + totalLines: number + }>(); + const projectEntries = await readdir(this.projectDir, { withFileTypes: true }).catch(() => []); for (const entry of projectEntries) { if (!entry.isFile() || !entry.name.endsWith('.jsonl')) { @@ -246,28 +291,49 @@ class ClaudeSessionScanner extends BaseSessionScanner { continue; } - const sidechainKey = this.sidechainKeyByPrompt.get(normalizePrompt(prompt)); - if (!sidechainKey) { + const taskCandidate = this.getUniqueTaskCandidateForPrompt(normalizePrompt(prompt), timestampFromEntries(events)); + if (!taskCandidate) { continue; } - const linkedChild: ClaudeLinkedChild = { + const candidateIds = childCandidateIdsByPrompt.get(taskCandidate.prompt) ?? []; + candidateIds.push(childSessionId); + childCandidateIdsByPrompt.set(taskCandidate.prompt, candidateIds); + childCandidatesBySessionId.set(childSessionId, { sessionId: childSessionId, - sidechainKey + sidechainKey: taskCandidate.sidechainKey, + filePath: childFilePath, + events, + totalLines + }); + } + + for (const childCandidate of childCandidatesBySessionId.values()) { + const taskCandidate = this.taskCandidateBySidechainKey.get(childCandidate.sidechainKey); + if (!taskCandidate) { + continue; + } + if ((childCandidateIdsByPrompt.get(taskCandidate.prompt) ?? []).length !== 1) { + continue; + } + + const linkedChild: ClaudeLinkedChild = { + sessionId: childCandidate.sessionId, + sidechainKey: childCandidate.sidechainKey }; - this.linkedChildSessions.set(childSessionId, linkedChild); - this.ensureWatcher(childFilePath); + this.linkedChildSessions.set(childCandidate.sessionId, linkedChild); + this.ensureWatcher(childCandidate.filePath); - const decoratedEntries = events.map((scanEntry) => ({ + const decoratedEntries = childCandidate.events.map((scanEntry) => ({ ...scanEntry, - event: linkChildMessage(scanEntry.event, sidechainKey) + event: linkChildMessage(scanEntry.event, childCandidate.sidechainKey) })); const newMessages: RawJSONLines[] = []; const newKeys: string[] = []; for (const decoratedEntry of decoratedEntries) { const key = this.generateEventKey(decoratedEntry.event, { - filePath: childFilePath, + filePath: childCandidate.filePath, lineIndex: decoratedEntry.lineIndex }); if (this.knownEventKeys.has(key)) { @@ -275,19 +341,47 @@ class ClaudeSessionScanner extends BaseSessionScanner { } this.knownEventKeys.add(key); newKeys.push(key); + if (this.consumeSyntheticReplayBudget(decoratedEntry.event, childCandidate.sidechainKey)) { + continue; + } newMessages.push(decoratedEntry.event); } for (const message of newMessages) { const id = message.type === 'summary' ? message.leafUuid : message.uuid; - logger.debug(`[SESSION_SCANNER] Sending linked child message: type=${message.type}, uuid=${id}, sidechain=${sidechainKey}`); + logger.debug(`[SESSION_SCANNER] Sending linked child message: type=${message.type}, uuid=${id}, sidechain=${childCandidate.sidechainKey}`); this.onMessage(message); } this.seedProcessedKeys(newKeys); - this.setCursor(childFilePath, totalLines); + this.setCursor(childCandidate.filePath, childCandidate.totalLines); } } + + private getUniqueTaskCandidateForPrompt(prompt: string, childTimestampMs: number): ClaudeTaskCandidate | null { + const matches = [...this.taskCandidateBySidechainKey.values()].filter((candidate) => ( + candidate.prompt === prompt + && childTimestampMs >= candidate.timestampMs + )); + return matches.length === 1 ? matches[0] : null; + } + + private consumeSyntheticReplayBudget(message: RawJSONLines, sidechainKey: string): boolean { + const aliasKey = sidechainSyntheticKey(message, sidechainKey); + if (!aliasKey) { + return false; + } + const remaining = this.sidechainSyntheticReplayBudget.get(aliasKey) ?? 0; + if (remaining <= 0) { + return false; + } + if (remaining === 1) { + this.sidechainSyntheticReplayBudget.delete(aliasKey); + } else { + this.sidechainSyntheticReplayBudget.set(aliasKey, remaining - 1); + } + return true; + } } // @@ -297,7 +391,11 @@ class ClaudeSessionScanner extends BaseSessionScanner { function messageKey(message: RawJSONLines, linkedSidechainKey: string | null = null): string { const sidechainKey = linkedSidechainKey ?? extractSidechainKey(message); if (sidechainKey) { - return `sidechain:${sidechainKey}:${stableStringify(sidechainMessageFingerprint(message))}`; + const uuidKey = sidechainUuidKey(message, sidechainKey); + if (uuidKey) { + return uuidKey; + } + return sidechainSyntheticKey(message, sidechainKey) ?? `sidechain:${sidechainKey}:missing`; } if (message.type === 'user') { return message.uuid; @@ -417,6 +515,16 @@ function extractFirstUserPrompt(messages: RawJSONLines[]): string | null { return null; } +function timestampFromEntries(entries: SessionFileScanEntry[]): number { + for (const entry of entries) { + const value = timestampMs(entry.event.timestamp); + if (value > 0) { + return value; + } + } + return 0; +} + function normalizePrompt(prompt: string): string { return prompt.trim().replace(/\s+/g, ' '); } @@ -427,14 +535,40 @@ function linkChildMessage(message: RawJSONLines, sidechainKey: string): RawJSONL isSidechain: true, meta: { ...(message.meta ?? {}), - subagent: { - kind: 'message', - sidechainKey - } + subagent: mergeSubagentMeta(message.meta?.subagent, sidechainKey) } }; } +function mergeSubagentMeta( + subagent: RawJSONLines['meta'] extends { subagent?: infer T } ? T : unknown, + sidechainKey: string +): unknown { + if (Array.isArray(subagent)) { + if (subagent.some((item) => item && typeof item === 'object' && (item as { sidechainKey?: unknown }).sidechainKey === sidechainKey)) { + return subagent; + } + return [{ + kind: 'message', + sidechainKey + }, ...subagent]; + } + + if (subagent && typeof subagent === 'object') { + return { + ...(subagent as Record), + sidechainKey: typeof (subagent as { sidechainKey?: unknown }).sidechainKey === 'string' + ? (subagent as { sidechainKey: string }).sidechainKey + : sidechainKey + }; + } + + return { + kind: 'message', + sidechainKey + }; +} + function extractSidechainKey(message: RawJSONLines): string | null { const subagent = message.meta?.subagent; if (!subagent) { @@ -480,6 +614,25 @@ function sidechainMessageFingerprint(message: RawJSONLines): Record 0) { + return `sidechain:${sidechainKey}:uuid:${message.uuid}`; + } + return null; +} + +function sidechainSyntheticKey(message: RawJSONLines, sidechainKey: string): string | null { + return `sidechain:${sidechainKey}:synthetic:${stableStringify(sidechainMessageFingerprint(message))}`; +} + +function timestampMs(timestamp: string | undefined): number { + if (!timestamp) { + return 0; + } + const value = Date.parse(timestamp); + return Number.isFinite(value) ? value : 0; +} + function stableStringify(value: unknown): string { if (value === null || value === undefined) { return JSON.stringify(value); From 76fe2090bb4c38f3b2cda6c6eb0c60208245d6e1 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 09:02:21 +0800 Subject: [PATCH 67/82] feat: normalize hub team extraction for subagents --- .../handlers/cli/sessionHandlers.test.ts | 113 ++++++++++++ hub/src/sync/teams.test.ts | 62 ++++++- hub/src/sync/teams.ts | 171 ++++++++++++++++++ 3 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 hub/src/socket/handlers/cli/sessionHandlers.test.ts diff --git a/hub/src/socket/handlers/cli/sessionHandlers.test.ts b/hub/src/socket/handlers/cli/sessionHandlers.test.ts new file mode 100644 index 000000000..7a39af3bd --- /dev/null +++ b/hub/src/socket/handlers/cli/sessionHandlers.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'bun:test' +import type { Store, StoredSession } from '../../../store' +import type { CliSocketWithData } from '../../socketTypes' +import { registerSessionHandlers } from './sessionHandlers' + +type EmittedEvent = { + event: string + data: unknown +} + +class FakeSocket { + readonly id: string + readonly data: Record = {} + readonly emitted: EmittedEvent[] = [] + readonly roomEmits: Array<{ room: string; event: string; data: unknown }> = [] + private readonly handlers = new Map void>() + + constructor(id: string) { + this.id = id + } + + on(event: string, handler: (...args: unknown[]) => void): this { + this.handlers.set(event, handler) + return this + } + + emit(event: string, data: unknown): boolean { + this.emitted.push({ event, data }) + return true + } + + to(room: string): { emit: (event: string, data: unknown) => void } { + return { + emit: (event: string, data: unknown) => { + this.roomEmits.push({ room, event, data }) + } + } + } + + trigger(event: string, data?: unknown): void { + const handler = this.handlers.get(event) + if (!handler) return + if (typeof data === 'undefined') { + handler() + return + } + handler(data) + } +} + +describe('cli session handlers', () => { + it('preserves nested subagent metadata when storing message content', () => { + const addMessage = vi.fn((sessionId: string, content: unknown, localId?: string) => ({ + id: 'message-1', + sessionId, + content, + createdAt: 123, + seq: 1, + localId: localId ?? null + })) + + const store = { + messages: { + addMessage + }, + sessions: { + getSession: () => ({ namespace: 'default' } as StoredSession), + setSessionTodos: () => false, + setSessionTeamState: () => false + } + } as unknown as Store + + const socket = new FakeSocket('cli-socket') + + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, + resolveSessionAccess: () => ({ ok: true, value: { namespace: 'default' } as StoredSession }), + emitAccessError: () => {} + }) + + const payload = { + role: 'assistant', + content: { + type: 'codex', + data: { + type: 'tool-call', + name: 'OtherTool', + input: { foo: 'bar' }, + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-1', + prompt: 'Investigate flaky test' + } + } + } + } + } + + socket.trigger('message', { + sid: 'session-1', + message: JSON.stringify(payload), + localId: 'local-1' + }) + + expect(addMessage).toHaveBeenCalledTimes(1) + expect(addMessage.mock.calls[0]).toEqual([ + 'session-1', + payload, + 'local-1' + ]) + }) +}) diff --git a/hub/src/sync/teams.test.ts b/hub/src/sync/teams.test.ts index 968c6b10f..0a1a40a9f 100644 --- a/hub/src/sync/teams.test.ts +++ b/hub/src/sync/teams.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test' -import { applyTeamStateDelta } from './teams' +import { applyTeamStateDelta, extractTeamStateFromMessageContent } from './teams' import type { TeamState, TeamTask } from '@hapi/protocol/types' const baseTeamState: TeamState = { @@ -71,3 +71,63 @@ describe('applyTeamStateDelta - orphan TaskUpdate', () => { expect(tasks[0]).toMatchObject({ id: 'task-1', status: 'in_progress' }) }) }) + +describe('extractTeamStateFromMessageContent - normalized subagent metadata', () => { + test('derives team task/member updates from normalized Codex subagent metadata', () => { + const delta = extractTeamStateFromMessageContent({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + name: 'CodexSpawnAgent', + input: { name: 'worker-1' }, + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-1', + prompt: 'Investigate flaky test' + } + } + } + } + }) + + expect(delta).toMatchObject({ + members: [{ name: 'worker-1', status: 'active' }], + tasks: [{ title: 'Investigate flaky test', status: 'in_progress', owner: 'worker-1' }] + }) + }) + + test('derives team task/member updates from normalized Claude subagent metadata', () => { + const delta = extractTeamStateFromMessageContent({ + role: 'assistant', + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-2', + prompt: 'Investigate flaky test' + } + }, + content: { + type: 'output', + data: { + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: { name: 'worker-1' } + }] + } + } + } + }) + + expect(delta).toMatchObject({ + members: [{ name: 'worker-1', status: 'active' }], + tasks: [{ title: 'Investigate flaky test', status: 'in_progress', owner: 'worker-1' }] + }) + }) +}) diff --git a/hub/src/sync/teams.ts b/hub/src/sync/teams.ts index ba26795e1..f6769f79d 100644 --- a/hub/src/sync/teams.ts +++ b/hub/src/sync/teams.ts @@ -4,6 +4,21 @@ import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { TeamState } from '@hapi/protocol/types' type TeamStateDelta = Partial & { _action?: 'create' | 'delete' | 'update' } +type NormalizedTeamSignal = + | { kind: 'spawn'; sidechainKey: string; prompt?: string } + | { kind: 'status'; sidechainKey: string; status: 'waiting' | 'running' | 'completed' | 'error' | 'closed' } + | { kind: 'title'; sidechainKey: string; title: string } + | { kind: 'message'; sidechainKey: string } + +const SPAWN_TOOL_NAMES = new Set(['Task', 'CodexSpawnAgent']) + +function asRecord(value: unknown): Record | null { + return isObject(value) ? value as Record : null +} + +function isSupportedSpawnToolName(name: string): boolean { + return SPAWN_TOOL_NAMES.has(name) +} function extractToolBlocks(content: Record): Array<{ name: string; input: Record }> { const blocks: Array<{ name: string; input: Record }> = [] @@ -43,6 +58,154 @@ function extractToolBlocks(content: Record): Array<{ name: stri return blocks } +function dedupeNormalizedSignals(signals: NormalizedTeamSignal[]): NormalizedTeamSignal[] { + const seen = new Set() + const deduped: NormalizedTeamSignal[] = [] + + for (const signal of signals) { + const key = signal.kind === 'spawn' + ? `${signal.kind}:${signal.sidechainKey}:${signal.prompt ?? ''}` + : signal.kind === 'status' + ? `${signal.kind}:${signal.sidechainKey}:${signal.status}` + : signal.kind === 'title' + ? `${signal.kind}:${signal.sidechainKey}:${signal.title}` + : `${signal.kind}:${signal.sidechainKey}` + if (seen.has(key)) continue + seen.add(key) + deduped.push(signal) + } + + return deduped +} + +function readNormalizedSubagentSignal(subagent: unknown): NormalizedTeamSignal[] { + const signals: NormalizedTeamSignal[] = [] + const items = Array.isArray(subagent) ? subagent : [subagent] + + for (const item of items) { + if (!isObject(item)) continue + + const kind = typeof item.kind === 'string' ? item.kind : null + const sidechainKey = typeof item.sidechainKey === 'string' ? item.sidechainKey : null + if (!kind || !sidechainKey) continue + + if (kind === 'spawn') { + const prompt = typeof item.prompt === 'string' ? item.prompt : undefined + signals.push({ kind, sidechainKey, ...(prompt ? { prompt } : {}) }) + continue + } + + if (kind === 'status') { + const status = typeof item.status === 'string' ? item.status : null + if (status === 'waiting' || status === 'running' || status === 'completed' || status === 'error' || status === 'closed') { + signals.push({ kind, sidechainKey, status }) + } + continue + } + + if (kind === 'title') { + const title = typeof item.title === 'string' ? item.title : null + if (title) { + signals.push({ kind, sidechainKey, title }) + } + continue + } + + if (kind === 'message') { + signals.push({ kind, sidechainKey }) + } + } + + return signals +} + +function collectNormalizedSubagentMeta(value: unknown, signals: NormalizedTeamSignal[], seen: Set): void { + if (!isObject(value)) return + + const record = value as Record + if (seen.has(record)) return + seen.add(record) + + const meta = asRecord(record.meta) + if (meta && 'subagent' in meta) { + signals.push(...readNormalizedSubagentSignal(meta.subagent)) + } + + for (const nested of Object.values(record)) { + if (Array.isArray(nested)) { + for (const item of nested) { + collectNormalizedSubagentMeta(item, signals, seen) + } + continue + } + + collectNormalizedSubagentMeta(nested, signals, seen) + } +} + +function extractNormalizedSubagentMeta(content: Record): NormalizedTeamSignal[] { + const signals: NormalizedTeamSignal[] = [] + collectNormalizedSubagentMeta(content, signals, new Set()) + return dedupeNormalizedSignals(signals) +} + +function processSpawnSignal( + input: Record, + signal: Extract +): TeamStateDelta | null { + const name = typeof input.name === 'string' ? input.name : null + if (!name) return null + + const agentType = typeof input.subagent_type === 'string' ? input.subagent_type : undefined + const description = typeof input.description === 'string' + ? input.description + : signal.prompt ?? null + + const delta: TeamStateDelta = { + _action: 'update', + members: [{ name, agentType, status: 'active' }], + updatedAt: Date.now() + } + + if (description) { + delta.tasks = [{ + id: `agent:${name}`, + title: description, + status: 'in_progress', + owner: name + }] + } + + return delta +} + +function extractNormalizedTeamDelta( + blocks: Array<{ name: string; input: Record }>, + signals: NormalizedTeamSignal[] +): TeamStateDelta | null { + const spawnSignals = signals.filter((signal): signal is Extract => signal.kind === 'spawn') + if (spawnSignals.length === 0) { + return null + } + + const spawnBlocks = blocks.filter((block) => isSupportedSpawnToolName(block.name)) + if (spawnBlocks.length === 0) { + return null + } + + let result: TeamStateDelta | null = null + const count = Math.min(spawnSignals.length, spawnBlocks.length) + + for (let index = 0; index < count; index += 1) { + const delta = processSpawnSignal(spawnBlocks[index].input, spawnSignals[index]) + if (delta) { + result = result ? mergeDelta(result, delta) : delta + } + } + + return result +} + function processTeamCreate(input: Record): TeamStateDelta | null { const teamName = typeof input.team_name === 'string' ? input.team_name : null if (!teamName) return null @@ -170,9 +333,17 @@ export function extractTeamStateFromMessageContent(messageContent: unknown): Tea if (record.role !== 'agent' && record.role !== 'assistant') return null if (!isObject(record.content) || typeof record.content.type !== 'string') return null + const normalizedSignals = extractNormalizedSubagentMeta(record as Record) const blocks = extractToolBlocks(record.content) if (blocks.length === 0) return null + if (normalizedSignals.length > 0) { + const normalizedDelta = extractNormalizedTeamDelta(blocks, normalizedSignals) + if (normalizedDelta) { + return normalizedDelta + } + } + let result: TeamStateDelta | null = null for (const block of blocks) { From aa4f194cb5f7857b4d90fc3276d314fd8f29c2f0 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 09:04:40 +0800 Subject: [PATCH 68/82] test: compare normalized team deltas across agents --- hub/src/sync/teams.test.ts | 41 ++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/hub/src/sync/teams.test.ts b/hub/src/sync/teams.test.ts index 0a1a40a9f..3a0db7a2b 100644 --- a/hub/src/sync/teams.test.ts +++ b/hub/src/sync/teams.test.ts @@ -73,8 +73,8 @@ describe('applyTeamStateDelta - orphan TaskUpdate', () => { }) describe('extractTeamStateFromMessageContent - normalized subagent metadata', () => { - test('derives team task/member updates from normalized Codex subagent metadata', () => { - const delta = extractTeamStateFromMessageContent({ + test('derives the same normalized delta shape for Claude and Codex subagent metadata', () => { + const codexDelta = extractTeamStateFromMessageContent({ role: 'agent', content: { type: 'codex', @@ -93,14 +93,7 @@ describe('extractTeamStateFromMessageContent - normalized subagent metadata', () } }) - expect(delta).toMatchObject({ - members: [{ name: 'worker-1', status: 'active' }], - tasks: [{ title: 'Investigate flaky test', status: 'in_progress', owner: 'worker-1' }] - }) - }) - - test('derives team task/member updates from normalized Claude subagent metadata', () => { - const delta = extractTeamStateFromMessageContent({ + const claudeDelta = extractTeamStateFromMessageContent({ role: 'assistant', meta: { subagent: { @@ -125,9 +118,31 @@ describe('extractTeamStateFromMessageContent - normalized subagent metadata', () } }) - expect(delta).toMatchObject({ - members: [{ name: 'worker-1', status: 'active' }], - tasks: [{ title: 'Investigate flaky test', status: 'in_progress', owner: 'worker-1' }] + const normalizeDeltaShape = (delta: NonNullable) => ({ + _action: delta._action, + members: delta.members?.map(({ name, agentType, status }) => ({ name, agentType, status })), + tasks: delta.tasks?.map(({ id, title, description, status, owner }) => ({ id, title, description, status, owner })), + messages: delta.messages, + description: delta.description, + teamName: delta.teamName + }) + + expect(codexDelta).toBeTruthy() + expect(claudeDelta).toBeTruthy() + expect(normalizeDeltaShape(codexDelta!)).toEqual(normalizeDeltaShape(claudeDelta!)) + expect(normalizeDeltaShape(codexDelta!)).toEqual({ + _action: 'update', + members: [{ name: 'worker-1', agentType: undefined, status: 'active' }], + tasks: [{ + id: 'agent:worker-1', + title: 'Investigate flaky test', + description: undefined, + status: 'in_progress', + owner: 'worker-1' + }], + messages: undefined, + description: undefined, + teamName: undefined }) }) }) From 73811fef811f9a706a48d4e026d4f8f80ca217de Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 09:07:57 +0800 Subject: [PATCH 69/82] test: cover normalized team delta precedence --- hub/src/sync/teams.test.ts | 67 ++++++++++++++++++++++++++++++++++++++ hub/src/sync/teams.ts | 5 ++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/hub/src/sync/teams.test.ts b/hub/src/sync/teams.test.ts index 3a0db7a2b..0f845415c 100644 --- a/hub/src/sync/teams.test.ts +++ b/hub/src/sync/teams.test.ts @@ -145,4 +145,71 @@ describe('extractTeamStateFromMessageContent - normalized subagent metadata', () teamName: undefined }) }) + + test('prefers normalized subagent metadata over conflicting raw tool data', () => { + const delta = extractTeamStateFromMessageContent({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + name: 'CodexSpawnAgent', + input: { + team_name: 'test-team', + name: 'worker-1', + description: 'Raw tool description' + }, + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-3', + prompt: 'Normalized prompt' + } + } + } + } + }) + + expect(delta).toMatchObject({ + members: [{ name: 'worker-1', status: 'active' }], + tasks: [{ title: 'Normalized prompt', status: 'in_progress', owner: 'worker-1' }] + }) + expect(delta?.tasks?.[0]).not.toMatchObject({ + title: 'Raw tool description' + }) + }) + + test('falls back to raw tool parsing when normalized metadata is unusable', () => { + const delta = extractTeamStateFromMessageContent({ + role: 'assistant', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'task-4', + name: 'Task', + input: { + team_name: 'test-team', + name: 'worker-2', + description: 'Raw fallback description' + } + }] + } + } + }, + meta: { + subagent: { + kind: 'spawn' + } + } + }) + + expect(delta).toMatchObject({ + members: [{ name: 'worker-2', status: 'active' }], + tasks: [{ title: 'Raw fallback description', status: 'in_progress', owner: 'worker-2' }] + }) + }) }) diff --git a/hub/src/sync/teams.ts b/hub/src/sync/teams.ts index f6769f79d..1c46706a1 100644 --- a/hub/src/sync/teams.ts +++ b/hub/src/sync/teams.ts @@ -157,9 +157,8 @@ function processSpawnSignal( if (!name) return null const agentType = typeof input.subagent_type === 'string' ? input.subagent_type : undefined - const description = typeof input.description === 'string' - ? input.description - : signal.prompt ?? null + const description = signal.prompt + ?? (typeof input.description === 'string' ? input.description : null) const delta: TeamStateDelta = { _action: 'update', From 7246be98cb2586c85081ec4c62ca4601cb136f44 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 09:14:17 +0800 Subject: [PATCH 70/82] refactor: make web sidechain rendering agent-neutral --- web/src/chat/normalize.test.ts | 39 ++++++ web/src/chat/normalizeAgent.ts | 47 ++++++- web/src/chat/reducer.test.ts | 41 +++++++ web/src/chat/reducer.ts | 4 +- web/src/chat/reducerTimeline.ts | 2 +- web/src/chat/subagentSidechain.ts | 195 ++++++++++++++++++++++++++++++ 6 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 web/src/chat/subagentSidechain.ts diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 972bafc7f..a39e953e4 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -44,6 +44,45 @@ describe('normalizeDecryptedMessage', () => { }) }) + it('preserves Claude subagent metadata as sidechain flags on agent payloads', () => { + const message = makeMessage({ + role: 'agent', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-1', + prompt: 'Investigate flaky test' + } + }, + content: { + type: 'output', + data: { + type: 'assistant', + message: { + content: [{ + type: 'text', + text: 'child answer' + }] + } + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'agent', + isSidechain: true, + sidechainKey: 'task-1', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-1', + prompt: 'Investigate flaky test' + } + } + }) + }) + it('keeps normal Codex payloads root-level when parentToolCallId is absent', () => { const message = makeMessage({ role: 'agent', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index fd4570292..def33b208 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -27,6 +27,47 @@ function normalizeToolResultPermissions(value: unknown): ToolResultPermission | } } +function extractSubagentSidechainKey(meta: unknown): string | null { + if (!isObject(meta)) return null + + const subagent = meta.subagent + if (Array.isArray(subagent)) { + for (const item of subagent) { + if (item && typeof item === 'object' && typeof (item as { sidechainKey?: unknown }).sidechainKey === 'string') { + const sidechainKey = (item as { sidechainKey: string }).sidechainKey + if (sidechainKey.length > 0) return sidechainKey + } + } + return null + } + + if (subagent && typeof subagent === 'object' && typeof (subagent as { sidechainKey?: unknown }).sidechainKey === 'string') { + const sidechainKey = (subagent as { sidechainKey: string }).sidechainKey + return sidechainKey.length > 0 ? sidechainKey : null + } + + return null +} + +function resolveSidechainMetadata( + data: Record, + meta: unknown +): { isSidechain: boolean; sidechainKey?: string } { + const subagentSidechainKey = extractSubagentSidechainKey(meta) + if (subagentSidechainKey) { + return { + isSidechain: true, + sidechainKey: subagentSidechainKey + } + } + + const codexSidechainKey = Boolean(data.isSidechain) ? asString(data.parentToolCallId) ?? undefined : undefined + return { + isSidechain: Boolean(data.isSidechain), + ...(codexSidechainKey ? { sidechainKey: codexSidechainKey } : {}) + } +} + function normalizeAgentEvent(value: unknown): AgentEvent | null { if (!isObject(value) || typeof value.type !== 'string') return null return value as AgentEvent @@ -41,7 +82,7 @@ function normalizeAssistantOutput( ): NormalizedMessage | null { const uuid = asString(data.uuid) ?? messageId const parentUUID = asString(data.parentUuid) ?? null - const isSidechain = Boolean(data.isSidechain) + const { isSidechain, sidechainKey } = resolveSidechainMetadata(data, meta) const message = isObject(data.message) ? data.message : null if (!message) return null @@ -81,6 +122,7 @@ function normalizeAssistantOutput( createdAt, role: 'agent', isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: blocks, meta, usage: inputTokens !== null && outputTokens !== null ? { @@ -314,8 +356,7 @@ export function normalizeAgentRecord( if (content.type === AGENT_MESSAGE_PAYLOAD_TYPE) { const data = isObject(content.data) ? content.data : null if (!data || typeof data.type !== 'string') return null - const isSidechain = Boolean(data.isSidechain) - const sidechainKey = isSidechain ? asString(data.parentToolCallId) ?? undefined : undefined + const { isSidechain, sidechainKey } = resolveSidechainMetadata(data, meta) if (data.type === 'message' && typeof data.message === 'string') { return { diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index c83ce2388..9dce5488e 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -250,4 +250,45 @@ describe('reduceChatBlocks', () => { expect(spawnBlock?.lifecycle?.latestText).toBe('Final child answer') }) + + it('groups Claude sidechain messages under the parent Task tool call', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-task-call', 'task-1', 'Task', { prompt: 'Investigate flaky test' }, 1), + { + ...userText('child-user', 'child prompt', 2), + isSidechain: true, + meta: { + subagent: { + kind: 'message', + sidechainKey: 'msg-task-call' + } + } + }, + { + ...agentText('child-agent', 'child answer', 3), + isSidechain: true, + meta: { + subagent: { + kind: 'message', + sidechainKey: 'msg-task-call' + } + } + } + ] + + const reduced = reduceChatBlocks(messages, null) + + expect(reduced.blocks).toContainEqual( + expect.objectContaining({ + kind: 'tool-call', + tool: expect.objectContaining({ + name: 'Task' + }), + children: expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: 'child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) + ]) + }) + ) + }) }) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 66728b037..878139754 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -1,7 +1,7 @@ import type { AgentState } from '@/types/api' import type { ChatBlock, NormalizedMessage, ToolCallBlock, UsageData } from '@/chat/types' -import { annotateCodexSidechains } from '@/chat/codexSidechain' import { applyCodexLifecycleAggregation } from '@/chat/codexLifecycle' +import { annotateSubagentSidechains } from '@/chat/subagentSidechain' import { traceMessages, type TracedMessage } from '@/chat/tracer' import { dedupeAgentEvents, foldApiErrorEvents } from '@/chat/reducerEvents' import { collectTitleChanges, collectToolIdsFromMessages, ensureToolBlock, getPermissions } from '@/chat/reducerTools' @@ -118,7 +118,7 @@ export function reduceChatBlocks( const titleChangesByToolUseId = collectTitleChanges(normalized) const traced = traceMessages(normalized) - const annotated = annotateCodexSidechains(traced) + const annotated = annotateSubagentSidechains(traced) const { groups, root } = groupMessagesBySidechain(annotated) const consumedGroupIds = new Set() diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 1d40714e1..66aa57b0a 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -173,7 +173,7 @@ export function reduceTimeline( block.tool.startedAt = msg.createdAt } - if (c.name === 'Task' && !context.consumedGroupIds.has(msg.id)) { + if (!context.consumedGroupIds.has(msg.id)) { const sidechain = context.groups.get(msg.id) ?? null if (sidechain && sidechain.length > 0) { context.consumedGroupIds.add(msg.id) diff --git a/web/src/chat/subagentSidechain.ts b/web/src/chat/subagentSidechain.ts new file mode 100644 index 000000000..3befe772f --- /dev/null +++ b/web/src/chat/subagentSidechain.ts @@ -0,0 +1,195 @@ +import type { NormalizedAgentContent, NormalizedMessage } from '@/chat/types' +import { isObject } from '@hapi/protocol' + +const SUBAGENT_NOTIFICATION_PREFIX = '' + +function extractSubagentSidechainKey(meta: unknown): string | null { + if (!isObject(meta)) return null + + const subagent = meta.subagent + if (Array.isArray(subagent)) { + for (const item of subagent) { + if (item && typeof item === 'object' && typeof (item as { sidechainKey?: unknown }).sidechainKey === 'string') { + const sidechainKey = (item as { sidechainKey: string }).sidechainKey + if (sidechainKey.length > 0) return sidechainKey + } + } + return null + } + + if (subagent && typeof subagent === 'object' && typeof (subagent as { sidechainKey?: unknown }).sidechainKey === 'string') { + const sidechainKey = (subagent as { sidechainKey: string }).sidechainKey + return sidechainKey.length > 0 ? sidechainKey : null + } + + return null +} + +function getToolCallBlocks(message: NormalizedMessage): Extract[] { + if (message.role !== 'agent') return [] + return message.content.filter((content): content is Extract => content.type === 'tool-call') +} + +function getToolResultBlocks(message: NormalizedMessage): Extract[] { + if (message.role !== 'agent') return [] + return message.content.filter((content): content is Extract => content.type === 'tool-result') +} + +function extractSpawnAgentId( + message: NormalizedMessage, + toolNameByToolUseId: Map +): { agentId: string; spawnToolUseId: string } | null { + for (const result of getToolResultBlocks(message)) { + const toolName = toolNameByToolUseId.get(result.tool_use_id) + if (toolName !== 'CodexSpawnAgent') continue + if (!isObject(result.content)) continue + + const agentId = typeof result.content.agent_id === 'string' ? result.content.agent_id : null + if (!agentId || agentId.length === 0) continue + + return { agentId, spawnToolUseId: result.tool_use_id } + } + + return null +} + +function extractWaitTargets(message: NormalizedMessage): string[] { + for (const toolCall of getToolCallBlocks(message)) { + if (toolCall.name !== 'CodexWaitAgent') continue + if (!isObject(toolCall.input) || !Array.isArray(toolCall.input.targets)) continue + + return toolCall.input.targets.filter((target): target is string => typeof target === 'string' && target.length > 0) + } + + return [] +} + +function messageLooksLikeInlineChildConversation(message: NormalizedMessage): boolean { + if (message.role === 'user') { + return message.content.type === 'text' && !message.content.text.trimStart().startsWith(SUBAGENT_NOTIFICATION_PREFIX) + } + + if (message.role !== 'agent') return false + if (message.content.length === 0) return false + + let sawNestableContent = false + for (const block of message.content) { + if (block.type === 'summary' || block.type === 'sidechain') return false + if (block.type === 'text') { + if (block.text.trimStart().startsWith(SUBAGENT_NOTIFICATION_PREFIX)) return false + sawNestableContent = true + continue + } + if (block.type === 'reasoning' || block.type === 'tool-call' || block.type === 'tool-result') { + sawNestableContent = true + continue + } + return false + } + + return sawNestableContent +} + +function messageContainsSpawnToolCall(message: NormalizedMessage): boolean { + return getToolCallBlocks(message).some((toolCall) => toolCall.name === 'CodexSpawnAgent') +} + +function removeActiveAgents(activeAgentIds: string[], targets: string[]): string[] { + if (targets.length === 0) return activeAgentIds + const closed = new Set(targets) + return activeAgentIds.filter((agentId) => !closed.has(agentId)) +} + +function annotateExplicitSidechain(message: NormalizedMessage): NormalizedMessage | null { + const sidechainKey = message.sidechainKey ?? extractSubagentSidechainKey(message.meta) + if (!sidechainKey) return null + return { + ...message, + isSidechain: true, + sidechainKey + } +} + +export function annotateSubagentSidechains(messages: NormalizedMessage[]): NormalizedMessage[] { + // Pass 1: Identify which spawn calls have valid results (with agent_id) + const toolNameByToolUseId = new Map() + for (const message of messages) { + for (const toolCall of getToolCallBlocks(message)) { + toolNameByToolUseId.set(toolCall.id, toolCall.name) + } + } + const validSpawnToolUseIds = new Set() + for (const message of messages) { + for (const result of getToolResultBlocks(message)) { + const toolName = toolNameByToolUseId.get(result.tool_use_id) + if (toolName !== 'CodexSpawnAgent') continue + if (!isObject(result.content)) continue + const agentId = typeof result.content.agent_id === 'string' ? result.content.agent_id : null + if (agentId && agentId.length > 0) { + validSpawnToolUseIds.add(result.tool_use_id) + } + } + } + + // Pass 2: Annotate + const agentIdToSpawnToolUseId = new Map() + let activeAgentIds: string[] = [] + let pendingSpawnToolUseId: string | null = null + + const result: NormalizedMessage[] = [] + + for (const message of messages) { + const explicit = annotateExplicitSidechain(message) + if (explicit) { + result.push(explicit) + continue + } + + let hasCodexSpawnToolCall = false + for (const toolCall of getToolCallBlocks(message)) { + if (toolCall.name === 'CodexSpawnAgent' && validSpawnToolUseIds.has(toolCall.id)) { + pendingSpawnToolUseId = toolCall.id + hasCodexSpawnToolCall = true + } + } + + const spawn = extractSpawnAgentId(message, toolNameByToolUseId) + if (spawn) { + pendingSpawnToolUseId = null + agentIdToSpawnToolUseId.set(spawn.agentId, spawn.spawnToolUseId) + activeAgentIds = removeActiveAgents(activeAgentIds, [spawn.agentId]) + activeAgentIds.push(spawn.agentId) + result.push({ ...message }) + continue + } + + const waitTargets = extractWaitTargets(message) + if (waitTargets.length > 0) { + activeAgentIds = removeActiveAgents(activeAgentIds, waitTargets) + result.push({ ...message }) + continue + } + + const activeAgentId = activeAgentIds.length === 1 ? activeAgentIds[0] : null + let activeSpawnToolUseId = activeAgentId ? agentIdToSpawnToolUseId.get(activeAgentId) ?? null : null + if (!activeSpawnToolUseId && pendingSpawnToolUseId && !hasCodexSpawnToolCall) { + activeSpawnToolUseId = pendingSpawnToolUseId + } + if ( + activeSpawnToolUseId !== null + && !messageContainsSpawnToolCall(message) + && messageLooksLikeInlineChildConversation(message) + ) { + result.push({ + ...message, + isSidechain: true, + sidechainKey: activeSpawnToolUseId + }) + continue + } + + result.push({ ...message }) + } + + return result +} From 8aa654264af11e82ee28d6dd7d3327d90425ff8b Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 09:41:14 +0800 Subject: [PATCH 71/82] fix: key web sidechains by tool-use id --- web/src/chat/reducer.test.ts | 68 ++++++++++++++++----- web/src/chat/reducerTimeline.ts | 6 +- web/src/chat/subagentSidechain.test.ts | 84 ++++++++++++++++++++++++++ web/src/chat/tracer.ts | 2 +- 4 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 web/src/chat/subagentSidechain.test.ts diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index 9dce5488e..a90b84656 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { reduceChatBlocks } from './reducer' -import type { NormalizedMessage, ToolCallBlock } from './types' +import type { NormalizedAgentContent, NormalizedMessage, ToolCallBlock } from './types' function agentToolCall( messageId: string, @@ -77,6 +77,24 @@ function agentText(id: string, text: string, createdAt: number): NormalizedMessa } } +function agentMessage( + id: string, + createdAt: number, + content: NormalizedAgentContent[], + extra: Partial> = {} +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: extra.isSidechain ?? false, + ...(extra.sidechainKey ? { sidechainKey: extra.sidechainKey } : {}), + ...(extra.meta ? { meta: extra.meta } : {}), + content + } +} + describe('reduceChatBlocks', () => { it('groups Codex child messages under the matching spawn tool block and folds lifecycle controls into it', () => { const messages: NormalizedMessage[] = [ @@ -253,24 +271,42 @@ describe('reduceChatBlocks', () => { it('groups Claude sidechain messages under the parent Task tool call', () => { const messages: NormalizedMessage[] = [ - agentToolCall('msg-task-call', 'task-1', 'Task', { prompt: 'Investigate flaky test' }, 1), + agentMessage('msg-parent', 1, [{ + type: 'tool-call', + id: 'other-tool', + name: 'OtherTool', + input: { prompt: 'ignore me' }, + description: null, + uuid: 'msg-parent-uuid', + parentUUID: null + }, { + type: 'tool-call', + id: 'task-1', + name: 'Task', + input: { prompt: 'Investigate flaky test' }, + description: null, + uuid: 'msg-parent-uuid', + parentUUID: null + }]), { ...userText('child-user', 'child prompt', 2), isSidechain: true, + sidechainKey: 'task-1', meta: { subagent: { kind: 'message', - sidechainKey: 'msg-task-call' + sidechainKey: 'task-1' } } }, { ...agentText('child-agent', 'child answer', 3), isSidechain: true, + sidechainKey: 'task-1', meta: { subagent: { kind: 'message', - sidechainKey: 'msg-task-call' + sidechainKey: 'task-1' } } } @@ -278,17 +314,19 @@ describe('reduceChatBlocks', () => { const reduced = reduceChatBlocks(messages, null) - expect(reduced.blocks).toContainEqual( - expect.objectContaining({ - kind: 'tool-call', - tool: expect.objectContaining({ - name: 'Task' - }), - children: expect.arrayContaining([ - expect.objectContaining({ kind: 'user-text', text: 'child prompt' }), - expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) - ]) - }) + const taskBlock = reduced.blocks.find( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.id === 'task-1' + ) + const otherBlock = reduced.blocks.find( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.id === 'other-tool' + ) + + expect(taskBlock?.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: 'child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) + ]) ) + expect(otherBlock?.children).toHaveLength(0) }) }) diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 66aa57b0a..7a8dcd378 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -173,10 +173,10 @@ export function reduceTimeline( block.tool.startedAt = msg.createdAt } - if (!context.consumedGroupIds.has(msg.id)) { - const sidechain = context.groups.get(msg.id) ?? null + if (!context.consumedGroupIds.has(c.id)) { + const sidechain = context.groups.get(c.id) ?? null if (sidechain && sidechain.length > 0) { - context.consumedGroupIds.add(msg.id) + context.consumedGroupIds.add(c.id) const child = reduceTimeline(sidechain, context) hasReadyEvent = hasReadyEvent || child.hasReadyEvent block.children = child.blocks diff --git a/web/src/chat/subagentSidechain.test.ts b/web/src/chat/subagentSidechain.test.ts new file mode 100644 index 000000000..636cd53a9 --- /dev/null +++ b/web/src/chat/subagentSidechain.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { annotateSubagentSidechains } from './subagentSidechain' +import type { NormalizedMessage } from './types' + +function agentToolCall( + messageId: string, + toolUseId: string, + name: string, + input: unknown, + createdAt: number +): NormalizedMessage { + return { + id: messageId, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: toolUseId, + name, + input, + description: null, + uuid: `${messageId}-uuid`, + parentUUID: null + }] + } +} + +function childAgentMessage( + id: string, + text: string, + createdAt: number, + sidechainKey: string +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: true, + sidechainKey, + meta: { + subagent: { + kind: 'message', + sidechainKey + } + }, + content: [{ + type: 'text', + text, + uuid: `${id}-uuid`, + parentUUID: null + }] + } +} + +describe('annotateSubagentSidechains', () => { + it('preserves Claude sidechain keys that point at the Task tool-use id', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-parent', 'task-1', 'Task', { prompt: 'Investigate flaky test' }, 1), + childAgentMessage('child-user', 'child prompt', 2, 'task-1'), + childAgentMessage('child-agent', 'child answer', 3, 'task-1') + ] + + const result = annotateSubagentSidechains(messages) + + expect(result[1]).toMatchObject({ isSidechain: true, sidechainKey: 'task-1' }) + expect(result[2]).toMatchObject({ isSidechain: true, sidechainKey: 'task-1' }) + }) + + it('does not rewrite explicit Claude sidechain keys to the enclosing message id when multiple tool calls exist', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-parent', 'other-tool', 'OtherTool', { prompt: 'ignore' }, 1), + agentToolCall('msg-parent', 'task-1', 'Task', { prompt: 'Investigate flaky test' }, 1), + childAgentMessage('child-user', 'child prompt', 2, 'task-1') + ] + + const result = annotateSubagentSidechains(messages) + + expect(result[2]).toMatchObject({ isSidechain: true, sidechainKey: 'task-1' }) + expect(result[2]).not.toMatchObject({ sidechainKey: 'msg-parent' }) + }) +}) diff --git a/web/src/chat/tracer.ts b/web/src/chat/tracer.ts index db3981c81..352572bb1 100644 --- a/web/src/chat/tracer.ts +++ b/web/src/chat/tracer.ts @@ -65,7 +65,7 @@ export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] { if (content.type !== 'tool-call' || content.name !== 'Task') continue const input = content.input if (!isObject(input) || typeof input.prompt !== 'string') continue - state.promptToTaskId.set(input.prompt, message.id) + state.promptToTaskId.set(input.prompt, content.id) } } From c182b8eca8b115fd33d037639488808d3034cee8 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 09:48:00 +0800 Subject: [PATCH 72/82] fix: preserve Claude sidechain keys in web tracing --- web/src/chat/normalize.test.ts | 43 ++++++++++++++++ web/src/chat/normalizeAgent.ts | 4 +- web/src/chat/tracer.test.ts | 92 ++++++++++++++++++++++++++++++++++ web/src/chat/tracer.ts | 34 +++++++++++-- 4 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 web/src/chat/tracer.test.ts diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index a39e953e4..686d56bab 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -83,6 +83,49 @@ describe('normalizeDecryptedMessage', () => { }) }) + it('preserves Claude sidechain root prompt records with normalized sidechain keys', () => { + const message = makeMessage({ + role: 'agent', + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-1', + prompt: 'Investigate flaky test' + } + }, + content: { + type: 'output', + data: { + type: 'user', + isSidechain: true, + message: { + content: 'Investigate flaky test' + } + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'agent', + isSidechain: true, + sidechainKey: 'task-1', + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-1', + prompt: 'Investigate flaky test' + } + }, + content: [ + { + type: 'sidechain', + prompt: 'Investigate flaky test' + } + ] + }) + }) + it('keeps normal Codex payloads root-level when parentToolCallId is absent', () => { const message = makeMessage({ role: 'agent', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index def33b208..b17b9e0ca 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -144,7 +144,7 @@ function normalizeUserOutput( ): NormalizedMessage | null { const uuid = asString(data.uuid) ?? messageId const parentUUID = asString(data.parentUuid) ?? null - const isSidechain = Boolean(data.isSidechain) + const { isSidechain, sidechainKey } = resolveSidechainMetadata(data, meta) const message = isObject(data.message) ? data.message : null if (!message) return null @@ -170,6 +170,8 @@ function normalizeUserOutput( createdAt, role: 'agent', isSidechain: true, + ...(sidechainKey ? { sidechainKey } : {}), + meta, content: [{ type: 'sidechain', uuid, prompt: messageContent }] } } diff --git a/web/src/chat/tracer.test.ts b/web/src/chat/tracer.test.ts new file mode 100644 index 000000000..de18ec1ff --- /dev/null +++ b/web/src/chat/tracer.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { reduceChatBlocks } from './reducer' +import type { NormalizedMessage } from './types' + +function agentToolCall( + messageId: string, + toolUseId: string, + name: string, + input: unknown, + createdAt: number +): NormalizedMessage { + return { + id: messageId, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: toolUseId, + name, + input, + description: null, + uuid: `${messageId}-uuid`, + parentUUID: null + }] + } +} + +function sidechainMessage( + id: string, + text: string, + createdAt: number +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: true, + content: [{ + type: 'sidechain', + uuid: `${id}-uuid`, + prompt: text + }] + } +} + +function sidechainText( + id: string, + text: string, + createdAt: number +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: true, + content: [{ + type: 'text', + text, + uuid: `${id}-uuid`, + parentUUID: null + }] + } +} + +describe('traceMessages sidechain fallback', () => { + it('does not attach ambiguous duplicate Task prompts to the later task', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-task-1', 'task-1', 'Task', { prompt: 'Investigate flaky test' }, 1), + agentToolCall('msg-task-2', 'task-2', 'Task', { prompt: 'Investigate flaky test' }, 2), + sidechainMessage('msg-root', 'Investigate flaky test', 3), + sidechainText('msg-child-user', 'child prompt', 4), + sidechainText('msg-child-agent', 'child answer', 5) + ] + + const reduced = reduceChatBlocks(messages, null) + const task1 = reduced.blocks.find((block): block is Extract<(typeof reduced.blocks)[number], { kind: 'tool-call' }> => block.kind === 'tool-call' && block.tool.id === 'task-1') + const task2 = reduced.blocks.find((block): block is Extract<(typeof reduced.blocks)[number], { kind: 'tool-call' }> => block.kind === 'tool-call' && block.tool.id === 'task-2') + + expect(task1?.children).toHaveLength(0) + expect(task2?.children).toHaveLength(0) + expect(reduced.blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'agent-text', text: 'child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) + ]) + ) + }) +}) diff --git a/web/src/chat/tracer.ts b/web/src/chat/tracer.ts index 352572bb1..059b03052 100644 --- a/web/src/chat/tracer.ts +++ b/web/src/chat/tracer.ts @@ -6,7 +6,7 @@ export type TracedMessage = NormalizedMessage & { } type TracerState = { - promptToTaskId: Map + promptToTaskIds: Map> uuidToSidechainId: Map orphanMessages: Map } @@ -49,9 +49,24 @@ function processOrphans(state: TracerState, parentUuid: string, sidechainId: str return results } +function addPromptTaskId(state: TracerState, prompt: string, taskId: string): void { + const existing = state.promptToTaskIds.get(prompt) + if (existing) { + existing.add(taskId) + return + } + state.promptToTaskIds.set(prompt, new Set([taskId])) +} + +function resolvePromptTaskId(state: TracerState, prompt: string): string | null { + const taskIds = state.promptToTaskIds.get(prompt) + if (!taskIds || taskIds.size !== 1) return null + return taskIds.values().next().value ?? null +} + export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] { const state: TracerState = { - promptToTaskId: new Map(), + promptToTaskIds: new Map(), uuidToSidechainId: new Map(), orphanMessages: new Map() } @@ -65,7 +80,7 @@ export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] { if (content.type !== 'tool-call' || content.name !== 'Task') continue const input = content.input if (!isObject(input) || typeof input.prompt !== 'string') continue - state.promptToTaskId.set(input.prompt, content.id) + addPromptTaskId(state, input.prompt, content.id) } } @@ -78,12 +93,23 @@ export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] { const uuid = getMessageUuid(message) const parentUuid = getParentUuid(message) + if (message.sidechainKey) { + if (uuid) { + state.uuidToSidechainId.set(uuid, message.sidechainKey) + } + results.push({ ...message, sidechainId: message.sidechainKey }) + if (uuid) { + results.push(...processOrphans(state, uuid, message.sidechainKey)) + } + continue + } + // Sidechain root matching (prompt == Task.prompt). let sidechainId: string | undefined if (message.role === 'agent') { for (const content of message.content) { if (content.type !== 'sidechain') continue - const taskId = state.promptToTaskId.get(content.prompt) + const taskId = resolvePromptTaskId(state, content.prompt) if (taskId) { sidechainId = taskId break From e74a36b39c14b54e08022ba345e27ca78a26b8e9 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 09:53:42 +0800 Subject: [PATCH 73/82] fix: preserve unresolved web sidechain descendants --- web/src/chat/tracer.test.ts | 44 +++++++++++++++++++++++++++++++++++++ web/src/chat/tracer.ts | 22 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/web/src/chat/tracer.test.ts b/web/src/chat/tracer.test.ts index de18ec1ff..21ba009b2 100644 --- a/web/src/chat/tracer.test.ts +++ b/web/src/chat/tracer.test.ts @@ -66,6 +66,27 @@ function sidechainText( } } +function sidechainTextWithParent( + id: string, + text: string, + createdAt: number, + parentUUID: string +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: true, + content: [{ + type: 'text', + text, + uuid: `${id}-uuid`, + parentUUID + }] + } +} + describe('traceMessages sidechain fallback', () => { it('does not attach ambiguous duplicate Task prompts to the later task', () => { const messages: NormalizedMessage[] = [ @@ -89,4 +110,27 @@ describe('traceMessages sidechain fallback', () => { ]) ) }) + + it('keeps ambiguous unresolved sidechain descendants visible at the root level', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-task-1', 'task-1', 'Task', { prompt: 'Investigate flaky test' }, 1), + agentToolCall('msg-task-2', 'task-2', 'Task', { prompt: 'Investigate flaky test' }, 2), + sidechainMessage('msg-root', 'Investigate flaky test', 3), + sidechainTextWithParent('msg-child-user', 'child prompt', 4, 'msg-root-uuid'), + sidechainTextWithParent('msg-child-agent', 'child answer', 5, 'msg-child-user-uuid') + ] + + const reduced = reduceChatBlocks(messages, null) + const task1 = reduced.blocks.find((block): block is Extract<(typeof reduced.blocks)[number], { kind: 'tool-call' }> => block.kind === 'tool-call' && block.tool.id === 'task-1') + const task2 = reduced.blocks.find((block): block is Extract<(typeof reduced.blocks)[number], { kind: 'tool-call' }> => block.kind === 'tool-call' && block.tool.id === 'task-2') + + expect(task1?.children).toHaveLength(0) + expect(task2?.children).toHaveLength(0) + expect(reduced.blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'agent-text', text: 'child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) + ]) + ) + }) }) diff --git a/web/src/chat/tracer.ts b/web/src/chat/tracer.ts index 059b03052..50eac34c9 100644 --- a/web/src/chat/tracer.ts +++ b/web/src/chat/tracer.ts @@ -49,6 +49,24 @@ function processOrphans(state: TracerState, parentUuid: string, sidechainId: str return results } +function flushOrphansAsRoot(state: TracerState, parentUuid: string): TracedMessage[] { + const results: TracedMessage[] = [] + const orphans = state.orphanMessages.get(parentUuid) + if (!orphans) return results + state.orphanMessages.delete(parentUuid) + + for (const orphan of orphans) { + results.push({ ...orphan }) + + const uuid = getMessageUuid(orphan) + if (uuid) { + results.push(...flushOrphansAsRoot(state, uuid)) + } + } + + return results +} + function addPromptTaskId(state: TracerState, prompt: string, taskId: string): void { const existing = state.promptToTaskIds.get(prompt) if (existing) { @@ -145,5 +163,9 @@ export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] { results.push({ ...message }) } + for (const [parentUuid] of state.orphanMessages) { + results.push(...flushOrphansAsRoot(state, parentUuid)) + } + return results } From a95d9aa3cc21fecf99e6dd0c5fabce0bc97b0a80 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 09:57:28 +0800 Subject: [PATCH 74/82] fix: preserve unconsumed web sidechain groups --- web/src/chat/reducer.test.ts | 40 ++++++++++++++++++++++++++++++++++++ web/src/chat/reducer.ts | 16 +++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index a90b84656..1691b9adc 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -329,4 +329,44 @@ describe('reduceChatBlocks', () => { ) expect(otherBlock?.children).toHaveLength(0) }) + + it('keeps explicit sidechain transcripts visible when the parent card is missing from the current slice', () => { + const messages: NormalizedMessage[] = [ + { + ...userText('child-user', 'root prompt', 1), + isSidechain: true, + sidechainKey: 'task-missing', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-missing' + } + } + }, + { + ...agentText('child-agent', 'child reply', 2), + isSidechain: true, + sidechainKey: 'task-missing', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-missing' + } + } + } + ] + + const reduced = reduceChatBlocks(messages, null) + const parentBlock = reduced.blocks.find( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.id === 'task-missing' + ) + + expect(parentBlock).toBeUndefined() + expect(reduced.blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: 'root prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'child reply' }) + ]) + ) + }) }) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 878139754..2ac53607e 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -52,6 +52,21 @@ function attachCodexSpawnChildren( } } +function appendUnconsumedSidechainGroups( + blocks: ChatBlock[], + groups: Map, + consumedGroupIds: Set, + reduceGroup: (groupId: string) => ChatBlock[] +): void { + for (const [groupId, sidechain] of groups) { + if (consumedGroupIds.has(groupId) || sidechain.length === 0) { + continue + } + + blocks.push(...reduceGroup(groupId)) + } +} + function extractSpawnAgentId(block: ToolCallBlock): string | null { const result = isObject(block.tool.result) ? block.tool.result : null return result && typeof result.agent_id === 'string' && result.agent_id.length > 0 @@ -136,6 +151,7 @@ export function reduceChatBlocks( attachCodexSpawnChildren(rootResult.blocks, groups, consumedGroupIds, reduceGroup) reattachWaitBackfilledChildReplies(rootResult.blocks) + appendUnconsumedSidechainGroups(rootResult.blocks, groups, consumedGroupIds, reduceGroup) // Only create permission-only tool cards when there is no tool call/result in the transcript. // Also skip if the permission is older than the oldest message in the current view, From a9f357328133fdcfba4898a7e9d536499810b8de Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 10:03:05 +0800 Subject: [PATCH 75/82] fix: preserve unconsumed sidechain prompts in order --- web/src/chat/reducer.test.ts | 59 +++++++++++++++++++++++++++++++++ web/src/chat/reducer.ts | 14 ++++++-- web/src/chat/reducerTimeline.ts | 14 +++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index 1691b9adc..ed4ab6302 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -369,4 +369,63 @@ describe('reduceChatBlocks', () => { ]) ) }) + + it('renders a root-only explicit sidechain prompt when no parent card exists in the current slice', () => { + const messages: NormalizedMessage[] = [ + { + id: 'msg-root-prompt', + localId: null, + createdAt: 1, + role: 'agent', + isSidechain: true, + sidechainKey: 'task-missing', + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-missing', + prompt: 'Investigate flaky test' + } + }, + content: [{ + type: 'sidechain', + uuid: 'msg-root-prompt-uuid', + prompt: 'Investigate flaky test' + }] + } + ] + + const reduced = reduceChatBlocks(messages, null) + + expect(reduced.blocks).toEqual([ + expect.objectContaining({ + kind: 'user-text', + text: 'Investigate flaky test' + }) + ]) + }) + + it('keeps preserved sidechain blocks in chronological order with root blocks', () => { + const messages: NormalizedMessage[] = [ + { + id: 'msg-root-prompt', + localId: null, + createdAt: 1, + role: 'agent', + isSidechain: true, + sidechainKey: 'task-missing', + content: [{ + type: 'sidechain', + uuid: 'msg-root-prompt-uuid', + prompt: 'Investigate flaky test' + }] + }, + agentText('root-update', 'Parent progress update', 2) + ] + + const reduced = reduceChatBlocks(messages, null) + + expect(reduced.blocks.map((block) => block.kind)).toEqual(['user-text', 'agent-text']) + expect(reduced.blocks[0]).toMatchObject({ kind: 'user-text', text: 'Investigate flaky test' }) + expect(reduced.blocks[1]).toMatchObject({ kind: 'agent-text', text: 'Parent progress update' }) + }) }) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 2ac53607e..efabbea18 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -58,13 +58,23 @@ function appendUnconsumedSidechainGroups( consumedGroupIds: Set, reduceGroup: (groupId: string) => ChatBlock[] ): void { + const preservedBlocks: ChatBlock[] = [] for (const [groupId, sidechain] of groups) { if (consumedGroupIds.has(groupId) || sidechain.length === 0) { continue } - blocks.push(...reduceGroup(groupId)) + preservedBlocks.push(...reduceGroup(groupId)) } + + if (preservedBlocks.length === 0) return + + const merged = [...blocks, ...preservedBlocks].sort((a, b) => { + if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt + return 0 + }) + + blocks.splice(0, blocks.length, ...merged) } function extractSpawnAgentId(block: ToolCallBlock): string | null { @@ -144,7 +154,7 @@ export function reduceChatBlocks( const reduceGroup = (groupId: string): ChatBlock[] => { const sidechain = groups.get(groupId) ?? [] - const child = reduceTimeline(sidechain, reducerContext) + const child = reduceTimeline(sidechain, reducerContext, { renderSidechainPromptAsUserText: true }) hasReadyEvent = hasReadyEvent || child.hasReadyEvent return child.blocks } diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 7a8dcd378..a2f82d0cd 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -12,6 +12,9 @@ export function reduceTimeline( consumedGroupIds: Set titleChangesByToolUseId: Map emittedTitleChangeToolUseIds: Set + }, + options?: { + renderSidechainPromptAsUserText?: boolean } ): { blocks: ChatBlock[]; toolBlocksById: Map; hasReadyEvent: boolean } { const blocks: ChatBlock[] = [] @@ -240,7 +243,16 @@ export function reduceTimeline( } if (c.type === 'sidechain') { - // Skip - the prompt is already visible in the parent Task tool call's input + if (options?.renderSidechainPromptAsUserText) { + blocks.push({ + kind: 'user-text', + id: `${msg.id}:${idx}`, + localId: msg.localId, + createdAt: msg.createdAt, + text: c.prompt, + meta: msg.meta + }) + } continue } } From 358809924b86204ab67cfd882235dbdde2b062b0 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 10:11:25 +0800 Subject: [PATCH 76/82] feat: share subagent preview card for task runs --- .../CodexSubagentPreviewCard.test.tsx | 79 ++++ .../messages/CodexSubagentPreviewCard.tsx | 354 +---------------- .../messages/SubagentPreviewCard.tsx | 357 ++++++++++++++++++ .../AssistantChat/messages/ToolMessage.tsx | 11 +- 4 files changed, 446 insertions(+), 355 deletions(-) create mode 100644 web/src/components/AssistantChat/messages/SubagentPreviewCard.tsx diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx index a3baabc9c..d0826ecaf 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -75,6 +75,54 @@ function makeSpawnBlock(): ToolCallBlock { } } +function makeTaskBlock(): ToolCallBlock { + const delegatedPrompt = 'Investigate flaky Task sidechain rendering' + + return { + kind: 'tool-call', + id: 'task-block-1', + localId: null, + createdAt: 1, + tool: { + id: 'task-1', + name: 'Task', + state: 'completed', + input: { + prompt: delegatedPrompt + }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null + }, + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-1', + prompt: delegatedPrompt + } + }, + children: [ + { + kind: 'user-text', + id: 'task-child-user-1', + localId: null, + createdAt: 2, + text: delegatedPrompt, + meta: undefined + }, + { + kind: 'agent-text', + id: 'task-child-agent-1', + localId: null, + createdAt: 3, + text: 'Task child answer', + meta: undefined + } + ] + } +} + function renderWithProviders(ui: ReactElement) { if (typeof window !== 'undefined' && !window.matchMedia) { window.matchMedia = () => ({ @@ -160,6 +208,32 @@ describe('CodexSubagentPreviewCard', () => { expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() }) + it('renders HappyToolMessage as the lifecycle card for Claude Task sidechains', () => { + const block = makeTaskBlock() + const props: any = { + artifact: block, + toolName: 'Task', + argsText: '{}', + result: undefined, + isError: false, + status: { type: 'complete' } + } + + renderWithProviders( + + ) + + expect(screen.getByText('Subagent conversation')).toBeInTheDocument() + expect(screen.getByText('Completed')).toBeInTheDocument() + expect(screen.getByText('Investigate flaky Task sidechain rendering')).toBeInTheDocument() + expect(screen.queryByText('Task child answer')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation/i })) + + expect(screen.getByText('Task child answer')).toBeInTheDocument() + expect(screen.getAllByText('Investigate flaky Task sidechain rendering').length).toBeGreaterThan(0) + }) + it('closes the dialog via the top close icon button', () => { const block = makeSpawnBlock() @@ -177,4 +251,9 @@ describe('CodexSubagentPreviewCard', () => { const block = makeSpawnBlock() expect(getToolChildRenderMode(block)).toBe('codex-subagent-preview') }) + + it('marks Task children for preview rendering instead of inline expansion', () => { + const block = makeTaskBlock() + expect(getToolChildRenderMode(block)).toBe('codex-subagent-preview') + }) }) diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx index c1093e20b..863aa55dd 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx @@ -1,355 +1,11 @@ -import { useMemo, useState, type ReactNode } from 'react' import type { ToolCallBlock } from '@/chat/types' -import { isObject } from '@hapi/protocol' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' -import { CliOutputBlock } from '@/components/CliOutputBlock' -import { getEventPresentation } from '@/chat/presentation' -import { MarkdownRenderer } from '@/components/MarkdownRenderer' -import { ToolCard } from '@/components/ToolCard/ToolCard' -import { useHappyChatContext } from '@/components/AssistantChat/context' -import { getInputStringAny, truncate } from '@/lib/toolInputUtils' - -function getSpawnSummary(block: ToolCallBlock): { - title: string - subtitle: string | null - detail: string - prompt: string | null - promptPreview: string | null -} { - const input = isObject(block.tool.input) ? block.tool.input : null - const result = isObject(block.tool.result) ? block.tool.result : null - - const nickname = result && typeof result.nickname === 'string' && result.nickname.length > 0 - ? result.nickname - : getInputStringAny(input, ['nickname', 'name', 'agent_name']) - const prompt = getInputStringAny(input, ['message', 'messagePreview', 'prompt', 'description']) - - const subtitle = nickname && nickname.length > 0 ? nickname : null - const countLabel = `${block.children.length} nested block${block.children.length === 1 ? '' : 's'}` - - return { - title: 'Subagent conversation', - subtitle, - detail: countLabel, - prompt: prompt ?? null, - promptPreview: prompt ? truncate(prompt, 72) : null - } -} - -type LifecycleAction = { - type?: string - createdAt?: number - summary?: string -} - -type LifecycleSnapshot = { - status: 'running' | 'waiting' | 'completed' | 'error' | 'closed' - latestText: string | null - agentId: string | null - nickname: string | null - actions: LifecycleAction[] -} - -function isLifecycleStatus(value: unknown): value is LifecycleSnapshot['status'] { - return value === 'running' || value === 'waiting' || value === 'completed' || value === 'error' || value === 'closed' -} - -function getLifecycleCandidate(block: ToolCallBlock): unknown { - if (isObject(block.lifecycle)) return block.lifecycle - const meta = block.meta - if (!isObject(meta)) return null - if (isObject(meta.codexLifecycle)) return meta.codexLifecycle - if (isObject(meta.lifecycle)) return meta.lifecycle - if (isObject(meta.codexAgentLifecycle)) return meta.codexAgentLifecycle - return meta -} - -function getLifecycleSnapshot(block: ToolCallBlock): LifecycleSnapshot { - const meta = getLifecycleCandidate(block) - const agentIdFromMeta = isObject(meta) && typeof meta.agentId === 'string' ? meta.agentId : null - const nicknameFromMeta = isObject(meta) && typeof meta.nickname === 'string' ? meta.nickname : null - const statusFromMeta = isObject(meta) && isLifecycleStatus(meta.status) ? meta.status : null - const latestTextFromMeta = isObject(meta) && typeof meta.latestText === 'string' - ? meta.latestText - : isObject(meta) && typeof meta.latest === 'string' - ? meta.latest - : isObject(meta) && typeof meta.message === 'string' - ? meta.message - : null - const actionsFromMeta = isObject(meta) && Array.isArray(meta.actions) ? meta.actions : [] - const prompt = getInputStringAny(isObject(block.tool.input) ? block.tool.input : null, ['message', 'messagePreview', 'prompt', 'description']) - const result = isObject(block.tool.result) ? block.tool.result : null - const agentIdFromResult = result && typeof result.agent_id === 'string' ? result.agent_id : null - const nicknameFromResult = result && typeof result.nickname === 'string' ? result.nickname : null - - const status: LifecycleSnapshot['status'] = statusFromMeta ?? ( - block.tool.state === 'completed' - ? 'completed' - : block.tool.state === 'error' - ? 'error' - : block.tool.state === 'pending' - ? 'waiting' - : 'running' - ) - - const latestText = latestTextFromMeta ?? (prompt ? truncate(prompt, 120) : null) - - return { - status, - latestText, - agentId: agentIdFromMeta ?? agentIdFromResult, - nickname: nicknameFromMeta ?? nicknameFromResult, - actions: actionsFromMeta.filter((action): action is LifecycleAction => isObject(action)) - } -} - -function getLifecycleStatusLabel(status: LifecycleSnapshot['status']): string { - if (status === 'waiting') return 'Waiting' - if (status === 'completed') return 'Completed' - if (status === 'error') return 'Error' - if (status === 'closed') return 'Closed' - return 'Running' -} - -function getLifecycleStatusClass(status: LifecycleSnapshot['status']): string { - if (status === 'completed') return 'bg-emerald-100 text-emerald-700 border-emerald-200' - if (status === 'error') return 'bg-red-100 text-red-700 border-red-200' - if (status === 'closed') return 'bg-slate-100 text-slate-700 border-slate-200' - if (status === 'waiting') return 'bg-amber-100 text-amber-700 border-amber-200' - return 'bg-blue-100 text-blue-700 border-blue-200' -} - -function OpenIcon() { - return ( - - ) -} - -function CloseIcon() { - return ( - - ) -} - -function normalizePromptForCompare(text: string): string { - return text.replace(/\s+/g, ' ').trim() -} - -function dedupeLeadingPrompt( - blocks: ToolCallBlock['children'], - prompt: string | null -): ToolCallBlock['children'] { - if (!prompt || blocks.length === 0) return blocks - const [first, ...rest] = blocks - if (first.kind !== 'user-text') return blocks - - const promptNorm = normalizePromptForCompare(prompt) - const firstNorm = normalizePromptForCompare(first.text) - if (!promptNorm || !firstNorm) return blocks - - if (promptNorm === firstNorm || promptNorm.includes(firstNorm) || firstNorm.includes(promptNorm)) { - return rest - } - - return blocks -} - -function SubagentBlockList(props: { blocks: ToolCallBlock['children'] }) { - const ctx = useHappyChatContext() - - return ( -
- {props.blocks.map((block) => { - if (block.kind === 'user-text') { - return ( -
-
{block.text}
-
- ) - } - - if (block.kind === 'agent-text') { - return ( -
- -
- ) - } - - if (block.kind === 'agent-reasoning') { - return ( -
- {block.text} -
- ) - } - - if (block.kind === 'cli-output') { - const alignClass = block.source === 'user' ? 'ml-auto w-full max-w-[92%]' : '' - return ( -
-
- -
-
- ) - } - - if (block.kind === 'agent-event') { - const presentation = getEventPresentation(block.event) - return ( -
-
- - {presentation.icon ? : null} - {presentation.text} - -
-
- ) - } - - if (block.kind === 'tool-call') { - return ( -
- - {block.children.length > 0 ? ( -
- -
- ) : null} -
- ) - } - - return null - })} -
- ) -} +import { SubagentPreviewCard } from '@/components/AssistantChat/messages/SubagentPreviewCard' export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { - const summary = getSpawnSummary(props.block) - const lifecycle = getLifecycleSnapshot(props.block) - const dialogTitle = summary.subtitle ? `${summary.title} — ${summary.subtitle}` : summary.title - const actionCount = lifecycle.actions.length - const [open, setOpen] = useState(false) - const dialogBlocks = useMemo( - () => dedupeLeadingPrompt(props.block.children, summary.prompt), - [props.block.children, summary.prompt] - ) - return ( - - - - - - - - - - {dialogTitle} - - Nested child transcript for this Codex subagent run. - - -
-
-
-
- - {getLifecycleStatusLabel(lifecycle.status)} - - {actionCount > 0 ? {actionCount} actions : null} -
- {summary.prompt ? ( -
- {summary.prompt} -
- ) : null} - {lifecycle.latestText ? ( -
- {lifecycle.latestText} -
- ) : !summary.prompt && summary.promptPreview ? ( -
- {summary.promptPreview} -
- ) : null} -
- -
-
-
-
+ ) } diff --git a/web/src/components/AssistantChat/messages/SubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/SubagentPreviewCard.tsx new file mode 100644 index 000000000..f0734e2c7 --- /dev/null +++ b/web/src/components/AssistantChat/messages/SubagentPreviewCard.tsx @@ -0,0 +1,357 @@ +import { useMemo, useState, type ReactNode } from 'react' +import type { ToolCallBlock } from '@/chat/types' +import { isObject } from '@hapi/protocol' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { CliOutputBlock } from '@/components/CliOutputBlock' +import { getEventPresentation } from '@/chat/presentation' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { ToolCard } from '@/components/ToolCard/ToolCard' +import { useHappyChatContext } from '@/components/AssistantChat/context' +import { getInputStringAny, truncate } from '@/lib/toolInputUtils' + +function getSubagentSummary(block: ToolCallBlock): { + title: string + subtitle: string | null + detail: string + prompt: string | null + promptPreview: string | null +} { + const input = isObject(block.tool.input) ? block.tool.input : null + const result = isObject(block.tool.result) ? block.tool.result : null + + const nickname = result && typeof result.nickname === 'string' && result.nickname.length > 0 + ? result.nickname + : getInputStringAny(input, ['nickname', 'name', 'agent_name']) + const prompt = getInputStringAny(input, ['message', 'messagePreview', 'prompt', 'description']) + + const subtitle = nickname && nickname.length > 0 ? nickname : null + const countLabel = `${block.children.length} nested block${block.children.length === 1 ? '' : 's'}` + + return { + title: 'Subagent conversation', + subtitle, + detail: countLabel, + prompt: prompt ?? null, + promptPreview: prompt ? truncate(prompt, 72) : null + } +} + +type LifecycleAction = { + type?: string + createdAt?: number + summary?: string +} + +type LifecycleSnapshot = { + status: 'running' | 'waiting' | 'completed' | 'error' | 'closed' + latestText: string | null + agentId: string | null + nickname: string | null + actions: LifecycleAction[] +} + +function isLifecycleStatus(value: unknown): value is LifecycleSnapshot['status'] { + return value === 'running' || value === 'waiting' || value === 'completed' || value === 'error' || value === 'closed' +} + +function getLifecycleCandidate(block: ToolCallBlock): unknown { + if (isObject(block.lifecycle)) return block.lifecycle + const meta = block.meta + if (!isObject(meta)) return null + if (isObject(meta.codexLifecycle)) return meta.codexLifecycle + if (isObject(meta.lifecycle)) return meta.lifecycle + if (isObject(meta.codexAgentLifecycle)) return meta.codexAgentLifecycle + if (isObject(meta.subagent)) return meta.subagent + return meta +} + +function getLifecycleSnapshot(block: ToolCallBlock): LifecycleSnapshot { + const meta = getLifecycleCandidate(block) + const agentIdFromMeta = isObject(meta) && typeof meta.agentId === 'string' ? meta.agentId : null + const nicknameFromMeta = isObject(meta) && typeof meta.nickname === 'string' ? meta.nickname : null + const statusFromMeta = isObject(meta) && isLifecycleStatus(meta.status) ? meta.status : null + const latestTextFromMeta = isObject(meta) && typeof meta.latestText === 'string' + ? meta.latestText + : isObject(meta) && typeof meta.latest === 'string' + ? meta.latest + : isObject(meta) && typeof meta.message === 'string' + ? meta.message + : null + const actionsFromMeta = isObject(meta) && Array.isArray(meta.actions) ? meta.actions : [] + const prompt = getInputStringAny(isObject(block.tool.input) ? block.tool.input : null, ['message', 'messagePreview', 'prompt', 'description']) + const result = isObject(block.tool.result) ? block.tool.result : null + const agentIdFromResult = result && typeof result.agent_id === 'string' ? result.agent_id : null + const nicknameFromResult = result && typeof result.nickname === 'string' ? result.nickname : null + + const status: LifecycleSnapshot['status'] = statusFromMeta ?? ( + block.tool.state === 'completed' + ? 'completed' + : block.tool.state === 'error' + ? 'error' + : block.tool.state === 'pending' + ? 'waiting' + : 'running' + ) + + const latestText = latestTextFromMeta ?? (prompt ? truncate(prompt, 120) : null) + + return { + status, + latestText, + agentId: agentIdFromMeta ?? agentIdFromResult, + nickname: nicknameFromMeta ?? nicknameFromResult, + actions: actionsFromMeta.filter((action): action is LifecycleAction => isObject(action)) + } +} + +function getLifecycleStatusLabel(status: LifecycleSnapshot['status']): string { + if (status === 'waiting') return 'Waiting' + if (status === 'completed') return 'Completed' + if (status === 'error') return 'Error' + if (status === 'closed') return 'Closed' + return 'Running' +} + +function getLifecycleStatusClass(status: LifecycleSnapshot['status']): string { + if (status === 'completed') return 'bg-emerald-100 text-emerald-700 border-emerald-200' + if (status === 'error') return 'bg-red-100 text-red-700 border-red-200' + if (status === 'closed') return 'bg-slate-100 text-slate-700 border-slate-200' + if (status === 'waiting') return 'bg-amber-100 text-amber-700 border-amber-200' + return 'bg-blue-100 text-blue-700 border-blue-200' +} + +function OpenIcon() { + return ( + + ) +} + +function CloseIcon() { + return ( + + ) +} + +function normalizePromptForCompare(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +function dedupeLeadingPrompt( + blocks: ToolCallBlock['children'], + prompt: string | null +): ToolCallBlock['children'] { + if (!prompt || blocks.length === 0) return blocks + const [first, ...rest] = blocks + if (first.kind !== 'user-text') return blocks + + const promptNorm = normalizePromptForCompare(prompt) + const firstNorm = normalizePromptForCompare(first.text) + if (!promptNorm || !firstNorm) return blocks + + if (promptNorm === firstNorm || promptNorm.includes(firstNorm) || firstNorm.includes(promptNorm)) { + return rest + } + + return blocks +} + +function SubagentBlockList(props: { blocks: ToolCallBlock['children'] }) { + const ctx = useHappyChatContext() + + return ( +
+ {props.blocks.map((block) => { + if (block.kind === 'user-text') { + return ( +
+
{block.text}
+
+ ) + } + + if (block.kind === 'agent-text') { + return ( +
+ +
+ ) + } + + if (block.kind === 'agent-reasoning') { + return ( +
+ {block.text} +
+ ) + } + + if (block.kind === 'cli-output') { + const alignClass = block.source === 'user' ? 'ml-auto w-full max-w-[92%]' : '' + return ( +
+
+ +
+
+ ) + } + + if (block.kind === 'agent-event') { + const presentation = getEventPresentation(block.event) + return ( +
+
+ + {presentation.icon ? : null} + {presentation.text} + +
+
+ ) + } + + if (block.kind === 'tool-call') { + return ( +
+ + {block.children.length > 0 ? ( +
+ +
+ ) : null} +
+ ) + } + + return null + })} +
+ ) +} + +export function SubagentPreviewCard(props: { block: ToolCallBlock; dialogDescription?: string }) { + const summary = getSubagentSummary(props.block) + const lifecycle = getLifecycleSnapshot(props.block) + const dialogTitle = summary.subtitle ? `${summary.title} — ${summary.subtitle}` : summary.title + const actionCount = lifecycle.actions.length + const [open, setOpen] = useState(false) + const dialogBlocks = useMemo( + () => dedupeLeadingPrompt(props.block.children, summary.prompt), + [props.block.children, summary.prompt] + ) + const dialogDescription = props.dialogDescription ?? 'Nested child transcript for this subagent run.' + + return ( + + + + + + + + + + {dialogTitle} + + {dialogDescription} + + +
+
+
+
+ + {getLifecycleStatusLabel(lifecycle.status)} + + {actionCount > 0 ? {actionCount} actions : null} +
+ {summary.prompt ? ( +
+ {summary.prompt} +
+ ) : null} + {lifecycle.latestText ? ( +
+ {lifecycle.latestText} +
+ ) : !summary.prompt && summary.promptPreview ? ( +
+ {summary.promptPreview} +
+ ) : null} +
+ +
+
+
+
+ ) +} diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 38e54c269..87f3493b6 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -8,7 +8,7 @@ import { CodeBlock } from '@/components/CodeBlock' import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { LazyRainbowText } from '@/components/LazyRainbowText' import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator' -import { CodexSubagentPreviewCard } from '@/components/AssistantChat/messages/CodexSubagentPreviewCard' +import { SubagentPreviewCard } from '@/components/AssistantChat/messages/SubagentPreviewCard' import { ToolCard } from '@/components/ToolCard/ToolCard' import { useHappyChatContext } from '@/components/AssistantChat/context' import { CliOutputBlock } from '@/components/CliOutputBlock' @@ -126,8 +126,7 @@ function HappyNestedBlockList(props: { export function getToolChildRenderMode(block: ToolCallBlock): 'none' | 'task' | 'codex-subagent-preview' | 'inline' { if (block.children.length === 0) return 'none' - if (block.tool.name === 'Task') return 'task' - if (block.tool.name === 'CodexSpawnAgent') return 'codex-subagent-preview' + if (block.tool.name === 'Task' || block.tool.name === 'CodexSpawnAgent') return 'codex-subagent-preview' return 'inline' } @@ -135,8 +134,8 @@ function renderToolBlock( block: ToolCallBlock, ctx: ReturnType ): ReactNode { - if (block.tool.name === 'CodexSpawnAgent') { - return + if (block.tool.name === 'Task' || block.tool.name === 'CodexSpawnAgent') { + return } return ( @@ -184,7 +183,7 @@ function renderToolChildren(block: ToolCallBlock): ReactNode | null { if (mode === 'codex-subagent-preview') { return (
- +
) } From 139febddf28e83824c3d64476603f693252ca734 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 10:18:12 +0800 Subject: [PATCH 77/82] fix: keep task pending children inline --- .../CodexSubagentPreviewCard.test.tsx | 119 +++++++++++++++++- .../AssistantChat/messages/ToolMessage.tsx | 53 ++++---- 2 files changed, 148 insertions(+), 24 deletions(-) diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx index d0826ecaf..efc722e48 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -123,6 +123,69 @@ function makeTaskBlock(): ToolCallBlock { } } +function makeTaskHybridBlock(): ToolCallBlock { + const delegatedPrompt = 'Investigate flaky Task sidechain rendering' + + return { + kind: 'tool-call', + id: 'task-block-2', + localId: null, + createdAt: 1, + tool: { + id: 'task-2', + name: 'Task', + state: 'completed', + input: { + prompt: delegatedPrompt + }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null + }, + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-2', + prompt: delegatedPrompt + } + }, + children: [ + { + kind: 'tool-call', + id: 'task-pending-child', + localId: null, + createdAt: 2, + tool: { + id: 'pending-1', + name: 'Bash', + state: 'pending', + input: { + command: ['echo', 'pending child'] + }, + createdAt: 2, + startedAt: null, + completedAt: null, + description: null, + permission: { + id: 'pending-approval-1', + status: 'pending' + } + }, + children: [] + }, + { + kind: 'agent-text', + id: 'task-child-agent-1', + localId: null, + createdAt: 3, + text: 'Task child answer', + meta: undefined + } + ] + } +} + function renderWithProviders(ui: ReactElement) { if (typeof window !== 'undefined' && !window.matchMedia) { window.matchMedia = () => ({ @@ -208,8 +271,8 @@ describe('CodexSubagentPreviewCard', () => { expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() }) - it('renders HappyToolMessage as the lifecycle card for Claude Task sidechains', () => { - const block = makeTaskBlock() + it('renders HappyToolMessage as the lifecycle card for Claude Task sidechains while keeping pending children inline', () => { + const block = makeTaskHybridBlock() const props: any = { artifact: block, toolName: 'Task', @@ -225,7 +288,8 @@ describe('CodexSubagentPreviewCard', () => { expect(screen.getByText('Subagent conversation')).toBeInTheDocument() expect(screen.getByText('Completed')).toBeInTheDocument() - expect(screen.getByText('Investigate flaky Task sidechain rendering')).toBeInTheDocument() + expect(screen.getAllByText('Investigate flaky Task sidechain rendering').length).toBeGreaterThan(0) + expect(screen.getByText('Waiting for approval…')).toBeInTheDocument() expect(screen.queryByText('Task child answer')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /Subagent conversation/i })) @@ -256,4 +320,53 @@ describe('CodexSubagentPreviewCard', () => { const block = makeTaskBlock() expect(getToolChildRenderMode(block)).toBe('codex-subagent-preview') }) + + it('keeps ordinary tool children inline instead of using the subagent preview card', () => { + const block: ToolCallBlock = { + kind: 'tool-call', + id: 'bash-block-1', + localId: null, + createdAt: 1, + tool: { + id: 'bash-1', + name: 'Bash', + state: 'completed', + input: { + command: ['echo', 'ordinary child'] + }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null + }, + children: [ + { + kind: 'agent-text', + id: 'bash-child-1', + localId: null, + createdAt: 2, + text: 'ordinary child transcript', + meta: undefined + } + ] + } + + expect(getToolChildRenderMode(block)).toBe('inline') + + const props: any = { + artifact: block, + toolName: 'Bash', + argsText: '{}', + result: undefined, + isError: false, + status: { type: 'complete' } + } + + renderWithProviders( + + ) + + expect(screen.queryByText('Subagent conversation')).not.toBeInTheDocument() + expect(screen.getByText('ordinary child transcript')).toBeInTheDocument() + }) }) diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 87f3493b6..688458a5d 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -8,6 +8,7 @@ import { CodeBlock } from '@/components/CodeBlock' import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { LazyRainbowText } from '@/components/LazyRainbowText' import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator' +import { CodexSubagentPreviewCard } from '@/components/AssistantChat/messages/CodexSubagentPreviewCard' import { SubagentPreviewCard } from '@/components/AssistantChat/messages/SubagentPreviewCard' import { ToolCard } from '@/components/ToolCard/ToolCard' import { useHappyChatContext } from '@/components/AssistantChat/context' @@ -134,8 +135,33 @@ function renderToolBlock( block: ToolCallBlock, ctx: ReturnType ): ReactNode { - if (block.tool.name === 'Task' || block.tool.name === 'CodexSpawnAgent') { - return + if (block.tool.name === 'CodexSpawnAgent') { + return + } + + if (block.tool.name === 'Task') { + const taskChildren = splitTaskChildren(block) + + return ( + <> + + {taskChildren.pending.length > 0 ? ( +
+ +
+ ) : null} +
+ +
+ + ) } return ( @@ -158,32 +184,17 @@ function renderToolChildren(block: ToolCallBlock): ReactNode | null { if (mode === 'none') return null if (mode === 'task') { - const taskChildren = splitTaskChildren(block) return ( - <> - {taskChildren.pending.length > 0 ? ( -
- -
- ) : null} - {taskChildren.rest.length > 0 ? ( -
- - Task details ({taskChildren.rest.length}) - -
- -
-
- ) : null} - +
+ +
) } if (mode === 'codex-subagent-preview') { return (
- +
) } From 5e9da6b9d19252899ba8571e2a21608fcf203e1f Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 10:21:16 +0800 Subject: [PATCH 78/82] fix: filter task preview transcript --- .../messages/CodexSubagentPreviewCard.test.tsx | 1 + .../components/AssistantChat/messages/ToolMessage.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx index efc722e48..c3eb6f96a 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -295,6 +295,7 @@ describe('CodexSubagentPreviewCard', () => { fireEvent.click(screen.getByRole('button', { name: /Subagent conversation/i })) expect(screen.getByText('Task child answer')).toBeInTheDocument() + expect(screen.getAllByText('Waiting for approval…')).toHaveLength(1) expect(screen.getAllByText('Investigate flaky Task sidechain rendering').length).toBeGreaterThan(0) }) diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 688458a5d..7ec2bac2e 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -48,6 +48,13 @@ function splitTaskChildren(block: ToolCallBlock): { pending: ChatBlock[]; rest: return { pending, rest } } +function createTaskPreviewBlock(block: ToolCallBlock, restChildren: ChatBlock[]): ToolCallBlock { + return { + ...block, + children: restChildren + } +} + function HappyNestedBlockList(props: { blocks: ChatBlock[] }) { @@ -141,6 +148,7 @@ function renderToolBlock( if (block.tool.name === 'Task') { const taskChildren = splitTaskChildren(block) + const previewBlock = createTaskPreviewBlock(block, taskChildren.rest) return ( <> @@ -158,7 +166,7 @@ function renderToolBlock( ) : null}
- +
) From 0f894b833497215f14f5cf23e020845ad30e47d2 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 13:43:51 +0800 Subject: [PATCH 79/82] refactor: re-import imported sessions instead of refresh --- hub/src/sync/syncEngine.test.ts | 195 +++++++----------- hub/src/sync/syncEngine.ts | 111 ++++++++-- hub/src/web/routes/importableSessions.test.ts | 4 +- .../NewSession/ImportExistingModal.test.tsx | 42 ++-- .../NewSession/ImportExistingModal.tsx | 13 +- .../NewSession/ImportableSessionList.tsx | 14 +- .../mutations/useImportableSessionActions.ts | 34 +-- web/src/lib/locales/en.ts | 6 +- web/src/lib/locales/zh-CN.ts | 6 +- 9 files changed, 236 insertions(+), 189 deletions(-) diff --git a/hub/src/sync/syncEngine.test.ts b/hub/src/sync/syncEngine.test.ts index da72a3aca..c296327ff 100644 --- a/hub/src/sync/syncEngine.test.ts +++ b/hub/src/sync/syncEngine.test.ts @@ -76,7 +76,7 @@ describe('SyncEngine codex import orchestration', () => { capturedSpawnArgs = args return { type: 'success', sessionId: 'spawned-session' } } - ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).waitForSessionSettled = async () => true const result = await engine.importExternalCodexSession('codex-thread-1', 'default') @@ -154,13 +154,13 @@ describe('SyncEngine codex import orchestration', () => { ) return { type: 'success', sessionId: imported.id } } - ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).waitForSessionSettled = async () => true const result = await engine.importExternalCodexSession('codex-thread-1', 'default') expect(result.type).toBe('success') if (result.type !== 'success') { - throw new Error(result.message) + throw new Error('expected success result') } const imported = engine.getSession(result.sessionId) expect(imported?.metadata?.name).toBe('Useful imported title') @@ -215,7 +215,7 @@ describe('SyncEngine codex import orchestration', () => { ) return { type: 'success', sessionId: spawned.id } } - ;(engine as any).waitForSessionActive = async () => false + ;(engine as any).waitForSessionSettled = async () => false const result = await engine.importExternalCodexSession('codex-thread-1', 'default') @@ -263,7 +263,7 @@ describe('SyncEngine codex import orchestration', () => { } }) - it('refreshes an imported codex session in place', async () => { + it('re-imports an imported codex session into a new HAPI session', async () => { const store = new Store(':memory:') const engine = new SyncEngine( store, @@ -295,22 +295,43 @@ describe('SyncEngine codex import orchestration', () => { ) let capturedSpawnArgs: unknown[] | null = null - let capturedMergeArgs: unknown[] | null = null + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/codex-thread-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt' + } + ] + }) ;(engine as any).rpcGateway.spawnSession = async (...args: unknown[]) => { capturedSpawnArgs = args - return { type: 'success', sessionId: 'spawned-codex-session' } - } - ;(engine as any).waitForSessionActive = async () => true - ;(engine as any).sessionCache.mergeSessions = async (...args: unknown[]) => { - capturedMergeArgs = args + const spawned = engine.getOrCreateSession( + 'spawned-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: spawned.id } } + ;(engine as any).waitForSessionSettled = async () => true const result = await engine.refreshExternalCodexSession('codex-thread-1', 'default') - expect(result).toEqual({ - type: 'success', - sessionId: imported.id - }) + expect(result.type).toBe('success') + if (result.type !== 'success') { + throw new Error('expected success result') + } if (capturedSpawnArgs === null) { throw new Error('spawn args were not captured') } @@ -330,26 +351,11 @@ describe('SyncEngine codex import orchestration', () => { if (refreshSpawnArgs[8] !== 'codex-thread-1') { throw new Error(`unexpected resume session id: ${String(refreshSpawnArgs[8])}`) } - if (capturedMergeArgs === null) { - throw new Error('merge args were not captured') - } - const refreshMergeArgs = capturedMergeArgs as unknown[] - if (refreshMergeArgs.length !== 3) { - throw new Error(`unexpected merge args length: ${refreshMergeArgs.length}`) - } - if (refreshMergeArgs[0] !== 'spawned-codex-session') { - throw new Error(`unexpected merge old session id: ${String(refreshMergeArgs[0])}`) - } - if (refreshMergeArgs[1] !== imported.id) { - throw new Error(`unexpected merge new session id: ${String(refreshMergeArgs[1])}`) - } - if (refreshMergeArgs[2] !== 'default') { - throw new Error(`unexpected merge namespace: ${String(refreshMergeArgs[2])}`) - } expect(engine.findSessionByExternalCodexSessionId('default', 'codex-thread-1')).toEqual({ - sessionId: imported.id + sessionId: result.sessionId }) expect(engine.getSession(imported.id)).toBeDefined() + expect(engine.getSession(imported.id)?.metadata?.codexSessionId).toBeUndefined() } finally { engine.stop() } @@ -414,21 +420,22 @@ describe('SyncEngine codex import orchestration', () => { ) return { type: 'success', sessionId: spawned.id } } - ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).waitForSessionSettled = async () => true const result = await engine.refreshExternalCodexSession('codex-thread-1', 'default') - expect(result).toEqual({ - type: 'success', - sessionId: imported.id - }) - expect(engine.getSession(imported.id)?.metadata?.name).toBe('Prompt fallback title') + expect(result.type).toBe('success') + if (result.type !== 'success') { + throw new Error('expected success result') + } + expect(engine.getSession(result.sessionId)?.metadata?.name).toBe('Prompt fallback title') + expect(engine.getSession(imported.id)?.metadata?.codexSessionId).toBeUndefined() } finally { engine.stop() } }) - it('keeps the existing imported mapping when refresh merge fails', async () => { + it('keeps the existing imported mapping when re-import replacement fails', async () => { const store = new Store(':memory:') const engine = new SyncEngine( store, @@ -474,16 +481,27 @@ describe('SyncEngine codex import orchestration', () => { ) return { type: 'success', sessionId: spawned.id } } - ;(engine as any).waitForSessionActive = async () => true - ;(engine as any).sessionCache.mergeSessions = async () => { - throw new Error('merge failed') - } + ;(engine as any).waitForSessionSettled = async () => true + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/codex-thread-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt' + } + ] + }) + ;(engine as any).store.sessions.updateSessionMetadata = () => ({ result: 'error' }) const result = await engine.refreshExternalCodexSession('codex-thread-1', 'default') expect(result).toEqual({ type: 'error', - message: 'merge failed', + message: 'Failed to detach old imported session mapping', code: 'refresh_failed' }) expect(engine.findSessionByExternalCodexSessionId('default', 'codex-thread-1')).toEqual({ @@ -494,74 +512,6 @@ describe('SyncEngine codex import orchestration', () => { engine.stop() } }) - - it('rolls back partial merge work when refresh merge fails mid-transaction', async () => { - const store = new Store(':memory:') - const engine = new SyncEngine( - store, - {} as never, - new RpcRegistry(), - { broadcast() {} } as never - ) - - try { - const machine = engine.getOrCreateMachine( - 'machine-1', - { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, - null, - 'default' - ) - engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) - - const imported = engine.getOrCreateSession( - 'imported-codex-session', - { - path: '/tmp/project', - host: 'localhost', - machineId: 'machine-1', - flavor: 'codex', - codexSessionId: 'codex-thread-1' - }, - null, - 'default', - 'gpt-5.4' - ) - store.messages.addMessage(imported.id, { text: 'existing message' }) - - const originalDeleteSession = store.sessions.deleteSession.bind(store.sessions) - ;(engine as any).rpcGateway.spawnSession = async () => { - const spawned = engine.getOrCreateSession( - 'spawned-codex-session', - { - path: '/tmp/project', - host: 'localhost', - machineId: 'machine-1', - flavor: 'codex', - codexSessionId: 'codex-thread-1' - }, - null, - 'default' - ) - store.messages.addMessage(spawned.id, { text: 'new message' }) - return { type: 'success', sessionId: spawned.id } - } - ;(engine as any).waitForSessionActive = async () => true - store.sessions.deleteSession = () => false - - const result = await engine.refreshExternalCodexSession('codex-thread-1', 'default') - - expect(result).toEqual({ - type: 'error', - message: 'Failed to delete old session during merge', - code: 'refresh_failed' - }) - expect(engine.getMessagesPage(imported.id, { limit: 10, beforeSeq: null }).messages).toHaveLength(1) - - store.sessions.deleteSession = originalDeleteSession - } finally { - engine.stop() - } - }) }) describe('SyncEngine claude import orchestration', () => { @@ -649,13 +599,13 @@ describe('SyncEngine claude import orchestration', () => { ) return { type: 'success', sessionId: imported.id } } - ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).waitForSessionSettled = async () => true const result = await engine.importExternalClaudeSession('claude-thread-1', 'default') expect(result.type).toBe('success') if (result.type !== 'success') { - throw new Error(result.message) + throw new Error('expected success result') } if (capturedSpawnArgs === null) { throw new Error('spawn args were not captured') @@ -682,7 +632,7 @@ describe('SyncEngine claude import orchestration', () => { } }) - it('refreshes an imported claude session in place', async () => { + it('re-imports an imported claude session into a new HAPI session', async () => { const store = new Store(':memory:') const engine = new SyncEngine( store, @@ -746,14 +696,14 @@ describe('SyncEngine claude import orchestration', () => { ) return { type: 'success', sessionId: spawned.id } } - ;(engine as any).waitForSessionActive = async () => true + ;(engine as any).waitForSessionSettled = async () => true const result = await engine.refreshExternalClaudeSession('claude-thread-1', 'default') - expect(result).toEqual({ - type: 'success', - sessionId: imported.id - }) + expect(result.type).toBe('success') + if (result.type !== 'success') { + throw new Error('expected success result') + } if (capturedSpawnArgs === null) { throw new Error('spawn args were not captured') } @@ -774,9 +724,10 @@ describe('SyncEngine claude import orchestration', () => { throw new Error(`unexpected resume session id: ${String(refreshSpawnArgs[8])}`) } expect(engine.findSessionByExternalClaudeSessionId('default', 'claude-thread-1')).toEqual({ - sessionId: imported.id + sessionId: result.sessionId }) - expect(engine.getSession(imported.id)?.metadata?.name).toBe('Prompt fallback title') + expect(engine.getSession(imported.id)?.metadata?.claudeSessionId).toBeUndefined() + expect(engine.getSession(result.sessionId)?.metadata?.name).toBe('Prompt fallback title') } finally { engine.stop() } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 0d23241e8..cd1bbbecc 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -446,8 +446,8 @@ export class SyncEngine { return { type: 'error', message: spawnResult.message, code: 'resume_failed' } } - const becameActive = await this.waitForSessionActive(spawnResult.sessionId) - if (!becameActive) { + const becameReady = await this.waitForSessionSettled(spawnResult.sessionId) + if (!becameReady) { return { type: 'error', message: 'Session failed to become active', code: 'resume_failed' } } @@ -483,6 +483,41 @@ export class SyncEngine { return false } + async waitForSessionSettled( + sessionId: string, + timeoutMs: number = 15_000, + stableMs: number = 800 + ): Promise { + const start = Date.now() + let lastSeq = -1 + let lastThinking: boolean | null = null + let lastChangeAt = Date.now() + + while (Date.now() - start < timeoutMs) { + const session = this.getSession(sessionId) + if (!session?.active) { + await new Promise((resolve) => setTimeout(resolve, 250)) + continue + } + + const latestMessage = this.store.messages.getMessages(sessionId, 1).at(-1) + const latestSeq = latestMessage?.seq ?? 0 + if (latestSeq !== lastSeq || session.thinking !== lastThinking) { + lastSeq = latestSeq + lastThinking = session.thinking + lastChangeAt = Date.now() + } + + if (!session.thinking && Date.now() - lastChangeAt >= stableMs) { + return true + } + + await new Promise((resolve) => setTimeout(resolve, 250)) + } + + return false + } + private getImportableAgentLabel(agent: 'codex' | 'claude'): 'Codex' | 'Claude' { return agent === 'codex' ? 'Codex' : 'Claude' } @@ -548,7 +583,7 @@ export class SyncEngine { } } - if (!(await this.waitForSessionActive(spawnResult.sessionId))) { + if (!(await this.waitForSessionSettled(spawnResult.sessionId))) { this.discardSpawnedSession(spawnResult.sessionId, namespace) return { type: 'error', @@ -587,27 +622,29 @@ export class SyncEngine { } const session = access.session - const metadata = session.metadata - if (!metadata || typeof metadata.path !== 'string') { + const sourceResult = await this.findImportableSessionSource(namespace, externalSessionId, agent) + if (sourceResult.type === 'error') { return { type: 'error', - message: 'Session metadata missing path', - code: 'resume_unavailable' + message: sourceResult.message, + code: sourceResult.code === 'no_machine_online' || sourceResult.code === 'session_not_found' + ? sourceResult.code + : 'refresh_failed' } } - const targetMachine = this.selectOnlineMachine(namespace, metadata) - if (!targetMachine) { + const cwd = sourceResult.session.cwd + if (typeof cwd !== 'string' || cwd.length === 0) { return { type: 'error', - message: 'No machine online', - code: 'no_machine_online' + message: `Importable ${this.getImportableAgentLabel(agent)} session is missing cwd`, + code: 'refresh_failed' } } const spawnResult = await this.rpcGateway.spawnSession( - targetMachine.id, - metadata.path, + sourceResult.machineId, + cwd, agent, session.model ?? undefined, undefined, @@ -626,7 +663,7 @@ export class SyncEngine { } } - if (!(await this.waitForSessionActive(spawnResult.sessionId))) { + if (!(await this.waitForSessionSettled(spawnResult.sessionId))) { this.discardSpawnedSession(spawnResult.sessionId, namespace) return { type: 'error', @@ -635,23 +672,23 @@ export class SyncEngine { } } - const importedTitle = await this.resolveImportableSessionTitle(namespace, externalSessionId, agent) + const importedTitle = this.getBestImportableSessionTitle(sourceResult.session) await this.applyImportableSessionTitle(spawnResult.sessionId, importedTitle) if (spawnResult.sessionId !== access.sessionId) { try { - await this.sessionCache.mergeSessions(spawnResult.sessionId, access.sessionId, namespace) + this.detachExternalSessionMapping(access.sessionId, namespace, agent) } catch (error) { this.discardSpawnedSession(spawnResult.sessionId, namespace) return { type: 'error', - message: error instanceof Error ? error.message : 'Failed to refresh imported session', + message: error instanceof Error ? error.message : 'Failed to replace imported session', code: 'refresh_failed' } } } - return { type: 'success', sessionId: access.sessionId } + return { type: 'success', sessionId: spawnResult.sessionId } } private async listImportableSessionsByAgent( @@ -804,6 +841,44 @@ export class SyncEngine { } } + private detachExternalSessionMapping( + sessionId: string, + namespace: string, + agent: 'codex' | 'claude' + ): void { + const session = this.getSessionByNamespace(sessionId, namespace) + if (!session?.metadata) { + return + } + + const nextMetadata = { ...session.metadata } + if (agent === 'codex') { + delete nextMetadata.codexSessionId + } else { + delete nextMetadata.claudeSessionId + } + + const update = (metadataVersion: number): boolean => { + const result = this.store.sessions.updateSessionMetadata( + sessionId, + nextMetadata, + metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + return result.result === 'success' + } + + if (!update(session.metadataVersion)) { + const refreshed = this.sessionCache.refreshSession(sessionId) + if (!refreshed || !update(refreshed.metadataVersion)) { + throw new Error('Failed to detach old imported session mapping') + } + } + + this.sessionCache.refreshSession(sessionId) + } + private discardSpawnedSession(sessionId: string, namespace: string): void { const deleted = this.store.sessions.deleteSession(sessionId, namespace) if (deleted) { diff --git a/hub/src/web/routes/importableSessions.test.ts b/hub/src/web/routes/importableSessions.test.ts index 0bafe2e03..2efa18d63 100644 --- a/hub/src/web/routes/importableSessions.test.ts +++ b/hub/src/web/routes/importableSessions.test.ts @@ -175,7 +175,7 @@ describe('importable sessions routes', () => { ]) }) - it('refreshes an external codex session in place', async () => { + it('re-imports an external codex session', async () => { const captured: Array<{ externalSessionId: string; namespace: string }> = [] const engine = { refreshExternalCodexSession: async (externalSessionId: string, namespace: string) => { @@ -237,7 +237,7 @@ describe('importable sessions routes', () => { ]) }) - it('refreshes an external claude session in place', async () => { + it('re-imports an external claude session', async () => { const captured: Array<{ externalSessionId: string; namespace: string }> = [] const engine = { refreshExternalClaudeSession: async (externalSessionId: string, namespace: string) => { diff --git a/web/src/components/NewSession/ImportExistingModal.test.tsx b/web/src/components/NewSession/ImportExistingModal.test.tsx index 117a03662..4c8649d5f 100644 --- a/web/src/components/NewSession/ImportExistingModal.test.tsx +++ b/web/src/components/NewSession/ImportExistingModal.test.tsx @@ -39,9 +39,9 @@ describe('ImportExistingModal', () => { }) useImportableSessionActionsMock.mockReturnValue({ importSession: vi.fn(), - refreshSession: vi.fn(), + reimportSession: vi.fn(), importingSessionId: null, - refreshingSessionId: null, + reimportingSessionId: null, error: null, }) }) @@ -67,7 +67,7 @@ describe('ImportExistingModal', () => { renderModal() expect(screen.getByRole('button', { name: 'Open in HAPI' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Refresh from source' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Re-import from source' })).toBeInTheDocument() expect(useImportableSessionsMock).toHaveBeenCalledWith(expect.anything(), 'codex', true) expect(useImportableSessionActionsMock).toHaveBeenCalledWith(expect.anything(), 'codex') }) @@ -95,9 +95,9 @@ describe('ImportExistingModal', () => { }) useImportableSessionActionsMock.mockReturnValue({ importSession, - refreshSession: vi.fn(), + reimportSession: vi.fn(), importingSessionId: null, - refreshingSessionId: null, + reimportingSessionId: null, error: null, }) @@ -132,9 +132,9 @@ describe('ImportExistingModal', () => { }) useImportableSessionActionsMock.mockReturnValue({ importSession, - refreshSession: vi.fn(), + reimportSession: vi.fn(), importingSessionId: null, - refreshingSessionId: null, + reimportingSessionId: null, error: null, }) @@ -146,8 +146,11 @@ describe('ImportExistingModal', () => { }) }) - it('switches to the Claude tab and loads Claude sessions with the same action model', () => { - const refreshSession = vi.fn() + it('switches to the Claude tab and loads Claude sessions with the same action model', async () => { + const reimportSession = vi.fn().mockResolvedValue({ + type: 'success', + sessionId: 'hapi-claude-2', + }) const onOpenSession = vi.fn() useImportableSessionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ @@ -170,9 +173,9 @@ describe('ImportExistingModal', () => { })) useImportableSessionActionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ importSession: vi.fn(), - refreshSession: agent === 'claude' ? refreshSession : vi.fn(), + reimportSession: agent === 'claude' ? reimportSession : vi.fn(), importingSessionId: null, - refreshingSessionId: null, + reimportingSessionId: null, error: null, })) @@ -183,13 +186,16 @@ describe('ImportExistingModal', () => { expect(useImportableSessionsMock).toHaveBeenLastCalledWith(expect.anything(), 'claude', true) expect(useImportableSessionActionsMock).toHaveBeenLastCalledWith(expect.anything(), 'claude') expect(screen.getByRole('button', { name: 'Open in HAPI' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Refresh from source' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Re-import from source' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Open in HAPI' })) expect(onOpenSession).toHaveBeenCalledWith('hapi-claude-1') - fireEvent.click(screen.getByRole('button', { name: 'Refresh from source' })) - expect(refreshSession).toHaveBeenCalledWith('claude-external-1') + fireEvent.click(screen.getByRole('button', { name: 'Re-import from source' })) + expect(reimportSession).toHaveBeenCalledWith('claude-external-1') + await vi.waitFor(() => { + expect(onOpenSession).toHaveBeenCalledWith('hapi-claude-2') + }) }) it('does not leak Codex action state into the Claude tab', () => { @@ -213,9 +219,9 @@ describe('ImportExistingModal', () => { const [error] = useState(agent === 'codex' ? 'Codex failed' : null) return { importSession: vi.fn(), - refreshSession: vi.fn(), + reimportSession: vi.fn(), importingSessionId: null, - refreshingSessionId: null, + reimportingSessionId: null, error, } }) @@ -257,9 +263,9 @@ describe('ImportExistingModal', () => { })) useImportableSessionActionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ importSession: agent === 'claude' ? importSession : vi.fn(), - refreshSession: vi.fn(), + reimportSession: vi.fn(), importingSessionId: null, - refreshingSessionId: null, + reimportingSessionId: null, error: null, })) diff --git a/web/src/components/NewSession/ImportExistingModal.tsx b/web/src/components/NewSession/ImportExistingModal.tsx index d9f84fddf..2f49e5e03 100644 --- a/web/src/components/NewSession/ImportExistingModal.tsx +++ b/web/src/components/NewSession/ImportExistingModal.tsx @@ -19,9 +19,9 @@ function ImportExistingAgentPanel(props: { const { sessions, isLoading, error, refetch } = useImportableSessions(props.api, props.agent, props.open) const { importSession, - refreshSession, + reimportSession, importingSessionId, - refreshingSessionId, + reimportingSessionId, error: actionError, } = useImportableSessionActions(props.api, props.agent) const [selectedExternalSessionId, setSelectedExternalSessionId] = useState(null) @@ -59,6 +59,11 @@ function ImportExistingAgentPanel(props: { props.onOpenSession(result.sessionId) } + const handleReimport = async (externalSessionId: string) => { + const result = await reimportSession(externalSessionId) + props.onOpenSession(result.sessionId) + } + return (
@@ -89,10 +94,10 @@ function ImportExistingAgentPanel(props: { sessions={filteredSessions} selectedExternalSessionId={selectedExternalSessionId} importingSessionId={importingSessionId} - refreshingSessionId={refreshingSessionId} + reimportingSessionId={reimportingSessionId} onSelect={setSelectedExternalSessionId} onImport={(externalSessionId) => void handleImport(externalSessionId)} - onRefresh={(externalSessionId) => void refreshSession(externalSessionId)} + onReimport={(externalSessionId) => void handleReimport(externalSessionId)} onOpen={props.onOpenSession} /> )} diff --git a/web/src/components/NewSession/ImportableSessionList.tsx b/web/src/components/NewSession/ImportableSessionList.tsx index 0579eb36b..d8c40bd81 100644 --- a/web/src/components/NewSession/ImportableSessionList.tsx +++ b/web/src/components/NewSession/ImportableSessionList.tsx @@ -24,10 +24,10 @@ export function ImportableSessionList(props: { sessions: ImportableSessionView[] selectedExternalSessionId: string | null importingSessionId: string | null - refreshingSessionId: string | null + reimportingSessionId: string | null onSelect: (externalSessionId: string) => void onImport: (externalSessionId: string) => void - onRefresh: (externalSessionId: string) => void + onReimport: (externalSessionId: string) => void onOpen: (sessionId: string) => void }) { const { t } = useTranslation() @@ -108,12 +108,12 @@ export function ImportableSessionList(props: { type="button" variant="secondary" className="w-full sm:w-auto" - onClick={() => props.onRefresh(selectedSession.externalSessionId)} - disabled={props.refreshingSessionId === selectedSession.externalSessionId} + onClick={() => props.onReimport(selectedSession.externalSessionId)} + disabled={props.reimportingSessionId === selectedSession.externalSessionId} > - {props.refreshingSessionId === selectedSession.externalSessionId - ? t('newSession.import.refreshing') - : t('newSession.import.refresh')} + {props.reimportingSessionId === selectedSession.externalSessionId + ? t('newSession.import.reimporting') + : t('newSession.import.reimport')} ) : ( diff --git a/web/src/hooks/mutations/useImportableSessionActions.ts b/web/src/hooks/mutations/useImportableSessionActions.ts index df70e65be..6c36bbf23 100644 --- a/web/src/hooks/mutations/useImportableSessionActions.ts +++ b/web/src/hooks/mutations/useImportableSessionActions.ts @@ -3,21 +3,31 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import type { ApiClient } from '@/api/client' import type { ExternalSessionActionResponse, ImportableSessionAgent } from '@/types/api' import { queryKeys } from '@/lib/query-keys' +import { fetchLatestMessages } from '@/lib/message-window-store' export function useImportableSessionActions(api: ApiClient | null, agent: ImportableSessionAgent): { importSession: (externalSessionId: string) => Promise - refreshSession: (externalSessionId: string) => Promise + reimportSession: (externalSessionId: string) => Promise importingSessionId: string | null - refreshingSessionId: string | null + reimportingSessionId: string | null error: string | null } { const queryClient = useQueryClient() - const invalidate = async () => { - await Promise.all([ + const invalidate = async (result?: ExternalSessionActionResponse) => { + const tasks: Array> = [ queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), queryClient.invalidateQueries({ queryKey: queryKeys.importableSessions(agent) }), - ]) + ] + + if (result?.sessionId) { + tasks.push(queryClient.invalidateQueries({ queryKey: queryKeys.session(result.sessionId) })) + if (api) { + tasks.push(fetchLatestMessages(api, result.sessionId)) + } + } + + await Promise.all(tasks) } const importMutation = useMutation({ @@ -30,7 +40,7 @@ export function useImportableSessionActions(api: ApiClient | null, agent: Import onSuccess: invalidate, }) - const refreshMutation = useMutation({ + const reimportMutation = useMutation({ mutationFn: async (externalSessionId: string) => { if (!api) { throw new Error('API unavailable') @@ -42,19 +52,19 @@ export function useImportableSessionActions(api: ApiClient | null, agent: Import useEffect(() => { importMutation.reset() - refreshMutation.reset() + reimportMutation.reset() }, [agent]) return { importSession: importMutation.mutateAsync, - refreshSession: refreshMutation.mutateAsync, + reimportSession: reimportMutation.mutateAsync, importingSessionId: importMutation.isPending ? importMutation.variables ?? null : null, - refreshingSessionId: refreshMutation.isPending ? refreshMutation.variables ?? null : null, + reimportingSessionId: reimportMutation.isPending ? reimportMutation.variables ?? null : null, error: importMutation.error instanceof Error ? importMutation.error.message - : refreshMutation.error instanceof Error - ? refreshMutation.error.message - : importMutation.error || refreshMutation.error + : reimportMutation.error instanceof Error + ? reimportMutation.error.message + : importMutation.error || reimportMutation.error ? 'Failed to update importable session' : null, } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index d4d9eb519..35773a771 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -113,7 +113,7 @@ export default { 'newSession.creating': 'Creating…', 'newSession.import.entry': 'Import Existing', 'newSession.import.title': 'Import Existing', - 'newSession.import.description': 'Browse local Codex or Claude sessions and import or refresh them without leaving HAPI.', + 'newSession.import.description': 'Browse local Codex or Claude sessions and import or re-import them without leaving HAPI.', 'newSession.import.tabs.claude': 'Claude', 'newSession.import.searchPlaceholder': 'Search imported titles, prompts, or paths', 'newSession.import.refreshList': 'Refresh', @@ -128,8 +128,8 @@ export default { 'newSession.import.noPreview': 'No prompt preview available.', 'newSession.import.transcript': 'Transcript', 'newSession.import.open': 'Open in HAPI', - 'newSession.import.refresh': 'Refresh from source', - 'newSession.import.refreshing': 'Refreshing...', + 'newSession.import.reimport': 'Re-import from source', + 'newSession.import.reimporting': 'Re-importing...', 'newSession.import.cta': 'Import into HAPI', 'newSession.import.importing': 'Importing...', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index bfab71a78..af42b32f1 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -115,7 +115,7 @@ export default { 'newSession.creating': '创建中…', 'newSession.import.entry': '导入现有会话', 'newSession.import.title': '导入现有会话', - 'newSession.import.description': '浏览本地 Codex 或 Claude 会话,并在不离开 HAPI 的情况下导入或刷新它们。', + 'newSession.import.description': '浏览本地 Codex 或 Claude 会话,并在不离开 HAPI 的情况下导入或重新导入它们。', 'newSession.import.tabs.claude': 'Claude', 'newSession.import.searchPlaceholder': '搜索标题、提示词或路径', 'newSession.import.refreshList': '刷新', @@ -130,8 +130,8 @@ export default { 'newSession.import.noPreview': '没有可用的提示词预览。', 'newSession.import.transcript': '转录文件', 'newSession.import.open': '在 HAPI 中打开', - 'newSession.import.refresh': '从源刷新', - 'newSession.import.refreshing': '刷新中...', + 'newSession.import.reimport': '重新从源导入', + 'newSession.import.reimporting': '重新导入中...', 'newSession.import.cta': '导入到 HAPI', 'newSession.import.importing': '导入中...', From 7dde6afeb6da66929c3eddaebeffda1b55270aea Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 15:06:30 +0800 Subject: [PATCH 80/82] fix: make mobile image uploads work end-to-end Root causes:\n- mobile composer still relied on assistant-ui attachment state, which was brittle on Android/PWA and could open the picker without surfacing an uploaded attachment in the composer\n- attachment uploads for inactive sessions did not consistently reuse the same resume/session-resolution flow as text sends\n- hub HTTP routes inherited Bun's socket handler maxRequestBodySize, which was ~1MB from @socket.io/bun-engine, so a normal 2.06MB JPEG was rejected with HTTP 413 before the upload route logic even ran\n\nFixes:\n- replace the mobile attachment entry with a native label/input picker and move composer attachment state to HAPI-managed state in HappyComposer\n- route picker uploads and pasted images through shared upload/delete helpers that resolve the active session before uploading\n- reuse the router's session-resolution and navigation callbacks for attachment sends so uploads follow the same resume path as text sends\n- raise normal HTTP request body handling to at least 100MB instead of inheriting the socket engine's 1MB ceiling\n- add focused web and hub tests for the attachment flow and request-body-size floor\n\nResult:\n- Android can now select an image, see it appear in the composer, send it through the normal message path, and upload files larger than the old 1MB server ceiling --- hub/src/web/server.test.ts | 12 + hub/src/web/server.ts | 8 +- .../AttachmentPickerButton.test.tsx | 68 ++++++ .../AssistantChat/AttachmentPickerButton.tsx | 81 +++++++ .../AssistantChat/ComposerButtons.tsx | 30 +-- .../AssistantChat/HappyComposer.tsx | 205 +++++++++++++++--- web/src/components/SessionChat.tsx | 16 +- web/src/lib/attachmentAdapter.test.ts | 65 ++++++ web/src/lib/attachmentAdapter.ts | 105 ++++++++- web/src/router.tsx | 93 ++++---- 10 files changed, 564 insertions(+), 119 deletions(-) create mode 100644 hub/src/web/server.test.ts create mode 100644 web/src/components/AssistantChat/AttachmentPickerButton.test.tsx create mode 100644 web/src/components/AssistantChat/AttachmentPickerButton.tsx create mode 100644 web/src/lib/attachmentAdapter.test.ts diff --git a/hub/src/web/server.test.ts b/hub/src/web/server.test.ts new file mode 100644 index 000000000..12649523b --- /dev/null +++ b/hub/src/web/server.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'bun:test' +import { resolveMaxRequestBodySize } from './server' + +describe('resolveMaxRequestBodySize', () => { + it('does not inherit the socket handler 1MB limit for normal HTTP routes', () => { + expect(resolveMaxRequestBodySize(1_000_000)).toBe(100 * 1024 * 1024) + }) + + it('preserves larger socket handler limits when they already exceed the upload floor', () => { + expect(resolveMaxRequestBodySize(150 * 1024 * 1024)).toBe(150 * 1024 * 1024) + }) +}) diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index e820c2517..a77a820c4 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -29,6 +29,8 @@ import { loadEmbeddedAssetMap, type EmbeddedWebAsset } from './embeddedAssets' import { isBunCompiled } from '../utils/bunCompiled' import type { Store } from '../store' +const MIN_HTTP_REQUEST_BODY_SIZE = 100 * 1024 * 1024 + function findWebappDistDir(): { distDir: string; indexHtmlPath: string } { const candidates = [ join(process.cwd(), '..', 'web', 'dist'), @@ -55,6 +57,10 @@ function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response { }) } +export function resolveMaxRequestBodySize(socketHandlerMaxRequestBodySize: number): number { + return Math.max(socketHandlerMaxRequestBodySize, MIN_HTTP_REQUEST_BODY_SIZE) +} + function createWebApp(options: { getSyncEngine: () => SyncEngine | null getSseManager: () => SSEManager | null @@ -236,7 +242,7 @@ export async function startWebServer(options: { hostname: configuration.listenHost, port: configuration.listenPort, idleTimeout: Math.max(30, socketHandler.idleTimeout), - maxRequestBodySize: socketHandler.maxRequestBodySize, + maxRequestBodySize: resolveMaxRequestBodySize(socketHandler.maxRequestBodySize), websocket: socketHandler.websocket, fetch: (req, server) => { const url = new URL(req.url) diff --git a/web/src/components/AssistantChat/AttachmentPickerButton.test.tsx b/web/src/components/AssistantChat/AttachmentPickerButton.test.tsx new file mode 100644 index 000000000..1a502b9d7 --- /dev/null +++ b/web/src/components/AssistantChat/AttachmentPickerButton.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { AttachmentPickerButton } from './AttachmentPickerButton' + +describe('AttachmentPickerButton', () => { + it('uses an image picker on touch devices', () => { + const { container } = render( + + ) + + const input = container.querySelector('input[type="file"]') + expect(input).not.toBeNull() + expect(input?.getAttribute('accept')).toBe('image/*') + }) + + it('keeps the generic picker on non-touch devices', () => { + const { container } = render( + + ) + + const input = container.querySelector('input[type="file"]') + expect(input).not.toBeNull() + expect(input?.getAttribute('accept')).toBeNull() + }) + + it('forwards selected files and clears the input value', async () => { + const onFilesSelected = vi.fn().mockResolvedValue(undefined) + const { container } = render( + + ) + + const input = container.querySelector('input[type="file"]') as HTMLInputElement | null + expect(input).not.toBeNull() + const file = new File(['photo'], 'photo.jpg', { type: 'image/jpeg' }) + + Object.defineProperty(input, 'files', { + configurable: true, + value: [file] + }) + Object.defineProperty(input, 'value', { + configurable: true, + writable: true, + value: 'C:\\fakepath\\photo.jpg' + }) + + fireEvent.change(input as HTMLInputElement) + + await vi.waitFor(() => { + expect(onFilesSelected).toHaveBeenCalledWith([file]) + }) + expect(input?.value).toBe('') + }) +}) diff --git a/web/src/components/AssistantChat/AttachmentPickerButton.tsx b/web/src/components/AssistantChat/AttachmentPickerButton.tsx new file mode 100644 index 000000000..4ebb17e60 --- /dev/null +++ b/web/src/components/AssistantChat/AttachmentPickerButton.tsx @@ -0,0 +1,81 @@ +import type { ChangeEvent, KeyboardEvent } from 'react' +import { useCallback, useId, useRef } from 'react' + +function AttachmentIcon() { + return ( + + + + ) +} + +export function AttachmentPickerButton(props: { + label: string + disabled: boolean + isTouch: boolean + onFilesSelected: (files: File[]) => void | Promise +}) { + const inputId = useId() + const inputRef = useRef(null) + const accept = props.isTouch ? 'image/*' : undefined + + const handleChange = useCallback(async (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []) + event.target.value = '' + if (files.length === 0) { + return + } + await props.onFilesSelected(files) + }, [props]) + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + if (props.disabled) { + return + } + if (event.key !== 'Enter' && event.key !== ' ') { + return + } + event.preventDefault() + inputRef.current?.click() + }, [props.disabled]) + + return ( + <> + + + + ) +} diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 2777c9056..a85231331 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -1,6 +1,6 @@ -import { ComposerPrimitive } from '@assistant-ui/react' import type { ConversationStatus } from '@/realtime/types' import { useTranslation } from '@/lib/use-translation' +import type { ReactNode } from 'react' function VoiceAssistantIcon() { return ( @@ -125,24 +125,6 @@ function TerminalIcon() { ) } -function AttachmentIcon() { - return ( - - - - ) -} - function AbortIcon(props: { spinning: boolean }) { if (props.spinning) { return ( @@ -318,6 +300,7 @@ export function ComposerButtons(props: { voiceMicMuted?: boolean onVoiceToggle: () => void onVoiceMicToggle?: () => void + attachmentControl: ReactNode onSend: () => void }) { const { t } = useTranslation() @@ -326,14 +309,7 @@ export function ComposerButtons(props: { return (
- - - + {props.attachmentControl} {props.showSettingsButton ? ( +
+ ) +} + const defaultSuggestionHandler = async (): Promise => [] export function HappyComposer(props: { + apiClient: ApiClient + sessionId: string disabled?: boolean permissionMode?: PermissionMode collaborationMode?: CodexCollaborationMode @@ -55,6 +117,9 @@ export function HappyComposer(props: { onPermissionModeChange?: (mode: PermissionMode) => void onModelChange?: (model: string | null) => void onEffortChange?: (effort: string | null) => void + onSendMessage?: (text: string, attachments?: AttachmentMetadata[]) => void + resolveSessionId?: (sessionId: string) => Promise + onSessionResolved?: (sessionId: string) => void onSwitchToRemote?: () => void onTerminal?: () => void terminalUnsupported?: boolean @@ -69,6 +134,8 @@ export function HappyComposer(props: { const { t } = useTranslation() const { disabled = false, + apiClient, + sessionId, permissionMode: rawPermissionMode, collaborationMode: rawCollaborationMode, model: rawModel, @@ -84,6 +151,9 @@ export function HappyComposer(props: { onPermissionModeChange, onModelChange, onEffortChange, + onSendMessage, + resolveSessionId, + onSessionResolved, onSwitchToRemote, onTerminal, terminalUnsupported = false, @@ -103,24 +173,15 @@ export function HappyComposer(props: { const api = useAssistantApi() const composerText = useAssistantState(({ composer }) => composer.text) - const attachments = useAssistantState(({ composer }) => composer.attachments) const threadIsRunning = useAssistantState(({ thread }) => thread.isRunning) const threadIsDisabled = useAssistantState(({ thread }) => thread.isDisabled) const controlsDisabled = disabled || (!active && !allowSendWhenInactive) || threadIsDisabled const trimmed = composerText.trim() const hasText = trimmed.length > 0 + const [attachments, setAttachments] = useState([]) const hasAttachments = attachments.length > 0 - const attachmentsReady = !hasAttachments || attachments.every((attachment) => { - if (attachment.status.type === 'complete') { - return true - } - if (attachment.status.type !== 'requires-action') { - return false - } - const path = (attachment as { path?: string }).path - return typeof path === 'string' && path.length > 0 - }) + const attachmentsReady = attachments.every((attachment) => attachment.status === 'ready' && attachment.path.length > 0) const canSend = (hasText || hasAttachments) && attachmentsReady && !controlsDisabled && !threadIsRunning const [inputState, setInputState] = useState({ @@ -278,6 +339,22 @@ export function HappyComposer(props: { [permissionModeOptions] ) + const performSend = useCallback(() => { + if (!attachmentsReady || !canSend || !onSendMessage) { + return + } + + const readyAttachments = attachments + .filter((attachment): attachment is LocalComposerAttachment & { status: 'ready' } => attachment.status === 'ready') + .map(({ status: _status, uploadSessionId: _uploadSessionId, error: _error, ...metadata }) => metadata) + + onSendMessage(composerText, readyAttachments.length > 0 ? readyAttachments : undefined) + api.composer().setText('') + setInputState({ text: '', selection: { start: 0, end: 0 } }) + setAttachments([]) + setShowContinueHint(false) + }, [api, attachments, attachmentsReady, canSend, composerText, onSendMessage]) + const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { const key = e.key @@ -290,8 +367,7 @@ export function HappyComposer(props: { if (key === 'Enter' && e.shiftKey) { e.preventDefault() if (!canSend) return - api.composer().send() - setShowContinueHint(false) + performSend() return } @@ -346,7 +422,7 @@ export function HappyComposer(props: { permissionMode, permissionModes, canSend, - api, + performSend, haptic ]) @@ -379,6 +455,53 @@ export function HappyComposer(props: { })) }, []) + const handleAttachmentFilesSelected = useCallback(async (files: File[]) => { + for (const file of files) { + const pendingId = makeClientSideId('attachment') + setAttachments((prev) => [ + ...prev, + { + id: pendingId, + filename: file.name, + mimeType: file.type || 'application/octet-stream', + size: file.size, + path: '', + previewUrl: undefined, + status: 'uploading' + } + ]) + + try { + const result = await uploadAttachmentFile({ + api: apiClient, + sessionId, + file, + options: { + resolveSessionId, + onSessionResolved + } + }) + setAttachments((prev) => prev.map((attachment) => ( + attachment.id === pendingId + ? { + ...result.metadata, + id: pendingId, + status: 'ready', + uploadSessionId: result.sessionId + } + : attachment + ))) + } catch (error) { + const message = formatAttachmentUploadError(error) + setAttachments((prev) => prev.map((attachment) => ( + attachment.id === pendingId + ? { ...attachment, status: 'error', error: message } + : attachment + ))) + } + } + }, [apiClient, onSessionResolved, resolveSessionId, sessionId]) + const handlePaste = useCallback(async (e: ReactClipboardEvent) => { const files = Array.from(e.clipboardData?.files || []) const imageFiles = files.filter(file => file.type.startsWith('image/')) @@ -386,15 +509,25 @@ export function HappyComposer(props: { if (imageFiles.length === 0) return e.preventDefault() + await handleAttachmentFilesSelected(imageFiles) + }, [handleAttachmentFilesSelected]) + const handleRemoveAttachment = useCallback(async (attachmentId: string) => { + const attachment = attachments.find((item) => item.id === attachmentId) + setAttachments((prev) => prev.filter((item) => item.id !== attachmentId)) + if (!attachment?.path || !attachment.uploadSessionId) { + return + } try { - for (const file of imageFiles) { - await api.composer().addAttachment(file) - } + await deleteUploadedAttachment({ + api: apiClient, + sessionId: attachment.uploadSessionId, + path: attachment.path + }) } catch (error) { - console.error('Error adding pasted image:', error) + console.error('Error deleting attachment:', error) } - }, [api]) + }, [apiClient, attachments]) const handleSettingsToggle = useCallback(() => { haptic('light') @@ -402,12 +535,9 @@ export function HappyComposer(props: { }, [haptic]) const handleSubmit = useCallback((event?: ReactFormEvent) => { - if (event && !attachmentsReady) { - event.preventDefault() - return - } - setShowContinueHint(false) - }, [attachmentsReady]) + event?.preventDefault() + performSend() + }, [performSend]) const handlePermissionChange = useCallback((mode: PermissionMode) => { if (!onPermissionModeChange || controlsDisabled) return @@ -446,8 +576,8 @@ export function HappyComposer(props: { const voiceEnabled = Boolean(onVoiceToggle) const handleSend = useCallback(() => { - api.composer().send() - }, [api]) + performSend() + }, [performSend]) const overlays = useMemo(() => { if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings)) { @@ -679,7 +809,15 @@ export function HappyComposer(props: {
{attachments.length > 0 ? (
- + {attachments.map((attachment) => ( + { + void handleRemoveAttachment(attachment.id) + }} + /> + ))}
) : null} @@ -696,6 +834,7 @@ export function HappyComposer(props: { onSelect={handleSelect} onKeyDown={handleKeyDown} onPaste={handlePaste} + addAttachmentOnPaste={false} className="flex-1 resize-none bg-transparent text-base leading-snug text-[var(--app-fg)] placeholder-[var(--app-hint)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" />
@@ -703,6 +842,14 @@ export function HappyComposer(props: { + )} showSettingsButton={showSettingsButton} onSettingsToggle={handleSettingsToggle} showTerminalButton={showTerminalButton} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 841286245..dfd107e48 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -18,7 +18,6 @@ import { reconcileChatBlocks } from '@/chat/reconcile' import { HappyComposer } from '@/components/AssistantChat/HappyComposer' import { HappyThread } from '@/components/AssistantChat/HappyThread' import { useHappyRuntime } from '@/lib/assistant-runtime' -import { createAttachmentAdapter } from '@/lib/attachmentAdapter' import { findUnsupportedCodexBuiltinSlashCommand } from '@/lib/codexSlashCommands' import { useToast } from '@/lib/toast-context' import { useTranslation } from '@/lib/use-translation' @@ -45,6 +44,8 @@ export function SessionChat(props: { onRefresh: () => void onLoadMore: () => Promise onSend: (text: string, attachments?: AttachmentMetadata[]) => void + resolveSessionId?: (sessionId: string) => Promise + onSessionResolved?: (sessionId: string) => void onFlushPending: () => void onAtBottomChange: (atBottom: boolean) => void onRetryMessage?: (localId: string) => void @@ -305,20 +306,12 @@ export function SessionChat(props: { setForceScrollToken((token) => token + 1) }, [agentFlavor, props.availableSlashCommands, props.onSend, props.session.id, addToast, haptic, t]) - const attachmentAdapter = useMemo(() => { - if (!props.session.active) { - return undefined - } - return createAttachmentAdapter(props.api, props.session.id) - }, [props.api, props.session.id, props.session.active]) - const runtime = useHappyRuntime({ session: props.session, blocks: reconciled.blocks, isSending: props.isSending, onSendMessage: handleSend, onAbort: handleAbort, - attachmentAdapter, allowSendWhenInactive: true }) @@ -369,6 +362,8 @@ export function SessionChat(props: { /> { + it('uploads attachments to the resolved active session', async () => { + const uploadFile = vi.fn().mockResolvedValue({ + success: true, + path: '/tmp/uploaded.png' + }) + const resolveSessionId = vi.fn().mockResolvedValue('session-active') + const onSessionResolved = vi.fn() + const api = { + uploadFile, + deleteUploadFile: vi.fn() + } + const adapter = createAttachmentAdapter(api as never, 'session-inactive', { + resolveSessionId, + onSessionResolved + }) + + const file = new File(['image-bytes'], 'photo.png', { type: 'image/png' }) + const states: unknown[] = [] + const addResult = adapter.add({ file }) as AsyncGenerator + for await (const state of addResult) { + states.push(state) + } + + expect(resolveSessionId).toHaveBeenCalledWith('session-inactive') + expect(onSessionResolved).toHaveBeenCalledWith('session-active') + expect(uploadFile).toHaveBeenCalledWith( + 'session-active', + 'photo.png', + expect.any(String), + 'image/png' + ) + expect(states).toHaveLength(3) + }) + + it('deletes uploaded attachments from the resolved session', async () => { + const uploadFile = vi.fn().mockResolvedValue({ + success: true, + path: '/tmp/uploaded.png' + }) + const deleteUploadFile = vi.fn().mockResolvedValue({ success: true }) + const api = { + uploadFile, + deleteUploadFile + } + const adapter = createAttachmentAdapter(api as never, 'session-inactive', { + resolveSessionId: vi.fn().mockResolvedValue('session-active'), + onSessionResolved: vi.fn() + }) + + const file = new File(['image-bytes'], 'photo.png', { type: 'image/png' }) + let uploaded: unknown = null + const addResult = adapter.add({ file }) as AsyncGenerator + for await (const state of addResult) { + uploaded = state + } + + await adapter.remove(uploaded as never) + + expect(deleteUploadFile).toHaveBeenCalledWith('session-active', '/tmp/uploaded.png') + }) +}) diff --git a/web/src/lib/attachmentAdapter.ts b/web/src/lib/attachmentAdapter.ts index 5e3fd325b..261ffb58b 100644 --- a/web/src/lib/attachmentAdapter.ts +++ b/web/src/lib/attachmentAdapter.ts @@ -2,22 +2,103 @@ import type { AttachmentAdapter, PendingAttachment, CompleteAttachment, Attachme import type { ApiClient } from '@/api/client' import type { AttachmentMetadata } from '@/types/api' import { isImageMimeType } from '@/lib/fileAttachments' +import { makeClientSideId } from '@/lib/messages' const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 const MAX_PREVIEW_BYTES = 5 * 1024 * 1024 +export type AttachmentUploadOptions = { + resolveSessionId?: (sessionId: string) => Promise + onSessionResolved?: (sessionId: string) => void +} + type PendingUploadAttachment = PendingAttachment & { path?: string previewUrl?: string + sessionId?: string +} + +export async function resolveAttachmentSessionId( + sessionId: string, + options?: AttachmentUploadOptions +): Promise { + if (!options?.resolveSessionId) { + return sessionId + } + const resolvedSessionId = await options.resolveSessionId(sessionId) + if (resolvedSessionId !== sessionId) { + options.onSessionResolved?.(resolvedSessionId) + } + return resolvedSessionId } -export function createAttachmentAdapter(api: ApiClient, sessionId: string): AttachmentAdapter { +export async function uploadAttachmentFile(args: { + api: ApiClient + sessionId: string + file: File + options?: AttachmentUploadOptions +}): Promise<{ metadata: AttachmentMetadata; sessionId: string }> { + const contentType = args.file.type || 'application/octet-stream' + if (args.file.size > MAX_UPLOAD_BYTES) { + throw new Error('File too large (max 50MB)') + } + + const targetSessionId = await resolveAttachmentSessionId(args.sessionId, args.options) + const content = await fileToBase64(args.file) + const result = await args.api.uploadFile(targetSessionId, args.file.name, content, contentType) + if (!result.success || !result.path) { + throw new Error(result.error || 'Failed to upload file') + } + + let previewUrl: string | undefined + if (isImageMimeType(contentType) && args.file.size <= MAX_PREVIEW_BYTES) { + previewUrl = await fileToDataUrl(args.file) + } + + return { + sessionId: targetSessionId, + metadata: { + id: makeClientSideId('attachment'), + filename: args.file.name, + mimeType: contentType, + size: args.file.size, + path: result.path, + previewUrl + } + } +} + +export async function deleteUploadedAttachment(args: { + api: ApiClient + sessionId: string + path?: string +}): Promise { + if (!args.path) { + return + } + await args.api.deleteUploadFile(args.sessionId, args.path) +} + +export function createAttachmentAdapter(api: ApiClient, sessionId: string, options?: AttachmentUploadOptions): AttachmentAdapter { const cancelledAttachmentIds = new Set() + let currentSessionId = sessionId + + const resolveTargetSessionId = async (): Promise => { + const resolvedSessionId = await resolveAttachmentSessionId(currentSessionId, options) + if (resolvedSessionId !== currentSessionId) { + currentSessionId = resolvedSessionId + } + return currentSessionId + } - const deleteUpload = async (path?: string) => { + const deleteUpload = async (path?: string, targetSessionId?: string) => { if (!path) return try { - await api.deleteUploadFile(sessionId, path) + await deleteUploadedAttachment({ + api, + sessionId: targetSessionId ?? currentSessionId, + path + }) } catch { // Best effort cleanup } @@ -27,7 +108,7 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta accept: '*/*', async *add({ file }): AsyncGenerator { - const id = crypto.randomUUID() + const id = makeClientSideId('attachment') const contentType = file.type || 'application/octet-stream' yield { @@ -44,6 +125,11 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta return } + const targetSessionId = await resolveTargetSessionId() + if (cancelledAttachmentIds.has(id)) { + return + } + if (file.size > MAX_UPLOAD_BYTES) { yield { id, @@ -70,10 +156,10 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta status: { type: 'running', reason: 'uploading', progress: 50 } } - const result = await api.uploadFile(sessionId, file.name, content, contentType) + const result = await api.uploadFile(targetSessionId, file.name, content, contentType) if (cancelledAttachmentIds.has(id)) { if (result.success && result.path) { - await deleteUpload(result.path) + await deleteUpload(result.path, targetSessionId) } return } @@ -104,7 +190,8 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta file, status: { type: 'requires-action', reason: 'composer-send' }, path: result.path, - previewUrl + previewUrl, + sessionId: targetSessionId } as PendingUploadAttachment } catch { yield { @@ -120,8 +207,8 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta async remove(attachment: Attachment): Promise { cancelledAttachmentIds.add(attachment.id) - const path = (attachment as PendingUploadAttachment).path - await deleteUpload(path) + const pending = attachment as PendingUploadAttachment + await deleteUpload(pending.path, pending.sessionId) }, async send(attachment: PendingAttachment): Promise { diff --git a/web/src/router.tsx b/web/src/router.tsx index 7527abcdd..f53d997c8 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -220,55 +220,58 @@ function SessionPage() { flushPending, setAtBottom, } = useMessages(api, sessionId) + const resolveActiveSessionId = useCallback(async (currentSessionId: string) => { + if (!api || !session || session.active) { + return currentSessionId + } + try { + return await api.resumeSession(currentSessionId) + } catch (error) { + const message = error instanceof Error ? error.message : 'Resume failed' + addToast({ + title: 'Resume failed', + body: message, + sessionId: currentSessionId, + url: '' + }) + throw error + } + }, [addToast, api, session]) + + const handleSessionResolved = useCallback((resolvedSessionId: string) => { + void (async () => { + if (api) { + if (session && resolvedSessionId !== session.id) { + seedMessageWindowFromSession(session.id, resolvedSessionId) + queryClient.setQueryData(queryKeys.session(resolvedSessionId), { + session: { ...session, id: resolvedSessionId, active: true } + }) + } + try { + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: queryKeys.session(resolvedSessionId), + queryFn: () => api.getSession(resolvedSessionId), + }), + fetchLatestMessages(api, resolvedSessionId), + ]) + } catch { + } + } + navigate({ + to: '/sessions/$sessionId', + params: { sessionId: resolvedSessionId }, + replace: true + }) + })() + }, [api, navigate, queryClient, session]) const { sendMessage, retryMessage, isSending, } = useSendMessage(api, sessionId, { - resolveSessionId: async (currentSessionId) => { - if (!api || !session || session.active) { - return currentSessionId - } - try { - return await api.resumeSession(currentSessionId) - } catch (error) { - const message = error instanceof Error ? error.message : 'Resume failed' - addToast({ - title: 'Resume failed', - body: message, - sessionId: currentSessionId, - url: '' - }) - throw error - } - }, - onSessionResolved: (resolvedSessionId) => { - void (async () => { - if (api) { - if (session && resolvedSessionId !== session.id) { - seedMessageWindowFromSession(session.id, resolvedSessionId) - queryClient.setQueryData(queryKeys.session(resolvedSessionId), { - session: { ...session, id: resolvedSessionId, active: true } - }) - } - try { - await Promise.all([ - queryClient.prefetchQuery({ - queryKey: queryKeys.session(resolvedSessionId), - queryFn: () => api.getSession(resolvedSessionId), - }), - fetchLatestMessages(api, resolvedSessionId), - ]) - } catch { - } - } - navigate({ - to: '/sessions/$sessionId', - params: { sessionId: resolvedSessionId }, - replace: true - }) - })() - }, + resolveSessionId: resolveActiveSessionId, + onSessionResolved: handleSessionResolved, onBlocked: (reason) => { if (reason === 'no-api') { addToast({ @@ -328,6 +331,8 @@ function SessionPage() { onRefresh={refreshSelectedSession} onLoadMore={loadMoreMessages} onSend={sendMessage} + resolveSessionId={resolveActiveSessionId} + onSessionResolved={handleSessionResolved} onFlushPending={flushPending} onAtBottomChange={setAtBottom} onRetryMessage={retryMessage} From 63332b0f184afc2654acf930cb8f9b0af6f8d4c6 Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sat, 4 Apr 2026 16:23:44 +0800 Subject: [PATCH 81/82] feat: add session info dialog Add a read-only Session Info entry under the session header's More menu. The dialog exposes agent flavor, working directory, HAPI session id, and the imported source session id without adding noise to the main chat view. Includes i18n strings and a focused SessionHeader test. --- web/src/components/SessionActionMenu.tsx | 40 +++++++++ web/src/components/SessionHeader.test.tsx | 65 ++++++++++++++ web/src/components/SessionHeader.tsx | 9 ++ web/src/components/SessionInfoDialog.tsx | 105 ++++++++++++++++++++++ web/src/lib/locales/en.ts | 10 +++ web/src/lib/locales/zh-CN.ts | 10 +++ 6 files changed, 239 insertions(+) create mode 100644 web/src/components/SessionHeader.test.tsx create mode 100644 web/src/components/SessionInfoDialog.tsx diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index 88d6ab97c..42a7fe1c6 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -13,6 +13,7 @@ type SessionActionMenuProps = { isOpen: boolean onClose: () => void sessionActive: boolean + onShowInfo?: () => void onRename: () => void onArchive: () => void onDelete: () => void @@ -84,6 +85,27 @@ function TrashIcon(props: { className?: string }) { ) } +function InfoIcon(props: { className?: string }) { + return ( + + + + + + ) +} + type MenuPosition = { top: number left: number @@ -96,6 +118,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) { isOpen, onClose, sessionActive, + onShowInfo, onRename, onArchive, onDelete, @@ -113,6 +136,11 @@ export function SessionActionMenu(props: SessionActionMenuProps) { onRename() } + const handleShowInfo = () => { + onClose() + onShowInfo?.() + } + const handleArchive = () => { onClose() onArchive() @@ -229,6 +257,18 @@ export function SessionActionMenu(props: SessionActionMenuProps) { aria-labelledby={headingId} className="flex flex-col gap-1" > + {onShowInfo ? ( + + ) : null} + + ) : null} +
+
+ ) +} + +export function SessionInfoDialog(props: { + session: Session + open: boolean + onClose: () => void +}) { + const { t } = useTranslation() + const sourceSessionId = getSourceSessionId(props.session) + const agent = props.session.metadata?.flavor?.trim() || t('session.info.notAvailable') + const workingDirectory = props.session.metadata?.path || t('session.info.notAvailable') + + return ( + !open && props.onClose()}> + + + {t('session.info.title')} + + {t('session.info.description')} + + + +
+ + + + +
+
+
+ ) +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 35773a771..5042ed2ea 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -59,11 +59,21 @@ export default { 'session.more': 'More actions', // Session actions + 'session.action.info': 'Session Info', 'session.action.rename': 'Rename', 'session.action.archive': 'Archive', 'session.action.delete': 'Delete', 'session.action.copy': 'Copy', + // Session info + 'session.info.title': 'Session Info', + 'session.info.description': 'Read-only identifiers and source details for this session.', + 'session.info.agent': 'Agent', + 'session.info.workingDirectory': 'Working directory', + 'session.info.hapiSessionId': 'HAPI session id', + 'session.info.sourceSessionId': 'Source session id', + 'session.info.notAvailable': 'Not available', + // Dialogs 'dialog.rename.title': 'Rename Session', 'dialog.rename.placeholder': 'Session name', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index af42b32f1..3982a825b 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -59,11 +59,21 @@ export default { 'session.more': '更多操作', // Session actions + 'session.action.info': '会话详情', 'session.action.rename': '重命名', 'session.action.archive': '归档', 'session.action.delete': '删除', 'session.action.copy': '复制', + // Session info + 'session.info.title': '会话详情', + 'session.info.description': '查看当前会话的只读标识和来源信息。', + 'session.info.agent': '代理', + 'session.info.workingDirectory': '工作目录', + 'session.info.hapiSessionId': 'HAPI 会话 ID', + 'session.info.sourceSessionId': '源会话 ID', + 'session.info.notAvailable': '不可用', + // Dialogs 'dialog.rename.title': '重命名会话', 'dialog.rename.placeholder': '会话名称', From 8caec46fe4fa127c9288e1a927639e34e58846fe Mon Sep 17 00:00:00 2001 From: duxiaoxiong Date: Sun, 5 Apr 2026 17:04:05 +0800 Subject: [PATCH 82/82] feat: generalize message queue, import existing sessions, and extract vite allowedHosts - Generalize MessageQueue2 from string to generic TMessage for agent-neutral queuing - Add attachment-to-input mapping in appServerConfig for mobile image uploads - Extract vite allowedHosts into configurable helper with env var support - Make React Query devtools conditional on VITE_SHOW_QUERY_DEVTOOLS env var - Harden sessionScanner null returns and remote launcher test coverage via [HAPI](https://hapi.run) Co-Authored-By: HAPI Co-Authored-By: Claude Opus 4.6 --- cli/src/agent/loopBase.ts | 4 +- cli/src/agent/sessionBase.ts | 10 ++-- cli/src/claude/utils/sessionScanner.test.ts | 4 ++ cli/src/claude/utils/sessionScanner.ts | 2 +- cli/src/codex/codexRemoteLauncher.test.ts | 50 +++++++++++++++++-- cli/src/codex/codexRemoteLauncher.ts | 9 ++-- cli/src/codex/loop.ts | 8 ++- cli/src/codex/runCodex.ts | 27 ++++++---- cli/src/codex/session.ts | 6 +-- cli/src/codex/utils/appServerConfig.test.ts | 25 ++++++++++ cli/src/codex/utils/appServerConfig.ts | 23 ++++++++- cli/src/utils/MessageQueue2.ts | 43 +++++++++------- .../CodexSubagentPreviewCard.test.tsx | 2 +- .../AssistantChat/messages/ToolMessage.tsx | 8 --- web/src/lib/viteAllowedHosts.test.ts | 8 +++ web/src/lib/viteAllowedHosts.ts | 16 ++++++ web/src/main.tsx | 4 +- web/vite.config.ts | 3 +- 18 files changed, 193 insertions(+), 59 deletions(-) create mode 100644 web/src/lib/viteAllowedHosts.test.ts create mode 100644 web/src/lib/viteAllowedHosts.ts diff --git a/cli/src/agent/loopBase.ts b/cli/src/agent/loopBase.ts index 6e2c35bd2..a7f19b05e 100644 --- a/cli/src/agent/loopBase.ts +++ b/cli/src/agent/loopBase.ts @@ -3,7 +3,7 @@ import type { AgentSessionBase } from './sessionBase'; export type LoopLauncher = (session: TSession) => Promise<'switch' | 'exit'>; -export async function runLocalRemoteSession>(opts: { +export async function runLocalRemoteSession>(opts: { session: TSession; startingMode?: 'local' | 'remote'; logTag: string; @@ -24,7 +24,7 @@ export async function runLocalRemoteSession>(opts: { +export async function runLocalRemoteLoop>(opts: { session: TSession; startingMode?: 'local' | 'remote'; logTag: string; diff --git a/cli/src/agent/sessionBase.ts b/cli/src/agent/sessionBase.ts index fabe17f26..d5b295aec 100644 --- a/cli/src/agent/sessionBase.ts +++ b/cli/src/agent/sessionBase.ts @@ -3,13 +3,13 @@ import { MessageQueue2 } from '@/utils/MessageQueue2'; import type { Metadata, SessionCollaborationMode, SessionEffort, SessionModel, SessionPermissionMode } from '@/api/types'; import { logger } from '@/ui/logger'; -export type AgentSessionBaseOptions = { +export type AgentSessionBaseOptions = { api: ApiClient; client: ApiSessionClient; path: string; logPath: string; sessionId: string | null; - messageQueue: MessageQueue2; + messageQueue: MessageQueue2; onModeChange: (mode: 'local' | 'remote') => void; mode?: 'local' | 'remote'; sessionLabel: string; @@ -21,12 +21,12 @@ export type AgentSessionBaseOptions = { collaborationMode?: SessionCollaborationMode; }; -export class AgentSessionBase { +export class AgentSessionBase { readonly path: string; readonly logPath: string; readonly api: ApiClient; readonly client: ApiSessionClient; - readonly queue: MessageQueue2; + readonly queue: MessageQueue2; protected readonly _onModeChange: (mode: 'local' | 'remote') => void; sessionId: string | null; @@ -43,7 +43,7 @@ export class AgentSessionBase { protected effort?: SessionEffort; protected collaborationMode?: SessionCollaborationMode; - constructor(opts: AgentSessionBaseOptions) { + constructor(opts: AgentSessionBaseOptions) { this.path = opts.path; this.api = opts.api; this.client = opts.client; diff --git a/cli/src/claude/utils/sessionScanner.test.ts b/cli/src/claude/utils/sessionScanner.test.ts index d960b5e05..cec3e9d88 100644 --- a/cli/src/claude/utils/sessionScanner.test.ts +++ b/cli/src/claude/utils/sessionScanner.test.ts @@ -11,6 +11,10 @@ function getMessageText(message: RawJSONLines): string | null { return message.summary } + if (message.type === 'system') { + return null + } + if (!message.message) { return null } diff --git a/cli/src/claude/utils/sessionScanner.ts b/cli/src/claude/utils/sessionScanner.ts index 8adfc1018..d6a87a637 100644 --- a/cli/src/claude/utils/sessionScanner.ts +++ b/cli/src/claude/utils/sessionScanner.ts @@ -609,7 +609,7 @@ function sidechainMessageFingerprint(message: RawJSONLines): Record ({ notifications: [] as Array<{ method: string; params: unknown }>, @@ -137,8 +137,15 @@ function createMode(): EnhancedMode { } function createSessionStub(overrides?: { sessionId?: string | null }) { - const queue = new MessageQueue2((mode) => JSON.stringify(mode)); - queue.push('hello from launcher test', createMode()); + const queue = new MessageQueue2( + (mode) => JSON.stringify(mode), + null, + (messages) => ({ + text: messages.map((message) => message.text).filter((text) => text.length > 0).join('\n'), + attachments: messages.flatMap((message) => message.attachments ?? []) + }) + ); + queue.push({ text: 'hello from launcher test' }, createMode()); queue.close(); const sessionEvents: Array<{ type: string; [key: string]: unknown }> = []; @@ -223,6 +230,17 @@ function createSessionStub(overrides?: { sessionId?: string | null }) { }; } +function createSessionStubWithQueuedMessage( + queuedMessage: CodexQueuedMessage, + overrides?: { sessionId?: string | null } +) { + const stub = createSessionStub(overrides); + stub.session.queue.reset(); + stub.session.queue.push(queuedMessage, createMode()); + stub.session.queue.close(); + return stub; +} + describe('codexRemoteLauncher', () => { afterEach(() => { harness.notifications = []; @@ -370,6 +388,32 @@ describe('codexRemoteLauncher', () => { expect(session.thinking).toBe(false); }); + it('passes image attachments to Codex turn/start as localImage inputs', async () => { + const { + session + } = createSessionStubWithQueuedMessage({ + text: 'describe this image', + attachments: [{ + id: 'att-1', + filename: 'photo.jpg', + mimeType: 'image/jpeg', + size: 1234, + path: '/tmp/hapi/photo.jpg' + }] + }); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.startTurnCalls).toHaveLength(1); + expect(harness.startTurnCalls[0]).toMatchObject({ + input: [ + { type: 'localImage', path: '/tmp/hapi/photo.jpg' }, + { type: 'text', text: 'describe this image' } + ] + }); + }); + it('promotes nested parent_tool_call_id from exec command payloads into top-level sidechain metadata', async () => { harness.extraNotifications = [ { diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 2c01f0e6d..4d7eae814 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -10,7 +10,7 @@ import { CodexDisplay } from '@/ui/ink/CodexDisplay'; import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge'; import { emitReadyIfIdle } from './utils/emitReadyIfIdle'; import type { CodexSession } from './session'; -import type { EnhancedMode } from './loop'; +import type { CodexQueuedMessage, EnhancedMode } from './loop'; import { hasCodexCliOverrides } from './utils/codexCliOverrides'; import { AppServerEventConverter } from './utils/appServerEventConverter'; import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; @@ -26,7 +26,7 @@ import { } from '@/modules/common/remote/RemoteLauncherBase'; type HappyServer = Awaited>['server']; -type QueuedMessage = { message: string; mode: EnhancedMode; isolate: boolean; hash: string }; +type QueuedMessage = { message: CodexQueuedMessage; mode: EnhancedMode; isolate: boolean; hash: string }; class CodexRemoteLauncher extends RemoteLauncherBase { private readonly session: CodexSession; @@ -744,7 +744,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { break; } - messageBuffer.addMessage(message.message, 'user'); + messageBuffer.addMessage(message.message.text, 'user'); try { if (!hasThread) { @@ -802,7 +802,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const turnParams = buildTurnStartParams({ threadId: this.currentThreadId, - message: message.message, + message: message.message.text, + attachments: message.message.attachments, cwd: session.path, mode: { ...message.mode, diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 013fd07ce..a4e9e1447 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -8,6 +8,7 @@ import { ApiClient, ApiSessionClient } from '@/lib'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; import type { ReasoningEffort } from './appServerTypes'; import type { CodexCollaborationMode, CodexPermissionMode } from '@hapi/protocol/types'; +import type { AttachmentMetadata } from '@/api/types'; export type PermissionMode = CodexPermissionMode; @@ -18,12 +19,17 @@ export interface EnhancedMode { modelReasoningEffort?: ReasoningEffort; } +export interface CodexQueuedMessage { + text: string; + attachments?: AttachmentMetadata[]; +} + interface LoopOptions { path: string; startingMode?: 'local' | 'remote'; startedBy?: 'runner' | 'terminal'; onModeChange: (mode: 'local' | 'remote') => void; - messageQueue: MessageQueue2; + messageQueue: MessageQueue2; session: ApiSessionClient; api: ApiClient; codexArgs?: string[]; diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index d94286a14..9f5a92869 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -10,9 +10,9 @@ import { bootstrapSession } from '@/agent/sessionFactory'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import type { ReasoningEffort } from './appServerTypes'; +import type { CodexQueuedMessage } from './loop'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; @@ -44,12 +44,19 @@ export async function runCodex(opts: { setControlledByUser(session, startingMode); - const messageQueue = new MessageQueue2((mode) => hashObject({ - permissionMode: mode.permissionMode, - model: mode.model, - modelReasoningEffort: mode.modelReasoningEffort, - collaborationMode: mode.collaborationMode - })); + const messageQueue = new MessageQueue2( + (mode) => hashObject({ + permissionMode: mode.permissionMode, + model: mode.model, + modelReasoningEffort: mode.modelReasoningEffort, + collaborationMode: mode.collaborationMode + }), + null, + (messages) => ({ + text: messages.map((message) => message.text).filter((text) => text.length > 0).join('\n'), + attachments: messages.flatMap((message) => message.attachments ?? []) + }) + ); const codexCliOverrides = parseCodexCliOverrides(opts.codexArgs); const sessionWrapperRef: { current: CodexSession | null } = { current: null }; @@ -114,8 +121,10 @@ export async function runCodex(opts: { modelReasoningEffort: currentModelReasoningEffort, collaborationMode: currentCollaborationMode }; - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); - messageQueue.push(formattedText, enhancedMode); + messageQueue.push({ + text: message.content.text, + attachments: message.content.attachments + }, enhancedMode); }); const formatFailureReason = (message: string): string => { diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 7009216c4..a314f4037 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -1,7 +1,7 @@ import { ApiClient, ApiSessionClient } from '@/lib'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { AgentSessionBase } from '@/agent/sessionBase'; -import type { EnhancedMode, PermissionMode } from './loop'; +import type { CodexQueuedMessage, EnhancedMode, PermissionMode } from './loop'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; import type { SessionModel } from '@/api/types'; @@ -11,7 +11,7 @@ type LocalLaunchFailure = { exitReason: LocalLaunchExitReason; }; -export class CodexSession extends AgentSessionBase { +export class CodexSession extends AgentSessionBase { readonly codexArgs?: string[]; readonly codexCliOverrides?: CodexCliOverrides; readonly startedBy: 'runner' | 'terminal'; @@ -24,7 +24,7 @@ export class CodexSession extends AgentSessionBase { path: string; logPath: string; sessionId: string | null; - messageQueue: MessageQueue2; + messageQueue: MessageQueue2; onModeChange: (mode: 'local' | 'remote') => void; mode?: 'local' | 'remote'; startedBy: 'runner' | 'terminal'; diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index 0951b4133..5fa7b707b 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -126,6 +126,31 @@ describe('appServerConfig', () => { expect(params.model).toBeUndefined(); }); + it('maps image attachments to localImage inputs for Codex turns', () => { + const params = buildTurnStartParams({ + threadId: 'thread-1', + message: 'describe this image', + cwd: '/workspace/project', + mode: { + permissionMode: 'default', + model: 'o3', + collaborationMode: 'default' + }, + attachments: [{ + id: 'att-1', + filename: 'photo.jpg', + mimeType: 'image/jpeg', + size: 1234, + path: '/tmp/hapi/photo.jpg' + }] as any + } as any); + + expect(params.input).toEqual([ + { type: 'localImage', path: '/tmp/hapi/photo.jpg' }, + { type: 'text', text: 'describe this image' } + ]); + }); + it('puts collaboration mode in turn params with model settings', () => { const params = buildTurnStartParams({ threadId: 'thread-1', diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 12565909f..9469cfe9f 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -2,12 +2,14 @@ import type { EnhancedMode } from '../loop'; import type { CodexCliOverrides } from './codexCliOverrides'; import type { McpServersConfig } from './buildHapiMcpBridge'; import { codexSystemPrompt } from './systemPrompt'; +import type { AttachmentMetadata } from '@/api/types'; import type { ApprovalPolicy, SandboxMode, SandboxPolicy, ThreadStartParams, - TurnStartParams + TurnStartParams, + UserInput } from '../appServerTypes'; import { resolveCodexPermissionModeConfig } from './permissionModeConfig'; @@ -108,6 +110,7 @@ export function buildThreadStartParams(args: { export function buildTurnStartParams(args: { threadId: string; message: string; + attachments?: AttachmentMetadata[]; cwd: string; mode?: EnhancedMode; cliOverrides?: CodexCliOverrides; @@ -119,10 +122,26 @@ export function buildTurnStartParams(args: { model?: string; }; }): TurnStartParams { + const inputs: UserInput[] = []; + const fileAttachments: string[] = []; + + for (const attachment of args.attachments ?? []) { + if (attachment.mimeType.startsWith('image/')) { + inputs.push({ type: 'localImage', path: attachment.path }); + continue; + } + fileAttachments.push(`@${attachment.path}`); + } + + const textParts = [fileAttachments.join(' '), args.message].filter((part) => part.length > 0); + if (textParts.length > 0) { + inputs.push({ type: 'text', text: textParts.join('\n\n') }); + } + const params: TurnStartParams = { threadId: args.threadId, cwd: args.cwd, - input: [{ type: 'text', text: args.message }] + input: inputs }; const allowCliOverrides = args.mode?.permissionMode === 'default'; diff --git a/cli/src/utils/MessageQueue2.ts b/cli/src/utils/MessageQueue2.ts index 6ba5fcdd1..1ce7fa0cb 100644 --- a/cli/src/utils/MessageQueue2.ts +++ b/cli/src/utils/MessageQueue2.ts @@ -1,43 +1,50 @@ import { logger } from "@/ui/logger"; -interface QueueItem { - message: string; - mode: T; +interface QueueItem { + message: TMessage; + mode: TMode; modeHash: string; isolate?: boolean; // If true, this message must be processed alone } +function defaultCombineMessages(messages: TMessage[]): TMessage { + return messages.join('\n') as TMessage; +} + /** * A mode-aware message queue that stores messages with their modes. * Returns consistent batches of messages with the same mode. */ -export class MessageQueue2 { - public queue: QueueItem[] = []; // Made public for testing +export class MessageQueue2 { + public queue: QueueItem[] = []; // Made public for testing private waiter: ((hasMessages: boolean) => void) | null = null; private closed = false; - private onMessageHandler: ((message: string, mode: T) => void) | null = null; - modeHasher: (mode: T) => string; + private onMessageHandler: ((message: TMessage, mode: TMode) => void) | null = null; + modeHasher: (mode: TMode) => string; + private readonly combineMessages: (messages: TMessage[]) => TMessage; constructor( - modeHasher: (mode: T) => string, - onMessageHandler: ((message: string, mode: T) => void) | null = null + modeHasher: (mode: TMode) => string, + onMessageHandler: ((message: TMessage, mode: TMode) => void) | null = null, + combineMessages: (messages: TMessage[]) => TMessage = defaultCombineMessages as (messages: TMessage[]) => TMessage ) { this.modeHasher = modeHasher; this.onMessageHandler = onMessageHandler; + this.combineMessages = combineMessages; logger.debug(`[MessageQueue2] Initialized`); } /** * Set a handler that will be called when a message arrives */ - setOnMessage(handler: ((message: string, mode: T) => void) | null): void { + setOnMessage(handler: ((message: TMessage, mode: TMode) => void) | null): void { this.onMessageHandler = handler; } /** * Push a message to the queue with a mode. */ - push(message: string, mode: T): void { + push(message: TMessage, mode: TMode): void { if (this.closed) { throw new Error('Cannot push to closed queue'); } @@ -72,7 +79,7 @@ export class MessageQueue2 { * Push a message immediately without batching delay. * Does not clear the queue or enforce isolation. */ - pushImmediate(message: string, mode: T): void { + pushImmediate(message: TMessage, mode: TMode): void { if (this.closed) { throw new Error('Cannot push to closed queue'); } @@ -108,7 +115,7 @@ export class MessageQueue2 { * Clears any pending messages and ensures this message is never batched with others. * Used for special commands that require dedicated processing. */ - pushIsolateAndClear(message: string, mode: T): void { + pushIsolateAndClear(message: TMessage, mode: TMode): void { if (this.closed) { throw new Error('Cannot push to closed queue'); } @@ -145,7 +152,7 @@ export class MessageQueue2 { /** * Push a message to the beginning of the queue with a mode. */ - unshift(message: string, mode: T): void { + unshift(message: TMessage, mode: TMode): void { if (this.closed) { throw new Error('Cannot unshift to closed queue'); } @@ -221,7 +228,7 @@ export class MessageQueue2 { * Wait for messages and return all messages with the same mode as a single string * Returns { message: string, mode: T } or null if aborted/closed */ - async waitForMessagesAndGetAsString(abortSignal?: AbortSignal): Promise<{ message: string, mode: T, isolate: boolean, hash: string } | null> { + async waitForMessagesAndGetAsString(abortSignal?: AbortSignal): Promise<{ message: TMessage, mode: TMode, isolate: boolean, hash: string } | null> { // If we have messages, return them immediately if (this.queue.length > 0) { return this.collectBatch(); @@ -245,13 +252,13 @@ export class MessageQueue2 { /** * Collect a batch of messages with the same mode, respecting isolation requirements */ - private collectBatch(): { message: string, mode: T, hash: string, isolate: boolean } | null { + private collectBatch(): { message: TMessage, mode: TMode, hash: string, isolate: boolean } | null { if (this.queue.length === 0) { return null; } const firstItem = this.queue[0]; - const sameModeMessages: string[] = []; + const sameModeMessages: TMessage[] = []; let mode = firstItem.mode; let isolate = firstItem.isolate ?? false; const targetModeHash = firstItem.modeHash; @@ -273,7 +280,7 @@ export class MessageQueue2 { } // Join all messages with newlines - const combinedMessage = sameModeMessages.join('\n'); + const combinedMessage = this.combineMessages(sameModeMessages); return { message: combinedMessage, diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx index c3eb6f96a..9c8d771ea 100644 --- a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -288,7 +288,7 @@ describe('CodexSubagentPreviewCard', () => { expect(screen.getByText('Subagent conversation')).toBeInTheDocument() expect(screen.getByText('Completed')).toBeInTheDocument() - expect(screen.getAllByText('Investigate flaky Task sidechain rendering').length).toBeGreaterThan(0) + expect(screen.getAllByText('Investigate flaky Task sidechain rendering')).toHaveLength(1) expect(screen.getByText('Waiting for approval…')).toBeInTheDocument() expect(screen.queryByText('Task child answer')).not.toBeInTheDocument() diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 7ec2bac2e..36c802db8 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -152,14 +152,6 @@ function renderToolBlock( return ( <> - {taskChildren.pending.length > 0 ? (
diff --git a/web/src/lib/viteAllowedHosts.test.ts b/web/src/lib/viteAllowedHosts.test.ts new file mode 100644 index 000000000..b4e6462b0 --- /dev/null +++ b/web/src/lib/viteAllowedHosts.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import { getAllowedHosts } from './viteAllowedHosts' + +describe('getAllowedHosts', () => { + it('includes the public hapidev host', () => { + expect(getAllowedHosts()).toContain('hapidev.duxiaoxiong.top') + }) +}) diff --git a/web/src/lib/viteAllowedHosts.ts b/web/src/lib/viteAllowedHosts.ts new file mode 100644 index 000000000..65de54c3a --- /dev/null +++ b/web/src/lib/viteAllowedHosts.ts @@ -0,0 +1,16 @@ +const DEFAULT_ALLOWED_HOSTS = [ + 'hapidev.weishu.me', + 'hapidev.duxiaoxiong.top' +] as const + +export function getAllowedHosts(extraHostsValue = ''): string[] { + const extraHosts = extraHostsValue + .split(',') + .map((host: string) => host.trim()) + .filter(Boolean) + + return Array.from(new Set([ + ...DEFAULT_ALLOWED_HOSTS, + ...extraHosts + ])) +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 31c1def66..4570f964f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -76,12 +76,14 @@ async function bootstrap() { : undefined const router = createAppRouter(history) + const showQueryDevtools = import.meta.env.DEV && import.meta.env.VITE_SHOW_QUERY_DEVTOOLS === 'true' + ReactDOM.createRoot(document.getElementById('root')!).render( - {import.meta.env.DEV ? : null} + {showQueryDevtools ? : null} diff --git a/web/vite.config.ts b/web/vite.config.ts index 631ac9813..25485eb15 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' import { resolve } from 'node:path' import { createRequire } from 'node:module' +import { getAllowedHosts } from './src/lib/viteAllowedHosts' const require = createRequire(import.meta.url) const base = process.env.VITE_BASE_URL || '/' @@ -38,7 +39,7 @@ export default defineConfig({ }, server: { host: true, - allowedHosts: ['hapidev.weishu.me'], + allowedHosts: getAllowedHosts(process.env.VITE_ALLOWED_HOSTS ?? ''), proxy: { '/api': { target: hubTarget,