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
dummy-edit.txt Normal file
View File

@@ -0,0 +1,2 @@
Dummy edit created by Codex.
You asked for any edit, so this file is a simple placeholder.

View File

@@ -492,6 +492,9 @@ interface AgentConfigRowProps {
selectedModel: string;
isModelLoading: boolean;
onSelectModel: (modelId: string) => void;
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
disabled?: boolean;
}
@@ -506,6 +509,9 @@ export function AgentConfigRow({
selectedModel,
isModelLoading,
onSelectModel,
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
disabled,
}: AgentConfigRowProps): ReactElement {
const providerOptions: ComboSelectOption[] = useMemo(
@@ -540,7 +546,18 @@ export function AgentConfigRow({
return opts;
}, [models]);
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
() =>
thinkingOptions.map((option) => ({
id: option.id,
label: option.label,
})),
[thinkingOptions]
);
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
return (
<View style={styles.agentConfigRow}>
@@ -584,6 +601,21 @@ export function AgentConfigRow({
showLabel={false}
/>
</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 File

@@ -4,9 +4,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
export function AgentStatusDot({
status,
requiresAttention,
showInactive = false,
}: {
status: string | null | undefined;
requiresAttention: boolean | null | undefined;
showInactive?: boolean;
}) {
const { theme } = useUnistyles();
@@ -15,7 +17,9 @@ export function AgentStatusDot({
? theme.colors.palette.blue[500]
: requiresAttention
? theme.colors.success
: null;
: showInactive
? theme.colors.border
: null;
if (!color) {
return null;
@@ -31,4 +35,3 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full,
},
}));

View File

@@ -1099,11 +1099,13 @@ function PermissionRequestCard({
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={{
type: "unknown",
input: request.input ?? null,
output: null,
}}
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}

View File

@@ -7,12 +7,14 @@ import {
View,
Platform,
} from "react-native";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
@@ -26,13 +28,16 @@ export function CommandCenter() {
query,
setQuery,
activeIndex,
results,
items,
handleClose,
handleSelect,
handleSelectItem,
} = useCommandCenter();
if (Platform.OS !== "web") return null;
const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent");
return (
<Modal
visible={open}
@@ -49,7 +54,7 @@ export function CommandCenter() {
ref={inputRef}
value={query}
onChangeText={setQuery}
placeholder="Search agents"
placeholder="Type a command or search agents..."
placeholderTextColor={theme.colors.foregroundMuted}
style={[
styles.input,
@@ -67,44 +72,125 @@ export function CommandCenter() {
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{results.length === 0 ? (
{items.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
No matches
</Text>
) : (
results.map((agent, index) => {
const active = index === activeIndex;
return (
<Pressable
key={agentKey(agent)}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={() => handleSelect(agent)}
>
<View style={styles.rowContent}>
<View style={styles.rowTitle}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
<>
{actionItems.length > 0 ? (
<>
<Text style={[styles.sectionLabel, { color: theme.colors.foregroundMuted }]}>
Actions
</Text>
{actionItems.map((item, index) => {
const active = index === activeIndex;
const action = item.action;
const actionIcon =
action.icon === "plus" ? (
<Plus
size={16}
strokeWidth={2.4}
color={theme.colors.foregroundMuted}
/>
) : action.icon === "settings" ? (
<Settings
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"}
</Text>
</View>
<Text
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}
numberOfLines={1}
>
{agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
</Pressable>
);
})
<View style={styles.rowContent}>
<View style={styles.rowMain}>
{actionIcon ? (
<View style={styles.iconSlot}>{actionIcon}</View>
) : null}
<View style={styles.textContent}>
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{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>
</View>
@@ -152,24 +238,55 @@ const styles = StyleSheet.create((theme) => ({
resultsContent: {
paddingVertical: theme.spacing[2],
},
sectionLabel: {
paddingHorizontal: theme.spacing[4],
paddingTop: 0,
paddingBottom: theme.spacing[2],
fontSize: theme.fontSize.xs,
},
sectionDivider: {
height: 1,
marginTop: theme.spacing[2],
marginBottom: theme.spacing[2],
},
row: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
rowContent: {
gap: 2,
},
rowTitle: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
justifyContent: "space-between",
gap: theme.spacing[3],
},
rowMain: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[3],
},
iconSlot: {
width: 16,
height: 20,
alignItems: "center",
justifyContent: "center",
},
textContent: {
gap: 2,
},
rowShortcut: {
marginLeft: theme.spacing[2],
flexShrink: 0,
},
title: {
fontSize: theme.fontSize.base,
fontWeight: "400",
lineHeight: 20,
},
subtitle: {
fontSize: theme.fontSize.sm,
lineHeight: 18,
},
emptyText: {
paddingHorizontal: theme.spacing[4],

View File

@@ -5,6 +5,7 @@ import {
closestCenter,
KeyboardSensor,
PointerSensor,
type Modifier,
useSensor,
useSensors,
type DragEndEvent,
@@ -25,6 +26,11 @@ import type {
export type { DraggableListProps, DraggableRenderItemInfo };
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
...transform,
x: 0,
});
interface SortableItemProps<T> {
id: string;
item: T;
@@ -157,6 +163,7 @@ export function DraggableList<T>({
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>

View File

@@ -177,6 +177,7 @@ interface DiffFileSectionProps {
file: ParsedDiffFile;
isExpanded: boolean;
onToggle: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string;
}
@@ -218,6 +219,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
file,
isExpanded,
onToggle,
onHeaderHeightChange,
testID,
}: DiffFileSectionProps) {
const expandStartRef = useRef<number | null>(null);
@@ -292,6 +294,9 @@ const DiffFileHeader = memo(function DiffFileHeader({
styles.fileSectionHeaderContainer,
!isExpanded && styles.fileSectionBorder,
]}
onLayout={(event) => {
onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
<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 [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const horizontalScroll = useHorizontalScrollOptional();
@@ -368,7 +381,13 @@ function DiffFileBody({ file, testID }: { file: ParsedDiffFile; testID?: string
);
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" ? (
<View style={styles.statusMessageContainer}>
<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
const [isManualRefresh, setIsManualRefresh] = useState(false);
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(() => {
let hunkCount = 0;
let lineCount = 0;
@@ -533,13 +556,6 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
[shipDefaultStorageKey]
);
const handleToggleExpanded = useCallback((path: string) => {
setExpandedByPath((prev) => ({
...prev,
[path]: !prev[path],
}));
}, []);
const { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = [];
const stickyIndices: number[] = [];
@@ -555,6 +571,60 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [files, expandedByPath]);
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) {
return;
}
headerHeightByPathRef.current[path] = height;
defaultHeaderHeightRef.current = height;
}, []);
const handleBodyHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height < 0) {
return;
}
bodyHeightByPathRef.current[path] = height;
}, []);
const computeHeaderOffset = useCallback(
(path: string): number => {
const defaultHeaderHeight = defaultHeaderHeightRef.current;
let offset = 0;
for (const file of files) {
if (file.path === path) {
break;
}
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
if (expandedByPath[file.path]) {
offset += bodyHeightByPathRef.current[file.path] ?? 0;
}
}
return Math.max(0, offset);
},
[expandedByPath, files]
);
const handleToggleExpanded = useCallback(
(path: string) => {
const isCurrentlyExpanded = expandedByPath[path] ?? false;
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
// Anchor to the clicked header before collapsing so visual context is preserved.
if (isCurrentlyExpanded && targetOffset !== null) {
diffListRef.current?.scrollToOffset({
offset: targetOffset,
animated: false,
});
}
setExpandedByPath((prev) => ({
...prev,
[path]: !prev[path],
}));
},
[computeHeaderOffset, expandedByPath]
);
const allExpanded = useMemo(() => {
if (files.length === 0) return false;
return files.every((file) => expandedByPath[file.path]);
@@ -707,15 +777,20 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
file={item.file}
isExpanded={item.isExpanded}
onToggle={handleToggleExpanded}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`}
/>
);
}
return (
<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(
@@ -808,6 +883,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
} else {
bodyContent = (
<FlatList
ref={diffListRef}
data={flatItems}
renderItem={renderFlatItem}
keyExtractor={flatKeyExtractor}
@@ -818,9 +894,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
testID="git-diff-scroll"
onRefresh={handleRefresh}
refreshing={isManualRefresh && isDiffFetching}
initialNumToRender={6}
maxToRenderPerBatch={6}
windowSize={5}
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
removeClippedSubviews={false}
initialNumToRender={12}
maxToRenderPerBatch={12}
windowSize={10}
/>
);
}

View File

@@ -138,31 +138,14 @@ export function ToolCallDetailsContent({
</View>
);
} else if (detail?.type === "unknown") {
const sectionsFromTopLevel = [
{ title: "Input", value: detail.input },
{ title: "Output", value: detail.output },
].filter((entry) => entry.value !== null && entry.value !== undefined);
const plainInputText =
typeof detail.input === "string" && detail.output === null
? detail.input
: null;
for (const section of sectionsFromTopLevel) {
let value = "";
try {
value =
typeof section.value === "string"
? section.value
: JSON.stringify(section.value, null, 2);
} catch {
value = String(section.value);
}
if (!value.length) {
continue;
}
if (plainInputText !== null) {
sections.push(
<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}>
<View key="unknown-plain-text" style={styles.section}>
<ScrollView
horizontal
nestedScrollEnabled
@@ -170,10 +153,48 @@ export function ToolCallDetailsContent({
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>{value}</Text>
<Text selectable style={styles.scrollText}>{plainInputText}</Text>
</ScrollView>
</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>
);
}
}
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-form-state";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
describe("useAgentFormState", () => {
describe("__private__.combineInitialValues", () => {
@@ -35,5 +36,115 @@ describe("useAgentFormState", () => {
});
});
});
});
describe("__private__.resolveFormState", () => {
const codexModels: AgentModelDefinition[] = [
{
provider: "codex",
id: "gpt-5.3-codex",
label: "gpt-5.3-codex",
isDefault: true,
defaultThinkingOptionId: "xhigh",
thinkingOptions: [
{ id: "low", label: "low" },
{ id: "xhigh", label: "xhigh", isDefault: true },
],
},
];
it("auto-selects the model's default thinking option when none is configured", () => {
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex" },
codexModels,
{
serverId: false,
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
},
{
serverId: null,
provider: "codex",
modeId: "",
model: "",
thinkingOptionId: "",
workingDir: "",
},
new Set<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");
});
});
});

View File

@@ -19,6 +19,7 @@ export interface FormInitialValues {
provider?: AgentProvider;
modeId?: string | null;
model?: string | null;
thinkingOptionId?: string | null;
workingDir?: string;
}
@@ -28,6 +29,7 @@ interface UserModifiedFields {
provider: boolean;
modeId: boolean;
model: boolean;
thinkingOptionId: boolean;
workingDir: boolean;
}
@@ -36,6 +38,7 @@ const INITIAL_USER_MODIFIED: UserModifiedFields = {
provider: false,
modeId: false,
model: false,
thinkingOptionId: false,
workingDir: false,
};
@@ -45,6 +48,7 @@ interface FormState {
provider: AgentProvider;
modeId: string;
model: string;
thinkingOptionId: string;
workingDir: string;
}
@@ -67,6 +71,8 @@ type UseAgentFormStateResult = {
setModeFromUser: (modeId: string) => void;
selectedModel: string;
setModelFromUser: (modelId: string) => void;
selectedThinkingOptionId: string;
setThinkingOptionFromUser: (thinkingOptionId: string) => void;
workingDir: string;
setWorkingDir: (value: string) => void;
setWorkingDirFromUser: (value: string) => void;
@@ -75,6 +81,7 @@ type UseAgentFormStateResult = {
agentDefinition?: AgentProviderDefinition;
modeOptions: AgentMode[];
availableModels: AgentModelDefinition[];
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
isModelLoading: boolean;
modelError: string | null;
refreshProviderModels: () => void;
@@ -91,6 +98,32 @@ const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER =
fallbackDefinition?.defaultModeId ?? "";
function resolveDefaultModel(
availableModels: AgentModelDefinition[] | null
): AgentModelDefinition | null {
if (!availableModels || availableModels.length === 0) {
return null;
}
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
}
function resolveEffectiveModel(
availableModels: AgentModelDefinition[] | null,
modelId: string
): AgentModelDefinition | null {
if (!availableModels || availableModels.length === 0) {
return null;
}
const normalizedModelId = modelId.trim();
if (!normalizedModelId) {
return resolveDefaultModel(availableModels);
}
return (
availableModels.find((model) => model.id === normalizedModelId) ??
resolveDefaultModel(availableModels)
);
}
/**
* Pure function that resolves form state from multiple data sources.
* Priority: explicit (URL params) > preferences > provider defaults > fallback
@@ -175,7 +208,43 @@ function resolveFormState(
}
}
// 4. Resolve serverId (independent)
// 4. Resolve thinking option (depends on effective model)
const initialThinkingOptionId =
typeof initialValues?.thinkingOptionId === "string"
? initialValues.thinkingOptionId.trim()
: "";
const preferredThinkingOptionId =
providerPrefs?.thinkingOptionId?.trim() ?? "";
if (!userModified.thinkingOptionId) {
if (initialThinkingOptionId.length > 0) {
result.thinkingOptionId = initialThinkingOptionId;
} else if (preferredThinkingOptionId.length > 0) {
result.thinkingOptionId = preferredThinkingOptionId;
} else {
result.thinkingOptionId = "";
}
}
// Validate thinking option once model metadata is available.
if (availableModels) {
const effectiveModel = resolveEffectiveModel(availableModels, result.model);
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
if (thinkingOptions.length === 0) {
result.thinkingOptionId = "";
} else {
const thinkingIds = new Set(thinkingOptions.map((option) => option.id));
const defaultThinkingOptionId =
effectiveModel?.defaultThinkingOptionId ??
thinkingOptions[0]?.id ??
"";
if (!result.thinkingOptionId || !thinkingIds.has(result.thinkingOptionId)) {
result.thinkingOptionId = defaultThinkingOptionId;
}
}
}
// 5. Resolve serverId (independent)
// Only use stored serverId if the host still exists in the registry
if (!userModified.serverId) {
if (initialValues?.serverId !== undefined) {
@@ -186,7 +255,7 @@ function resolveFormState(
// else keep current
}
// 5. Resolve workingDir (independent)
// 6. Resolve workingDir (independent)
if (!userModified.workingDir) {
if (initialValues?.workingDir !== undefined) {
result.workingDir = initialValues.workingDir;
@@ -258,6 +327,7 @@ export function useAgentFormState(
provider: DEFAULT_PROVIDER,
modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER,
model: "",
thinkingOptionId: "",
workingDir: "",
}));
const formStateRef = useRef(formState);
@@ -342,6 +412,7 @@ export function useAgentFormState(
resolved.provider !== formStateRef.current.provider ||
resolved.modeId !== formStateRef.current.modeId ||
resolved.model !== formStateRef.current.model ||
resolved.thinkingOptionId !== formStateRef.current.thinkingOptionId ||
resolved.workingDir !== formStateRef.current.workingDir
) {
setFormState(resolved);
@@ -430,6 +501,7 @@ export function useAgentFormState(
provider,
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
model: providerPrefs?.model ?? "",
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
}));
},
[preferences?.providerPreferences, updatePreferences]
@@ -453,6 +525,15 @@ export function useAgentFormState(
[formState.provider, updateProviderPreferences]
);
const setThinkingOptionFromUser = useCallback(
(thinkingOptionId: string) => {
setFormState((prev) => ({ ...prev, thinkingOptionId }));
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
void updateProviderPreferences(formState.provider, { thinkingOptionId });
},
[formState.provider, updateProviderPreferences]
);
const setWorkingDir = useCallback((value: string) => {
setFormState((prev) => ({ ...prev, workingDir: value }));
}, []);
@@ -475,27 +556,40 @@ export function useAgentFormState(
}, [providerModelsQuery]);
const persistFormPreferences = useCallback(async () => {
const providerPreferenceUpdates: {
mode: string;
model: string;
thinkingOptionId?: string;
} = {
mode: formState.modeId,
model: formState.model,
};
if (userModified.thinkingOptionId) {
providerPreferenceUpdates.thinkingOptionId = formState.thinkingOptionId;
}
await updatePreferences({
workingDir: formState.workingDir,
provider: formState.provider,
serverId: formState.serverId ?? undefined,
});
await updateProviderPreferences(formState.provider, {
mode: formState.modeId,
model: formState.model,
});
await updateProviderPreferences(formState.provider, providerPreferenceUpdates);
}, [
formState.modeId,
formState.model,
formState.provider,
formState.serverId,
formState.thinkingOptionId,
formState.workingDir,
userModified.thinkingOptionId,
updatePreferences,
updateProviderPreferences,
]);
const agentDefinition = providerDefinitionMap.get(formState.provider);
const modeOptions = agentDefinition?.modes ?? [];
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
const modelError =
providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null;
@@ -513,6 +607,8 @@ export function useAgentFormState(
setModeFromUser,
selectedModel: formState.model,
setModelFromUser,
selectedThinkingOptionId: formState.thinkingOptionId,
setThinkingOptionFromUser,
workingDir: formState.workingDir,
setWorkingDir,
setWorkingDirFromUser,
@@ -521,6 +617,7 @@ export function useAgentFormState(
agentDefinition,
modeOptions,
availableModels: availableModels ?? [],
availableThinkingOptions,
isModelLoading,
modelError,
refreshProviderModels,
@@ -532,17 +629,20 @@ export function useAgentFormState(
formState.provider,
formState.modeId,
formState.model,
formState.thinkingOptionId,
formState.workingDir,
setSelectedServerId,
setSelectedServerIdFromUser,
setProviderFromUser,
setModeFromUser,
setModelFromUser,
setThinkingOptionFromUser,
setWorkingDir,
setWorkingDirFromUser,
agentDefinition,
modeOptions,
availableModels,
availableThinkingOptions,
isModelLoading,
modelError,
refreshProviderModels,

View File

@@ -4,10 +4,20 @@ import { router, usePathname } from "expo-router";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import {
buildNewAgentRoute,
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { focusWithRetries } from "@/utils/web-focus";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
@@ -41,6 +51,74 @@ function parseAgentKeyFromPathname(pathname: string): string | null {
return `${match[1]}:${match[2]}`;
}
function parseAgentRouteFromPathname(
pathname: string
): { serverId: string; agentId: string } | null {
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/);
if (!match) return null;
const [, serverId, agentId] = match;
if (!serverId || !agentId) return null;
return { serverId, agentId };
}
type CommandCenterActionDefinition = {
id: string;
title: string;
icon?: "plus" | "settings";
shortcutKeys?: ShortcutKey[];
keywords: string[];
buildRoute: (params: { newAgentRoute: string }) => string;
};
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
{
id: "new-agent",
title: "New agent",
icon: "plus",
shortcutKeys: ["mod", "alt", "N"],
keywords: ["new", "new agent", "create", "start", "launch", "agent"],
buildRoute: ({ newAgentRoute }) => newAgentRoute,
},
{
id: "settings",
title: "Settings",
icon: "settings",
keywords: ["settings", "preferences", "config", "configuration"],
buildRoute: () => "/settings",
},
];
function matchesActionQuery(
query: string,
action: CommandCenterActionDefinition
): boolean {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
if (action.title.toLowerCase().includes(normalized)) {
return true;
}
return action.keywords.some((keyword) => keyword.includes(normalized));
}
export type CommandCenterActionItem = {
kind: "action";
id: string;
title: string;
icon?: "plus" | "settings";
route: string;
shortcutKeys?: ShortcutKey[];
};
export type CommandCenterItem =
| {
kind: "action";
action: CommandCenterActionItem;
}
| {
kind: "agent";
agent: AggregatedAgent;
};
export function useCommandCenter() {
const pathname = usePathname();
const { agents } = useAggregatedAgents();
@@ -53,7 +131,7 @@ export function useCommandCenter() {
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const results = useMemo(() => {
const agentResults = useMemo(() => {
const filtered = agents.filter((agent) => isMatch(agent, query));
filtered.sort(sortAgents);
return filtered;
@@ -64,11 +142,62 @@ export function useCommandCenter() {
[pathname]
);
const newAgentRoute = useMemo(() => {
const routeAgent = parseAgentRouteFromPathname(pathname);
if (!routeAgent) {
return "/agent";
}
const { serverId, agentId } = routeAgent;
const currentAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
const cwd = currentAgent?.cwd?.trim();
if (!cwd) {
return "/agent";
}
const checkout =
queryClient.getQueryData<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(() => {
setOpen(false);
}, [setOpen]);
const handleSelect = useCallback(
const handleSelectAgent = useCallback(
(agent: AggregatedAgent) => {
didNavigateRef.current = true;
const session = useSessionStore.getState().sessions[agent.serverId];
@@ -86,6 +215,24 @@ export function useCommandCenter() {
[pathname, requestFocusChatInput, setOpen]
);
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
didNavigateRef.current = true;
clearCommandCenterFocusRestoreElement();
setOpen(false);
router.push(action.route as any);
}, [setOpen]);
const handleSelectItem = useCallback(
(item: CommandCenterItem) => {
if (item.kind === "action") {
handleSelectAction(item.action);
return;
}
handleSelectAgent(item.agent);
},
[handleSelectAction, handleSelectAgent]
);
useEffect(() => {
const prevOpen = prevOpenRef.current;
prevOpenRef.current = open;
@@ -126,10 +273,10 @@ export function useCommandCenter() {
useEffect(() => {
if (!open) return;
if (activeIndex >= results.length) {
setActiveIndex(results.length > 0 ? results.length - 1 : 0);
if (activeIndex >= items.length) {
setActiveIndex(items.length > 0 ? items.length - 1 : 0);
}
}, [activeIndex, open, results.length]);
}, [activeIndex, items.length, open]);
useEffect(() => {
if (!open) return;
@@ -152,21 +299,21 @@ export function useCommandCenter() {
}
if (key === "Enter") {
if (results.length === 0) return;
if (items.length === 0) return;
event.preventDefault();
const index = Math.max(0, Math.min(activeIndex, results.length - 1));
handleSelect(results[index]!);
const index = Math.max(0, Math.min(activeIndex, items.length - 1));
handleSelectItem(items[index]!);
return;
}
if (key === "ArrowDown" || key === "ArrowUp") {
if (results.length === 0) return;
if (items.length === 0) return;
event.preventDefault();
setActiveIndex((current) => {
const delta = key === "ArrowDown" ? 1 : -1;
const next = current + delta;
if (next < 0) return results.length - 1;
if (next >= results.length) return 0;
if (next < 0) return items.length - 1;
if (next >= items.length) return 0;
return next;
});
}
@@ -175,7 +322,7 @@ export function useCommandCenter() {
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [activeIndex, handleClose, handleSelect, open, results]);
}, [activeIndex, handleClose, handleSelectItem, items, open]);
return {
open,
@@ -184,9 +331,8 @@ export function useCommandCenter() {
setQuery,
activeIndex,
setActiveIndex,
results,
items,
handleClose,
handleSelect,
handleSelectItem,
};
}

View File

@@ -10,6 +10,7 @@ const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingOptionId: z.string().optional(),
});
const formPreferencesSchema = z.object({

View File

@@ -3,8 +3,18 @@ import { Platform } from "react-native";
import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useSessionStore } from "@/stores/session-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
import {
buildNewAgentRoute,
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
export function useGlobalKeyboardNav({
enabled,
@@ -81,6 +91,29 @@ export function useGlobalKeyboardNav({
navigate(`/agent/${serverId}/${agentId}` as any);
};
const navigateToNewAgent = () => {
let target = "/agent";
if (selectedAgentId) {
const separatorIndex = selectedAgentId.indexOf(":");
if (separatorIndex > 0) {
const serverId = selectedAgentId.slice(0, separatorIndex);
const agentId = selectedAgentId.slice(separatorIndex + 1);
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
const cwd = agent?.cwd?.trim();
if (cwd) {
const checkout =
queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(serverId, cwd)
) ?? null;
const workingDir = resolveNewAgentWorkingDir(cwd, checkout);
target = buildNewAgentRoute(workingDir);
}
}
}
router.push(target as any);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
@@ -103,9 +136,28 @@ export function useGlobalKeyboardNav({
}
}
const isMod = event.metaKey || event.ctrlKey;
const isKeyN = event.code === "KeyN" || lowerKey === "n";
// Cmd/Ctrl+Alt+N: new agent (web + Tauri)
// Note: intentionally works even when focus is inside an input/textarea.
if (isMod && event.altKey && !event.shiftKey && isKeyN) {
event.preventDefault();
navigateToNewAgent();
return;
}
// Cmd/Ctrl+N: new agent (Tauri only)
// Note: intentionally works even when focus is inside an input/textarea.
if (isTauri && isMod && !event.altKey && !event.shiftKey && isKeyN) {
event.preventDefault();
navigateToNewAgent();
return;
}
// Cmd+B: toggle sidebar
if (
(event.metaKey || event.ctrlKey) &&
isMod &&
(event.code === "KeyB" || lowerKey === "b")
) {
// The MessageInput already handles Cmd+B inside editable fields. If we also
@@ -121,7 +173,7 @@ export function useGlobalKeyboardNav({
// Cmd+.: toggle sidebar (VS Code quick-fix muscle memory)
// Note: intentionally works even when focus is inside an input/textarea.
if (
(event.metaKey || event.ctrlKey) &&
isMod &&
(event.code === "Period" || key === ".")
) {
// Ignore while command center is open.
@@ -137,7 +189,7 @@ export function useGlobalKeyboardNav({
if (
selectedAgentId &&
toggleFileExplorer &&
(event.metaKey || event.ctrlKey) &&
isMod &&
(event.code === "KeyE" || lowerKey === "e")
) {
// Same double-toggle issue as Cmd+B when focus is inside a text input.
@@ -168,7 +220,7 @@ export function useGlobalKeyboardNav({
}
// Cmd+K: command center
if ((event.metaKey || event.ctrlKey) && lowerKey === "k") {
if (isMod && lowerKey === "k") {
event.preventDefault();
const s = useKeyboardNavStore.getState();
if (!s.commandCenterOpen) {
@@ -205,7 +257,7 @@ export function useGlobalKeyboardNav({
}
// Cmd/Ctrl+number: Tauri only (avoid browser tab switching)
if (isTauri && (event.metaKey || event.ctrlKey)) {
if (isTauri && isMod) {
event.preventDefault();
navigateToSidebarShortcut(digit);
}

View File

@@ -104,6 +104,7 @@ type DraftAgentParams = {
provider?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
workingDir?: string;
};
@@ -146,6 +147,7 @@ export function DraftAgentScreen({
const resolvedProvider = getValidProvider(getParamValue(params.provider));
const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId));
const resolvedModel = getParamValue(params.model);
const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId);
const resolvedWorkingDir = getParamValue(params.workingDir);
const onlineServerIds = useMemo(() => {
@@ -172,8 +174,17 @@ export function DraftAgentScreen({
if (resolvedModel) {
values.model = resolvedModel;
}
if (resolvedThinkingOptionId) {
values.thinkingOptionId = resolvedThinkingOptionId;
}
return values;
}, [resolvedMode, resolvedModel, resolvedProvider, resolvedWorkingDir]);
}, [
resolvedMode,
resolvedModel,
resolvedProvider,
resolvedThinkingOptionId,
resolvedWorkingDir,
]);
const {
selectedServerId,
@@ -184,11 +195,14 @@ export function DraftAgentScreen({
setModeFromUser,
selectedModel,
setModelFromUser,
selectedThinkingOptionId,
setThinkingOptionFromUser,
workingDir,
setWorkingDirFromUser,
providerDefinitions,
modeOptions,
availableModels,
availableThinkingOptions,
isModelLoading,
modelError,
refreshProviderModels,
@@ -688,6 +702,7 @@ export function DraftAgentScreen({
const cwd = (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || ".";
const provider = selectedProvider;
const model = selectedModel.trim() || null;
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
return {
@@ -713,6 +728,7 @@ export function DraftAgentScreen({
title: "New agent",
cwd,
model,
thinkingOptionId,
labels: {},
};
}, [
@@ -722,6 +738,7 @@ export function DraftAgentScreen({
modeOptions.length,
selectedMode,
selectedModel,
selectedThinkingOptionId,
selectedProvider,
selectedServerId,
selectedWorktreePath,
@@ -797,11 +814,15 @@ export function DraftAgentScreen({
const modeId =
modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
const trimmedModel = selectedModel.trim();
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
const config: AgentSessionConfig = {
provider: selectedProvider,
cwd: resolvedWorkingDir,
...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}),
...(trimmedThinkingOptionId
? { thinkingOptionId: trimmedThinkingOptionId }
: {}),
};
const effectiveBaseBranch = baseBranch.trim();
const effectiveWorktreeSlug =
@@ -874,6 +895,7 @@ export function DraftAgentScreen({
router,
selectedMode,
selectedModel,
selectedThinkingOptionId,
selectedProvider,
selectedServerId,
createAgentClient,
@@ -979,6 +1001,9 @@ export function DraftAgentScreen({
selectedModel={selectedModel}
isModelLoading={isModelLoading}
onSelectModel={setModelFromUser}
thinkingOptions={availableThinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId}
onSelectThinkingOption={setThinkingOptionFromUser}
/>
{isMobile && trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
<View style={styles.formSeparator} />

View File

@@ -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"
);
});
});

View File

@@ -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)}`;
}

View File

@@ -14,16 +14,19 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], 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) {
return TOOL_DETAIL_ICONS[detail.type];
}
const lowerName = toolName.trim().toLowerCase();
if (lowerName === "task") {
return Bot;
}
if (lowerName === "thinking") {
return Brain;
}
return Wrench;
}

View File

@@ -158,6 +158,49 @@ describe("EncryptedChannel", () => {
expect(sentData.length).toBeGreaterThan(plaintext.length + 20);
});
it("does not throw uncaught when handshake hello retry send fails", async () => {
vi.useFakeTimers();
try {
const daemonKeyPair = await generateKeyPair();
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
const transport: Transport = {
send: vi.fn(),
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
let sendAttempts = 0;
(transport.send as ReturnType<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 () => {
const [daemonTransport] = createMockTransportPair();

View File

@@ -85,6 +85,21 @@ export async function createClientChannel(
const helloText = JSON.stringify(hello);
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 = () => {
if (retry) {
clearInterval(retry);
@@ -95,13 +110,13 @@ export async function createClientChannel(
channel.onTransitionToOpen(() => clearRetry());
channel.onClose(() => clearRetry());
transport.send(helloText);
sendHello();
retry = setInterval(() => {
if (channel.isOpen()) {
clearRetry();
return;
}
transport.send(helloText);
sendHello();
}, HANDSHAKE_RETRY_MS);
// Avoid keeping Node processes alive (e.g. tests) if the handshake is stuck.
(retry as unknown as { unref?: () => void }).unref?.();

View File

@@ -223,6 +223,7 @@ export type AgentPermissionRequest = {
title?: string;
description?: string;
input?: AgentMetadata;
detail?: ToolCallDetail;
suggestions?: AgentPermissionUpdate[];
metadata?: AgentMetadata;
};

View File

@@ -368,26 +368,35 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
);
test(
"shows the command inside pending tool calls",
"shows the command inside permission requests",
async () => {
const cwd = tmpCwd();
const client = new ClaudeAgentClient({ logger });
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
const session = await client.createSession(config);
const filePath = path.join(cwd, "permission.txt");
writeFileSync(filePath, "ok", "utf8");
let pendingCommand: string | null = null;
const events = session.stream("Run the exact command `pwd` via Bash and stop.");
let requestedCommand: string | null = null;
const events = session.stream(
"Run the exact command `rm -f permission.txt` via Bash and stop."
);
try {
for await (const event of events) {
await autoApprove(session, event);
if (
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name.toLowerCase().includes("bash") &&
event.item.status === "pending"
event.type === "permission_requested" &&
event.request.kind === "tool" &&
event.request.name.toLowerCase().includes("bash")
) {
pendingCommand = extractToolCommand(event.item.detail);
requestedCommand = extractToolCommand(
event.request.detail ?? {
type: "unknown",
input: event.request.input ?? null,
output: null,
}
);
await session.respondToPermission(event.request.id, { behavior: "allow" });
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
@@ -398,8 +407,8 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
rmSync(cwd, { recursive: true, force: true });
}
expect(pendingCommand).toBeTruthy();
expect(pendingCommand?.toLowerCase()).toContain("pwd");
expect(requestedCommand).toBeTruthy();
expect(requestedCommand?.toLowerCase()).toContain("permission.txt");
},
150_000
);
@@ -501,6 +510,14 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
for await (const event of session.stream(prompt)) {
if (event.type === "permission_requested" && !captured) {
captured = event.request;
const requestedCommand = extractToolCommand(
captured.detail ?? {
type: "unknown",
input: captured.input ?? null,
output: null,
}
);
expect((requestedCommand ?? "").toLowerCase()).toContain("permission.txt");
expect(session.getPendingPermissions().length).toBeGreaterThan(0);
await session.respondToPermission(captured.id, { behavior: "allow" });
}
@@ -597,6 +614,14 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
item.status === "completed"
)
).toBe(false);
expect(
timeline.some(
(item) =>
item.type === "tool_call" &&
isPermissionCommandToolCall(item) &&
item.status === "failed"
)
).toBe(true);
expect(existsSync(filePath)).toBe(true);
} finally {
await cleanup();
@@ -668,6 +693,14 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
item.status === "completed"
)
).toBe(false);
expect(
timeline.some(
(item) =>
item.type === "tool_call" &&
isPermissionCommandToolCall(item) &&
item.status === "failed"
)
).toBe(true);
expect(existsSync(filePath)).toBe(true);
} finally {
await cleanup();
@@ -909,6 +942,79 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
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(
"hydrates persisted tool call results into the UI stream",
async () => {

View File

@@ -783,6 +783,20 @@ class ClaudeAgentSession implements AgentSession {
};
pending.resolve(result);
} else {
if (pending.request.kind === "tool") {
this.pushToolCall(
mapClaudeFailedToolCall({
name: pending.request.name,
callId:
(typeof pending.request.metadata?.toolUseId === "string"
? pending.request.metadata.toolUseId
: null) ?? pending.request.id,
input: pending.request.input ?? null,
output: null,
error: { message: response.message ?? "Permission denied" },
})
);
}
const result: PermissionResult = {
behavior: "deny",
message: response.message ?? "Permission request denied",
@@ -1361,6 +1375,7 @@ class ClaudeAgentSession implements AgentSession {
options
): Promise<PermissionResult> => {
const requestId = `permission-${randomUUID()}`;
const kind = resolvePermissionKind(toolName, input);
const metadata: AgentMetadata = {};
if (options.toolUseID) {
metadata.toolUseId = options.toolUseID;
@@ -1368,13 +1383,23 @@ class ClaudeAgentSession implements AgentSession {
if (toolName === "ExitPlanMode" && typeof input.plan === "string") {
metadata.planText = input.plan;
}
const detail =
kind === "tool"
? mapClaudeRunningToolCall({
name: toolName,
callId: options.toolUseID ?? requestId,
input,
output: null,
}).detail
: undefined;
const request: AgentPermissionRequest = {
id: requestId,
provider: "claude",
name: toolName,
kind: resolvePermissionKind(toolName, input),
kind,
input,
detail,
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
metadata: Object.keys(metadata).length ? metadata : undefined,
};

View File

@@ -203,6 +203,43 @@ describe("Codex app-server provider (integration)", () => {
30000
);
test.runIf(isCodexInstalled())(
"listModels honors configured Codex model + reasoning defaults",
async () => {
const codexHome = tmpCwd("codex-home-defaults-");
const prevCodexHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = codexHome;
writeFileSync(
path.join(codexHome, "config.toml"),
[
'model = "gpt-5.3-codex"',
'model_reasoning_effort = "xhigh"',
].join("\n"),
"utf8"
);
try {
const client = new CodexAppServerAgentClient(logger);
const models = await client.listModels();
const configuredModel = models.find((model) => model.id === "gpt-5.3-codex");
expect(configuredModel).toBeDefined();
expect(configuredModel?.isDefault).toBe(true);
expect(configuredModel?.defaultThinkingOptionId).toBe("xhigh");
expect(configuredModel?.thinkingOptions?.some((option) => option.id === "xhigh")).toBe(
true
);
} finally {
if (prevCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = prevCodexHome;
}
rmSync(codexHome, { recursive: true, force: true });
}
},
30000
);
test.runIf(isCodexInstalled())("accepts image prompt blocks without request validation errors", async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-image-prompt-");
@@ -473,6 +510,10 @@ describe("Codex app-server provider (integration)", () => {
if (event.type === "permission_requested" && event.request.name === "CodexBash") {
sawPermission = true;
captured = event.request;
expect(captured.detail?.type).toBe("shell");
if (captured.detail?.type === "shell") {
expect(captured.detail.command).toContain("printf");
}
await session.respondToPermission(event.request.id, { behavior: "allow" });
}
if (
@@ -515,6 +556,87 @@ describe("Codex app-server provider (integration)", () => {
}
}, 60000);
test.runIf(isCodexInstalled())("command approval deny emits failed tool call and skips execution", async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-cmd-deny-");
const filePath = path.join(cwd, "permission-deny.txt");
writeFileSync(filePath, "ok", "utf8");
try {
const client = new CodexAppServerAgentClient(logger);
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
approvalPolicy: "on-request",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
let sawPermission = false;
let captured: AgentPermissionRequest | null = null;
let sawPermissionResolvedDeny = false;
const timelineItems: AgentTimelineItem[] = [];
const events = session.stream(
[
"You must use your shell tool to run the exact command",
"`rm -f permission-deny.txt`.",
"If approval is denied, reply DENIED and stop.",
].join(" ")
);
let failure: string | null = null;
for await (const event of events) {
if (event.type === "permission_requested" && event.request.name === "CodexBash") {
sawPermission = true;
captured = event.request;
await session.respondToPermission(event.request.id, {
behavior: "deny",
message: "Denied by test",
});
}
if (
event.type === "permission_resolved" &&
captured &&
event.requestId === captured.id &&
event.resolution.behavior === "deny"
) {
sawPermissionResolvedDeny = true;
}
if (event.type === "timeline" && event.item.type === "tool_call") {
timelineItems.push(event.item);
}
if (event.type === "turn_failed") {
failure = event.error;
break;
}
if (event.type === "turn_completed") {
break;
}
}
await session.close();
if (failure) {
throw new Error(failure);
}
expect(sawPermission).toBe(true);
expect(sawPermissionResolvedDeny).toBe(true);
expect(
timelineItems.some(
(item) =>
item.status === "failed" && hasShellCommand(item, "permission-deny.txt")
)
).toBe(true);
expect(existsSync(filePath)).toBe(true);
} finally {
cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
test.runIf(isCodexInstalled())(
"streams responses and maps shell + file change tool calls into timeline items",
async () => {
@@ -651,6 +773,225 @@ describe("Codex app-server provider (integration)", () => {
120000
);
test.runIf(isCodexInstalled())(
"emits expandable canonical detail for apply_patch tool calls",
async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-patch-detail-");
const patchFile = path.join(cwd, "expandable-patch.txt");
try {
const client = new CodexAppServerAgentClient(logger);
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "full-access",
approvalPolicy: "on-request",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
const persistenceHandle = session.describePersistence();
const timelineItems: AgentTimelineItem[] = [];
let failure: string | null = null;
const patch = [
"*** Begin Patch",
"*** Add File: expandable-patch.txt",
"+expandable",
"*** End Patch",
].join("\n");
const events = session.stream(
[
"Use the apply_patch tool and nothing else.",
"Do not use shell or any other file-edit tool.",
"Apply this patch exactly:",
patch,
"After tool completion, reply PATCH_DONE.",
].join("\n")
);
for await (const event of events) {
if (event.type === "permission_requested") {
await session.respondToPermission(event.request.id, { behavior: "allow" });
}
if (event.type === "timeline") {
timelineItems.push(event.item);
}
if (event.type === "turn_failed") {
failure = event.error;
break;
}
if (event.type === "turn_completed") {
break;
}
}
await session.close();
if (failure) {
throw new Error(failure);
}
const patchCalls = timelineItems.filter(
(item): item is Extract<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())(
"interrupts long-running commands and emits a canceled turn",
async () => {

View File

@@ -17,6 +17,7 @@ import type {
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
ToolCallTimelineItem,
AgentUsage,
ListModelsOptions,
ListPersistedAgentsOptions,
@@ -34,7 +35,10 @@ import path from "node:path";
import readline from "node:readline";
import { z } from "zod";
import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js";
import { mapCodexToolCallFromThreadItem } from "./codex/tool-call-mapper.js";
import {
mapCodexRolloutToolCall,
mapCodexToolCallFromThreadItem,
} from "./codex/tool-call-mapper.js";
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
@@ -114,6 +118,32 @@ function normalizeCodexThinkingOptionId(
return normalized;
}
function normalizeCodexModelId(modelId: string | null | undefined): string | undefined {
if (typeof modelId !== "string") {
return undefined;
}
const normalized = modelId.trim();
if (!normalized) {
return undefined;
}
return normalized;
}
type CodexConfiguredDefaults = {
model?: string;
thinkingOptionId?: string;
};
function mergeCodexConfiguredDefaults(
primary: CodexConfiguredDefaults,
fallback: CodexConfiguredDefaults
): CodexConfiguredDefaults {
return {
model: primary.model ?? fallback.model,
thinkingOptionId: primary.thinkingOptionId ?? fallback.thinkingOptionId,
};
}
function resolveCodexBinary(): string {
try {
const codexPath = execSync("which codex", { encoding: "utf8" }).trim();
@@ -672,6 +702,230 @@ function planStepsToTodoItems(steps: Array<{ step: string; status: string }>): {
}));
}
type CodexPatchFileChange = {
path: string;
kind?: string;
content?: string;
};
function normalizeCodexThreadItemType(rawType: string | undefined): string | undefined {
if (!rawType) {
return rawType;
}
switch (rawType) {
case "UserMessage":
return "userMessage";
case "AgentMessage":
return "agentMessage";
case "Reasoning":
return "reasoning";
case "Plan":
return "plan";
case "CommandExecution":
return "commandExecution";
case "FileChange":
return "fileChange";
case "McpToolCall":
return "mcpToolCall";
case "WebSearch":
return "webSearch";
default:
return rawType;
}
}
function normalizeCodexCommandValue(
value: unknown
): string | string[] | null {
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed.length) {
return null;
}
const wrapperMatch = trimmed.match(
/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/
);
if (!wrapperMatch) {
return trimmed;
}
const candidate = wrapperMatch[1]?.trim() ?? "";
if (!candidate.length) {
return trimmed;
}
if (
(candidate.startsWith('"') && candidate.endsWith('"')) ||
(candidate.startsWith("'") && candidate.endsWith("'"))
) {
return candidate.slice(1, -1);
}
return candidate;
}
if (!Array.isArray(value)) {
return null;
}
const parts = value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (parts.length === 0) {
return null;
}
if (parts.length >= 3 && (parts[1] === "-lc" || parts[1] === "-c")) {
return parts[2] ?? parts;
}
return parts;
}
function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
if (!changes || typeof changes !== "object") {
return [];
}
return Object.entries(changes as Record<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(
item: any,
options?: { includeUserMessage?: boolean; cwd?: string | null }
@@ -679,25 +933,37 @@ function threadItemToTimeline(
if (!item || typeof item !== "object") return null;
const includeUserMessage = options?.includeUserMessage ?? true;
const cwd = options?.cwd ?? null;
switch (item.type) {
const normalizedType = normalizeCodexThreadItemType(
typeof item.type === "string" ? item.type : undefined
);
const normalizedItem =
normalizedType && normalizedType !== item.type
? ({ ...item, type: normalizedType } as typeof item)
: item;
switch (normalizedType) {
case "userMessage": {
if (!includeUserMessage) {
return null;
}
const text = extractUserText(item.content) ?? "";
const text = extractUserText(normalizedItem.content) ?? "";
return { type: "user_message", text };
}
case "agentMessage": {
return { type: "assistant_message", text: item.text ?? "" };
return { type: "assistant_message", text: normalizedItem.text ?? "" };
}
case "plan": {
const text = item.text ?? "";
const text = normalizedItem.text ?? "";
const items = parsePlanTextToTodoItems(text);
return { type: "todo", items };
}
case "reasoning": {
const summary = Array.isArray(item.summary) ? item.summary.join("\n") : "";
const content = Array.isArray(item.content) ? item.content.join("\n") : "";
const summary = Array.isArray(normalizedItem.summary)
? normalizedItem.summary.join("\n")
: "";
const content = Array.isArray(normalizedItem.content)
? normalizedItem.content.join("\n")
: "";
const text = summary || content;
return text ? { type: "reasoning", text } : null;
}
@@ -705,7 +971,7 @@ function threadItemToTimeline(
case "fileChange":
case "mcpToolCall":
case "webSearch":
return mapCodexToolCallFromThreadItem(item, { cwd });
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
default:
return null;
}
@@ -772,6 +1038,7 @@ const TurnCompletedNotificationSchema = z.object({
message: z.string().optional(),
})
.passthrough()
.nullable()
.optional(),
})
.passthrough(),
@@ -827,6 +1094,83 @@ const CodexEventTaskCompleteNotificationSchema = z.object({
.passthrough(),
}).passthrough();
const CodexEventItemLifecycleNotificationSchema = z.object({
msg: z
.object({
type: z.enum(["item_started", "item_completed"]),
item: z
.object({
id: z.string().optional(),
type: z.string().optional(),
})
.passthrough(),
})
.passthrough(),
}).passthrough();
const CodexEventExecCommandBeginNotificationSchema = z.object({
msg: z
.object({
type: z.literal("exec_command_begin"),
call_id: z.string().optional(),
command: z.unknown().optional(),
cwd: z.string().optional(),
})
.passthrough(),
}).passthrough();
const CodexEventExecCommandEndNotificationSchema = z.object({
msg: z
.object({
type: z.literal("exec_command_end"),
call_id: z.string().optional(),
command: z.unknown().optional(),
cwd: z.string().optional(),
stdout: z.string().optional(),
stderr: z.string().optional(),
aggregated_output: z.string().optional(),
aggregatedOutput: z.string().optional(),
formatted_output: z.string().optional(),
exit_code: z.number().nullable().optional(),
exitCode: z.number().nullable().optional(),
success: z.boolean().optional(),
})
.passthrough(),
}).passthrough();
const CodexEventPatchApplyBeginNotificationSchema = z.object({
msg: z
.object({
type: z.literal("patch_apply_begin"),
call_id: z.string().optional(),
changes: z.unknown().optional(),
})
.passthrough(),
}).passthrough();
const CodexEventPatchApplyEndNotificationSchema = z.object({
msg: z
.object({
type: z.literal("patch_apply_end"),
call_id: z.string().optional(),
changes: z.unknown().optional(),
stdout: z.string().optional(),
stderr: z.string().optional(),
success: z.boolean().optional(),
})
.passthrough(),
}).passthrough();
const CodexEventTurnDiffNotificationSchema = z.object({
msg: z
.object({
type: z.literal("turn_diff"),
unified_diff: z.string().optional(),
diff: z.string().optional(),
})
.passthrough(),
}).passthrough();
type ParsedCodexNotification =
| { kind: "thread_started"; threadId: string }
| { kind: "turn_started"; turnId: string }
@@ -836,8 +1180,45 @@ type ParsedCodexNotification =
| { kind: "token_usage_updated"; tokenUsage: unknown }
| { kind: "agent_message_delta"; itemId: string; delta: string }
| { kind: "reasoning_delta"; itemId: string; delta: string }
| { kind: "item_completed"; item: { id?: string; type?: string; [key: string]: unknown } }
| { kind: "item_started"; item: { id?: string; type?: string; [key: string]: unknown } }
| {
kind: "item_completed";
source: "item" | "codex_event";
item: { id?: string; type?: string; [key: string]: unknown };
}
| {
kind: "item_started";
source: "item" | "codex_event";
item: { id?: string; type?: string; [key: string]: unknown };
}
| {
kind: "exec_command_started";
callId: string | null;
command: unknown;
cwd: string | null;
}
| {
kind: "exec_command_completed";
callId: string | null;
command: unknown;
cwd: string | null;
output: string | null;
exitCode: number | null;
success: boolean | null;
stderr: string | null;
}
| {
kind: "patch_apply_started";
callId: string | null;
changes: unknown;
}
| {
kind: "patch_apply_completed";
callId: string | null;
changes: unknown;
stdout: string | null;
stderr: string | null;
success: boolean | null;
}
| { kind: "invalid_payload"; method: string; params: unknown }
| { kind: "unknown_method"; method: string; params: unknown };
@@ -915,17 +1296,121 @@ const CodexNotificationSchema = z.union([
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({ method: z.literal("item/completed"), params: ItemLifecycleNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({ kind: "item_completed", item: params.item })
({ params }): ParsedCodexNotification => ({ kind: "item_completed", source: "item", item: params.item })
),
z.object({ method: z.literal("item/completed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({ method: z.literal("item/started"), params: ItemLifecycleNotificationSchema }).transform(
({ params }): ParsedCodexNotification => ({ kind: "item_started", item: params.item })
({ params }): ParsedCodexNotification => ({ kind: "item_started", source: "item", item: params.item })
),
z.object({ method: z.literal("item/started"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({
method: z.literal("codex/event/item_started"),
params: CodexEventItemLifecycleNotificationSchema,
}).transform(
({ params }): ParsedCodexNotification => ({
kind: "item_started",
source: "codex_event",
item: params.msg.item,
})
),
z.object({ method: z.literal("codex/event/item_started"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({
method: z.literal("codex/event/item_completed"),
params: CodexEventItemLifecycleNotificationSchema,
}).transform(
({ params }): ParsedCodexNotification => ({
kind: "item_completed",
source: "codex_event",
item: params.msg.item,
})
),
z.object({ method: z.literal("codex/event/item_completed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({
method: z.literal("codex/event/exec_command_begin"),
params: CodexEventExecCommandBeginNotificationSchema,
}).transform(
({ params }): ParsedCodexNotification => ({
kind: "exec_command_started",
callId: params.msg.call_id ?? null,
command: params.msg.command ?? null,
cwd: params.msg.cwd ?? null,
})
),
z.object({ method: z.literal("codex/event/exec_command_begin"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({
method: z.literal("codex/event/exec_command_end"),
params: CodexEventExecCommandEndNotificationSchema,
}).transform(
({ params }): ParsedCodexNotification => ({
kind: "exec_command_completed",
callId: params.msg.call_id ?? null,
command: params.msg.command ?? null,
cwd: params.msg.cwd ?? null,
output:
params.msg.aggregated_output ??
params.msg.aggregatedOutput ??
params.msg.formatted_output ??
params.msg.stdout ??
null,
exitCode: params.msg.exit_code ?? params.msg.exitCode ?? null,
success: params.msg.success ?? null,
stderr: params.msg.stderr ?? null,
})
),
z.object({ method: z.literal("codex/event/exec_command_end"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({
method: z.literal("codex/event/patch_apply_begin"),
params: CodexEventPatchApplyBeginNotificationSchema,
}).transform(
({ params }): ParsedCodexNotification => ({
kind: "patch_apply_started",
callId: params.msg.call_id ?? null,
changes: params.msg.changes ?? null,
})
),
z.object({ method: z.literal("codex/event/patch_apply_begin"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({
method: z.literal("codex/event/patch_apply_end"),
params: CodexEventPatchApplyEndNotificationSchema,
}).transform(
({ params }): ParsedCodexNotification => ({
kind: "patch_apply_completed",
callId: params.msg.call_id ?? null,
changes: params.msg.changes ?? null,
stdout: params.msg.stdout ?? null,
stderr: params.msg.stderr ?? null,
success: params.msg.success ?? null,
})
),
z.object({ method: z.literal("codex/event/patch_apply_end"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({
method: z.literal("codex/event/turn_diff"),
params: CodexEventTurnDiffNotificationSchema,
}).transform(
({ params }): ParsedCodexNotification => ({
kind: "diff_updated",
diff: params.msg.unified_diff ?? params.msg.diff ?? "",
})
),
z.object({ method: z.literal("codex/event/turn_diff"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
),
z.object({
method: z.literal("codex/event/turn_aborted"),
params: CodexEventTurnAbortedNotificationSchema,
@@ -968,6 +1453,53 @@ async function writeImageAttachment(mimeType: string, data: string): Promise<str
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(
prompt: AgentPromptInput,
logger: Logger
@@ -1033,8 +1565,11 @@ class CodexAppServerAgentSession implements AgentSession {
private resolvedPermissionRequests = new Set<string>();
private pendingAgentMessages = 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 warnedInvalidNotificationPayloads = new Set<string>();
private latestTurnUnifiedDiff: string | null = null;
private latestUsage: AgentUsage | undefined;
private connected = false;
private collaborationModes: Array<{
@@ -1443,10 +1978,41 @@ class CodexAppServerAgentSession implements AgentSession {
if (!pending) {
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
}
const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
this.resolvedPermissionRequests.add(requestId);
if (response.behavior === "deny" && pendingRequest?.kind === "tool") {
const fallbackName =
pendingRequest.name === "CodexBash"
? "shell"
: pendingRequest.name === "CodexFileChange"
? "apply_patch"
: pendingRequest.name;
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: {
type: "tool_call",
callId: requestId,
name: fallbackName,
status: "failed",
error: { message: response.message ?? "Permission denied" },
detail:
pendingRequest.detail ?? {
type: "unknown",
input: pendingRequest.input ?? null,
output: null,
},
metadata: {
permissionRequestId: requestId,
denied: true,
},
},
});
}
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
@@ -1607,6 +2173,10 @@ class CodexAppServerAgentSession implements AgentSession {
// Resolve model - if not specified, query available models and pick default
let model = this.config.model;
if (!model) {
const configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger);
model = configuredDefaults.model;
}
if (!model) {
const modelResponse = (await this.client.request("model/list", {})) as CodexModelListResponse;
const models = modelResponse?.data ?? [];
@@ -1616,6 +2186,7 @@ class CodexAppServerAgentSession implements AgentSession {
}
model = defaultModel.id;
}
this.config.model = model;
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
@@ -1681,6 +2252,9 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.kind === "turn_started") {
this.currentTurnId = parsed.turnId;
this.latestTurnUnifiedDiff = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
return;
}
@@ -1697,6 +2271,9 @@ class CodexAppServerAgentSession implements AgentSession {
} else {
this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER, usage: this.latestUsage });
}
this.latestTurnUnifiedDiff = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.eventQueue?.end();
return;
}
@@ -1717,13 +2294,12 @@ class CodexAppServerAgentSession implements AgentSession {
}
if (parsed.kind === "diff_updated") {
if (parsed.diff.trim().length > 0) {
// NOTE: Codex app-server emits frequent `turn/diff/updated` notifications
// containing a full accumulated unified diff for the *entire turn*.
// This is not a concrete file-change tool call; it is progress telemetry.
// We intentionally do NOT store it in the agent timeline to avoid
// snapshot bloat and relay/WebSocket size limits.
}
const trimmedDiff = parsed.diff.trim();
this.latestTurnUnifiedDiff = trimmedDiff.length > 0 ? trimmedDiff : null;
// NOTE: Codex app-server emits frequent `turn/diff/updated` notifications
// containing a full accumulated unified diff for the *entire turn*.
// This is not a concrete file-change tool call; it is progress telemetry.
// We intentionally do NOT store every diff update in the timeline.
return;
}
@@ -1745,13 +2321,79 @@ class CodexAppServerAgentSession implements AgentSession {
return;
}
if (parsed.kind === "exec_command_started") {
const timelineItem = mapCodexExecNotificationToToolCall({
callId: parsed.callId,
command: parsed.command,
cwd: parsed.cwd ?? this.config.cwd ?? null,
running: true,
});
if (timelineItem) {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
return;
}
if (parsed.kind === "exec_command_completed") {
const timelineItem = mapCodexExecNotificationToToolCall({
callId: parsed.callId,
command: parsed.command,
cwd: parsed.cwd ?? this.config.cwd ?? null,
output: parsed.output,
exitCode: parsed.exitCode,
success: parsed.success,
stderr: parsed.stderr,
running: false,
});
if (timelineItem) {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
return;
}
if (parsed.kind === "patch_apply_started") {
const timelineItem = mapCodexPatchNotificationToToolCall({
callId: parsed.callId,
changes: parsed.changes,
cwd: this.config.cwd ?? null,
latestUnifiedDiff: this.latestTurnUnifiedDiff,
running: true,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
return;
}
if (parsed.kind === "patch_apply_completed") {
const timelineItem = mapCodexPatchNotificationToToolCall({
callId: parsed.callId,
changes: parsed.changes,
cwd: this.config.cwd ?? null,
stdout: parsed.stdout,
stderr: parsed.stderr,
success: parsed.success,
latestUnifiedDiff: this.latestTurnUnifiedDiff,
running: false,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
return;
}
if (parsed.kind === "item_completed") {
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
// and canonical `item/*`. We render only the canonical channel to avoid
// duplicated assistant/reasoning rows.
if (parsed.source === "codex_event") {
return;
}
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
if (timelineItem) {
const itemId = parsed.item.id;
if (itemId && this.emittedItemCompletedIds.has(itemId)) {
return;
}
if (timelineItem.type === "assistant_message" && itemId) {
const buffered = this.pendingAgentMessages.get(itemId);
if (buffered && buffered.length > 0) {
@@ -1765,17 +2407,31 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (itemId) {
this.emittedItemCompletedIds.add(itemId);
this.emittedItemStartedIds.delete(itemId);
}
}
return;
}
if (parsed.kind === "item_started") {
if (parsed.source === "codex_event") {
return;
}
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
if (timelineItem && timelineItem.type === "tool_call") {
const itemId = parsed.item.id;
if (itemId && this.emittedItemStartedIds.has(itemId)) {
return;
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (itemId) {
this.emittedItemStartedIds.add(itemId);
}
}
return;
}
@@ -1817,6 +2473,12 @@ class CodexAppServerAgentSession implements AgentSession {
cwd?: string | null;
reason?: string | null;
};
const commandPreview = mapCodexExecNotificationToToolCall({
callId: parsed.itemId,
command: parsed.command,
cwd: parsed.cwd ?? this.config.cwd ?? null,
running: true,
});
const requestId = `permission-${parsed.itemId}`;
const title = parsed.command ? `Run command: ${parsed.command}` : "Run command";
const request: AgentPermissionRequest = {
@@ -1830,6 +2492,15 @@ class CodexAppServerAgentSession implements AgentSession {
command: parsed.command ?? undefined,
cwd: parsed.cwd ?? undefined,
},
detail:
commandPreview?.detail ?? {
type: "unknown",
input: {
command: parsed.command ?? null,
cwd: parsed.cwd ?? null,
},
output: null,
},
metadata: {
itemId: parsed.itemId,
threadId: parsed.threadId,
@@ -1853,6 +2524,13 @@ class CodexAppServerAgentSession implements AgentSession {
kind: "tool",
title: "Apply file changes",
description: parsed.reason ?? undefined,
detail: {
type: "unknown",
input: {
reason: parsed.reason ?? null,
},
output: null,
},
metadata: {
itemId: parsed.itemId,
threadId: parsed.threadId,
@@ -1876,6 +2554,13 @@ class CodexAppServerAgentSession implements AgentSession {
kind: "tool",
title: "Tool action requires approval",
description: undefined,
detail: {
type: "unknown",
input: {
questions: Array.isArray(parsed.questions) ? parsed.questions : [],
},
output: null,
},
metadata: {
itemId: parsed.itemId,
threadId: parsed.threadId,
@@ -2020,12 +2705,21 @@ export class CodexAppServerAgentClient implements AgentClient {
const response = (await client.request("model/list", {})) as { data?: Array<any> };
const models = Array.isArray(response?.data) ? response.data : [];
const configuredDefaults = await readCodexConfiguredDefaults(client, this.logger);
const configuredDefaultModelId = configuredDefaults.model;
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
const hasConfiguredDefaultModel =
typeof configuredDefaultModelId === "string"
? models.some((model) => model?.id === configuredDefaultModelId)
: false;
return models.map((model) => {
const defaultReasoningEffort = normalizeCodexThinkingOptionId(
typeof model.defaultReasoningEffort === "string"
? model.defaultReasoningEffort
: null
);
const resolvedDefaultReasoningEffort =
configuredDefaultThinkingOptionId ?? defaultReasoningEffort;
const thinkingById = new Map<string, { id: string; label: string; description?: string }>();
if (Array.isArray(model.supportedReasoningEfforts)) {
@@ -2042,27 +2736,35 @@ export class CodexAppServerAgentClient implements AgentClient {
}
}
if (defaultReasoningEffort && !thinkingById.has(defaultReasoningEffort)) {
thinkingById.set(defaultReasoningEffort, {
id: defaultReasoningEffort,
label: defaultReasoningEffort,
description: "Model default reasoning effort",
if (resolvedDefaultReasoningEffort && !thinkingById.has(resolvedDefaultReasoningEffort)) {
thinkingById.set(resolvedDefaultReasoningEffort, {
id: resolvedDefaultReasoningEffort,
label: resolvedDefaultReasoningEffort,
description:
configuredDefaultThinkingOptionId === resolvedDefaultReasoningEffort
? "Configured default reasoning effort"
: "Model default reasoning effort",
});
}
const thinkingOptions = Array.from(thinkingById.values()).map((option) => ({
...option,
isDefault: option.id === defaultReasoningEffort,
isDefault: option.id === resolvedDefaultReasoningEffort,
}));
const defaultThinkingOptionId =
defaultReasoningEffort ?? thinkingOptions.find((option) => option.isDefault)?.id ?? thinkingOptions[0]?.id;
resolvedDefaultReasoningEffort ??
thinkingOptions.find((option) => option.isDefault)?.id ??
thinkingOptions[0]?.id;
const isDefaultModel = hasConfiguredDefaultModel
? model.id === configuredDefaultModelId
: model.isDefault;
return {
provider: CODEX_PROVIDER,
id: model.id,
label: model.displayName,
description: model.description,
isDefault: model.isDefault,
isDefault: isDefaultModel,
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
defaultThinkingOptionId,
metadata: {

View File

@@ -40,7 +40,7 @@ describe("codex rollout parsing", () => {
type: "tool_call",
name: "Bash",
callId: "call_MhTWDF2mpM4dhbNmHNt6ikDF",
input: { command: "task show cc4ea7d1" },
detail: { type: "shell", command: "task show cc4ea7d1" },
});
});
@@ -78,8 +78,11 @@ describe("codex rollout parsing", () => {
type: "tool_call",
name: "Bash",
callId: "call_abc123",
input: { command: "echo hello" },
output: expect.stringContaining("hello"),
detail: {
type: "shell",
command: "echo hello",
output: "hello",
},
});
});
@@ -143,7 +146,7 @@ describe("codex rollout parsing", () => {
type: "tool_call",
name: "Bash",
callId: "call_shell123",
input: { command: "ls -la" },
detail: { type: "shell", command: "ls -la" },
});
});
});
@@ -249,11 +252,61 @@ describe("codex rollout parsing", () => {
type: "tool_call",
name: "Bash",
callId: "call_legacy_1",
input: { command: "echo hello" },
output: "hello",
detail: { type: "shell", command: "echo hello", output: "hello" },
});
});
test("parses custom_tool_call apply_patch with input/output into editable tool detail", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const patch = [
"*** Begin Patch",
"*** Add File: src/new-file.ts",
"+export const value = 1;",
"*** End Patch",
].join("\n");
const lines = [
JSON.stringify({
timestamp: "2026-02-09T10:00:00.000Z",
type: "response_item",
payload: {
type: "custom_tool_call",
name: "apply_patch",
call_id: "call_patch_custom_1",
input: patch,
},
}),
JSON.stringify({
timestamp: "2026-02-09T10:00:01.000Z",
type: "response_item",
payload: {
type: "custom_tool_call_output",
call_id: "call_patch_custom_1",
output: '{"output":"Success. Updated the following files:\\nA src/new-file.ts\\n","metadata":{"exit_code":0}}',
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
const toolCalls = timeline.filter((i) => i.type === "tool_call");
expect(toolCalls.length).toBe(1);
const patchCall = toolCalls[0];
expect(patchCall).toMatchObject({
type: "tool_call",
name: "apply_patch",
callId: "call_patch_custom_1",
status: "completed",
});
expect(patchCall.detail.type).toBe("edit");
if (patchCall.detail.type === "edit") {
expect(patchCall.detail.filePath).toBe("src/new-file.ts");
expect(patchCall.detail.unifiedDiff).toContain("diff --git");
expect(patchCall.detail.unifiedDiff).toContain("+export const value = 1;");
expect(patchCall.detail.unifiedDiff).not.toContain("*** Begin Patch");
}
});
test("parses legacy event_msg shape using msg", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
@@ -289,6 +342,70 @@ describe("codex rollout parsing", () => {
expect(timeline).toContainEqual({ type: "assistant_message", text: "done" });
expect(timeline).toContainEqual({ type: "user_message", text: "question" });
});
test("deduplicates mirrored response_item and event_msg text records", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:08:54.000Z",
type: "response_item",
payload: {
type: "message",
role: "user",
content: [{ type: "input_text", text: "question" }],
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.001Z",
type: "event_msg",
payload: {
type: "user_message",
message: "question",
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.010Z",
type: "response_item",
payload: {
type: "reasoning",
content: [{ type: "reasoning_text", text: "thinking" }],
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.011Z",
type: "event_msg",
payload: {
type: "agent_reasoning",
text: "thinking",
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.020Z",
type: "response_item",
payload: {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "answer" }],
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.021Z",
type: "event_msg",
payload: {
type: "agent_message",
message: "answer",
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
expect(timeline).toEqual([
{ type: "user_message", text: "question" },
{ type: "reasoning", text: "thinking" },
{ type: "assistant_message", text: "answer" },
]);
});
});
describe("complex conversation", () => {
@@ -362,8 +479,11 @@ describe("codex rollout parsing", () => {
expect(timeline[2]).toMatchObject({
type: "tool_call",
name: "Bash",
input: { command: "npm test" },
output: expect.stringContaining("All tests passed!"),
detail: {
type: "shell",
command: "npm test",
output: "All tests passed!",
},
});
expect(timeline[3]).toMatchObject({
type: "assistant_message",

View File

@@ -95,21 +95,34 @@ const RolloutResponseFunctionCallPayloadSchema = z.object({
type: z.literal("function_call"),
name: z.string().optional(),
call_id: z.string().optional(),
arguments: z.string().optional(),
arguments: z.unknown().optional(),
});
const RolloutResponseCustomToolCallPayloadSchema = z.object({
type: z.literal("custom_tool_call"),
name: z.string().optional(),
call_id: z.string().optional(),
arguments: z.string().optional(),
});
const RolloutResponseCustomToolCallPayloadSchema = z
.object({
type: z.literal("custom_tool_call"),
name: z.string().optional(),
call_id: z.string().optional(),
arguments: z.unknown().optional(),
input: z.unknown().optional(),
})
.passthrough();
const RolloutResponseFunctionCallOutputPayloadSchema = z.object({
type: z.literal("function_call_output"),
call_id: z.string().optional(),
output: z.string().optional(),
});
const RolloutResponseFunctionCallOutputPayloadSchema = z
.object({
type: z.literal("function_call_output"),
call_id: z.string().optional(),
output: z.unknown().optional(),
})
.passthrough();
const RolloutResponseCustomToolCallOutputPayloadSchema = z
.object({
type: z.literal("custom_tool_call_output"),
call_id: z.string().optional(),
output: z.unknown().optional(),
})
.passthrough();
const RolloutEventAgentReasoningPayloadSchema = z.object({
type: z.literal("agent_reasoning"),
@@ -154,7 +167,7 @@ type RolloutResponseReasoningPayload = z.infer<
type ParsedRolloutRecord =
| { kind: "timeline"; item: AgentTimelineItem }
| { kind: "call"; name: string; callId?: string; input?: unknown }
| { kind: "output"; callId: string; output: string }
| { kind: "output"; callId: string; output: unknown }
| { kind: "ignore" };
const RolloutMessageContentSchema = z
@@ -252,6 +265,22 @@ function parseJsonLikeString(value: string): unknown {
}
}
function parseJsonLikeValue(value: unknown): unknown {
if (typeof value === "string") {
return parseJsonLikeString(value);
}
return value;
}
function readOutputPayloadValue(payload: Record<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
.union([
z.object({ cmd: z.string() }).transform((input) => ({ name: "Bash", input: { command: input.cmd } })),
@@ -284,7 +313,8 @@ const RolloutResponseRecordSchema = z
])
.transform((payload): ParsedRolloutRecord => {
const rawName = payload.name ?? "unknown";
const parsedArguments = payload.arguments ? parseJsonLikeString(payload.arguments) : undefined;
const rawInput = payload.arguments ?? ("input" in payload ? payload.input : undefined);
const parsedArguments = parseJsonLikeValue(rawInput);
const normalized =
rawName === "exec_command" || rawName === "shell"
? FunctionCallInputNormalizationSchema.parse(parsedArguments)
@@ -299,12 +329,20 @@ const RolloutResponseRecordSchema = z
input: normalized.input,
};
}),
RolloutResponseFunctionCallOutputPayloadSchema.transform(
(payload): ParsedRolloutRecord =>
payload.call_id && payload.output
? { kind: "output", callId: payload.call_id, output: payload.output }
: { kind: "ignore" }
),
z
.union([
RolloutResponseFunctionCallOutputPayloadSchema,
RolloutResponseCustomToolCallOutputPayloadSchema,
])
.transform((payload): ParsedRolloutRecord => {
if (!payload.call_id) {
return { kind: "ignore" };
}
const output = readOutputPayloadValue(payload);
return output !== undefined
? { kind: "output", callId: payload.call_id, output }
: { kind: "ignore" };
}),
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
]);
@@ -391,6 +429,37 @@ function parseJsonRolloutTimeline(
return timeline;
}
function timelineTextFingerprint(item: AgentTimelineItem): string | null {
switch (item.type) {
case "user_message":
case "assistant_message":
case "reasoning":
return `${item.type}\u0000${item.text}`;
default:
return null;
}
}
function dedupeMirroredTextTimelineItems(
timeline: AgentTimelineItem[]
): AgentTimelineItem[] {
const deduped: AgentTimelineItem[] = [];
for (const item of timeline) {
const prev = deduped[deduped.length - 1];
if (!prev) {
deduped.push(item);
continue;
}
const currentFingerprint = timelineTextFingerprint(item);
const previousFingerprint = timelineTextFingerprint(prev);
if (currentFingerprint && previousFingerprint && currentFingerprint === previousFingerprint) {
continue;
}
deduped.push(item);
}
return deduped;
}
export async function parseRolloutFile(
filePath: string
): Promise<AgentTimelineItem[]> {
@@ -403,7 +472,7 @@ export async function parseRolloutFile(
const parsed = JSON.parse(trimmed);
const jsonTimeline = parseJsonRolloutTimeline(parsed);
if (jsonTimeline) {
return jsonTimeline;
return dedupeMirroredTextTimelineItems(jsonTimeline);
}
} catch {
// Fall back to JSONL parsing.
@@ -428,9 +497,9 @@ export async function parseRolloutFile(
const outputsByCallId = parsedRecords
.filter((record): record is Extract<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.item]
: record.kind === "call"
@@ -444,6 +513,7 @@ export async function parseRolloutFile(
]
: []
);
return dedupeMirroredTextTimelineItems(timeline);
}
export type CodexPersistedTimelineOptions = {

View File

@@ -27,6 +27,63 @@ describe("codex tool-call mapper", () => {
});
});
it("unwraps shell wrapper arrays for commandExecution", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",
id: "codex-call-wrapper-array",
status: "running",
command: ["/bin/zsh", "-lc", "echo hello"],
cwd: "/tmp/repo",
});
expect(item?.detail).toEqual({
type: "shell",
command: "echo hello",
cwd: "/tmp/repo",
});
});
it("unwraps shell wrapper strings for commandExecution", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",
id: "codex-call-wrapper-string",
status: "running",
command: '/bin/zsh -lc "echo hello"',
cwd: "/tmp/repo",
});
expect(item?.detail).toEqual({
type: "shell",
command: "echo hello",
cwd: "/tmp/repo",
});
});
it("keeps only command output body when commandExecution output is wrapped in shell envelope", () => {
const item = mapCodexToolCallFromThreadItem({
type: "commandExecution",
id: "codex-call-envelope-output",
status: "completed",
command: "echo hello",
cwd: "/tmp/repo",
aggregatedOutput:
"Chunk ID: e87d40\nWall time: 0.0521 seconds\nProcess exited with code 0\nOriginal token count: 192\nOutput:\n214 export type AgentPermissionRequestKind = \"tool\";",
exitCode: 0,
});
expect(item?.detail?.type).toBe("shell");
if (item?.detail?.type === "shell") {
expect(item.detail.output).toBe(
"214 export type AgentPermissionRequestKind = \"tool\";"
);
expect(item.detail.output).not.toContain("Chunk ID:");
expect(item.detail.output).not.toContain("Wall time:");
expect(item.detail.output).not.toContain("Process exited with code");
expect(item.detail.output).not.toContain("Original token count:");
expect(item.detail.output).not.toContain("Output:");
}
});
it("maps running known tool variants with detail for early summaries", () => {
const readItem = mapCodexToolCallFromThreadItem(
{
@@ -159,6 +216,26 @@ describe("codex tool-call mapper", () => {
}
});
it("maps fileChange content fallback into editable text when unified diff is absent", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-content-1",
status: "completed",
changes: [{ path: "/tmp/repo/src/content-only.ts", kind: "modify", content: "line one\nline two\n" }],
},
{ cwd: "/tmp/repo" }
);
expect(item).toBeTruthy();
expect(item?.detail?.type).toBe("edit");
if (item?.detail?.type === "edit") {
expect(item.detail.filePath).toBe("src/content-only.ts");
expect(item.detail.newString).toContain("line one");
expect(item.detail.unifiedDiff).toBeUndefined();
}
});
it("maps write/edit/search known variants with distinct detail types", () => {
const writeItem = mapCodexToolCallFromThreadItem(
{
@@ -240,4 +317,98 @@ describe("codex tool-call mapper", () => {
});
expect(item.callId).toBe("codex-call-4");
});
it("maps apply_patch rollout calls with raw patch input into edit detail", () => {
const patch = [
"*** Begin Patch",
"*** Update File: /tmp/repo/src/index.ts",
"@@",
"-old",
"+new",
"*** End Patch",
].join("\n");
const item = mapCodexRolloutToolCall({
callId: "codex-call-apply",
name: "apply_patch",
input: patch,
output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}',
cwd: "/tmp/repo",
});
expect(item.status).toBe("completed");
expect(item.error).toBeNull();
expect(item.detail.type).toBe("edit");
if (item.detail.type === "edit") {
expect(item.detail.filePath).toBe("src/index.ts");
expect(item.detail.unifiedDiff).toContain("diff --git");
expect(item.detail.unifiedDiff).toContain("@@");
expect(item.detail.unifiedDiff).toContain("-old");
expect(item.detail.unifiedDiff).toContain("+new");
expect(item.detail.unifiedDiff).not.toContain("*** Begin Patch");
expect(item.detail.newString).toBeUndefined();
}
});
it("maps apply_patch object content payloads into unified diff detail", () => {
const patch = [
"*** Begin Patch",
"*** Update File: /tmp/repo/src/object.ts",
"@@",
"-before",
"+after",
"*** End Patch",
].join("\n");
const item = mapCodexRolloutToolCall({
callId: "codex-call-apply-object",
name: "apply_patch",
input: {
path: "/tmp/repo/src/object.ts",
content: patch,
},
output: null,
cwd: "/tmp/repo",
});
expect(item.detail.type).toBe("edit");
if (item.detail.type === "edit") {
expect(item.detail.filePath).toBe("src/object.ts");
expect(item.detail.unifiedDiff).toContain("diff --git");
expect(item.detail.unifiedDiff).toContain("@@");
expect(item.detail.unifiedDiff).toContain("-before");
expect(item.detail.unifiedDiff).toContain("+after");
expect(item.detail.unifiedDiff).not.toContain("*** Begin Patch");
expect(item.detail.newString).toBeUndefined();
}
});
it("maps fileChange content that contains codex patch envelopes as unified diffs", () => {
const patch = [
"*** Begin Patch",
"*** Update File: /tmp/repo/src/from-file-change.ts",
"@@",
"-alpha",
"+beta",
"*** End Patch",
].join("\n");
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-file-change-patch-content",
status: "completed",
changes: [{ path: "/tmp/repo/src/from-file-change.ts", kind: "modify", content: patch }],
},
{ cwd: "/tmp/repo" }
);
expect(item?.detail?.type).toBe("edit");
if (item?.detail?.type === "edit") {
expect(item.detail.filePath).toBe("src/from-file-change.ts");
expect(item.detail.unifiedDiff).toContain("-alpha");
expect(item.detail.unifiedDiff).toContain("+beta");
expect(item.detail.unifiedDiff).not.toContain("*** Begin Patch");
expect(item.detail.newString).toBeUndefined();
}
});
});

View File

@@ -4,7 +4,7 @@ import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types
import { CommandValueSchema } from "../tool-call-detail-primitives.js";
import {
coerceToolCallId,
commandFromValue,
extractCodexShellOutput,
truncateDiffText,
} from "../tool-call-mapper-utils.js";
import {
@@ -59,6 +59,7 @@ const CodexFileChangeItemSchema = z
path: z.string().optional(),
kind: z.string().optional(),
diff: z.string().optional(),
content: z.string().optional(),
})
.passthrough()
)
@@ -108,6 +109,213 @@ function coerceCallId(raw: string | null | undefined, name: string, input: unkno
});
}
function maybeUnwrapShellWrapperCommand(command: string): string {
const trimmed = command.trim();
const wrapperMatch = trimmed.match(
/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/
);
if (!wrapperMatch) {
return trimmed;
}
const candidate = wrapperMatch[1]?.trim() ?? "";
if (!candidate) {
return trimmed;
}
if (
(candidate.startsWith('"') && candidate.endsWith('"')) ||
(candidate.startsWith("'") && candidate.endsWith("'"))
) {
return candidate.slice(1, -1);
}
return candidate;
}
function normalizeCommandExecutionCommand(value: unknown): string | undefined {
if (typeof value === "string") {
const normalized = maybeUnwrapShellWrapperCommand(value);
return normalized.length > 0 ? normalized : undefined;
}
if (!Array.isArray(value)) {
return undefined;
}
const parts = value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (parts.length === 0) {
return undefined;
}
if (parts.length >= 3 && (parts[1] === "-lc" || parts[1] === "-c")) {
const unwrapped = parts[2]?.trim();
return unwrapped && unwrapped.length > 0 ? unwrapped : undefined;
}
return parts.join(" ");
}
function looksLikeUnifiedDiff(text: string): boolean {
const normalized = text.trimStart();
if (!normalized) {
return false;
}
return (
normalized.startsWith("diff --git") ||
normalized.startsWith("@@") ||
normalized.startsWith("--- ") ||
normalized.startsWith("+++ ")
);
}
type CodexApplyPatchDirective = {
kind: "add" | "update" | "delete";
path: string;
};
function parseCodexApplyPatchDirective(line: string): CodexApplyPatchDirective | null {
const trimmed = line.trim();
if (trimmed.startsWith("*** Add File:")) {
return { kind: "add", path: trimmed.replace("*** Add File:", "").trim() };
}
if (trimmed.startsWith("*** Update File:")) {
return { kind: "update", path: trimmed.replace("*** Update File:", "").trim() };
}
if (trimmed.startsWith("*** Delete File:")) {
return { kind: "delete", path: trimmed.replace("*** Delete File:", "").trim() };
}
return null;
}
function looksLikeCodexApplyPatch(text: string): boolean {
const normalized = text.trimStart();
if (!normalized) {
return false;
}
if (normalized.startsWith("*** Begin Patch")) {
return true;
}
return text.split(/\r?\n/).some((line) => parseCodexApplyPatchDirective(line) !== null);
}
function normalizeDiffHeaderPath(rawPath: string): string {
return rawPath.trim().replace(/^["']+|["']+$/g, "");
}
function codexApplyPatchToUnifiedDiff(text: string): string {
const lines = text.replace(/\r\n/g, "\n").split("\n");
const output: string[] = [];
let sawDiffBody = false;
for (const line of lines) {
const directive = parseCodexApplyPatchDirective(line);
if (directive) {
const path = normalizeDiffHeaderPath(directive.path);
if (path.length > 0) {
if (output.length > 0 && output[output.length - 1] !== "") {
output.push("");
}
const left = directive.kind === "add" ? "/dev/null" : `a/${path}`;
const right = directive.kind === "delete" ? "/dev/null" : `b/${path}`;
output.push(`diff --git a/${path} b/${path}`);
output.push(`--- ${left}`);
output.push(`+++ ${right}`);
}
continue;
}
const trimmed = line.trim();
if (
trimmed === "*** Begin Patch" ||
trimmed === "*** End Patch" ||
trimmed === "*** End of File" ||
trimmed.startsWith("*** Move to:")
) {
continue;
}
if (line.startsWith("@@")) {
output.push(line);
sawDiffBody = true;
continue;
}
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) {
output.push(line);
sawDiffBody = true;
continue;
}
if (line.startsWith("\\ No newline at end of file")) {
output.push(line);
sawDiffBody = true;
continue;
}
}
if (!sawDiffBody) {
return text;
}
const normalized = output.join("\n").trim();
return normalized.length > 0 ? normalized : text;
}
function classifyDiffLikeText(
text: string
): { isDiff: true; text: string } | { isDiff: false; text: string } {
if (looksLikeUnifiedDiff(text)) {
return { isDiff: true, text };
}
if (looksLikeCodexApplyPatch(text)) {
return { isDiff: true, text: codexApplyPatchToUnifiedDiff(text) };
}
return { isDiff: false, text };
}
function asEditTextFields(
text: string | undefined
): { unifiedDiff?: string; newString?: string } {
if (typeof text !== "string" || text.length === 0) {
return {};
}
const classified = classifyDiffLikeText(text);
if (classified.isDiff) {
return { unifiedDiff: truncateDiffText(classified.text) };
}
return { newString: text };
}
function asEditFileOutputFields(
text: string | undefined
): { patch?: string; content?: string } {
if (typeof text !== "string" || text.length === 0) {
return {};
}
const classified = classifyDiffLikeText(text);
if (classified.isDiff) {
return { patch: truncateDiffText(classified.text) };
}
return { content: text };
}
function asPatchOrContentFields(text: string | undefined): { patch?: string; content?: string } {
if (typeof text !== "string" || text.length === 0) {
return {};
}
const classified = classifyDiffLikeText(text);
if (classified.isDiff) {
return { patch: truncateDiffText(classified.text) };
}
return { content: text };
}
function removePatchLikeFields(input: Record<string, unknown>): Record<string, unknown> {
const {
patch: _patch,
diff: _diff,
unified_diff: _unifiedDiffSnake,
unifiedDiff: _unifiedDiffCamel,
...rest
} = input;
return rest;
}
function resolveStatus(
rawStatus: string | undefined,
error: unknown,
@@ -189,20 +397,121 @@ function toNullableObject(value: Record<string, unknown>): Record<string, unknow
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(
item: z.infer<typeof CodexCommandExecutionItemSchema>
): ToolCallTimelineItem {
const command = item.command ? commandFromValue(item.command) : undefined;
const command = normalizeCommandExecutionCommand(item.command);
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput);
const input = toNullableObject({
...(command !== undefined ? { command } : {}),
...(item.cwd !== undefined ? { cwd: item.cwd } : {}),
});
const output =
item.aggregatedOutput !== undefined || item.exitCode !== undefined
parsedOutput !== undefined || item.exitCode !== undefined
? {
...(command !== undefined ? { command } : {}),
...(item.aggregatedOutput !== undefined ? { output: item.aggregatedOutput } : {}),
...(parsedOutput !== undefined ? { output: parsedOutput } : {}),
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
}
: null;
@@ -212,7 +521,7 @@ function mapCommandExecutionItem(
type: "shell" as const,
command,
...(item.cwd ? { cwd: item.cwd } : {}),
...(item.aggregatedOutput ? { output: item.aggregatedOutput } : {}),
...(parsedOutput ? { output: parsedOutput } : {}),
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
}
: {
@@ -251,7 +560,7 @@ function mapFileChangeItem(
return {
path: pathValue,
kind: change.kind,
diff: change.diff,
diff: change.diff ?? change.content,
};
})
.filter((change) => change.path !== undefined);
@@ -273,18 +582,19 @@ function mapFileChangeItem(
files: files.map((file) => ({
path: file.path,
...(file.kind !== undefined ? { kind: file.kind } : {}),
...(file.diff !== undefined ? { patch: truncateDiffText(file.diff) } : {}),
...asEditFileOutputFields(file.diff),
})),
}
: {}),
});
const firstFile = files[0];
const firstTextFields = asEditTextFields(firstFile?.diff);
const detail = firstFile?.path
? {
type: "edit" as const,
filePath: firstFile.path,
...(firstFile.diff !== undefined ? { unifiedDiff: truncateDiffText(firstFile.diff) } : {}),
...firstTextFields,
}
: {
type: "unknown" as const,
@@ -400,19 +710,31 @@ export function mapCodexRolloutToolCall(params: {
input?: unknown;
output?: unknown;
error?: unknown;
cwd?: string | null;
}): ToolCallTimelineItem {
const parsed = CodexRolloutToolCallParamsSchema.parse(params);
const input = parsed.input ?? null;
const rawInput = parsed.input ?? null;
const normalizedName = parsed.name.trim().toLowerCase();
const input =
normalizedName === "apply_patch" || normalizedName === "apply_diff"
? normalizeApplyPatchInput(rawInput)
: rawInput;
const output = parsed.output ?? null;
const error = parsed.error ?? null;
const status = resolveStatus("completed", error, output);
const callId = coerceCallId(parsed.callId, parsed.name, input);
const detail = deriveCodexToolDetail({
let detail = deriveCodexToolDetail({
name: parsed.name,
input,
output,
cwd: null,
cwd: params.cwd ?? null,
});
if (detail.type === "unknown" && (normalizedName === "apply_patch" || normalizedName === "apply_diff")) {
const fallbackDetail = deriveApplyPatchDetailFromInput(input, params.cwd ?? null);
if (fallbackDetail) {
detail = fallbackDetail;
}
}
return buildToolCall({
callId,

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import type { ToolCallDetail } from "../agent-sdk-types.js";
import {
commandFromValue,
extractCodexShellOutput,
flattenReadContent as flattenToolReadContent,
nonEmptyString,
truncateDiffText,
@@ -83,12 +84,11 @@ const ToolShellOutputObjectSchema = z
export const ToolShellOutputSchema = z.union([
z.string().transform((value) => ({
command: undefined,
output: nonEmptyString(value),
output: extractCodexShellOutput(value),
exitCode: undefined,
})),
ToolShellOutputObjectSchema.transform((value) => ({
command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command),
output:
ToolShellOutputObjectSchema.transform((value) => {
const rawOutput =
nonEmptyString(value.output) ??
nonEmptyString(value.text) ??
nonEmptyString(value.content) ??
@@ -102,14 +102,19 @@ export const ToolShellOutputSchema = z.union([
nonEmptyString(value.structured_content?.content) ??
nonEmptyString(value.result?.output) ??
nonEmptyString(value.result?.text) ??
nonEmptyString(value.result?.content),
exitCode:
value.exitCode ??
value.exit_code ??
value.metadata?.exitCode ??
value.metadata?.exit_code ??
undefined,
})),
nonEmptyString(value.result?.content);
return {
command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command),
output: extractCodexShellOutput(rawOutput),
exitCode:
value.exitCode ??
value.exit_code ??
value.metadata?.exitCode ??
value.metadata?.exit_code ??
undefined,
};
}),
]);
export const ToolPathInputSchema = z.union([

View File

@@ -21,6 +21,70 @@ export function commandFromValue(value: unknown): string | undefined {
return tokens.length > 0 ? tokens.join(" ") : undefined;
}
const CODEX_SHELL_ENVELOPE_HEADER_LINES = new Set([
"chunk id:",
"wall time:",
"process exited with code",
"original token count:",
]);
function isCodexShellEnvelopeHeaderLine(line: string): boolean {
const normalized = line.trim().toLowerCase();
for (const prefix of CODEX_SHELL_ENVELOPE_HEADER_LINES) {
if (normalized.startsWith(prefix)) {
return true;
}
}
return false;
}
function looksLikeCodexShellEnvelope(lines: string[]): boolean {
if (lines.length === 0) {
return false;
}
const first = lines[0]?.trim().toLowerCase() ?? "";
if (!first.startsWith("chunk id:")) {
return false;
}
const headerWindow = lines.slice(0, 8).map((line) => line.trim().toLowerCase());
const hasWallTime = headerWindow.some((line) => line.startsWith("wall time:"));
const hasExitCode = headerWindow.some((line) => line.startsWith("process exited with code"));
return hasWallTime && hasExitCode;
}
export function extractCodexShellOutput(value: string | undefined): string | undefined {
const text = nonEmptyString(value);
if (!text) {
return undefined;
}
const normalized = text.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
if (!looksLikeCodexShellEnvelope(lines)) {
return text;
}
const outputLineIndex = lines.findIndex((line) => line.trim() === "Output:");
if (outputLineIndex >= 0) {
return nonEmptyString(lines.slice(outputLineIndex + 1).join("\n"));
}
let firstBodyLineIndex = -1;
for (let index = 1; index < lines.length; index += 1) {
const line = lines[index] ?? "";
if (!isCodexShellEnvelopeHeaderLine(line)) {
firstBodyLineIndex = index;
break;
}
}
if (firstBodyLineIndex === -1) {
return undefined;
}
return nonEmptyString(lines.slice(firstBodyLineIndex).join("\n"));
}
export function flattenReadContent<Chunk extends ReadChunkLike>(
value: string | Chunk | Chunk[] | undefined
): string | undefined {

View File

@@ -770,6 +770,110 @@ describe("daemon client E2E", () => {
90_000
);
speechTest(
"voice mode flushes buffered audio after inactivity when isLast is missing",
async () => {
const voiceCwd = tmpCwd();
const voiceAgent = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: voiceCwd,
},
});
await ctx.client.setVoiceMode(true, voiceAgent.id);
const transcription = waitForSignal(40_000, (resolve) => {
const unsubscribe = ctx.client.on("transcription_result", (message) => {
if (message.type !== "transcription_result") {
return;
}
resolve(message.payload);
});
return unsubscribe;
});
const errorSignal = waitForSignal(40_000, (resolve) => {
const unsubscribeStatus = ctx.client.on("status", (message) => {
if (message.type !== "status") {
return;
}
if (message.payload.status !== "error") {
return;
}
resolve(`status:error ${message.payload.message}`);
});
const unsubscribeLog = ctx.client.on("activity_log", (message) => {
if (message.type !== "activity_log") {
return;
}
if (message.payload.type !== "error") {
return;
}
resolve(`activity_log:error ${message.payload.content}`);
});
return () => {
unsubscribeStatus();
unsubscribeLog();
};
});
try {
const fixturePath = path.resolve(
process.cwd(),
"..",
"app",
"e2e",
"fixtures",
"recording.wav"
);
const wav = await import("node:fs/promises").then((fs) => fs.readFile(fixturePath));
const { sampleRate, pcm16 } = parsePcm16MonoWav(wav);
expect(sampleRate).toBe(16000);
const format = "audio/pcm;rate=16000;bits=16";
const chunkBytes = 3200; // 100ms @ 16kHz mono PCM16
const maxChunksWithoutLast = 25;
let sentChunks = 0;
for (
let offset = 0;
offset < pcm16.length && sentChunks < maxChunksWithoutLast;
offset += chunkBytes
) {
const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes));
await ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, false);
sentChunks += 1;
}
const outcome = await Promise.race([
transcription.then((payload) => ({ kind: "ok" as const, payload })),
errorSignal.then((error) => ({ kind: "error" as const, error })),
]);
if (outcome.kind === "error") {
throw new Error(outcome.error);
}
expect(typeof outcome.payload.text).toBe("string");
if (outcome.payload.byteLength !== undefined) {
expect(outcome.payload.byteLength).toBeGreaterThan(0);
}
if (outcome.payload.text.trim().length > 0) {
expect(outcome.payload.text.trim().length).toBeGreaterThan(1);
} else {
expect(outcome.payload.isLowConfidence).toBe(true);
}
} finally {
await Promise.allSettled([transcription, errorSignal]);
await ctx.client.setVoiceMode(false);
rmSync(voiceCwd, { recursive: true, force: true });
}
},
90_000
);
speechTest(
"streams dictation PCM and returns final transcript",
async () => {

View File

@@ -31,6 +31,7 @@ import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
import {
DictationStreamManager,
type DictationStreamOutboundMessage,
} from "./dictation/dictation-stream-manager.js";
import {
buildConfigOverrides,
@@ -241,6 +242,8 @@ const MIN_STREAMING_SEGMENT_DURATION_MS = 1000;
const MIN_STREAMING_SEGMENT_BYTES = Math.round(
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS
);
const VOICE_MODE_INACTIVITY_FLUSH_MS = 4500;
const VOICE_INTERNAL_DICTATION_ID_PREFIX = "__voice_turn__:";
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
const AgentIdSchema = z.string().uuid();
const VOICE_MCP_SERVER_NAME = "paseo_voice";
@@ -257,6 +260,18 @@ interface AudioBufferState {
totalPCMBytes: number;
}
type VoiceTranscriptionResultPayload = {
text: string;
requestId: string;
language?: string;
duration?: number;
avgLogprob?: number;
isLowConfidence?: boolean;
byteLength?: number;
format?: string;
debugRecordingPath?: string;
};
export type SessionOptions = {
clientId: string;
onMessage: (msg: SessionOutboundMessage) => void;
@@ -384,11 +399,25 @@ export class Session {
private speechInProgress = false;
private readonly dictationStreamManager: DictationStreamManager;
private readonly voiceStreamManager: DictationStreamManager;
// Audio buffering for interruption handling
private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = [];
private bufferTimeout: NodeJS.Timeout | null = null;
private voiceModeInactivityTimeout: NodeJS.Timeout | null = null;
private audioBuffer: AudioBufferState | null = null;
private activeVoiceDictationId: string | null = null;
private activeVoiceDictationFormat: string | null = null;
private activeVoiceDictationNextSeq = 0;
private activeVoiceDictationStartPromise: Promise<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)
private readonly ttsDebugStreams = new Map<
@@ -511,10 +540,17 @@ export class Session {
this.dictationStreamManager = new DictationStreamManager({
logger: this.sessionLogger,
sessionId: this.sessionId,
emit: (msg) => this.emit(msg as unknown as SessionOutboundMessage),
emit: (msg) => this.handleDictationManagerMessage(msg),
stt: dictation?.stt ?? null,
finalTimeoutMs: dictation?.finalTimeoutMs,
});
this.voiceStreamManager = new DictationStreamManager({
logger: this.sessionLogger.child({ stream: "voice-internal" }),
sessionId: this.sessionId,
emit: (msg) => this.handleDictationManagerMessage(msg),
stt: stt,
finalTimeoutMs: dictation?.finalTimeoutMs,
});
// Initialize agent MCP client asynchronously
void this.initializeAgentMcp();
@@ -1573,6 +1609,9 @@ export class Session {
private async disableVoiceModeForActiveAgent(
restoreAgentConfig: boolean
): Promise<void> {
this.clearVoiceModeInactivityTimeout();
this.cancelActiveVoiceDictationStream("voice mode disabled");
const agentId = this.voiceModeAgentId;
if (!agentId) {
this.voiceModeBaseConfig = null;
@@ -1607,6 +1646,234 @@ export class Session {
this.voiceModeAgentId = null;
}
private isInternalVoiceDictationId(dictationId: string): boolean {
return dictationId.startsWith(VOICE_INTERNAL_DICTATION_ID_PREFIX);
}
private handleDictationManagerMessage(msg: DictationStreamOutboundMessage): void {
if (msg.type === "activity_log") {
const metadata = msg.payload.metadata as { dictationId?: unknown } | undefined;
const dictationId =
metadata && typeof metadata.dictationId === "string" ? metadata.dictationId : null;
if (dictationId && this.isInternalVoiceDictationId(dictationId)) {
return;
}
this.emit(msg as unknown as SessionOutboundMessage);
return;
}
const payloadWithDictationId = msg.payload as { dictationId?: unknown };
const dictationId =
payloadWithDictationId && typeof payloadWithDictationId.dictationId === "string"
? payloadWithDictationId.dictationId
: null;
if (!dictationId || !this.isInternalVoiceDictationId(dictationId)) {
this.emit(msg as unknown as SessionOutboundMessage);
return;
}
if (msg.type === "dictation_stream_final") {
if (dictationId !== this.activeVoiceDictationId || !this.activeVoiceDictationResolve) {
return;
}
this.activeVoiceDictationResolve({
text: msg.payload.text,
...(msg.payload.debugRecordingPath
? { debugRecordingPath: msg.payload.debugRecordingPath }
: {}),
});
return;
}
if (msg.type === "dictation_stream_error") {
if (dictationId !== this.activeVoiceDictationId || !this.activeVoiceDictationReject) {
return;
}
this.activeVoiceDictationReject(new Error(msg.payload.error));
return;
}
// Ack/partial messages for internal voice dictation are consumed server-side.
}
private resetActiveVoiceDictationState(): void {
this.activeVoiceDictationId = null;
this.activeVoiceDictationFormat = null;
this.activeVoiceDictationNextSeq = 0;
this.activeVoiceDictationStartPromise = null;
this.activeVoiceDictationFinalizePromise = null;
this.activeVoiceDictationResultPromise = null;
this.activeVoiceDictationResolve = null;
this.activeVoiceDictationReject = null;
}
private cancelActiveVoiceDictationStream(reason: string): void {
const dictationId = this.activeVoiceDictationId;
if (!dictationId) {
return;
}
this.sessionLogger.debug({ dictationId, reason }, "Cancelling active internal voice dictation stream");
if (this.activeVoiceDictationReject) {
this.activeVoiceDictationReject(new Error(`Voice dictation cancelled: ${reason}`));
}
this.voiceStreamManager.handleCancel(dictationId);
this.resetActiveVoiceDictationState();
}
private async ensureActiveVoiceDictationStream(format: string): Promise<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)
*/
@@ -4553,8 +4820,23 @@ export class Session {
await this.handleVoiceSpeechStart();
const chunkBuffer = Buffer.from(msg.audio, "base64");
const chunkFormat = msg.format || "audio/wav";
if (this.isVoiceMode) {
await this.appendToActiveVoiceDictationStream(msg.audio, chunkFormat);
if (!msg.isLast) {
this.setVoiceModeInactivityTimeout();
this.sessionLogger.debug("Voice mode: streaming chunk, waiting for speech end");
return;
}
this.clearVoiceModeInactivityTimeout();
this.sessionLogger.debug("Voice mode: speech ended, finalizing streaming transcription");
await this.finalizeActiveVoiceDictationStream("speech ended");
return;
}
const chunkBuffer = Buffer.from(msg.audio, "base64");
const isPCMChunk = chunkFormat.toLowerCase().includes("pcm");
if (!this.audioBuffer) {
@@ -4592,16 +4874,6 @@ export class Session {
this.audioBuffer.totalPCMBytes += chunkBuffer.length;
}
// In voice mode, only process audio when the user has finished speaking (isLast = true)
// This prevents partial transcriptions from being sent to the LLM
if (this.isVoiceMode) {
if (!msg.isLast) {
this.sessionLogger.debug("Voice mode: buffering audio, waiting for speech end");
return;
}
this.sessionLogger.debug("Voice mode: speech ended, processing complete audio");
}
// In non-voice mode, use streaming threshold to process chunks
const reachedStreamingThreshold =
!this.isVoiceMode &&
@@ -4747,85 +5019,17 @@ export class Session {
"Transcription result"
);
// Emit transcription result
this.emit({
type: "transcription_result",
payload: {
text: result.text,
language: result.language,
duration: result.duration,
requestId,
avgLogprob: result.avgLogprob,
isLowConfidence: result.isLowConfidence,
byteLength: result.byteLength,
format: result.format,
debugRecordingPath: result.debugRecordingPath,
},
await this.handleTranscriptionResultPayload({
text: result.text,
language: result.language,
duration: result.duration,
requestId,
avgLogprob: result.avgLogprob,
isLowConfidence: result.isLowConfidence,
byteLength: result.byteLength,
format: result.format,
debugRecordingPath: result.debugRecordingPath,
});
if (!transcriptText) {
this.sessionLogger.debug("Empty transcription (false positive), not aborting");
this.setPhase("idle");
this.clearSpeechInProgress("empty transcription");
return;
}
// Has content - abort any in-progress stream now
this.createAbortController();
// Wait for aborted stream to finish cleanup (save partial response)
if (this.currentStreamPromise) {
this.sessionLogger.debug("Waiting for aborted stream to finish cleanup");
await this.currentStreamPromise;
}
if (result.debugRecordingPath) {
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "system",
content: `Saved input audio: ${result.debugRecordingPath}`,
metadata: {
recordingPath: result.debugRecordingPath,
format: result.format,
requestId,
},
},
});
}
// Emit activity log
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "transcript",
content: result.text,
metadata: {
language: result.language,
duration: result.duration,
},
},
});
this.clearSpeechInProgress("transcription complete");
if (!this.isVoiceMode) {
this.sessionLogger.debug(
{ requestId },
"Skipping voice agent processing because voice mode is disabled"
);
this.setPhase("idle");
return;
}
// Set phase to LLM and process (TTS enabled in voice mode for voice agents)
this.setPhase("llm");
this.currentStreamPromise = this.processVoiceTurn(result.text);
await this.currentStreamPromise;
this.setPhase("idle");
} catch (error: any) {
this.setPhase("idle");
this.clearSpeechInProgress("transcription error");
@@ -4842,6 +5046,89 @@ export class Session {
}
}
private async handleTranscriptionResultPayload(
result: VoiceTranscriptionResultPayload
): Promise<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 {
this.registerVoiceSpeakHandler?.(agentId, async ({ text, signal }) => {
this.sessionLogger.info(
@@ -5061,6 +5348,8 @@ export class Session {
this.audioBuffer = null;
}
this.cancelActiveVoiceDictationStream("new speech turn started");
this.clearVoiceModeInactivityTimeout();
this.clearBufferTimeout();
this.abortController.abort();
@@ -5126,6 +5415,43 @@ export class Session {
}, 10000); // 10 second timeout
}
private setVoiceModeInactivityTimeout(): void {
if (!this.isVoiceMode) {
return;
}
this.clearVoiceModeInactivityTimeout();
this.voiceModeInactivityTimeout = setTimeout(() => {
this.voiceModeInactivityTimeout = null;
if (!this.isVoiceMode || !this.activeVoiceDictationId) {
return;
}
this.sessionLogger.warn(
{
timeoutMs: VOICE_MODE_INACTIVITY_FLUSH_MS,
dictationId: this.activeVoiceDictationId,
nextSeq: this.activeVoiceDictationNextSeq,
},
"Voice mode inactivity timeout reached without isLast; finalizing active voice dictation stream"
);
void this.finalizeActiveVoiceDictationStream("inactivity timeout").catch((error) => {
this.sessionLogger.error(
{ err: error },
"Failed to finalize voice dictation stream after inactivity timeout"
);
});
}, VOICE_MODE_INACTIVITY_FLUSH_MS);
}
private clearVoiceModeInactivityTimeout(): void {
if (this.voiceModeInactivityTimeout) {
clearTimeout(this.voiceModeInactivityTimeout);
this.voiceModeInactivityTimeout = null;
}
}
/**
* Clear buffer timeout
*/
@@ -5206,15 +5532,18 @@ export class Session {
this.abortController.abort();
// Clear timeouts
this.clearVoiceModeInactivityTimeout();
this.clearBufferTimeout();
// Clear buffers
this.cancelActiveVoiceDictationStream("session cleanup");
this.pendingAudioSegments = [];
this.audioBuffer = null;
// Cleanup managers
this.ttsManager.cleanup();
this.sttManager.cleanup();
this.voiceStreamManager.cleanupAll();
this.dictationStreamManager.cleanupAll();
// Close MCP clients

View File

@@ -3,6 +3,14 @@ import { tmpdir } from "os";
import path from "path";
import { seedClaudeAuth } from "./claude-auth.js";
function isIgnorableCleanupError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const code = (error as NodeJS.ErrnoException).code;
return code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM";
}
/**
* Sets up an isolated Claude config directory for testing.
* Creates a temp directory with:
@@ -39,6 +47,12 @@ export function useTempClaudeConfigDir(): () => void {
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
}
rmSync(configDir, { recursive: true, force: true });
try {
rmSync(configDir, { recursive: true, force: true });
} catch (error) {
if (!isIgnorableCleanupError(error)) {
throw error;
}
}
};
}