diff --git a/dummy-edit.txt b/dummy-edit.txt new file mode 100644 index 000000000..6be1a1b3c --- /dev/null +++ b/dummy-edit.txt @@ -0,0 +1,2 @@ +Dummy edit created by Codex. +You asked for any edit, so this file is a simple placeholder. diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index ae8034884..6c41c530a 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -492,6 +492,9 @@ interface AgentConfigRowProps { selectedModel: string; isModelLoading: boolean; onSelectModel: (modelId: string) => void; + thinkingOptions: NonNullable; + selectedThinkingOptionId: string; + onSelectThinkingOption: (thinkingOptionId: string) => void; disabled?: boolean; } @@ -506,6 +509,9 @@ export function AgentConfigRow({ selectedModel, isModelLoading, onSelectModel, + thinkingOptions, + selectedThinkingOptionId, + onSelectThinkingOption, disabled, }: AgentConfigRowProps): ReactElement { const providerOptions: ComboSelectOption[] = useMemo( @@ -540,7 +546,18 @@ export function AgentConfigRow({ return opts; }, [models]); + const thinkingSelectOptions: ComboSelectOption[] = useMemo( + () => + thinkingOptions.map((option) => ({ + id: option.id, + label: option.label, + })), + [thinkingOptions] + ); + const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : ""); + const effectiveSelectedThinkingOption = + selectedThinkingOptionId || thinkingSelectOptions[0]?.id || ""; return ( @@ -584,6 +601,21 @@ export function AgentConfigRow({ showLabel={false} /> + {thinkingSelectOptions.length > 0 ? ( + + } + showLabel={false} + /> + + ) : null} ); } diff --git a/packages/app/src/components/agent-status-dot.tsx b/packages/app/src/components/agent-status-dot.tsx index 2e0f32665..93309a3b5 100644 --- a/packages/app/src/components/agent-status-dot.tsx +++ b/packages/app/src/components/agent-status-dot.tsx @@ -4,9 +4,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; export function AgentStatusDot({ status, requiresAttention, + showInactive = false, }: { status: string | null | undefined; requiresAttention: boolean | null | undefined; + showInactive?: boolean; }) { const { theme } = useUnistyles(); @@ -15,7 +17,9 @@ export function AgentStatusDot({ ? theme.colors.palette.blue[500] : requiresAttention ? theme.colors.success - : null; + : showInactive + ? theme.colors.border + : null; if (!color) { return null; @@ -31,4 +35,3 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.full, }, })); - diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 75659e3d9..d5093ac72 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -1099,11 +1099,13 @@ function PermissionRequestCard({ {!isPlanRequest ? ( ) : null} diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index 416b99fdd..dd0a91b3e 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -7,12 +7,14 @@ import { View, Platform, } from "react-native"; +import { Plus, Settings } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useCommandCenter } from "@/hooks/use-command-center"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; import { AgentStatusDot } from "@/components/agent-status-dot"; +import { Shortcut } from "@/components/ui/shortcut"; function agentKey(agent: Pick): string { return `${agent.serverId}:${agent.id}`; @@ -26,13 +28,16 @@ export function CommandCenter() { query, setQuery, activeIndex, - results, + items, handleClose, - handleSelect, + handleSelectItem, } = useCommandCenter(); if (Platform.OS !== "web") return null; + const actionItems = items.filter((item) => item.kind === "action"); + const agentItems = items.filter((item) => item.kind === "agent"); + return ( - {results.length === 0 ? ( + {items.length === 0 ? ( No matches ) : ( - results.map((agent, index) => { - const active = index === activeIndex; - return ( - [ - styles.row, - (hovered || pressed || active) && { - backgroundColor: theme.colors.surface1, - }, - ]} - onPress={() => handleSelect(agent)} - > - - - - + {actionItems.length > 0 ? ( + <> + + Actions + + {actionItems.map((item, index) => { + const active = index === activeIndex; + const action = item.action; + const actionIcon = + action.icon === "plus" ? ( + + ) : action.icon === "settings" ? ( + + ) : null; + return ( + [ + styles.row, + (hovered || pressed || active) && { + backgroundColor: theme.colors.surface1, + }, + ]} + onPress={() => handleSelectItem(item)} > - {agent.title || "New agent"} - - - - {agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)} - - - - ); - }) + + + {actionIcon ? ( + {actionIcon} + ) : null} + + + {action.title} + + + + {action.shortcutKeys ? ( + + ) : null} + + + ); + })} + + ) : null} + + {agentItems.length > 0 ? ( + <> + {actionItems.length > 0 ? ( + + ) : null} + + Agents + + {agentItems.map((item, index) => { + const rowIndex = actionItems.length + index; + const active = rowIndex === activeIndex; + const agent = item.agent; + return ( + [ + styles.row, + (hovered || pressed || active) && { + backgroundColor: theme.colors.surface1, + }, + ]} + onPress={() => handleSelectItem(item)} + > + + + + + + + + {agent.title || "New agent"} + + + {agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)} + + + + + + ); + })} + + ) : null} + )} @@ -152,24 +238,55 @@ const styles = StyleSheet.create((theme) => ({ resultsContent: { paddingVertical: theme.spacing[2], }, + sectionLabel: { + paddingHorizontal: theme.spacing[4], + paddingTop: 0, + paddingBottom: theme.spacing[2], + fontSize: theme.fontSize.xs, + }, + sectionDivider: { + height: 1, + marginTop: theme.spacing[2], + marginBottom: theme.spacing[2], + }, row: { paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], + paddingVertical: theme.spacing[2], }, rowContent: { - gap: 2, - }, - rowTitle: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], + justifyContent: "space-between", + gap: theme.spacing[3], + }, + rowMain: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[3], + }, + iconSlot: { + width: 16, + height: 20, + alignItems: "center", + justifyContent: "center", + }, + textContent: { + gap: 2, + }, + rowShortcut: { + marginLeft: theme.spacing[2], + flexShrink: 0, }, title: { fontSize: theme.fontSize.base, fontWeight: "400", + lineHeight: 20, }, subtitle: { fontSize: theme.fontSize.sm, + lineHeight: 18, }, emptyText: { paddingHorizontal: theme.spacing[4], diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index b04212876..74631db43 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -5,6 +5,7 @@ import { closestCenter, KeyboardSensor, PointerSensor, + type Modifier, useSensor, useSensors, type DragEndEvent, @@ -25,6 +26,11 @@ import type { export type { DraggableListProps, DraggableRenderItemInfo }; +const restrictToVerticalAxis: Modifier = ({ transform }) => ({ + ...transform, + x: 0, +}); + interface SortableItemProps { id: string; item: T; @@ -157,6 +163,7 @@ export function DraggableList({ diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 5f0849cfd..613082c81 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -177,6 +177,7 @@ interface DiffFileSectionProps { file: ParsedDiffFile; isExpanded: boolean; onToggle: (path: string) => void; + onHeaderHeightChange?: (path: string, height: number) => void; testID?: string; } @@ -218,6 +219,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ file, isExpanded, onToggle, + onHeaderHeightChange, testID, }: DiffFileSectionProps) { const expandStartRef = useRef(null); @@ -292,6 +294,9 @@ const DiffFileHeader = memo(function DiffFileHeader({ styles.fileSectionHeaderContainer, !isExpanded && styles.fileSectionBorder, ]} + onLayout={(event) => { + onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height); + }} testID={testID} > void; + testID?: string; +}) { const [scrollViewWidth, setScrollViewWidth] = useState(0); const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); const horizontalScroll = useHorizontalScrollOptional(); @@ -368,7 +381,13 @@ function DiffFileBody({ file, testID }: { file: ParsedDiffFile; testID?: string ); return ( - + { + onBodyHeightChange?.(file.path, event.nativeEvent.layout.height); + }} + testID={testID} + > {file.status === "too_large" || file.status === "binary" ? ( @@ -464,6 +483,10 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { // Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches const [isManualRefresh, setIsManualRefresh] = useState(false); const [expandedByPath, setExpandedByPath] = useState>({}); + const diffListRef = useRef>(null); + const headerHeightByPathRef = useRef>({}); + const bodyHeightByPathRef = useRef>({}); + const defaultHeaderHeightRef = useRef(44); const diffMetrics = useMemo(() => { let hunkCount = 0; let lineCount = 0; @@ -533,13 +556,6 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { [shipDefaultStorageKey] ); - const handleToggleExpanded = useCallback((path: string) => { - setExpandedByPath((prev) => ({ - ...prev, - [path]: !prev[path], - })); - }, []); - const { flatItems, stickyHeaderIndices } = useMemo(() => { const items: DiffFlatItem[] = []; const stickyIndices: number[] = []; @@ -555,6 +571,60 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { return { flatItems: items, stickyHeaderIndices: stickyIndices }; }, [files, expandedByPath]); + const handleHeaderHeightChange = useCallback((path: string, height: number) => { + if (!Number.isFinite(height) || height <= 0) { + return; + } + headerHeightByPathRef.current[path] = height; + defaultHeaderHeightRef.current = height; + }, []); + + const handleBodyHeightChange = useCallback((path: string, height: number) => { + if (!Number.isFinite(height) || height < 0) { + return; + } + bodyHeightByPathRef.current[path] = height; + }, []); + + const computeHeaderOffset = useCallback( + (path: string): number => { + const defaultHeaderHeight = defaultHeaderHeightRef.current; + let offset = 0; + for (const file of files) { + if (file.path === path) { + break; + } + offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight; + if (expandedByPath[file.path]) { + offset += bodyHeightByPathRef.current[file.path] ?? 0; + } + } + return Math.max(0, offset); + }, + [expandedByPath, files] + ); + + const handleToggleExpanded = useCallback( + (path: string) => { + const isCurrentlyExpanded = expandedByPath[path] ?? false; + const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null; + + // Anchor to the clicked header before collapsing so visual context is preserved. + if (isCurrentlyExpanded && targetOffset !== null) { + diffListRef.current?.scrollToOffset({ + offset: targetOffset, + animated: false, + }); + } + + setExpandedByPath((prev) => ({ + ...prev, + [path]: !prev[path], + })); + }, + [computeHeaderOffset, expandedByPath] + ); + const allExpanded = useMemo(() => { if (files.length === 0) return false; return files.every((file) => expandedByPath[file.path]); @@ -707,15 +777,20 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { file={item.file} isExpanded={item.isExpanded} onToggle={handleToggleExpanded} + onHeaderHeightChange={handleHeaderHeightChange} testID={`diff-file-${item.fileIndex}`} /> ); } return ( - + ); }, - [handleToggleExpanded] + [handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded] ); const flatKeyExtractor = useCallback( @@ -808,6 +883,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { } else { bodyContent = ( ); } diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 0bedd487a..ca5ebddf6 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -138,31 +138,14 @@ export function ToolCallDetailsContent({ ); } else if (detail?.type === "unknown") { - const sectionsFromTopLevel = [ - { title: "Input", value: detail.input }, - { title: "Output", value: detail.output }, - ].filter((entry) => entry.value !== null && entry.value !== undefined); + const plainInputText = + typeof detail.input === "string" && detail.output === null + ? detail.input + : null; - for (const section of sectionsFromTopLevel) { - let value = ""; - try { - value = - typeof section.value === "string" - ? section.value - : JSON.stringify(section.value, null, 2); - } catch { - value = String(section.value); - } - if (!value.length) { - continue; - } + if (plainInputText !== null) { sections.push( - - {section.title} - - ); - sections.push( - + - {value} + {plainInputText} ); + } else { + const sectionsFromTopLevel = [ + { title: "Input", value: detail.input }, + { title: "Output", value: detail.output }, + ].filter((entry) => entry.value !== null && entry.value !== undefined); + + for (const section of sectionsFromTopLevel) { + let value = ""; + try { + value = + typeof section.value === "string" + ? section.value + : JSON.stringify(section.value, null, 2); + } catch { + value = String(section.value); + } + if (!value.length) { + continue; + } + sections.push( + + {section.title} + + ); + sections.push( + + + {value} + + + ); + } } } diff --git a/packages/app/src/hooks/use-agent-form-state.test.ts b/packages/app/src/hooks/use-agent-form-state.test.ts index 81478bb70..c76572427 100644 --- a/packages/app/src/hooks/use-agent-form-state.test.ts +++ b/packages/app/src/hooks/use-agent-form-state.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { __private__ } from "./use-agent-form-state"; +import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; describe("useAgentFormState", () => { describe("__private__.combineInitialValues", () => { @@ -35,5 +36,115 @@ describe("useAgentFormState", () => { }); }); }); -}); + describe("__private__.resolveFormState", () => { + const codexModels: AgentModelDefinition[] = [ + { + provider: "codex", + id: "gpt-5.3-codex", + label: "gpt-5.3-codex", + isDefault: true, + defaultThinkingOptionId: "xhigh", + thinkingOptions: [ + { id: "low", label: "low" }, + { id: "xhigh", label: "xhigh", isDefault: true }, + ], + }, + ]; + + it("auto-selects the model's default thinking option when none is configured", () => { + const resolved = __private__.resolveFormState( + undefined, + { provider: "codex" }, + codexModels, + { + serverId: false, + provider: false, + modeId: false, + model: false, + thinkingOptionId: false, + workingDir: false, + }, + { + serverId: null, + provider: "codex", + modeId: "", + model: "", + thinkingOptionId: "", + workingDir: "", + }, + new Set() + ); + + expect(resolved.thinkingOptionId).toBe("xhigh"); + }); + + it("keeps provider thinking preference when it is valid for the effective model", () => { + const resolved = __private__.resolveFormState( + undefined, + { + provider: "codex", + providerPreferences: { + codex: { + thinkingOptionId: "low", + }, + }, + }, + codexModels, + { + serverId: false, + provider: false, + modeId: false, + model: false, + thinkingOptionId: false, + workingDir: false, + }, + { + serverId: null, + provider: "codex", + modeId: "", + model: "", + thinkingOptionId: "", + workingDir: "", + }, + new Set() + ); + + expect(resolved.thinkingOptionId).toBe("low"); + }); + + it("falls back to model default when saved thinking preference is invalid", () => { + const resolved = __private__.resolveFormState( + undefined, + { + provider: "codex", + providerPreferences: { + codex: { + thinkingOptionId: "medium", + }, + }, + }, + codexModels, + { + serverId: false, + provider: false, + modeId: false, + model: false, + thinkingOptionId: false, + workingDir: false, + }, + { + serverId: null, + provider: "codex", + modeId: "", + model: "", + thinkingOptionId: "", + workingDir: "", + }, + new Set() + ); + + expect(resolved.thinkingOptionId).toBe("xhigh"); + }); + }); +}); diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index f9bdb17c2..c8dff74d6 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -19,6 +19,7 @@ export interface FormInitialValues { provider?: AgentProvider; modeId?: string | null; model?: string | null; + thinkingOptionId?: string | null; workingDir?: string; } @@ -28,6 +29,7 @@ interface UserModifiedFields { provider: boolean; modeId: boolean; model: boolean; + thinkingOptionId: boolean; workingDir: boolean; } @@ -36,6 +38,7 @@ const INITIAL_USER_MODIFIED: UserModifiedFields = { provider: false, modeId: false, model: false, + thinkingOptionId: false, workingDir: false, }; @@ -45,6 +48,7 @@ interface FormState { provider: AgentProvider; modeId: string; model: string; + thinkingOptionId: string; workingDir: string; } @@ -67,6 +71,8 @@ type UseAgentFormStateResult = { setModeFromUser: (modeId: string) => void; selectedModel: string; setModelFromUser: (modelId: string) => void; + selectedThinkingOptionId: string; + setThinkingOptionFromUser: (thinkingOptionId: string) => void; workingDir: string; setWorkingDir: (value: string) => void; setWorkingDirFromUser: (value: string) => void; @@ -75,6 +81,7 @@ type UseAgentFormStateResult = { agentDefinition?: AgentProviderDefinition; modeOptions: AgentMode[]; availableModels: AgentModelDefinition[]; + availableThinkingOptions: NonNullable; isModelLoading: boolean; modelError: string | null; refreshProviderModels: () => void; @@ -91,6 +98,32 @@ const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude"; const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? ""; +function resolveDefaultModel( + availableModels: AgentModelDefinition[] | null +): AgentModelDefinition | null { + if (!availableModels || availableModels.length === 0) { + return null; + } + return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null; +} + +function resolveEffectiveModel( + availableModels: AgentModelDefinition[] | null, + modelId: string +): AgentModelDefinition | null { + if (!availableModels || availableModels.length === 0) { + return null; + } + const normalizedModelId = modelId.trim(); + if (!normalizedModelId) { + return resolveDefaultModel(availableModels); + } + return ( + availableModels.find((model) => model.id === normalizedModelId) ?? + resolveDefaultModel(availableModels) + ); +} + /** * Pure function that resolves form state from multiple data sources. * Priority: explicit (URL params) > preferences > provider defaults > fallback @@ -175,7 +208,43 @@ function resolveFormState( } } - // 4. Resolve serverId (independent) + // 4. Resolve thinking option (depends on effective model) + const initialThinkingOptionId = + typeof initialValues?.thinkingOptionId === "string" + ? initialValues.thinkingOptionId.trim() + : ""; + const preferredThinkingOptionId = + providerPrefs?.thinkingOptionId?.trim() ?? ""; + + if (!userModified.thinkingOptionId) { + if (initialThinkingOptionId.length > 0) { + result.thinkingOptionId = initialThinkingOptionId; + } else if (preferredThinkingOptionId.length > 0) { + result.thinkingOptionId = preferredThinkingOptionId; + } else { + result.thinkingOptionId = ""; + } + } + + // Validate thinking option once model metadata is available. + if (availableModels) { + const effectiveModel = resolveEffectiveModel(availableModels, result.model); + const thinkingOptions = effectiveModel?.thinkingOptions ?? []; + if (thinkingOptions.length === 0) { + result.thinkingOptionId = ""; + } else { + const thinkingIds = new Set(thinkingOptions.map((option) => option.id)); + const defaultThinkingOptionId = + effectiveModel?.defaultThinkingOptionId ?? + thinkingOptions[0]?.id ?? + ""; + if (!result.thinkingOptionId || !thinkingIds.has(result.thinkingOptionId)) { + result.thinkingOptionId = defaultThinkingOptionId; + } + } + } + + // 5. Resolve serverId (independent) // Only use stored serverId if the host still exists in the registry if (!userModified.serverId) { if (initialValues?.serverId !== undefined) { @@ -186,7 +255,7 @@ function resolveFormState( // else keep current } - // 5. Resolve workingDir (independent) + // 6. Resolve workingDir (independent) if (!userModified.workingDir) { if (initialValues?.workingDir !== undefined) { result.workingDir = initialValues.workingDir; @@ -258,6 +327,7 @@ export function useAgentFormState( provider: DEFAULT_PROVIDER, modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER, model: "", + thinkingOptionId: "", workingDir: "", })); const formStateRef = useRef(formState); @@ -342,6 +412,7 @@ export function useAgentFormState( resolved.provider !== formStateRef.current.provider || resolved.modeId !== formStateRef.current.modeId || resolved.model !== formStateRef.current.model || + resolved.thinkingOptionId !== formStateRef.current.thinkingOptionId || resolved.workingDir !== formStateRef.current.workingDir ) { setFormState(resolved); @@ -430,6 +501,7 @@ export function useAgentFormState( provider, modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "", model: providerPrefs?.model ?? "", + thinkingOptionId: providerPrefs?.thinkingOptionId ?? "", })); }, [preferences?.providerPreferences, updatePreferences] @@ -453,6 +525,15 @@ export function useAgentFormState( [formState.provider, updateProviderPreferences] ); + const setThinkingOptionFromUser = useCallback( + (thinkingOptionId: string) => { + setFormState((prev) => ({ ...prev, thinkingOptionId })); + setUserModified((prev) => ({ ...prev, thinkingOptionId: true })); + void updateProviderPreferences(formState.provider, { thinkingOptionId }); + }, + [formState.provider, updateProviderPreferences] + ); + const setWorkingDir = useCallback((value: string) => { setFormState((prev) => ({ ...prev, workingDir: value })); }, []); @@ -475,27 +556,40 @@ export function useAgentFormState( }, [providerModelsQuery]); const persistFormPreferences = useCallback(async () => { + const providerPreferenceUpdates: { + mode: string; + model: string; + thinkingOptionId?: string; + } = { + mode: formState.modeId, + model: formState.model, + }; + if (userModified.thinkingOptionId) { + providerPreferenceUpdates.thinkingOptionId = formState.thinkingOptionId; + } + await updatePreferences({ workingDir: formState.workingDir, provider: formState.provider, serverId: formState.serverId ?? undefined, }); - await updateProviderPreferences(formState.provider, { - mode: formState.modeId, - model: formState.model, - }); + await updateProviderPreferences(formState.provider, providerPreferenceUpdates); }, [ formState.modeId, formState.model, formState.provider, formState.serverId, + formState.thinkingOptionId, formState.workingDir, + userModified.thinkingOptionId, updatePreferences, updateProviderPreferences, ]); const agentDefinition = providerDefinitionMap.get(formState.provider); const modeOptions = agentDefinition?.modes ?? []; + const effectiveModel = resolveEffectiveModel(availableModels, formState.model); + const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching; const modelError = providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null; @@ -513,6 +607,8 @@ export function useAgentFormState( setModeFromUser, selectedModel: formState.model, setModelFromUser, + selectedThinkingOptionId: formState.thinkingOptionId, + setThinkingOptionFromUser, workingDir: formState.workingDir, setWorkingDir, setWorkingDirFromUser, @@ -521,6 +617,7 @@ export function useAgentFormState( agentDefinition, modeOptions, availableModels: availableModels ?? [], + availableThinkingOptions, isModelLoading, modelError, refreshProviderModels, @@ -532,17 +629,20 @@ export function useAgentFormState( formState.provider, formState.modeId, formState.model, + formState.thinkingOptionId, formState.workingDir, setSelectedServerId, setSelectedServerIdFromUser, setProviderFromUser, setModeFromUser, setModelFromUser, + setThinkingOptionFromUser, setWorkingDir, setWorkingDirFromUser, agentDefinition, modeOptions, availableModels, + availableThinkingOptions, isModelLoading, modelError, refreshProviderModels, diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 96f059777..fefeaa974 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -4,10 +4,20 @@ import { router, usePathname } from "expo-router"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; +import { + checkoutStatusQueryKey, + type CheckoutStatusPayload, +} from "@/hooks/use-checkout-status-query"; +import { queryClient } from "@/query/query-client"; import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, } from "@/utils/command-center-focus-restore"; +import { + buildNewAgentRoute, + resolveNewAgentWorkingDir, +} from "@/utils/new-agent-routing"; +import type { ShortcutKey } from "@/utils/format-shortcut"; import { focusWithRetries } from "@/utils/web-focus"; function agentKey(agent: Pick): string { @@ -41,6 +51,74 @@ function parseAgentKeyFromPathname(pathname: string): string | null { return `${match[1]}:${match[2]}`; } +function parseAgentRouteFromPathname( + pathname: string +): { serverId: string; agentId: string } | null { + const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/); + if (!match) return null; + const [, serverId, agentId] = match; + if (!serverId || !agentId) return null; + return { serverId, agentId }; +} + +type CommandCenterActionDefinition = { + id: string; + title: string; + icon?: "plus" | "settings"; + shortcutKeys?: ShortcutKey[]; + keywords: string[]; + buildRoute: (params: { newAgentRoute: string }) => string; +}; + +const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ + { + id: "new-agent", + title: "New agent", + icon: "plus", + shortcutKeys: ["mod", "alt", "N"], + keywords: ["new", "new agent", "create", "start", "launch", "agent"], + buildRoute: ({ newAgentRoute }) => newAgentRoute, + }, + { + id: "settings", + title: "Settings", + icon: "settings", + keywords: ["settings", "preferences", "config", "configuration"], + buildRoute: () => "/settings", + }, +]; + +function matchesActionQuery( + query: string, + action: CommandCenterActionDefinition +): boolean { + const normalized = query.trim().toLowerCase(); + if (!normalized) return true; + if (action.title.toLowerCase().includes(normalized)) { + return true; + } + return action.keywords.some((keyword) => keyword.includes(normalized)); +} + +export type CommandCenterActionItem = { + kind: "action"; + id: string; + title: string; + icon?: "plus" | "settings"; + route: string; + shortcutKeys?: ShortcutKey[]; +}; + +export type CommandCenterItem = + | { + kind: "action"; + action: CommandCenterActionItem; + } + | { + kind: "agent"; + agent: AggregatedAgent; + }; + export function useCommandCenter() { const pathname = usePathname(); const { agents } = useAggregatedAgents(); @@ -53,7 +131,7 @@ export function useCommandCenter() { const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); - const results = useMemo(() => { + const agentResults = useMemo(() => { const filtered = agents.filter((agent) => isMatch(agent, query)); filtered.sort(sortAgents); return filtered; @@ -64,11 +142,62 @@ export function useCommandCenter() { [pathname] ); + const newAgentRoute = useMemo(() => { + const routeAgent = parseAgentRouteFromPathname(pathname); + if (!routeAgent) { + return "/agent"; + } + + const { serverId, agentId } = routeAgent; + const currentAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId); + const cwd = currentAgent?.cwd?.trim(); + if (!cwd) { + return "/agent"; + } + + const checkout = + queryClient.getQueryData( + checkoutStatusQueryKey(serverId, cwd) + ) ?? null; + const workingDir = resolveNewAgentWorkingDir(cwd, checkout); + return buildNewAgentRoute(workingDir); + }, [pathname]); + + const actionItems = useMemo(() => { + return COMMAND_CENTER_ACTIONS.filter((action) => + matchesActionQuery(query, action) + ).map((action) => ({ + kind: "action", + id: action.id, + title: action.title, + icon: action.icon, + route: action.buildRoute({ newAgentRoute }), + shortcutKeys: action.shortcutKeys, + })); + }, [newAgentRoute, query]); + + const items = useMemo(() => { + const next: CommandCenterItem[] = []; + for (const action of actionItems) { + next.push({ + kind: "action", + action, + }); + } + for (const agent of agentResults) { + next.push({ + kind: "agent", + agent, + }); + } + return next; + }, [actionItems, agentResults]); + const handleClose = useCallback(() => { setOpen(false); }, [setOpen]); - const handleSelect = useCallback( + const handleSelectAgent = useCallback( (agent: AggregatedAgent) => { didNavigateRef.current = true; const session = useSessionStore.getState().sessions[agent.serverId]; @@ -86,6 +215,24 @@ export function useCommandCenter() { [pathname, requestFocusChatInput, setOpen] ); + const handleSelectAction = useCallback((action: CommandCenterActionItem) => { + didNavigateRef.current = true; + clearCommandCenterFocusRestoreElement(); + setOpen(false); + router.push(action.route as any); + }, [setOpen]); + + const handleSelectItem = useCallback( + (item: CommandCenterItem) => { + if (item.kind === "action") { + handleSelectAction(item.action); + return; + } + handleSelectAgent(item.agent); + }, + [handleSelectAction, handleSelectAgent] + ); + useEffect(() => { const prevOpen = prevOpenRef.current; prevOpenRef.current = open; @@ -126,10 +273,10 @@ export function useCommandCenter() { useEffect(() => { if (!open) return; - if (activeIndex >= results.length) { - setActiveIndex(results.length > 0 ? results.length - 1 : 0); + if (activeIndex >= items.length) { + setActiveIndex(items.length > 0 ? items.length - 1 : 0); } - }, [activeIndex, open, results.length]); + }, [activeIndex, items.length, open]); useEffect(() => { if (!open) return; @@ -152,21 +299,21 @@ export function useCommandCenter() { } if (key === "Enter") { - if (results.length === 0) return; + if (items.length === 0) return; event.preventDefault(); - const index = Math.max(0, Math.min(activeIndex, results.length - 1)); - handleSelect(results[index]!); + const index = Math.max(0, Math.min(activeIndex, items.length - 1)); + handleSelectItem(items[index]!); return; } if (key === "ArrowDown" || key === "ArrowUp") { - if (results.length === 0) return; + if (items.length === 0) return; event.preventDefault(); setActiveIndex((current) => { const delta = key === "ArrowDown" ? 1 : -1; const next = current + delta; - if (next < 0) return results.length - 1; - if (next >= results.length) return 0; + if (next < 0) return items.length - 1; + if (next >= items.length) return 0; return next; }); } @@ -175,7 +322,7 @@ export function useCommandCenter() { // react-native-web can stop propagation on key events, so listen in capture phase. window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [activeIndex, handleClose, handleSelect, open, results]); + }, [activeIndex, handleClose, handleSelectItem, items, open]); return { open, @@ -184,9 +331,8 @@ export function useCommandCenter() { setQuery, activeIndex, setActiveIndex, - results, + items, handleClose, - handleSelect, + handleSelectItem, }; } - diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts index 8edcf201c..b1ad8c1b2 100644 --- a/packages/app/src/hooks/use-form-preferences.ts +++ b/packages/app/src/hooks/use-form-preferences.ts @@ -10,6 +10,7 @@ const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"]; const providerPreferencesSchema = z.object({ model: z.string().optional(), mode: z.string().optional(), + thinkingOptionId: z.string().optional(), }); const formPreferencesSchema = z.object({ diff --git a/packages/app/src/hooks/use-global-keyboard-nav.ts b/packages/app/src/hooks/use-global-keyboard-nav.ts index c115bdf08..a0235cedb 100644 --- a/packages/app/src/hooks/use-global-keyboard-nav.ts +++ b/packages/app/src/hooks/use-global-keyboard-nav.ts @@ -3,8 +3,18 @@ import { Platform } from "react-native"; import { usePathname, useRouter } from "expo-router"; import { getIsTauri } from "@/constants/layout"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; +import { useSessionStore } from "@/stores/session-store"; import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; +import { + checkoutStatusQueryKey, + type CheckoutStatusPayload, +} from "@/hooks/use-checkout-status-query"; +import { queryClient } from "@/query/query-client"; +import { + buildNewAgentRoute, + resolveNewAgentWorkingDir, +} from "@/utils/new-agent-routing"; export function useGlobalKeyboardNav({ enabled, @@ -81,6 +91,29 @@ export function useGlobalKeyboardNav({ navigate(`/agent/${serverId}/${agentId}` as any); }; + const navigateToNewAgent = () => { + let target = "/agent"; + if (selectedAgentId) { + const separatorIndex = selectedAgentId.indexOf(":"); + if (separatorIndex > 0) { + const serverId = selectedAgentId.slice(0, separatorIndex); + const agentId = selectedAgentId.slice(separatorIndex + 1); + const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId); + const cwd = agent?.cwd?.trim(); + if (cwd) { + const checkout = + queryClient.getQueryData( + checkoutStatusQueryKey(serverId, cwd) + ) ?? null; + const workingDir = resolveNewAgentWorkingDir(cwd, checkout); + target = buildNewAgentRoute(workingDir); + } + } + } + + router.push(target as any); + }; + const handleKeyDown = (event: KeyboardEvent) => { if (!shouldHandle()) { return; @@ -103,9 +136,28 @@ export function useGlobalKeyboardNav({ } } + const isMod = event.metaKey || event.ctrlKey; + const isKeyN = event.code === "KeyN" || lowerKey === "n"; + + // Cmd/Ctrl+Alt+N: new agent (web + Tauri) + // Note: intentionally works even when focus is inside an input/textarea. + if (isMod && event.altKey && !event.shiftKey && isKeyN) { + event.preventDefault(); + navigateToNewAgent(); + return; + } + + // Cmd/Ctrl+N: new agent (Tauri only) + // Note: intentionally works even when focus is inside an input/textarea. + if (isTauri && isMod && !event.altKey && !event.shiftKey && isKeyN) { + event.preventDefault(); + navigateToNewAgent(); + return; + } + // Cmd+B: toggle sidebar if ( - (event.metaKey || event.ctrlKey) && + isMod && (event.code === "KeyB" || lowerKey === "b") ) { // The MessageInput already handles Cmd+B inside editable fields. If we also @@ -121,7 +173,7 @@ export function useGlobalKeyboardNav({ // Cmd+.: toggle sidebar (VS Code quick-fix muscle memory) // Note: intentionally works even when focus is inside an input/textarea. if ( - (event.metaKey || event.ctrlKey) && + isMod && (event.code === "Period" || key === ".") ) { // Ignore while command center is open. @@ -137,7 +189,7 @@ export function useGlobalKeyboardNav({ if ( selectedAgentId && toggleFileExplorer && - (event.metaKey || event.ctrlKey) && + isMod && (event.code === "KeyE" || lowerKey === "e") ) { // Same double-toggle issue as Cmd+B when focus is inside a text input. @@ -168,7 +220,7 @@ export function useGlobalKeyboardNav({ } // Cmd+K: command center - if ((event.metaKey || event.ctrlKey) && lowerKey === "k") { + if (isMod && lowerKey === "k") { event.preventDefault(); const s = useKeyboardNavStore.getState(); if (!s.commandCenterOpen) { @@ -205,7 +257,7 @@ export function useGlobalKeyboardNav({ } // Cmd/Ctrl+number: Tauri only (avoid browser tab switching) - if (isTauri && (event.metaKey || event.ctrlKey)) { + if (isTauri && isMod) { event.preventDefault(); navigateToSidebarShortcut(digit); } diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index b1e1d2ff1..613e638bd 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -104,6 +104,7 @@ type DraftAgentParams = { provider?: string; modeId?: string; model?: string; + thinkingOptionId?: string; workingDir?: string; }; @@ -146,6 +147,7 @@ export function DraftAgentScreen({ const resolvedProvider = getValidProvider(getParamValue(params.provider)); const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId)); const resolvedModel = getParamValue(params.model); + const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId); const resolvedWorkingDir = getParamValue(params.workingDir); const onlineServerIds = useMemo(() => { @@ -172,8 +174,17 @@ export function DraftAgentScreen({ if (resolvedModel) { values.model = resolvedModel; } + if (resolvedThinkingOptionId) { + values.thinkingOptionId = resolvedThinkingOptionId; + } return values; - }, [resolvedMode, resolvedModel, resolvedProvider, resolvedWorkingDir]); + }, [ + resolvedMode, + resolvedModel, + resolvedProvider, + resolvedThinkingOptionId, + resolvedWorkingDir, + ]); const { selectedServerId, @@ -184,11 +195,14 @@ export function DraftAgentScreen({ setModeFromUser, selectedModel, setModelFromUser, + selectedThinkingOptionId, + setThinkingOptionFromUser, workingDir, setWorkingDirFromUser, providerDefinitions, modeOptions, availableModels, + availableThinkingOptions, isModelLoading, modelError, refreshProviderModels, @@ -688,6 +702,7 @@ export function DraftAgentScreen({ const cwd = (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || "."; const provider = selectedProvider; const model = selectedModel.trim() || null; + const thinkingOptionId = selectedThinkingOptionId.trim() || null; const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null; return { @@ -713,6 +728,7 @@ export function DraftAgentScreen({ title: "New agent", cwd, model, + thinkingOptionId, labels: {}, }; }, [ @@ -722,6 +738,7 @@ export function DraftAgentScreen({ modeOptions.length, selectedMode, selectedModel, + selectedThinkingOptionId, selectedProvider, selectedServerId, selectedWorktreePath, @@ -797,11 +814,15 @@ export function DraftAgentScreen({ const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; const trimmedModel = selectedModel.trim(); + const trimmedThinkingOptionId = selectedThinkingOptionId.trim(); const config: AgentSessionConfig = { provider: selectedProvider, cwd: resolvedWorkingDir, ...(modeId ? { modeId } : {}), ...(trimmedModel ? { model: trimmedModel } : {}), + ...(trimmedThinkingOptionId + ? { thinkingOptionId: trimmedThinkingOptionId } + : {}), }; const effectiveBaseBranch = baseBranch.trim(); const effectiveWorktreeSlug = @@ -874,6 +895,7 @@ export function DraftAgentScreen({ router, selectedMode, selectedModel, + selectedThinkingOptionId, selectedProvider, selectedServerId, createAgentClient, @@ -979,6 +1001,9 @@ export function DraftAgentScreen({ selectedModel={selectedModel} isModelLoading={isModelLoading} onSelectModel={setModelFromUser} + thinkingOptions={availableThinkingOptions} + selectedThinkingOptionId={selectedThinkingOptionId} + onSelectThinkingOption={setThinkingOptionFromUser} /> {isMobile && trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( diff --git a/packages/app/src/utils/new-agent-routing.test.ts b/packages/app/src/utils/new-agent-routing.test.ts new file mode 100644 index 000000000..19ba51606 --- /dev/null +++ b/packages/app/src/utils/new-agent-routing.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; +import { + buildNewAgentRoute, + resolveNewAgentWorkingDir, +} from "./new-agent-routing"; + +describe("buildNewAgentRoute", () => { + it("falls back to /agent when no working directory is provided", () => { + expect(buildNewAgentRoute(undefined)).toBe("/agent"); + expect(buildNewAgentRoute(" ")).toBe("/agent"); + }); + + it("encodes the working directory query parameter", () => { + expect(buildNewAgentRoute("/Users/me/dev/paseo")).toBe( + "/agent?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo" + ); + }); +}); + +describe("resolveNewAgentWorkingDir", () => { + it("returns the current cwd for regular checkouts", () => { + expect(resolveNewAgentWorkingDir("/repo/path", null)).toBe("/repo/path"); + }); + + it("returns the main repo root for paseo-owned worktrees", () => { + const checkout = { + isPaseoOwnedWorktree: true, + mainRepoRoot: "/repo/main", + } as CheckoutStatusPayload; + + expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", checkout)).toBe( + "/repo/main" + ); + }); +}); diff --git a/packages/app/src/utils/new-agent-routing.ts b/packages/app/src/utils/new-agent-routing.ts new file mode 100644 index 000000000..ac3caf3ee --- /dev/null +++ b/packages/app/src/utils/new-agent-routing.ts @@ -0,0 +1,16 @@ +import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; + +export function resolveNewAgentWorkingDir( + cwd: string, + checkout: CheckoutStatusPayload | null +): string { + return (checkout?.isPaseoOwnedWorktree ? checkout.mainRepoRoot : null) ?? cwd; +} + +export function buildNewAgentRoute(workingDir?: string | null): string { + const trimmedWorkingDir = workingDir?.trim(); + if (!trimmedWorkingDir) { + return "/agent"; + } + return `/agent?workingDir=${encodeURIComponent(trimmedWorkingDir)}`; +} diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts index 747240ad7..c60fed502 100644 --- a/packages/app/src/utils/tool-call-icon.ts +++ b/packages/app/src/utils/tool-call-icon.ts @@ -14,16 +14,19 @@ const TOOL_DETAIL_ICONS: Record = }; export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): ToolCallIconComponent { + const lowerName = toolName.trim().toLowerCase(); + + // Thoughts are rendered through ToolCall with unknown detail payloads. + if (lowerName === "thinking" && (!detail || detail.type === "unknown")) { + return Brain; + } + if (detail) { return TOOL_DETAIL_ICONS[detail.type]; } - const lowerName = toolName.trim().toLowerCase(); if (lowerName === "task") { return Bot; } - if (lowerName === "thinking") { - return Brain; - } return Wrench; } diff --git a/packages/relay/src/encrypted-channel.test.ts b/packages/relay/src/encrypted-channel.test.ts index 4142c1f1c..f8b22d315 100644 --- a/packages/relay/src/encrypted-channel.test.ts +++ b/packages/relay/src/encrypted-channel.test.ts @@ -158,6 +158,49 @@ describe("EncryptedChannel", () => { expect(sentData.length).toBeGreaterThan(plaintext.length + 20); }); + it("does not throw uncaught when handshake hello retry send fails", async () => { + vi.useFakeTimers(); + try { + const daemonKeyPair = await generateKeyPair(); + const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey); + + const transport: Transport = { + send: vi.fn(), + close: vi.fn(), + onmessage: null, + onclose: null, + onerror: null, + }; + + let sendAttempts = 0; + (transport.send as ReturnType).mockImplementation(() => { + sendAttempts += 1; + if (sendAttempts >= 2) { + throw new Error("WebSocket not open (readyState=2)"); + } + }); + + const onerror = vi.fn(); + await createClientChannel(transport, daemonPubKeyB64, { onerror }); + + expect(() => { + vi.advanceTimersByTime(1000); + }).not.toThrow(); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onerror.mock.calls[0][0]).toBeInstanceOf(Error); + expect((onerror.mock.calls[0][0] as Error).message).toContain( + "WebSocket not open" + ); + + // Close the transport to stop retry timer. + transport.onclose?.(1000, "closed"); + vi.runOnlyPendingTimers(); + } finally { + vi.useRealTimers(); + } + }); + it("fails handshake on invalid hello", async () => { const [daemonTransport] = createMockTransportPair(); diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts index 2068b5fc8..e81e0e7e5 100644 --- a/packages/relay/src/encrypted-channel.ts +++ b/packages/relay/src/encrypted-channel.ts @@ -85,6 +85,21 @@ export async function createClientChannel( const helloText = JSON.stringify(hello); let retry: ReturnType | null = null; + const emitSendError = (error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + events.onerror?.(err); + }; + const sendHello = () => { + try { + transport.send(helloText); + return true; + } catch (error) { + // This can happen during daemon restarts while the socket transitions + // through CLOSING/CLOSED states. Report it but do not throw from timers. + emitSendError(error); + return false; + } + }; const clearRetry = () => { if (retry) { clearInterval(retry); @@ -95,13 +110,13 @@ export async function createClientChannel( channel.onTransitionToOpen(() => clearRetry()); channel.onClose(() => clearRetry()); - transport.send(helloText); + sendHello(); retry = setInterval(() => { if (channel.isOpen()) { clearRetry(); return; } - transport.send(helloText); + sendHello(); }, HANDSHAKE_RETRY_MS); // Avoid keeping Node processes alive (e.g. tests) if the handshake is stuck. (retry as unknown as { unref?: () => void }).unref?.(); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 633a9b163..d26ce1cbe 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -223,6 +223,7 @@ export type AgentPermissionRequest = { title?: string; description?: string; input?: AgentMetadata; + detail?: ToolCallDetail; suggestions?: AgentPermissionUpdate[]; metadata?: AgentMetadata; }; diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 7a8a8063e..a9de83dc3 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -368,26 +368,35 @@ async function startAgentMcpServer(): Promise { ); test( - "shows the command inside pending tool calls", + "shows the command inside permission requests", async () => { const cwd = tmpCwd(); const client = new ClaudeAgentClient({ logger }); const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); const session = await client.createSession(config); + const filePath = path.join(cwd, "permission.txt"); + writeFileSync(filePath, "ok", "utf8"); - let pendingCommand: string | null = null; - const events = session.stream("Run the exact command `pwd` via Bash and stop."); + let requestedCommand: string | null = null; + const events = session.stream( + "Run the exact command `rm -f permission.txt` via Bash and stop." + ); try { for await (const event of events) { - await autoApprove(session, event); if ( - event.type === "timeline" && - event.item.type === "tool_call" && - event.item.name.toLowerCase().includes("bash") && - event.item.status === "pending" + event.type === "permission_requested" && + event.request.kind === "tool" && + event.request.name.toLowerCase().includes("bash") ) { - pendingCommand = extractToolCommand(event.item.detail); + requestedCommand = extractToolCommand( + event.request.detail ?? { + type: "unknown", + input: event.request.input ?? null, + output: null, + } + ); + await session.respondToPermission(event.request.id, { behavior: "allow" }); } if (event.type === "turn_completed" || event.type === "turn_failed") { break; @@ -398,8 +407,8 @@ async function startAgentMcpServer(): Promise { rmSync(cwd, { recursive: true, force: true }); } - expect(pendingCommand).toBeTruthy(); - expect(pendingCommand?.toLowerCase()).toContain("pwd"); + expect(requestedCommand).toBeTruthy(); + expect(requestedCommand?.toLowerCase()).toContain("permission.txt"); }, 150_000 ); @@ -501,6 +510,14 @@ async function startAgentMcpServer(): Promise { for await (const event of session.stream(prompt)) { if (event.type === "permission_requested" && !captured) { captured = event.request; + const requestedCommand = extractToolCommand( + captured.detail ?? { + type: "unknown", + input: captured.input ?? null, + output: null, + } + ); + expect((requestedCommand ?? "").toLowerCase()).toContain("permission.txt"); expect(session.getPendingPermissions().length).toBeGreaterThan(0); await session.respondToPermission(captured.id, { behavior: "allow" }); } @@ -597,6 +614,14 @@ async function startAgentMcpServer(): Promise { item.status === "completed" ) ).toBe(false); + expect( + timeline.some( + (item) => + item.type === "tool_call" && + isPermissionCommandToolCall(item) && + item.status === "failed" + ) + ).toBe(true); expect(existsSync(filePath)).toBe(true); } finally { await cleanup(); @@ -668,6 +693,14 @@ async function startAgentMcpServer(): Promise { item.status === "completed" ) ).toBe(false); + expect( + timeline.some( + (item) => + item.type === "tool_call" && + isPermissionCommandToolCall(item) && + item.status === "failed" + ) + ).toBe(true); expect(existsSync(filePath)).toBe(true); } finally { await cleanup(); @@ -909,6 +942,79 @@ async function startAgentMcpServer(): Promise { 180_000 ); + test( + "handles AskUserQuestion approval flow", + async () => { + const cwd = tmpCwd(); + const client = new ClaudeAgentClient({ logger }); + const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); + const session = await client.createSession(config); + + const prompt = [ + "You must call the AskUserQuestion tool exactly once and wait for the user's answer.", + "Create one question with header 'color', prompt 'Choose a color', and options Blue and Red.", + "Set multiSelect to false.", + "After receiving the answer, reply with exactly QUESTION_FLOW_DONE.", + "Do not use any other tools.", + ].join(" "); + + let capturedQuestion: AgentPermissionRequest | null = null; + let sawResolvedAllow = false; + let sawDone = false; + + for await (const event of session.stream(prompt)) { + if ( + event.type === "permission_requested" && + event.request.kind === "question" && + !capturedQuestion + ) { + capturedQuestion = event.request; + const baseInput = + typeof capturedQuestion.input === "object" && capturedQuestion.input !== null + ? (capturedQuestion.input as Record) + : {}; + await session.respondToPermission(capturedQuestion.id, { + behavior: "allow", + updatedInput: { + ...baseInput, + answers: { color: "Blue" }, + }, + }); + } + + if ( + event.type === "permission_resolved" && + capturedQuestion && + event.requestId === capturedQuestion.id && + event.resolution.behavior === "allow" + ) { + sawResolvedAllow = true; + } + + if ( + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes("QUESTION_FLOW_DONE") + ) { + sawDone = true; + } + + if (event.type === "turn_completed" || event.type === "turn_failed") { + break; + } + } + + expect(capturedQuestion).not.toBeNull(); + expect(sawResolvedAllow).toBe(true); + expect(session.getPendingPermissions()).toHaveLength(0); + expect(sawDone).toBe(true); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, + 180_000 + ); + test( "hydrates persisted tool call results into the UI stream", async () => { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 48a16e6ee..0ddcfbf70 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -783,6 +783,20 @@ class ClaudeAgentSession implements AgentSession { }; pending.resolve(result); } else { + if (pending.request.kind === "tool") { + this.pushToolCall( + mapClaudeFailedToolCall({ + name: pending.request.name, + callId: + (typeof pending.request.metadata?.toolUseId === "string" + ? pending.request.metadata.toolUseId + : null) ?? pending.request.id, + input: pending.request.input ?? null, + output: null, + error: { message: response.message ?? "Permission denied" }, + }) + ); + } const result: PermissionResult = { behavior: "deny", message: response.message ?? "Permission request denied", @@ -1361,6 +1375,7 @@ class ClaudeAgentSession implements AgentSession { options ): Promise => { const requestId = `permission-${randomUUID()}`; + const kind = resolvePermissionKind(toolName, input); const metadata: AgentMetadata = {}; if (options.toolUseID) { metadata.toolUseId = options.toolUseID; @@ -1368,13 +1383,23 @@ class ClaudeAgentSession implements AgentSession { if (toolName === "ExitPlanMode" && typeof input.plan === "string") { metadata.planText = input.plan; } + const detail = + kind === "tool" + ? mapClaudeRunningToolCall({ + name: toolName, + callId: options.toolUseID ?? requestId, + input, + output: null, + }).detail + : undefined; const request: AgentPermissionRequest = { id: requestId, provider: "claude", name: toolName, - kind: resolvePermissionKind(toolName, input), + kind, input, + detail, suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })), metadata: Object.keys(metadata).length ? metadata : undefined, }; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 085430355..5f1edd2ef 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -203,6 +203,43 @@ describe("Codex app-server provider (integration)", () => { 30000 ); + test.runIf(isCodexInstalled())( + "listModels honors configured Codex model + reasoning defaults", + async () => { + const codexHome = tmpCwd("codex-home-defaults-"); + const prevCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; + writeFileSync( + path.join(codexHome, "config.toml"), + [ + 'model = "gpt-5.3-codex"', + 'model_reasoning_effort = "xhigh"', + ].join("\n"), + "utf8" + ); + + try { + const client = new CodexAppServerAgentClient(logger); + const models = await client.listModels(); + const configuredModel = models.find((model) => model.id === "gpt-5.3-codex"); + expect(configuredModel).toBeDefined(); + expect(configuredModel?.isDefault).toBe(true); + expect(configuredModel?.defaultThinkingOptionId).toBe("xhigh"); + expect(configuredModel?.thinkingOptions?.some((option) => option.id === "xhigh")).toBe( + true + ); + } finally { + if (prevCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = prevCodexHome; + } + rmSync(codexHome, { recursive: true, force: true }); + } + }, + 30000 + ); + test.runIf(isCodexInstalled())("accepts image prompt blocks without request validation errors", async () => { const cleanup = useTempCodexSessionDir(); const cwd = tmpCwd("codex-image-prompt-"); @@ -473,6 +510,10 @@ describe("Codex app-server provider (integration)", () => { if (event.type === "permission_requested" && event.request.name === "CodexBash") { sawPermission = true; captured = event.request; + expect(captured.detail?.type).toBe("shell"); + if (captured.detail?.type === "shell") { + expect(captured.detail.command).toContain("printf"); + } await session.respondToPermission(event.request.id, { behavior: "allow" }); } if ( @@ -515,6 +556,87 @@ describe("Codex app-server provider (integration)", () => { } }, 60000); + test.runIf(isCodexInstalled())("command approval deny emits failed tool call and skips execution", async () => { + const cleanup = useTempCodexSessionDir(); + const cwd = tmpCwd("codex-cmd-deny-"); + const filePath = path.join(cwd, "permission-deny.txt"); + writeFileSync(filePath, "ok", "utf8"); + + try { + const client = new CodexAppServerAgentClient(logger); + const session = await client.createSession({ + provider: "codex", + cwd, + modeId: "auto", + approvalPolicy: "on-request", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + }); + + let sawPermission = false; + let captured: AgentPermissionRequest | null = null; + let sawPermissionResolvedDeny = false; + const timelineItems: AgentTimelineItem[] = []; + + const events = session.stream( + [ + "You must use your shell tool to run the exact command", + "`rm -f permission-deny.txt`.", + "If approval is denied, reply DENIED and stop.", + ].join(" ") + ); + + let failure: string | null = null; + for await (const event of events) { + if (event.type === "permission_requested" && event.request.name === "CodexBash") { + sawPermission = true; + captured = event.request; + await session.respondToPermission(event.request.id, { + behavior: "deny", + message: "Denied by test", + }); + } + if ( + event.type === "permission_resolved" && + captured && + event.requestId === captured.id && + event.resolution.behavior === "deny" + ) { + sawPermissionResolvedDeny = true; + } + if (event.type === "timeline" && event.item.type === "tool_call") { + timelineItems.push(event.item); + } + if (event.type === "turn_failed") { + failure = event.error; + break; + } + if (event.type === "turn_completed") { + break; + } + } + + await session.close(); + + if (failure) { + throw new Error(failure); + } + + expect(sawPermission).toBe(true); + expect(sawPermissionResolvedDeny).toBe(true); + expect( + timelineItems.some( + (item) => + item.status === "failed" && hasShellCommand(item, "permission-deny.txt") + ) + ).toBe(true); + expect(existsSync(filePath)).toBe(true); + } finally { + cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 60000); + test.runIf(isCodexInstalled())( "streams responses and maps shell + file change tool calls into timeline items", async () => { @@ -651,6 +773,225 @@ describe("Codex app-server provider (integration)", () => { 120000 ); + test.runIf(isCodexInstalled())( + "emits expandable canonical detail for apply_patch tool calls", + async () => { + const cleanup = useTempCodexSessionDir(); + const cwd = tmpCwd("codex-patch-detail-"); + const patchFile = path.join(cwd, "expandable-patch.txt"); + + try { + const client = new CodexAppServerAgentClient(logger); + const session = await client.createSession({ + provider: "codex", + cwd, + modeId: "full-access", + approvalPolicy: "on-request", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + }); + const persistenceHandle = session.describePersistence(); + + const timelineItems: AgentTimelineItem[] = []; + let failure: string | null = null; + const patch = [ + "*** Begin Patch", + "*** Add File: expandable-patch.txt", + "+expandable", + "*** End Patch", + ].join("\n"); + const events = session.stream( + [ + "Use the apply_patch tool and nothing else.", + "Do not use shell or any other file-edit tool.", + "Apply this patch exactly:", + patch, + "After tool completion, reply PATCH_DONE.", + ].join("\n") + ); + + for await (const event of events) { + if (event.type === "permission_requested") { + await session.respondToPermission(event.request.id, { behavior: "allow" }); + } + if (event.type === "timeline") { + timelineItems.push(event.item); + } + if (event.type === "turn_failed") { + failure = event.error; + break; + } + if (event.type === "turn_completed") { + break; + } + } + + await session.close(); + + if (failure) { + throw new Error(failure); + } + + const patchCalls = timelineItems.filter( + (item): item is Extract => + item.type === "tool_call" && + item.name.trim().replace(/[.\s-]+/g, "_").toLowerCase().endsWith("apply_patch") + ); + if (patchCalls.length === 0) { + const fileExists = existsSync(patchFile); + const fileContent = fileExists ? readFileSync(patchFile, "utf8") : null; + const toolCalls = timelineItems + .filter((item): item is Extract => item.type === "tool_call") + .map((item) => ({ + name: item.name, + status: item.status, + detail: item.detail, + error: item.error, + })); + let historyToolCalls: Array<{ + name: string; + status: string; + detail: unknown; + error: unknown; + }> = []; + if (persistenceHandle) { + const resumed = await client.resumeSession(persistenceHandle); + for await (const event of resumed.streamHistory()) { + if (event.type === "timeline" && event.item.type === "tool_call") { + historyToolCalls.push({ + name: event.item.name, + status: event.item.status, + detail: event.item.detail, + error: event.item.error, + }); + } + } + await resumed.close(); + } + throw new Error( + `No apply_patch call observed. fileExists=${fileExists} fileContent=${JSON.stringify(fileContent)} liveToolCalls=${JSON.stringify(toolCalls)} historyToolCalls=${JSON.stringify(historyToolCalls)}` + ); + } + const completedPatchCall = patchCalls.find((item) => item.status === "completed"); + expect(completedPatchCall).toBeDefined(); + if (!completedPatchCall) { + return; + } + + // Patch tool calls must be renderable as expandable details in the UI. + expect(completedPatchCall.detail.type).toBe("edit"); + if (completedPatchCall.detail.type === "edit") { + const renderablePayload = + completedPatchCall.detail.unifiedDiff ?? completedPatchCall.detail.newString; + expect(typeof renderablePayload).toBe("string"); + expect(renderablePayload).toContain("expandable"); + expect(renderablePayload).not.toContain("*** Begin Patch"); + } + + const patchText = + (await waitForFileToContainText(patchFile, "expandable")) ?? + readFileSync(patchFile, "utf8"); + expect(patchText.trim()).toBe("expandable"); + } finally { + cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 120000 + ); + + test.runIf(isCodexInstalled())( + "avoids duplicate assistant timeline rows when mirrored item lifecycle notifications are emitted", + async () => { + const cleanup = useTempCodexSessionDir(); + const cwd = tmpCwd("codex-mirrored-item-lifecycle-"); + + try { + const client = new CodexAppServerAgentClient(logger); + const session = await client.createSession({ + provider: "codex", + cwd, + modeId: "full-access", + approvalPolicy: "never", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + }); + + const lifecycleChannelsByItemId = new Map(); + const rawClient = (session as any).client as + | { + notificationHandler?: (method: string, params: unknown) => void; + setNotificationHandler?: (handler: (method: string, params: unknown) => void) => void; + } + | null; + const originalHandler = rawClient?.notificationHandler; + rawClient?.setNotificationHandler?.((method: string, params: unknown) => { + if (method === "item/completed" || method === "codex/event/item_completed") { + const record = + params && typeof params === "object" && "msg" in (params as Record) + ? ((params as { msg?: { item?: { id?: unknown; type?: unknown } } }).msg?.item ?? null) + : ((params as { item?: { id?: unknown; type?: unknown } })?.item ?? null); + const itemId = typeof record?.id === "string" ? record.id : null; + const normalizedType = + typeof record?.type === "string" + ? record.type.replace(/[._-]/g, "").toLowerCase() + : ""; + if (itemId && normalizedType === "agentmessage") { + const existing = lifecycleChannelsByItemId.get(itemId) ?? { + item: false, + codexEvent: false, + }; + if (method === "item/completed") { + existing.item = true; + } else { + existing.codexEvent = true; + } + lifecycleChannelsByItemId.set(itemId, existing); + } + } + originalHandler?.(method, params); + }); + + const assistantMessages: string[] = []; + let failure: string | null = null; + for await (const event of session.stream("Reply with exactly: DUPLICATE_CHECK_DONE")) { + if (event.type === "timeline" && event.item.type === "assistant_message") { + assistantMessages.push(event.item.text); + } + if (event.type === "turn_failed") { + failure = event.error; + break; + } + if (event.type === "turn_completed") { + break; + } + } + + await session.close(); + if (failure) { + throw new Error(failure); + } + + const normalizedMessages = assistantMessages.map((text) => text.trim()).filter(Boolean); + expect( + normalizedMessages.some((text) => text.toLowerCase().includes("duplicate_check_done")) + ).toBe(true); + const adjacentDuplicates = normalizedMessages.filter( + (text, index) => index > 0 && normalizedMessages[index - 1] === text + ); + expect(adjacentDuplicates.length).toBe(0); + const sawMirroredLifecycleForAgentMessage = Array.from( + lifecycleChannelsByItemId.values() + ).some((entry) => entry.item && entry.codexEvent); + expect(sawMirroredLifecycleForAgentMessage).toBe(true); + } finally { + cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 120000 + ); + test.runIf(isCodexInstalled())( "interrupts long-running commands and emits a canceled turn", async () => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 30665d34a..53e00f0e4 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -17,6 +17,7 @@ import type { AgentSlashCommand, AgentStreamEvent, AgentTimelineItem, + ToolCallTimelineItem, AgentUsage, ListModelsOptions, ListPersistedAgentsOptions, @@ -34,7 +35,10 @@ import path from "node:path"; import readline from "node:readline"; import { z } from "zod"; import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js"; -import { mapCodexToolCallFromThreadItem } from "./codex/tool-call-mapper.js"; +import { + mapCodexRolloutToolCall, + mapCodexToolCallFromThreadItem, +} from "./codex/tool-call-mapper.js"; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; @@ -114,6 +118,32 @@ function normalizeCodexThinkingOptionId( return normalized; } +function normalizeCodexModelId(modelId: string | null | undefined): string | undefined { + if (typeof modelId !== "string") { + return undefined; + } + const normalized = modelId.trim(); + if (!normalized) { + return undefined; + } + return normalized; +} + +type CodexConfiguredDefaults = { + model?: string; + thinkingOptionId?: string; +}; + +function mergeCodexConfiguredDefaults( + primary: CodexConfiguredDefaults, + fallback: CodexConfiguredDefaults +): CodexConfiguredDefaults { + return { + model: primary.model ?? fallback.model, + thinkingOptionId: primary.thinkingOptionId ?? fallback.thinkingOptionId, + }; +} + function resolveCodexBinary(): string { try { const codexPath = execSync("which codex", { encoding: "utf8" }).trim(); @@ -672,6 +702,230 @@ function planStepsToTodoItems(steps: Array<{ step: string; status: string }>): { })); } +type CodexPatchFileChange = { + path: string; + kind?: string; + content?: string; +}; + +function normalizeCodexThreadItemType(rawType: string | undefined): string | undefined { + if (!rawType) { + return rawType; + } + switch (rawType) { + case "UserMessage": + return "userMessage"; + case "AgentMessage": + return "agentMessage"; + case "Reasoning": + return "reasoning"; + case "Plan": + return "plan"; + case "CommandExecution": + return "commandExecution"; + case "FileChange": + return "fileChange"; + case "McpToolCall": + return "mcpToolCall"; + case "WebSearch": + return "webSearch"; + default: + return rawType; + } +} + +function normalizeCodexCommandValue( + value: unknown +): string | string[] | null { + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed.length) { + return null; + } + const wrapperMatch = trimmed.match( + /^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/ + ); + if (!wrapperMatch) { + return trimmed; + } + const candidate = wrapperMatch[1]?.trim() ?? ""; + if (!candidate.length) { + return trimmed; + } + if ( + (candidate.startsWith('"') && candidate.endsWith('"')) || + (candidate.startsWith("'") && candidate.endsWith("'")) + ) { + return candidate.slice(1, -1); + } + return candidate; + } + if (!Array.isArray(value)) { + return null; + } + const parts = value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (parts.length === 0) { + return null; + } + if (parts.length >= 3 && (parts[1] === "-lc" || parts[1] === "-c")) { + return parts[2] ?? parts; + } + return parts; +} + +function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] { + if (!changes || typeof changes !== "object") { + return []; + } + return Object.entries(changes as Record) + .map(([path, value]): CodexPatchFileChange | null => { + const normalizedPath = path.trim(); + if (!normalizedPath) { + return null; + } + const parsed = + value && typeof value === "object" + ? (value as { type?: unknown; content?: unknown }) + : null; + return { + path: normalizedPath, + kind: typeof parsed?.type === "string" ? parsed.type : undefined, + content: typeof parsed?.content === "string" ? parsed.content : undefined, + }; + }) + .filter((entry): entry is CodexPatchFileChange => entry !== null); +} + +function codexPatchTextFields( + text: string | null | undefined +): { patch?: string; content?: string } { + if (typeof text !== "string") { + return {}; + } + const normalized = text.trimStart(); + const looksLikeUnifiedDiff = + normalized.startsWith("diff --git") || + normalized.startsWith("@@") || + normalized.startsWith("--- ") || + normalized.startsWith("+++ "); + return looksLikeUnifiedDiff ? { patch: text } : { content: text }; +} + +function toRunningToolCall(item: ToolCallTimelineItem): ToolCallTimelineItem { + return { + ...item, + status: "running", + error: null, + }; +} + +function mapCodexExecNotificationToToolCall(params: { + callId?: string | null; + command: unknown; + cwd?: string | null; + output?: string | null; + exitCode?: number | null; + success?: boolean | null; + stderr?: string | null; + running: boolean; +}): ToolCallTimelineItem | null { + const command = normalizeCodexCommandValue(params.command); + if (!command) { + return null; + } + const isFailure = + params.running + ? false + : params.success === false || + (typeof params.exitCode === "number" && params.exitCode !== 0); + const output = + params.running + ? null + : { + command, + ...(params.output !== null && params.output !== undefined + ? { output: params.output } + : {}), + ...(params.exitCode !== null && params.exitCode !== undefined + ? { exitCode: params.exitCode } + : {}), + }; + const mapped = mapCodexRolloutToolCall({ + callId: params.callId ?? null, + name: "shell", + input: { + command, + ...(params.cwd ? { cwd: params.cwd } : {}), + }, + output, + error: isFailure + ? { message: params.stderr?.trim() || "Command failed" } + : null, + cwd: params.cwd ?? null, + }); + return params.running ? toRunningToolCall(mapped) : mapped; +} + +function mapCodexPatchNotificationToToolCall(params: { + callId?: string | null; + changes: unknown; + cwd?: string | null; + stdout?: string | null; + stderr?: string | null; + success?: boolean | null; + latestUnifiedDiff?: string | null; + running: boolean; +}): ToolCallTimelineItem { + const files = parseCodexPatchChanges(params.changes); + const firstPath = files[0]?.path; + const firstContent = files + .map((file) => file.content?.trim()) + .find((value): value is string => typeof value === "string" && value.length > 0); + const patchText = params.latestUnifiedDiff?.trim() || firstContent; + const patchFields = codexPatchTextFields(patchText); + const mapped = mapCodexRolloutToolCall({ + callId: params.callId ?? null, + name: "apply_patch", + input: firstPath + ? { + path: firstPath, + ...patchFields, + files: files.map((file) => ({ path: file.path, kind: file.kind })), + } + : { + changes: params.changes ?? null, + ...patchFields, + }, + output: params.running + ? null + : { + ...(files.length > 0 + ? { + files: files.map((file) => ({ + path: file.path, + ...(file.kind ? { kind: file.kind } : {}), + ...patchFields, + })), + } + : {}), + ...(params.stdout ? { stdout: params.stdout } : {}), + ...(params.stderr ? { stderr: params.stderr } : {}), + ...(params.success !== null && params.success !== undefined + ? { success: params.success } + : {}), + }, + error: + params.running || params.success !== false + ? null + : { message: params.stderr?.trim() || "Patch apply failed" }, + cwd: params.cwd ?? null, + }); + return params.running ? toRunningToolCall(mapped) : mapped; +} + function threadItemToTimeline( item: any, options?: { includeUserMessage?: boolean; cwd?: string | null } @@ -679,25 +933,37 @@ function threadItemToTimeline( if (!item || typeof item !== "object") return null; const includeUserMessage = options?.includeUserMessage ?? true; const cwd = options?.cwd ?? null; - switch (item.type) { + const normalizedType = normalizeCodexThreadItemType( + typeof item.type === "string" ? item.type : undefined + ); + const normalizedItem = + normalizedType && normalizedType !== item.type + ? ({ ...item, type: normalizedType } as typeof item) + : item; + + switch (normalizedType) { case "userMessage": { if (!includeUserMessage) { return null; } - const text = extractUserText(item.content) ?? ""; + const text = extractUserText(normalizedItem.content) ?? ""; return { type: "user_message", text }; } case "agentMessage": { - return { type: "assistant_message", text: item.text ?? "" }; + return { type: "assistant_message", text: normalizedItem.text ?? "" }; } case "plan": { - const text = item.text ?? ""; + const text = normalizedItem.text ?? ""; const items = parsePlanTextToTodoItems(text); return { type: "todo", items }; } case "reasoning": { - const summary = Array.isArray(item.summary) ? item.summary.join("\n") : ""; - const content = Array.isArray(item.content) ? item.content.join("\n") : ""; + const summary = Array.isArray(normalizedItem.summary) + ? normalizedItem.summary.join("\n") + : ""; + const content = Array.isArray(normalizedItem.content) + ? normalizedItem.content.join("\n") + : ""; const text = summary || content; return text ? { type: "reasoning", text } : null; } @@ -705,7 +971,7 @@ function threadItemToTimeline( case "fileChange": case "mcpToolCall": case "webSearch": - return mapCodexToolCallFromThreadItem(item, { cwd }); + return mapCodexToolCallFromThreadItem(normalizedItem, { cwd }); default: return null; } @@ -772,6 +1038,7 @@ const TurnCompletedNotificationSchema = z.object({ message: z.string().optional(), }) .passthrough() + .nullable() .optional(), }) .passthrough(), @@ -827,6 +1094,83 @@ const CodexEventTaskCompleteNotificationSchema = z.object({ .passthrough(), }).passthrough(); +const CodexEventItemLifecycleNotificationSchema = z.object({ + msg: z + .object({ + type: z.enum(["item_started", "item_completed"]), + item: z + .object({ + id: z.string().optional(), + type: z.string().optional(), + }) + .passthrough(), + }) + .passthrough(), +}).passthrough(); + +const CodexEventExecCommandBeginNotificationSchema = z.object({ + msg: z + .object({ + type: z.literal("exec_command_begin"), + call_id: z.string().optional(), + command: z.unknown().optional(), + cwd: z.string().optional(), + }) + .passthrough(), +}).passthrough(); + +const CodexEventExecCommandEndNotificationSchema = z.object({ + msg: z + .object({ + type: z.literal("exec_command_end"), + call_id: z.string().optional(), + command: z.unknown().optional(), + cwd: z.string().optional(), + stdout: z.string().optional(), + stderr: z.string().optional(), + aggregated_output: z.string().optional(), + aggregatedOutput: z.string().optional(), + formatted_output: z.string().optional(), + exit_code: z.number().nullable().optional(), + exitCode: z.number().nullable().optional(), + success: z.boolean().optional(), + }) + .passthrough(), +}).passthrough(); + +const CodexEventPatchApplyBeginNotificationSchema = z.object({ + msg: z + .object({ + type: z.literal("patch_apply_begin"), + call_id: z.string().optional(), + changes: z.unknown().optional(), + }) + .passthrough(), +}).passthrough(); + +const CodexEventPatchApplyEndNotificationSchema = z.object({ + msg: z + .object({ + type: z.literal("patch_apply_end"), + call_id: z.string().optional(), + changes: z.unknown().optional(), + stdout: z.string().optional(), + stderr: z.string().optional(), + success: z.boolean().optional(), + }) + .passthrough(), +}).passthrough(); + +const CodexEventTurnDiffNotificationSchema = z.object({ + msg: z + .object({ + type: z.literal("turn_diff"), + unified_diff: z.string().optional(), + diff: z.string().optional(), + }) + .passthrough(), +}).passthrough(); + type ParsedCodexNotification = | { kind: "thread_started"; threadId: string } | { kind: "turn_started"; turnId: string } @@ -836,8 +1180,45 @@ type ParsedCodexNotification = | { kind: "token_usage_updated"; tokenUsage: unknown } | { kind: "agent_message_delta"; itemId: string; delta: string } | { kind: "reasoning_delta"; itemId: string; delta: string } - | { kind: "item_completed"; item: { id?: string; type?: string; [key: string]: unknown } } - | { kind: "item_started"; item: { id?: string; type?: string; [key: string]: unknown } } + | { + kind: "item_completed"; + source: "item" | "codex_event"; + item: { id?: string; type?: string; [key: string]: unknown }; + } + | { + kind: "item_started"; + source: "item" | "codex_event"; + item: { id?: string; type?: string; [key: string]: unknown }; + } + | { + kind: "exec_command_started"; + callId: string | null; + command: unknown; + cwd: string | null; + } + | { + kind: "exec_command_completed"; + callId: string | null; + command: unknown; + cwd: string | null; + output: string | null; + exitCode: number | null; + success: boolean | null; + stderr: string | null; + } + | { + kind: "patch_apply_started"; + callId: string | null; + changes: unknown; + } + | { + kind: "patch_apply_completed"; + callId: string | null; + changes: unknown; + stdout: string | null; + stderr: string | null; + success: boolean | null; + } | { kind: "invalid_payload"; method: string; params: unknown } | { kind: "unknown_method"; method: string; params: unknown }; @@ -915,17 +1296,121 @@ const CodexNotificationSchema = z.union([ ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) ), z.object({ method: z.literal("item/completed"), params: ItemLifecycleNotificationSchema }).transform( - ({ params }): ParsedCodexNotification => ({ kind: "item_completed", item: params.item }) + ({ params }): ParsedCodexNotification => ({ kind: "item_completed", source: "item", item: params.item }) ), z.object({ method: z.literal("item/completed"), params: z.unknown() }).transform( ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) ), z.object({ method: z.literal("item/started"), params: ItemLifecycleNotificationSchema }).transform( - ({ params }): ParsedCodexNotification => ({ kind: "item_started", item: params.item }) + ({ params }): ParsedCodexNotification => ({ kind: "item_started", source: "item", item: params.item }) ), z.object({ method: z.literal("item/started"), params: z.unknown() }).transform( ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) ), + z.object({ + method: z.literal("codex/event/item_started"), + params: CodexEventItemLifecycleNotificationSchema, + }).transform( + ({ params }): ParsedCodexNotification => ({ + kind: "item_started", + source: "codex_event", + item: params.msg.item, + }) + ), + z.object({ method: z.literal("codex/event/item_started"), params: z.unknown() }).transform( + ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) + ), + z.object({ + method: z.literal("codex/event/item_completed"), + params: CodexEventItemLifecycleNotificationSchema, + }).transform( + ({ params }): ParsedCodexNotification => ({ + kind: "item_completed", + source: "codex_event", + item: params.msg.item, + }) + ), + z.object({ method: z.literal("codex/event/item_completed"), params: z.unknown() }).transform( + ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) + ), + z.object({ + method: z.literal("codex/event/exec_command_begin"), + params: CodexEventExecCommandBeginNotificationSchema, + }).transform( + ({ params }): ParsedCodexNotification => ({ + kind: "exec_command_started", + callId: params.msg.call_id ?? null, + command: params.msg.command ?? null, + cwd: params.msg.cwd ?? null, + }) + ), + z.object({ method: z.literal("codex/event/exec_command_begin"), params: z.unknown() }).transform( + ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) + ), + z.object({ + method: z.literal("codex/event/exec_command_end"), + params: CodexEventExecCommandEndNotificationSchema, + }).transform( + ({ params }): ParsedCodexNotification => ({ + kind: "exec_command_completed", + callId: params.msg.call_id ?? null, + command: params.msg.command ?? null, + cwd: params.msg.cwd ?? null, + output: + params.msg.aggregated_output ?? + params.msg.aggregatedOutput ?? + params.msg.formatted_output ?? + params.msg.stdout ?? + null, + exitCode: params.msg.exit_code ?? params.msg.exitCode ?? null, + success: params.msg.success ?? null, + stderr: params.msg.stderr ?? null, + }) + ), + z.object({ method: z.literal("codex/event/exec_command_end"), params: z.unknown() }).transform( + ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) + ), + z.object({ + method: z.literal("codex/event/patch_apply_begin"), + params: CodexEventPatchApplyBeginNotificationSchema, + }).transform( + ({ params }): ParsedCodexNotification => ({ + kind: "patch_apply_started", + callId: params.msg.call_id ?? null, + changes: params.msg.changes ?? null, + }) + ), + z.object({ method: z.literal("codex/event/patch_apply_begin"), params: z.unknown() }).transform( + ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) + ), + z.object({ + method: z.literal("codex/event/patch_apply_end"), + params: CodexEventPatchApplyEndNotificationSchema, + }).transform( + ({ params }): ParsedCodexNotification => ({ + kind: "patch_apply_completed", + callId: params.msg.call_id ?? null, + changes: params.msg.changes ?? null, + stdout: params.msg.stdout ?? null, + stderr: params.msg.stderr ?? null, + success: params.msg.success ?? null, + }) + ), + z.object({ method: z.literal("codex/event/patch_apply_end"), params: z.unknown() }).transform( + ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) + ), + z.object({ + method: z.literal("codex/event/turn_diff"), + params: CodexEventTurnDiffNotificationSchema, + }).transform( + ({ params }): ParsedCodexNotification => ({ + kind: "diff_updated", + diff: params.msg.unified_diff ?? params.msg.diff ?? "", + }) + ), + z.object({ method: z.literal("codex/event/turn_diff"), params: z.unknown() }).transform( + ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) + ), z.object({ method: z.literal("codex/event/turn_aborted"), params: CodexEventTurnAbortedNotificationSchema, @@ -968,6 +1453,53 @@ async function writeImageAttachment(mimeType: string, data: string): Promise { + let savedConfigDefaults: CodexConfiguredDefaults = {}; + try { + const response = (await client.request("getUserSavedConfig", {})) as { + config?: { + model?: string | null; + modelReasoningEffort?: string | null; + }; + }; + savedConfigDefaults = { + model: normalizeCodexModelId(response?.config?.model), + thinkingOptionId: normalizeCodexThinkingOptionId( + response?.config?.modelReasoningEffort ?? null + ), + }; + } catch (error) { + logger.debug({ error }, "Failed to read Codex saved config defaults"); + } + + if (savedConfigDefaults.model && savedConfigDefaults.thinkingOptionId) { + return savedConfigDefaults; + } + + let configReadDefaults: CodexConfiguredDefaults = {}; + try { + const response = (await client.request("config/read", {})) as { + config?: { + model?: string | null; + model_reasoning_effort?: string | null; + }; + }; + configReadDefaults = { + model: normalizeCodexModelId(response?.config?.model), + thinkingOptionId: normalizeCodexThinkingOptionId( + response?.config?.model_reasoning_effort ?? null + ), + }; + } catch (error) { + logger.debug({ error }, "Failed to read Codex config defaults"); + } + + return mergeCodexConfiguredDefaults(savedConfigDefaults, configReadDefaults); +} + export async function codexAppServerTurnInputFromPrompt( prompt: AgentPromptInput, logger: Logger @@ -1033,8 +1565,11 @@ class CodexAppServerAgentSession implements AgentSession { private resolvedPermissionRequests = new Set(); private pendingAgentMessages = new Map(); private pendingReasoning = new Map(); + private emittedItemStartedIds = new Set(); + private emittedItemCompletedIds = new Set(); private warnedUnknownNotificationMethods = new Set(); private warnedInvalidNotificationPayloads = new Set(); + private latestTurnUnifiedDiff: string | null = null; private latestUsage: AgentUsage | undefined; private connected = false; private collaborationModes: Array<{ @@ -1443,10 +1978,41 @@ class CodexAppServerAgentSession implements AgentSession { if (!pending) { throw new Error(`No pending Codex app-server permission request with id '${requestId}'`); } + const pendingRequest = this.pendingPermissions.get(requestId) ?? null; this.pendingPermissionHandlers.delete(requestId); this.pendingPermissions.delete(requestId); this.resolvedPermissionRequests.add(requestId); + if (response.behavior === "deny" && pendingRequest?.kind === "tool") { + const fallbackName = + pendingRequest.name === "CodexBash" + ? "shell" + : pendingRequest.name === "CodexFileChange" + ? "apply_patch" + : pendingRequest.name; + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: { + type: "tool_call", + callId: requestId, + name: fallbackName, + status: "failed", + error: { message: response.message ?? "Permission denied" }, + detail: + pendingRequest.detail ?? { + type: "unknown", + input: pendingRequest.input ?? null, + output: null, + }, + metadata: { + permissionRequestId: requestId, + denied: true, + }, + }, + }); + } + this.emitEvent({ type: "permission_resolved", provider: CODEX_PROVIDER, @@ -1607,6 +2173,10 @@ class CodexAppServerAgentSession implements AgentSession { // Resolve model - if not specified, query available models and pick default let model = this.config.model; + if (!model) { + const configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger); + model = configuredDefaults.model; + } if (!model) { const modelResponse = (await this.client.request("model/list", {})) as CodexModelListResponse; const models = modelResponse?.data ?? []; @@ -1616,6 +2186,7 @@ class CodexAppServerAgentSession implements AgentSession { } model = defaultModel.id; } + this.config.model = model; const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID]; const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy; @@ -1681,6 +2252,9 @@ class CodexAppServerAgentSession implements AgentSession { if (parsed.kind === "turn_started") { this.currentTurnId = parsed.turnId; + this.latestTurnUnifiedDiff = null; + this.emittedItemStartedIds.clear(); + this.emittedItemCompletedIds.clear(); this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER }); return; } @@ -1697,6 +2271,9 @@ class CodexAppServerAgentSession implements AgentSession { } else { this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER, usage: this.latestUsage }); } + this.latestTurnUnifiedDiff = null; + this.emittedItemStartedIds.clear(); + this.emittedItemCompletedIds.clear(); this.eventQueue?.end(); return; } @@ -1717,13 +2294,12 @@ class CodexAppServerAgentSession implements AgentSession { } if (parsed.kind === "diff_updated") { - if (parsed.diff.trim().length > 0) { - // NOTE: Codex app-server emits frequent `turn/diff/updated` notifications - // containing a full accumulated unified diff for the *entire turn*. - // This is not a concrete file-change tool call; it is progress telemetry. - // We intentionally do NOT store it in the agent timeline to avoid - // snapshot bloat and relay/WebSocket size limits. - } + const trimmedDiff = parsed.diff.trim(); + this.latestTurnUnifiedDiff = trimmedDiff.length > 0 ? trimmedDiff : null; + // NOTE: Codex app-server emits frequent `turn/diff/updated` notifications + // containing a full accumulated unified diff for the *entire turn*. + // This is not a concrete file-change tool call; it is progress telemetry. + // We intentionally do NOT store every diff update in the timeline. return; } @@ -1745,13 +2321,79 @@ class CodexAppServerAgentSession implements AgentSession { return; } + if (parsed.kind === "exec_command_started") { + const timelineItem = mapCodexExecNotificationToToolCall({ + callId: parsed.callId, + command: parsed.command, + cwd: parsed.cwd ?? this.config.cwd ?? null, + running: true, + }); + if (timelineItem) { + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + } + return; + } + + if (parsed.kind === "exec_command_completed") { + const timelineItem = mapCodexExecNotificationToToolCall({ + callId: parsed.callId, + command: parsed.command, + cwd: parsed.cwd ?? this.config.cwd ?? null, + output: parsed.output, + exitCode: parsed.exitCode, + success: parsed.success, + stderr: parsed.stderr, + running: false, + }); + if (timelineItem) { + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + } + return; + } + + if (parsed.kind === "patch_apply_started") { + const timelineItem = mapCodexPatchNotificationToToolCall({ + callId: parsed.callId, + changes: parsed.changes, + cwd: this.config.cwd ?? null, + latestUnifiedDiff: this.latestTurnUnifiedDiff, + running: true, + }); + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + return; + } + + if (parsed.kind === "patch_apply_completed") { + const timelineItem = mapCodexPatchNotificationToToolCall({ + callId: parsed.callId, + changes: parsed.changes, + cwd: this.config.cwd ?? null, + stdout: parsed.stdout, + stderr: parsed.stderr, + success: parsed.success, + latestUnifiedDiff: this.latestTurnUnifiedDiff, + running: false, + }); + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + return; + } + if (parsed.kind === "item_completed") { + // Codex emits mirrored lifecycle notifications via both `codex/event/item_*` + // and canonical `item/*`. We render only the canonical channel to avoid + // duplicated assistant/reasoning rows. + if (parsed.source === "codex_event") { + return; + } const timelineItem = threadItemToTimeline(parsed.item, { includeUserMessage: false, cwd: this.config.cwd ?? null, }); if (timelineItem) { const itemId = parsed.item.id; + if (itemId && this.emittedItemCompletedIds.has(itemId)) { + return; + } if (timelineItem.type === "assistant_message" && itemId) { const buffered = this.pendingAgentMessages.get(itemId); if (buffered && buffered.length > 0) { @@ -1765,17 +2407,31 @@ class CodexAppServerAgentSession implements AgentSession { } } this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + if (itemId) { + this.emittedItemCompletedIds.add(itemId); + this.emittedItemStartedIds.delete(itemId); + } } return; } if (parsed.kind === "item_started") { + if (parsed.source === "codex_event") { + return; + } const timelineItem = threadItemToTimeline(parsed.item, { includeUserMessage: false, cwd: this.config.cwd ?? null, }); if (timelineItem && timelineItem.type === "tool_call") { + const itemId = parsed.item.id; + if (itemId && this.emittedItemStartedIds.has(itemId)) { + return; + } this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + if (itemId) { + this.emittedItemStartedIds.add(itemId); + } } return; } @@ -1817,6 +2473,12 @@ class CodexAppServerAgentSession implements AgentSession { cwd?: string | null; reason?: string | null; }; + const commandPreview = mapCodexExecNotificationToToolCall({ + callId: parsed.itemId, + command: parsed.command, + cwd: parsed.cwd ?? this.config.cwd ?? null, + running: true, + }); const requestId = `permission-${parsed.itemId}`; const title = parsed.command ? `Run command: ${parsed.command}` : "Run command"; const request: AgentPermissionRequest = { @@ -1830,6 +2492,15 @@ class CodexAppServerAgentSession implements AgentSession { command: parsed.command ?? undefined, cwd: parsed.cwd ?? undefined, }, + detail: + commandPreview?.detail ?? { + type: "unknown", + input: { + command: parsed.command ?? null, + cwd: parsed.cwd ?? null, + }, + output: null, + }, metadata: { itemId: parsed.itemId, threadId: parsed.threadId, @@ -1853,6 +2524,13 @@ class CodexAppServerAgentSession implements AgentSession { kind: "tool", title: "Apply file changes", description: parsed.reason ?? undefined, + detail: { + type: "unknown", + input: { + reason: parsed.reason ?? null, + }, + output: null, + }, metadata: { itemId: parsed.itemId, threadId: parsed.threadId, @@ -1876,6 +2554,13 @@ class CodexAppServerAgentSession implements AgentSession { kind: "tool", title: "Tool action requires approval", description: undefined, + detail: { + type: "unknown", + input: { + questions: Array.isArray(parsed.questions) ? parsed.questions : [], + }, + output: null, + }, metadata: { itemId: parsed.itemId, threadId: parsed.threadId, @@ -2020,12 +2705,21 @@ export class CodexAppServerAgentClient implements AgentClient { const response = (await client.request("model/list", {})) as { data?: Array }; const models = Array.isArray(response?.data) ? response.data : []; + const configuredDefaults = await readCodexConfiguredDefaults(client, this.logger); + const configuredDefaultModelId = configuredDefaults.model; + const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId; + const hasConfiguredDefaultModel = + typeof configuredDefaultModelId === "string" + ? models.some((model) => model?.id === configuredDefaultModelId) + : false; return models.map((model) => { const defaultReasoningEffort = normalizeCodexThinkingOptionId( typeof model.defaultReasoningEffort === "string" ? model.defaultReasoningEffort : null ); + const resolvedDefaultReasoningEffort = + configuredDefaultThinkingOptionId ?? defaultReasoningEffort; const thinkingById = new Map(); if (Array.isArray(model.supportedReasoningEfforts)) { @@ -2042,27 +2736,35 @@ export class CodexAppServerAgentClient implements AgentClient { } } - if (defaultReasoningEffort && !thinkingById.has(defaultReasoningEffort)) { - thinkingById.set(defaultReasoningEffort, { - id: defaultReasoningEffort, - label: defaultReasoningEffort, - description: "Model default reasoning effort", + if (resolvedDefaultReasoningEffort && !thinkingById.has(resolvedDefaultReasoningEffort)) { + thinkingById.set(resolvedDefaultReasoningEffort, { + id: resolvedDefaultReasoningEffort, + label: resolvedDefaultReasoningEffort, + description: + configuredDefaultThinkingOptionId === resolvedDefaultReasoningEffort + ? "Configured default reasoning effort" + : "Model default reasoning effort", }); } const thinkingOptions = Array.from(thinkingById.values()).map((option) => ({ ...option, - isDefault: option.id === defaultReasoningEffort, + isDefault: option.id === resolvedDefaultReasoningEffort, })); const defaultThinkingOptionId = - defaultReasoningEffort ?? thinkingOptions.find((option) => option.isDefault)?.id ?? thinkingOptions[0]?.id; + resolvedDefaultReasoningEffort ?? + thinkingOptions.find((option) => option.isDefault)?.id ?? + thinkingOptions[0]?.id; + const isDefaultModel = hasConfiguredDefaultModel + ? model.id === configuredDefaultModelId + : model.isDefault; return { provider: CODEX_PROVIDER, id: model.id, label: model.displayName, description: model.description, - isDefault: model.isDefault, + isDefault: isDefaultModel, thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, defaultThinkingOptionId, metadata: { diff --git a/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts b/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts index 3a28665df..b7fdbf821 100644 --- a/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts +++ b/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts @@ -40,7 +40,7 @@ describe("codex rollout parsing", () => { type: "tool_call", name: "Bash", callId: "call_MhTWDF2mpM4dhbNmHNt6ikDF", - input: { command: "task show cc4ea7d1" }, + detail: { type: "shell", command: "task show cc4ea7d1" }, }); }); @@ -78,8 +78,11 @@ describe("codex rollout parsing", () => { type: "tool_call", name: "Bash", callId: "call_abc123", - input: { command: "echo hello" }, - output: expect.stringContaining("hello"), + detail: { + type: "shell", + command: "echo hello", + output: "hello", + }, }); }); @@ -143,7 +146,7 @@ describe("codex rollout parsing", () => { type: "tool_call", name: "Bash", callId: "call_shell123", - input: { command: "ls -la" }, + detail: { type: "shell", command: "ls -la" }, }); }); }); @@ -249,11 +252,61 @@ describe("codex rollout parsing", () => { type: "tool_call", name: "Bash", callId: "call_legacy_1", - input: { command: "echo hello" }, - output: "hello", + detail: { type: "shell", command: "echo hello", output: "hello" }, }); }); + test("parses custom_tool_call apply_patch with input/output into editable tool detail", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const patch = [ + "*** Begin Patch", + "*** Add File: src/new-file.ts", + "+export const value = 1;", + "*** End Patch", + ].join("\n"); + const lines = [ + JSON.stringify({ + timestamp: "2026-02-09T10:00:00.000Z", + type: "response_item", + payload: { + type: "custom_tool_call", + name: "apply_patch", + call_id: "call_patch_custom_1", + input: patch, + }, + }), + JSON.stringify({ + timestamp: "2026-02-09T10:00:01.000Z", + type: "response_item", + payload: { + type: "custom_tool_call_output", + call_id: "call_patch_custom_1", + output: '{"output":"Success. Updated the following files:\\nA src/new-file.ts\\n","metadata":{"exit_code":0}}', + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls.length).toBe(1); + + const patchCall = toolCalls[0]; + expect(patchCall).toMatchObject({ + type: "tool_call", + name: "apply_patch", + callId: "call_patch_custom_1", + status: "completed", + }); + expect(patchCall.detail.type).toBe("edit"); + if (patchCall.detail.type === "edit") { + expect(patchCall.detail.filePath).toBe("src/new-file.ts"); + expect(patchCall.detail.unifiedDiff).toContain("diff --git"); + expect(patchCall.detail.unifiedDiff).toContain("+export const value = 1;"); + expect(patchCall.detail.unifiedDiff).not.toContain("*** Begin Patch"); + } + }); + test("parses legacy event_msg shape using msg", async () => { const rolloutPath = join(tmpDir, "rollout.jsonl"); const lines = [ @@ -289,6 +342,70 @@ describe("codex rollout parsing", () => { expect(timeline).toContainEqual({ type: "assistant_message", text: "done" }); expect(timeline).toContainEqual({ type: "user_message", text: "question" }); }); + + test("deduplicates mirrored response_item and event_msg text records", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.000Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "question" }], + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.001Z", + type: "event_msg", + payload: { + type: "user_message", + message: "question", + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.010Z", + type: "response_item", + payload: { + type: "reasoning", + content: [{ type: "reasoning_text", text: "thinking" }], + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.011Z", + type: "event_msg", + payload: { + type: "agent_reasoning", + text: "thinking", + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.020Z", + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "answer" }], + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.021Z", + type: "event_msg", + payload: { + type: "agent_message", + message: "answer", + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + expect(timeline).toEqual([ + { type: "user_message", text: "question" }, + { type: "reasoning", text: "thinking" }, + { type: "assistant_message", text: "answer" }, + ]); + }); }); describe("complex conversation", () => { @@ -362,8 +479,11 @@ describe("codex rollout parsing", () => { expect(timeline[2]).toMatchObject({ type: "tool_call", name: "Bash", - input: { command: "npm test" }, - output: expect.stringContaining("All tests passed!"), + detail: { + type: "shell", + command: "npm test", + output: "All tests passed!", + }, }); expect(timeline[3]).toMatchObject({ type: "assistant_message", diff --git a/packages/server/src/server/agent/providers/codex-rollout-timeline.ts b/packages/server/src/server/agent/providers/codex-rollout-timeline.ts index 5400c11cb..acc383d4a 100644 --- a/packages/server/src/server/agent/providers/codex-rollout-timeline.ts +++ b/packages/server/src/server/agent/providers/codex-rollout-timeline.ts @@ -95,21 +95,34 @@ const RolloutResponseFunctionCallPayloadSchema = z.object({ type: z.literal("function_call"), name: z.string().optional(), call_id: z.string().optional(), - arguments: z.string().optional(), + arguments: z.unknown().optional(), }); -const RolloutResponseCustomToolCallPayloadSchema = z.object({ - type: z.literal("custom_tool_call"), - name: z.string().optional(), - call_id: z.string().optional(), - arguments: z.string().optional(), -}); +const RolloutResponseCustomToolCallPayloadSchema = z + .object({ + type: z.literal("custom_tool_call"), + name: z.string().optional(), + call_id: z.string().optional(), + arguments: z.unknown().optional(), + input: z.unknown().optional(), + }) + .passthrough(); -const RolloutResponseFunctionCallOutputPayloadSchema = z.object({ - type: z.literal("function_call_output"), - call_id: z.string().optional(), - output: z.string().optional(), -}); +const RolloutResponseFunctionCallOutputPayloadSchema = z + .object({ + type: z.literal("function_call_output"), + call_id: z.string().optional(), + output: z.unknown().optional(), + }) + .passthrough(); + +const RolloutResponseCustomToolCallOutputPayloadSchema = z + .object({ + type: z.literal("custom_tool_call_output"), + call_id: z.string().optional(), + output: z.unknown().optional(), + }) + .passthrough(); const RolloutEventAgentReasoningPayloadSchema = z.object({ type: z.literal("agent_reasoning"), @@ -154,7 +167,7 @@ type RolloutResponseReasoningPayload = z.infer< type ParsedRolloutRecord = | { kind: "timeline"; item: AgentTimelineItem } | { kind: "call"; name: string; callId?: string; input?: unknown } - | { kind: "output"; callId: string; output: string } + | { kind: "output"; callId: string; output: unknown } | { kind: "ignore" }; const RolloutMessageContentSchema = z @@ -252,6 +265,22 @@ function parseJsonLikeString(value: string): unknown { } } +function parseJsonLikeValue(value: unknown): unknown { + if (typeof value === "string") { + return parseJsonLikeString(value); + } + return value; +} + +function readOutputPayloadValue(payload: Record): unknown { + if (payload.output !== undefined) { + return parseJsonLikeValue(payload.output); + } + + const { type: _type, call_id: _callId, ...rest } = payload; + return Object.keys(rest).length > 0 ? rest : undefined; +} + const FunctionCallInputNormalizationSchema = z .union([ z.object({ cmd: z.string() }).transform((input) => ({ name: "Bash", input: { command: input.cmd } })), @@ -284,7 +313,8 @@ const RolloutResponseRecordSchema = z ]) .transform((payload): ParsedRolloutRecord => { const rawName = payload.name ?? "unknown"; - const parsedArguments = payload.arguments ? parseJsonLikeString(payload.arguments) : undefined; + const rawInput = payload.arguments ?? ("input" in payload ? payload.input : undefined); + const parsedArguments = parseJsonLikeValue(rawInput); const normalized = rawName === "exec_command" || rawName === "shell" ? FunctionCallInputNormalizationSchema.parse(parsedArguments) @@ -299,12 +329,20 @@ const RolloutResponseRecordSchema = z input: normalized.input, }; }), - RolloutResponseFunctionCallOutputPayloadSchema.transform( - (payload): ParsedRolloutRecord => - payload.call_id && payload.output - ? { kind: "output", callId: payload.call_id, output: payload.output } - : { kind: "ignore" } - ), + z + .union([ + RolloutResponseFunctionCallOutputPayloadSchema, + RolloutResponseCustomToolCallOutputPayloadSchema, + ]) + .transform((payload): ParsedRolloutRecord => { + if (!payload.call_id) { + return { kind: "ignore" }; + } + const output = readOutputPayloadValue(payload); + return output !== undefined + ? { kind: "output", callId: payload.call_id, output } + : { kind: "ignore" }; + }), z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })), ]); @@ -391,6 +429,37 @@ function parseJsonRolloutTimeline( return timeline; } +function timelineTextFingerprint(item: AgentTimelineItem): string | null { + switch (item.type) { + case "user_message": + case "assistant_message": + case "reasoning": + return `${item.type}\u0000${item.text}`; + default: + return null; + } +} + +function dedupeMirroredTextTimelineItems( + timeline: AgentTimelineItem[] +): AgentTimelineItem[] { + const deduped: AgentTimelineItem[] = []; + for (const item of timeline) { + const prev = deduped[deduped.length - 1]; + if (!prev) { + deduped.push(item); + continue; + } + const currentFingerprint = timelineTextFingerprint(item); + const previousFingerprint = timelineTextFingerprint(prev); + if (currentFingerprint && previousFingerprint && currentFingerprint === previousFingerprint) { + continue; + } + deduped.push(item); + } + return deduped; +} + export async function parseRolloutFile( filePath: string ): Promise { @@ -403,7 +472,7 @@ export async function parseRolloutFile( const parsed = JSON.parse(trimmed); const jsonTimeline = parseJsonRolloutTimeline(parsed); if (jsonTimeline) { - return jsonTimeline; + return dedupeMirroredTextTimelineItems(jsonTimeline); } } catch { // Fall back to JSONL parsing. @@ -428,9 +497,9 @@ export async function parseRolloutFile( const outputsByCallId = parsedRecords .filter((record): record is Extract => record.kind === "output") - .reduce((map, record) => map.set(record.callId, record.output), new Map()); + .reduce((map, record) => map.set(record.callId, record.output), new Map()); - return parsedRecords.flatMap((record): AgentTimelineItem[] => + const timeline = parsedRecords.flatMap((record): AgentTimelineItem[] => record.kind === "timeline" ? [record.item] : record.kind === "call" @@ -444,6 +513,7 @@ export async function parseRolloutFile( ] : [] ); + return dedupeMirroredTextTimelineItems(timeline); } export type CodexPersistedTimelineOptions = { diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index c8bfd1db4..cea63ed50 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -27,6 +27,63 @@ describe("codex tool-call mapper", () => { }); }); + it("unwraps shell wrapper arrays for commandExecution", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "commandExecution", + id: "codex-call-wrapper-array", + status: "running", + command: ["/bin/zsh", "-lc", "echo hello"], + cwd: "/tmp/repo", + }); + + expect(item?.detail).toEqual({ + type: "shell", + command: "echo hello", + cwd: "/tmp/repo", + }); + }); + + it("unwraps shell wrapper strings for commandExecution", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "commandExecution", + id: "codex-call-wrapper-string", + status: "running", + command: '/bin/zsh -lc "echo hello"', + cwd: "/tmp/repo", + }); + + expect(item?.detail).toEqual({ + type: "shell", + command: "echo hello", + cwd: "/tmp/repo", + }); + }); + + it("keeps only command output body when commandExecution output is wrapped in shell envelope", () => { + const item = mapCodexToolCallFromThreadItem({ + type: "commandExecution", + id: "codex-call-envelope-output", + status: "completed", + command: "echo hello", + cwd: "/tmp/repo", + aggregatedOutput: + "Chunk ID: e87d40\nWall time: 0.0521 seconds\nProcess exited with code 0\nOriginal token count: 192\nOutput:\n214 export type AgentPermissionRequestKind = \"tool\";", + exitCode: 0, + }); + + expect(item?.detail?.type).toBe("shell"); + if (item?.detail?.type === "shell") { + expect(item.detail.output).toBe( + "214 export type AgentPermissionRequestKind = \"tool\";" + ); + expect(item.detail.output).not.toContain("Chunk ID:"); + expect(item.detail.output).not.toContain("Wall time:"); + expect(item.detail.output).not.toContain("Process exited with code"); + expect(item.detail.output).not.toContain("Original token count:"); + expect(item.detail.output).not.toContain("Output:"); + } + }); + it("maps running known tool variants with detail for early summaries", () => { const readItem = mapCodexToolCallFromThreadItem( { @@ -159,6 +216,26 @@ describe("codex tool-call mapper", () => { } }); + it("maps fileChange content fallback into editable text when unified diff is absent", () => { + const item = mapCodexToolCallFromThreadItem( + { + type: "fileChange", + id: "codex-content-1", + status: "completed", + changes: [{ path: "/tmp/repo/src/content-only.ts", kind: "modify", content: "line one\nline two\n" }], + }, + { cwd: "/tmp/repo" } + ); + + expect(item).toBeTruthy(); + expect(item?.detail?.type).toBe("edit"); + if (item?.detail?.type === "edit") { + expect(item.detail.filePath).toBe("src/content-only.ts"); + expect(item.detail.newString).toContain("line one"); + expect(item.detail.unifiedDiff).toBeUndefined(); + } + }); + it("maps write/edit/search known variants with distinct detail types", () => { const writeItem = mapCodexToolCallFromThreadItem( { @@ -240,4 +317,98 @@ describe("codex tool-call mapper", () => { }); expect(item.callId).toBe("codex-call-4"); }); + + it("maps apply_patch rollout calls with raw patch input into edit detail", () => { + const patch = [ + "*** Begin Patch", + "*** Update File: /tmp/repo/src/index.ts", + "@@", + "-old", + "+new", + "*** End Patch", + ].join("\n"); + const item = mapCodexRolloutToolCall({ + callId: "codex-call-apply", + name: "apply_patch", + input: patch, + output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}', + cwd: "/tmp/repo", + }); + + expect(item.status).toBe("completed"); + expect(item.error).toBeNull(); + expect(item.detail.type).toBe("edit"); + if (item.detail.type === "edit") { + expect(item.detail.filePath).toBe("src/index.ts"); + expect(item.detail.unifiedDiff).toContain("diff --git"); + expect(item.detail.unifiedDiff).toContain("@@"); + expect(item.detail.unifiedDiff).toContain("-old"); + expect(item.detail.unifiedDiff).toContain("+new"); + expect(item.detail.unifiedDiff).not.toContain("*** Begin Patch"); + expect(item.detail.newString).toBeUndefined(); + } + }); + + it("maps apply_patch object content payloads into unified diff detail", () => { + const patch = [ + "*** Begin Patch", + "*** Update File: /tmp/repo/src/object.ts", + "@@", + "-before", + "+after", + "*** End Patch", + ].join("\n"); + + const item = mapCodexRolloutToolCall({ + callId: "codex-call-apply-object", + name: "apply_patch", + input: { + path: "/tmp/repo/src/object.ts", + content: patch, + }, + output: null, + cwd: "/tmp/repo", + }); + + expect(item.detail.type).toBe("edit"); + if (item.detail.type === "edit") { + expect(item.detail.filePath).toBe("src/object.ts"); + expect(item.detail.unifiedDiff).toContain("diff --git"); + expect(item.detail.unifiedDiff).toContain("@@"); + expect(item.detail.unifiedDiff).toContain("-before"); + expect(item.detail.unifiedDiff).toContain("+after"); + expect(item.detail.unifiedDiff).not.toContain("*** Begin Patch"); + expect(item.detail.newString).toBeUndefined(); + } + }); + + it("maps fileChange content that contains codex patch envelopes as unified diffs", () => { + const patch = [ + "*** Begin Patch", + "*** Update File: /tmp/repo/src/from-file-change.ts", + "@@", + "-alpha", + "+beta", + "*** End Patch", + ].join("\n"); + + const item = mapCodexToolCallFromThreadItem( + { + type: "fileChange", + id: "codex-file-change-patch-content", + status: "completed", + changes: [{ path: "/tmp/repo/src/from-file-change.ts", kind: "modify", content: patch }], + }, + { cwd: "/tmp/repo" } + ); + + expect(item?.detail?.type).toBe("edit"); + if (item?.detail?.type === "edit") { + expect(item.detail.filePath).toBe("src/from-file-change.ts"); + expect(item.detail.unifiedDiff).toContain("-alpha"); + expect(item.detail.unifiedDiff).toContain("+beta"); + expect(item.detail.unifiedDiff).not.toContain("*** Begin Patch"); + expect(item.detail.newString).toBeUndefined(); + } + }); }); diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 471e5e85d..bcdfd6a89 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -4,7 +4,7 @@ import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types import { CommandValueSchema } from "../tool-call-detail-primitives.js"; import { coerceToolCallId, - commandFromValue, + extractCodexShellOutput, truncateDiffText, } from "../tool-call-mapper-utils.js"; import { @@ -59,6 +59,7 @@ const CodexFileChangeItemSchema = z path: z.string().optional(), kind: z.string().optional(), diff: z.string().optional(), + content: z.string().optional(), }) .passthrough() ) @@ -108,6 +109,213 @@ function coerceCallId(raw: string | null | undefined, name: string, input: unkno }); } +function maybeUnwrapShellWrapperCommand(command: string): string { + const trimmed = command.trim(); + const wrapperMatch = trimmed.match( + /^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/ + ); + if (!wrapperMatch) { + return trimmed; + } + const candidate = wrapperMatch[1]?.trim() ?? ""; + if (!candidate) { + return trimmed; + } + if ( + (candidate.startsWith('"') && candidate.endsWith('"')) || + (candidate.startsWith("'") && candidate.endsWith("'")) + ) { + return candidate.slice(1, -1); + } + return candidate; +} + +function normalizeCommandExecutionCommand(value: unknown): string | undefined { + if (typeof value === "string") { + const normalized = maybeUnwrapShellWrapperCommand(value); + return normalized.length > 0 ? normalized : undefined; + } + if (!Array.isArray(value)) { + return undefined; + } + const parts = value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (parts.length === 0) { + return undefined; + } + if (parts.length >= 3 && (parts[1] === "-lc" || parts[1] === "-c")) { + const unwrapped = parts[2]?.trim(); + return unwrapped && unwrapped.length > 0 ? unwrapped : undefined; + } + return parts.join(" "); +} + +function looksLikeUnifiedDiff(text: string): boolean { + const normalized = text.trimStart(); + if (!normalized) { + return false; + } + return ( + normalized.startsWith("diff --git") || + normalized.startsWith("@@") || + normalized.startsWith("--- ") || + normalized.startsWith("+++ ") + ); +} + +type CodexApplyPatchDirective = { + kind: "add" | "update" | "delete"; + path: string; +}; + +function parseCodexApplyPatchDirective(line: string): CodexApplyPatchDirective | null { + const trimmed = line.trim(); + if (trimmed.startsWith("*** Add File:")) { + return { kind: "add", path: trimmed.replace("*** Add File:", "").trim() }; + } + if (trimmed.startsWith("*** Update File:")) { + return { kind: "update", path: trimmed.replace("*** Update File:", "").trim() }; + } + if (trimmed.startsWith("*** Delete File:")) { + return { kind: "delete", path: trimmed.replace("*** Delete File:", "").trim() }; + } + return null; +} + +function looksLikeCodexApplyPatch(text: string): boolean { + const normalized = text.trimStart(); + if (!normalized) { + return false; + } + if (normalized.startsWith("*** Begin Patch")) { + return true; + } + return text.split(/\r?\n/).some((line) => parseCodexApplyPatchDirective(line) !== null); +} + +function normalizeDiffHeaderPath(rawPath: string): string { + return rawPath.trim().replace(/^["']+|["']+$/g, ""); +} + +function codexApplyPatchToUnifiedDiff(text: string): string { + const lines = text.replace(/\r\n/g, "\n").split("\n"); + const output: string[] = []; + let sawDiffBody = false; + + for (const line of lines) { + const directive = parseCodexApplyPatchDirective(line); + if (directive) { + const path = normalizeDiffHeaderPath(directive.path); + if (path.length > 0) { + if (output.length > 0 && output[output.length - 1] !== "") { + output.push(""); + } + const left = directive.kind === "add" ? "/dev/null" : `a/${path}`; + const right = directive.kind === "delete" ? "/dev/null" : `b/${path}`; + output.push(`diff --git a/${path} b/${path}`); + output.push(`--- ${left}`); + output.push(`+++ ${right}`); + } + continue; + } + + const trimmed = line.trim(); + if ( + trimmed === "*** Begin Patch" || + trimmed === "*** End Patch" || + trimmed === "*** End of File" || + trimmed.startsWith("*** Move to:") + ) { + continue; + } + + if (line.startsWith("@@")) { + output.push(line); + sawDiffBody = true; + continue; + } + if (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) { + output.push(line); + sawDiffBody = true; + continue; + } + if (line.startsWith("\\ No newline at end of file")) { + output.push(line); + sawDiffBody = true; + continue; + } + } + + if (!sawDiffBody) { + return text; + } + + const normalized = output.join("\n").trim(); + return normalized.length > 0 ? normalized : text; +} + +function classifyDiffLikeText( + text: string +): { isDiff: true; text: string } | { isDiff: false; text: string } { + if (looksLikeUnifiedDiff(text)) { + return { isDiff: true, text }; + } + if (looksLikeCodexApplyPatch(text)) { + return { isDiff: true, text: codexApplyPatchToUnifiedDiff(text) }; + } + return { isDiff: false, text }; +} + +function asEditTextFields( + text: string | undefined +): { unifiedDiff?: string; newString?: string } { + if (typeof text !== "string" || text.length === 0) { + return {}; + } + const classified = classifyDiffLikeText(text); + if (classified.isDiff) { + return { unifiedDiff: truncateDiffText(classified.text) }; + } + return { newString: text }; +} + +function asEditFileOutputFields( + text: string | undefined +): { patch?: string; content?: string } { + if (typeof text !== "string" || text.length === 0) { + return {}; + } + const classified = classifyDiffLikeText(text); + if (classified.isDiff) { + return { patch: truncateDiffText(classified.text) }; + } + return { content: text }; +} + +function asPatchOrContentFields(text: string | undefined): { patch?: string; content?: string } { + if (typeof text !== "string" || text.length === 0) { + return {}; + } + const classified = classifyDiffLikeText(text); + if (classified.isDiff) { + return { patch: truncateDiffText(classified.text) }; + } + return { content: text }; +} + +function removePatchLikeFields(input: Record): Record { + const { + patch: _patch, + diff: _diff, + unified_diff: _unifiedDiffSnake, + unifiedDiff: _unifiedDiffCamel, + ...rest + } = input; + return rest; +} + function resolveStatus( rawStatus: string | undefined, error: unknown, @@ -189,20 +397,121 @@ function toNullableObject(value: Record): Record 0 ? value : null; } +function extractPatchPrimaryFilePath(patch: string): string | undefined { + for (const line of patch.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.startsWith("*** Add File:")) { + return trimmed.replace("*** Add File:", "").trim(); + } + if (trimmed.startsWith("*** Update File:")) { + return trimmed.replace("*** Update File:", "").trim(); + } + if (trimmed.startsWith("*** Delete File:")) { + return trimmed.replace("*** Delete File:", "").trim(); + } + } + return undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeApplyPatchInput(input: unknown): unknown { + if (typeof input === "string") { + const filePath = extractPatchPrimaryFilePath(input); + const textFields = asPatchOrContentFields(input); + return filePath ? { path: filePath, ...textFields } : textFields; + } + + if (!isRecord(input)) { + return input; + } + + const existingPath = + (typeof input.path === "string" && input.path.trim().length > 0 && input.path.trim()) || + (typeof input.file_path === "string" && + input.file_path.trim().length > 0 && + input.file_path.trim()) || + (typeof input.filePath === "string" && + input.filePath.trim().length > 0 && + input.filePath.trim()); + const patchText = + (typeof input.patch === "string" && input.patch) || + (typeof input.diff === "string" && input.diff) || + (typeof input.unified_diff === "string" && input.unified_diff) || + (typeof input.unifiedDiff === "string" && input.unifiedDiff) || + undefined; + const contentText = typeof input.content === "string" ? input.content : undefined; + const inferredPatchFromContent = !patchText && typeof contentText === "string" ? contentText : undefined; + const patchOrContentText = patchText ?? inferredPatchFromContent; + + if (existingPath && !patchOrContentText) { + return input; + } + + if (!patchOrContentText) { + return input; + } + + const base = removePatchLikeFields(input); + if (inferredPatchFromContent) { + delete (base as { content?: unknown }).content; + } + const filePath = existingPath || extractPatchPrimaryFilePath(patchOrContentText); + const textFields = asPatchOrContentFields(patchOrContentText); + return filePath ? { ...base, path: filePath, ...textFields } : { ...base, ...textFields }; +} + +function deriveApplyPatchDetailFromInput( + input: unknown, + cwd: string | null | undefined +): ToolCallDetail | null { + if (!isRecord(input)) { + return null; + } + + const pathValue = + (typeof input.path === "string" && input.path.trim()) || + (typeof input.file_path === "string" && input.file_path.trim()) || + (typeof input.filePath === "string" && input.filePath.trim()) || + ""; + if (!pathValue) { + return null; + } + + const normalizedPath = normalizeCodexFilePath(pathValue, cwd) ?? pathValue; + const diffText = + (typeof input.patch === "string" && input.patch) || + (typeof input.diff === "string" && input.diff) || + (typeof input.unified_diff === "string" && input.unified_diff) || + (typeof input.unifiedDiff === "string" && input.unifiedDiff) || + (typeof input.content === "string" && input.content) || + undefined; + + const textFields = asEditTextFields(diffText); + return { + type: "edit", + filePath: normalizedPath, + ...textFields, + }; +} + function mapCommandExecutionItem( item: z.infer ): ToolCallTimelineItem { - const command = item.command ? commandFromValue(item.command) : undefined; + const command = normalizeCommandExecutionCommand(item.command); + const parsedOutput = extractCodexShellOutput(item.aggregatedOutput); const input = toNullableObject({ ...(command !== undefined ? { command } : {}), ...(item.cwd !== undefined ? { cwd: item.cwd } : {}), }); const output = - item.aggregatedOutput !== undefined || item.exitCode !== undefined + parsedOutput !== undefined || item.exitCode !== undefined ? { ...(command !== undefined ? { command } : {}), - ...(item.aggregatedOutput !== undefined ? { output: item.aggregatedOutput } : {}), + ...(parsedOutput !== undefined ? { output: parsedOutput } : {}), ...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}), } : null; @@ -212,7 +521,7 @@ function mapCommandExecutionItem( type: "shell" as const, command, ...(item.cwd ? { cwd: item.cwd } : {}), - ...(item.aggregatedOutput ? { output: item.aggregatedOutput } : {}), + ...(parsedOutput ? { output: parsedOutput } : {}), ...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}), } : { @@ -251,7 +560,7 @@ function mapFileChangeItem( return { path: pathValue, kind: change.kind, - diff: change.diff, + diff: change.diff ?? change.content, }; }) .filter((change) => change.path !== undefined); @@ -273,18 +582,19 @@ function mapFileChangeItem( files: files.map((file) => ({ path: file.path, ...(file.kind !== undefined ? { kind: file.kind } : {}), - ...(file.diff !== undefined ? { patch: truncateDiffText(file.diff) } : {}), + ...asEditFileOutputFields(file.diff), })), } : {}), }); const firstFile = files[0]; + const firstTextFields = asEditTextFields(firstFile?.diff); const detail = firstFile?.path ? { type: "edit" as const, filePath: firstFile.path, - ...(firstFile.diff !== undefined ? { unifiedDiff: truncateDiffText(firstFile.diff) } : {}), + ...firstTextFields, } : { type: "unknown" as const, @@ -400,19 +710,31 @@ export function mapCodexRolloutToolCall(params: { input?: unknown; output?: unknown; error?: unknown; + cwd?: string | null; }): ToolCallTimelineItem { const parsed = CodexRolloutToolCallParamsSchema.parse(params); - const input = parsed.input ?? null; + const rawInput = parsed.input ?? null; + const normalizedName = parsed.name.trim().toLowerCase(); + const input = + normalizedName === "apply_patch" || normalizedName === "apply_diff" + ? normalizeApplyPatchInput(rawInput) + : rawInput; const output = parsed.output ?? null; const error = parsed.error ?? null; const status = resolveStatus("completed", error, output); const callId = coerceCallId(parsed.callId, parsed.name, input); - const detail = deriveCodexToolDetail({ + let detail = deriveCodexToolDetail({ name: parsed.name, input, output, - cwd: null, + cwd: params.cwd ?? null, }); + if (detail.type === "unknown" && (normalizedName === "apply_patch" || normalizedName === "apply_diff")) { + const fallbackDetail = deriveApplyPatchDetailFromInput(input, params.cwd ?? null); + if (fallbackDetail) { + detail = fallbackDetail; + } + } return buildToolCall({ callId, diff --git a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts index a04e6b22f..39c184b91 100644 --- a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts +++ b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import type { ToolCallDetail } from "../agent-sdk-types.js"; import { commandFromValue, + extractCodexShellOutput, flattenReadContent as flattenToolReadContent, nonEmptyString, truncateDiffText, @@ -83,12 +84,11 @@ const ToolShellOutputObjectSchema = z export const ToolShellOutputSchema = z.union([ z.string().transform((value) => ({ command: undefined, - output: nonEmptyString(value), + output: extractCodexShellOutput(value), exitCode: undefined, })), - ToolShellOutputObjectSchema.transform((value) => ({ - command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command), - output: + ToolShellOutputObjectSchema.transform((value) => { + const rawOutput = nonEmptyString(value.output) ?? nonEmptyString(value.text) ?? nonEmptyString(value.content) ?? @@ -102,14 +102,19 @@ export const ToolShellOutputSchema = z.union([ nonEmptyString(value.structured_content?.content) ?? nonEmptyString(value.result?.output) ?? nonEmptyString(value.result?.text) ?? - nonEmptyString(value.result?.content), - exitCode: - value.exitCode ?? - value.exit_code ?? - value.metadata?.exitCode ?? - value.metadata?.exit_code ?? - undefined, - })), + nonEmptyString(value.result?.content); + + return { + command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command), + output: extractCodexShellOutput(rawOutput), + exitCode: + value.exitCode ?? + value.exit_code ?? + value.metadata?.exitCode ?? + value.metadata?.exit_code ?? + undefined, + }; + }), ]); export const ToolPathInputSchema = z.union([ diff --git a/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts index bfb4b0272..443aa22da 100644 --- a/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts +++ b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts @@ -21,6 +21,70 @@ export function commandFromValue(value: unknown): string | undefined { return tokens.length > 0 ? tokens.join(" ") : undefined; } +const CODEX_SHELL_ENVELOPE_HEADER_LINES = new Set([ + "chunk id:", + "wall time:", + "process exited with code", + "original token count:", +]); + +function isCodexShellEnvelopeHeaderLine(line: string): boolean { + const normalized = line.trim().toLowerCase(); + for (const prefix of CODEX_SHELL_ENVELOPE_HEADER_LINES) { + if (normalized.startsWith(prefix)) { + return true; + } + } + return false; +} + +function looksLikeCodexShellEnvelope(lines: string[]): boolean { + if (lines.length === 0) { + return false; + } + const first = lines[0]?.trim().toLowerCase() ?? ""; + if (!first.startsWith("chunk id:")) { + return false; + } + + const headerWindow = lines.slice(0, 8).map((line) => line.trim().toLowerCase()); + const hasWallTime = headerWindow.some((line) => line.startsWith("wall time:")); + const hasExitCode = headerWindow.some((line) => line.startsWith("process exited with code")); + return hasWallTime && hasExitCode; +} + +export function extractCodexShellOutput(value: string | undefined): string | undefined { + const text = nonEmptyString(value); + if (!text) { + return undefined; + } + + const normalized = text.replace(/\r\n/g, "\n"); + const lines = normalized.split("\n"); + if (!looksLikeCodexShellEnvelope(lines)) { + return text; + } + + const outputLineIndex = lines.findIndex((line) => line.trim() === "Output:"); + if (outputLineIndex >= 0) { + return nonEmptyString(lines.slice(outputLineIndex + 1).join("\n")); + } + + let firstBodyLineIndex = -1; + for (let index = 1; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (!isCodexShellEnvelopeHeaderLine(line)) { + firstBodyLineIndex = index; + break; + } + } + + if (firstBodyLineIndex === -1) { + return undefined; + } + return nonEmptyString(lines.slice(firstBodyLineIndex).join("\n")); +} + export function flattenReadContent( value: string | Chunk | Chunk[] | undefined ): string | undefined { diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 449086e48..79f5b40a9 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -770,6 +770,110 @@ describe("daemon client E2E", () => { 90_000 ); + speechTest( + "voice mode flushes buffered audio after inactivity when isLast is missing", + async () => { + const voiceCwd = tmpCwd(); + const voiceAgent = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd: voiceCwd, + }, + }); + await ctx.client.setVoiceMode(true, voiceAgent.id); + + const transcription = waitForSignal(40_000, (resolve) => { + const unsubscribe = ctx.client.on("transcription_result", (message) => { + if (message.type !== "transcription_result") { + return; + } + resolve(message.payload); + }); + return unsubscribe; + }); + + const errorSignal = waitForSignal(40_000, (resolve) => { + const unsubscribeStatus = ctx.client.on("status", (message) => { + if (message.type !== "status") { + return; + } + if (message.payload.status !== "error") { + return; + } + resolve(`status:error ${message.payload.message}`); + }); + + const unsubscribeLog = ctx.client.on("activity_log", (message) => { + if (message.type !== "activity_log") { + return; + } + if (message.payload.type !== "error") { + return; + } + resolve(`activity_log:error ${message.payload.content}`); + }); + + return () => { + unsubscribeStatus(); + unsubscribeLog(); + }; + }); + + try { + const fixturePath = path.resolve( + process.cwd(), + "..", + "app", + "e2e", + "fixtures", + "recording.wav" + ); + const wav = await import("node:fs/promises").then((fs) => fs.readFile(fixturePath)); + const { sampleRate, pcm16 } = parsePcm16MonoWav(wav); + expect(sampleRate).toBe(16000); + + const format = "audio/pcm;rate=16000;bits=16"; + const chunkBytes = 3200; // 100ms @ 16kHz mono PCM16 + const maxChunksWithoutLast = 25; + + let sentChunks = 0; + for ( + let offset = 0; + offset < pcm16.length && sentChunks < maxChunksWithoutLast; + offset += chunkBytes + ) { + const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); + await ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, false); + sentChunks += 1; + } + + const outcome = await Promise.race([ + transcription.then((payload) => ({ kind: "ok" as const, payload })), + errorSignal.then((error) => ({ kind: "error" as const, error })), + ]); + + if (outcome.kind === "error") { + throw new Error(outcome.error); + } + + expect(typeof outcome.payload.text).toBe("string"); + if (outcome.payload.byteLength !== undefined) { + expect(outcome.payload.byteLength).toBeGreaterThan(0); + } + if (outcome.payload.text.trim().length > 0) { + expect(outcome.payload.text.trim().length).toBeGreaterThan(1); + } else { + expect(outcome.payload.isLowConfidence).toBe(true); + } + } finally { + await Promise.allSettled([transcription, errorSignal]); + await ctx.client.setVoiceMode(false); + rmSync(voiceCwd, { recursive: true, force: true }); + } + }, + 90_000 + ); + speechTest( "streams dictation PCM and returns final transcript", async () => { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index dfebb2e2c..96b067a99 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -31,6 +31,7 @@ import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js"; import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js"; import { DictationStreamManager, + type DictationStreamOutboundMessage, } from "./dictation/dictation-stream-manager.js"; import { buildConfigOverrides, @@ -241,6 +242,8 @@ const MIN_STREAMING_SEGMENT_DURATION_MS = 1000; const MIN_STREAMING_SEGMENT_BYTES = Math.round( PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS ); +const VOICE_MODE_INACTIVITY_FLUSH_MS = 4500; +const VOICE_INTERNAL_DICTATION_ID_PREFIX = "__voice_turn__:"; const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; const AgentIdSchema = z.string().uuid(); const VOICE_MCP_SERVER_NAME = "paseo_voice"; @@ -257,6 +260,18 @@ interface AudioBufferState { totalPCMBytes: number; } +type VoiceTranscriptionResultPayload = { + text: string; + requestId: string; + language?: string; + duration?: number; + avgLogprob?: number; + isLowConfidence?: boolean; + byteLength?: number; + format?: string; + debugRecordingPath?: string; +}; + export type SessionOptions = { clientId: string; onMessage: (msg: SessionOutboundMessage) => void; @@ -384,11 +399,25 @@ export class Session { private speechInProgress = false; private readonly dictationStreamManager: DictationStreamManager; + private readonly voiceStreamManager: DictationStreamManager; // Audio buffering for interruption handling private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = []; private bufferTimeout: NodeJS.Timeout | null = null; + private voiceModeInactivityTimeout: NodeJS.Timeout | null = null; private audioBuffer: AudioBufferState | null = null; + private activeVoiceDictationId: string | null = null; + private activeVoiceDictationFormat: string | null = null; + private activeVoiceDictationNextSeq = 0; + private activeVoiceDictationStartPromise: Promise | null = null; + private activeVoiceDictationFinalizePromise: Promise | null = null; + private activeVoiceDictationResultPromise: + | Promise<{ text: string; debugRecordingPath?: string }> + | null = null; + private activeVoiceDictationResolve: + | ((value: { text: string; debugRecordingPath?: string }) => void) + | null = null; + private activeVoiceDictationReject: ((error: Error) => void) | null = null; // Optional TTS debug capture (persisted per utterance) private readonly ttsDebugStreams = new Map< @@ -511,10 +540,17 @@ export class Session { this.dictationStreamManager = new DictationStreamManager({ logger: this.sessionLogger, sessionId: this.sessionId, - emit: (msg) => this.emit(msg as unknown as SessionOutboundMessage), + emit: (msg) => this.handleDictationManagerMessage(msg), stt: dictation?.stt ?? null, finalTimeoutMs: dictation?.finalTimeoutMs, }); + this.voiceStreamManager = new DictationStreamManager({ + logger: this.sessionLogger.child({ stream: "voice-internal" }), + sessionId: this.sessionId, + emit: (msg) => this.handleDictationManagerMessage(msg), + stt: stt, + finalTimeoutMs: dictation?.finalTimeoutMs, + }); // Initialize agent MCP client asynchronously void this.initializeAgentMcp(); @@ -1573,6 +1609,9 @@ export class Session { private async disableVoiceModeForActiveAgent( restoreAgentConfig: boolean ): Promise { + this.clearVoiceModeInactivityTimeout(); + this.cancelActiveVoiceDictationStream("voice mode disabled"); + const agentId = this.voiceModeAgentId; if (!agentId) { this.voiceModeBaseConfig = null; @@ -1607,6 +1646,234 @@ export class Session { this.voiceModeAgentId = null; } + private isInternalVoiceDictationId(dictationId: string): boolean { + return dictationId.startsWith(VOICE_INTERNAL_DICTATION_ID_PREFIX); + } + + private handleDictationManagerMessage(msg: DictationStreamOutboundMessage): void { + if (msg.type === "activity_log") { + const metadata = msg.payload.metadata as { dictationId?: unknown } | undefined; + const dictationId = + metadata && typeof metadata.dictationId === "string" ? metadata.dictationId : null; + if (dictationId && this.isInternalVoiceDictationId(dictationId)) { + return; + } + this.emit(msg as unknown as SessionOutboundMessage); + return; + } + + const payloadWithDictationId = msg.payload as { dictationId?: unknown }; + const dictationId = + payloadWithDictationId && typeof payloadWithDictationId.dictationId === "string" + ? payloadWithDictationId.dictationId + : null; + + if (!dictationId || !this.isInternalVoiceDictationId(dictationId)) { + this.emit(msg as unknown as SessionOutboundMessage); + return; + } + + if (msg.type === "dictation_stream_final") { + if (dictationId !== this.activeVoiceDictationId || !this.activeVoiceDictationResolve) { + return; + } + this.activeVoiceDictationResolve({ + text: msg.payload.text, + ...(msg.payload.debugRecordingPath + ? { debugRecordingPath: msg.payload.debugRecordingPath } + : {}), + }); + return; + } + + if (msg.type === "dictation_stream_error") { + if (dictationId !== this.activeVoiceDictationId || !this.activeVoiceDictationReject) { + return; + } + this.activeVoiceDictationReject(new Error(msg.payload.error)); + return; + } + + // Ack/partial messages for internal voice dictation are consumed server-side. + } + + private resetActiveVoiceDictationState(): void { + this.activeVoiceDictationId = null; + this.activeVoiceDictationFormat = null; + this.activeVoiceDictationNextSeq = 0; + this.activeVoiceDictationStartPromise = null; + this.activeVoiceDictationFinalizePromise = null; + this.activeVoiceDictationResultPromise = null; + this.activeVoiceDictationResolve = null; + this.activeVoiceDictationReject = null; + } + + private cancelActiveVoiceDictationStream(reason: string): void { + const dictationId = this.activeVoiceDictationId; + if (!dictationId) { + return; + } + + this.sessionLogger.debug({ dictationId, reason }, "Cancelling active internal voice dictation stream"); + if (this.activeVoiceDictationReject) { + this.activeVoiceDictationReject(new Error(`Voice dictation cancelled: ${reason}`)); + } + this.voiceStreamManager.handleCancel(dictationId); + this.resetActiveVoiceDictationState(); + } + + private async ensureActiveVoiceDictationStream(format: string): Promise { + if (this.activeVoiceDictationId && this.activeVoiceDictationFormat === format) { + if (this.activeVoiceDictationStartPromise) { + await this.activeVoiceDictationStartPromise; + } + return; + } + + if (this.activeVoiceDictationId) { + await this.finalizeActiveVoiceDictationStream("voice format changed"); + } + + const dictationId = `${VOICE_INTERNAL_DICTATION_ID_PREFIX}${uuidv4()}`; + let resolve: + | ((value: { text: string; debugRecordingPath?: string }) => void) + | null = null; + let reject: ((error: Error) => void) | null = null; + const resultPromise = new Promise<{ text: string; debugRecordingPath?: string }>( + (resolveFn, rejectFn) => { + resolve = resolveFn; + reject = rejectFn; + } + ); + // Prevent process-level unhandled rejection warnings when cancellation races are resolved later. + void resultPromise.catch(() => undefined); + + this.activeVoiceDictationId = dictationId; + this.activeVoiceDictationFormat = format; + this.activeVoiceDictationNextSeq = 0; + this.activeVoiceDictationFinalizePromise = null; + this.activeVoiceDictationResultPromise = resultPromise; + this.activeVoiceDictationResolve = resolve; + this.activeVoiceDictationReject = reject; + this.setPhase("transcribing"); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "system", + content: "Transcribing audio...", + }, + }); + + const startPromise = this.voiceStreamManager.handleStart(dictationId, format); + this.activeVoiceDictationStartPromise = startPromise; + try { + await startPromise; + } catch (error) { + this.resetActiveVoiceDictationState(); + throw error; + } finally { + if (this.activeVoiceDictationId === dictationId) { + this.activeVoiceDictationStartPromise = null; + } + } + } + + private async appendToActiveVoiceDictationStream( + audioBase64: string, + format: string + ): Promise { + if (this.activeVoiceDictationFinalizePromise) { + await this.activeVoiceDictationFinalizePromise.catch(() => undefined); + } + await this.ensureActiveVoiceDictationStream(format); + const dictationId = this.activeVoiceDictationId; + if (!dictationId) { + throw new Error("Voice dictation stream did not initialize"); + } + + const seq = this.activeVoiceDictationNextSeq; + this.activeVoiceDictationNextSeq += 1; + await this.voiceStreamManager.handleChunk({ + dictationId, + seq, + audioBase64, + format, + }); + } + + private async finalizeActiveVoiceDictationStream(reason: string): Promise { + const dictationId = this.activeVoiceDictationId; + if (!dictationId) { + return; + } + this.clearVoiceModeInactivityTimeout(); + if (this.activeVoiceDictationStartPromise) { + await this.activeVoiceDictationStartPromise; + } + + if (this.activeVoiceDictationFinalizePromise) { + await this.activeVoiceDictationFinalizePromise; + return; + } + + const finalSeq = this.activeVoiceDictationNextSeq - 1; + const resultPromise = this.activeVoiceDictationResultPromise; + if (!resultPromise) { + this.resetActiveVoiceDictationState(); + return; + } + + this.activeVoiceDictationFinalizePromise = (async () => { + this.sessionLogger.debug( + { dictationId, finalSeq, reason }, + "Finalizing internal voice dictation stream" + ); + await this.voiceStreamManager.handleFinish(dictationId, finalSeq); + const result = await resultPromise; + this.resetActiveVoiceDictationState(); + const requestId = uuidv4(); + const transcriptText = result.text.trim(); + this.sessionLogger.info( + { + requestId, + isVoiceMode: this.isVoiceMode, + transcriptLength: transcriptText.length, + transcript: transcriptText, + }, + "Transcription result" + ); + await this.handleTranscriptionResultPayload({ + text: result.text, + requestId, + ...(result.debugRecordingPath + ? { debugRecordingPath: result.debugRecordingPath, format: "audio/wav" } + : {}), + }); + })(); + + try { + await this.activeVoiceDictationFinalizePromise; + } catch (error) { + this.resetActiveVoiceDictationState(); + this.setPhase("idle"); + this.clearSpeechInProgress("transcription error"); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Transcription error: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + }); + throw error; + } + } + /** * Handle text message to agent (with optional image attachments) */ @@ -4553,8 +4820,23 @@ export class Session { await this.handleVoiceSpeechStart(); - const chunkBuffer = Buffer.from(msg.audio, "base64"); const chunkFormat = msg.format || "audio/wav"; + + if (this.isVoiceMode) { + await this.appendToActiveVoiceDictationStream(msg.audio, chunkFormat); + if (!msg.isLast) { + this.setVoiceModeInactivityTimeout(); + this.sessionLogger.debug("Voice mode: streaming chunk, waiting for speech end"); + return; + } + + this.clearVoiceModeInactivityTimeout(); + this.sessionLogger.debug("Voice mode: speech ended, finalizing streaming transcription"); + await this.finalizeActiveVoiceDictationStream("speech ended"); + return; + } + + const chunkBuffer = Buffer.from(msg.audio, "base64"); const isPCMChunk = chunkFormat.toLowerCase().includes("pcm"); if (!this.audioBuffer) { @@ -4592,16 +4874,6 @@ export class Session { this.audioBuffer.totalPCMBytes += chunkBuffer.length; } - // In voice mode, only process audio when the user has finished speaking (isLast = true) - // This prevents partial transcriptions from being sent to the LLM - if (this.isVoiceMode) { - if (!msg.isLast) { - this.sessionLogger.debug("Voice mode: buffering audio, waiting for speech end"); - return; - } - this.sessionLogger.debug("Voice mode: speech ended, processing complete audio"); - } - // In non-voice mode, use streaming threshold to process chunks const reachedStreamingThreshold = !this.isVoiceMode && @@ -4747,85 +5019,17 @@ export class Session { "Transcription result" ); - // Emit transcription result - this.emit({ - type: "transcription_result", - payload: { - text: result.text, - language: result.language, - duration: result.duration, - requestId, - avgLogprob: result.avgLogprob, - isLowConfidence: result.isLowConfidence, - byteLength: result.byteLength, - format: result.format, - debugRecordingPath: result.debugRecordingPath, - }, + await this.handleTranscriptionResultPayload({ + text: result.text, + language: result.language, + duration: result.duration, + requestId, + avgLogprob: result.avgLogprob, + isLowConfidence: result.isLowConfidence, + byteLength: result.byteLength, + format: result.format, + debugRecordingPath: result.debugRecordingPath, }); - - if (!transcriptText) { - this.sessionLogger.debug("Empty transcription (false positive), not aborting"); - this.setPhase("idle"); - this.clearSpeechInProgress("empty transcription"); - return; - } - - // Has content - abort any in-progress stream now - this.createAbortController(); - - // Wait for aborted stream to finish cleanup (save partial response) - if (this.currentStreamPromise) { - this.sessionLogger.debug("Waiting for aborted stream to finish cleanup"); - await this.currentStreamPromise; - } - - if (result.debugRecordingPath) { - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "system", - content: `Saved input audio: ${result.debugRecordingPath}`, - metadata: { - recordingPath: result.debugRecordingPath, - format: result.format, - requestId, - }, - }, - }); - } - - // Emit activity log - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "transcript", - content: result.text, - metadata: { - language: result.language, - duration: result.duration, - }, - }, - }); - - this.clearSpeechInProgress("transcription complete"); - if (!this.isVoiceMode) { - this.sessionLogger.debug( - { requestId }, - "Skipping voice agent processing because voice mode is disabled" - ); - this.setPhase("idle"); - return; - } - - // Set phase to LLM and process (TTS enabled in voice mode for voice agents) - this.setPhase("llm"); - this.currentStreamPromise = this.processVoiceTurn(result.text); - await this.currentStreamPromise; - this.setPhase("idle"); } catch (error: any) { this.setPhase("idle"); this.clearSpeechInProgress("transcription error"); @@ -4842,6 +5046,89 @@ export class Session { } } + private async handleTranscriptionResultPayload( + result: VoiceTranscriptionResultPayload + ): Promise { + const transcriptText = result.text.trim(); + + this.emit({ + type: "transcription_result", + payload: { + text: result.text, + ...(result.language ? { language: result.language } : {}), + ...(result.duration !== undefined ? { duration: result.duration } : {}), + requestId: result.requestId, + ...(result.avgLogprob !== undefined ? { avgLogprob: result.avgLogprob } : {}), + ...(result.isLowConfidence !== undefined ? { isLowConfidence: result.isLowConfidence } : {}), + ...(result.byteLength !== undefined ? { byteLength: result.byteLength } : {}), + ...(result.format ? { format: result.format } : {}), + ...(result.debugRecordingPath ? { debugRecordingPath: result.debugRecordingPath } : {}), + }, + }); + + if (!transcriptText) { + this.sessionLogger.debug("Empty transcription (false positive), not aborting"); + this.setPhase("idle"); + this.clearSpeechInProgress("empty transcription"); + return; + } + + // Has content - abort any in-progress stream now + this.createAbortController(); + + // Wait for aborted stream to finish cleanup (save partial response) + if (this.currentStreamPromise) { + this.sessionLogger.debug("Waiting for aborted stream to finish cleanup"); + await this.currentStreamPromise; + } + + if (result.debugRecordingPath) { + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "system", + content: `Saved input audio: ${result.debugRecordingPath}`, + metadata: { + recordingPath: result.debugRecordingPath, + ...(result.format ? { format: result.format } : {}), + requestId: result.requestId, + }, + }, + }); + } + + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "transcript", + content: result.text, + metadata: { + ...(result.language ? { language: result.language } : {}), + ...(result.duration !== undefined ? { duration: result.duration } : {}), + }, + }, + }); + + this.clearSpeechInProgress("transcription complete"); + if (!this.isVoiceMode) { + this.sessionLogger.debug( + { requestId: result.requestId }, + "Skipping voice agent processing because voice mode is disabled" + ); + this.setPhase("idle"); + return; + } + + this.setPhase("llm"); + this.currentStreamPromise = this.processVoiceTurn(result.text); + await this.currentStreamPromise; + this.setPhase("idle"); + } + private registerVoiceBridgeForAgent(agentId: string): void { this.registerVoiceSpeakHandler?.(agentId, async ({ text, signal }) => { this.sessionLogger.info( @@ -5061,6 +5348,8 @@ export class Session { this.audioBuffer = null; } + this.cancelActiveVoiceDictationStream("new speech turn started"); + this.clearVoiceModeInactivityTimeout(); this.clearBufferTimeout(); this.abortController.abort(); @@ -5126,6 +5415,43 @@ export class Session { }, 10000); // 10 second timeout } + private setVoiceModeInactivityTimeout(): void { + if (!this.isVoiceMode) { + return; + } + + this.clearVoiceModeInactivityTimeout(); + this.voiceModeInactivityTimeout = setTimeout(() => { + this.voiceModeInactivityTimeout = null; + if (!this.isVoiceMode || !this.activeVoiceDictationId) { + return; + } + + this.sessionLogger.warn( + { + timeoutMs: VOICE_MODE_INACTIVITY_FLUSH_MS, + dictationId: this.activeVoiceDictationId, + nextSeq: this.activeVoiceDictationNextSeq, + }, + "Voice mode inactivity timeout reached without isLast; finalizing active voice dictation stream" + ); + + void this.finalizeActiveVoiceDictationStream("inactivity timeout").catch((error) => { + this.sessionLogger.error( + { err: error }, + "Failed to finalize voice dictation stream after inactivity timeout" + ); + }); + }, VOICE_MODE_INACTIVITY_FLUSH_MS); + } + + private clearVoiceModeInactivityTimeout(): void { + if (this.voiceModeInactivityTimeout) { + clearTimeout(this.voiceModeInactivityTimeout); + this.voiceModeInactivityTimeout = null; + } + } + /** * Clear buffer timeout */ @@ -5206,15 +5532,18 @@ export class Session { this.abortController.abort(); // Clear timeouts + this.clearVoiceModeInactivityTimeout(); this.clearBufferTimeout(); // Clear buffers + this.cancelActiveVoiceDictationStream("session cleanup"); this.pendingAudioSegments = []; this.audioBuffer = null; // Cleanup managers this.ttsManager.cleanup(); this.sttManager.cleanup(); + this.voiceStreamManager.cleanupAll(); this.dictationStreamManager.cleanupAll(); // Close MCP clients diff --git a/packages/server/src/server/test-utils/claude-config.ts b/packages/server/src/server/test-utils/claude-config.ts index 2eadb5679..fe17861d1 100644 --- a/packages/server/src/server/test-utils/claude-config.ts +++ b/packages/server/src/server/test-utils/claude-config.ts @@ -3,6 +3,14 @@ import { tmpdir } from "os"; import path from "path"; import { seedClaudeAuth } from "./claude-auth.js"; +function isIgnorableCleanupError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM"; +} + /** * Sets up an isolated Claude config directory for testing. * Creates a temp directory with: @@ -39,6 +47,12 @@ export function useTempClaudeConfigDir(): () => void { } else { process.env.CLAUDE_CONFIG_DIR = previousConfigDir; } - rmSync(configDir, { recursive: true, force: true }); + try { + rmSync(configDir, { recursive: true, force: true }); + } catch (error) { + if (!isIgnorableCleanupError(error)) { + throw error; + } + } }; }