diff --git a/server/agent-api/router.ts b/server/agent-api/router.ts index fa919e235..284410cb2 100644 --- a/server/agent-api/router.ts +++ b/server/agent-api/router.ts @@ -459,7 +459,7 @@ type CodexPromptBlocker = { type FreshAgentRuntimeManagerLike = { create: (input: any) => Promise<{ sessionId: string; sessionType: FreshAgentSessionType; runtimeProvider: FreshAgentRuntimeProvider; sessionRef?: { provider: string; sessionId: string } }> - send: (locator: FreshAgentSessionLocator, input: { text: string; settings?: any }) => Promise<{ sessionId?: string; sessionRef?: { provider: string; sessionId: string } } | void> + send: (locator: FreshAgentSessionLocator, input: { text: string; settings?: any }) => Promise<{ sessionId?: string; submittedTurnId?: string; sessionRef?: { provider: string; sessionId: string } } | void> attach: (locator: FreshAgentSessionLocator) => Promise<{ sessionId: string; sessionRef?: { provider: string; sessionId: string } }> getSnapshot: (input: FreshAgentThreadLocator) => Promise } @@ -1705,7 +1705,13 @@ export function createAgentApiRouter({ const deadline = Date.now() + (Number.isFinite(timeoutSec) ? timeoutSec * 1000 : FRESH_AGENT_SEND_IDLE_TIMEOUT_MS) const idle = await waitForFreshAgentIdle(freshAgentRuntimeManager, snapshotLocator, deadline) if (idle.deadlineMissed) { - return res.json(approx({ paneId, sessionId: result?.sessionId ?? locator.sessionId, sessionRef: result?.sessionRef, status: idle.status }, 'prompt sent; turn did not complete within deadline')) + return res.json(approx({ + paneId, + sessionId: result?.sessionId ?? locator.sessionId, + submittedTurnId: result?.submittedTurnId, + sessionRef: result?.sessionRef, + status: idle.status, + }, 'prompt sent; turn did not complete within deadline')) } const finalSessionId = result?.sessionId ?? locator.sessionId @@ -1728,7 +1734,13 @@ export function createAgentApiRouter({ }) } - return res.json(ok({ paneId, sessionId: finalSessionId, sessionRef: finalSessionRef, status: idle.status }, 'prompt sent')) + return res.json(ok({ + paneId, + sessionId: finalSessionId, + submittedTurnId: result?.submittedTurnId, + sessionRef: finalSessionRef, + status: idle.status, + }, 'prompt sent')) } catch (err: any) { return res.status(agentRouteErrorStatus(err)).json(fail(err?.message || 'fresh-agent send failed')) } diff --git a/server/config-store.ts b/server/config-store.ts index 1f50984d3..b7a5ecf63 100644 --- a/server/config-store.ts +++ b/server/config-store.ts @@ -1,5 +1,6 @@ import fsp from 'fs/promises' import path from 'path' +import { randomBytes } from 'node:crypto' import { logger } from './logger.js' import { getFreshellConfigDir } from './freshell-home.js' import { @@ -57,6 +58,9 @@ export type UserConfig = { version: 1 settings: AppSettings legacyLocalSettingsSeed?: LocalSettingsPatch + serverSecrets?: { + codexDisplayIdSecret?: string + } sessionOverrides: Record terminalOverrides: Record projectColors: Record @@ -266,6 +270,10 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } +function generateServerLocalSecret(): string { + return randomBytes(32).toString('base64url') +} + function migrateLegacyFreshClaudeSettings(rawSettings: Record): Record { if (!isRecord(rawSettings.freshclaude)) { return rawSettings @@ -336,6 +344,14 @@ export class ConfigStore { ...existing, settings, legacyLocalSettingsSeed, + serverSecrets: isRecord(existing.serverSecrets) + ? { + ...(typeof existing.serverSecrets.codexDisplayIdSecret === 'string' + && existing.serverSecrets.codexDisplayIdSecret.trim().length > 0 + ? { codexDisplayIdSecret: existing.serverSecrets.codexDisplayIdSecret } + : {}), + } + : undefined, sessionOverrides: existing.sessionOverrides || {}, terminalOverrides: existing.terminalOverrides || {}, projectColors: existing.projectColors || {}, @@ -371,6 +387,7 @@ export class ConfigStore { version: 1, settings: defaultSettings, legacyLocalSettingsSeed: undefined, + serverSecrets: undefined, sessionOverrides: {}, terminalOverrides: {}, projectColors: {}, @@ -419,6 +436,25 @@ export class ConfigStore { return cfg.settings } + async getCodexDisplayIdSecret(): Promise { + return this.writeMutex.acquire(async () => { + const cfg = await this.loadForWrite() + const existing = cfg.serverSecrets?.codexDisplayIdSecret + if (typeof existing === 'string' && existing.trim().length > 0) { + return existing + } + const secret = generateServerLocalSecret() + await this.saveInternal({ + ...cfg, + serverSecrets: { + ...(cfg.serverSecrets ?? {}), + codexDisplayIdSecret: secret, + }, + }) + return secret + }) + } + async getLegacyLocalSettingsSeed(): Promise { const cfg = await this.load() return cfg.legacyLocalSettingsSeed diff --git a/server/fresh-agent-extras-router.ts b/server/fresh-agent-extras-router.ts index de92c884d..a0eec3403 100644 --- a/server/fresh-agent-extras-router.ts +++ b/server/fresh-agent-extras-router.ts @@ -5,7 +5,7 @@ import * as fsp from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' import { FreshAgentRuntimeProviderSchema, FreshAgentSessionTypeSchema } from '../shared/fresh-agent-contract.js' -import type { FreshAgentCreateRequest, FreshAgentSessionLocator } from './fresh-agent/runtime-adapter.js' +import type { FreshAgentCreateRequest, FreshAgentSendResult, FreshAgentSessionLocator } from './fresh-agent/runtime-adapter.js' const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 const EXEC_TIMEOUT_MS = 30_000 @@ -104,15 +104,83 @@ async function ensureCheckpointRepo(cwd: string): Promise { return gitDir } -export type CheckpointEntry = { id: string; ts: number; label: string } +export type CheckpointEntry = { id: string; ts: number; label: string; requestId?: string; turnId?: string } +type CheckpointMetadata = Record -async function createCheckpoint(cwd: string, label: string): Promise { +function checkpointMetadataPath(gitDir: string): string { + return path.join(gitDir, 'freshell-checkpoint-metadata.json') +} + +function isValidCheckpointId(id: string): boolean { + return /^[0-9a-f]{7,40}$/i.test(id) +} + +async function readCheckpointMetadata(gitDir: string): Promise { + try { + const raw = await fsp.readFile(checkpointMetadataPath(gitDir), 'utf8') + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + const metadata: CheckpointMetadata = {} + for (const [id, value] of Object.entries(parsed)) { + if (!isValidCheckpointId(id) || !value || typeof value !== 'object' || Array.isArray(value)) continue + const entry = value as Record + metadata[id] = { + ...(typeof entry.requestId === 'string' && entry.requestId ? { requestId: entry.requestId } : {}), + ...(typeof entry.turnId === 'string' && entry.turnId ? { turnId: entry.turnId } : {}), + } + } + return metadata + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw error + } +} + +async function writeCheckpointMetadata(gitDir: string, metadata: CheckpointMetadata): Promise { + const filePath = checkpointMetadataPath(gitDir) + const tempPath = `${filePath}.tmp-${randomUUID()}` + await fsp.writeFile(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8') + try { + await fsp.rename(tempPath, filePath) + } catch (error) { + await fsp.unlink(tempPath).catch(() => undefined) + throw error + } +} + +async function updateCheckpointMetadata( + cwd: string, + id: string, + patch: { requestId?: string; turnId?: string }, +): Promise { + if (!isValidCheckpointId(id)) { + throw new Error('invalid checkpoint id') + } + const gitDir = await ensureCheckpointRepo(cwd) + const resolvedId = (await runGit([`--git-dir=${gitDir}`, 'rev-parse', '--verify', `${id}^{commit}`])).trim() + const metadata = await readCheckpointMetadata(gitDir) + metadata[resolvedId] = { + ...(metadata[resolvedId] ?? {}), + ...(patch.requestId ? { requestId: patch.requestId } : {}), + ...(patch.turnId ? { turnId: patch.turnId } : {}), + } + await writeCheckpointMetadata(gitDir, metadata) + const entries = await listCheckpoints(cwd) + const entry = entries.find((candidate) => candidate.id === resolvedId) + if (!entry) throw new Error('invalid checkpoint id') + return entry +} + +async function createCheckpoint(cwd: string, label: string, metadata: { requestId?: string } = {}): Promise { const gitDir = await ensureCheckpointRepo(cwd) const base = [`--git-dir=${gitDir}`, `--work-tree=${cwd}`] await runGit([...base, 'add', '-A'], { cwd }) await runGit([...base, ...CHECKPOINT_IDENTITY, 'commit', '--allow-empty', '-q', '-m', label], { cwd }) const sha = (await runGit([`--git-dir=${gitDir}`, 'rev-parse', 'HEAD'])).trim() - return { id: sha, ts: Math.floor(Date.now() / 1000), label } + if (metadata.requestId) { + await updateCheckpointMetadata(cwd, sha, { requestId: metadata.requestId }) + } + return { id: sha, ts: Math.floor(Date.now() / 1000), label, ...metadata } } async function listCheckpoints(cwd: string): Promise { @@ -134,17 +202,18 @@ async function listCheckpoints(cwd: string): Promise { // Empty repo (no commits yet). return [] } + const metadata = await readCheckpointMetadata(gitDir) return raw .split('\n') .filter(Boolean) .map((line) => { const [id, ts, ...rest] = line.split('\t') - return { id, ts: Number(ts), label: rest.join('\t') } + return { id, ts: Number(ts), label: rest.join('\t'), ...(metadata[id] ?? {}) } }) } async function restoreCheckpoint(cwd: string, id: string): Promise { - if (!/^[0-9a-f]{7,40}$/i.test(id)) { + if (!isValidCheckpointId(id)) { throw new Error('invalid checkpoint id') } const gitDir = await ensureCheckpointRepo(cwd) @@ -171,7 +240,7 @@ async function restoreCheckpoint(cwd: string, id: string): Promise { /** Structural slice of FreshAgentRuntimeManager — keeps this router * standalone-testable with a fake and avoids a hard import cycle. */ export type FreshAgentSendCapable = { - send?: (locator: FreshAgentSessionLocator, payload: { text: string; settings?: FreshAgentCreateRequest }) => Promise + send?: (locator: FreshAgentSessionLocator, payload: { text: string; settings?: FreshAgentCreateRequest }) => Promise } function parseSendLocator(body: unknown): FreshAgentSessionLocator | null { @@ -267,8 +336,8 @@ export function createFreshAgentExtrasRouter( ? req.body.settings as FreshAgentCreateRequest : undefined try { - await manager.send(locator, { text, ...(settings ? { settings } : {}) }) - res.json({ sent: true }) + const result = await manager.send(locator, { text, ...(settings ? { settings } : {}) }) + res.json({ sent: true, submittedTurnId: result?.submittedTurnId }) } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : 'send failed' }) } @@ -279,6 +348,9 @@ export function createFreshAgentExtrasRouter( const label = typeof req.body?.label === 'string' && req.body.label.trim() ? req.body.label.trim().slice(0, 120) : 'checkpoint' + const requestId = typeof req.body?.requestId === 'string' && req.body.requestId.trim() + ? req.body.requestId.trim() + : undefined if (!cwd) { return res.status(400).json({ error: 'cwd is required' }) } @@ -288,7 +360,7 @@ export function createFreshAgentExtrasRouter( return res.status(400).json({ error: `cwd does not exist: ${cwd}` }) } try { - const entry = await createCheckpoint(cwd, label) + const entry = await createCheckpoint(cwd, label, { ...(requestId ? { requestId } : {}) }) res.json(entry) } catch (error) { res.status(500).json({ error: error instanceof Error ? error.message : 'checkpoint failed' }) @@ -307,6 +379,34 @@ export function createFreshAgentExtrasRouter( } }) + router.post('/checkpoints/metadata', async (req, res) => { + const cwd = typeof req.body?.cwd === 'string' ? req.body.cwd : '' + const id = typeof req.body?.id === 'string' ? req.body.id : '' + const requestId = typeof req.body?.requestId === 'string' && req.body.requestId.trim() + ? req.body.requestId.trim() + : undefined + const turnId = typeof req.body?.turnId === 'string' && req.body.turnId.trim() + ? req.body.turnId.trim() + : undefined + if (!cwd || !id) { + return res.status(400).json({ error: 'cwd and id are required' }) + } + if (!requestId && !turnId) { + return res.status(400).json({ error: 'requestId or turnId is required' }) + } + try { + await fsp.access(cwd) + } catch { + return res.status(400).json({ error: `cwd does not exist: ${cwd}` }) + } + try { + res.json(await updateCheckpointMetadata(cwd, id, { requestId, turnId })) + } catch (error) { + const message = error instanceof Error ? error.message : 'metadata update failed' + res.status(message.includes('invalid checkpoint id') ? 400 : 500).json({ error: message }) + } + }) + router.post('/checkpoints/restore', async (req, res) => { const cwd = typeof req.body?.cwd === 'string' ? req.body.cwd : '' const id = typeof req.body?.id === 'string' ? req.body.id : '' diff --git a/server/fresh-agent/adapters/codex/adapter.ts b/server/fresh-agent/adapters/codex/adapter.ts index 12c5e3ff9..be7f75315 100644 --- a/server/fresh-agent/adapters/codex/adapter.ts +++ b/server/fresh-agent/adapters/codex/adapter.ts @@ -1,14 +1,28 @@ +import { randomBytes } from 'node:crypto' + import type { FreshAgentCreateRequest, FreshAgentInputImage, FreshAgentRuntimeAdapter } from '../../runtime-adapter.js' +import type { FreshAgentTurn } from '../../../../shared/fresh-agent-contract.js' +import { FreshAgentTurnPageSchema } from '../../../../shared/fresh-agent-contract.js' +import { + FreshAgentAmbiguousTurnBodyError, + FreshAgentInvalidDisplayIdError, + FreshAgentInvalidTurnCursorError, + FreshAgentStaleThreadRevisionError, + FreshAgentUnprovableThreadRevisionError, + FreshAgentTurnNotFoundError, +} from '../../runtime-manager.js' import type { CodexThreadForkParams, CodexTurnInterruptParams, CodexTurnStartParams, } from '../../../coding-cli/codex-app-server/protocol.js' import { + CodexDisplayTurnNotFoundError, + createCodexDisplayId, + normalizeCodexDisplayTurns, normalizeCodexThreadSnapshot, - normalizeCodexTurn, normalizeCodexTurnBody, - normalizeCodexTurnPage, + parseCodexDisplayIdHandle, } from './normalize.js' import { normalizeFreshAgentEffort, normalizeFreshAgentModel } from '../../../../shared/fresh-agent-models.js' @@ -61,6 +75,40 @@ type CodexRuntimePort = { readThreadTurn: (input: { threadId: string; turnId: string; revision?: number }) => Promise> } +type DisplayIndexEntry = { + threadId: string + revision: number + displayTurnId: string + providerTurnId: string + role: NonNullable + rawTurn: Record +} + +type CodexDisplayCursorEntry = { + threadId: string + revision: number + providerCursor: string | null + drainingProviderTurnId?: string + nextDisplayOffset: number + rawTurn?: Record + order: 'provider-default' + expiresAt: number +} + +type SubmittedInputRecord = { + requestId: string + providerTurnId: string + submittedTurnId: string + input: CodexTurnStartParams['input'] + createdAt: number +} + +const DISPLAY_INDEX_MAX_REVISIONS = 32 +const DISPLAY_CURSOR_PREFIX = 'codex-cursor:v1:' +const DISPLAY_CURSOR_TTL_MS = 5 * 60 * 1000 +const DISPLAY_CURSOR_MAX_ENTRIES = 512 +const SUBMITTED_INPUT_TTL_MS = 30 * 60 * 1000 + function toCodexApprovalPolicy(value: string | undefined) { if (value === undefined) return undefined if (value === 'untrusted' || value === 'on-failure' || value === 'on-request' || value === 'never') { @@ -124,6 +172,61 @@ function toCodexUserInput(text: string, images: FreshAgentInputImage[] | undefin return input } +function submittedInputKey(providerTurnId: string, requestId: string): string { + return `${providerTurnId}\u0000${requestId}` +} + +function displayIndexKey(threadId: string, revision: number): string { + return `${threadId}\u0000${revision}` +} + +function readDisplayCursorHandle(cursor: string): string | null { + const pattern = new RegExp(`^${DISPLAY_CURSOR_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([A-Za-z0-9_-]{22})$`) + return cursor.match(pattern)?.[1] ?? null +} + +function stripCodexDisplayMetadata(turn: FreshAgentTurn): FreshAgentTurn { + const { + syntheticKind: _syntheticKind, + requestId: _requestId, + ...publicTurn + } = turn as FreshAgentTurn & { + syntheticKind?: string + requestId?: string | number + } + return publicTurn +} + +function readRawTurns(value: unknown): Record[] { + return Array.isArray(value) + ? value.filter((turn): turn is Record => !!turn && typeof turn === 'object' && !Array.isArray(turn)) + : [] +} + +function hasCodexUserMessage(rawTurn: Record): boolean { + return readRawTurns(rawTurn.items).some((item) => item.type === 'userMessage') +} + +function submittedInputContent(input: CodexTurnStartParams['input']): unknown[] { + return input.map((part) => { + if (part.type === 'text') { + return { type: 'text', text: part.text } + } + if (part.type === 'localImage') { + return { type: 'localImage', path: part.path } + } + return { type: 'image', url: part.url } + }) +} + +function makeSubmittedUserMessage(record: SubmittedInputRecord): Record { + return { + id: `codex-submitted-input:${record.requestId}`, + type: 'userMessage', + content: submittedInputContent(record.input), + } +} + function normalizeCodexInput(input: FreshAgentCreateRequest): FreshAgentCreateRequest { const model = normalizeFreshAgentModel(input.sessionType, 'codex', input.model) return { @@ -174,9 +277,14 @@ function findActiveTurnId(rawSnapshot: Record): string | undefined } export function createCodexFreshAgentAdapter(deps: { + displayIdSecret: string runtime?: CodexRuntimePort runtimeFactory?: () => CodexRuntimePort }): FreshAgentRuntimeAdapter { + if (typeof deps.displayIdSecret !== 'string' || deps.displayIdSecret.trim().length === 0) { + throw new Error('Codex fresh-agent adapter requires a persisted display-id secret.') + } + const displayIdSecret = deps.displayIdSecret const activeTurnByThread = new Map() const settingsByThread = new Map>() const runtimeByThread = new Map() @@ -185,6 +293,393 @@ export function createCodexFreshAgentAdapter(deps: { const runtimeResumeByThread = new Map>() const runtimeResumeGenerationByThread = new Map() const modelByTurnByThread = new Map>() + const displayIndexByRevision = new Map>() + const displayCursorByHandle = new Map() + const submittedInputsByThread = new Map>() + const submittedAliasByThread = new Map>() + + const pruneDisplayIndex = () => { + while (displayIndexByRevision.size > DISPLAY_INDEX_MAX_REVISIONS) { + const oldestKey = displayIndexByRevision.keys().next().value + if (!oldestKey) return + displayIndexByRevision.delete(oldestKey) + } + } + + const pruneDisplayCursors = () => { + const now = Date.now() + for (const [handle, entry] of displayCursorByHandle) { + if (entry.expiresAt <= now) { + displayCursorByHandle.delete(handle) + } + } + while (displayCursorByHandle.size > DISPLAY_CURSOR_MAX_ENTRIES) { + const oldestHandle = displayCursorByHandle.keys().next().value + if (!oldestHandle) return + displayCursorByHandle.delete(oldestHandle) + } + } + + const createDisplayCursor = (entry: Omit): string => { + pruneDisplayCursors() + let handle = randomBytes(16).toString('base64url') + while (displayCursorByHandle.has(handle)) { + handle = randomBytes(16).toString('base64url') + } + displayCursorByHandle.set(handle, { + ...entry, + expiresAt: Date.now() + DISPLAY_CURSOR_TTL_MS, + }) + pruneDisplayCursors() + return `${DISPLAY_CURSOR_PREFIX}${handle}` + } + + const resolveDisplayCursor = (input: { + cursor: string + threadId: string + revision: number + }): CodexDisplayCursorEntry => { + pruneDisplayCursors() + const handle = readDisplayCursorHandle(input.cursor) + if (!handle) { + throw new FreshAgentInvalidTurnCursorError('Invalid Codex display cursor.') + } + const entry = displayCursorByHandle.get(handle) + if (!entry) { + throw new FreshAgentInvalidTurnCursorError('Invalid or expired Codex display cursor.') + } + if (entry.threadId !== input.threadId) { + throw new FreshAgentInvalidTurnCursorError('Codex display cursor does not belong to this thread.') + } + if (entry.revision !== input.revision) { + throw new FreshAgentStaleThreadRevisionError(entry.revision) + } + return entry + } + + const pruneSubmittedInputs = (threadId: string) => { + const records = submittedInputsByThread.get(threadId) + if (!records) return + const cutoff = Date.now() - SUBMITTED_INPUT_TTL_MS + for (const [key, record] of records) { + if (record.createdAt < cutoff) { + records.delete(key) + } + } + if (records.size === 0) { + submittedInputsByThread.delete(threadId) + } + } + + const submittedRequestIdMap = (threadId: string): Map => { + pruneSubmittedInputs(threadId) + const requestIds = new Map() + const aliases = submittedAliasByThread.get(threadId) + for (const [providerTurnId, requestId] of aliases ?? []) { + requestIds.set(providerTurnId, requestId) + } + for (const record of submittedInputsByThread.get(threadId)?.values() ?? []) { + requestIds.set(record.providerTurnId, record.requestId) + } + return requestIds + } + + const firstSubmittedRecordForProviderTurn = (threadId: string, providerTurnId: string): SubmittedInputRecord | undefined => { + pruneSubmittedInputs(threadId) + for (const record of submittedInputsByThread.get(threadId)?.values() ?? []) { + if (record.providerTurnId === providerTurnId) return record + } + return undefined + } + + const rememberSubmittedInput = (threadId: string, record: SubmittedInputRecord) => { + const records = submittedInputsByThread.get(threadId) ?? new Map() + records.set(submittedInputKey(record.providerTurnId, record.requestId), record) + submittedInputsByThread.set(threadId, records) + } + + const rememberSubmittedAlias = (threadId: string, providerTurnId: string, requestId: string) => { + const aliases = submittedAliasByThread.get(threadId) ?? new Map() + aliases.set(providerTurnId, requestId) + submittedAliasByThread.set(threadId, aliases) + } + + const prepareRawTurnForNormalization = (threadId: string, rawTurn: Record): Record => { + const providerTurnId = String(rawTurn.id ?? '') + if (!providerTurnId) return rawTurn + const record = firstSubmittedRecordForProviderTurn(threadId, providerTurnId) + if (!record) return rawTurn + + if (hasCodexUserMessage(rawTurn)) { + submittedInputsByThread.get(threadId)?.delete(submittedInputKey(providerTurnId, record.requestId)) + rememberSubmittedAlias(threadId, providerTurnId, record.requestId) + return rawTurn + } + + return { + ...rawTurn, + items: [ + makeSubmittedUserMessage(record), + ...readRawTurns(rawTurn.items), + ], + } + } + + const registerDisplayRows = (input: { + threadId: string + revision: number + rawTurn: Record + displayRows: Array<{ + turnId: string + role: NonNullable + providerTurnId: string + }> + }) => { + const key = displayIndexKey(input.threadId, input.revision) + const entries = displayIndexByRevision.get(key) ?? new Map() + for (const row of input.displayRows) { + entries.set(row.turnId, { + threadId: input.threadId, + revision: input.revision, + displayTurnId: row.turnId, + providerTurnId: row.providerTurnId, + role: row.role, + rawTurn: input.rawTurn, + }) + } + displayIndexByRevision.set(key, entries) + pruneDisplayIndex() + } + + const normalizeSingleRawTurn = (input: { + threadId: string + revision: number + rawTurn: Record + }) => { + const preparedTurn = prepareRawTurnForNormalization(input.threadId, input.rawTurn) + const normalized = normalizeCodexDisplayTurns(preparedTurn, 0, { + threadId: input.threadId, + secret: displayIdSecret, + submittedRequestIdByProviderTurnId: submittedRequestIdMap(input.threadId), + model: typeof preparedTurn.id === 'string' + ? modelByTurnByThread.get(input.threadId)?.get(preparedTurn.id) + : undefined, + }) + registerDisplayRows({ + threadId: input.threadId, + revision: input.revision, + rawTurn: preparedTurn, + displayRows: normalized.displayRows, + }) + return { + rawTurn: preparedTurn, + providerTurnId: String(preparedTurn.id ?? ''), + turns: normalized.turns.map(stripCodexDisplayMetadata), + } + } + + const normalizeRawTurns = (input: { + threadId: string + revision: number + rawTurns: Record[] + }): FreshAgentTurn[] => input.rawTurns.flatMap((rawTurn) => normalizeSingleRawTurn({ + threadId: input.threadId, + revision: input.revision, + rawTurn, + }).turns).map((turn, index) => ({ + ...turn, + ordinal: index, + })) + + const normalizeRawPage = (input: { + threadId: string + revision: number + rawPage: { turns?: unknown[]; nextCursor?: string | null; backwardsCursor?: string | null } + }) => { + const turns = normalizeRawTurns({ + threadId: input.threadId, + revision: input.revision, + rawTurns: readRawTurns(input.rawPage.turns), + }) + return FreshAgentTurnPageSchema.parse({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: input.threadId, + revision: input.revision, + nextCursor: input.rawPage.nextCursor ?? null, + backwardsCursor: input.rawPage.backwardsCursor ?? null, + turns, + bodies: Object.fromEntries(turns.map((turn) => [turn.turnId, turn])), + }) + } + + const normalizeDisplayTurnPage = async (input: { + runtime: CodexRuntimePort + threadId: string + revision: number + cursor?: string + limit?: number + }) => { + const limit = input.limit ?? 30 + const turns: FreshAgentTurn[] = [] + let providerCursor: string | null | undefined + let backwardsCursor: string | null | undefined + + const appendTurnRows = (rawTurn: Record, offset: number, providerCursorAfterTurn: string | null) => { + const normalized = normalizeSingleRawTurn({ + threadId: input.threadId, + revision: input.revision, + rawTurn, + }) + const availableRows = normalized.turns.slice(offset) + const remainingSlots = limit - turns.length + const selectedRows = availableRows.slice(0, remainingSlots) + turns.push(...selectedRows) + const nextDisplayOffset = offset + selectedRows.length + if (nextDisplayOffset < normalized.turns.length) { + return createDisplayCursor({ + threadId: input.threadId, + revision: input.revision, + providerCursor: providerCursorAfterTurn, + drainingProviderTurnId: normalized.providerTurnId, + nextDisplayOffset, + rawTurn: normalized.rawTurn, + order: 'provider-default', + }) + } + if (providerCursorAfterTurn && turns.length >= limit) { + return createDisplayCursor({ + threadId: input.threadId, + revision: input.revision, + providerCursor: providerCursorAfterTurn, + nextDisplayOffset: 0, + order: 'provider-default', + }) + } + return null + } + + if (input.cursor) { + const cursor = resolveDisplayCursor({ + cursor: input.cursor, + threadId: input.threadId, + revision: input.revision, + }) + providerCursor = cursor.providerCursor + if (cursor.rawTurn) { + const nextCursor = appendTurnRows(cursor.rawTurn, cursor.nextDisplayOffset, cursor.providerCursor) + if (turns.length >= limit || nextCursor || !cursor.providerCursor) { + return FreshAgentTurnPageSchema.parse({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: input.threadId, + revision: input.revision, + nextCursor, + backwardsCursor: null, + turns: turns.map((turn, index) => ({ ...turn, ordinal: index })), + bodies: Object.fromEntries(turns.map((turn) => [turn.turnId, turn])), + }) + } + } + } + + while (turns.length < limit) { + const rawPage = await input.runtime.listThreadTurns({ + threadId: input.threadId, + ...(providerCursor ? { cursor: providerCursor } : {}), + limit: 1, + itemsView: 'full', + }) + const pageRevision = Number(rawPage.revision ?? input.revision) + if (pageRevision !== input.revision) { + throw new FreshAgentStaleThreadRevisionError(pageRevision) + } + backwardsCursor = typeof rawPage.backwardsCursor === 'string' ? rawPage.backwardsCursor : backwardsCursor + const rawTurns = readRawTurns(rawPage.turns) + providerCursor = typeof rawPage.nextCursor === 'string' && rawPage.nextCursor.length > 0 + ? rawPage.nextCursor + : null + if (rawTurns.length === 0) { + if (providerCursor) continue + break + } + const nextCursor = appendTurnRows(rawTurns[0], 0, providerCursor) + if (turns.length >= limit || nextCursor) { + return FreshAgentTurnPageSchema.parse({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: input.threadId, + revision: input.revision, + nextCursor, + backwardsCursor: backwardsCursor ?? null, + turns: turns.map((turn, index) => ({ ...turn, ordinal: index })), + bodies: Object.fromEntries(turns.map((turn) => [turn.turnId, turn])), + }) + } + if (!providerCursor) break + } + + return FreshAgentTurnPageSchema.parse({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: input.threadId, + revision: input.revision, + nextCursor: null, + backwardsCursor: backwardsCursor ?? null, + turns: turns.map((turn, index) => ({ ...turn, ordinal: index })), + bodies: Object.fromEntries(turns.map((turn) => [turn.turnId, turn])), + }) + } + + const findDisplayIndexEntry = (threadId: string, revision: number, displayTurnId: string): DisplayIndexEntry | undefined => { + return displayIndexByRevision.get(displayIndexKey(threadId, revision))?.get(displayTurnId) + } + + const rescanDisplayIndex = async ( + runtime: CodexRuntimePort, + threadId: string, + revision: number, + displayTurnId: string, + ): Promise => { + let cursor: string | undefined + do { + const rawPage = await runtime.listThreadTurns({ + threadId, + ...(cursor ? { cursor } : {}), + limit: 100, + itemsView: 'full', + }) + const currentRevision = Number(rawPage.revision ?? revision) + normalizeRawPage({ threadId, revision: currentRevision, rawPage }) + if (currentRevision !== revision) { + throw new FreshAgentStaleThreadRevisionError(currentRevision) + } + const entry = findDisplayIndexEntry(threadId, revision, displayTurnId) + if (entry) return entry + cursor = typeof rawPage.nextCursor === 'string' && rawPage.nextCursor.length > 0 + ? rawPage.nextCursor + : undefined + } while (cursor) + return null + } + + const clearThreadState = (threadId: string) => { + activeTurnByThread.delete(threadId) + settingsByThread.delete(threadId) + modelByTurnByThread.delete(threadId) + submittedInputsByThread.delete(threadId) + submittedAliasByThread.delete(threadId) + for (const key of [...displayIndexByRevision.keys()]) { + if (key.startsWith(`${threadId}\u0000`)) { + displayIndexByRevision.delete(key) + } + } + for (const [handle, entry] of displayCursorByHandle) { + if (entry.threadId === threadId) { + displayCursorByHandle.delete(handle) + } + } + } const rememberThreadSettings = ( threadId: string, @@ -380,7 +875,7 @@ export function createCodexFreshAgentAdapter(deps: { } if (event.kind === 'thread_closed') { if (event.threadId !== sessionId) return - activeTurnByThread.delete(sessionId) + clearThreadState(sessionId) void releaseRuntime(sessionId).catch(() => undefined) listener({ type: 'sdk.status', @@ -399,6 +894,7 @@ export function createCodexFreshAgentAdapter(deps: { }, async send(sessionId, input) { + const requestId = input.requestId ?? `codex-send-${Date.now()}` const settings: Partial = { ...settingsByThread.get(sessionId), ...input.settings, @@ -423,11 +919,27 @@ export function createCodexFreshAgentAdapter(deps: { effort: toCodexReasoningEffort(settings.effort), }) activeTurnByThread.set(sessionId, turn.turnId) + const submittedTurnId = createCodexDisplayId({ + secret: displayIdSecret, + threadId: sessionId, + providerTurnId: turn.turnId, + role: 'user', + syntheticKind: 'submitted-input', + requestId, + }) + rememberSubmittedInput(sessionId, { + requestId, + providerTurnId: turn.turnId, + submittedTurnId, + input: toCodexUserInput(input.text, input.images), + createdAt: Date.now(), + }) if (settings.model) { const modelByTurn = modelByTurnByThread.get(sessionId) ?? new Map() modelByTurn.set(turn.turnId, settings.model) modelByTurnByThread.set(sessionId, modelByTurn) } + return { requestId, submittedTurnId } }, async interrupt(sessionId) { @@ -533,17 +1045,18 @@ export function createCodexFreshAgentAdapter(deps: { } const rawTurns = rawThreadTurns .filter((turn): turn is Record => !!turn && typeof turn === 'object' && !Array.isArray(turn)) - .map((turn, index) => normalizeCodexTurn(turn, index, { - model: typeof turn.id === 'string' - ? modelByTurnByThread.get(thread.threadId)?.get(turn.id) - : undefined, - })) + const revisionNumber = Number(rawSnapshot.thread?.updatedAt ?? revision ?? 0) + const turns = normalizeRawTurns({ + threadId: thread.threadId, + revision: revisionNumber, + rawTurns, + }) return normalizeCodexThreadSnapshot({ threadId: thread.threadId, - revision: Number(rawSnapshot.thread?.updatedAt ?? revision ?? 0), + revision: revisionNumber, status: normalizeCodexThreadStatus(rawSnapshot.thread?.status), transcript: { - turns: rawTurns, + turns, }, rawSnapshot, }) @@ -554,17 +1067,12 @@ export function createCodexFreshAgentAdapter(deps: { thread.threadId, settingsFromLocator(thread) ?? settingsByThread.get(thread.threadId), ) - const rawPage = await runtime.listThreadTurns({ + return normalizeDisplayTurnPage({ + runtime, threadId: thread.threadId, + revision: Number(query.revision ?? 0), cursor: typeof query.cursor === 'string' ? query.cursor : undefined, limit: typeof query.limit === 'number' ? query.limit : undefined, - itemsView: 'full', - }) - return normalizeCodexTurnPage({ - threadId: thread.threadId, - revision: Number(rawPage.revision ?? query.revision ?? 0), - rawPage, - modelByTurn: modelByTurnByThread.get(thread.threadId), }) }, @@ -573,27 +1081,81 @@ export function createCodexFreshAgentAdapter(deps: { thread.threadId, settingsFromLocator(thread) ?? settingsByThread.get(thread.threadId), ) + if (thread.turnId.startsWith('codex-display:') && !parseCodexDisplayIdHandle(thread.turnId)) { + throw new FreshAgentInvalidDisplayIdError('Invalid Codex display turn id.') + } + const displayHandle = parseCodexDisplayIdHandle(thread.turnId) + if (displayHandle) { + const entry = findDisplayIndexEntry(thread.threadId, revision, thread.turnId) + ?? await rescanDisplayIndex(runtime, thread.threadId, revision, thread.turnId) + if (!entry) { + throw new FreshAgentTurnNotFoundError('Codex display turn was not found in the requested thread revision.') + } + const rawTurn = await runtime.readThreadTurn({ + threadId: thread.threadId, + turnId: entry.providerTurnId, + revision, + }) + try { + return normalizeCodexTurnBody({ + threadId: thread.threadId, + revision, + requestedTurnId: thread.turnId, + rawTurn: prepareRawTurnForNormalization(thread.threadId, rawTurn), + model: modelByTurnByThread.get(thread.threadId)?.get(entry.providerTurnId), + secret: displayIdSecret, + submittedRequestIdByProviderTurnId: submittedRequestIdMap(thread.threadId), + }) + } catch (error) { + if (error instanceof CodexDisplayTurnNotFoundError) { + throw new FreshAgentUnprovableThreadRevisionError(revision) + } + throw error + } + } + const rawTurn = await runtime.readThreadTurn({ threadId: thread.threadId, turnId: thread.turnId, revision, }) + const preparedTurn = prepareRawTurnForNormalization(thread.threadId, rawTurn) + const normalized = normalizeCodexDisplayTurns(preparedTurn, 0, { + threadId: thread.threadId, + secret: displayIdSecret, + submittedRequestIdByProviderTurnId: submittedRequestIdMap(thread.threadId), + model: typeof preparedTurn.id === 'string' + ? modelByTurnByThread.get(thread.threadId)?.get(preparedTurn.id) + : undefined, + }) + registerDisplayRows({ + threadId: thread.threadId, + revision, + rawTurn: preparedTurn, + displayRows: normalized.displayRows, + }) + if (normalized.turns.length !== 1) { + throw new FreshAgentAmbiguousTurnBodyError( + `Codex native turn ${thread.turnId} normalizes to ${normalized.turns.length} display turns; request a display turn id instead.`, + ) + } return normalizeCodexTurnBody({ threadId: thread.threadId, revision, - rawTurn, + requestedTurnId: normalized.turns[0].turnId, + rawTurn: preparedTurn, model: typeof rawTurn.id === 'string' ? modelByTurnByThread.get(thread.threadId)?.get(rawTurn.id) : undefined, + secret: displayIdSecret, + submittedRequestIdByProviderTurnId: submittedRequestIdMap(thread.threadId), }) }, async kill(sessionId) { - activeTurnByThread.delete(sessionId) - settingsByThread.delete(sessionId) + clearThreadState(sessionId) runtimeResumeGenerationByThread.set(sessionId, (runtimeResumeGenerationByThread.get(sessionId) ?? 0) + 1) runtimeResumeByThread.delete(sessionId) - modelByTurnByThread.delete(sessionId) await releaseRuntime(sessionId) return true }, @@ -608,6 +1170,10 @@ export function createCodexFreshAgentAdapter(deps: { activeTurnByThread.clear() settingsByThread.clear() modelByTurnByThread.clear() + displayIndexByRevision.clear() + displayCursorByHandle.clear() + submittedInputsByThread.clear() + submittedAliasByThread.clear() await Promise.all(runtimes.map((runtime) => runtime.shutdown?.())) }, } diff --git a/server/fresh-agent/adapters/codex/normalize.ts b/server/fresh-agent/adapters/codex/normalize.ts index 9d0c7b3f1..9fe5179a3 100644 --- a/server/fresh-agent/adapters/codex/normalize.ts +++ b/server/fresh-agent/adapters/codex/normalize.ts @@ -1,7 +1,11 @@ +import { createHmac } from 'node:crypto' + +import { + CodexThreadItemTypeSchema, +} from '../../../coding-cli/codex-app-server/protocol.js' import { FreshAgentSnapshotSchema, FreshAgentTurnBodySchema, - FreshAgentTurnPageSchema, type FreshAgentTranscriptItem, type FreshAgentTurn, } from '../../../../shared/fresh-agent-contract.js' @@ -26,156 +30,474 @@ type CodexRawSnapshot = { extension?: { codex?: Record } } -function normalizeCodexItem(turnId: string, item: Record, index: number): FreshAgentTranscriptItem[] { - const id = typeof item.id === 'string' && item.id.length > 0 ? item.id : `${turnId}:item:${index}` - switch (item.type) { - case 'userMessage': { - const content = Array.isArray(item.content) ? item.content : [] - if (content.length === 0) { - return [{ id, kind: 'text', text: '' }] +type CodexDisplayRole = NonNullable +type CodexDisplaySyntheticKind = 'empty-response' | 'error' | 'submitted-input' +type CodexThreadItemVariant = (typeof CodexThreadItemTypeSchema.options)[number] + +const CODEX_DISPLAY_ID_PREFIX = 'codex-display:v1:' +const CODEX_DISPLAY_HANDLE_LENGTH = 22 + +export class CodexDisplayProtocolError extends Error { + constructor(message: string) { + super(message) + this.name = 'CodexDisplayProtocolError' + } +} + +export class CodexDisplayConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'CodexDisplayConfigError' + } +} + +export class CodexDisplayTurnNotFoundError extends Error { + constructor(message: string) { + super(message) + this.name = 'CodexDisplayTurnNotFoundError' + } +} + +export type CodexDisplayIdentity = { + secret: string + threadId: string + providerTurnId: string + role: CodexDisplayRole + itemIds?: string[] + partIndexes?: number[] + syntheticKind?: CodexDisplaySyntheticKind + requestId?: string | number +} + +export type CodexDisplayRow = { + turnId: string + role: CodexDisplayRole + providerTurnId: string + itemIds: string[] + partIndexes: number[] + items: FreshAgentTranscriptItem[] + syntheticKind?: CodexDisplaySyntheticKind + requestId?: string | number +} + +type NormalizeCodexDisplayTurnsOptions = { + model?: string + secret?: string + threadId?: string + submittedRequestIdByProviderTurnId?: ReadonlyMap +} + +type CodexNormalizedContribution = { + role: CodexDisplayRole + itemId: string + partIndex: number + item: FreshAgentTranscriptItem +} + +type CodexPendingRow = { + role: CodexDisplayRole + itemIds: string[] + partIndexes: number[] + items: FreshAgentTranscriptItem[] + syntheticKind?: CodexDisplaySyntheticKind +} + +function normalizeCommandStatus(status: unknown): 'running' | 'completed' | 'failed' | 'declined' { + return status === 'inProgress' + ? 'running' + : status === 'declined' + ? 'declined' + : status === 'failed' + ? 'failed' + : 'completed' +} + +function normalizeToolStatus(status: unknown): 'running' | 'completed' | 'failed' { + return status === 'inProgress' + ? 'running' + : status === 'failed' + ? 'failed' + : 'completed' +} + +function assertNever(value: never): never { + throw new CodexDisplayProtocolError(`Unsupported Codex thread item type: ${String(value)}`) +} + +function readRequiredCodexDisplaySecret(secret: string | undefined): string { + if (typeof secret === 'string' && secret.trim().length > 0) { + return secret + } + throw new CodexDisplayConfigError('Codex display-turn normalization requires a non-empty adapter-supplied display-id secret.') +} + +function readRequiredCodexThreadId(threadId: string | undefined): string { + if (typeof threadId === 'string' && threadId.trim().length > 0) { + return threadId + } + throw new CodexDisplayConfigError('Codex display-turn normalization requires a non-empty adapter-supplied threadId.') +} + +function readCodexThreadItemType(item: Record): CodexThreadItemVariant { + const parsed = CodexThreadItemTypeSchema.safeParse(item.type) + if (!parsed.success) { + throw new CodexDisplayProtocolError(`Unsupported Codex thread item type: ${String(item.type)}`) + } + return parsed.data +} + +function readCodexItemId(providerTurnId: string, item: Record): string { + if (typeof item.id === 'string' && item.id.length > 0) { + return item.id + } + throw new CodexDisplayProtocolError( + `Codex provider item in turn ${providerTurnId} is missing a stable item id.`, + ) +} + +function stringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === 'string') + } + if (typeof value === 'string') { + return [value] + } + return [] +} + +function summarizeFreshAgentItems(items: FreshAgentTranscriptItem[]): string { + for (const item of items) { + switch (item.kind) { + case 'text': + case 'thinking': + return item.text.slice(0, 140) + case 'reasoning': + return (item.text ?? (item.summary.join('\n') || item.content.join('\n'))).slice(0, 140) + case 'command': + return item.command.slice(0, 140) + case 'file_change': + return 'File change' + case 'mcp_tool': + return `${item.server}:${item.tool}`.slice(0, 140) + case 'dynamic_tool': + return item.tool.slice(0, 140) + case 'collab_agent': + return item.tool.slice(0, 140) + case 'web_search': + return item.query.slice(0, 140) + case 'image_view': + return item.path.slice(0, 140) + case 'image_generation': + return item.result.slice(0, 140) + case 'review_mode': + return `${item.event} review mode`.slice(0, 140) + case 'context_compaction': + return 'Context compacted' + case 'tool_use': + return item.name.slice(0, 140) + case 'tool_result': + return item.isError ? 'Tool error' : 'Tool result' + default: { + const neverItem: never = item + return String(neverItem) + } + } + } + return '' +} + +function readUserMessageTextParts(item: Record): Array<{ partIndex: number; text: string }> { + const content = Array.isArray(item.content) ? item.content : [] + const contentParts = content.map((part, partIndex) => { + const typedPart = part && typeof part === 'object' && !Array.isArray(part) + ? part as Record + : {} + if (typedPart.type === 'text' || typedPart.type === 'input_text') { + return { + partIndex, + text: typeof typedPart.text === 'string' ? typedPart.text : '', } - return content.map((part, partIndex) => { - const typedPart = part && typeof part === 'object' ? part as Record : {} - if (typedPart.type === 'text') { - return { - id: `${id}:part:${partIndex}`, - kind: 'text' as const, - text: typeof typedPart.text === 'string' ? typedPart.text : '', - } - } - return { - id: `${id}:part:${partIndex}`, - kind: 'text' as const, - text: `[${String(typedPart.type ?? 'input')}]`, - } - }) } + return { + partIndex, + text: `[${String(typedPart.type ?? 'input')}]`, + } + }) + if (contentParts.length > 0) { + return contentParts + } + if (typeof item.text === 'string') { + return [{ partIndex: 0, text: item.text }] + } + if (typeof item.summary === 'string') { + return [{ partIndex: 0, text: item.summary }] + } + return [{ partIndex: 0, text: '' }] +} + +function normalizeCodexItem( + providerTurnId: string, + item: Record, +): CodexNormalizedContribution[] { + const type = readCodexThreadItemType(item) + const itemId = readCodexItemId(providerTurnId, item) + + switch (type) { + case 'userMessage': + return readUserMessageTextParts(item).map(({ partIndex, text }) => ({ + role: 'user', + itemId, + partIndex, + item: { + id: `${itemId}:part:${partIndex}`, + kind: 'text', + text, + }, + })) case 'agentMessage': - return [{ id, kind: 'text', text: typeof item.text === 'string' ? item.text : '' }] + return [{ + role: 'assistant', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'text', + text: typeof item.text === 'string' + ? item.text + : typeof item.summary === 'string' + ? item.summary + : '', + }, + }] case 'plan': - return [{ id, kind: 'text', text: typeof item.text === 'string' ? item.text : '' }] + return [{ + role: 'assistant', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'text', + text: typeof item.text === 'string' + ? item.text + : typeof item.summary === 'string' + ? item.summary + : '', + }, + }] case 'reasoning': { - const summary = Array.isArray(item.summary) ? item.summary.filter((value): value is string => typeof value === 'string') : [] - const content = Array.isArray(item.content) ? item.content.filter((value): value is string => typeof value === 'string') : [] + const summary = stringArray(item.summary) + const content = stringArray(item.content) return [{ - id, - kind: 'reasoning', - summary, - content, - text: summary.join('\n') || content.join('\n'), + role: 'assistant', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'reasoning', + summary, + content, + text: summary.join('\n') || content.join('\n'), + }, }] } case 'commandExecution': return [{ - id, - kind: 'command', - command: typeof item.command === 'string' ? item.command : '', - cwd: typeof item.cwd === 'string' ? item.cwd : undefined, - status: item.status === 'inProgress' ? 'running' : item.status === 'declined' ? 'declined' : item.status === 'failed' ? 'failed' : 'completed', - output: typeof item.aggregatedOutput === 'string' ? item.aggregatedOutput : null, - exitCode: typeof item.exitCode === 'number' ? item.exitCode : null, - extensions: { codex: item }, + role: 'tool', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'command', + command: typeof item.command === 'string' ? item.command : '', + cwd: typeof item.cwd === 'string' ? item.cwd : undefined, + status: normalizeCommandStatus(item.status), + output: typeof item.aggregatedOutput === 'string' ? item.aggregatedOutput : null, + exitCode: typeof item.exitCode === 'number' ? item.exitCode : null, + extensions: { codex: item }, + }, }] case 'fileChange': return [{ - id, - kind: 'file_change', - status: item.status === 'inProgress' ? 'running' : item.status === 'declined' ? 'declined' : item.status === 'failed' ? 'failed' : 'completed', - changes: Array.isArray(item.changes) - ? item.changes.filter((change): change is Record => !!change && typeof change === 'object' && !Array.isArray(change)) - : [], - extensions: { codex: item }, + role: 'tool', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'file_change', + status: normalizeCommandStatus(item.status), + changes: Array.isArray(item.changes) + ? item.changes.filter((change): change is Record => !!change && typeof change === 'object' && !Array.isArray(change)) + : [], + extensions: { codex: item }, + }, }] case 'mcpToolCall': return [{ - id, - kind: 'mcp_tool', - server: typeof item.server === 'string' ? item.server : '', - tool: typeof item.tool === 'string' ? item.tool : '', - status: item.status === 'inProgress' ? 'running' : item.status === 'failed' ? 'failed' : 'completed', - arguments: item.arguments ?? null, - result: item.result, - error: item.error, + role: 'tool', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'mcp_tool', + server: typeof item.server === 'string' ? item.server : '', + tool: typeof item.tool === 'string' ? item.tool : '', + status: normalizeToolStatus(item.status), + arguments: item.arguments ?? null, + result: item.result, + error: item.error, + }, }] case 'dynamicToolCall': return [{ - id, - kind: 'dynamic_tool', - namespace: typeof item.namespace === 'string' ? item.namespace : null, - tool: typeof item.tool === 'string' ? item.tool : '', - status: item.status === 'inProgress' ? 'running' : item.status === 'failed' ? 'failed' : 'completed', - arguments: item.arguments ?? null, - contentItems: Array.isArray(item.contentItems) ? item.contentItems : null, - success: typeof item.success === 'boolean' ? item.success : null, + role: 'tool', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'dynamic_tool', + namespace: typeof item.namespace === 'string' ? item.namespace : null, + tool: typeof item.tool === 'string' ? item.tool : '', + status: normalizeToolStatus(item.status), + arguments: item.arguments ?? null, + contentItems: Array.isArray(item.contentItems) ? item.contentItems : null, + success: typeof item.success === 'boolean' ? item.success : null, + }, }] case 'collabAgentToolCall': return [{ - id, - kind: 'collab_agent', - tool: String(item.tool ?? ''), - status: item.status === 'inProgress' ? 'running' : item.status === 'failed' ? 'failed' : 'completed', - senderThreadId: String(item.senderThreadId ?? ''), - receiverThreadIds: Array.isArray(item.receiverThreadIds) - ? item.receiverThreadIds.filter((value): value is string => typeof value === 'string') - : [], - prompt: typeof item.prompt === 'string' ? item.prompt : null, - model: typeof item.model === 'string' ? item.model : null, - reasoningEffort: typeof item.reasoningEffort === 'string' ? item.reasoningEffort : null, - agentsStates: item.agentsStates && typeof item.agentsStates === 'object' && !Array.isArray(item.agentsStates) - ? item.agentsStates as Record - : {}, + role: 'tool', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'collab_agent', + tool: String(item.tool ?? ''), + status: normalizeToolStatus(item.status), + senderThreadId: String(item.senderThreadId ?? ''), + receiverThreadIds: Array.isArray(item.receiverThreadIds) + ? item.receiverThreadIds.filter((value): value is string => typeof value === 'string') + : [], + prompt: typeof item.prompt === 'string' ? item.prompt : null, + model: typeof item.model === 'string' ? item.model : null, + reasoningEffort: typeof item.reasoningEffort === 'string' ? item.reasoningEffort : null, + agentsStates: item.agentsStates && typeof item.agentsStates === 'object' && !Array.isArray(item.agentsStates) + ? item.agentsStates as Record + : {}, + }, }] case 'webSearch': return [{ - id, - kind: 'web_search', - query: typeof item.query === 'string' ? item.query : '', - action: item.action ?? null, + role: 'tool', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'web_search', + query: typeof item.query === 'string' ? item.query : '', + action: item.action ?? null, + }, }] case 'imageView': - return [{ id, kind: 'image_view', path: typeof item.path === 'string' ? item.path : '' }] + return [{ + role: 'tool', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'image_view', + path: typeof item.path === 'string' ? item.path : '', + }, + }] case 'imageGeneration': return [{ - id, - kind: 'image_generation', - status: String(item.status ?? ''), - revisedPrompt: typeof item.revisedPrompt === 'string' ? item.revisedPrompt : null, - result: String(item.result ?? ''), - savedPath: typeof item.savedPath === 'string' ? item.savedPath : undefined, + role: 'tool', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'image_generation', + status: String(item.status ?? ''), + revisedPrompt: typeof item.revisedPrompt === 'string' ? item.revisedPrompt : null, + result: String(item.result ?? ''), + savedPath: typeof item.savedPath === 'string' ? item.savedPath : undefined, + }, }] case 'enteredReviewMode': - return [{ id, kind: 'review_mode', event: 'entered', review: String(item.review ?? '') }] + return [{ + role: 'system', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'review_mode', + event: 'entered', + review: String(item.review ?? ''), + }, + }] case 'exitedReviewMode': - return [{ id, kind: 'review_mode', event: 'exited', review: String(item.review ?? '') }] + return [{ + role: 'system', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'review_mode', + event: 'exited', + review: String(item.review ?? ''), + }, + }] case 'contextCompaction': - return [{ id, kind: 'context_compaction' }] + return [{ + role: 'system', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'context_compaction', + }, + }] case 'hookPrompt': - return [{ id, kind: 'text', text: 'Hook prompt' }] + return [{ + role: 'system', + itemId, + partIndex: 0, + item: { + id: itemId, + kind: 'text', + text: typeof item.text === 'string' ? item.text : 'Hook prompt', + }, + }] default: - throw new Error(`Unsupported Codex thread item type: ${String(item.type)}`) + return assertNever(type) } } -function inferCodexTurnRole(rawItems: Record[]): FreshAgentTurn['role'] { - if (rawItems.some((item) => item.type === 'agentMessage' || item.type === 'reasoning' || item.type === 'plan')) { - return 'assistant' - } - if (rawItems.some((item) => item.type === 'userMessage')) { - return 'user' - } - if (rawItems.some((item) => ( - item.type === 'commandExecution' - || item.type === 'fileChange' - || item.type === 'mcpToolCall' - || item.type === 'dynamicToolCall' - || item.type === 'collabAgentToolCall' - || item.type === 'webSearch' - || item.type === 'imageView' - || item.type === 'imageGeneration' - ))) { - return 'tool' +export function classifyCodexItemRole(item: Record): CodexDisplayRole { + const type = readCodexThreadItemType(item) + switch (type) { + case 'userMessage': + return 'user' + case 'agentMessage': + case 'plan': + case 'reasoning': + return 'assistant' + case 'commandExecution': + case 'fileChange': + case 'mcpToolCall': + case 'dynamicToolCall': + case 'collabAgentToolCall': + case 'webSearch': + case 'imageView': + case 'imageGeneration': + return 'tool' + case 'hookPrompt': + case 'enteredReviewMode': + case 'exitedReviewMode': + case 'contextCompaction': + return 'system' + default: + return assertNever(type) } - return undefined } function readCodexTurnError(rawTurn: Record): string | undefined { @@ -190,85 +512,226 @@ function readCodexTurnError(rawTurn: Record): string | undefine return String(error) } -export function normalizeCodexTurn( +function createSyntheticPendingRow(kind: Exclude, text: string): CodexPendingRow { + return { + role: 'assistant', + itemIds: [], + partIndexes: [], + items: [{ + id: `codex-display-synthetic:${kind}`, + kind: 'text', + text, + }], + syntheticKind: kind, + } +} + +function buildDisplayTurn(input: { + providerTurnId: string + ordinal: number + model?: string + threadId: string + secret: string + row: CodexPendingRow + submittedRequestId?: string | number +}): FreshAgentTurn & { requestId?: string | number; syntheticKind?: CodexDisplaySyntheticKind } { + const { row } = input + const requestId = input.submittedRequestId + const syntheticKind = requestId !== undefined && row.role === 'user' + ? 'submitted-input' + : row.syntheticKind + const turnId = createCodexDisplayId({ + secret: input.secret, + threadId: input.threadId, + providerTurnId: input.providerTurnId, + role: row.role, + itemIds: requestId !== undefined && row.role === 'user' ? undefined : row.itemIds, + partIndexes: requestId !== undefined && row.role === 'user' ? undefined : row.partIndexes, + syntheticKind, + ...(requestId !== undefined ? { requestId } : {}), + }) + + return { + id: turnId, + turnId, + ordinal: input.ordinal, + source: 'durable', + role: row.role, + ...(input.model ? { model: input.model } : {}), + summary: summarizeFreshAgentItems(row.items), + items: row.items, + ...(syntheticKind ? { syntheticKind } : {}), + ...(requestId !== undefined ? { requestId } : {}), + } +} + +export function createCodexDisplayId(identity: CodexDisplayIdentity): string { + const payload = JSON.stringify({ + threadId: identity.threadId, + providerTurnId: identity.providerTurnId, + role: identity.role, + parts: (identity.itemIds ?? []).map((itemId, index) => ({ + itemId, + partIndex: identity.partIndexes?.[index] ?? 0, + })), + syntheticKind: identity.syntheticKind ?? null, + requestId: identity.requestId ?? null, + }) + const handle = createHmac('sha256', identity.secret) + .update(payload) + .digest() + .subarray(0, 16) + .toString('base64url') + + return `${CODEX_DISPLAY_ID_PREFIX}${handle}` +} + +export function parseCodexDisplayIdHandle(turnId: string): { handle: string } | null { + const match = turnId.match(new RegExp(`^${CODEX_DISPLAY_ID_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([A-Za-z0-9_-]{${CODEX_DISPLAY_HANDLE_LENGTH}})$`)) + return match ? { handle: match[1] } : null +} + +export function normalizeCodexDisplayTurns( rawTurn: Record, ordinal = 0, - options: { model?: string } = {}, -): FreshAgentTurn { - const turnId = String(rawTurn.id ?? `turn:${ordinal}`) + options: NormalizeCodexDisplayTurnsOptions = {}, +): { turns: FreshAgentTurn[]; displayRows: CodexDisplayRow[] } { + const providerTurnId = String(rawTurn.id ?? `turn:${ordinal}`) + const threadId = readRequiredCodexThreadId(options.threadId) + const secret = readRequiredCodexDisplaySecret(options.secret) const model = typeof rawTurn.model === 'string' && rawTurn.model.length > 0 ? rawTurn.model : options.model const rawItems = Array.isArray(rawTurn.items) ? rawTurn.items.filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item)) : [] - const items = rawItems.flatMap((item, index) => normalizeCodexItem(turnId, item, index)) - const hasAssistantOutput = rawItems.some((item) => item.type === 'agentMessage' || item.type === 'reasoning' || item.type === 'plan') + + const pendingRows: CodexPendingRow[] = [] + for (const rawItem of rawItems) { + const role = classifyCodexItemRole(rawItem) + const normalizedItems = normalizeCodexItem(providerTurnId, rawItem) + const currentRow = pendingRows.at(-1) + if (!currentRow || currentRow.role !== role || currentRow.syntheticKind) { + pendingRows.push({ + role, + itemIds: normalizedItems.map((item) => item.itemId), + partIndexes: normalizedItems.map((item) => item.partIndex), + items: normalizedItems.map((item) => item.item), + }) + continue + } + currentRow.itemIds.push(...normalizedItems.map((item) => item.itemId)) + currentRow.partIndexes.push(...normalizedItems.map((item) => item.partIndex)) + currentRow.items.push(...normalizedItems.map((item) => item.item)) + } + + const hasAssistantOutput = rawItems.some((item) => { + const role = classifyCodexItemRole(item) + return role === 'assistant' + }) + const hasUserOutput = rawItems.some((item) => classifyCodexItemRole(item) === 'user') const turnError = readCodexTurnError(rawTurn) if (turnError) { - items.push({ - id: `${turnId}:error`, - kind: 'text', - text: `Codex turn failed: ${turnError}`, - }) + pendingRows.push(createSyntheticPendingRow('error', `Codex turn failed: ${turnError}`)) } else if ( rawTurn.status === 'completed' - && rawItems.some((item) => item.type === 'userMessage') - && rawItems.every((item) => item.type === 'userMessage') + && hasUserOutput + && rawItems.every((item) => classifyCodexItemRole(item) === 'user') && !hasAssistantOutput ) { - items.push({ - id: `${turnId}:empty-response`, - kind: 'text', - text: 'Codex completed this turn without recording an assistant response.', + pendingRows.push(createSyntheticPendingRow( + 'empty-response', + 'Codex completed this turn without recording an assistant response.', + )) + } + + const submittedRequestId = options.submittedRequestIdByProviderTurnId?.get(providerTurnId) + let submittedAliasConsumed = false + const turns = pendingRows.map((row, rowIndex) => { + const useSubmittedAlias = submittedRequestId !== undefined && !submittedAliasConsumed && row.role === 'user' + if (useSubmittedAlias) { + submittedAliasConsumed = true + } + return buildDisplayTurn({ + providerTurnId, + ordinal: ordinal + rowIndex, + model, + threadId, + secret, + row, + ...(useSubmittedAlias ? { submittedRequestId } : {}), }) + }) + + const displayRows = turns.map((turn, index) => ({ + turnId: turn.turnId, + role: turn.role ?? 'assistant', + providerTurnId, + itemIds: pendingRows[index]?.itemIds ?? [], + partIndexes: pendingRows[index]?.partIndexes ?? [], + items: turn.items, + ...(typeof turn.syntheticKind === 'string' ? { syntheticKind: turn.syntheticKind } : {}), + ...(turn.requestId !== undefined ? { requestId: turn.requestId } : {}), + })) + + return { turns, displayRows } +} + +export function normalizeCodexTurn( + rawTurn: Record, + ordinal = 0, + options: NormalizeCodexDisplayTurnsOptions = {}, +): FreshAgentTurn { + const normalized = normalizeCodexDisplayTurns(rawTurn, ordinal, options).turns[0] + if (normalized) { + const { syntheticKind: _syntheticKind, requestId: _requestId, ...turn } = normalized as FreshAgentTurn & { + syntheticKind?: CodexDisplaySyntheticKind + requestId?: string | number + } + return turn } - const firstText = items.find((item): item is Extract => item.kind === 'text') + const providerTurnId = String(rawTurn.id ?? `turn:${ordinal}`) + const model = typeof rawTurn.model === 'string' && rawTurn.model.length > 0 + ? rawTurn.model + : options.model return { - id: turnId, - turnId, + id: providerTurnId, + turnId: providerTurnId, ordinal, source: 'durable', - role: inferCodexTurnRole(rawItems), ...(model ? { model } : {}), - summary: firstText?.text.slice(0, 140) ?? '', - items, + summary: '', + items: [], } } -export function normalizeCodexTurnPage(input: { - threadId: string - revision: number - rawPage: { turns?: unknown[]; nextCursor?: string | null; backwardsCursor?: string | null } - model?: string - modelByTurn?: Map -}) { - const turns = (Array.isArray(input.rawPage.turns) ? input.rawPage.turns : []) - .filter((turn): turn is Record => !!turn && typeof turn === 'object' && !Array.isArray(turn)) - .map((turn, index) => normalizeCodexTurn(turn, index, { - model: (typeof turn.id === 'string' ? input.modelByTurn?.get(turn.id) : undefined) ?? input.model, - })) - - return FreshAgentTurnPageSchema.parse({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: input.threadId, - revision: input.revision, - nextCursor: input.rawPage.nextCursor ?? null, - backwardsCursor: input.rawPage.backwardsCursor ?? null, - turns, - bodies: Object.fromEntries(turns.map((turn) => [turn.turnId, turn])), - }) -} - export function normalizeCodexTurnBody(input: { threadId: string revision: number + requestedTurnId: string rawTurn: Record model?: string + secret?: string + submittedRequestIdByProviderTurnId?: ReadonlyMap }) { + const { turns } = normalizeCodexDisplayTurns(input.rawTurn, 0, { + threadId: input.threadId, + model: input.model, + secret: input.secret, + submittedRequestIdByProviderTurnId: input.submittedRequestIdByProviderTurnId, + }) + const selectedTurn = turns.find((turn) => turn.turnId === input.requestedTurnId) + if (!selectedTurn) { + throw new CodexDisplayTurnNotFoundError( + `Codex display turn ${input.requestedTurnId} was not found in provider turn ${String(input.rawTurn.id ?? 'unknown')}.`, + ) + } + const { syntheticKind: _syntheticKind, requestId: _requestId, ...turn } = selectedTurn as FreshAgentTurn & { + syntheticKind?: CodexDisplaySyntheticKind + requestId?: string | number + } return FreshAgentTurnBodySchema.parse({ - ...normalizeCodexTurn(input.rawTurn, 0, { model: input.model }), + ...turn, sessionType: 'freshcodex', provider: 'codex', threadId: input.threadId, diff --git a/server/fresh-agent/adapters/opencode/normalize.ts b/server/fresh-agent/adapters/opencode/normalize.ts index ba8710c09..10d0529ca 100644 --- a/server/fresh-agent/adapters/opencode/normalize.ts +++ b/server/fresh-agent/adapters/opencode/normalize.ts @@ -21,6 +21,12 @@ type OpencodeExportWithPageMetadata = OpencodeExport & { nextCursor?: string | null } +function normalizeOpencodeRole(value: unknown): FreshAgentTurn['role'] { + return value === 'user' || value === 'assistant' || value === 'system' || value === 'tool' + ? value + : undefined +} + function modelFromInfo(info: Record | undefined): string | undefined { const providerId = info?.providerID ?? info?.model?.providerID const modelId = info?.modelID ?? info?.model?.modelID ?? info?.model?.id @@ -199,14 +205,18 @@ function collectOpencodePartMetadata(messages: NonNullable[number], ordinal: number): FreshAgentTurn { +export function normalizeOpencodeTurn( + message: NonNullable[number], + ordinal: number, +): FreshAgentTurn | null { const info = message.info ?? {} const id = typeof info.id === 'string' && info.id.length > 0 ? info.id : `message-${ordinal}` - const role: FreshAgentTurn['role'] = info.role === 'user' || info.role === 'assistant' || info.role === 'system' || info.role === 'tool' ? info.role : undefined + const role = normalizeOpencodeRole(info.role) const parts = Array.isArray(message.parts) ? message.parts : [] const items = parts .map((part, index) => itemFromPart(part, `${id}:part-${index}`, role)) .filter((item): item is FreshAgentTranscriptItem => Boolean(item)) + if (!role && items.length > 0) return null const textSummary = items.find((item) => item.kind === 'text')?.text const reasoningSummary = items.find((item) => item.kind === 'reasoning')?.summary?.[0] return { @@ -233,7 +243,9 @@ export function normalizeOpencodeSnapshot(input: { }): FreshAgentSnapshot { const info = input.exported?.info ?? {} const messages = Array.isArray(input.exported?.messages) ? input.exported.messages : [] - const turns = messages.map((message, index) => normalizeOpencodeTurn(message, index)) + const turns = messages + .map((message, index) => normalizeOpencodeTurn(message, index)) + .filter((turn): turn is FreshAgentTurn => Boolean(turn)) const sessionModel = modelFromInfo(info) ?? input.model const durableSessionId = typeof info.id === 'string' && info.id.length > 0 ? info.id : input.threadId const opencodeExtensions = collectOpencodePartMetadata(messages) @@ -287,7 +299,9 @@ export function normalizeOpencodeTurnPage(input: { threadId: input.threadId, revision: input.revision, nextCursor: typeof nextCursor === 'string' ? nextCursor : null, - turns: messages.map((message, index) => normalizeOpencodeTurn(message, index)), + turns: messages + .map((message, index) => normalizeOpencodeTurn(message, index)) + .filter((turn): turn is FreshAgentTurn => Boolean(turn)), } } @@ -300,8 +314,10 @@ export function normalizeOpencodeTurnBody(input: { const messages = Array.isArray(input.exported?.messages) ? input.exported.messages : [] const index = messages.findIndex((message) => message.info?.id === input.turnId) if (index < 0) return null + const turn = normalizeOpencodeTurn(messages[index], index) + if (!turn) return null return { - ...normalizeOpencodeTurn(messages[index], index), + ...turn, sessionType: 'freshopencode', provider: 'opencode', threadId: input.threadId, diff --git a/server/fresh-agent/router.ts b/server/fresh-agent/router.ts index 6825ea567..983f83abc 100644 --- a/server/fresh-agent/router.ts +++ b/server/fresh-agent/router.ts @@ -10,7 +10,12 @@ import { FreshAgentRuntimeManager, FreshAgentRuntimeUnavailableError, FreshAgentStaleThreadRevisionError, + FreshAgentUnprovableThreadRevisionError, FreshAgentUnsupportedCapabilityError, + FreshAgentInvalidDisplayIdError, + FreshAgentInvalidTurnCursorError, + FreshAgentTurnNotFoundError, + FreshAgentAmbiguousTurnBodyError, FreshAgentLostSessionError, FreshAgentSessionLocatorMismatchError, FreshAgentContractValidationError, @@ -114,6 +119,25 @@ export function createFreshAgentRouter(deps: { currentRevision: error.currentRevision, }) } + if (error instanceof FreshAgentUnprovableThreadRevisionError) { + return res.status(409).json({ + error: error.message, + code: error.code, + requestedRevision: error.requestedRevision, + }) + } + if (error instanceof FreshAgentInvalidDisplayIdError) { + return res.status(400).json({ error: error.message, code: error.code }) + } + if (error instanceof FreshAgentInvalidTurnCursorError) { + return res.status(400).json({ error: error.message, code: error.code }) + } + if (error instanceof FreshAgentAmbiguousTurnBodyError) { + return res.status(409).json({ error: error.message, code: error.ambiguousCode }) + } + if (error instanceof FreshAgentTurnNotFoundError) { + return res.status(404).json({ error: error.message, code: error.code }) + } if (error instanceof FreshAgentRuntimeUnavailableError) { return res.status(503).json({ error: error.message, code: error.code }) } diff --git a/server/fresh-agent/runtime-adapter.ts b/server/fresh-agent/runtime-adapter.ts index 43779b948..87e070103 100644 --- a/server/fresh-agent/runtime-adapter.ts +++ b/server/fresh-agent/runtime-adapter.ts @@ -29,6 +29,8 @@ export type FreshAgentCreateResult = { } export type FreshAgentSendResult = void | { + requestId?: string + submittedTurnId?: string sessionId?: string sessionRef?: { provider: string; sessionId: string } } @@ -58,7 +60,7 @@ export interface FreshAgentRuntimeAdapter { resume?(input: FreshAgentCreateRequest): Promise<{ sessionId: string; sessionRef?: { provider: string; sessionId: string } }> attach?(locator: FreshAgentSessionLocator): Promise<{ sessionId: string; sessionRef?: { provider: string; sessionId: string } }> | { sessionId: string; sessionRef?: { provider: string; sessionId: string } } subscribe?(sessionId: string, listener: (message: unknown) => void): Promise<() => void> | (() => void) - send?(sessionId: string, input: { text: string; images?: FreshAgentInputImage[]; settings?: FreshAgentCreateRequest }): Promise | FreshAgentSendResult + send?(sessionId: string, input: { requestId?: string; text: string; images?: FreshAgentInputImage[]; settings?: FreshAgentCreateRequest }): Promise | FreshAgentSendResult interrupt?(sessionId: string): Promise | void compact?(sessionId: string, input?: { instructions?: string }): Promise | void kill?(sessionId: string): Promise | boolean diff --git a/server/fresh-agent/runtime-manager.ts b/server/fresh-agent/runtime-manager.ts index c3ff92095..d8684ac10 100644 --- a/server/fresh-agent/runtime-manager.ts +++ b/server/fresh-agent/runtime-manager.ts @@ -1,3 +1,6 @@ +import { + randomUUID, +} from 'node:crypto' import { makeFreshAgentSessionKey, type FreshAgentRuntimeProvider, @@ -31,10 +34,34 @@ export class FreshAgentStaleThreadRevisionError extends Error { } } +export class FreshAgentUnprovableThreadRevisionError extends Error { + readonly code = 'UNPROVABLE_THREAD_REVISION' as const + + constructor(readonly requestedRevision: number) { + super('Fresh-agent thread revision could not be proven from the current provider body') + } +} + export class FreshAgentUnsupportedCapabilityError extends Error { readonly code = 'FRESH_AGENT_UNSUPPORTED_CAPABILITY' as const } +export class FreshAgentInvalidDisplayIdError extends Error { + readonly code = 'INVALID_DISPLAY_ID' as const +} + +export class FreshAgentInvalidTurnCursorError extends Error { + readonly code = 'INVALID_TURN_CURSOR' as const +} + +export class FreshAgentTurnNotFoundError extends Error { + readonly code = 'TURN_NOT_FOUND' as const +} + +export class FreshAgentAmbiguousTurnBodyError extends FreshAgentUnsupportedCapabilityError { + readonly ambiguousCode = 'AMBIGUOUS_NATIVE_TURN_ID' as const +} + export class FreshAgentLostSessionError extends Error { readonly code = 'FRESH_AGENT_LOST_SESSION' as const constructor(message?: string) { @@ -150,13 +177,20 @@ export class FreshAgentRuntimeManager { async send( locator: FreshAgentSessionLocator, - input: { text: string; images?: FreshAgentInputImage[]; settings?: FreshAgentCreateRequest }, + input: { requestId?: string; text: string; images?: FreshAgentInputImage[]; settings?: FreshAgentCreateRequest }, ): Promise { const record = this.requireSession(locator) if (!record.adapter.send) { throw new FreshAgentUnsupportedCapabilityError(`Send is not supported for ${record.sessionType}`) } - const result = await record.adapter.send(locator.sessionId, input) + const requestId = input.requestId ?? randomUUID() + const { requestId: _requestId, ...adapterInput } = input + Object.defineProperty(adapterInput, 'requestId', { + value: requestId, + enumerable: false, + configurable: true, + }) + const result = await record.adapter.send(locator.sessionId, adapterInput) if (result?.sessionId && result.sessionId !== locator.sessionId) { this.sessions.set(this.key({ sessionType: locator.sessionType, @@ -164,7 +198,16 @@ export class FreshAgentRuntimeManager { sessionId: result.sessionId, }), record) } - return result + if (result?.requestId) { + return result + } + const wrappedResult = { ...(result ?? {}) } as Exclude + Object.defineProperty(wrappedResult, 'requestId', { + value: requestId, + enumerable: result == null, + configurable: true, + }) + return wrappedResult } async interrupt(locator: FreshAgentSessionLocator) { diff --git a/server/index.ts b/server/index.ts index 630bf650d..1d48f8e20 100644 --- a/server/index.ts +++ b/server/index.ts @@ -313,7 +313,9 @@ async function main() { sdkBridge, agentHistorySource, }) + const codexDisplayIdSecret = await configStore.getCodexDisplayIdSecret() const codexFreshAgentAdapter = createCodexFreshAgentAdapter({ + displayIdSecret: codexDisplayIdSecret, runtimeFactory: () => new CodexAppServerRuntime({ serverInstanceId }), }) const opencodeServeManager = new OpencodeServeManager() diff --git a/server/ws-handler.ts b/server/ws-handler.ts index e450d6206..3dba92697 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -134,6 +134,8 @@ type FreshAgentRuntimeManagerLike = { } type FreshAgentSendResult = void | { + requestId?: string + submittedTurnId?: string sessionId?: string sessionRef?: { provider: string; sessionId: string } } @@ -3187,7 +3189,12 @@ export class WsHandler { const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider } if (!this.requireFreshAgentAuthorization(ws, state, locator)) return try { - const result = await manager.send(locator, { text: m.text, images: m.images, settings: m.settings }) + const result = await manager.send(locator, { + requestId: m.requestId, + text: m.text, + images: m.images, + settings: m.settings, + }) if (result?.sessionId && result.sessionId !== m.sessionId) { this.authorizeFreshAgentSession(state, { sessionId: result.sessionId, @@ -3208,8 +3215,15 @@ export class WsHandler { sessionRef: result.sessionRef ?? { provider: m.provider, sessionId: result.sessionId }, }) } + if (m.requestId) { + this.send(ws, { + type: 'freshAgent.send.accepted', + requestId: m.requestId, + submittedTurnId: result?.submittedTurnId, + }) + } } catch (error) { - this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) }) + this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error), requestId: m.requestId }) } return } diff --git a/shared/fresh-agent-turns.ts b/shared/fresh-agent-turns.ts new file mode 100644 index 000000000..957a7183f --- /dev/null +++ b/shared/fresh-agent-turns.ts @@ -0,0 +1,23 @@ +import type { FreshAgentSnapshot, FreshAgentTurn } from './fresh-agent-contract.js' + +export function getFreshAgentDisplayTurnKey(turn: Pick): string { + return turn.turnId ?? turn.id +} + +export function freshAgentTurnText(turn: Pick): string { + const textItems = turn.items + .filter((item): item is Extract => item.kind === 'text') + .map((item) => item.text) + const text = textItems.join(' ') + return textItems.length > 0 ? text : turn.summary +} + +function normalizeTurnRole(role: unknown): string | undefined { + return typeof role === 'string' ? role.trim().toLowerCase() : undefined +} + +export function freshAgentSnapshotHasUserTurn( + snapshot: Pick | null | undefined, +): boolean { + return snapshot?.turns?.some((turn) => normalizeTurnRole(turn.role) === 'user') ?? false +} diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index b0d4663f0..a94270eb1 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -430,6 +430,7 @@ export const FreshAgentAttachSchema = z.object({ export const FreshAgentSendSchema = z.object({ type: z.literal('freshAgent.send'), + requestId: z.string().min(1).optional(), sessionId: z.string().min(1), sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), provider: z.enum(['claude', 'codex', 'opencode']), @@ -863,6 +864,7 @@ export type SdkRestoreFailureCode = export type FreshAgentServerMessage = | { type: 'freshAgent.created'; requestId: string; sessionId: string; sessionType: string; provider: string; runtimeProvider: string; sessionRef?: { provider: string; sessionId: string } } | { type: 'freshAgent.create.failed'; requestId: string; code: string; message: string; retryable?: boolean } + | { type: 'freshAgent.send.accepted'; requestId: string; submittedTurnId?: string } | { type: 'freshAgent.event'; sessionId: string; sessionType: string; provider: string; event: unknown } | { type: 'freshAgent.session.materialized'; previousSessionId: string; sessionId: string; sessionType: string; provider: string; sessionRef?: { provider: string; sessionId: string } } | { type: 'freshAgent.forked'; requestId?: string; parentSessionId: string; sessionId: string; sessionType: string; provider: string; runtimeProvider: string; sessionRef?: { provider: string; sessionId: string } } diff --git a/src/components/fresh-agent/FreshAgentTranscript.tsx b/src/components/fresh-agent/FreshAgentTranscript.tsx index 77b92c3c7..965c256b5 100644 --- a/src/components/fresh-agent/FreshAgentTranscript.tsx +++ b/src/components/fresh-agent/FreshAgentTranscript.tsx @@ -21,6 +21,7 @@ import { } from './FreshAgentTurnActions' import { FreshAgentActionSheet } from './FreshAgentActionSheet' import { buildLongPressHandlers, useCoarsePointer } from '@/lib/pointer' +import { getFreshAgentDisplayTurnKey } from '@shared/fresh-agent-turns' function getTurnLabel(turn: FreshAgentTurn, agentLabel?: string): string { switch (turn.role) { @@ -223,41 +224,6 @@ function buildBlocks( return blocks } -function isActivityOnlyTurn(turn: FreshAgentTurn): boolean { - return turn.items.length > 0 && turn.items.every(isActivityLike) -} - -function mergeActivityOnlyTurns(previous: FreshAgentTurn, next: FreshAgentTurn): FreshAgentTurn { - return { - ...next, - id: `${previous.id}:${next.id}`, - turnId: next.turnId ?? next.id, - summary: [previous.summary, next.summary].filter(Boolean).join('\n\n'), - items: [...previous.items, ...next.items], - model: next.model ?? previous.model, - timestamp: next.timestamp ?? previous.timestamp, - } -} - -function coalesceActivityOnlyTurns(turns: FreshAgentTurn[]): FreshAgentTurn[] { - const coalesced: FreshAgentTurn[] = [] - for (const turn of turns) { - const previous = coalesced[coalesced.length - 1] - if ( - previous - && turn.role !== 'user' - && previous.role === turn.role - && isActivityOnlyTurn(previous) - && isActivityOnlyTurn(turn) - ) { - coalesced[coalesced.length - 1] = mergeActivityOnlyTurns(previous, turn) - continue - } - coalesced.push(turn) - } - return coalesced -} - function filterTurnsForDisplay( turns: FreshAgentTurn[], options: TranscriptDisplayOptions, @@ -575,7 +541,7 @@ export function FreshAgentTranscript({ showThinking, }), [showThinking]) const displayTurns = useMemo(() => ( - coalesceActivityOnlyTurns(filterTurnsForDisplay(turns, displayOptions)) + filterTurnsForDisplay(turns, displayOptions) ), [displayOptions, turns]) const liveActivityBlockId = useMemo( () => selectLiveActivityBlockId(displayTurns, isStreaming, displayOptions), @@ -598,7 +564,7 @@ export function FreshAgentTranscript({ } return `${item.id}:${item.kind}` }).join(',') - return `${turn.id}:${turn.summary?.length ?? 0}:${itemSignature}` + return `${getFreshAgentDisplayTurnKey(turn)}:${turn.summary?.length ?? 0}:${itemSignature}` }).join('|') ), [displayTurns]) @@ -642,7 +608,7 @@ export function FreshAgentTranscript({ > {displayTurns.map((turn, index) => ( ( + turn.role === 'user' + && getFreshAgentDisplayTurnKey(turn) === submittedTurnId + && freshAgentTurnText(turn).includes(needle) + )) + } + if (pending && !pending.legacyAccepted) return false + return turns.some((turn) => ( + turn.role === 'user' + && freshAgentTurnText(turn).includes(needle) + )) } function isSnapshotInFlight(snapshot: FreshAgentSnapshot): boolean { @@ -350,19 +390,14 @@ export function FreshAgentView({ // through the freshAgent slice too, but the claudeSession selector above is // claude-only — without this, a dead codex/opencode process left the pane // looking healthy (blank pane, enabled composer). - const agentSessionMeta = useAppSelector((state) => { + const agentSession = useAppSelector((state) => { if (!paneContent.sessionId) return undefined const sessionKey = makeFreshAgentSessionKey({ sessionId: paneContent.sessionId, sessionType: paneContent.sessionType, provider: paneContent.provider, }) - const session = state.freshAgent.sessions[sessionKey] - if (!session) return undefined - return { - status: session.status as string | undefined, - lastError: (session as { lastError?: string }).lastError, - } + return state.freshAgent.sessions[sessionKey] }) const refreshRequest = useAppSelector((state) => state.panes.refreshRequestsByPane?.[tabId]?.[paneId] ?? null) const [snapshot, setSnapshot] = useState(null) @@ -379,9 +414,10 @@ export function FreshAgentView({ // Optimistic echo of the just-sent user message: the transcript renders // snapshot turns only, which left a 2-10s blank gap after send // (live-test finding). Cleared when a snapshot containing the turn lands. - const [localEcho, setLocalEcho] = useState(null) - const localEchoRef = useRef(null) + const [localEcho, setLocalEcho] = useState(null) + const localEchoRef = useRef(null) localEchoRef.current = localEcho + const pendingSendMetadataRef = useRef>(new Map()) const descriptor = resolveFreshAgentType(paneContent.sessionType) // Capability-gated commands (e.g. /fork) only appear once the snapshot // confirms the provider supports the action. @@ -431,7 +467,7 @@ export function FreshAgentView({ && claudeSession?.historyLoaded !== true && !hasRestoreFailure, ) - const hasUserTurns = useMemo(() => snapshot?.turns.some((turn) => turn.role === 'user') ?? false, [snapshot?.turns]) + const hasUserTurns = useMemo(() => freshAgentSnapshotHasUserTurn(snapshot), [snapshot]) const autoTitleDurableIdentity = useMemo(() => { const paneSessionRefId = paneContent.sessionRef?.provider === paneContent.provider ? paneContent.sessionRef.sessionId @@ -488,6 +524,37 @@ export function FreshAgentView({ ws.send(message as never) }, [paneId, ws]) + const recordPendingSendMetadata = useCallback((requestId: string, patch: PendingSendMetadata) => { + const current = pendingSendMetadataRef.current.get(requestId) ?? {} + const next: PendingSendMetadata = { ...current, ...patch } + pendingSendMetadataRef.current.set(requestId, next) + if ( + next.metadataUpdateStarted + || !next.cwd + || !next.checkpointId + || !next.submittedTurnId + ) { + return + } + pendingSendMetadataRef.current.set(requestId, { ...next, metadataUpdateStarted: true }) + void Promise + .resolve(api.post('/api/fresh-agent/checkpoints/metadata', { + cwd: next.cwd, + id: next.checkpointId, + requestId, + turnId: next.submittedTurnId, + })) + .then(() => { + pendingSendMetadataRef.current.delete(requestId) + }) + .catch(() => { + const latest = pendingSendMetadataRef.current.get(requestId) + if (latest) { + pendingSendMetadataRef.current.set(requestId, { ...latest, metadataUpdateStarted: false }) + } + }) + }, []) + const migratePendingAutoTitle = useCallback(( previousSessionId: string | undefined, nextSessionId: string | undefined, @@ -858,6 +925,24 @@ export function FreshAgentView({ }, })) } + if ( + message.type === 'freshAgent.send.accepted' + && typeof message.requestId === 'string' + ) { + const submittedTurnId = typeof message.submittedTurnId === 'string' + ? message.submittedTurnId + : undefined + if (submittedTurnId) { + recordPendingSendMetadata(message.requestId, { submittedTurnId }) + const echo = localEchoRef.current + if (echo?.requestId === message.requestId) { + setLocalEcho({ ...echo, submittedTurnId }) + } + } else { + recordPendingSendMetadata(message.requestId, { legacyAccepted: true }) + } + setSnapshotRefreshNonce((value) => value + 1) + } if ( message.type === 'freshAgent.event' && message.sessionId === paneContent.sessionId @@ -903,7 +988,7 @@ export function FreshAgentView({ } }) return unsubscribe - }, [commitSnapshot, dispatch, migratePendingAutoTitle, paneContent, paneContent.createRequestId, paneId, sendFreshAgentMessage, tabId, ws]) + }, [commitSnapshot, dispatch, migratePendingAutoTitle, paneContent, paneContent.createRequestId, paneId, recordPendingSendMetadata, sendFreshAgentMessage, tabId, ws]) useEffect(() => { if (!snapshotThreadId) return @@ -929,7 +1014,7 @@ export function FreshAgentView({ if (isStaleSnapshotRequest()) return const snapshotIdentity = currentAutoTitleIdentityRef.current const resolved = next as FreshAgentSnapshot - const resolvedHasUserTurns = resolved.turns.some((turn) => turn.role === 'user') + const resolvedHasUserTurns = freshAgentSnapshotHasUserTurn(resolved) if (!resolvedHasUserTurns && !autoTitleSentRef.current) { autoTitleFreshBoundaryRef.current = true } @@ -942,13 +1027,8 @@ export function FreshAgentView({ setSnapshotAutoTitleIdentity(snapshotIdentity) const echo = localEchoRef.current if (echo) { - const needle = echo.slice(0, 80) - const echoLanded = displaySnapshot.turns.some((turn) => ( - turn.role === 'user' && turn.items.some((item) => ( - item.kind === 'text' && item.text.includes(needle) - )) - )) - if (echoLanded) setLocalEcho(null) + const pending = pendingSendMetadataRef.current.get(echo.requestId) + if (localEchoLanded(displaySnapshot.turns, echo, pending)) setLocalEcho(null) } const fresh = paneContentRef.current const nextStatus = (resolved.status as FreshAgentPaneContent['status']) ?? fresh.status @@ -1135,10 +1215,10 @@ export function FreshAgentView({ const effectiveStatus = paneContent.provider === 'claude' ? (claudeSessionStatus ?? paneContent.status) - : (agentSessionMeta?.status ?? paneContent.status) + : (agentSession?.status ?? paneContent.status) const isBusy = BUSY_STATES.has(effectiveStatus) const sessionEnded = effectiveStatus === 'exited' || effectiveStatus === 'create-failed' - const sessionErrorMessage = agentSessionMeta?.lastError ?? null + const sessionErrorMessage = (agentSession as { lastError?: string } | undefined)?.lastError ?? null useEffect(() => { if (hidden) return @@ -1171,15 +1251,27 @@ export function FreshAgentView({ const sendUserText = useCallback((text: string) => { const current = paneContentRef.current if (!current.sessionId) return + const requestId = nanoid() + recordPendingSendMetadata(requestId, {}) // Checkpoint the working tree before the agent acts on this message, so // "rewind code to here" on this turn restores the pre-turn state. Fire and // forget: a failed snapshot must never block the send. if (current.initialCwd) { + recordPendingSendMetadata(requestId, { cwd: current.initialCwd }) void Promise - .resolve(api.post('/api/fresh-agent/checkpoints', { + .resolve(api.post('/api/fresh-agent/checkpoints', { cwd: current.initialCwd, label: checkpointLabelForText(text), + requestId, })) + .then((entry) => { + if (entry?.id) { + recordPendingSendMetadata(requestId, { + cwd: current.initialCwd, + checkpointId: entry.id, + }) + } + }) .catch(() => { /* surfaced lazily when a rewind finds no checkpoint */ }) } const isFirstMessage = !autoTitleSentRef.current @@ -1198,6 +1290,7 @@ export function FreshAgentView({ } sendFreshAgentMessage({ type: 'freshAgent.send', + requestId, sessionId: current.sessionId, sessionType: current.sessionType, provider: current.provider, @@ -1210,8 +1303,8 @@ export function FreshAgentView({ ...(getEffectiveFreshAgentEffort(current) ? { effort: getEffectiveFreshAgentEffort(current) } : {}), }, }) - setLocalEcho(text) - }, [dispatch, paneId, sendFreshAgentMessage, snapshotConfirmsNoUserTurns, tabId]) + setLocalEcho({ text, requestId }) + }, [dispatch, paneId, recordPendingSendMetadata, sendFreshAgentMessage, snapshotConfirmsNoUserTurns, tabId]) // Flush queued messages when the turn ends. One flush per status change is // enough: all queued entries are delivered in order for the next turn. @@ -1512,11 +1605,12 @@ export function FreshAgentView({ => item.kind === 'text') - .map((item) => item.text) - .join('\n\n') - return checkpointLabelForText(text || turn.summary || '') + return checkpointLabelForText(freshAgentTurnText(turn) || '') +} + +function turnRequestId(turn: FreshAgentTurn): string | null { + const requestId = (turn as FreshAgentTurn & { requestId?: unknown }).requestId + return typeof requestId === 'string' && requestId.length > 0 ? requestId : null +} + +function hasDirectCheckpoint(checkpoints: readonly CheckpointEntry[], turn: FreshAgentTurn): boolean { + const turnId = getFreshAgentDisplayTurnKey(turn) + if (checkpoints.some((entry) => entry.turnId === turnId)) return true + const requestId = turnRequestId(turn) + return requestId !== null && checkpoints.some((entry) => entry.requestId === requestId) +} + +function currentUserTurnLinks( + turns: readonly FreshAgentTurn[], +): { turnIds: Set; requestIds: Set } { + const turnIds = new Set() + const requestIds = new Set() + for (const turn of turns) { + if (turn.role !== 'user') continue + turnIds.add(getFreshAgentDisplayTurnKey(turn)) + const requestId = turnRequestId(turn) + if (requestId) requestIds.add(requestId) + } + return { turnIds, requestIds } +} + +function checkpointLinksCurrentTurn( + entry: CheckpointEntry, + links: { turnIds: ReadonlySet; requestIds: ReadonlySet }, +): boolean { + if (entry.turnId !== undefined && links.turnIds.has(entry.turnId)) return true + return entry.requestId !== undefined && links.requestIds.has(entry.requestId) } /** @@ -29,6 +60,16 @@ export function pickCheckpointForTurn( target: FreshAgentTurn, ): CheckpointEntry | null { if (target.role !== 'user') return null + const targetTurnId = getFreshAgentDisplayTurnKey(target) + const directTurnIdMatch = checkpoints.find((entry) => entry.turnId === targetTurnId) + if (directTurnIdMatch) return directTurnIdMatch + + const targetRequestId = turnRequestId(target) + if (targetRequestId) { + const requestIdMatch = checkpoints.find((entry) => entry.requestId === targetRequestId) + if (requestIdMatch) return requestIdMatch + } + const label = turnLabel(target) if (!label) return null @@ -37,13 +78,15 @@ export function pickCheckpointForTurn( if (turn.role !== 'user') continue if (turnLabel(turn) === label) { if (turn.id === target.id) break + if (hasDirectCheckpoint(checkpoints, turn)) continue ordinal += 1 } } // git log order is newest-first; we need oldest-first to index by ordinal. + const resolvedLinks = currentUserTurnLinks(turns) const matches = checkpoints - .filter((entry) => entry.label === label) + .filter((entry) => entry.label === label && !checkpointLinksCurrentTurn(entry, resolvedLinks)) .slice() .reverse() return matches[ordinal] ?? null diff --git a/test/integration/real/codex-app-server-readiness-contract.test.ts b/test/integration/real/codex-app-server-readiness-contract.test.ts index b2019e67c..ba4513b51 100644 --- a/test/integration/real/codex-app-server-readiness-contract.test.ts +++ b/test/integration/real/codex-app-server-readiness-contract.test.ts @@ -10,6 +10,7 @@ import WebSocket from 'ws' import { CodexAppServerClient, type CodexThreadLifecycleEvent } from '../../../server/coding-cli/codex-app-server/client.js' import { CodexAppServerRuntime } from '../../../server/coding-cli/codex-app-server/runtime.js' +import { createCodexFreshAgentAdapter } from '../../../server/fresh-agent/adapters/codex/adapter.js' type JsonRpcNotification = { method: string @@ -212,6 +213,44 @@ function isDurableReadinessEvidence(event: CodexThreadLifecycleEvent, threadId: const codexProbe = await codexAvailability() const describeCodex = codexProbe.ready ? describe : describe.skip +function readUserMessageTexts(item: Record): string[] { + const contentTexts = Array.isArray(item.content) + ? item.content.flatMap((part) => { + if (!part || typeof part !== 'object' || Array.isArray(part)) return [] + return typeof part.text === 'string' ? [part.text] : [] + }) + : [] + if (contentTexts.length > 0) return contentTexts + if (typeof item.text === 'string') return [item.text] + if (typeof item.summary === 'string') return [item.summary] + return [] +} + +function rawTurnIncludesUserMessageText(rawTurn: Record, needle: string): boolean { + const items = Array.isArray(rawTurn.items) + ? rawTurn.items.filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item)) + : [] + return items.some((item) => item.type === 'userMessage' && readUserMessageTexts(item).some((text) => text.includes(needle))) +} + +function findRawTurnByUserMessageText(rawTurns: unknown, needle: string): Record | undefined { + const turns = Array.isArray(rawTurns) + ? rawTurns.filter((turn): turn is Record => !!turn && typeof turn === 'object' && !Array.isArray(turn)) + : [] + return turns.find((turn) => rawTurnIncludesUserMessageText(turn, needle)) +} + +function displayTurnIncludesText(turn: { items?: Array> }, needle: string): boolean { + return (turn.items ?? []).some((item) => item.kind === 'text' && typeof item.text === 'string' && item.text.includes(needle)) +} + +function comparableDisplayTurn(turn: Record): Record { + const comparableKeys = ['id', 'turnId', 'messageId', 'source', 'role', 'timestamp', 'model', 'summary', 'items'] as const + return Object.fromEntries(comparableKeys + .filter((key) => key in turn) + .map((key) => [key, turn[key]])) +} + async function waitForSessionArtifact(codexHome: string, threadId: string, timeoutMs = 60_000): Promise { const sessionsRoot = path.join(codexHome, 'sessions') const deadline = Date.now() + timeoutMs @@ -229,6 +268,8 @@ async function waitForSessionArtifact(codexHome: string, threadId: string, timeo describeCodex(`real Codex app-server durable readiness contract${codexProbe.ready ? '' : ` (${codexProbe.reason})`}`, () => { it('emits current-generation lifecycle evidence when a durable thread is resumed', async () => { const { codexHome, root } = await seedIsolatedCodexHome() + const promptNonce = `freshell-readiness-contract-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const displayIdSecret = 'task-7-real-provider-contract-guard' const creationRuntime = new CodexAppServerRuntime({ env: { CODEX_HOME: codexHome }, startupAttemptLimit: 1, @@ -237,6 +278,9 @@ describeCodex(`real Codex app-server durable readiness contract${codexProbe.read }) let creationClient: JsonRpcClient | null = null let durableThreadId = '' + let providerUserTurnId = '' + let providerRevision = 0 + let displayUserTurnIdBeforeResume = '' try { const ready = await creationRuntime.ensureReady() @@ -253,7 +297,7 @@ describeCodex(`real Codex app-server durable readiness contract${codexProbe.read durableThreadId = parseThreadId(started) await creationClient.request('turn/start', { threadId: durableThreadId, - input: [{ type: 'text', text: 'Reply with exactly: freshell-readiness-contract' }], + input: [{ type: 'text', text: `Reply with exactly: ${promptNonce}` }], }) await creationClient.waitForNotification( (notification) => notification.method === 'turn/completed' @@ -268,6 +312,39 @@ describeCodex(`real Codex app-server durable readiness contract${codexProbe.read && notification.params?.threadId === durableThreadId, ) await waitForSessionArtifact(codexHome, durableThreadId) + + const creationClientApi = new CodexAppServerClient({ wsUrl: ready.wsUrl }, { requestTimeoutMs: 10_000 }) + const creationAdapter = createCodexFreshAgentAdapter({ + runtime: creationRuntime, + displayIdSecret, + }) + try { + await creationClientApi.initialize() + const creationSnapshot = await creationClientApi.readThread({ + threadId: durableThreadId, + includeTurns: true, + }) + providerRevision = Number(creationSnapshot.thread.updatedAt ?? 0) + const providerUserTurn = findRawTurnByUserMessageText(creationSnapshot.thread.turns, promptNonce) + expect(providerUserTurn).toBeDefined() + expect(providerUserTurn && typeof providerUserTurn.id === 'string' ? providerUserTurn.id : '').not.toBe('') + providerUserTurnId = String(providerUserTurn?.id ?? '') + + const creationPage = await creationAdapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: durableThreadId, + }, { + revision: providerRevision, + limit: 100, + }) + const creationDisplayTurn = creationPage?.turns.find((turn) => turn.role === 'user' && displayTurnIncludesText(turn, promptNonce)) + expect(creationDisplayTurn).toBeDefined() + displayUserTurnIdBeforeResume = creationDisplayTurn?.turnId ?? '' + } finally { + await creationClientApi.close().catch(() => undefined) + await creationAdapter.shutdown?.().catch(() => undefined) + } } finally { await creationClient?.close().catch(() => undefined) await creationRuntime.shutdown().catch(() => undefined) @@ -280,6 +357,10 @@ describeCodex(`real Codex app-server durable readiness contract${codexProbe.read requestTimeoutMs: 60_000, }) const clients: CodexAppServerClient[] = [] + const adapter = createCodexFreshAgentAdapter({ + runtime, + displayIdSecret, + }) try { const ready = await runtime.ensureReady() @@ -336,6 +417,40 @@ describeCodex(`real Codex app-server durable readiness contract${codexProbe.read id: olderTurnPage.turns[0]!.id, itemsView: 'full', }) + const resumedSnapshot = await actor.readThread({ + threadId: durableThreadId, + includeTurns: true, + }) + const resumedRevision = Number(resumedSnapshot.thread.updatedAt ?? providerRevision) + const providerUserTurn = findRawTurnByUserMessageText(resumedSnapshot.thread.turns, promptNonce) + expect(providerUserTurn).toBeDefined() + expect(providerUserTurn).toMatchObject({ + id: providerUserTurnId, + }) + expect(providerUserTurn && rawTurnIncludesUserMessageText(providerUserTurn, promptNonce)).toBe(true) + + const displayPage = await adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: durableThreadId, + }, { + revision: resumedRevision, + limit: 100, + }) + const displayUserTurn = displayPage?.turns.find((turn) => turn.role === 'user' && displayTurnIncludesText(turn, promptNonce)) + expect(displayUserTurn).toBeDefined() + expect(displayUserTurn?.turnId).toBe(displayUserTurnIdBeforeResume) + expect(displayUserTurn?.turnId).toMatch(/^codex-display:/) + expect(displayUserTurn?.turnId).not.toContain(providerUserTurnId) + + const displayBody = await adapter.getTurnBody?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: durableThreadId, + turnId: displayUserTurn!.turnId, + }, resumedRevision) + expect(displayBody).toBeDefined() + expect(comparableDisplayTurn(displayBody!)).toEqual(comparableDisplayTurn(displayUserTurn!)) const readiness = await waitForLifecycle( lifecycle, @@ -352,6 +467,7 @@ describeCodex(`real Codex app-server durable readiness contract${codexProbe.read } } finally { await Promise.all(clients.map((client) => client.close().catch(() => undefined))) + await adapter.shutdown?.().catch(() => undefined) await runtime.shutdown().catch(() => undefined) await fsp.rm(root, { force: true, recursive: true }).catch(() => undefined) } diff --git a/test/server/agent-api-fresh-agent.test.ts b/test/server/agent-api-fresh-agent.test.ts index 32d11df81..d42e1c6b5 100644 --- a/test/server/agent-api-fresh-agent.test.ts +++ b/test/server/agent-api-fresh-agent.test.ts @@ -112,12 +112,18 @@ describe('agent-api fresh-agent: create', () => { describe('agent-api fresh-agent: send-keys', () => { it('routes send-keys to the runtime manager for a fresh-agent pane and blocks until the turn returns', async () => { - const { app, freshAgentRuntimeManager } = makeApp() + const freshAgentRuntimeManager = { + create: vi.fn(async () => ({ sessionId: 'freshopencode-abc', sessionType: 'freshopencode', runtimeProvider: 'opencode', sessionRef: { provider: 'opencode', sessionId: 'freshopencode-abc' } })), + send: vi.fn(async () => ({ submittedTurnId: 'display-user-1' })), + attach: vi.fn(async () => ({ sessionId: 'ses_real_1' })), + getSnapshot: vi.fn(async () => ({ status: 'idle', turns: [] })), + } + const { app } = makeApp({ freshAgentRuntimeManager }) const created = await request(app).post('/api/tabs').send({ agent: 'opencode' }) const paneId = created.body.data.paneId const res = await request(app).post(`/api/panes/${paneId}/send-keys`).send({ data: 'Reply with: ok' }) expect(res.status).toBe(200) - expect(res.body.data).toMatchObject({ sessionId: 'freshopencode-abc' }) + expect(res.body.data).toMatchObject({ sessionId: 'freshopencode-abc', submittedTurnId: 'display-user-1' }) expect(freshAgentRuntimeManager.send).toHaveBeenCalledWith( { sessionId: 'freshopencode-abc', sessionType: 'freshopencode', provider: 'opencode' }, { text: 'Reply with: ok' }, diff --git a/test/server/fresh-agent-extras.test.ts b/test/server/fresh-agent-extras.test.ts index 6c692b39a..5e284bc34 100644 --- a/test/server/fresh-agent-extras.test.ts +++ b/test/server/fresh-agent-extras.test.ts @@ -150,15 +150,16 @@ describe('fresh-agent extras router', () => { describe('POST /send', () => { it('forwards to the injected runtime manager', async () => { - const send = vi.fn().mockResolvedValue(undefined) + const send = vi.fn().mockResolvedValue({ submittedTurnId: 'display-user-1' }) const app = express() app.use('/api/fresh-agent', createFreshAgentExtrasRouter({ freshAgentRuntimeManager: { send } })) - await request(app) + const res = await request(app) .post('/api/fresh-agent/send') .send({ sessionId: 's1', sessionType: 'freshclaude', provider: 'claude', text: 'ls the cwd' }) .expect(200) + expect(res.body).toMatchObject({ sent: true, submittedTurnId: 'display-user-1' }) expect(send).toHaveBeenCalledWith( { sessionId: 's1', sessionType: 'freshclaude', provider: 'claude' }, { text: 'ls the cwd' }, @@ -214,10 +215,22 @@ describe('fresh-agent extras router', () => { await fsp.writeFile(file, 'version one\n') const first = await request(app) .post('/api/fresh-agent/checkpoints') - .send({ cwd: dir, label: 'before refactor' }) + .send({ cwd: dir, label: 'before refactor', requestId: 'send-request-1' }) .expect(200) expect(first.body.id).toMatch(/^[0-9a-f]{40}$/) expect(first.body.label).toBe('before refactor') + expect(first.body.requestId).toBe('send-request-1') + + const withTurnId = await request(app) + .post('/api/fresh-agent/checkpoints/metadata') + .send({ cwd: dir, id: first.body.id, turnId: 'display-user-1' }) + .expect(200) + expect(withTurnId.body).toMatchObject({ + id: first.body.id, + label: 'before refactor', + requestId: 'send-request-1', + turnId: 'display-user-1', + }) await fsp.writeFile(file, 'version two\n') await request(app) @@ -233,6 +246,10 @@ describe('fresh-agent extras router', () => { // Newest first, git log order. expect(list.body.checkpoints[0].label).toBe('after refactor') expect(list.body.checkpoints[1].label).toBe('before refactor') + expect(list.body.checkpoints[1]).toMatchObject({ + requestId: 'send-request-1', + turnId: 'display-user-1', + }) await request(app) .post('/api/fresh-agent/checkpoints/restore') diff --git a/test/server/ws-protocol.test.ts b/test/server/ws-protocol.test.ts index c2e87a997..4bceb35ed 100644 --- a/test/server/ws-protocol.test.ts +++ b/test/server/ws-protocol.test.ts @@ -8,6 +8,7 @@ import { ClaudeActivityUpdatedSchema, ClaudeActivityListSchema, ErrorCode, + FreshAgentSendSchema, HelloSchema, TerminalAttachSchema, TerminalInputSchema, @@ -61,6 +62,17 @@ describe('websocket protocol schemas', () => { expect(parsed.expectedSessionRef).toEqual({ provider: 'codex', sessionId: 'thread-1' }) }) + it('accepts an optional freshAgent.send requestId', () => { + expect(FreshAgentSendSchema.parse({ + type: 'freshAgent.send', + requestId: 'send-request-1', + sessionId: 'thread-1', + sessionType: 'freshcodex', + provider: 'codex', + text: 'Continue', + }).requestId).toBe('send-request-1') + }) + it('accepts SESSION_IDENTITY_MISMATCH as an error code', () => { expect(ErrorCode.parse('SESSION_IDENTITY_MISMATCH')).toBe('SESSION_IDENTITY_MISMATCH') }) diff --git a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx index 473fbf2a0..20c0eefa9 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx @@ -434,7 +434,7 @@ describe('FreshAgentTranscript', () => { expect(strips[0]).toHaveTextContent('1 tool used') }) - it('coalesces consecutive activity-only assistant turns into one live activity strip', () => { + it('keeps consecutive activity-only assistant turns separate while marking only the latest live', () => { render( { />, ) - expect(screen.getAllByRole('region', { name: 'Activity strip' })).toHaveLength(1) + expect(screen.getAllByRole('region', { name: 'Activity strip' })).toHaveLength(3) expect(screen.getAllByLabelText('running')).toHaveLength(1) expect(screen.getByText('src/three.ts')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Toggle activity details' })) + fireEvent.click(screen.getAllByRole('button', { name: 'Toggle activity details' })[2]) expect(screen.getAllByLabelText('running')).toHaveLength(1) - expect(screen.getAllByLabelText('complete')).toHaveLength(2) + fireEvent.click(screen.getAllByRole('button', { name: 'Toggle activity details' })[0]) + expect(screen.getAllByLabelText('complete').length).toBeGreaterThanOrEqual(1) }) it('shows the speaker label once for consecutive turns from the same role', () => { @@ -690,6 +691,39 @@ describe('FreshAgentTranscript', () => { .toHaveTextContent('1 tool used · 1 file changed') }) + it('keeps adjacent activity-only display turns distinct and actionable', () => { + const onFork = vi.fn() + render( + , + ) + + expect(screen.getAllByRole('article', { name: 'Assistant transcript turn' })).toHaveLength(2) + expect(screen.getAllByRole('region', { name: 'Activity strip' })).toHaveLength(2) + + const forkButtons = screen.getAllByRole('button', { name: 'Fork conversation from here' }) + fireEvent.click(forkButtons[1]) + expect(onFork).toHaveBeenCalledWith('display-activity-2') + }) + it('strips system reminders without collapsing older turns', () => { render( ({ + copyText: vi.fn().mockResolvedValue(true), +})) + +afterEach(() => cleanup()) + +function codexDisplayTurn(): FreshAgentTurn { + return { + id: 'codex-native-turn-1', + turnId: 'codex-display:v1:opaque-user-row', + role: 'assistant', + summary: 'answer', + items: [{ id: 'text-1', kind: 'text', text: 'done' }], + } +} + +describe('FreshAgentTurnActions', () => { + it('passes the opaque display turn id to action callbacks', () => { + const onForkFromTurn = vi.fn() + const items = buildTurnActionItems(codexDisplayTurn(), { + canFork: true, + onForkFromTurn, + }) + + items.find((item) => item.label === 'Fork conversation from here')?.run() + + expect(onForkFromTurn).toHaveBeenCalledWith('codex-display:v1:opaque-user-row') + }) + + it('uses the display turn id from the hover toolbar', () => { + const onForkFromTurn = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Fork conversation from here' })) + + expect(onForkFromTurn).toHaveBeenCalledWith('codex-display:v1:opaque-user-row') + }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 69ce5d677..761734179 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -1200,8 +1200,9 @@ describe('FreshAgentView', () => { }) fireEvent.click(screen.getByRole('button', { name: 'Send' })) - expect(wsMock.send).toHaveBeenCalledWith({ + expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.send', + requestId: expect.any(String), sessionId: 'thread-1', sessionType: 'freshcodex', provider: 'codex', @@ -1211,12 +1212,134 @@ describe('FreshAgentView', () => { model: 'gpt-5.3-codex-spark', effort: 'max', }, - }) + })) expect(screen.queryByRole('button', { name: 'Interrupt' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Fork' })).not.toBeInTheDocument() }) + it('uses send acknowledgements to patch checkpoints and clear local echo only on the submitted user display turn', async () => { + const store = createStore() + const checkpoint = createDeferred<{ id: string; ts: number; label: string; requestId: string }>() + let onMessage: ((message: Record) => void) | undefined + wsMock.onMessage.mockImplementation((handler: (message: Record) => void) => { + onMessage = handler + return () => {} + }) + apiMock.getFreshAgentThreadSnapshot + .mockResolvedValueOnce({ + status: 'idle', + summary: 'empty', + capabilities: { send: true, interrupt: true, fork: true }, + turns: [], + }) + .mockResolvedValueOnce({ + status: 'idle', + summary: 'answered', + capabilities: { send: true, interrupt: true, fork: true }, + turns: [ + { + id: 'native-user-turn', + turnId: 'display-user-1', + role: 'user', + summary: 'Ship it', + items: [{ id: 'user-text-1', kind: 'text', text: 'Ship it' }], + }, + { + id: 'native-assistant-turn', + turnId: 'display-assistant-1', + role: 'assistant', + summary: 'Done', + items: [{ id: 'assistant-text-1', kind: 'text', text: 'Done.' }], + }, + ], + }) + apiMock.post.mockImplementation((url: string, body: Record) => { + if (url === '/api/fresh-agent/checkpoints') return checkpoint.promise + if (url === '/api/fresh-agent/checkpoints/metadata') return Promise.resolve({ ok: true, body }) + return Promise.resolve({ title: null, source: 'none' }) + }) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'req-normalized-send', + sessionId: 'thread-normalized-send', + status: 'idle', + initialCwd: '/repo', + }, + })) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Chat message input' })).not.toBeDisabled() + }) + wsMock.send.mockClear() + + fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { + target: { value: 'Ship it' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + + const send = sentFreshAgentMessages('freshAgent.send').at(-1) + expect(send).toMatchObject({ + type: 'freshAgent.send', + sessionId: 'thread-normalized-send', + sessionType: 'freshcodex', + provider: 'codex', + text: 'Ship it', + }) + expect(send?.requestId).toEqual(expect.any(String)) + const requestId = String(send?.requestId) + expect(apiMock.post).toHaveBeenCalledWith('/api/fresh-agent/checkpoints', { + cwd: '/repo', + label: 'Ship it', + requestId, + }) + expect(screen.getByText('Ship it')).toBeInTheDocument() + + expect(onMessage).toBeTypeOf('function') + act(() => { + onMessage?.({ + type: 'freshAgent.send.accepted', + requestId, + submittedTurnId: 'display-user-1', + }) + }) + await act(async () => { + checkpoint.resolve({ + id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ts: 1, + label: 'Ship it', + requestId, + }) + await Promise.resolve() + }) + + await waitFor(() => { + expect(apiMock.post).toHaveBeenCalledWith('/api/fresh-agent/checkpoints/metadata', { + cwd: '/repo', + id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + requestId, + turnId: 'display-user-1', + }) + }) + await waitFor(() => { + expect(screen.getByText('Done.')).toBeInTheDocument() + }) + expect(screen.getAllByText('Ship it')).toHaveLength(1) + const transcriptTurns = screen.getAllByRole('article') + expect(transcriptTurns.at(-1)).toHaveTextContent('Done.') + }) + it('does not transmit stale Freshopencode permissionMode on create or send', async () => { const creatingStore = createStore() creatingStore.dispatch(initLayout({ @@ -1284,8 +1407,9 @@ describe('FreshAgentView', () => { }) fireEvent.click(screen.getByRole('button', { name: 'Send' })) - expect(wsMock.send).toHaveBeenCalledWith({ + expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.send', + requestId: expect.any(String), sessionId: 'freshopencode-req-opencode-send-policy', sessionType: 'freshopencode', provider: 'opencode', @@ -1295,7 +1419,7 @@ describe('FreshAgentView', () => { model: 'opencode-go/deepseek-v4-flash', effort: 'max', }, - }) + })) }) it('auto-titles the fresh-agent pane and tab from the first user message', async () => { @@ -2100,12 +2224,19 @@ describe('FreshAgentView', () => { target: { value: 'New user request' }, }) fireEvent.click(screen.getByRole('button', { name: 'Send' })) + const send = sentFreshAgentMessages('freshAgent.send').at(-1) + const requestId = String(send?.requestId) expect(screen.getByText('Older user request')).toBeInTheDocument() expect(screen.getByText('Older assistant answer')).toBeInTheDocument() expect(onMessage).toBeTypeOf('function') act(() => { + onMessage?.({ + type: 'freshAgent.send.accepted', + requestId, + submittedTurnId: 'turn-new-user', + }) onMessage?.({ type: 'freshAgent.event', sessionId: 'thread-partial-refresh', diff --git a/test/unit/client/lib/fresh-agent-checkpoints.test.ts b/test/unit/client/lib/fresh-agent-checkpoints.test.ts index 07adf1f43..7af9e930c 100644 --- a/test/unit/client/lib/fresh-agent-checkpoints.test.ts +++ b/test/unit/client/lib/fresh-agent-checkpoints.test.ts @@ -6,10 +6,10 @@ import { } from '@/lib/fresh-agent-checkpoints' import type { FreshAgentTurn } from '@shared/fresh-agent-contract' -function userTurn(id: string, text: string): FreshAgentTurn { +function userTurn(id: string, text: string, turnId = id): FreshAgentTurn { return { id, - turnId: id, + turnId, role: 'user', summary: text, items: [{ id: `${id}-item`, kind: 'text', text }], @@ -42,6 +42,82 @@ describe('checkpointLabelForText', () => { }) describe('pickCheckpointForTurn', () => { + it('matches persisted display turn id only for normalized user turns', () => { + const user = userTurn('native-turn-1-user', 'same native', 'display-user-1') + const assistant = { + ...assistantTurn('native-turn-1-assistant'), + turnId: 'display-assistant-1', + id: 'native-turn-1-assistant', + summary: 'same native', + items: [{ id: 'assistant-item', kind: 'text' as const, text: 'same native' }], + } as FreshAgentTurn + const checkpoints: CheckpointEntry[] = [ + { id: 'sha-user', ts: 1, label: 'same native', turnId: 'display-user-1' }, + ] + + expect(pickCheckpointForTurn(checkpoints, [user, assistant], user)?.id).toBe('sha-user') + expect(pickCheckpointForTurn(checkpoints, [user, assistant], assistant)).toBeNull() + }) + + it('prefers display turn id and request id before duplicate label ordinal matching', () => { + const older = userTurn('old-native', 'fix the bug', 'display-old') + const newer = userTurn('new-native', 'fix the bug', 'display-new') as FreshAgentTurn & { requestId: string } + newer.requestId = 'send-new' + const turns = [newer, assistantTurn('reply'), older] + const checkpoints: CheckpointEntry[] = [ + { id: 'sha-old', ts: 100, label: 'fix the bug', turnId: 'display-old', requestId: 'send-old' }, + { id: 'sha-new', ts: 200, label: 'fix the bug', requestId: 'send-new' }, + ] + + expect(pickCheckpointForTurn(checkpoints, turns, newer)?.id).toBe('sha-new') + expect(pickCheckpointForTurn(checkpoints, turns, older)?.id).toBe('sha-old') + }) + + it('does not let a checkpoint for an older duplicate prompt satisfy a newer display turn by label', () => { + const newer = userTurn('new-native', 'fix the bug', 'display-new') + const older = userTurn('old-native', 'fix the bug', 'display-old') + const checkpoints: CheckpointEntry[] = [ + { id: 'sha-old', ts: 100, label: 'fix the bug', turnId: 'display-old', requestId: 'send-old' }, + ] + + expect(pickCheckpointForTurn(checkpoints, [newer, older], newer)).toBeNull() + }) + + it('does not count direct-id checkpoints when indexing legacy label-only matches', () => { + const older = userTurn('old-native', 'fix the bug', 'display-old') + const newer = userTurn('new-native', 'fix the bug', 'display-new') + const checkpoints: CheckpointEntry[] = [ + { id: 'sha-new', ts: 200, label: 'fix the bug' }, + { id: 'sha-old', ts: 100, label: 'fix the bug', turnId: 'display-old' }, + ] + + expect(pickCheckpointForTurn(checkpoints, [older, newer], newer)?.id).toBe('sha-new') + }) + + it('uses label ordinal matching when saved submitted display ids no longer resolve after restart', () => { + const older = userTurn('old-native', 'fix the bug', 'display-old-after-restart') + const newer = userTurn('new-native', 'fix the bug', 'display-new-after-restart') + const checkpoints: CheckpointEntry[] = [ + { + id: 'sha-new', + ts: 200, + label: 'fix the bug', + turnId: 'submitted-new-before-restart', + requestId: 'send-new', + }, + { + id: 'sha-old', + ts: 100, + label: 'fix the bug', + turnId: 'submitted-old-before-restart', + requestId: 'send-old', + }, + ] + + expect(pickCheckpointForTurn(checkpoints, [older, newer], older)?.id).toBe('sha-old') + expect(pickCheckpointForTurn(checkpoints, [older, newer], newer)?.id).toBe('sha-new') + }) + it('matches a user turn to its checkpoint by label', () => { const turns = [userTurn('t1', 'add a test'), assistantTurn('t2')] expect(pickCheckpointForTurn(CHECKPOINTS, turns, turns[0])?.id).toBe('sha-2') diff --git a/test/unit/server/config-store.test.ts b/test/unit/server/config-store.test.ts index e991bf9f5..fd4081678 100644 --- a/test/unit/server/config-store.test.ts +++ b/test/unit/server/config-store.test.ts @@ -90,6 +90,21 @@ describe('ConfigStore', () => { expect(exists).toBe(true) }) + it('generates and persists the Codex display-id secret without exposing it in settings', async () => { + const store = new ConfigStore() + + const secret = await store.getCodexDisplayIdSecret() + const settings = await store.getSettings() + const saved = JSON.parse(await fsp.readFile(configPath, 'utf-8')) + const reloaded = new ConfigStore() + + expect(secret).toEqual(expect.any(String)) + expect(secret.length).toBeGreaterThanOrEqual(32) + expect(saved.serverSecrets.codexDisplayIdSecret).toBe(secret) + expect(JSON.stringify(settings)).not.toContain(secret) + await expect(reloaded.getCodexDisplayIdSecret()).resolves.toBe(secret) + }) + it('creates .freshell directory if needed', async () => { const store = new ConfigStore() await store.load() diff --git a/test/unit/server/fresh-agent/claude-normalize.test.ts b/test/unit/server/fresh-agent/claude-normalize.test.ts index ca7333e52..30255f2ed 100644 --- a/test/unit/server/fresh-agent/claude-normalize.test.ts +++ b/test/unit/server/fresh-agent/claude-normalize.test.ts @@ -43,4 +43,54 @@ describe('Claude fresh-agent normalization', () => { costUsd: 1.25, }) }) + + it('keeps user and assistant messages as separate display turns with their native turnIds', () => { + const snapshot = normalizeClaudeThreadSnapshot({ + threadId: 'sdk-claude-invariant', + resolved: { + kind: 'resolved', + queryId: 'sdk-claude-invariant', + liveSessionId: 'sdk-claude-invariant', + timelineSessionId: '00000000-0000-4000-8000-000000000222', + readiness: 'merged', + revision: 2, + latestTurnId: 'turn:assistant-1', + turns: [ + { + sessionId: '00000000-0000-4000-8000-000000000222', + turnId: 'turn:user-1', + messageId: 'user-1', + ordinal: 0, + source: 'durable', + message: { + role: 'user', + content: [{ type: 'text', text: 'Inspect the config.' }], + timestamp: '2026-04-18T12:00:00.000Z', + messageId: 'user-1', + }, + }, + { + sessionId: '00000000-0000-4000-8000-000000000222', + turnId: 'turn:assistant-1', + messageId: 'assistant-1', + ordinal: 1, + source: 'durable', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Inspecting the config now.' }], + timestamp: '2026-04-18T12:00:01.000Z', + messageId: 'assistant-1', + }, + }, + ], + }, + status: 'running', + }) + + expect(snapshot.turns).toHaveLength(2) + expect(snapshot.turns).toMatchObject([ + { turnId: 'turn:user-1', messageId: 'user-1', role: 'user', summary: 'Inspect the config.' }, + { turnId: 'turn:assistant-1', messageId: 'assistant-1', role: 'assistant', summary: 'Inspecting the config now.' }, + ]) + }) }) diff --git a/test/unit/server/fresh-agent/codex-adapter.test.ts b/test/unit/server/fresh-agent/codex-adapter.test.ts index 7525c2372..f2307ed8d 100644 --- a/test/unit/server/fresh-agent/codex-adapter.test.ts +++ b/test/unit/server/fresh-agent/codex-adapter.test.ts @@ -1,6 +1,22 @@ import { describe, expect, it, vi } from 'vitest' -import { createCodexFreshAgentAdapter } from '../../../../server/fresh-agent/adapters/codex/adapter.js' +import { createCodexFreshAgentAdapter as createRawCodexFreshAgentAdapter } from '../../../../server/fresh-agent/adapters/codex/adapter.js' +import { createCodexDisplayId } from '../../../../server/fresh-agent/adapters/codex/normalize.js' +import { + FreshAgentInvalidDisplayIdError, + FreshAgentInvalidTurnCursorError, + FreshAgentStaleThreadRevisionError, + FreshAgentUnprovableThreadRevisionError, + FreshAgentTurnNotFoundError, +} from '../../../../server/fresh-agent/runtime-manager.js' + +const DISPLAY_SECRET = 'task-3-persisted-display-secret' + +function createCodexFreshAgentAdapter( + deps: Omit[0], 'displayIdSecret'> & { displayIdSecret?: string }, +) { + return createRawCodexFreshAgentAdapter({ displayIdSecret: DISPLAY_SECRET, ...deps }) +} function makeCodexThread(id: string) { return { @@ -33,7 +49,298 @@ function makeCodexTurn(id: string) { } } +function makeMixedCodexTurn(id: string) { + return { + id, + status: 'completed', + items: [ + { + type: 'userMessage', + id: `${id}:user`, + content: [{ type: 'text', text: 'Review the diff.' }], + }, + { + type: 'reasoning', + id: `${id}:reasoning`, + summary: ['Checking changes'], + content: [], + }, + { + type: 'agentMessage', + id: `${id}:assistant`, + text: 'The patch is safe.', + }, + ], + } +} + describe('Codex fresh-agent adapter', () => { + it('paginates Codex history by display rows within a split provider turn', async () => { + const firstTurn = makeMixedCodexTurn('turn-1') + const secondTurn = makeCodexTurn('turn-2') + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn(), + listThreadTurns: vi.fn() + .mockResolvedValueOnce({ revision: 7, nextCursor: 'provider-after-turn-1', turns: [firstTurn] }) + .mockResolvedValueOnce({ revision: 7, nextCursor: null, turns: [secondTurn] }), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + const firstPage: any = await adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, { revision: 7, limit: 1 }) + + expect(firstPage.turns).toHaveLength(1) + expect(firstPage.turns[0]).toMatchObject({ role: 'user', summary: 'Review the diff.' }) + expect(firstPage.nextCursor).toMatch(/^codex-cursor:v1:[A-Za-z0-9_-]+$/) + expect(firstPage.nextCursor).not.toContain('provider-after-turn-1') + expect(firstPage.nextCursor).not.toContain('turn-1') + expect(() => JSON.parse(Buffer.from(firstPage.nextCursor.split(':').at(-1) ?? '', 'base64url').toString('utf8'))).toThrow() + + const secondPage: any = await adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, { revision: 7, limit: 1, cursor: firstPage.nextCursor }) + + expect(secondPage.turns).toHaveLength(1) + expect(secondPage.turns[0]).toMatchObject({ role: 'assistant', summary: 'Checking changes' }) + expect(secondPage.turns[0].turnId).not.toBe(firstPage.turns[0].turnId) + expect(runtime.listThreadTurns).toHaveBeenCalledTimes(1) + + const thirdPage: any = await adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, { revision: 7, limit: 1, cursor: secondPage.nextCursor }) + + expect(thirdPage.turns).toHaveLength(1) + expect(thirdPage.turns[0]).toMatchObject({ role: 'assistant', summary: 'Codex summary' }) + expect(thirdPage.nextCursor).toBeNull() + expect(runtime.listThreadTurns).toHaveBeenCalledTimes(2) + expect(runtime.listThreadTurns).toHaveBeenNthCalledWith(1, { + threadId: 'thread-new-1', + limit: 1, + itemsView: 'full', + }) + expect(runtime.listThreadTurns).toHaveBeenNthCalledWith(2, { + threadId: 'thread-new-1', + cursor: 'provider-after-turn-1', + limit: 1, + itemsView: 'full', + }) + }) + + it('does not refetch from the beginning after draining a final cached provider turn with a larger page limit', async () => { + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn(), + listThreadTurns: vi.fn().mockResolvedValue({ + revision: 7, + nextCursor: null, + turns: [makeMixedCodexTurn('turn-1')], + }), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + const firstPage: any = await adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, { revision: 7, limit: 1 }) + expect(firstPage.turns).toHaveLength(1) + expect(firstPage.turns[0]).toMatchObject({ role: 'user' }) + + const secondPage: any = await adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, { revision: 7, limit: 30, cursor: firstPage.nextCursor }) + + expect(secondPage.turns).toHaveLength(1) + expect(secondPage.turns[0]).toMatchObject({ role: 'assistant' }) + expect(secondPage.turns.map((turn: any) => turn.turnId)).not.toContain(firstPage.turns[0].turnId) + expect(secondPage.nextCursor).toBeNull() + expect(runtime.listThreadTurns).toHaveBeenCalledTimes(1) + }) + + it('applies includeBodies to display-row limited pages with display turn body keys', async () => { + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn(), + listThreadTurns: vi.fn().mockResolvedValue({ + revision: 7, + nextCursor: 'provider-after-turn-1', + turns: [makeMixedCodexTurn('turn-1')], + }), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + const page: any = await adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, { revision: 7, limit: 1, includeBodies: true }) + + expect(page.turns).toHaveLength(1) + expect(Object.keys(page.bodies)).toEqual([page.turns[0].turnId]) + expect(page.bodies[page.turns[0].turnId]).toMatchObject({ role: 'user' }) + expect(page.bodies).not.toHaveProperty('turn-1') + }) + + it('rejects malformed, cross-thread, expired, and stale-revision display cursors with typed errors', async () => { + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn(), + listThreadTurns: vi.fn().mockResolvedValue({ + revision: 7, + nextCursor: 'provider-after-turn-1', + turns: [makeMixedCodexTurn('turn-1')], + }), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + const firstPage: any = await adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, { revision: 7, limit: 1 }) + + await expect(adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, { revision: 7, limit: 1, cursor: 'not-a-codex-cursor' })).rejects.toBeInstanceOf(FreshAgentInvalidTurnCursorError) + + await expect(adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'other-thread', + }, { revision: 7, limit: 1, cursor: firstPage.nextCursor })).rejects.toBeInstanceOf(FreshAgentInvalidTurnCursorError) + + await expect(adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, { revision: 8, limit: 1, cursor: firstPage.nextCursor })).rejects.toBeInstanceOf(FreshAgentStaleThreadRevisionError) + + await adapter.shutdown?.() + await expect(adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, { revision: 7, limit: 1, cursor: firstPage.nextCursor })).rejects.toBeInstanceOf(FreshAgentInvalidTurnCursorError) + }) + + it('throws a typed invalid display id error for malformed Codex display body ids', async () => { + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn(), + listThreadTurns: vi.fn(), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + await expect(adapter.getTurnBody?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + turnId: 'codex-display:v1:not-a-valid-envelope', + }, 7)).rejects.toBeInstanceOf(FreshAgentInvalidDisplayIdError) + expect(runtime.readThreadTurn).not.toHaveBeenCalled() + }) + + it('returns unprovable revision when an indexed display body no longer matches the provider turn body', async () => { + const durableTurn = makeMixedCodexTurn('turn-1') + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn(), + listThreadTurns: vi.fn().mockResolvedValue({ + revision: 7, + nextCursor: null, + turns: [durableTurn], + }), + readThreadTurn: vi.fn().mockResolvedValue(makeCodexTurn('turn-1')), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + const page: any = await adapter.getTurnPage?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, { revision: 7, limit: 1 }) + + await expect(adapter.getTurnBody?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + turnId: page.turns[0].turnId, + }, 7)).rejects.toBeInstanceOf(FreshAgentUnprovableThreadRevisionError) + }) + + it('rescans display indexes through provider pagination before returning an exact miss', async () => { + const targetTurn = makeMixedCodexTurn('turn-target') + const targetDisplayTurnId = createCodexDisplayId({ + secret: DISPLAY_SECRET, + threadId: 'thread-new-1', + providerTurnId: 'turn-target', + role: 'user', + itemIds: ['turn-target:user'], + partIndexes: [0], + }) + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn(), + listThreadTurns: vi.fn() + .mockResolvedValueOnce({ + revision: 7, + nextCursor: 'provider-page-2', + turns: [makeCodexTurn('turn-before-target')], + }) + .mockResolvedValueOnce({ + revision: 7, + nextCursor: null, + turns: [targetTurn], + }), + readThreadTurn: vi.fn().mockResolvedValue(targetTurn), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + await expect(adapter.getTurnBody?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + turnId: targetDisplayTurnId, + }, 7)).resolves.toMatchObject({ + turnId: targetDisplayTurnId, + role: 'user', + summary: 'Review the diff.', + }) + expect(runtime.listThreadTurns).toHaveBeenNthCalledWith(1, { + threadId: 'thread-new-1', + limit: 100, + itemsView: 'full', + }) + expect(runtime.listThreadTurns).toHaveBeenNthCalledWith(2, { + threadId: 'thread-new-1', + cursor: 'provider-page-2', + limit: 100, + itemsView: 'full', + }) + }) + it('allocates separate runtimes for fresh Codex threads in different cwd values', async () => { const runtimes = ['/repo/one', '/repo/two'].map((cwd, index) => ({ startThread: vi.fn().mockImplementation(async (input) => { @@ -148,8 +455,7 @@ describe('Codex fresh-agent adapter', () => { }) it('reads snapshots and turns from the official Codex thread APIs', async () => { - const durableTurn = makeCodexTurn('turn-1') - const expectedFullItem = expect.objectContaining({ kind: 'text', text: 'Codex summary' }) + const durableTurn = makeMixedCodexTurn('turn-1') const runtime = { startThread: vi.fn(), resumeThread: vi.fn(), @@ -168,25 +474,292 @@ describe('Codex fresh-agent adapter', () => { } const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) - await expect(adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' }, 7)).resolves.toMatchObject({ + const snapshot: any = await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' }, 7) + expect(snapshot).toMatchObject({ provider: 'codex', threadId: 'thread-new-1', revision: 7, - turns: [{ id: 'turn-1', turnId: 'turn-1' }], }) + expect(snapshot.turns).toHaveLength(2) + expect(snapshot.turns[0]).toMatchObject({ role: 'user', ordinal: 0 }) + expect(snapshot.turns[1]).toMatchObject({ role: 'assistant', ordinal: 1 }) + expect(snapshot.turns[0].turnId).toMatch(/^codex-display:v1:[A-Za-z0-9_-]{22}$/) + expect(snapshot.turns[1].turnId).toMatch(/^codex-display:v1:[A-Za-z0-9_-]{22}$/) + expect(snapshot.turns[0].turnId).not.toContain('turn-1') + expect(snapshot.turns[0]).not.toHaveProperty('providerTurnId') expect(runtime.readThread).toHaveBeenCalledWith({ threadId: 'thread-new-1', includeTurns: true }) - await expect(adapter.getTurnPage?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' }, { revision: 7 })).resolves.toMatchObject({ + const page: any = await adapter.getTurnPage?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' }, { revision: 7 }) + expect(page).toMatchObject({ + revision: 7, + turns: [ + expect.objectContaining({ role: 'user' }), + expect.objectContaining({ role: 'assistant' }), + ], + }) + expect(page.turns[1].items).toEqual([ + expect.objectContaining({ kind: 'reasoning' }), + expect.objectContaining({ kind: 'text', text: 'The patch is safe.' }), + ]) + expect(page.bodies[page.turns[1].turnId]).toMatchObject({ role: 'assistant' }) + await expect(adapter.getTurnBody?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1', turnId: page.turns[1].turnId }, 7)).resolves.toMatchObject({ + turnId: page.turns[1].turnId, revision: 7, - turns: [{ id: 'turn-1', turnId: 'turn-1', items: [expectedFullItem] }], - bodies: { 'turn-1': expect.objectContaining({ items: [expectedFullItem] }) }, + items: [ + expect.objectContaining({ kind: 'reasoning' }), + expect.objectContaining({ kind: 'text', text: 'The patch is safe.' }), + ], }) - await expect(adapter.getTurnBody?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1', turnId: 'turn-1' }, 7)).resolves.toMatchObject({ + expect(runtime.readThreadTurn).toHaveBeenCalledWith({ + threadId: 'thread-new-1', turnId: 'turn-1', revision: 7, - items: [expectedFullItem], }) }) + it('keeps display ids short and opaque for long native ids and item ids', async () => { + const longProviderId = `turn-${'native-id-'.repeat(40)}` + const longItemId = `item-${'item-id-'.repeat(40)}` + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn().mockResolvedValue({ + thread: { + ...makeCodexThread('thread-long-ids'), + updatedAt: 11, + turns: [{ + id: longProviderId, + status: 'completed', + items: [{ type: 'agentMessage', id: longItemId, text: 'Short public id' }], + }], + }, + }), + listThreadTurns: vi.fn(), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + const snapshot: any = await adapter.getSnapshot?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-long-ids', + }, 11) + + expect(snapshot.turns[0].turnId).toMatch(/^codex-display:v1:[A-Za-z0-9_-]{22}$/) + expect(snapshot.turns[0].turnId.length).toBeLessThan(45) + expect(snapshot.turns[0].turnId).not.toContain(longProviderId.slice(0, 20)) + expect(snapshot.turns[0].turnId).not.toContain(longItemId.slice(0, 20)) + }) + + it('does not pass unknown or malformed display ids to Codex body reads', async () => { + const durableTurn = makeMixedCodexTurn('turn-1') + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn().mockResolvedValue({ + thread: { ...makeCodexThread('thread-new-1'), turns: [durableTurn] }, + }), + listThreadTurns: vi.fn().mockResolvedValue({ + revision: 7, + nextCursor: null, + turns: [durableTurn], + }), + readThreadTurn: vi.fn().mockResolvedValue(durableTurn), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + await expect(adapter.getTurnBody?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + turnId: 'codex-display:v1:not-a-valid-envelope', + }, 7)).rejects.toBeInstanceOf(FreshAgentInvalidDisplayIdError) + expect(runtime.readThreadTurn).not.toHaveBeenCalled() + + await expect(adapter.getTurnBody?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + turnId: 'codex-display:v1:abcdefghijklmnopqrstu1', + }, 7)).rejects.toBeInstanceOf(FreshAgentTurnNotFoundError) + expect(runtime.readThreadTurn).not.toHaveBeenCalled() + }) + + it('returns stale revision when a display body read has no matching cached revision', async () => { + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn(), + listThreadTurns: vi.fn().mockResolvedValue({ + revision: 9, + nextCursor: null, + turns: [makeMixedCodexTurn('turn-current')], + }), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + await expect(adapter.getTurnBody?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + turnId: 'codex-display:v1:abcdefghijklmnopqrstu1', + }, 7)).rejects.toBeInstanceOf(FreshAgentStaleThreadRevisionError) + expect(runtime.readThreadTurn).not.toHaveBeenCalled() + }) + + it('accepts native body ids only when they normalize to one display row', async () => { + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn(), + listThreadTurns: vi.fn(), + readThreadTurn: vi.fn() + .mockResolvedValueOnce(makeCodexTurn('turn-single')) + .mockResolvedValueOnce(makeMixedCodexTurn('turn-mixed')), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + const single: any = await adapter.getTurnBody?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + turnId: 'turn-single', + }, 7) + expect(single.turnId).toMatch(/^codex-display:v1:/) + expect(single.items).toEqual([expect.objectContaining({ text: 'Codex summary' })]) + + await expect(adapter.getTurnBody?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + turnId: 'turn-mixed', + }, 7)).rejects.toThrow(/display turns/) + }) + + it('materializes submitted input rows until Codex returns the provider user message', async () => { + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + startTurn: vi.fn().mockResolvedValue({ turnId: 'turn-submitted-1' }), + readThread: vi.fn() + .mockResolvedValueOnce({ + thread: { + ...makeCodexThread('thread-new-1'), + updatedAt: 8, + turns: [{ + id: 'turn-submitted-1', + status: 'inProgress', + items: [{ type: 'agentMessage', id: 'assistant-1', text: 'Working on it.' }], + }], + }, + }) + .mockResolvedValueOnce({ + thread: { + ...makeCodexThread('thread-new-1'), + updatedAt: 9, + turns: [{ + id: 'turn-submitted-1', + status: 'completed', + items: [ + { type: 'userMessage', id: 'real-user-1', content: [{ type: 'text', text: 'Review this image' }] }, + { type: 'agentMessage', id: 'assistant-1', text: 'Done.' }, + ], + }], + }, + }), + listThreadTurns: vi.fn(), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + const sendResult: any = await adapter.send?.('thread-new-1', { + requestId: 'send-1', + text: 'Review this image', + images: [ + { kind: 'local', path: '/tmp/screenshot.png', mediaType: 'image/png' }, + { kind: 'data', mediaType: 'image/png', data: 'abc123' }, + ], + }) + + expect(sendResult).toMatchObject({ + requestId: 'send-1', + submittedTurnId: expect.stringMatching(/^codex-display:v1:/), + }) + expect(runtime.startTurn).toHaveBeenCalledWith(expect.objectContaining({ + input: [ + { type: 'text', text: 'Review this image', text_elements: [] }, + { type: 'localImage', path: '/tmp/screenshot.png' }, + { type: 'image', url: 'data:image/png;base64,abc123' }, + ], + })) + + const pendingSnapshot: any = await adapter.getSnapshot?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, 8) + expect(pendingSnapshot.turns[0]).toMatchObject({ + turnId: sendResult.submittedTurnId, + role: 'user', + source: 'durable', + }) + expect(pendingSnapshot.turns[0].summary).toBe('Review this image') + + const materializedSnapshot: any = await adapter.getSnapshot?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, 9) + expect(materializedSnapshot.turns[0]).toMatchObject({ + turnId: sendResult.submittedTurnId, + role: 'user', + }) + expect(materializedSnapshot.turns.filter((turn: any) => turn.role === 'user')).toHaveLength(1) + }) + + it('keeps same-text queued submitted rows distinct by request id', async () => { + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + startTurn: vi.fn() + .mockResolvedValueOnce({ turnId: 'turn-submitted-1' }) + .mockResolvedValueOnce({ turnId: 'turn-submitted-2' }), + readThread: vi.fn().mockResolvedValue({ + thread: { + ...makeCodexThread('thread-new-1'), + updatedAt: 8, + turns: [ + { + id: 'turn-submitted-1', + status: 'inProgress', + items: [{ type: 'agentMessage', id: 'assistant-1', text: 'Working.' }], + }, + { + id: 'turn-submitted-2', + status: 'inProgress', + items: [{ type: 'agentMessage', id: 'assistant-2', text: 'Still working.' }], + }, + ], + }, + }), + listThreadTurns: vi.fn(), + readThreadTurn: vi.fn(), + } + const adapter = createCodexFreshAgentAdapter({ runtime: runtime as any }) + + const first: any = await adapter.send?.('thread-new-1', { requestId: 'send-1', text: 'Same prompt' }) + const second: any = await adapter.send?.('thread-new-1', { requestId: 'send-2', text: 'Same prompt' }) + const snapshot: any = await adapter.getSnapshot?.({ + sessionType: 'freshcodex', + provider: 'codex', + threadId: 'thread-new-1', + }, 8) + + const userRows = snapshot.turns.filter((turn: any) => turn.role === 'user') + expect(first.submittedTurnId).not.toBe(second.submittedTurnId) + expect(userRows.map((turn: any) => turn.turnId)).toEqual([first.submittedTurnId, second.submittedTurnId]) + }) + it('reads a just-created Codex thread without turns when includeTurns is not materialized yet', async () => { const runtime = { startThread: vi.fn().mockResolvedValue({ @@ -428,26 +1001,20 @@ describe('Codex fresh-agent adapter', () => { model: 'gpt-5-codex', }) await adapter.send?.('thread-new-1', { + requestId: 'send-model-1', text: 'Use the small model', settings: { model: 'gpt-5.4-flash' }, }) - await expect(adapter.getSnapshot?.({ - sessionType: 'freshcodex', - provider: 'codex', - threadId: 'thread-new-1', - }, 7)).resolves.toMatchObject({ - turns: [ - { id: 'turn-1' }, - { id: 'turn-2', model: 'gpt-5.4-flash' }, - ], - }) const snapshot = await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1', }, 7) as any + expect(snapshot.turns).toHaveLength(3) expect(snapshot.turns[0]).not.toHaveProperty('model') + expect(snapshot.turns[1]).toMatchObject({ role: 'user', model: 'gpt-5.4-flash' }) + expect(snapshot.turns[2]).toMatchObject({ role: 'assistant', model: 'gpt-5.4-flash' }) }) it('subscribes to Codex lifecycle notifications and projects matching thread updates', async () => { diff --git a/test/unit/server/fresh-agent/codex-normalize.test.ts b/test/unit/server/fresh-agent/codex-normalize.test.ts index 7f53993c5..e235b8d51 100644 --- a/test/unit/server/fresh-agent/codex-normalize.test.ts +++ b/test/unit/server/fresh-agent/codex-normalize.test.ts @@ -1,10 +1,36 @@ import { describe, expect, it } from 'vitest' +import { CodexThreadItemTypeSchema } from '../../../../server/coding-cli/codex-app-server/protocol.js' import { + CodexDisplayConfigError, + createCodexDisplayId, + normalizeCodexDisplayTurns, normalizeCodexThreadSnapshot, normalizeCodexTurn, + normalizeCodexTurnBody, + parseCodexDisplayIdHandle, } from '../../../../server/fresh-agent/adapters/codex/normalize.js' +const DISPLAY_SECRET = 'task-2-deterministic-secret' +const THREAD_ID = 'thread-codex-1' + +function normalizeDisplayTurns( + rawTurn: Record, + overrides: { + model?: string + secret?: string + threadId?: string + submittedRequestIdByProviderTurnId?: Map + } = {}, +) { + return normalizeCodexDisplayTurns(rawTurn, 0, { + model: 'gpt-5.4-mini', + secret: DISPLAY_SECRET, + threadId: THREAD_ID, + ...overrides, + }) +} + describe('Codex fresh-agent normalization', () => { it('normalizes codex fork, review, worktree, and child-thread metadata into the shared snapshot', () => { const snapshot = normalizeCodexThreadSnapshot({ @@ -59,7 +85,7 @@ describe('Codex fresh-agent normalization', () => { expect(snapshot.diffs[0]).toMatchObject({ path: 'src/app.ts' }) }) - it('surfaces the Codex turn model in the shared turn state', () => { + it('surfaces the Codex turn model in the shared turn state for single-row turns', () => { const turn = normalizeCodexTurn({ id: 'turn-1', model: 'gpt-5.4-mini', @@ -70,15 +96,15 @@ describe('Codex fresh-agent normalization', () => { text: 'Done', }, ], - }) + }, 0, { threadId: THREAD_ID, secret: DISPLAY_SECRET }) expect(turn).toMatchObject({ - id: 'turn-1', - turnId: 'turn-1', model: 'gpt-5.4-mini', role: 'assistant', summary: 'Done', }) + expect(turn.turnId).toMatch(/^codex-display:v1:/) + expect(turn.id).toBe(turn.turnId) }) it('uses the active runtime model as a fallback when Codex omits per-turn model metadata', () => { @@ -91,73 +117,489 @@ describe('Codex fresh-agent normalization', () => { text: 'Done', }, ], - }, 0, { model: 'gpt-5.4-mini' }) + }, 0, { threadId: THREAD_ID, model: 'gpt-5.4-mini', secret: DISPLAY_SECRET }) expect(turn.model).toBe('gpt-5.4-mini') }) - it('marks user-only Codex turns and explains an empty assistant response', () => { - const turn = normalizeCodexTurn({ - id: 'turn-empty', + it('segments mixed user, reasoning, and agent output into user then assistant display rows', () => { + const { turns, displayRows } = normalizeDisplayTurns({ + id: 'turn-mixed', + status: 'completed', + items: [ + { + id: 'item-user', + type: 'userMessage', + content: [{ type: 'text', text: 'Review the diff.' }], + }, + { + id: 'item-reasoning', + type: 'reasoning', + summary: ['Comparing changed files'], + content: ['Walking the patch'], + }, + { + id: 'item-agent', + type: 'agentMessage', + text: 'I found two regressions.', + }, + ], + }) + + expect(turns).toHaveLength(2) + expect(turns.map((turn) => turn.role)).toEqual(['user', 'assistant']) + expect(turns[0]?.items).toEqual([ + { id: 'item-user:part:0', kind: 'text', text: 'Review the diff.' }, + ]) + expect(turns[1]?.items.map((item) => item.kind)).toEqual(['reasoning', 'text']) + expect(displayRows.map((row) => row.role)).toEqual(['user', 'assistant']) + }) + + it('uses thread-bound display ids for the same native provider turn id', () => { + const first = createCodexDisplayId({ + secret: DISPLAY_SECRET, + threadId: 'thread-alpha', + providerTurnId: 'turn-shared', + role: 'assistant', + itemIds: ['item-agent'], + partIndexes: [0], + }) + const second = createCodexDisplayId({ + secret: DISPLAY_SECRET, + threadId: 'thread-beta', + providerTurnId: 'turn-shared', + role: 'assistant', + itemIds: ['item-agent'], + partIndexes: [0], + }) + + expect(first).not.toBe(second) + }) + + it('keeps display ids short and opaque to native ids, prompt text, and cursor payloads', () => { + const turnId = createCodexDisplayId({ + secret: DISPLAY_SECRET, + threadId: THREAD_ID, + providerTurnId: 'provider-turn-123', + role: 'user', + itemIds: ['provider-item-987'], + partIndexes: [0], + }) + const parsed = parseCodexDisplayIdHandle(turnId) + const decodedHandle = parsed ? Buffer.from(parsed.handle, 'base64url').toString('utf8') : '' + + expect(turnId.length).toBeLessThan(48) + expect(turnId).not.toContain('provider-turn-123') + expect(turnId).not.toContain('provider-item-987') + expect(turnId).not.toContain('printenv SECRET_TOKEN') + expect(turnId).not.toContain('cursor:opaque-payload') + expect(decodedHandle).not.toContain('provider-turn-123') + expect(decodedHandle).not.toContain('provider-item-987') + expect(decodedHandle).not.toContain('printenv SECRET_TOKEN') + expect(decodedHandle).not.toContain('cursor:opaque-payload') + }) + + it('fails display normalization when the adapter does not supply a non-empty secret', () => { + expect(() => normalizeCodexDisplayTurns({ + id: 'turn-missing-secret', status: 'completed', items: [{ - id: 'item-1', + id: 'item-user', type: 'userMessage', - content: [{ type: 'text', text: 'Write a temp file.' }], + content: [{ type: 'text', text: 'Prompt' }], }], - }) + }, 0, { + threadId: THREAD_ID, + secret: '', + })).toThrow(CodexDisplayConfigError) - expect(turn.role).toBe('user') - expect(turn.items).toEqual([ - { id: 'item-1:part:0', kind: 'text', text: 'Write a temp file.' }, - { - id: 'turn-empty:empty-response', - kind: 'text', - text: 'Codex completed this turn without recording an assistant response.', - }, - ]) + expect(() => normalizeCodexDisplayTurns({ + id: 'turn-missing-secret', + status: 'completed', + items: [{ + id: 'item-user', + type: 'userMessage', + content: [{ type: 'text', text: 'Prompt' }], + }], + }, 0, { + threadId: THREAD_ID, + })).toThrow(/non-empty.*secret/i) }) - it('does not add an empty response sentinel to user turns with tool output', () => { - const turn = normalizeCodexTurn({ - id: 'turn-tool-only', + it('fails display normalization when the adapter does not supply a non-empty threadId', () => { + expect(() => normalizeCodexDisplayTurns({ + id: 'turn-missing-thread', + status: 'completed', + items: [{ + id: 'item-user', + type: 'userMessage', + content: [{ type: 'text', text: 'Prompt' }], + }], + }, 0, { + secret: DISPLAY_SECRET, + threadId: '', + })).toThrow(CodexDisplayConfigError) + + expect(() => normalizeCodexDisplayTurns({ + id: 'turn-missing-thread', + status: 'completed', + items: [{ + id: 'item-user', + type: 'userMessage', + content: [{ type: 'text', text: 'Prompt' }], + }], + }, 0, { + secret: DISPLAY_SECRET, + })).toThrow(/non-empty.*threadId/i) + }) + + it('preserves existing display ids when unrelated rows are inserted later in the provider turn', () => { + const base = normalizeDisplayTurns({ + id: 'turn-inserted', status: 'completed', items: [ { - id: 'item-1', + id: 'item-user', + type: 'userMessage', + content: [{ type: 'text', text: 'Run the tests.' }], + }, + { + id: 'item-command', + type: 'commandExecution', + command: 'npm test', + status: 'completed', + aggregatedOutput: 'ok', + exitCode: 0, + }, + ], + }).turns + const expanded = normalizeDisplayTurns({ + id: 'turn-inserted', + status: 'completed', + items: [ + { + id: 'item-user', type: 'userMessage', - content: [{ type: 'text', text: 'Write a temp file.' }], + content: [{ type: 'text', text: 'Run the tests.' }], + }, + { + id: 'item-agent', + type: 'agentMessage', + text: 'Running them now.', }, { - id: 'item-2', + id: 'item-command', type: 'commandExecution', - command: 'cat temp.txt', + command: 'npm test', status: 'completed', aggregatedOutput: 'ok', exitCode: 0, }, ], + }).turns + + expect(base.map((turn) => turn.role)).toEqual(['user', 'tool']) + expect(expanded.map((turn) => turn.role)).toEqual(['user', 'assistant', 'tool']) + expect(base[0]?.turnId).toBe(expanded[0]?.turnId) + expect(base[1]?.turnId).toBe(expanded[2]?.turnId) + }) + + it('segments assistant output away from later tool items', () => { + const { turns } = normalizeDisplayTurns({ + id: 'turn-tools', + status: 'completed', + items: [ + { + id: 'item-agent', + type: 'agentMessage', + text: 'Applied the patch.', + }, + { + id: 'item-command', + type: 'commandExecution', + command: 'npm test', + status: 'completed', + aggregatedOutput: 'ok', + exitCode: 0, + }, + { + id: 'item-file-change', + type: 'fileChange', + status: 'completed', + changes: [{ path: 'src/app.ts', changeType: 'modified' }], + }, + ], }) - expect(turn.items.map((item) => item.id)).not.toContain('turn-tool-only:empty-response') + expect(turns).toHaveLength(2) + expect(turns.map((turn) => turn.role)).toEqual(['assistant', 'tool']) + expect(turns[1]?.items.map((item) => item.kind)).toEqual(['command', 'file_change']) }) - it('surfaces Codex turn errors in transcript text', () => { - const turn = normalizeCodexTurn({ + it('classifies every Codex thread item type into a normalized display role', () => { + const factories: Record<(typeof CodexThreadItemTypeSchema.options)[number], Record> = { + userMessage: { type: 'userMessage', content: [{ type: 'text', text: 'Prompt' }] }, + hookPrompt: { type: 'hookPrompt' }, + agentMessage: { type: 'agentMessage', text: 'Done' }, + plan: { type: 'plan', text: '1. Do the work' }, + reasoning: { type: 'reasoning', summary: ['Thinking'], content: ['Longer trace'] }, + commandExecution: { type: 'commandExecution', command: 'npm test', status: 'completed', aggregatedOutput: 'ok', exitCode: 0 }, + fileChange: { type: 'fileChange', status: 'completed', changes: [{ path: 'src/app.ts' }] }, + mcpToolCall: { type: 'mcpToolCall', server: 'docs', tool: 'search', status: 'completed', arguments: { q: 'codex' }, result: { ok: true } }, + dynamicToolCall: { type: 'dynamicToolCall', namespace: 'tools', tool: 'exec', status: 'completed', arguments: { cmd: 'pwd' }, contentItems: [], success: true }, + collabAgentToolCall: { type: 'collabAgentToolCall', tool: 'dispatch', status: 'completed', senderThreadId: 'thread-parent', receiverThreadIds: ['thread-child'], agentsStates: {} }, + webSearch: { type: 'webSearch', query: 'freshell' }, + imageView: { type: 'imageView', path: '/tmp/screenshot.png' }, + imageGeneration: { type: 'imageGeneration', status: 'completed', result: 'saved', revisedPrompt: 'prompt' }, + enteredReviewMode: { type: 'enteredReviewMode', review: 'security' }, + exitedReviewMode: { type: 'exitedReviewMode', review: 'security' }, + contextCompaction: { type: 'contextCompaction' }, + } + const expectedRoles: Record<(typeof CodexThreadItemTypeSchema.options)[number], string> = { + userMessage: 'user', + hookPrompt: 'system', + agentMessage: 'assistant', + plan: 'assistant', + reasoning: 'assistant', + commandExecution: 'tool', + fileChange: 'tool', + mcpToolCall: 'tool', + dynamicToolCall: 'tool', + collabAgentToolCall: 'tool', + webSearch: 'tool', + imageView: 'tool', + imageGeneration: 'tool', + enteredReviewMode: 'system', + exitedReviewMode: 'system', + contextCompaction: 'system', + } + + for (const type of CodexThreadItemTypeSchema.options) { + const result = normalizeDisplayTurns({ + id: `turn-${type}`, + status: 'inProgress', + items: [{ id: `item-${type}`, ...factories[type] }], + }) + + expect(result.turns).toHaveLength(1) + expect(result.turns[0]?.role).toBe(expectedRoles[type]) + } + }) + + it('extracts user text from content, then input_text, then text, then summary', () => { + const fromContent = normalizeDisplayTurns({ + id: 'turn-content', + status: 'completed', + items: [{ + id: 'item-user', + type: 'userMessage', + text: 'fallback text', + summary: 'fallback summary', + content: [{ type: 'text', text: 'from content array' }], + }], + }).turns[0] + const fromInputText = normalizeDisplayTurns({ + id: 'turn-input-text', + status: 'completed', + items: [{ + id: 'item-user', + type: 'userMessage', + content: [{ type: 'input_text', text: 'from input_text part' }], + }], + }).turns[0] + const fromText = normalizeDisplayTurns({ + id: 'turn-text', + status: 'completed', + items: [{ + id: 'item-user', + type: 'userMessage', + text: 'from top-level text', + }], + }).turns[0] + const fromSummary = normalizeDisplayTurns({ + id: 'turn-summary', + status: 'completed', + items: [{ + id: 'item-user', + type: 'userMessage', + summary: 'from top-level summary', + }], + }).turns[0] + + expect(fromContent?.items[0]).toMatchObject({ kind: 'text', text: 'from content array' }) + expect(fromInputText?.items[0]).toMatchObject({ kind: 'text', text: 'from input_text part' }) + expect(fromText?.items[0]).toMatchObject({ kind: 'text', text: 'from top-level text' }) + expect(fromSummary?.items[0]).toMatchObject({ kind: 'text', text: 'from top-level summary' }) + }) + + it('adds an assistant empty-response sentinel after completed user-only turns', () => { + const turns = normalizeDisplayTurns({ + id: 'turn-empty', + status: 'completed', + items: [{ + id: 'item-user', + type: 'userMessage', + content: [{ type: 'text', text: 'Write a temp file.' }], + }], + }).turns + + expect(turns.map((turn) => turn.role)).toEqual(['user', 'assistant']) + expect(turns[1]?.items).toEqual([ + { + id: 'codex-display-synthetic:empty-response', + kind: 'text', + text: 'Codex completed this turn without recording an assistant response.', + }, + ]) + }) + + it('adds an assistant error row after failed user-only turns', () => { + const turns = normalizeDisplayTurns({ id: 'turn-error', status: 'failed', error: { message: 'model rejected the request' }, items: [{ - id: 'item-1', + id: 'item-user', type: 'userMessage', content: [{ type: 'text', text: 'Do the thing.' }], }], + }).turns + + expect(turns.map((turn) => turn.role)).toEqual(['user', 'assistant']) + expect(turns[1]?.items).toEqual([ + { + id: 'codex-display-synthetic:error', + kind: 'text', + text: 'Codex turn failed: model rejected the request', + }, + ]) + }) + + it('throws a protocol error when a provider item that participates in identity is missing an id', () => { + expect(() => normalizeDisplayTurns({ + id: 'turn-missing-item-id', + status: 'completed', + items: [{ + type: 'userMessage', + content: [{ type: 'text', text: 'Prompt' }], + }], + })).toThrow(/protocol|stable item id/i) + }) + + it('reuses submitted user display ids when the provider userMessage later materializes', () => { + const requestId = 'request-42' + const submittedTurnId = createCodexDisplayId({ + secret: DISPLAY_SECRET, + threadId: THREAD_ID, + providerTurnId: 'turn-submitted', + role: 'user', + syntheticKind: 'submitted-input', + requestId, }) + const turns = normalizeDisplayTurns({ + id: 'turn-submitted', + status: 'completed', + items: [{ + id: 'item-user', + type: 'userMessage', + content: [{ type: 'text', text: 'Ship it.' }], + }], + }, { + submittedRequestIdByProviderTurnId: new Map([['turn-submitted', requestId]]), + }).turns + + expect(turns[0]?.turnId).toBe(submittedTurnId) + }) + + it('rejects malformed codex-display prefixes as invalid public ids', () => { + expect(parseCodexDisplayIdHandle('codex-display:not-a-valid-envelope')).toBeNull() + }) + + it('selects the exact requested display row for turn bodies and throws when it is absent', () => { + const rawTurn = { + id: 'turn-body', + status: 'completed', + items: [ + { + id: 'item-user', + type: 'userMessage', + content: [{ type: 'text', text: 'Review the diff.' }], + }, + { + id: 'item-reasoning', + type: 'reasoning', + summary: ['Comparing changed files'], + content: ['Walking the patch'], + }, + { + id: 'item-agent', + type: 'agentMessage', + text: 'I found two regressions.', + }, + ], + } + const turns = normalizeDisplayTurns(rawTurn).turns + const assistantTurnId = turns[1]?.turnId + expect(assistantTurnId).toBeTruthy() - expect(turn.items.at(-1)).toEqual({ - id: 'turn-error:error', - kind: 'text', - text: 'Codex turn failed: model rejected the request', + const assistantBody = normalizeCodexTurnBody({ + threadId: THREAD_ID, + revision: 9, + requestedTurnId: assistantTurnId ?? '', + rawTurn, + model: 'gpt-5.4-mini', + secret: DISPLAY_SECRET, }) + + expect(assistantBody.turnId).toBe(assistantTurnId) + expect(assistantBody.role).toBe('assistant') + expect(assistantBody.items.map((item) => item.kind)).toEqual(['reasoning', 'text']) + expect(() => normalizeCodexTurnBody({ + threadId: THREAD_ID, + revision: 9, + requestedTurnId: 'codex-display:v1:not-found-handle', + rawTurn, + model: 'gpt-5.4-mini', + secret: DISPLAY_SECRET, + })).toThrow(/not found/i) + + expect(() => normalizeCodexTurnBody({ + threadId: THREAD_ID, + revision: 9, + requestedTurnId: '', + rawTurn, + model: 'gpt-5.4-mini', + secret: DISPLAY_SECRET, + })).toThrow(/not found/i) + + expect(() => normalizeCodexTurnBody({ + threadId: THREAD_ID, + revision: 9, + requestedTurnId: undefined as unknown as string, + rawTurn, + model: 'gpt-5.4-mini', + secret: DISPLAY_SECRET, + })).toThrow(/not found/i) + }) + + it('fails body normalization when the adapter does not supply a non-empty secret', () => { + const rawTurn = { + id: 'turn-body-secret', + status: 'completed', + items: [{ + id: 'item-user', + type: 'userMessage', + content: [{ type: 'text', text: 'Review the diff.' }], + }], + } + + expect(() => normalizeCodexTurnBody({ + threadId: THREAD_ID, + revision: 9, + requestedTurnId: 'codex-display:v1:missingsecret0000', + rawTurn, + model: 'gpt-5.4-mini', + secret: '', + })).toThrow(CodexDisplayConfigError) }) }) diff --git a/test/unit/server/fresh-agent/opencode-normalize.test.ts b/test/unit/server/fresh-agent/opencode-normalize.test.ts index 0c8bb46f4..9998be50b 100644 --- a/test/unit/server/fresh-agent/opencode-normalize.test.ts +++ b/test/unit/server/fresh-agent/opencode-normalize.test.ts @@ -37,7 +37,7 @@ describe('OpenCode fresh-agent normalization', () => { summary: 'Compacted older OpenCode context.', }, ], - }, 0) + }, 0)! expect(turn.items).toEqual([ { @@ -70,9 +70,9 @@ describe('OpenCode fresh-agent normalization', () => { it('guards missing patch files and returns an empty completed file change', () => { const turn = normalizeOpencodeTurn({ - info: { id: 'msg-patch' }, + info: { id: 'msg-patch', role: 'assistant' }, parts: [{ id: 'part-patch-empty', type: 'patch', diff: '@@ empty @@' }], - }, 0) + }, 0)! expect(turn.items).toEqual([ { @@ -161,7 +161,7 @@ describe('OpenCode fresh-agent normalization', () => { const turn = normalizeOpencodeTurn({ info: { id: 'msg-user-quoted', role: 'user' }, parts: [{ id: 'part-user-quoted', type: 'text', text: '"Do a directory listing."' }], - }, 0) + }, 0)! expect(turn.role).toBe('user') expect(turn.items).toEqual([ @@ -174,7 +174,7 @@ describe('OpenCode fresh-agent normalization', () => { const turn = normalizeOpencodeTurn({ info: { id: 'msg-user-plain', role: 'user' }, parts: [{ id: 'part-user-plain', type: 'text', text: 'Do a directory listing.' }], - }, 0) + }, 0)! expect(turn.items[0]?.text).toBe('Do a directory listing.') expect(turn.summary).toBe('Do a directory listing.') @@ -184,7 +184,7 @@ describe('OpenCode fresh-agent normalization', () => { const turn = normalizeOpencodeTurn({ info: { id: 'msg-assistant-quoted', role: 'assistant' }, parts: [{ id: 'part-assistant-quoted', type: 'text', text: '"Hello, world."' }], - }, 0) + }, 0)! expect(turn.role).toBe('assistant') expect(turn.items[0]?.text).toBe('"Hello, world."') @@ -194,7 +194,7 @@ describe('OpenCode fresh-agent normalization', () => { const turn = normalizeOpencodeTurn({ info: { id: 'msg-user-nested', role: 'user' }, parts: [{ id: 'part-user-nested', type: 'text', text: '""nested" quotes"' }], - }, 0) + }, 0)! expect(turn.items[0]?.text).toBe('"nested" quotes') }) @@ -214,7 +214,7 @@ describe('OpenCode fresh-agent normalization', () => { text: 'Intro I could change all instances. done.', }, ], - }, 0) + }, 0)! expect(turn.items).toEqual([ { id: 'part-think', kind: 'text', text: 'Before and after.' }, @@ -229,7 +229,7 @@ describe('OpenCode fresh-agent normalization', () => { parts: [ { id: 'part-user-think', type: 'text', text: 'Why did the assistant say secret?' }, ], - }, 0) + }, 0)! expect(turn.items).toEqual([ { id: 'part-user-think', kind: 'text', text: 'Why did the assistant say secret?' }, @@ -240,7 +240,7 @@ describe('OpenCode fresh-agent normalization', () => { const turn = normalizeOpencodeTurn({ info: { id: 'msg-preserve-ws', role: 'assistant' }, parts: [{ id: 'part-ws', type: 'text', text: ' leading\n and trailing ' }], - }, 0) + }, 0)! expect(turn.items).toEqual([{ id: 'part-ws', kind: 'text', text: ' leading\n and trailing ' }]) }) @@ -250,13 +250,65 @@ describe('OpenCode fresh-agent normalization', () => { const turn = normalizeOpencodeTurn({ info: { id: 'msg-reasoning', role: 'assistant' }, parts: [{ id: 'part-reasoning', type: 'reasoning', text }], - }, 0) + }, 0)! expect(turn.items).toEqual([ { id: 'part-reasoning', kind: 'reasoning', summary: [text], content: [text], text }, ]) }) + it('keeps user and assistant messages as separate display turns with their native turnIds', () => { + const snapshot = normalizeOpencodeSnapshot({ + sessionType: 'freshopencode', + threadId: 'ses-separated-turns', + exported: { + messages: [ + { + info: { id: 'msg-user', role: 'user' }, + parts: [{ id: 'part-user', type: 'text', text: 'Summarize the changes.' }], + }, + { + info: { id: 'msg-assistant', role: 'assistant' }, + parts: [{ id: 'part-assistant', type: 'text', text: 'Summarizing now.' }], + }, + ], + }, + }) + + expect(snapshot.turns).toHaveLength(2) + expect(snapshot.turns).toMatchObject([ + { turnId: 'msg-user', messageId: 'msg-user', role: 'user', summary: 'Summarize the changes.' }, + { turnId: 'msg-assistant', messageId: 'msg-assistant', role: 'assistant', summary: 'Summarizing now.' }, + ]) + }) + + it('rejects visible roleless messages instead of emitting roleless display turns', () => { + const message = { + info: { id: 'msg-roleless' }, + parts: [{ id: 'part-roleless', type: 'text', text: 'Hidden until OpenCode provides a display role.' }], + } + + expect(normalizeOpencodeTurn(message, 0)).toBeNull() + + const snapshot = normalizeOpencodeSnapshot({ + sessionType: 'freshopencode', + threadId: 'ses-roleless', + exported: { + messages: [ + { + info: { id: 'msg-user', role: 'user' }, + parts: [{ id: 'part-user', type: 'text', text: 'User input' }], + }, + message, + ], + }, + }) + + expect(snapshot.turns).toHaveLength(1) + expect(snapshot.turns[0]).toMatchObject({ turnId: 'msg-user', role: 'user' }) + expect(snapshot.turns.some((turn) => turn.turnId === 'msg-roleless')).toBe(false) + }) + it('carries turn-page nextCursor explicitly and keeps export fallback compatibility', () => { const explicit = normalizeOpencodeTurnPage({ threadId: 'ses-page', diff --git a/test/unit/server/fresh-agent/production-wiring.test.ts b/test/unit/server/fresh-agent/production-wiring.test.ts index d388db93c..a0c6db7f9 100644 --- a/test/unit/server/fresh-agent/production-wiring.test.ts +++ b/test/unit/server/fresh-agent/production-wiring.test.ts @@ -14,6 +14,8 @@ describe('fresh-agent production wiring', () => { expect(source).toContain('registerFreshAgentThreadRoutes') expect(source).toContain('createClaudeFreshAgentAdapter') expect(source).toContain('createCodexFreshAgentAdapter') + expect(source).toContain('getCodexDisplayIdSecret') + expect(source).toMatch(/displayIdSecret:\s*codexDisplayIdSecret/) expect(source).toContain('createOpencodeFreshAgentAdapter') expect(source).toMatch(/const freshAgentRuntimeManager = new FreshAgentRuntimeManager\(/) expect(source).toMatch(/freshAgentRuntimeManager,\s*\n/) diff --git a/test/unit/server/fresh-agent/router.test.ts b/test/unit/server/fresh-agent/router.test.ts index 93eaf52f0..edd7e0b08 100644 --- a/test/unit/server/fresh-agent/router.test.ts +++ b/test/unit/server/fresh-agent/router.test.ts @@ -2,9 +2,67 @@ import { describe, expect, it, vi } from 'vitest' import express from 'express' import request from 'supertest' +import { createCodexFreshAgentAdapter } from '../../../../server/fresh-agent/adapters/codex/adapter.js' +import { FreshAgentProviderRegistry } from '../../../../server/fresh-agent/provider-registry.js' import { createFreshAgentRouter } from '../../../../server/fresh-agent/router.js' import { FreshAgentRuntimeManager, FreshAgentStaleThreadRevisionError } from '../../../../server/fresh-agent/runtime-manager.js' +function makeCodexThread(id: string, turns: unknown[] = []) { + return { + id, + sessionId: id, + preview: 'Codex summary', + ephemeral: false, + modelProvider: 'openai', + createdAt: 1770000000, + updatedAt: 7, + status: { type: 'idle' }, + cwd: '/repo', + cliVersion: 'codex-cli 0.129.0', + source: 'appServer', + turns, + } +} + +function makeMixedCodexTurn(id: string) { + return { + id, + status: 'completed', + items: [ + { + type: 'userMessage', + id: `${id}:user`, + content: [{ type: 'text', text: 'Review the diff.' }], + }, + { + type: 'reasoning', + id: `${id}:reasoning`, + summary: ['Checking changes'], + content: [], + }, + { + type: 'agentMessage', + id: `${id}:assistant`, + text: 'The patch is safe.', + }, + ], + } +} + +function makeCodexTurn(id: string) { + return { + id, + status: 'completed', + items: [{ + type: 'agentMessage', + id: `${id}:item-1`, + text: 'Codex summary', + phase: null, + memoryCitation: null, + }], + } +} + describe('fresh-agent router', () => { it('returns 409 for stale thread revisions instead of mixing bodies from different revisions', async () => { const manager = { @@ -21,4 +79,114 @@ describe('fresh-agent router', () => { expect(response.body.code).toBe('STALE_THREAD_REVISION') expect(response.body.currentRevision).toBe(7) }) + + it('serves Freshcodex split display turns and maps Codex boundary errors intentionally', async () => { + const durableTurn = makeMixedCodexTurn('turn-1') + const runtime = { + startThread: vi.fn(), + resumeThread: vi.fn(), + readThread: vi.fn().mockResolvedValue({ + thread: makeCodexThread('thread-new-1', [durableTurn]), + }), + listThreadTurns: vi.fn().mockResolvedValue({ + revision: 7, + nextCursor: null, + turns: [durableTurn], + }), + readThreadTurn: vi.fn().mockResolvedValue(durableTurn), + } + const adapter = createCodexFreshAgentAdapter({ + displayIdSecret: 'router-task-4-secret', + runtime: runtime as any, + }) + const registry = new FreshAgentProviderRegistry([{ + sessionType: 'freshcodex', + runtimeProvider: 'codex', + adapter, + }]) + const runtimeManager = new FreshAgentRuntimeManager({ registry }) + const app = express() + app.use('/api', createFreshAgentRouter({ runtimeManager })) + + const snapshot = await request(app) + .get('/api/fresh-agent/threads/freshcodex/codex/thread-new-1') + .expect(200) + expect(snapshot.body.turns).toHaveLength(2) + expect(snapshot.body.turns.map((turn: any) => turn.role)).toEqual(['user', 'assistant']) + expect(JSON.stringify(snapshot.body)).not.toContain('providerTurnId') + + const firstPage = await request(app) + .get('/api/fresh-agent/threads/freshcodex/codex/thread-new-1/turns?revision=7&limit=1') + .expect(200) + expect(firstPage.body.turns).toHaveLength(1) + expect(firstPage.body.turns[0]).toMatchObject({ role: 'user' }) + expect(firstPage.body.nextCursor).toMatch(/^codex-cursor:v1:/) + expect(JSON.stringify(firstPage.body)).not.toContain('providerTurnId') + + const secondPage = await request(app) + .get('/api/fresh-agent/threads/freshcodex/codex/thread-new-1/turns') + .query({ revision: '7', limit: '1', cursor: firstPage.body.nextCursor }) + .expect(200) + expect(secondPage.body.turns).toHaveLength(1) + expect(secondPage.body.turns[0]).toMatchObject({ role: 'assistant' }) + expect(JSON.stringify(secondPage.body)).not.toContain('providerTurnId') + + const body = await request(app) + .get(`/api/fresh-agent/threads/freshcodex/codex/thread-new-1/turns/${encodeURIComponent(secondPage.body.turns[0].turnId)}?revision=7`) + .expect(200) + expect(body.body).toMatchObject({ + turnId: secondPage.body.turns[0].turnId, + role: 'assistant', + threadId: 'thread-new-1', + revision: 7, + }) + expect(runtime.readThreadTurn).toHaveBeenCalledWith({ + threadId: 'thread-new-1', + turnId: 'turn-1', + revision: 7, + }) + expect(JSON.stringify(body.body)).not.toContain('providerTurnId') + + runtime.readThreadTurn.mockResolvedValueOnce(makeCodexTurn('turn-1')) + const unprovableBody = await request(app) + .get(`/api/fresh-agent/threads/freshcodex/codex/thread-new-1/turns/${encodeURIComponent(secondPage.body.turns[0].turnId)}?revision=7`) + .expect(409) + expect(unprovableBody.body.code).toBe('UNPROVABLE_THREAD_REVISION') + expect(unprovableBody.body.requestedRevision).toBe(7) + + const malformedCursor = await request(app) + .get('/api/fresh-agent/threads/freshcodex/codex/thread-new-1/turns?revision=7&cursor=bad-cursor') + .expect(400) + expect(malformedCursor.body.code).toBe('INVALID_TURN_CURSOR') + + const malformedDisplayId = await request(app) + .get('/api/fresh-agent/threads/freshcodex/codex/thread-new-1/turns/codex-display:v1:not-a-valid-envelope?revision=7') + .expect(400) + expect(malformedDisplayId.body.code).toBe('INVALID_DISPLAY_ID') + + runtime.listThreadTurns.mockResolvedValueOnce({ + revision: 9, + nextCursor: null, + turns: [durableTurn], + }) + const staleRevision = await request(app) + .get('/api/fresh-agent/threads/freshcodex/codex/thread-new-1/turns/codex-display:v1:abcdefghijklmnopqrstu1?revision=7') + .expect(409) + expect(staleRevision.body.code).toBe('STALE_THREAD_REVISION') + + const ambiguousNativeId = await request(app) + .get('/api/fresh-agent/threads/freshcodex/codex/thread-new-1/turns/turn-1?revision=7') + .expect(409) + expect(ambiguousNativeId.body.code).toBe('AMBIGUOUS_NATIVE_TURN_ID') + + runtime.listThreadTurns.mockResolvedValueOnce({ + revision: 7, + nextCursor: null, + turns: [durableTurn], + }) + const exactMiss = await request(app) + .get('/api/fresh-agent/threads/freshcodex/codex/thread-new-1/turns/codex-display:v1:abcdefghijklmnopqrstu1?revision=7') + .expect(404) + expect(exactMiss.body.code).toBe('TURN_NOT_FOUND') + }) }) diff --git a/test/unit/server/ws-handler-fresh-agent.test.ts b/test/unit/server/ws-handler-fresh-agent.test.ts index 7a0461f66..479ebe3f1 100644 --- a/test/unit/server/ws-handler-fresh-agent.test.ts +++ b/test/unit/server/ws-handler-fresh-agent.test.ts @@ -368,7 +368,7 @@ describe('WsHandler fresh-agent routing', () => { runtimeProvider: 'codex', }), subscribe: vi.fn().mockResolvedValue(() => undefined), - send: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockResolvedValue({ requestId: 'send-req-1', submittedTurnId: 'display-user-1' }), interrupt: vi.fn().mockResolvedValue(undefined), resolveApproval: vi.fn().mockResolvedValue(undefined), answerQuestion: vi.fn().mockResolvedValue(undefined), @@ -397,6 +397,7 @@ describe('WsHandler fresh-agent routing', () => { ws.send(JSON.stringify({ type: 'freshAgent.send', + requestId: 'send-req-1', sessionId: 'codex-session-2', sessionType: 'freshcodex', provider: 'codex', @@ -442,6 +443,7 @@ describe('WsHandler fresh-agent routing', () => { await vi.waitFor(() => { const locator = { sessionId: 'codex-session-2', sessionType: 'freshcodex', provider: 'codex' } expect(runtimeManager.send).toHaveBeenCalledWith(locator, { + requestId: 'send-req-1', text: 'Ship it', images: undefined, settings: { cwd: '/repo', model: 'gpt-5.4-mini', effort: 'low' }, @@ -455,6 +457,11 @@ describe('WsHandler fresh-agent routing', () => { expect.any(Function), ) expect(runtimeManager.kill).toHaveBeenCalledWith(locator) + expect(seenMessages).toContainEqual(expect.objectContaining({ + type: 'freshAgent.send.accepted', + requestId: 'send-req-1', + submittedTurnId: 'display-user-1', + })) expect(seenMessages).toContainEqual(expect.objectContaining({ type: 'freshAgent.forked', requestId: 'fork-req-1', diff --git a/test/unit/shared/fresh-agent-turns.test.ts b/test/unit/shared/fresh-agent-turns.test.ts new file mode 100644 index 000000000..94bdf0f62 --- /dev/null +++ b/test/unit/shared/fresh-agent-turns.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' + +import { FreshAgentTurnSchema } from '../../../shared/fresh-agent-contract.js' +import { + freshAgentSnapshotHasUserTurn, + freshAgentTurnText, + getFreshAgentDisplayTurnKey, +} from '../../../shared/fresh-agent-turns.js' + +describe('fresh-agent display turn helpers', () => { + it('prefers turnId over id for display keys', () => { + expect(getFreshAgentDisplayTurnKey({ turnId: 'turn-1', id: 'id-1' })).toBe('turn-1') + expect(getFreshAgentDisplayTurnKey({ turnId: 'turn-2', id: 'id-2' })).toBe('turn-2') + }) + + it('falls back to id when turnId is missing', () => { + expect(getFreshAgentDisplayTurnKey({ turnId: undefined as unknown as string, id: 'id-fallback' })).toBe('id-fallback') + }) + + it('joins text items and falls back to summary when no text item exists', () => { + expect(freshAgentTurnText({ + summary: 'fallback', + items: [ + { id: 'a', kind: 'text', text: 'hello' }, + { id: 'b', kind: 'reasoning', summary: ['ignore'], content: ['ignore'], text: 'ignore' }, + { id: 'c', kind: 'text', text: 'world' }, + ], + })).toBe('hello world') + + expect(freshAgentTurnText({ + summary: 'fallback text', + items: [{ id: 'x', kind: 'thinking', text: 'ignored' }], + })).toBe('fallback text') + + expect(freshAgentTurnText({ + summary: 'fallback text', + items: [{ id: 'y', kind: 'text', text: '' }], + })).toBe('') + }) + + it('returns true only for normalized user turns', () => { + expect(freshAgentSnapshotHasUserTurn({ + turns: [ + { turnId: '1', id: '1', summary: 'user prompt', role: 'user', items: [] }, + { turnId: '2', id: '2', summary: 'assistant response', role: 'assistant', items: [] }, + ], + })).toBe(true) + + expect(freshAgentSnapshotHasUserTurn({ turns: [] })).toBe(false) + expect(freshAgentSnapshotHasUserTurn({ + turns: [{ turnId: '3', id: '3', summary: 'tool', role: 'tool', items: [] }], + })).toBe(false) + + expect(freshAgentSnapshotHasUserTurn({ + turns: [{ turnId: '4', id: '4', summary: 'legacy user value', role: 'USER' as unknown as string, items: [] }], + })).toBe(true) + + expect(freshAgentSnapshotHasUserTurn({ + turns: [{ turnId: '5', id: '5', summary: 'normalized user', role: 'USER', items: [] }], + })).toBe(true) + }) + + it('does not treat assistant quoted prompt text as user submissions', () => { + expect(freshAgentSnapshotHasUserTurn({ + turns: [{ + turnId: '6', + id: '6', + summary: 'assistant turn', + role: 'assistant', + items: [{ id: 'a', kind: 'text', text: 'user: hi there' }], + }], + })).toBe(false) + }) + + it('supports legacy calls with null or undefined snapshots', () => { + expect(freshAgentSnapshotHasUserTurn(null)).toBe(false) + expect(freshAgentSnapshotHasUserTurn(undefined)).toBe(false) + }) + + it('keeps FreshAgentTurnSchema unchanged and rejects providerTurnId', () => { + expect(() => FreshAgentTurnSchema.parse({ + id: '1', + turnId: 't-1', + summary: 'summary', + items: [], + providerTurnId: 'legacy-id', + })).toThrow() + }) +})