Update files

This commit is contained in:
Mohamed Boudra
2026-02-10 10:50:54 +07:00
parent 7a21a5cce1
commit 7001928f4f
33 changed files with 3490 additions and 304 deletions
+2
View File
@@ -0,0 +1,2 @@
Dummy edit created by Codex.
You asked for any edit, so this file is a simple placeholder.
@@ -492,6 +492,9 @@ interface AgentConfigRowProps {
selectedModel: string; selectedModel: string;
isModelLoading: boolean; isModelLoading: boolean;
onSelectModel: (modelId: string) => void; onSelectModel: (modelId: string) => void;
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
disabled?: boolean; disabled?: boolean;
} }
@@ -506,6 +509,9 @@ export function AgentConfigRow({
selectedModel, selectedModel,
isModelLoading, isModelLoading,
onSelectModel, onSelectModel,
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
disabled, disabled,
}: AgentConfigRowProps): ReactElement { }: AgentConfigRowProps): ReactElement {
const providerOptions: ComboSelectOption[] = useMemo( const providerOptions: ComboSelectOption[] = useMemo(
@@ -540,7 +546,18 @@ export function AgentConfigRow({
return opts; return opts;
}, [models]); }, [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 effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
return ( return (
<View style={styles.agentConfigRow}> <View style={styles.agentConfigRow}>
@@ -584,6 +601,21 @@ export function AgentConfigRow({
showLabel={false} showLabel={false}
/> />
</View> </View>
{thinkingSelectOptions.length > 0 ? (
<View style={styles.agentConfigColumn}>
<ComboSelect
label="Thinking"
title="Select thinking effort"
value={effectiveSelectedThinkingOption}
options={thinkingSelectOptions}
placeholder="Select..."
disabled={disabled}
onSelect={onSelectThinkingOption}
icon={<Brain size={16} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
/>
</View>
) : null}
</View> </View>
); );
} }
@@ -4,9 +4,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
export function AgentStatusDot({ export function AgentStatusDot({
status, status,
requiresAttention, requiresAttention,
showInactive = false,
}: { }: {
status: string | null | undefined; status: string | null | undefined;
requiresAttention: boolean | null | undefined; requiresAttention: boolean | null | undefined;
showInactive?: boolean;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -15,7 +17,9 @@ export function AgentStatusDot({
? theme.colors.palette.blue[500] ? theme.colors.palette.blue[500]
: requiresAttention : requiresAttention
? theme.colors.success ? theme.colors.success
: null; : showInactive
? theme.colors.border
: null;
if (!color) { if (!color) {
return null; return null;
@@ -31,4 +35,3 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
}, },
})); }));
@@ -1099,11 +1099,13 @@ function PermissionRequestCard({
{!isPlanRequest ? ( {!isPlanRequest ? (
<ToolCallDetailsContent <ToolCallDetailsContent
detail={{ detail={
type: "unknown", request.detail ?? {
input: request.input ?? null, type: "unknown",
output: null, input: request.input ?? null,
}} output: null,
}
}
maxHeight={200} maxHeight={200}
/> />
) : null} ) : null}
+158 -41
View File
@@ -7,12 +7,14 @@ import {
View, View,
Platform, Platform,
} from "react-native"; } from "react-native";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center"; import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot"; import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string { function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`; return `${agent.serverId}:${agent.id}`;
@@ -26,13 +28,16 @@ export function CommandCenter() {
query, query,
setQuery, setQuery,
activeIndex, activeIndex,
results, items,
handleClose, handleClose,
handleSelect, handleSelectItem,
} = useCommandCenter(); } = useCommandCenter();
if (Platform.OS !== "web") return null; if (Platform.OS !== "web") return null;
const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent");
return ( return (
<Modal <Modal
visible={open} visible={open}
@@ -49,7 +54,7 @@ export function CommandCenter() {
ref={inputRef} ref={inputRef}
value={query} value={query}
onChangeText={setQuery} onChangeText={setQuery}
placeholder="Search agents…" placeholder="Type a command or search agents..."
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
style={[ style={[
styles.input, styles.input,
@@ -67,44 +72,125 @@ export function CommandCenter() {
keyboardShouldPersistTaps="always" keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{results.length === 0 ? ( {items.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}> <Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
No matches No matches
</Text> </Text>
) : ( ) : (
results.map((agent, index) => { <>
const active = index === activeIndex; {actionItems.length > 0 ? (
return ( <>
<Pressable <Text style={[styles.sectionLabel, { color: theme.colors.foregroundMuted }]}>
key={agentKey(agent)} Actions
style={({ hovered, pressed }) => [ </Text>
styles.row, {actionItems.map((item, index) => {
(hovered || pressed || active) && { const active = index === activeIndex;
backgroundColor: theme.colors.surface1, const action = item.action;
}, const actionIcon =
]} action.icon === "plus" ? (
onPress={() => handleSelect(agent)} <Plus
> size={16}
<View style={styles.rowContent}> strokeWidth={2.4}
<View style={styles.rowTitle}> color={theme.colors.foregroundMuted}
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} /> />
<Text ) : action.icon === "settings" ? (
style={[styles.title, { color: theme.colors.foreground }]} <Settings
numberOfLines={1} size={16}
strokeWidth={2.2}
color={theme.colors.foregroundMuted}
/>
) : null;
return (
<Pressable
key={`action:${action.id}`}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={() => handleSelectItem(item)}
> >
{agent.title || "New agent"} <View style={styles.rowContent}>
</Text> <View style={styles.rowMain}>
</View> {actionIcon ? (
<Text <View style={styles.iconSlot}>{actionIcon}</View>
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]} ) : null}
numberOfLines={1} <View style={styles.textContent}>
> <Text
{agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)} style={[styles.title, { color: theme.colors.foreground }]}
</Text> numberOfLines={1}
</View> >
</Pressable> {action.title}
); </Text>
}) </View>
</View>
{action.shortcutKeys ? (
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</Pressable>
);
})}
</>
) : null}
{agentItems.length > 0 ? (
<>
{actionItems.length > 0 ? (
<View
style={[styles.sectionDivider, { backgroundColor: theme.colors.border }]}
/>
) : null}
<Text style={[styles.sectionLabel, { color: theme.colors.foregroundMuted }]}>
Agents
</Text>
{agentItems.map((item, index) => {
const rowIndex = actionItems.length + index;
const active = rowIndex === activeIndex;
const agent = item.agent;
return (
<Pressable
key={agentKey(agent)}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={() => handleSelectItem(item)}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
status={agent.status}
requiresAttention={agent.requiresAttention}
showInactive
/>
</View>
<View style={styles.textContent}>
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
<Text
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}
numberOfLines={1}
>
{agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
</View>
</View>
</Pressable>
);
})}
</>
) : null}
</>
)} )}
</ScrollView> </ScrollView>
</View> </View>
@@ -152,24 +238,55 @@ const styles = StyleSheet.create((theme) => ({
resultsContent: { resultsContent: {
paddingVertical: theme.spacing[2], 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: { row: {
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[2],
}, },
rowContent: { rowContent: {
gap: 2,
},
rowTitle: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", 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: { title: {
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: "400", fontWeight: "400",
lineHeight: 20,
}, },
subtitle: { subtitle: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
lineHeight: 18,
}, },
emptyText: { emptyText: {
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
@@ -5,6 +5,7 @@ import {
closestCenter, closestCenter,
KeyboardSensor, KeyboardSensor,
PointerSensor, PointerSensor,
type Modifier,
useSensor, useSensor,
useSensors, useSensors,
type DragEndEvent, type DragEndEvent,
@@ -25,6 +26,11 @@ import type {
export type { DraggableListProps, DraggableRenderItemInfo }; export type { DraggableListProps, DraggableRenderItemInfo };
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
...transform,
x: 0,
});
interface SortableItemProps<T> { interface SortableItemProps<T> {
id: string; id: string;
item: T; item: T;
@@ -157,6 +163,7 @@ export function DraggableList<T>({
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
+93 -14
View File
@@ -177,6 +177,7 @@ interface DiffFileSectionProps {
file: ParsedDiffFile; file: ParsedDiffFile;
isExpanded: boolean; isExpanded: boolean;
onToggle: (path: string) => void; onToggle: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string; testID?: string;
} }
@@ -218,6 +219,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
file, file,
isExpanded, isExpanded,
onToggle, onToggle,
onHeaderHeightChange,
testID, testID,
}: DiffFileSectionProps) { }: DiffFileSectionProps) {
const expandStartRef = useRef<number | null>(null); const expandStartRef = useRef<number | null>(null);
@@ -292,6 +294,9 @@ const DiffFileHeader = memo(function DiffFileHeader({
styles.fileSectionHeaderContainer, styles.fileSectionHeaderContainer,
!isExpanded && styles.fileSectionBorder, !isExpanded && styles.fileSectionBorder,
]} ]}
onLayout={(event) => {
onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID} testID={testID}
> >
<Pressable <Pressable
@@ -329,7 +334,15 @@ const DiffFileHeader = memo(function DiffFileHeader({
); );
}); });
function DiffFileBody({ file, testID }: { file: ParsedDiffFile; testID?: string }) { function DiffFileBody({
file,
onBodyHeightChange,
testID,
}: {
file: ParsedDiffFile;
onBodyHeightChange?: (path: string, height: number) => void;
testID?: string;
}) {
const [scrollViewWidth, setScrollViewWidth] = useState(0); const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const horizontalScroll = useHorizontalScrollOptional(); const horizontalScroll = useHorizontalScrollOptional();
@@ -368,7 +381,13 @@ function DiffFileBody({ file, testID }: { file: ParsedDiffFile; testID?: string
); );
return ( return (
<View style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]} testID={testID}> <View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
{file.status === "too_large" || file.status === "binary" ? ( {file.status === "too_large" || file.status === "binary" ? (
<View style={styles.statusMessageContainer}> <View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}> <Text style={styles.statusMessageText}>
@@ -464,6 +483,10 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches // Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
const [isManualRefresh, setIsManualRefresh] = useState(false); const [isManualRefresh, setIsManualRefresh] = useState(false);
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({}); const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const headerHeightByPathRef = useRef<Record<string, number>>({});
const bodyHeightByPathRef = useRef<Record<string, number>>({});
const defaultHeaderHeightRef = useRef<number>(44);
const diffMetrics = useMemo(() => { const diffMetrics = useMemo(() => {
let hunkCount = 0; let hunkCount = 0;
let lineCount = 0; let lineCount = 0;
@@ -533,13 +556,6 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
[shipDefaultStorageKey] [shipDefaultStorageKey]
); );
const handleToggleExpanded = useCallback((path: string) => {
setExpandedByPath((prev) => ({
...prev,
[path]: !prev[path],
}));
}, []);
const { flatItems, stickyHeaderIndices } = useMemo(() => { const { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = []; const items: DiffFlatItem[] = [];
const stickyIndices: number[] = []; const stickyIndices: number[] = [];
@@ -555,6 +571,60 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
return { flatItems: items, stickyHeaderIndices: stickyIndices }; return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [files, expandedByPath]); }, [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(() => { const allExpanded = useMemo(() => {
if (files.length === 0) return false; if (files.length === 0) return false;
return files.every((file) => expandedByPath[file.path]); return files.every((file) => expandedByPath[file.path]);
@@ -707,15 +777,20 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
file={item.file} file={item.file}
isExpanded={item.isExpanded} isExpanded={item.isExpanded}
onToggle={handleToggleExpanded} onToggle={handleToggleExpanded}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`} testID={`diff-file-${item.fileIndex}`}
/> />
); );
} }
return ( return (
<DiffFileBody file={item.file} testID={`diff-file-${item.fileIndex}-body`} /> <DiffFileBody
file={item.file}
onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`}
/>
); );
}, },
[handleToggleExpanded] [handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded]
); );
const flatKeyExtractor = useCallback( const flatKeyExtractor = useCallback(
@@ -808,6 +883,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
} else { } else {
bodyContent = ( bodyContent = (
<FlatList <FlatList
ref={diffListRef}
data={flatItems} data={flatItems}
renderItem={renderFlatItem} renderItem={renderFlatItem}
keyExtractor={flatKeyExtractor} keyExtractor={flatKeyExtractor}
@@ -818,9 +894,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
testID="git-diff-scroll" testID="git-diff-scroll"
onRefresh={handleRefresh} onRefresh={handleRefresh}
refreshing={isManualRefresh && isDiffFetching} refreshing={isManualRefresh && isDiffFetching}
initialNumToRender={6} // Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
maxToRenderPerBatch={6} // Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
windowSize={5} removeClippedSubviews={false}
initialNumToRender={12}
maxToRenderPerBatch={12}
windowSize={10}
/> />
); );
} }
@@ -138,31 +138,14 @@ export function ToolCallDetailsContent({
</View> </View>
); );
} else if (detail?.type === "unknown") { } else if (detail?.type === "unknown") {
const sectionsFromTopLevel = [ const plainInputText =
{ title: "Input", value: detail.input }, typeof detail.input === "string" && detail.output === null
{ title: "Output", value: detail.output }, ? detail.input
].filter((entry) => entry.value !== null && entry.value !== undefined); : null;
for (const section of sectionsFromTopLevel) { if (plainInputText !== null) {
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( sections.push(
<View key={`${section.title}-header`} style={styles.groupHeader}> <View key="unknown-plain-text" style={styles.section}>
<Text style={styles.groupHeaderText}>{section.title}</Text>
</View>
);
sections.push(
<View key={`${section.title}-value`} style={styles.section}>
<ScrollView <ScrollView
horizontal horizontal
nestedScrollEnabled nestedScrollEnabled
@@ -170,10 +153,48 @@ export function ToolCallDetailsContent({
contentContainerStyle={styles.jsonContent} contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true} showsHorizontalScrollIndicator={true}
> >
<Text selectable style={styles.scrollText}>{value}</Text> <Text selectable style={styles.scrollText}>{plainInputText}</Text>
</ScrollView> </ScrollView>
</View> </View>
); );
} 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(
<View key={`${section.title}-header`} style={styles.groupHeader}>
<Text style={styles.groupHeaderText}>{section.title}</Text>
</View>
);
sections.push(
<View key={`${section.title}-value`} style={styles.section}>
<ScrollView
horizontal
nestedScrollEnabled
style={styles.jsonScroll}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>{value}</Text>
</ScrollView>
</View>
);
}
} }
} }
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-form-state"; import { __private__ } from "./use-agent-form-state";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
describe("useAgentFormState", () => { describe("useAgentFormState", () => {
describe("__private__.combineInitialValues", () => { 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<string>()
);
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<string>()
);
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<string>()
);
expect(resolved.thinkingOptionId).toBe("xhigh");
});
});
});
+106 -6
View File
@@ -19,6 +19,7 @@ export interface FormInitialValues {
provider?: AgentProvider; provider?: AgentProvider;
modeId?: string | null; modeId?: string | null;
model?: string | null; model?: string | null;
thinkingOptionId?: string | null;
workingDir?: string; workingDir?: string;
} }
@@ -28,6 +29,7 @@ interface UserModifiedFields {
provider: boolean; provider: boolean;
modeId: boolean; modeId: boolean;
model: boolean; model: boolean;
thinkingOptionId: boolean;
workingDir: boolean; workingDir: boolean;
} }
@@ -36,6 +38,7 @@ const INITIAL_USER_MODIFIED: UserModifiedFields = {
provider: false, provider: false,
modeId: false, modeId: false,
model: false, model: false,
thinkingOptionId: false,
workingDir: false, workingDir: false,
}; };
@@ -45,6 +48,7 @@ interface FormState {
provider: AgentProvider; provider: AgentProvider;
modeId: string; modeId: string;
model: string; model: string;
thinkingOptionId: string;
workingDir: string; workingDir: string;
} }
@@ -67,6 +71,8 @@ type UseAgentFormStateResult = {
setModeFromUser: (modeId: string) => void; setModeFromUser: (modeId: string) => void;
selectedModel: string; selectedModel: string;
setModelFromUser: (modelId: string) => void; setModelFromUser: (modelId: string) => void;
selectedThinkingOptionId: string;
setThinkingOptionFromUser: (thinkingOptionId: string) => void;
workingDir: string; workingDir: string;
setWorkingDir: (value: string) => void; setWorkingDir: (value: string) => void;
setWorkingDirFromUser: (value: string) => void; setWorkingDirFromUser: (value: string) => void;
@@ -75,6 +81,7 @@ type UseAgentFormStateResult = {
agentDefinition?: AgentProviderDefinition; agentDefinition?: AgentProviderDefinition;
modeOptions: AgentMode[]; modeOptions: AgentMode[];
availableModels: AgentModelDefinition[]; availableModels: AgentModelDefinition[];
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
isModelLoading: boolean; isModelLoading: boolean;
modelError: string | null; modelError: string | null;
refreshProviderModels: () => void; refreshProviderModels: () => void;
@@ -91,6 +98,32 @@ const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = const DEFAULT_MODE_FOR_DEFAULT_PROVIDER =
fallbackDefinition?.defaultModeId ?? ""; 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. * Pure function that resolves form state from multiple data sources.
* Priority: explicit (URL params) > preferences > provider defaults > fallback * 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 // Only use stored serverId if the host still exists in the registry
if (!userModified.serverId) { if (!userModified.serverId) {
if (initialValues?.serverId !== undefined) { if (initialValues?.serverId !== undefined) {
@@ -186,7 +255,7 @@ function resolveFormState(
// else keep current // else keep current
} }
// 5. Resolve workingDir (independent) // 6. Resolve workingDir (independent)
if (!userModified.workingDir) { if (!userModified.workingDir) {
if (initialValues?.workingDir !== undefined) { if (initialValues?.workingDir !== undefined) {
result.workingDir = initialValues.workingDir; result.workingDir = initialValues.workingDir;
@@ -258,6 +327,7 @@ export function useAgentFormState(
provider: DEFAULT_PROVIDER, provider: DEFAULT_PROVIDER,
modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER, modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER,
model: "", model: "",
thinkingOptionId: "",
workingDir: "", workingDir: "",
})); }));
const formStateRef = useRef(formState); const formStateRef = useRef(formState);
@@ -342,6 +412,7 @@ export function useAgentFormState(
resolved.provider !== formStateRef.current.provider || resolved.provider !== formStateRef.current.provider ||
resolved.modeId !== formStateRef.current.modeId || resolved.modeId !== formStateRef.current.modeId ||
resolved.model !== formStateRef.current.model || resolved.model !== formStateRef.current.model ||
resolved.thinkingOptionId !== formStateRef.current.thinkingOptionId ||
resolved.workingDir !== formStateRef.current.workingDir resolved.workingDir !== formStateRef.current.workingDir
) { ) {
setFormState(resolved); setFormState(resolved);
@@ -430,6 +501,7 @@ export function useAgentFormState(
provider, provider,
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "", modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
model: providerPrefs?.model ?? "", model: providerPrefs?.model ?? "",
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
})); }));
}, },
[preferences?.providerPreferences, updatePreferences] [preferences?.providerPreferences, updatePreferences]
@@ -453,6 +525,15 @@ export function useAgentFormState(
[formState.provider, updateProviderPreferences] [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) => { const setWorkingDir = useCallback((value: string) => {
setFormState((prev) => ({ ...prev, workingDir: value })); setFormState((prev) => ({ ...prev, workingDir: value }));
}, []); }, []);
@@ -475,27 +556,40 @@ export function useAgentFormState(
}, [providerModelsQuery]); }, [providerModelsQuery]);
const persistFormPreferences = useCallback(async () => { 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({ await updatePreferences({
workingDir: formState.workingDir, workingDir: formState.workingDir,
provider: formState.provider, provider: formState.provider,
serverId: formState.serverId ?? undefined, serverId: formState.serverId ?? undefined,
}); });
await updateProviderPreferences(formState.provider, { await updateProviderPreferences(formState.provider, providerPreferenceUpdates);
mode: formState.modeId,
model: formState.model,
});
}, [ }, [
formState.modeId, formState.modeId,
formState.model, formState.model,
formState.provider, formState.provider,
formState.serverId, formState.serverId,
formState.thinkingOptionId,
formState.workingDir, formState.workingDir,
userModified.thinkingOptionId,
updatePreferences, updatePreferences,
updateProviderPreferences, updateProviderPreferences,
]); ]);
const agentDefinition = providerDefinitionMap.get(formState.provider); const agentDefinition = providerDefinitionMap.get(formState.provider);
const modeOptions = agentDefinition?.modes ?? []; const modeOptions = agentDefinition?.modes ?? [];
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching; const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
const modelError = const modelError =
providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null; providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null;
@@ -513,6 +607,8 @@ export function useAgentFormState(
setModeFromUser, setModeFromUser,
selectedModel: formState.model, selectedModel: formState.model,
setModelFromUser, setModelFromUser,
selectedThinkingOptionId: formState.thinkingOptionId,
setThinkingOptionFromUser,
workingDir: formState.workingDir, workingDir: formState.workingDir,
setWorkingDir, setWorkingDir,
setWorkingDirFromUser, setWorkingDirFromUser,
@@ -521,6 +617,7 @@ export function useAgentFormState(
agentDefinition, agentDefinition,
modeOptions, modeOptions,
availableModels: availableModels ?? [], availableModels: availableModels ?? [],
availableThinkingOptions,
isModelLoading, isModelLoading,
modelError, modelError,
refreshProviderModels, refreshProviderModels,
@@ -532,17 +629,20 @@ export function useAgentFormState(
formState.provider, formState.provider,
formState.modeId, formState.modeId,
formState.model, formState.model,
formState.thinkingOptionId,
formState.workingDir, formState.workingDir,
setSelectedServerId, setSelectedServerId,
setSelectedServerIdFromUser, setSelectedServerIdFromUser,
setProviderFromUser, setProviderFromUser,
setModeFromUser, setModeFromUser,
setModelFromUser, setModelFromUser,
setThinkingOptionFromUser,
setWorkingDir, setWorkingDir,
setWorkingDirFromUser, setWorkingDirFromUser,
agentDefinition, agentDefinition,
modeOptions, modeOptions,
availableModels, availableModels,
availableThinkingOptions,
isModelLoading, isModelLoading,
modelError, modelError,
refreshProviderModels, refreshProviderModels,
+161 -15
View File
@@ -4,10 +4,20 @@ import { router, usePathname } from "expo-router";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
import { import {
clearCommandCenterFocusRestoreElement, clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore"; } 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"; import { focusWithRetries } from "@/utils/web-focus";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string { function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
@@ -41,6 +51,74 @@ function parseAgentKeyFromPathname(pathname: string): string | null {
return `${match[1]}:${match[2]}`; 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() { export function useCommandCenter() {
const pathname = usePathname(); const pathname = usePathname();
const { agents } = useAggregatedAgents(); const { agents } = useAggregatedAgents();
@@ -53,7 +131,7 @@ export function useCommandCenter() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const results = useMemo(() => { const agentResults = useMemo(() => {
const filtered = agents.filter((agent) => isMatch(agent, query)); const filtered = agents.filter((agent) => isMatch(agent, query));
filtered.sort(sortAgents); filtered.sort(sortAgents);
return filtered; return filtered;
@@ -64,11 +142,62 @@ export function useCommandCenter() {
[pathname] [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<CheckoutStatusPayload>(
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<CommandCenterActionItem>((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(() => { const handleClose = useCallback(() => {
setOpen(false); setOpen(false);
}, [setOpen]); }, [setOpen]);
const handleSelect = useCallback( const handleSelectAgent = useCallback(
(agent: AggregatedAgent) => { (agent: AggregatedAgent) => {
didNavigateRef.current = true; didNavigateRef.current = true;
const session = useSessionStore.getState().sessions[agent.serverId]; const session = useSessionStore.getState().sessions[agent.serverId];
@@ -86,6 +215,24 @@ export function useCommandCenter() {
[pathname, requestFocusChatInput, setOpen] [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(() => { useEffect(() => {
const prevOpen = prevOpenRef.current; const prevOpen = prevOpenRef.current;
prevOpenRef.current = open; prevOpenRef.current = open;
@@ -126,10 +273,10 @@ export function useCommandCenter() {
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (activeIndex >= results.length) { if (activeIndex >= items.length) {
setActiveIndex(results.length > 0 ? results.length - 1 : 0); setActiveIndex(items.length > 0 ? items.length - 1 : 0);
} }
}, [activeIndex, open, results.length]); }, [activeIndex, items.length, open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -152,21 +299,21 @@ export function useCommandCenter() {
} }
if (key === "Enter") { if (key === "Enter") {
if (results.length === 0) return; if (items.length === 0) return;
event.preventDefault(); event.preventDefault();
const index = Math.max(0, Math.min(activeIndex, results.length - 1)); const index = Math.max(0, Math.min(activeIndex, items.length - 1));
handleSelect(results[index]!); handleSelectItem(items[index]!);
return; return;
} }
if (key === "ArrowDown" || key === "ArrowUp") { if (key === "ArrowDown" || key === "ArrowUp") {
if (results.length === 0) return; if (items.length === 0) return;
event.preventDefault(); event.preventDefault();
setActiveIndex((current) => { setActiveIndex((current) => {
const delta = key === "ArrowDown" ? 1 : -1; const delta = key === "ArrowDown" ? 1 : -1;
const next = current + delta; const next = current + delta;
if (next < 0) return results.length - 1; if (next < 0) return items.length - 1;
if (next >= results.length) return 0; if (next >= items.length) return 0;
return next; return next;
}); });
} }
@@ -175,7 +322,7 @@ export function useCommandCenter() {
// react-native-web can stop propagation on key events, so listen in capture phase. // react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true); window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true);
}, [activeIndex, handleClose, handleSelect, open, results]); }, [activeIndex, handleClose, handleSelectItem, items, open]);
return { return {
open, open,
@@ -184,9 +331,8 @@ export function useCommandCenter() {
setQuery, setQuery,
activeIndex, activeIndex,
setActiveIndex, setActiveIndex,
results, items,
handleClose, handleClose,
handleSelect, handleSelectItem,
}; };
} }
@@ -10,6 +10,7 @@ const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
const providerPreferencesSchema = z.object({ const providerPreferencesSchema = z.object({
model: z.string().optional(), model: z.string().optional(),
mode: z.string().optional(), mode: z.string().optional(),
thinkingOptionId: z.string().optional(),
}); });
const formPreferencesSchema = z.object({ const formPreferencesSchema = z.object({
@@ -3,8 +3,18 @@ import { Platform } from "react-native";
import { usePathname, useRouter } from "expo-router"; import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout"; import { getIsTauri } from "@/constants/layout";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useSessionStore } from "@/stores/session-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts"; import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; 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({ export function useGlobalKeyboardNav({
enabled, enabled,
@@ -81,6 +91,29 @@ export function useGlobalKeyboardNav({
navigate(`/agent/${serverId}/${agentId}` as any); 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<CheckoutStatusPayload>(
checkoutStatusQueryKey(serverId, cwd)
) ?? null;
const workingDir = resolveNewAgentWorkingDir(cwd, checkout);
target = buildNewAgentRoute(workingDir);
}
}
}
router.push(target as any);
};
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldHandle()) { if (!shouldHandle()) {
return; 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 // Cmd+B: toggle sidebar
if ( if (
(event.metaKey || event.ctrlKey) && isMod &&
(event.code === "KeyB" || lowerKey === "b") (event.code === "KeyB" || lowerKey === "b")
) { ) {
// The MessageInput already handles Cmd+B inside editable fields. If we also // 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) // Cmd+.: toggle sidebar (VS Code quick-fix muscle memory)
// Note: intentionally works even when focus is inside an input/textarea. // Note: intentionally works even when focus is inside an input/textarea.
if ( if (
(event.metaKey || event.ctrlKey) && isMod &&
(event.code === "Period" || key === ".") (event.code === "Period" || key === ".")
) { ) {
// Ignore while command center is open. // Ignore while command center is open.
@@ -137,7 +189,7 @@ export function useGlobalKeyboardNav({
if ( if (
selectedAgentId && selectedAgentId &&
toggleFileExplorer && toggleFileExplorer &&
(event.metaKey || event.ctrlKey) && isMod &&
(event.code === "KeyE" || lowerKey === "e") (event.code === "KeyE" || lowerKey === "e")
) { ) {
// Same double-toggle issue as Cmd+B when focus is inside a text input. // 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 // Cmd+K: command center
if ((event.metaKey || event.ctrlKey) && lowerKey === "k") { if (isMod && lowerKey === "k") {
event.preventDefault(); event.preventDefault();
const s = useKeyboardNavStore.getState(); const s = useKeyboardNavStore.getState();
if (!s.commandCenterOpen) { if (!s.commandCenterOpen) {
@@ -205,7 +257,7 @@ export function useGlobalKeyboardNav({
} }
// Cmd/Ctrl+number: Tauri only (avoid browser tab switching) // Cmd/Ctrl+number: Tauri only (avoid browser tab switching)
if (isTauri && (event.metaKey || event.ctrlKey)) { if (isTauri && isMod) {
event.preventDefault(); event.preventDefault();
navigateToSidebarShortcut(digit); navigateToSidebarShortcut(digit);
} }
@@ -104,6 +104,7 @@ type DraftAgentParams = {
provider?: string; provider?: string;
modeId?: string; modeId?: string;
model?: string; model?: string;
thinkingOptionId?: string;
workingDir?: string; workingDir?: string;
}; };
@@ -146,6 +147,7 @@ export function DraftAgentScreen({
const resolvedProvider = getValidProvider(getParamValue(params.provider)); const resolvedProvider = getValidProvider(getParamValue(params.provider));
const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId)); const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId));
const resolvedModel = getParamValue(params.model); const resolvedModel = getParamValue(params.model);
const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId);
const resolvedWorkingDir = getParamValue(params.workingDir); const resolvedWorkingDir = getParamValue(params.workingDir);
const onlineServerIds = useMemo(() => { const onlineServerIds = useMemo(() => {
@@ -172,8 +174,17 @@ export function DraftAgentScreen({
if (resolvedModel) { if (resolvedModel) {
values.model = resolvedModel; values.model = resolvedModel;
} }
if (resolvedThinkingOptionId) {
values.thinkingOptionId = resolvedThinkingOptionId;
}
return values; return values;
}, [resolvedMode, resolvedModel, resolvedProvider, resolvedWorkingDir]); }, [
resolvedMode,
resolvedModel,
resolvedProvider,
resolvedThinkingOptionId,
resolvedWorkingDir,
]);
const { const {
selectedServerId, selectedServerId,
@@ -184,11 +195,14 @@ export function DraftAgentScreen({
setModeFromUser, setModeFromUser,
selectedModel, selectedModel,
setModelFromUser, setModelFromUser,
selectedThinkingOptionId,
setThinkingOptionFromUser,
workingDir, workingDir,
setWorkingDirFromUser, setWorkingDirFromUser,
providerDefinitions, providerDefinitions,
modeOptions, modeOptions,
availableModels, availableModels,
availableThinkingOptions,
isModelLoading, isModelLoading,
modelError, modelError,
refreshProviderModels, refreshProviderModels,
@@ -688,6 +702,7 @@ export function DraftAgentScreen({
const cwd = (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || "."; const cwd = (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || ".";
const provider = selectedProvider; const provider = selectedProvider;
const model = selectedModel.trim() || null; const model = selectedModel.trim() || null;
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null; const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
return { return {
@@ -713,6 +728,7 @@ export function DraftAgentScreen({
title: "New agent", title: "New agent",
cwd, cwd,
model, model,
thinkingOptionId,
labels: {}, labels: {},
}; };
}, [ }, [
@@ -722,6 +738,7 @@ export function DraftAgentScreen({
modeOptions.length, modeOptions.length,
selectedMode, selectedMode,
selectedModel, selectedModel,
selectedThinkingOptionId,
selectedProvider, selectedProvider,
selectedServerId, selectedServerId,
selectedWorktreePath, selectedWorktreePath,
@@ -797,11 +814,15 @@ export function DraftAgentScreen({
const modeId = const modeId =
modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
const trimmedModel = selectedModel.trim(); const trimmedModel = selectedModel.trim();
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
const config: AgentSessionConfig = { const config: AgentSessionConfig = {
provider: selectedProvider, provider: selectedProvider,
cwd: resolvedWorkingDir, cwd: resolvedWorkingDir,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}), ...(trimmedModel ? { model: trimmedModel } : {}),
...(trimmedThinkingOptionId
? { thinkingOptionId: trimmedThinkingOptionId }
: {}),
}; };
const effectiveBaseBranch = baseBranch.trim(); const effectiveBaseBranch = baseBranch.trim();
const effectiveWorktreeSlug = const effectiveWorktreeSlug =
@@ -874,6 +895,7 @@ export function DraftAgentScreen({
router, router,
selectedMode, selectedMode,
selectedModel, selectedModel,
selectedThinkingOptionId,
selectedProvider, selectedProvider,
selectedServerId, selectedServerId,
createAgentClient, createAgentClient,
@@ -979,6 +1001,9 @@ export function DraftAgentScreen({
selectedModel={selectedModel} selectedModel={selectedModel}
isModelLoading={isModelLoading} isModelLoading={isModelLoading}
onSelectModel={setModelFromUser} onSelectModel={setModelFromUser}
thinkingOptions={availableThinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId}
onSelectThinkingOption={setThinkingOptionFromUser}
/> />
{isMobile && trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( {isMobile && trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
<View style={styles.formSeparator} /> <View style={styles.formSeparator} />
@@ -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"
);
});
});
@@ -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)}`;
}
+7 -4
View File
@@ -14,16 +14,19 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
}; };
export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): ToolCallIconComponent { 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) { if (detail) {
return TOOL_DETAIL_ICONS[detail.type]; return TOOL_DETAIL_ICONS[detail.type];
} }
const lowerName = toolName.trim().toLowerCase();
if (lowerName === "task") { if (lowerName === "task") {
return Bot; return Bot;
} }
if (lowerName === "thinking") {
return Brain;
}
return Wrench; return Wrench;
} }
@@ -158,6 +158,49 @@ describe("EncryptedChannel", () => {
expect(sentData.length).toBeGreaterThan(plaintext.length + 20); 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<typeof vi.fn>).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 () => { it("fails handshake on invalid hello", async () => {
const [daemonTransport] = createMockTransportPair(); const [daemonTransport] = createMockTransportPair();
+17 -2
View File
@@ -85,6 +85,21 @@ export async function createClientChannel(
const helloText = JSON.stringify(hello); const helloText = JSON.stringify(hello);
let retry: ReturnType<typeof setInterval> | null = null; let retry: ReturnType<typeof setInterval> | 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 = () => { const clearRetry = () => {
if (retry) { if (retry) {
clearInterval(retry); clearInterval(retry);
@@ -95,13 +110,13 @@ export async function createClientChannel(
channel.onTransitionToOpen(() => clearRetry()); channel.onTransitionToOpen(() => clearRetry());
channel.onClose(() => clearRetry()); channel.onClose(() => clearRetry());
transport.send(helloText); sendHello();
retry = setInterval(() => { retry = setInterval(() => {
if (channel.isOpen()) { if (channel.isOpen()) {
clearRetry(); clearRetry();
return; return;
} }
transport.send(helloText); sendHello();
}, HANDSHAKE_RETRY_MS); }, HANDSHAKE_RETRY_MS);
// Avoid keeping Node processes alive (e.g. tests) if the handshake is stuck. // Avoid keeping Node processes alive (e.g. tests) if the handshake is stuck.
(retry as unknown as { unref?: () => void }).unref?.(); (retry as unknown as { unref?: () => void }).unref?.();
@@ -223,6 +223,7 @@ export type AgentPermissionRequest = {
title?: string; title?: string;
description?: string; description?: string;
input?: AgentMetadata; input?: AgentMetadata;
detail?: ToolCallDetail;
suggestions?: AgentPermissionUpdate[]; suggestions?: AgentPermissionUpdate[];
metadata?: AgentMetadata; metadata?: AgentMetadata;
}; };
@@ -368,26 +368,35 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
); );
test( test(
"shows the command inside pending tool calls", "shows the command inside permission requests",
async () => { async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const client = new ClaudeAgentClient({ logger }); const client = new ClaudeAgentClient({ logger });
const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config); const session = await client.createSession(config);
const filePath = path.join(cwd, "permission.txt");
writeFileSync(filePath, "ok", "utf8");
let pendingCommand: string | null = null; let requestedCommand: string | null = null;
const events = session.stream("Run the exact command `pwd` via Bash and stop."); const events = session.stream(
"Run the exact command `rm -f permission.txt` via Bash and stop."
);
try { try {
for await (const event of events) { for await (const event of events) {
await autoApprove(session, event);
if ( if (
event.type === "timeline" && event.type === "permission_requested" &&
event.item.type === "tool_call" && event.request.kind === "tool" &&
event.item.name.toLowerCase().includes("bash") && event.request.name.toLowerCase().includes("bash")
event.item.status === "pending"
) { ) {
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") { if (event.type === "turn_completed" || event.type === "turn_failed") {
break; break;
@@ -398,8 +407,8 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
} }
expect(pendingCommand).toBeTruthy(); expect(requestedCommand).toBeTruthy();
expect(pendingCommand?.toLowerCase()).toContain("pwd"); expect(requestedCommand?.toLowerCase()).toContain("permission.txt");
}, },
150_000 150_000
); );
@@ -501,6 +510,14 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
for await (const event of session.stream(prompt)) { for await (const event of session.stream(prompt)) {
if (event.type === "permission_requested" && !captured) { if (event.type === "permission_requested" && !captured) {
captured = event.request; 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); expect(session.getPendingPermissions().length).toBeGreaterThan(0);
await session.respondToPermission(captured.id, { behavior: "allow" }); await session.respondToPermission(captured.id, { behavior: "allow" });
} }
@@ -597,6 +614,14 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
item.status === "completed" item.status === "completed"
) )
).toBe(false); ).toBe(false);
expect(
timeline.some(
(item) =>
item.type === "tool_call" &&
isPermissionCommandToolCall(item) &&
item.status === "failed"
)
).toBe(true);
expect(existsSync(filePath)).toBe(true); expect(existsSync(filePath)).toBe(true);
} finally { } finally {
await cleanup(); await cleanup();
@@ -668,6 +693,14 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
item.status === "completed" item.status === "completed"
) )
).toBe(false); ).toBe(false);
expect(
timeline.some(
(item) =>
item.type === "tool_call" &&
isPermissionCommandToolCall(item) &&
item.status === "failed"
)
).toBe(true);
expect(existsSync(filePath)).toBe(true); expect(existsSync(filePath)).toBe(true);
} finally { } finally {
await cleanup(); await cleanup();
@@ -909,6 +942,79 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
180_000 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<string, unknown>)
: {};
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( test(
"hydrates persisted tool call results into the UI stream", "hydrates persisted tool call results into the UI stream",
async () => { async () => {
@@ -783,6 +783,20 @@ class ClaudeAgentSession implements AgentSession {
}; };
pending.resolve(result); pending.resolve(result);
} else { } 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 = { const result: PermissionResult = {
behavior: "deny", behavior: "deny",
message: response.message ?? "Permission request denied", message: response.message ?? "Permission request denied",
@@ -1361,6 +1375,7 @@ class ClaudeAgentSession implements AgentSession {
options options
): Promise<PermissionResult> => { ): Promise<PermissionResult> => {
const requestId = `permission-${randomUUID()}`; const requestId = `permission-${randomUUID()}`;
const kind = resolvePermissionKind(toolName, input);
const metadata: AgentMetadata = {}; const metadata: AgentMetadata = {};
if (options.toolUseID) { if (options.toolUseID) {
metadata.toolUseId = options.toolUseID; metadata.toolUseId = options.toolUseID;
@@ -1368,13 +1383,23 @@ class ClaudeAgentSession implements AgentSession {
if (toolName === "ExitPlanMode" && typeof input.plan === "string") { if (toolName === "ExitPlanMode" && typeof input.plan === "string") {
metadata.planText = input.plan; metadata.planText = input.plan;
} }
const detail =
kind === "tool"
? mapClaudeRunningToolCall({
name: toolName,
callId: options.toolUseID ?? requestId,
input,
output: null,
}).detail
: undefined;
const request: AgentPermissionRequest = { const request: AgentPermissionRequest = {
id: requestId, id: requestId,
provider: "claude", provider: "claude",
name: toolName, name: toolName,
kind: resolvePermissionKind(toolName, input), kind,
input, input,
detail,
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })), suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
metadata: Object.keys(metadata).length ? metadata : undefined, metadata: Object.keys(metadata).length ? metadata : undefined,
}; };
@@ -203,6 +203,43 @@ describe("Codex app-server provider (integration)", () => {
30000 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 () => { test.runIf(isCodexInstalled())("accepts image prompt blocks without request validation errors", async () => {
const cleanup = useTempCodexSessionDir(); const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-image-prompt-"); 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") { if (event.type === "permission_requested" && event.request.name === "CodexBash") {
sawPermission = true; sawPermission = true;
captured = event.request; 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" }); await session.respondToPermission(event.request.id, { behavior: "allow" });
} }
if ( if (
@@ -515,6 +556,87 @@ describe("Codex app-server provider (integration)", () => {
} }
}, 60000); }, 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())( test.runIf(isCodexInstalled())(
"streams responses and maps shell + file change tool calls into timeline items", "streams responses and maps shell + file change tool calls into timeline items",
async () => { async () => {
@@ -651,6 +773,225 @@ describe("Codex app-server provider (integration)", () => {
120000 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<AgentTimelineItem, { type: "tool_call" }> =>
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<AgentTimelineItem, { type: "tool_call" }> => 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<string, { item: boolean; codexEvent: boolean }>();
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<string, unknown>)
? ((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())( test.runIf(isCodexInstalled())(
"interrupts long-running commands and emits a canceled turn", "interrupts long-running commands and emits a canceled turn",
async () => { async () => {
@@ -17,6 +17,7 @@ import type {
AgentSlashCommand, AgentSlashCommand,
AgentStreamEvent, AgentStreamEvent,
AgentTimelineItem, AgentTimelineItem,
ToolCallTimelineItem,
AgentUsage, AgentUsage,
ListModelsOptions, ListModelsOptions,
ListPersistedAgentsOptions, ListPersistedAgentsOptions,
@@ -34,7 +35,10 @@ import path from "node:path";
import readline from "node:readline"; import readline from "node:readline";
import { z } from "zod"; import { z } from "zod";
import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js"; 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; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
@@ -114,6 +118,32 @@ function normalizeCodexThinkingOptionId(
return normalized; 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 { function resolveCodexBinary(): string {
try { try {
const codexPath = execSync("which codex", { encoding: "utf8" }).trim(); 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<string, unknown>)
.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( function threadItemToTimeline(
item: any, item: any,
options?: { includeUserMessage?: boolean; cwd?: string | null } options?: { includeUserMessage?: boolean; cwd?: string | null }
@@ -679,25 +933,37 @@ function threadItemToTimeline(
if (!item || typeof item !== "object") return null; if (!item || typeof item !== "object") return null;
const includeUserMessage = options?.includeUserMessage ?? true; const includeUserMessage = options?.includeUserMessage ?? true;
const cwd = options?.cwd ?? null; 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": { case "userMessage": {
if (!includeUserMessage) { if (!includeUserMessage) {
return null; return null;
} }
const text = extractUserText(item.content) ?? ""; const text = extractUserText(normalizedItem.content) ?? "";
return { type: "user_message", text }; return { type: "user_message", text };
} }
case "agentMessage": { case "agentMessage": {
return { type: "assistant_message", text: item.text ?? "" }; return { type: "assistant_message", text: normalizedItem.text ?? "" };
} }
case "plan": { case "plan": {
const text = item.text ?? ""; const text = normalizedItem.text ?? "";
const items = parsePlanTextToTodoItems(text); const items = parsePlanTextToTodoItems(text);
return { type: "todo", items }; return { type: "todo", items };
} }
case "reasoning": { case "reasoning": {
const summary = Array.isArray(item.summary) ? item.summary.join("\n") : ""; const summary = Array.isArray(normalizedItem.summary)
const content = Array.isArray(item.content) ? item.content.join("\n") : ""; ? normalizedItem.summary.join("\n")
: "";
const content = Array.isArray(normalizedItem.content)
? normalizedItem.content.join("\n")
: "";
const text = summary || content; const text = summary || content;
return text ? { type: "reasoning", text } : null; return text ? { type: "reasoning", text } : null;
} }
@@ -705,7 +971,7 @@ function threadItemToTimeline(
case "fileChange": case "fileChange":
case "mcpToolCall": case "mcpToolCall":
case "webSearch": case "webSearch":
return mapCodexToolCallFromThreadItem(item, { cwd }); return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
default: default:
return null; return null;
} }
@@ -772,6 +1038,7 @@ const TurnCompletedNotificationSchema = z.object({
message: z.string().optional(), message: z.string().optional(),
}) })
.passthrough() .passthrough()
.nullable()
.optional(), .optional(),
}) })
.passthrough(), .passthrough(),
@@ -827,6 +1094,83 @@ const CodexEventTaskCompleteNotificationSchema = z.object({
.passthrough(), .passthrough(),
}).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 = type ParsedCodexNotification =
| { kind: "thread_started"; threadId: string } | { kind: "thread_started"; threadId: string }
| { kind: "turn_started"; turnId: string } | { kind: "turn_started"; turnId: string }
@@ -836,8 +1180,45 @@ type ParsedCodexNotification =
| { kind: "token_usage_updated"; tokenUsage: unknown } | { kind: "token_usage_updated"; tokenUsage: unknown }
| { kind: "agent_message_delta"; itemId: string; delta: string } | { kind: "agent_message_delta"; itemId: string; delta: string }
| { kind: "reasoning_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: "invalid_payload"; method: string; params: unknown }
| { kind: "unknown_method"; 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 }) ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
), ),
z.object({ method: z.literal("item/completed"), params: ItemLifecycleNotificationSchema }).transform( 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( z.object({ method: z.literal("item/completed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) ({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
), ),
z.object({ method: z.literal("item/started"), params: ItemLifecycleNotificationSchema }).transform( 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( z.object({ method: z.literal("item/started"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params }) ({ 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({ z.object({
method: z.literal("codex/event/turn_aborted"), method: z.literal("codex/event/turn_aborted"),
params: CodexEventTurnAbortedNotificationSchema, params: CodexEventTurnAbortedNotificationSchema,
@@ -968,6 +1453,53 @@ async function writeImageAttachment(mimeType: string, data: string): Promise<str
return filePath; return filePath;
} }
async function readCodexConfiguredDefaults(
client: CodexAppServerClient,
logger: Logger
): Promise<CodexConfiguredDefaults> {
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( export async function codexAppServerTurnInputFromPrompt(
prompt: AgentPromptInput, prompt: AgentPromptInput,
logger: Logger logger: Logger
@@ -1033,8 +1565,11 @@ class CodexAppServerAgentSession implements AgentSession {
private resolvedPermissionRequests = new Set<string>(); private resolvedPermissionRequests = new Set<string>();
private pendingAgentMessages = new Map<string, string>(); private pendingAgentMessages = new Map<string, string>();
private pendingReasoning = new Map<string, string[]>(); private pendingReasoning = new Map<string, string[]>();
private emittedItemStartedIds = new Set<string>();
private emittedItemCompletedIds = new Set<string>();
private warnedUnknownNotificationMethods = new Set<string>(); private warnedUnknownNotificationMethods = new Set<string>();
private warnedInvalidNotificationPayloads = new Set<string>(); private warnedInvalidNotificationPayloads = new Set<string>();
private latestTurnUnifiedDiff: string | null = null;
private latestUsage: AgentUsage | undefined; private latestUsage: AgentUsage | undefined;
private connected = false; private connected = false;
private collaborationModes: Array<{ private collaborationModes: Array<{
@@ -1443,10 +1978,41 @@ class CodexAppServerAgentSession implements AgentSession {
if (!pending) { if (!pending) {
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`); 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.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId); this.pendingPermissions.delete(requestId);
this.resolvedPermissionRequests.add(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({ this.emitEvent({
type: "permission_resolved", type: "permission_resolved",
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
@@ -1607,6 +2173,10 @@ class CodexAppServerAgentSession implements AgentSession {
// Resolve model - if not specified, query available models and pick default // Resolve model - if not specified, query available models and pick default
let model = this.config.model; let model = this.config.model;
if (!model) {
const configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger);
model = configuredDefaults.model;
}
if (!model) { if (!model) {
const modelResponse = (await this.client.request("model/list", {})) as CodexModelListResponse; const modelResponse = (await this.client.request("model/list", {})) as CodexModelListResponse;
const models = modelResponse?.data ?? []; const models = modelResponse?.data ?? [];
@@ -1616,6 +2186,7 @@ class CodexAppServerAgentSession implements AgentSession {
} }
model = defaultModel.id; model = defaultModel.id;
} }
this.config.model = model;
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID]; const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy; const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
@@ -1681,6 +2252,9 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.kind === "turn_started") { if (parsed.kind === "turn_started") {
this.currentTurnId = parsed.turnId; this.currentTurnId = parsed.turnId;
this.latestTurnUnifiedDiff = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER }); this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
return; return;
} }
@@ -1697,6 +2271,9 @@ class CodexAppServerAgentSession implements AgentSession {
} else { } else {
this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER, usage: this.latestUsage }); this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER, usage: this.latestUsage });
} }
this.latestTurnUnifiedDiff = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.eventQueue?.end(); this.eventQueue?.end();
return; return;
} }
@@ -1717,13 +2294,12 @@ class CodexAppServerAgentSession implements AgentSession {
} }
if (parsed.kind === "diff_updated") { if (parsed.kind === "diff_updated") {
if (parsed.diff.trim().length > 0) { const trimmedDiff = parsed.diff.trim();
// NOTE: Codex app-server emits frequent `turn/diff/updated` notifications this.latestTurnUnifiedDiff = trimmedDiff.length > 0 ? trimmedDiff : null;
// containing a full accumulated unified diff for the *entire turn*. // NOTE: Codex app-server emits frequent `turn/diff/updated` notifications
// This is not a concrete file-change tool call; it is progress telemetry. // containing a full accumulated unified diff for the *entire turn*.
// We intentionally do NOT store it in the agent timeline to avoid // This is not a concrete file-change tool call; it is progress telemetry.
// snapshot bloat and relay/WebSocket size limits. // We intentionally do NOT store every diff update in the timeline.
}
return; return;
} }
@@ -1745,13 +2321,79 @@ class CodexAppServerAgentSession implements AgentSession {
return; 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") { 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, { const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false, includeUserMessage: false,
cwd: this.config.cwd ?? null, cwd: this.config.cwd ?? null,
}); });
if (timelineItem) { if (timelineItem) {
const itemId = parsed.item.id; const itemId = parsed.item.id;
if (itemId && this.emittedItemCompletedIds.has(itemId)) {
return;
}
if (timelineItem.type === "assistant_message" && itemId) { if (timelineItem.type === "assistant_message" && itemId) {
const buffered = this.pendingAgentMessages.get(itemId); const buffered = this.pendingAgentMessages.get(itemId);
if (buffered && buffered.length > 0) { if (buffered && buffered.length > 0) {
@@ -1765,17 +2407,31 @@ class CodexAppServerAgentSession implements AgentSession {
} }
} }
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (itemId) {
this.emittedItemCompletedIds.add(itemId);
this.emittedItemStartedIds.delete(itemId);
}
} }
return; return;
} }
if (parsed.kind === "item_started") { if (parsed.kind === "item_started") {
if (parsed.source === "codex_event") {
return;
}
const timelineItem = threadItemToTimeline(parsed.item, { const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false, includeUserMessage: false,
cwd: this.config.cwd ?? null, cwd: this.config.cwd ?? null,
}); });
if (timelineItem && timelineItem.type === "tool_call") { 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 }); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (itemId) {
this.emittedItemStartedIds.add(itemId);
}
} }
return; return;
} }
@@ -1817,6 +2473,12 @@ class CodexAppServerAgentSession implements AgentSession {
cwd?: string | null; cwd?: string | null;
reason?: 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 requestId = `permission-${parsed.itemId}`;
const title = parsed.command ? `Run command: ${parsed.command}` : "Run command"; const title = parsed.command ? `Run command: ${parsed.command}` : "Run command";
const request: AgentPermissionRequest = { const request: AgentPermissionRequest = {
@@ -1830,6 +2492,15 @@ class CodexAppServerAgentSession implements AgentSession {
command: parsed.command ?? undefined, command: parsed.command ?? undefined,
cwd: parsed.cwd ?? undefined, cwd: parsed.cwd ?? undefined,
}, },
detail:
commandPreview?.detail ?? {
type: "unknown",
input: {
command: parsed.command ?? null,
cwd: parsed.cwd ?? null,
},
output: null,
},
metadata: { metadata: {
itemId: parsed.itemId, itemId: parsed.itemId,
threadId: parsed.threadId, threadId: parsed.threadId,
@@ -1853,6 +2524,13 @@ class CodexAppServerAgentSession implements AgentSession {
kind: "tool", kind: "tool",
title: "Apply file changes", title: "Apply file changes",
description: parsed.reason ?? undefined, description: parsed.reason ?? undefined,
detail: {
type: "unknown",
input: {
reason: parsed.reason ?? null,
},
output: null,
},
metadata: { metadata: {
itemId: parsed.itemId, itemId: parsed.itemId,
threadId: parsed.threadId, threadId: parsed.threadId,
@@ -1876,6 +2554,13 @@ class CodexAppServerAgentSession implements AgentSession {
kind: "tool", kind: "tool",
title: "Tool action requires approval", title: "Tool action requires approval",
description: undefined, description: undefined,
detail: {
type: "unknown",
input: {
questions: Array.isArray(parsed.questions) ? parsed.questions : [],
},
output: null,
},
metadata: { metadata: {
itemId: parsed.itemId, itemId: parsed.itemId,
threadId: parsed.threadId, threadId: parsed.threadId,
@@ -2020,12 +2705,21 @@ export class CodexAppServerAgentClient implements AgentClient {
const response = (await client.request("model/list", {})) as { data?: Array<any> }; const response = (await client.request("model/list", {})) as { data?: Array<any> };
const models = Array.isArray(response?.data) ? response.data : []; 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) => { return models.map((model) => {
const defaultReasoningEffort = normalizeCodexThinkingOptionId( const defaultReasoningEffort = normalizeCodexThinkingOptionId(
typeof model.defaultReasoningEffort === "string" typeof model.defaultReasoningEffort === "string"
? model.defaultReasoningEffort ? model.defaultReasoningEffort
: null : null
); );
const resolvedDefaultReasoningEffort =
configuredDefaultThinkingOptionId ?? defaultReasoningEffort;
const thinkingById = new Map<string, { id: string; label: string; description?: string }>(); const thinkingById = new Map<string, { id: string; label: string; description?: string }>();
if (Array.isArray(model.supportedReasoningEfforts)) { if (Array.isArray(model.supportedReasoningEfforts)) {
@@ -2042,27 +2736,35 @@ export class CodexAppServerAgentClient implements AgentClient {
} }
} }
if (defaultReasoningEffort && !thinkingById.has(defaultReasoningEffort)) { if (resolvedDefaultReasoningEffort && !thinkingById.has(resolvedDefaultReasoningEffort)) {
thinkingById.set(defaultReasoningEffort, { thinkingById.set(resolvedDefaultReasoningEffort, {
id: defaultReasoningEffort, id: resolvedDefaultReasoningEffort,
label: defaultReasoningEffort, label: resolvedDefaultReasoningEffort,
description: "Model default reasoning effort", description:
configuredDefaultThinkingOptionId === resolvedDefaultReasoningEffort
? "Configured default reasoning effort"
: "Model default reasoning effort",
}); });
} }
const thinkingOptions = Array.from(thinkingById.values()).map((option) => ({ const thinkingOptions = Array.from(thinkingById.values()).map((option) => ({
...option, ...option,
isDefault: option.id === defaultReasoningEffort, isDefault: option.id === resolvedDefaultReasoningEffort,
})); }));
const defaultThinkingOptionId = 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 { return {
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
id: model.id, id: model.id,
label: model.displayName, label: model.displayName,
description: model.description, description: model.description,
isDefault: model.isDefault, isDefault: isDefaultModel,
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
defaultThinkingOptionId, defaultThinkingOptionId,
metadata: { metadata: {
@@ -40,7 +40,7 @@ describe("codex rollout parsing", () => {
type: "tool_call", type: "tool_call",
name: "Bash", name: "Bash",
callId: "call_MhTWDF2mpM4dhbNmHNt6ikDF", 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", type: "tool_call",
name: "Bash", name: "Bash",
callId: "call_abc123", callId: "call_abc123",
input: { command: "echo hello" }, detail: {
output: expect.stringContaining("hello"), type: "shell",
command: "echo hello",
output: "hello",
},
}); });
}); });
@@ -143,7 +146,7 @@ describe("codex rollout parsing", () => {
type: "tool_call", type: "tool_call",
name: "Bash", name: "Bash",
callId: "call_shell123", callId: "call_shell123",
input: { command: "ls -la" }, detail: { type: "shell", command: "ls -la" },
}); });
}); });
}); });
@@ -249,11 +252,61 @@ describe("codex rollout parsing", () => {
type: "tool_call", type: "tool_call",
name: "Bash", name: "Bash",
callId: "call_legacy_1", callId: "call_legacy_1",
input: { command: "echo hello" }, detail: { type: "shell", command: "echo hello", output: "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 () => { test("parses legacy event_msg shape using msg", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl"); const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [ const lines = [
@@ -289,6 +342,70 @@ describe("codex rollout parsing", () => {
expect(timeline).toContainEqual({ type: "assistant_message", text: "done" }); expect(timeline).toContainEqual({ type: "assistant_message", text: "done" });
expect(timeline).toContainEqual({ type: "user_message", text: "question" }); 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", () => { describe("complex conversation", () => {
@@ -362,8 +479,11 @@ describe("codex rollout parsing", () => {
expect(timeline[2]).toMatchObject({ expect(timeline[2]).toMatchObject({
type: "tool_call", type: "tool_call",
name: "Bash", name: "Bash",
input: { command: "npm test" }, detail: {
output: expect.stringContaining("All tests passed!"), type: "shell",
command: "npm test",
output: "All tests passed!",
},
}); });
expect(timeline[3]).toMatchObject({ expect(timeline[3]).toMatchObject({
type: "assistant_message", type: "assistant_message",
@@ -95,21 +95,34 @@ const RolloutResponseFunctionCallPayloadSchema = z.object({
type: z.literal("function_call"), type: z.literal("function_call"),
name: z.string().optional(), name: z.string().optional(),
call_id: z.string().optional(), call_id: z.string().optional(),
arguments: z.string().optional(), arguments: z.unknown().optional(),
}); });
const RolloutResponseCustomToolCallPayloadSchema = z.object({ const RolloutResponseCustomToolCallPayloadSchema = z
type: z.literal("custom_tool_call"), .object({
name: z.string().optional(), type: z.literal("custom_tool_call"),
call_id: z.string().optional(), name: z.string().optional(),
arguments: z.string().optional(), call_id: z.string().optional(),
}); arguments: z.unknown().optional(),
input: z.unknown().optional(),
})
.passthrough();
const RolloutResponseFunctionCallOutputPayloadSchema = z.object({ const RolloutResponseFunctionCallOutputPayloadSchema = z
type: z.literal("function_call_output"), .object({
call_id: z.string().optional(), type: z.literal("function_call_output"),
output: z.string().optional(), 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({ const RolloutEventAgentReasoningPayloadSchema = z.object({
type: z.literal("agent_reasoning"), type: z.literal("agent_reasoning"),
@@ -154,7 +167,7 @@ type RolloutResponseReasoningPayload = z.infer<
type ParsedRolloutRecord = type ParsedRolloutRecord =
| { kind: "timeline"; item: AgentTimelineItem } | { kind: "timeline"; item: AgentTimelineItem }
| { kind: "call"; name: string; callId?: string; input?: unknown } | { kind: "call"; name: string; callId?: string; input?: unknown }
| { kind: "output"; callId: string; output: string } | { kind: "output"; callId: string; output: unknown }
| { kind: "ignore" }; | { kind: "ignore" };
const RolloutMessageContentSchema = z 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<string, unknown>): 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 const FunctionCallInputNormalizationSchema = z
.union([ .union([
z.object({ cmd: z.string() }).transform((input) => ({ name: "Bash", input: { command: input.cmd } })), z.object({ cmd: z.string() }).transform((input) => ({ name: "Bash", input: { command: input.cmd } })),
@@ -284,7 +313,8 @@ const RolloutResponseRecordSchema = z
]) ])
.transform((payload): ParsedRolloutRecord => { .transform((payload): ParsedRolloutRecord => {
const rawName = payload.name ?? "unknown"; 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 = const normalized =
rawName === "exec_command" || rawName === "shell" rawName === "exec_command" || rawName === "shell"
? FunctionCallInputNormalizationSchema.parse(parsedArguments) ? FunctionCallInputNormalizationSchema.parse(parsedArguments)
@@ -299,12 +329,20 @@ const RolloutResponseRecordSchema = z
input: normalized.input, input: normalized.input,
}; };
}), }),
RolloutResponseFunctionCallOutputPayloadSchema.transform( z
(payload): ParsedRolloutRecord => .union([
payload.call_id && payload.output RolloutResponseFunctionCallOutputPayloadSchema,
? { kind: "output", callId: payload.call_id, output: payload.output } RolloutResponseCustomToolCallOutputPayloadSchema,
: { kind: "ignore" } ])
), .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" })), z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
]); ]);
@@ -391,6 +429,37 @@ function parseJsonRolloutTimeline(
return timeline; 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( export async function parseRolloutFile(
filePath: string filePath: string
): Promise<AgentTimelineItem[]> { ): Promise<AgentTimelineItem[]> {
@@ -403,7 +472,7 @@ export async function parseRolloutFile(
const parsed = JSON.parse(trimmed); const parsed = JSON.parse(trimmed);
const jsonTimeline = parseJsonRolloutTimeline(parsed); const jsonTimeline = parseJsonRolloutTimeline(parsed);
if (jsonTimeline) { if (jsonTimeline) {
return jsonTimeline; return dedupeMirroredTextTimelineItems(jsonTimeline);
} }
} catch { } catch {
// Fall back to JSONL parsing. // Fall back to JSONL parsing.
@@ -428,9 +497,9 @@ export async function parseRolloutFile(
const outputsByCallId = parsedRecords const outputsByCallId = parsedRecords
.filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output") .filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output")
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, string>()); .reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
return parsedRecords.flatMap((record): AgentTimelineItem[] => const timeline = parsedRecords.flatMap((record): AgentTimelineItem[] =>
record.kind === "timeline" record.kind === "timeline"
? [record.item] ? [record.item]
: record.kind === "call" : record.kind === "call"
@@ -444,6 +513,7 @@ export async function parseRolloutFile(
] ]
: [] : []
); );
return dedupeMirroredTextTimelineItems(timeline);
} }
export type CodexPersistedTimelineOptions = { export type CodexPersistedTimelineOptions = {
@@ -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", () => { it("maps running known tool variants with detail for early summaries", () => {
const readItem = mapCodexToolCallFromThreadItem( 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", () => { it("maps write/edit/search known variants with distinct detail types", () => {
const writeItem = mapCodexToolCallFromThreadItem( const writeItem = mapCodexToolCallFromThreadItem(
{ {
@@ -240,4 +317,98 @@ describe("codex tool-call mapper", () => {
}); });
expect(item.callId).toBe("codex-call-4"); 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();
}
});
}); });
@@ -4,7 +4,7 @@ import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types
import { CommandValueSchema } from "../tool-call-detail-primitives.js"; import { CommandValueSchema } from "../tool-call-detail-primitives.js";
import { import {
coerceToolCallId, coerceToolCallId,
commandFromValue, extractCodexShellOutput,
truncateDiffText, truncateDiffText,
} from "../tool-call-mapper-utils.js"; } from "../tool-call-mapper-utils.js";
import { import {
@@ -59,6 +59,7 @@ const CodexFileChangeItemSchema = z
path: z.string().optional(), path: z.string().optional(),
kind: z.string().optional(), kind: z.string().optional(),
diff: z.string().optional(), diff: z.string().optional(),
content: z.string().optional(),
}) })
.passthrough() .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<string, unknown>): Record<string, unknown> {
const {
patch: _patch,
diff: _diff,
unified_diff: _unifiedDiffSnake,
unifiedDiff: _unifiedDiffCamel,
...rest
} = input;
return rest;
}
function resolveStatus( function resolveStatus(
rawStatus: string | undefined, rawStatus: string | undefined,
error: unknown, error: unknown,
@@ -189,20 +397,121 @@ function toNullableObject(value: Record<string, unknown>): Record<string, unknow
return Object.keys(value).length > 0 ? value : null; return Object.keys(value).length > 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<string, unknown> {
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( function mapCommandExecutionItem(
item: z.infer<typeof CodexCommandExecutionItemSchema> item: z.infer<typeof CodexCommandExecutionItemSchema>
): ToolCallTimelineItem { ): ToolCallTimelineItem {
const command = item.command ? commandFromValue(item.command) : undefined; const command = normalizeCommandExecutionCommand(item.command);
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput);
const input = toNullableObject({ const input = toNullableObject({
...(command !== undefined ? { command } : {}), ...(command !== undefined ? { command } : {}),
...(item.cwd !== undefined ? { cwd: item.cwd } : {}), ...(item.cwd !== undefined ? { cwd: item.cwd } : {}),
}); });
const output = const output =
item.aggregatedOutput !== undefined || item.exitCode !== undefined parsedOutput !== undefined || item.exitCode !== undefined
? { ? {
...(command !== undefined ? { command } : {}), ...(command !== undefined ? { command } : {}),
...(item.aggregatedOutput !== undefined ? { output: item.aggregatedOutput } : {}), ...(parsedOutput !== undefined ? { output: parsedOutput } : {}),
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}), ...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
} }
: null; : null;
@@ -212,7 +521,7 @@ function mapCommandExecutionItem(
type: "shell" as const, type: "shell" as const,
command, command,
...(item.cwd ? { cwd: item.cwd } : {}), ...(item.cwd ? { cwd: item.cwd } : {}),
...(item.aggregatedOutput ? { output: item.aggregatedOutput } : {}), ...(parsedOutput ? { output: parsedOutput } : {}),
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}), ...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
} }
: { : {
@@ -251,7 +560,7 @@ function mapFileChangeItem(
return { return {
path: pathValue, path: pathValue,
kind: change.kind, kind: change.kind,
diff: change.diff, diff: change.diff ?? change.content,
}; };
}) })
.filter((change) => change.path !== undefined); .filter((change) => change.path !== undefined);
@@ -273,18 +582,19 @@ function mapFileChangeItem(
files: files.map((file) => ({ files: files.map((file) => ({
path: file.path, path: file.path,
...(file.kind !== undefined ? { kind: file.kind } : {}), ...(file.kind !== undefined ? { kind: file.kind } : {}),
...(file.diff !== undefined ? { patch: truncateDiffText(file.diff) } : {}), ...asEditFileOutputFields(file.diff),
})), })),
} }
: {}), : {}),
}); });
const firstFile = files[0]; const firstFile = files[0];
const firstTextFields = asEditTextFields(firstFile?.diff);
const detail = firstFile?.path const detail = firstFile?.path
? { ? {
type: "edit" as const, type: "edit" as const,
filePath: firstFile.path, filePath: firstFile.path,
...(firstFile.diff !== undefined ? { unifiedDiff: truncateDiffText(firstFile.diff) } : {}), ...firstTextFields,
} }
: { : {
type: "unknown" as const, type: "unknown" as const,
@@ -400,19 +710,31 @@ export function mapCodexRolloutToolCall(params: {
input?: unknown; input?: unknown;
output?: unknown; output?: unknown;
error?: unknown; error?: unknown;
cwd?: string | null;
}): ToolCallTimelineItem { }): ToolCallTimelineItem {
const parsed = CodexRolloutToolCallParamsSchema.parse(params); 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 output = parsed.output ?? null;
const error = parsed.error ?? null; const error = parsed.error ?? null;
const status = resolveStatus("completed", error, output); const status = resolveStatus("completed", error, output);
const callId = coerceCallId(parsed.callId, parsed.name, input); const callId = coerceCallId(parsed.callId, parsed.name, input);
const detail = deriveCodexToolDetail({ let detail = deriveCodexToolDetail({
name: parsed.name, name: parsed.name,
input, input,
output, 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({ return buildToolCall({
callId, callId,
@@ -3,6 +3,7 @@ import { z } from "zod";
import type { ToolCallDetail } from "../agent-sdk-types.js"; import type { ToolCallDetail } from "../agent-sdk-types.js";
import { import {
commandFromValue, commandFromValue,
extractCodexShellOutput,
flattenReadContent as flattenToolReadContent, flattenReadContent as flattenToolReadContent,
nonEmptyString, nonEmptyString,
truncateDiffText, truncateDiffText,
@@ -83,12 +84,11 @@ const ToolShellOutputObjectSchema = z
export const ToolShellOutputSchema = z.union([ export const ToolShellOutputSchema = z.union([
z.string().transform((value) => ({ z.string().transform((value) => ({
command: undefined, command: undefined,
output: nonEmptyString(value), output: extractCodexShellOutput(value),
exitCode: undefined, exitCode: undefined,
})), })),
ToolShellOutputObjectSchema.transform((value) => ({ ToolShellOutputObjectSchema.transform((value) => {
command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command), const rawOutput =
output:
nonEmptyString(value.output) ?? nonEmptyString(value.output) ??
nonEmptyString(value.text) ?? nonEmptyString(value.text) ??
nonEmptyString(value.content) ?? nonEmptyString(value.content) ??
@@ -102,14 +102,19 @@ export const ToolShellOutputSchema = z.union([
nonEmptyString(value.structured_content?.content) ?? nonEmptyString(value.structured_content?.content) ??
nonEmptyString(value.result?.output) ?? nonEmptyString(value.result?.output) ??
nonEmptyString(value.result?.text) ?? nonEmptyString(value.result?.text) ??
nonEmptyString(value.result?.content), nonEmptyString(value.result?.content);
exitCode:
value.exitCode ?? return {
value.exit_code ?? command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command),
value.metadata?.exitCode ?? output: extractCodexShellOutput(rawOutput),
value.metadata?.exit_code ?? exitCode:
undefined, value.exitCode ??
})), value.exit_code ??
value.metadata?.exitCode ??
value.metadata?.exit_code ??
undefined,
};
}),
]); ]);
export const ToolPathInputSchema = z.union([ export const ToolPathInputSchema = z.union([
@@ -21,6 +21,70 @@ export function commandFromValue(value: unknown): string | undefined {
return tokens.length > 0 ? tokens.join(" ") : 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<Chunk extends ReadChunkLike>( export function flattenReadContent<Chunk extends ReadChunkLike>(
value: string | Chunk | Chunk[] | undefined value: string | Chunk | Chunk[] | undefined
): string | undefined { ): string | undefined {
@@ -770,6 +770,110 @@ describe("daemon client E2E", () => {
90_000 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( speechTest(
"streams dictation PCM and returns final transcript", "streams dictation PCM and returns final transcript",
async () => { async () => {
+419 -90
View File
@@ -31,6 +31,7 @@ import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js"; import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
import { import {
DictationStreamManager, DictationStreamManager,
type DictationStreamOutboundMessage,
} from "./dictation/dictation-stream-manager.js"; } from "./dictation/dictation-stream-manager.js";
import { import {
buildConfigOverrides, buildConfigOverrides,
@@ -241,6 +242,8 @@ const MIN_STREAMING_SEGMENT_DURATION_MS = 1000;
const MIN_STREAMING_SEGMENT_BYTES = Math.round( const MIN_STREAMING_SEGMENT_BYTES = Math.round(
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS 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 SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
const AgentIdSchema = z.string().uuid(); const AgentIdSchema = z.string().uuid();
const VOICE_MCP_SERVER_NAME = "paseo_voice"; const VOICE_MCP_SERVER_NAME = "paseo_voice";
@@ -257,6 +260,18 @@ interface AudioBufferState {
totalPCMBytes: number; 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 = { export type SessionOptions = {
clientId: string; clientId: string;
onMessage: (msg: SessionOutboundMessage) => void; onMessage: (msg: SessionOutboundMessage) => void;
@@ -384,11 +399,25 @@ export class Session {
private speechInProgress = false; private speechInProgress = false;
private readonly dictationStreamManager: DictationStreamManager; private readonly dictationStreamManager: DictationStreamManager;
private readonly voiceStreamManager: DictationStreamManager;
// Audio buffering for interruption handling // Audio buffering for interruption handling
private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = []; private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = [];
private bufferTimeout: NodeJS.Timeout | null = null; private bufferTimeout: NodeJS.Timeout | null = null;
private voiceModeInactivityTimeout: NodeJS.Timeout | null = null;
private audioBuffer: AudioBufferState | null = null; private audioBuffer: AudioBufferState | null = null;
private activeVoiceDictationId: string | null = null;
private activeVoiceDictationFormat: string | null = null;
private activeVoiceDictationNextSeq = 0;
private activeVoiceDictationStartPromise: Promise<void> | null = null;
private activeVoiceDictationFinalizePromise: Promise<void> | 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) // Optional TTS debug capture (persisted per utterance)
private readonly ttsDebugStreams = new Map< private readonly ttsDebugStreams = new Map<
@@ -511,10 +540,17 @@ export class Session {
this.dictationStreamManager = new DictationStreamManager({ this.dictationStreamManager = new DictationStreamManager({
logger: this.sessionLogger, logger: this.sessionLogger,
sessionId: this.sessionId, sessionId: this.sessionId,
emit: (msg) => this.emit(msg as unknown as SessionOutboundMessage), emit: (msg) => this.handleDictationManagerMessage(msg),
stt: dictation?.stt ?? null, stt: dictation?.stt ?? null,
finalTimeoutMs: dictation?.finalTimeoutMs, 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 // Initialize agent MCP client asynchronously
void this.initializeAgentMcp(); void this.initializeAgentMcp();
@@ -1573,6 +1609,9 @@ export class Session {
private async disableVoiceModeForActiveAgent( private async disableVoiceModeForActiveAgent(
restoreAgentConfig: boolean restoreAgentConfig: boolean
): Promise<void> { ): Promise<void> {
this.clearVoiceModeInactivityTimeout();
this.cancelActiveVoiceDictationStream("voice mode disabled");
const agentId = this.voiceModeAgentId; const agentId = this.voiceModeAgentId;
if (!agentId) { if (!agentId) {
this.voiceModeBaseConfig = null; this.voiceModeBaseConfig = null;
@@ -1607,6 +1646,234 @@ export class Session {
this.voiceModeAgentId = null; 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<void> {
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<void> {
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<void> {
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) * Handle text message to agent (with optional image attachments)
*/ */
@@ -4553,8 +4820,23 @@ export class Session {
await this.handleVoiceSpeechStart(); await this.handleVoiceSpeechStart();
const chunkBuffer = Buffer.from(msg.audio, "base64");
const chunkFormat = msg.format || "audio/wav"; 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"); const isPCMChunk = chunkFormat.toLowerCase().includes("pcm");
if (!this.audioBuffer) { if (!this.audioBuffer) {
@@ -4592,16 +4874,6 @@ export class Session {
this.audioBuffer.totalPCMBytes += chunkBuffer.length; 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 // In non-voice mode, use streaming threshold to process chunks
const reachedStreamingThreshold = const reachedStreamingThreshold =
!this.isVoiceMode && !this.isVoiceMode &&
@@ -4747,85 +5019,17 @@ export class Session {
"Transcription result" "Transcription result"
); );
// Emit transcription result await this.handleTranscriptionResultPayload({
this.emit({ text: result.text,
type: "transcription_result", language: result.language,
payload: { duration: result.duration,
text: result.text, requestId,
language: result.language, avgLogprob: result.avgLogprob,
duration: result.duration, isLowConfidence: result.isLowConfidence,
requestId, byteLength: result.byteLength,
avgLogprob: result.avgLogprob, format: result.format,
isLowConfidence: result.isLowConfidence, debugRecordingPath: result.debugRecordingPath,
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) { } catch (error: any) {
this.setPhase("idle"); this.setPhase("idle");
this.clearSpeechInProgress("transcription error"); this.clearSpeechInProgress("transcription error");
@@ -4842,6 +5046,89 @@ export class Session {
} }
} }
private async handleTranscriptionResultPayload(
result: VoiceTranscriptionResultPayload
): Promise<void> {
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 { private registerVoiceBridgeForAgent(agentId: string): void {
this.registerVoiceSpeakHandler?.(agentId, async ({ text, signal }) => { this.registerVoiceSpeakHandler?.(agentId, async ({ text, signal }) => {
this.sessionLogger.info( this.sessionLogger.info(
@@ -5061,6 +5348,8 @@ export class Session {
this.audioBuffer = null; this.audioBuffer = null;
} }
this.cancelActiveVoiceDictationStream("new speech turn started");
this.clearVoiceModeInactivityTimeout();
this.clearBufferTimeout(); this.clearBufferTimeout();
this.abortController.abort(); this.abortController.abort();
@@ -5126,6 +5415,43 @@ export class Session {
}, 10000); // 10 second timeout }, 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 * Clear buffer timeout
*/ */
@@ -5206,15 +5532,18 @@ export class Session {
this.abortController.abort(); this.abortController.abort();
// Clear timeouts // Clear timeouts
this.clearVoiceModeInactivityTimeout();
this.clearBufferTimeout(); this.clearBufferTimeout();
// Clear buffers // Clear buffers
this.cancelActiveVoiceDictationStream("session cleanup");
this.pendingAudioSegments = []; this.pendingAudioSegments = [];
this.audioBuffer = null; this.audioBuffer = null;
// Cleanup managers // Cleanup managers
this.ttsManager.cleanup(); this.ttsManager.cleanup();
this.sttManager.cleanup(); this.sttManager.cleanup();
this.voiceStreamManager.cleanupAll();
this.dictationStreamManager.cleanupAll(); this.dictationStreamManager.cleanupAll();
// Close MCP clients // Close MCP clients
@@ -3,6 +3,14 @@ import { tmpdir } from "os";
import path from "path"; import path from "path";
import { seedClaudeAuth } from "./claude-auth.js"; 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. * Sets up an isolated Claude config directory for testing.
* Creates a temp directory with: * Creates a temp directory with:
@@ -39,6 +47,12 @@ export function useTempClaudeConfigDir(): () => void {
} else { } else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir; 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;
}
}
}; };
} }