From 7e99529bde63c1f610f3b6a6794ba07a498b9431 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 16:31:58 +0700 Subject: [PATCH] Add Codex plan mode draft features --- .../src/components/agent-status-bar.test.ts | 27 ++ .../app/src/components/agent-status-bar.tsx | 408 ++++++++++-------- .../src/components/agent-status-bar.utils.ts | 18 +- .../app/src/components/agent-stream-view.tsx | 189 +++----- packages/app/src/components/message.tsx | 7 + packages/app/src/components/plan-card.tsx | 154 +++++++ .../src/components/sidebar-workspace-list.tsx | 26 +- .../app/src/hooks/use-agent-autocomplete.ts | 2 + .../app/src/hooks/use-agent-commands-query.ts | 2 + .../app/src/hooks/use-draft-agent-features.ts | 134 ++++++ .../src/screens/agent/draft-agent-screen.tsx | 19 + .../workspace/workspace-draft-agent-tab.tsx | 19 + .../app/src/utils/tool-call-detail-state.ts | 2 + packages/app/src/utils/tool-call-icon.ts | 1 + packages/server/src/client/daemon-client.ts | 19 +- .../server/src/server/agent/agent-manager.ts | 25 ++ .../src/server/agent/agent-sdk-types.ts | 6 + .../src/server/agent/provider-manifest.ts | 14 +- .../codex-app-server-agent.e2e.test.ts | 1 + .../codex-app-server-agent.features.test.ts | 105 ++++- .../providers/codex-app-server-agent.test.ts | 19 + .../agent/providers/codex-app-server-agent.ts | 242 ++++++----- .../providers/codex-feature-definitions.ts | 66 +++ .../codex-plan-mode.real.e2e.test.ts | 64 +++ packages/server/src/server/session.ts | 69 ++- .../src/shared/agent-feature-schemas.test.ts | 3 + .../src/shared/messages.list-commands.test.ts | 6 + packages/server/src/shared/messages.ts | 32 ++ .../src/shared/tool-call-display.test.ts | 16 + .../server/src/shared/tool-call-display.ts | 4 + 30 files changed, 1239 insertions(+), 460 deletions(-) create mode 100644 packages/app/src/components/plan-card.tsx create mode 100644 packages/app/src/hooks/use-draft-agent-features.ts create mode 100644 packages/server/src/server/agent/providers/codex-feature-definitions.ts create mode 100644 packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts diff --git a/packages/app/src/components/agent-status-bar.test.ts b/packages/app/src/components/agent-status-bar.test.ts index 4653bf4f8..5e4ffe505 100644 --- a/packages/app/src/components/agent-status-bar.test.ts +++ b/packages/app/src/components/agent-status-bar.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + getFeatureHighlightColor, + getFeatureTooltip, getStatusSelectorHint, normalizeModelId, resolveAgentModelSelection, @@ -13,6 +15,31 @@ describe("getStatusSelectorHint", () => { }); }); +describe("feature metadata helpers", () => { + it("prefers explicit feature tooltip copy", () => { + expect( + getFeatureTooltip({ + label: "Plan", + tooltip: "Toggle plan mode", + }), + ).toBe("Toggle plan mode"); + }); + + it("falls back to the feature label when no tooltip is provided", () => { + expect( + getFeatureTooltip({ + label: "Custom", + }), + ).toBe("Custom"); + }); + + it("maps feature highlight colors by feature id", () => { + expect(getFeatureHighlightColor("fast_mode")).toBe("yellow"); + expect(getFeatureHighlightColor("plan_mode")).toBe("blue"); + expect(getFeatureHighlightColor("other")).toBe("default"); + }); +}); + describe("normalizeModelId", () => { it("treats empty values as unset", () => { expect(normalizeModelId("")).toBeNull(); diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 2d4d99054..93cd8e6ca 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -3,7 +3,16 @@ import { View, Text, Platform, Pressable, Keyboard } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import { Brain, ChevronDown, Settings2, ShieldAlert, ShieldCheck, ShieldOff, Zap } from "lucide-react-native"; +import { + Brain, + ChevronDown, + ListTodo, + Settings2, + ShieldAlert, + ShieldCheck, + ShieldOff, + Zap, +} from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { useQuery } from "@tanstack/react-query"; @@ -37,6 +46,8 @@ import { type AgentModeIcon, } from "@server/server/agent/provider-manifest"; import { + getFeatureHighlightColor, + getFeatureTooltip, getStatusSelectorHint, resolveAgentModelSelection, } from "@/components/agent-status-bar.utils"; @@ -95,6 +106,8 @@ export interface DraftAgentStatusBarProps { thinkingOptions: NonNullable; selectedThinkingOptionId: string; onSelectThinkingOption: (thinkingOptionId: string) => void; + features?: AgentFeature[]; + onSetFeature?: (featureId: string, value: unknown) => void; disabled?: boolean; } @@ -116,6 +129,7 @@ function findOptionLabel( } const FEATURE_ICONS: Record = { + "list-todo": ListTodo, zap: Zap, }; @@ -123,6 +137,29 @@ function getFeatureIcon(icon?: string) { return (icon && FEATURE_ICONS[icon]) || Settings2; } +function getFeatureIconColor( + featureId: string, + enabled: boolean, + palette: { + blue: { 400: string }; + yellow: { 400: string }; + }, + foregroundMuted: string, +): string { + if (!enabled) { + return foregroundMuted; + } + + switch (getFeatureHighlightColor(featureId)) { + case "blue": + return palette.blue[400]; + case "yellow": + return palette.yellow[400]; + default: + return foregroundMuted; + } +} + const MODE_ICONS = { ShieldCheck, ShieldAlert, @@ -216,7 +253,8 @@ function ControlledStatusBar({ Boolean(providerOptions?.length) || Boolean(modeOptions?.length) || canSelectModel || - Boolean(thinkingOptions?.length); + Boolean(thinkingOptions?.length) || + Boolean(features?.length); if (!hasAnyControl) { return null; @@ -420,106 +458,6 @@ function ControlledStatusBar({ ) : null} - {features?.map((feature) => { - if (feature.type === "toggle") { - const FeatureIcon = getFeatureIcon(feature.icon); - return ( - - - onSetFeature?.(feature.id, !feature.value)} - style={({ pressed, hovered }) => [ - styles.modeIconBadge, - hovered && styles.modeBadgeHovered, - pressed && styles.modeBadgePressed, - disabled && styles.disabledBadge, - ]} - accessibilityRole="button" - accessibilityLabel={feature.label} - testID={`agent-feature-${feature.id}`} - > - - - - - {feature.label} - - - ); - } - if (feature.type === "select") { - const FeatureIcon = getFeatureIcon(feature.icon); - const selectedOption = feature.options.find((o) => o.id === feature.value); - return ( - - setOpenSelector(open ? `feature-${feature.id}` : null) - } - > - - - [ - styles.modeBadge, - hovered && styles.modeBadgeHovered, - (pressed || openSelector === `feature-${feature.id}`) && - styles.modeBadgePressed, - disabled && styles.disabledBadge, - ]} - accessibilityRole="button" - accessibilityLabel={feature.label} - testID={`agent-feature-${feature.id}`} - > - - - {selectedOption?.label ?? feature.label} - - - - - - {feature.label} - - - - {feature.options.map((option) => ( - onSetFeature?.(feature.id, option.id)} - > - {option.label} - - ))} - - - ); - } - return null; - })} - {modeOptions && modeOptions.length > 0 ? ( <> ) : null} + + {features?.map((feature) => { + if (feature.type === "toggle") { + const FeatureIcon = getFeatureIcon(feature.icon); + return ( + + + onSetFeature?.(feature.id, !feature.value)} + style={({ pressed, hovered }) => [ + styles.modeIconBadge, + hovered && styles.modeBadgeHovered, + pressed && styles.modeBadgePressed, + disabled && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel={getFeatureTooltip(feature)} + testID={`agent-feature-${feature.id}`} + > + + + + + {getFeatureTooltip(feature)} + + + ); + } + if (feature.type === "select") { + const FeatureIcon = getFeatureIcon(feature.icon); + const selectedOption = feature.options.find((o) => o.id === feature.value); + return ( + + setOpenSelector(open ? `feature-${feature.id}` : null) + } + > + + + [ + styles.modeBadge, + hovered && styles.modeBadgeHovered, + (pressed || openSelector === `feature-${feature.id}`) && + styles.modeBadgePressed, + disabled && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel={getFeatureTooltip(feature)} + testID={`agent-feature-${feature.id}`} + > + + + {selectedOption?.label ?? feature.label} + + + + + + {getFeatureTooltip(feature)} + + + + {feature.options.map((option) => ( + onSetFeature?.(feature.id, option.id)} + > + {option.label} + + ))} + + + ); + } + return null; + })} ) : ( <> @@ -664,86 +703,6 @@ function ControlledStatusBar({ ) : null} - {features?.map((feature) => { - if (feature.type === "toggle") { - const FeatureIcon = getFeatureIcon(feature.icon); - return ( - - onSetFeature?.(feature.id, !feature.value)} - style={({ pressed }) => [ - styles.sheetSelect, - pressed && styles.sheetSelectPressed, - disabled && styles.disabledSheetSelect, - ]} - accessibilityRole="button" - accessibilityLabel={feature.label} - testID={`agent-feature-${feature.id}`} - > - - {feature.label} - - {feature.value ? "On" : "Off"} - - - - ); - } - if (feature.type === "select") { - const selectedOption = feature.options.find((o) => o.id === feature.value); - return ( - - - setOpenSelector(open ? `feature-${feature.id}` : null) - } - > - [ - styles.sheetSelect, - pressed && styles.sheetSelectPressed, - disabled && styles.disabledSheetSelect, - ]} - accessibilityRole="button" - accessibilityLabel={feature.label} - testID={`agent-feature-${feature.id}`} - > - - {selectedOption?.label ?? feature.label} - - - - - {feature.options.map((option) => ( - onSetFeature?.(feature.id, option.id)} - > - {option.label} - - ))} - - - - ); - } - return null; - })} - {modeOptions && modeOptions.length > 0 ? ( ) : null} + + {features?.map((feature) => { + if (feature.type === "toggle") { + const FeatureIcon = getFeatureIcon(feature.icon); + return ( + + onSetFeature?.(feature.id, !feature.value)} + style={({ pressed }) => [ + styles.sheetSelect, + pressed && styles.sheetSelectPressed, + disabled && styles.disabledSheetSelect, + ]} + accessibilityRole="button" + accessibilityLabel={getFeatureTooltip(feature)} + testID={`agent-feature-${feature.id}`} + > + + {feature.label} + {feature.value ? "On" : "Off"} + + + ); + } + if (feature.type === "select") { + const selectedOption = feature.options.find((o) => o.id === feature.value); + return ( + + + setOpenSelector(open ? `feature-${feature.id}` : null) + } + > + [ + styles.sheetSelect, + pressed && styles.sheetSelectPressed, + disabled && styles.disabledSheetSelect, + ]} + accessibilityRole="button" + accessibilityLabel={getFeatureTooltip(feature)} + testID={`agent-feature-${feature.id}`} + > + + {selectedOption?.label ?? feature.label} + + + + + {feature.options.map((option) => ( + onSetFeature?.(feature.id, option.id)} + > + {option.label} + + ))} + + + + ); + } + return null; + })} )} @@ -1008,6 +1046,8 @@ export function DraftAgentStatusBar({ thinkingOptions, selectedThinkingOptionId, onSelectThinkingOption, + features, + onSetFeature, disabled = false, }: DraftAgentStatusBarProps) { const isWeb = Platform.OS === "web"; @@ -1061,6 +1101,8 @@ export function DraftAgentStatusBar({ thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} selectedThinkingOptionId={effectiveSelectedThinkingOption} onSelectThinkingOption={onSelectThinkingOption} + features={features} + onSetFeature={onSetFeature} disabled={disabled} /> @@ -1093,6 +1135,8 @@ export function DraftAgentStatusBar({ thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} selectedThinkingOptionId={effectiveSelectedThinkingOption} onSelectThinkingOption={onSelectThinkingOption} + features={features} + onSetFeature={onSetFeature} disabled={disabled} /> ); diff --git a/packages/app/src/components/agent-status-bar.utils.ts b/packages/app/src/components/agent-status-bar.utils.ts index f242b00f2..baf563553 100644 --- a/packages/app/src/components/agent-status-bar.utils.ts +++ b/packages/app/src/components/agent-status-bar.utils.ts @@ -1,6 +1,7 @@ -import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; +import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; export type ExplainedStatusSelector = "mode" | "model" | "thinking"; +export type FeatureHighlightColor = "blue" | "default" | "yellow"; export function getStatusSelectorHint(selector: ExplainedStatusSelector): string { switch (selector) { @@ -21,6 +22,21 @@ export function normalizeModelId(modelId: string | null | undefined): string | n return normalized; } +export function getFeatureTooltip(feature: Pick): string { + return feature.tooltip ?? feature.label; +} + +export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor { + switch (featureId) { + case "fast_mode": + return "yellow"; + case "plan_mode": + return "blue"; + default: + return "default"; + } +} + export function resolveAgentModelSelection(input: { models: AgentModelDefinition[] | null; runtimeModelId: string | null | undefined; diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index a4bf19ddf..00c1790ba 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -9,7 +9,6 @@ import { useState, } from "react"; import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native"; -import Markdown from "react-native-markdown-display"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "expo-router"; @@ -36,6 +35,7 @@ import { MessageOuterSpacingProvider, type InlinePathTarget, } from "./message"; +import { PlanCard } from "./plan-card"; import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; @@ -59,9 +59,7 @@ import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, } from "./use-bottom-anchor-controller"; -import { createMarkdownStyles } from "@/styles/markdown-styles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; -import { getMarkdownListMarker } from "@/utils/markdown-list"; import { normalizeInlinePathTarget } from "@/utils/inline-path"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -252,10 +250,7 @@ const AgentStreamViewComponent = forwardRef ); - case "thought": { const nextItem = getStreamNeighborItem({ strategy: streamRenderStrategy, @@ -755,90 +749,6 @@ function PermissionRequestCard({ return undefined; }, [request]); - const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); - - const markdownRules = useMemo(() => { - return { - text: ( - node: any, - _children: React.ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, - ) => ( - - {node.content} - - ), - textgroup: ( - node: any, - children: React.ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, - ) => ( - - {children} - - ), - code_block: ( - node: any, - _children: React.ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, - ) => ( - - {node.content} - - ), - fence: ( - node: any, - _children: React.ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, - ) => ( - - {node.content} - - ), - code_inline: ( - node: any, - _children: React.ReactNode[], - _parent: any, - styles: any, - inheritedStyles: any = {}, - ) => ( - - {node.content} - - ), - bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => ( - - {children} - - ), - ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => ( - - {children} - - ), - list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => { - const { isOrdered, marker } = getMarkdownListMarker(node, parent); - const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon; - const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content; - - return ( - - {marker} - {children} - - ); - }, - }; - }, []); - const permissionMutation = useMutation({ mutationFn: async (input: { agentId: string; @@ -891,50 +801,8 @@ function PermissionRequestCard({ ); } - return ( - - {title} - - {description ? ( - - {description} - - ) : null} - - {planMarkdown ? ( - - {!isPlanRequest ? ( - - Proposed plan - - ) : null} - - {planMarkdown} - - - ) : null} - - {!isPlanRequest ? ( - - ) : null} - + const footer = ( + <> + + ); + + if (isPlanRequest && planMarkdown) { + return ( + + ); + } + + return ( + + {title} + + {description ? ( + + {description} + + ) : null} + + {planMarkdown ? : null} + + {!isPlanRequest ? ( + + ) : null} + + {footer} ); } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index eab286780..7c65e5e55 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -71,6 +71,7 @@ import { getMarkdownListMarker } from "@/utils/markdown-list"; import { openExternalUrl } from "@/utils/open-external-url"; import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation"; export type { InlinePathTarget } from "@/utils/inline-path"; +import { PlanCard } from "./plan-card"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; @@ -1884,6 +1885,12 @@ export const ToolCall = memo(function ToolCall({ ); }, [isMobile, effectiveDetail, errorText, isLoadingDetails]); + if (effectiveDetail?.type === "plan") { + return ( + + ); + } + return ( ( + + {node.content} + + ), + textgroup: ( + node: any, + children: ReactNode[], + _parent: any, + styles: any, + inheritedStyles: any = {}, + ) => ( + + {children} + + ), + code_block: ( + node: any, + _children: ReactNode[], + _parent: any, + styles: any, + inheritedStyles: any = {}, + ) => ( + + {node.content} + + ), + fence: ( + node: any, + _children: ReactNode[], + _parent: any, + styles: any, + inheritedStyles: any = {}, + ) => ( + + {node.content} + + ), + code_inline: ( + node: any, + _children: ReactNode[], + _parent: any, + styles: any, + inheritedStyles: any = {}, + ) => ( + + {node.content} + + ), + bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => ( + + {children} + + ), + ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => ( + + {children} + + ), + list_item: (node: any, children: ReactNode[], parent: any, styles: any) => { + const { isOrdered, marker } = getMarkdownListMarker(node, parent); + const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon; + const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content; + + return ( + + {marker} + {children} + + ); + }, + }; +} + +export function PlanCard({ + title = "Plan", + description, + text, + footer, + disableOuterSpacing = false, +}: { + title?: string; + description?: string; + text: string; + footer?: ReactNode; + disableOuterSpacing?: boolean; +}) { + const { theme } = useUnistyles(); + const markdownStyles = createMarkdownStyles(theme); + const markdownRules = createPlanMarkdownRules(); + + return ( + + {title} + {description ? ( + + {description} + + ) : null} + + {text} + + {footer ? {footer} : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + marginVertical: theme.spacing[3], + padding: theme.spacing[3], + borderRadius: theme.spacing[2], + borderWidth: 1, + gap: theme.spacing[2], + }, + containerCompact: { + marginVertical: 0, + }, + title: { + fontSize: theme.fontSize.base, + lineHeight: 22, + }, + description: { + fontSize: theme.fontSize.sm, + lineHeight: 20, + }, + footer: { + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index f2bb37431..e29793dc9 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -100,11 +100,16 @@ const DEFAULT_STATUS_DOT_SIZE = 7; const EMPHASIZED_STATUS_DOT_SIZE = 9; const DEFAULT_STATUS_DOT_OFFSET = 0; const EMPHASIZED_STATUS_DOT_OFFSET = -1; -const GITHUB_PR_STATE_LABELS: Record = { - open: "Open", - merged: "Merged", - closed: "Closed", -}; +function getWorkspacePrIconColor(theme: ReturnType["theme"], state: PrHint["state"]) { + switch (state) { + case "merged": + return theme.colors.palette.purple[500]; + case "open": + return theme.colors.palette.green[500]; + case "closed": + return theme.colors.palette.red[500]; + } +} interface SidebarWorkspaceListProps { projects: SidebarProjectEntry[]; @@ -169,7 +174,8 @@ interface WorkspaceRowInnerProps { function WorkspacePrBadge({ hint }: { hint: PrHint }) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); - const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted; + const textColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted; + const iconColor = getWorkspacePrIconColor(theme, hint.state); const handlePressIn = useCallback((event: GestureResponderEvent) => { event.stopPropagation(); @@ -197,17 +203,17 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) { pressed && styles.workspacePrBadgePressed, ]} > - + - #{hint.number} ยท {GITHUB_PR_STATE_LABELS[hint.state]} + #{hint.number} - {isHovered && } + {isHovered && } ); } diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index 8090c28a8..4abc88dd2 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -62,12 +62,14 @@ function normalizeDraftCommandConfig( const modeId = draftConfig.modeId?.trim() ?? ""; const model = draftConfig.model?.trim() ?? ""; const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; + const featureValues = draftConfig.featureValues; return { provider: draftConfig.provider, cwd, ...(modeId ? { modeId } : {}), ...(model ? { model } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}), + ...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}), }; } diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 518f0c372..5d39ba65d 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -16,6 +16,7 @@ export interface DraftCommandConfig { modeId?: string; model?: string; thinkingOptionId?: string; + featureValues?: Record; } function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) { @@ -28,6 +29,7 @@ function commandsQueryKey(serverId: string, agentId: string, draftConfig?: Draft draftConfig?.modeId ?? null, draftConfig?.model ?? null, draftConfig?.thinkingOptionId ?? null, + draftConfig?.featureValues ?? null, ] as const; } diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts new file mode 100644 index 000000000..eaef93d6f --- /dev/null +++ b/packages/app/src/hooks/use-draft-agent-features.ts @@ -0,0 +1,134 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { + AgentFeature, + AgentProvider, + AgentSessionConfig, +} from "@server/server/agent/agent-sdk-types"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; + +function pruneFeatureValues( + featureValues: Record, + features: AgentFeature[], +): Record { + const allowedFeatureIds = new Set(features.map((feature) => feature.id)); + let changed = false; + const next: Record = {}; + + for (const [featureId, value] of Object.entries(featureValues)) { + if (!allowedFeatureIds.has(featureId)) { + changed = true; + continue; + } + next[featureId] = value; + } + + return changed ? next : featureValues; +} + +function applyFeatureValues( + features: AgentFeature[], + featureValues: Record, +): AgentFeature[] { + if (Object.keys(featureValues).length === 0) { + return features; + } + + return features.map((feature) => { + if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) { + return feature; + } + + return { + ...feature, + value: featureValues[feature.id], + } as AgentFeature; + }); +} + +type DraftFeatureConfig = Pick< + AgentSessionConfig, + "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" +>; + +export function useDraftAgentFeatures(input: { + serverId: string | null | undefined; + provider: AgentProvider; + cwd: string | null | undefined; + modeId: string | null | undefined; + modelId: string | null | undefined; + thinkingOptionId: string | null | undefined; +}) { + const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input; + const [featureValues, setFeatureValues] = useState>({}); + const client = useHostRuntimeClient(serverId ?? ""); + const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const normalizedCwd = cwd?.trim() || ""; + + const draftConfig = useMemo(() => { + if (!normalizedCwd) { + return null; + } + + return { + provider, + cwd: normalizedCwd, + ...(modeId ? { modeId } : {}), + ...(modelId ? { model: modelId } : {}), + ...(thinkingOptionId ? { thinkingOptionId } : {}), + }; + }, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]); + + const featuresQuery = useQuery({ + queryKey: [ + "providerFeatures", + serverId ?? null, + provider, + normalizedCwd || null, + modeId ?? null, + modelId ?? null, + thinkingOptionId ?? null, + ], + enabled: Boolean(serverId && client && isConnected && draftConfig), + staleTime: 5 * 60 * 1000, + queryFn: async () => { + if (!client || !draftConfig) { + throw new Error("Host is not connected"); + } + const payload = await client.listProviderFeatures(draftConfig); + if (payload.error) { + throw new Error(payload.error); + } + return payload.features ?? []; + }, + }); + + const features = useMemo(() => { + return applyFeatureValues(featuresQuery.data ?? [], featureValues); + }, [featureValues, featuresQuery.data]); + + useEffect(() => { + const next = pruneFeatureValues(featureValues, features); + if (next !== featureValues) { + setFeatureValues(next); + } + }, [featureValues, features]); + + const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined; + const setFeatureValue = useCallback((featureId: string, value: unknown) => { + setFeatureValues((current) => { + if (Object.is(current[featureId], value)) { + return current; + } + + return { ...current, [featureId]: value }; + }); + }, []); + + return { + features, + featureValues: effectiveFeatureValues, + isLoading: featuresQuery.isLoading, + setFeatureValue, + }; +} diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 7acd471c1..1ca014d32 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -56,6 +56,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; +import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; const EMPTY_PENDING_PERMISSIONS = new Map(); const DRAFT_CAPABILITIES: AgentCapabilityFlags = { @@ -757,6 +758,18 @@ function DraftAgentScreenContent({ availableModels.find((model) => model.id === effectiveDraftModelId) ?? null; return selectedModelDefinition?.defaultThinkingOptionId ?? ""; }, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]); + const { + features: draftFeatures, + featureValues: draftFeatureValues, + setFeatureValue: setDraftFeatureValue, + } = useDraftAgentFeatures({ + serverId: selectedServerId, + provider: selectedProvider, + cwd: workingDir, + modeId: selectedMode, + modelId: effectiveDraftModelId, + thinkingOptionId: effectiveDraftThinkingOptionId, + }); const draftCommandConfig = useMemo(() => { const cwd = ( isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir @@ -773,8 +786,10 @@ function DraftAgentScreenContent({ ...(effectiveDraftThinkingOptionId ? { thinkingOptionId: effectiveDraftThinkingOptionId } : {}), + ...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}), }; }, [ + draftFeatureValues, effectiveDraftModelId, effectiveDraftThinkingOptionId, isAttachWorktree, @@ -878,6 +893,7 @@ function DraftAgentScreenContent({ title: "New agent", cwd, model, + features: draftFeatures, thinkingOptionId, labels: {}, }; @@ -896,6 +912,7 @@ function DraftAgentScreenContent({ ...(effectiveDraftThinkingOptionId ? { thinkingOptionId: effectiveDraftThinkingOptionId } : {}), + ...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}), }; const effectiveBaseBranch = baseBranch.trim(); @@ -1248,6 +1265,8 @@ function DraftAgentScreenContent({ thinkingOptions: availableThinkingOptions, selectedThinkingOptionId, onSelectThinkingOption: setThinkingOptionFromUser, + features: draftFeatures, + onSetFeature: setDraftFeatureValue, disabled: isSubmitting, }} /> diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index b5a41e0e8..7ec57e62a 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -8,6 +8,7 @@ import type { ImageAttachment } from "@/components/message-input"; import { useAgentFormState } from "@/hooks/use-agent-form-state"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; +import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { Agent } from "@/stores/session-store"; @@ -110,6 +111,18 @@ export function WorkspaceDraftAgentTab({ availableModels.find((model) => model.id === effectiveDraftModelId) ?? null; return selectedModelDefinition?.defaultThinkingOptionId ?? ""; }, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]); + const { + features: draftFeatures, + featureValues: draftFeatureValues, + setFeatureValue: setDraftFeatureValue, + } = useDraftAgentFeatures({ + serverId, + provider: selectedProvider, + cwd: workspaceId, + modeId: selectedMode, + modelId: effectiveDraftModelId, + thinkingOptionId: effectiveDraftThinkingOptionId, + }); const { formErrorMessage, @@ -168,6 +181,7 @@ export function WorkspaceDraftAgentTab({ title: "Agent", cwd: workspaceId, model, + features: draftFeatures, thinkingOptionId, labels: {}, }; @@ -186,6 +200,7 @@ export function WorkspaceDraftAgentTab({ ...(effectiveDraftThinkingOptionId ? { thinkingOptionId: effectiveDraftThinkingOptionId } : {}), + ...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}), }; const imagesData = await encodeImages(images); @@ -215,8 +230,10 @@ export function WorkspaceDraftAgentTab({ ...(effectiveDraftThinkingOptionId ? { thinkingOptionId: effectiveDraftThinkingOptionId } : {}), + ...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}), }; }, [ + draftFeatureValues, effectiveDraftModelId, effectiveDraftThinkingOptionId, modeOptions.length, @@ -297,6 +314,8 @@ export function WorkspaceDraftAgentTab({ thinkingOptions: availableThinkingOptions, selectedThinkingOptionId, onSelectThinkingOption: setThinkingOptionFromUser, + features: draftFeatures, + onSetFeature: setDraftFeatureValue, disabled: isSubmitting, }} /> diff --git a/packages/app/src/utils/tool-call-detail-state.ts b/packages/app/src/utils/tool-call-detail-state.ts index 4f90b4f08..e07d448b3 100644 --- a/packages/app/src/utils/tool-call-detail-state.ts +++ b/packages/app/src/utils/tool-call-detail-state.ts @@ -51,6 +51,8 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined): ); case "plain_text": return Boolean(detail.label || detail.text); + case "plan": + return detail.text.trim().length > 0; case "unknown": return hasMeaningfulUnknownValue(detail.input) || hasMeaningfulUnknownValue(detail.output); } diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts index ff40a072b..be524faad 100644 --- a/packages/app/src/utils/tool-call-icon.ts +++ b/packages/app/src/utils/tool-call-icon.ts @@ -24,6 +24,7 @@ const TOOL_DETAIL_ICONS: Record = worktree_setup: SquareTerminal, sub_agent: Bot, plain_text: Wrench, + plan: Brain, unknown: Wrench, }; diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index bf49719fa..dadff3900 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -37,6 +37,7 @@ import type { OpenProjectResponseMessage, ArchiveWorkspaceResponseMessage, ListCommandsResponse, + ListProviderFeaturesResponseMessage, ListProviderModelsResponseMessage, ListProviderModesResponseMessage, ListAvailableProvidersResponse, @@ -216,13 +217,14 @@ type CreatePaseoWorktreePayload = Extract< >["payload"]; type FileExplorerPayload = FileExplorerResponse["payload"]; type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; +type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListProviderModesPayload = ListProviderModesResponseMessage["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< AgentSessionConfig, - "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" + "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues" >; type ListCommandsOptions = { requestId?: string; @@ -2521,6 +2523,21 @@ export class DaemonClient { }); } + async listProviderFeatures( + draftConfig: ListCommandsDraftConfig, + options?: { requestId?: string }, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "list_provider_features_request", + draftConfig, + }, + responseType: "list_provider_features_response", + timeout: 45000, + }); + } + async listAvailableProviders(options?: { requestId?: string; }): Promise { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 48bb0b310..50e4efa5f 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -550,6 +550,31 @@ export class AgentManager { } } + async listDraftFeatures(config: AgentSessionConfig): Promise { + const normalizedConfig = await this.normalizeConfig(config); + const client = this.requireClient(normalizedConfig.provider); + const available = await client.isAvailable(); + if (!available) { + throw new Error( + `Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`, + ); + } + + const session = await client.createSession(normalizedConfig); + try { + return session.features ?? []; + } finally { + try { + await session.close(); + } catch (error) { + this.logger.warn( + { err: error, provider: normalizedConfig.provider }, + "Failed to close draft feature listing session", + ); + } + } + } + getAgent(id: string): ManagedAgent | null { const agent = this.agents.get(id); return agent ? { ...agent } : null; diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 9edfda6a2..7823ae02f 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -69,6 +69,7 @@ export type AgentFeatureToggle = { id: string; label: string; description?: string; + tooltip?: string; icon?: string; value: boolean; }; @@ -78,6 +79,7 @@ export type AgentFeatureSelect = { id: string; label: string; description?: string; + tooltip?: string; icon?: string; value: string | null; options: AgentSelectOption[]; @@ -222,6 +224,10 @@ export type ToolCallDetail = text?: string; icon?: ToolCallIconName; } + | { + type: "plan"; + text: string; + } | { type: "unknown"; input: unknown | null; diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index f3a39dbe1..78eb15e60 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -59,18 +59,10 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [ ]; const CODEX_MODES: AgentProviderModeDefinition[] = [ - { - id: "read-only", - label: "Read Only", - description: - "Read files and answer questions. Manual approval required for edits, commands, or network ops.", - icon: "ShieldCheck", - colorTier: "safe", - }, { id: "auto", - label: "Auto", - description: "Edit files and run commands but still request approval before escalating scope.", + label: "Default Permissions", + description: "Edit files and run commands with Codex's default approval flow.", icon: "ShieldAlert", colorTier: "moderate", }, @@ -145,7 +137,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ modes: CODEX_MODES, voice: { enabled: true, - defaultModeId: "read-only", + defaultModeId: "auto", defaultModel: "gpt-5.1-codex-mini", }, }, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts index 9fd4b800b..8094fe95e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts @@ -35,6 +35,7 @@ describe("Codex app-server provider (e2e)", () => { model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); + expect(session.features?.some((feature) => feature.id === "plan_mode")).toBe(true); const result = await session.run("Say hello in one sentence."); expect(result.finalText.length).toBeGreaterThan(0); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index d7870895f..c86e9b7d8 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -5,6 +5,18 @@ import { __codexAppServerInternals } from "./codex-app-server-agent.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; const CODEX_PROVIDER = "codex"; +const TEST_COLLABORATION_MODES = [ + { + name: "Code", + mode: "code", + developer_instructions: "Built-in code mode", + }, + { + name: "Plan", + mode: "plan", + developer_instructions: "Built-in plan mode", + }, +]; function createConfig(overrides: Partial = {}): AgentSessionConfig { return { @@ -28,11 +40,13 @@ function createSession(configOverrides: Partial = {}) { ) as unknown as AgentSession & { [key: string]: unknown }; session.connected = true; session.currentThreadId = "test-thread"; + session.collaborationModes = TEST_COLLABORATION_MODES; + session.refreshResolvedCollaborationMode(); return session; } -describe("Codex app-server provider fast mode", () => { - test("features returns fast_mode toggle when model supports it", async () => { +describe("Codex app-server provider features", () => { + test("features returns fast and plan toggles when supported", async () => { const session = createSession(); expect(session.features).toEqual([ @@ -41,12 +55,23 @@ describe("Codex app-server provider fast mode", () => { id: "fast_mode", label: "Fast", description: "Priority inference at 2x usage", + tooltip: "Toggle fast mode", icon: "zap", value: false, }, + { + type: "toggle", + id: "plan_mode", + label: "Plan", + description: "Switch Codex into planning-only collaboration mode", + tooltip: "Toggle plan mode", + icon: "list-todo", + value: false, + }, ]); await session.setFeature?.("fast_mode", true); + await session.setFeature?.("plan_mode", true); expect(session.features).toEqual([ { @@ -54,16 +79,36 @@ describe("Codex app-server provider fast mode", () => { id: "fast_mode", label: "Fast", description: "Priority inference at 2x usage", + tooltip: "Toggle fast mode", icon: "zap", value: true, }, + { + type: "toggle", + id: "plan_mode", + label: "Plan", + description: "Switch Codex into planning-only collaboration mode", + tooltip: "Toggle plan mode", + icon: "list-todo", + value: true, + }, ]); }); - test("features returns empty array when model does not support fast mode", () => { + test("features returns only plan toggle when model does not support fast mode", () => { const session = createSession({ model: "gpt-3.5-turbo" }); - expect(session.features).toEqual([]); + expect(session.features).toEqual([ + { + type: "toggle", + id: "plan_mode", + label: "Plan", + description: "Switch Codex into planning-only collaboration mode", + tooltip: "Toggle plan mode", + icon: "list-todo", + value: false, + }, + ]); }); test("setFeature('fast_mode', true) sets serviceTier to fast", async () => { @@ -103,21 +148,32 @@ describe("Codex app-server provider fast mode", () => { ); }); - test("constructor restores serviceTier from config.featureValues", () => { + test("constructor restores feature flags from config.featureValues", () => { const session = createSession({ - featureValues: { fast_mode: true }, + featureValues: { fast_mode: true, plan_mode: true }, }); expect((session as any).serviceTier).toBe("fast"); + expect((session as any).planModeEnabled).toBe(true); expect(session.features).toEqual([ { type: "toggle", id: "fast_mode", label: "Fast", description: "Priority inference at 2x usage", + tooltip: "Toggle fast mode", icon: "zap", value: true, }, + { + type: "toggle", + id: "plan_mode", + label: "Plan", + description: "Switch Codex into planning-only collaboration mode", + tooltip: "Toggle plan mode", + icon: "list-todo", + value: true, + }, ]); }); @@ -158,7 +214,17 @@ describe("Codex app-server provider fast mode", () => { await session.setFeature?.("fast_mode", true); await session.setModel("gpt-3.5-turbo"); - expect(session.features).toEqual([]); + expect(session.features).toEqual([ + { + type: "toggle", + id: "plan_mode", + label: "Plan", + description: "Switch Codex into planning-only collaboration mode", + tooltip: "Toggle plan mode", + icon: "list-todo", + value: false, + }, + ]); expect((session as any).serviceTier).toBeNull(); await session.startTurn("hello"); @@ -171,4 +237,29 @@ describe("Codex app-server provider fast mode", () => { expect.any(Number), ); }); + + test("startTurn switches collaboration mode when plan mode is enabled", async () => { + const session = createSession(); + const request = vi.fn().mockResolvedValue(undefined); + (session as any).client = { request }; + (session as any).connected = true; + (session as any).currentThreadId = "thread-123"; + (session as any).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined); + (session as any).ensureThread = vi.fn().mockResolvedValue(undefined); + (session as any).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]); + (session as any).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null); + + await session.setFeature?.("plan_mode", true); + await session.startTurn("hello"); + + expect(request).toHaveBeenCalledWith( + "turn/start", + expect.objectContaining({ + collaborationMode: expect.objectContaining({ + mode: "plan", + }), + }), + expect.any(Number), + ); + }); }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 3ec99eaca..671ca4517 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -55,6 +55,25 @@ describe("Codex app-server provider", () => { } }); + test("maps Codex plan markdown to a synthetic plan tool call", () => { + const item = __codexAppServerInternals.mapCodexPlanToToolCall({ + callId: "plan-turn-1", + text: "### Login Screen\n- Build layout\n- Add validation", + }); + + expect(item).toEqual({ + type: "tool_call", + callId: "plan-turn-1", + name: "plan", + status: "completed", + error: null, + detail: { + type: "plan", + text: "### Login Screen\n- Build layout\n- Add validation", + }, + }); + }); + test("maps patch notifications with object-style single change payloads", () => { const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({ callId: "patch-object-single", diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index f9e1d23fb..cf88f12f8 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -2,7 +2,6 @@ import type { AgentCapabilityFlags, AgentClient, AgentFeature, - AgentFeatureToggle, AgentLaunchContext, AgentMode, AgentModelDefinition, @@ -52,6 +51,7 @@ import { quoteWindowsCommand, } from "../../../utils/executable.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; +import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const TURN_START_TIMEOUT_MS = 90 * 1000; @@ -68,16 +68,10 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = { }; const CODEX_MODES: AgentMode[] = [ - { - id: "read-only", - label: "Read Only", - description: - "Read files and answer questions. Manual approval required for edits, commands, or network ops.", - }, { id: "auto", - label: "Auto", - description: "Edit files and run commands but still request approval before escalating scope.", + label: "Default Permissions", + description: "Edit files and run commands with Codex's default approval flow.", }, { id: "full-access", @@ -87,20 +81,6 @@ const CODEX_MODES: AgentMode[] = [ ]; const DEFAULT_CODEX_MODE_ID = "auto"; -const CODEX_FAST_MODE_SUPPORTED_MODE_PREFIXES = [ - "gpt-5", - "gpt-4.1", - "o3", - "o4-mini", -] as const; -const CODEX_FAST_MODE_FEATURE: AgentFeatureToggle = { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at 2x usage", - icon: "zap", - value: false, -}; const MODE_PRESETS: Record< string, @@ -156,16 +136,6 @@ function normalizeCodexModelLabel(displayName: string): string { return displayName.replace(/\bgpt\b/gi, "GPT"); } -function codexModelSupportsFastMode(modelId: string | null | undefined): boolean { - const normalizedModelId = normalizeCodexModelId(modelId); - if (!normalizedModelId) { - return false; - } - return CODEX_FAST_MODE_SUPPORTED_MODE_PREFIXES.some( - (prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(prefix), - ); -} - type CodexConfiguredDefaults = { model?: string; thinkingOptionId?: string; @@ -714,28 +684,43 @@ function extractUserText(content: unknown): string | null { return parts.length > 0 ? parts.join("\n") : null; } -function parsePlanTextToTodoItems(text: string): { text: string; completed: boolean }[] { - const lines = text +function normalizePlanMarkdown(text: string): string { + return text .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - if (lines.length === 0) { - return [{ text, completed: false }]; - } - return lines.map((line) => ({ - text: line.replace(/^[-*]\s+/, ""), - completed: false, - })); + .map((line) => line.replace(/\s+$/, "")) + .join("\n") + .trim(); } -function planStepsToTodoItems(steps: Array<{ step: string; status: string }>): { - text: string; - completed: boolean; -}[] { - return steps.map((entry) => ({ - text: entry.step, - completed: entry.status === "completed", - })); +function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): string { + const lines = steps + .map((entry) => entry.step.trim()) + .filter((step) => step.length > 0) + .map((step) => { + if (/^(#{1,6}\s|[-*+]\s|\d+\.\s)/.test(step)) { + return step; + } + return `- ${step}`; + }); + return normalizePlanMarkdown(lines.join("\n")); +} + +function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolCallTimelineItem | null { + const text = normalizePlanMarkdown(params.text); + if (!text) { + return null; + } + return { + type: "tool_call", + callId: params.callId, + name: "plan", + status: "completed", + error: null, + detail: { + type: "plan", + text, + }, + }; } type CodexPatchFileChange = { @@ -1120,9 +1105,12 @@ function threadItemToTimeline( return { type: "assistant_message", text: normalizedItem.text ?? "" }; } case "plan": { - const text = normalizedItem.text ?? ""; - const items = parsePlanTextToTodoItems(text); - return { type: "todo", items }; + return mapCodexPlanToToolCall({ + callId: + nonEmptyString(normalizedItem.id ?? normalizedItem.itemId ?? undefined) ?? + `plan:${normalizePlanMarkdown(normalizedItem.text ?? "")}`, + text: normalizedItem.text ?? "", + }); } case "reasoning": { const summary = Array.isArray(normalizedItem.summary) @@ -2045,6 +2033,22 @@ function buildCodexAppServerEnv( }; } +function buildCodexAppServerInitializeParams(): { + clientInfo: { name: string; title: string; version: string }; + capabilities: { experimentalApi: true }; +} { + return { + clientInfo: { + name: "paseo", + title: "Paseo", + version: "0.0.0", + }, + capabilities: { + experimentalApi: true, + }, + }; +} + class CodexAppServerAgentSession implements AgentSession { readonly provider = CODEX_PROVIDER; readonly capabilities = CODEX_APP_SERVER_CAPABILITIES; @@ -2060,6 +2064,7 @@ class CodexAppServerAgentSession implements AgentSession { private activeForegroundTurnId: string | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; private serviceTier: "fast" | null = null; + private planModeEnabled = false; private historyPending = false; private persistedHistory: AgentTimelineItem[] = []; private pendingPermissions = new Map(); @@ -2119,6 +2124,9 @@ class CodexAppServerAgentSession implements AgentSession { if (this.config.featureValues?.fast_mode) { this.serviceTier = "fast"; } + if (this.config.featureValues?.plan_mode) { + this.planModeEnabled = true; + } if (this.resumeHandle?.sessionId) { this.currentThreadId = this.resumeHandle.sessionId; @@ -2131,10 +2139,12 @@ class CodexAppServerAgentSession implements AgentSession { } get features(): AgentFeature[] { - if (!codexModelSupportsFastMode(this.config.model)) { - return []; - } - return [{ ...CODEX_FAST_MODE_FEATURE, value: this.serviceTier === "fast" }]; + return buildCodexFeatures({ + modelId: this.config.model, + fastModeEnabled: this.serviceTier === "fast", + planModeEnabled: this.planModeEnabled, + planModeAvailable: this.hasPlanCollaborationMode(), + }); } async connect(): Promise { @@ -2144,13 +2154,7 @@ class CodexAppServerAgentSession implements AgentSession { this.client.setNotificationHandler((method, params) => this.handleNotification(method, params)); this.registerRequestHandlers(); - await this.client.request("initialize", { - clientInfo: { - name: "paseo", - title: "Paseo", - version: "0.0.0", - }, - }); + await this.client.request("initialize", buildCodexAppServerInitializeParams()); this.client.notify("initialized", {}); await this.loadCollaborationModes(); @@ -2182,7 +2186,7 @@ class CodexAppServerAgentSession implements AgentSession { this.logger.trace({ error }, "Failed to load collaboration modes"); this.collaborationModes = []; } - this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); + this.refreshResolvedCollaborationMode(); } private async loadSkills(): Promise { @@ -2211,23 +2215,46 @@ class CodexAppServerAgentSession implements AgentSession { } } - private resolveCollaborationMode( - modeId: string, - ): { mode: string; settings: Record; name: string } | null { + private findCollaborationMode( + target: "code" | "plan", + ): { + name: string; + mode?: string | null; + model?: string | null; + reasoning_effort?: string | null; + developer_instructions?: string | null; + } | null { if (this.collaborationModes.length === 0) return null; - const normalized = modeId.toLowerCase(); const findByName = (predicate: (name: string) => boolean) => this.collaborationModes.find((entry) => predicate(entry.name.toLowerCase())); - let match = - normalized === "read-only" - ? findByName((name) => name.includes("read") || name.includes("plan")) - : normalized === "full-access" - ? findByName((name) => name.includes("full") || name.includes("exec")) - : findByName((name) => name.includes("auto") || name.includes("code")); - if (!match) { - match = this.collaborationModes[0] ?? null; + + if (target === "plan") { + return findByName((name) => name.includes("plan") || name.includes("read")) ?? null; } + + return ( + findByName((name) => name.includes("auto") || name.includes("code")) ?? + this.collaborationModes.find((entry) => { + const name = entry.name.toLowerCase(); + return !name.includes("plan") && !name.includes("read"); + }) ?? + this.collaborationModes[0] ?? + null + ); + } + + private hasPlanCollaborationMode(): boolean { + return this.findCollaborationMode("plan") !== null; + } + + private resolveCollaborationMode(): { + mode: string; + settings: Record; + name: string; + } | null { + const match = this.findCollaborationMode(this.planModeEnabled ? "plan" : "code"); if (!match) return null; + const settings: Record = {}; if (match.model) settings.model = match.model; if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort; @@ -2244,6 +2271,10 @@ class CodexAppServerAgentSession implements AgentSession { return { mode: match.mode ?? "code", settings, name: match.name }; } + private refreshResolvedCollaborationMode(): void { + this.resolvedCollaborationMode = this.resolveCollaborationMode(); + } + private registerRequestHandlers(): void { if (!this.client) return; @@ -2425,6 +2456,8 @@ class CodexAppServerAgentSession implements AgentSession { timeline.push(event.item); if (event.item.type === "assistant_message") { finalText = event.item.text; + } else if (event.item.type === "tool_call" && event.item.detail.type === "plan") { + finalText = event.item.detail.text; } return; } @@ -2615,7 +2648,6 @@ class CodexAppServerAgentSession implements AgentSession { async setMode(modeId: string): Promise { validateCodexMode(modeId); this.currentMode = modeId; - this.resolvedCollaborationMode = this.resolveCollaborationMode(modeId); this.cachedRuntimeInfo = null; } @@ -2624,13 +2656,13 @@ class CodexAppServerAgentSession implements AgentSession { if (!codexModelSupportsFastMode(this.config.model)) { this.serviceTier = null; } - this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); + this.refreshResolvedCollaborationMode(); this.cachedRuntimeInfo = null; } async setThinkingOption(thinkingOptionId: string | null): Promise { this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId); - this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); + this.refreshResolvedCollaborationMode(); this.cachedRuntimeInfo = null; } @@ -2640,6 +2672,12 @@ class CodexAppServerAgentSession implements AgentSession { this.cachedRuntimeInfo = null; return; } + if (featureId === "plan_mode") { + this.planModeEnabled = Boolean(value); + this.refreshResolvedCollaborationMode(); + this.cachedRuntimeInfo = null; + return; + } throw new Error(`Unknown Codex feature: ${featureId}`); } @@ -2966,17 +3004,22 @@ class CodexAppServerAgentSession implements AgentSession { } if (parsed.kind === "plan_updated") { - const items = planStepsToTodoItems( - parsed.plan.map((entry) => ({ - step: entry.step ?? "", - status: entry.status ?? "pending", - })), - ); - this.emitEvent({ - type: "timeline", - provider: CODEX_PROVIDER, - item: { type: "todo", items }, + const timelineItem = mapCodexPlanToToolCall({ + callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`, + text: planStepsToMarkdown( + parsed.plan.map((entry) => ({ + step: entry.step ?? "", + status: entry.status ?? "pending", + })), + ), }); + if (timelineItem) { + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: timelineItem, + }); + } return; } @@ -3520,9 +3563,7 @@ export class CodexAppServerAgentClient implements AgentClient { const client = new CodexAppServerClient(child, this.logger); try { - await client.request("initialize", { - clientInfo: { name: "paseo", title: "Paseo", version: "0.0.0" }, - }); + await client.request("initialize", buildCodexAppServerInitializeParams()); client.notify("initialized", {}); const limit = options?.limit ?? 20; @@ -3592,13 +3633,7 @@ export class CodexAppServerAgentClient implements AgentClient { const client = new CodexAppServerClient(child, this.logger); try { - await client.request("initialize", { - clientInfo: { - name: "paseo", - title: "Paseo", - version: "0.0.0", - }, - }); + await client.request("initialize", buildCodexAppServerInitializeParams()); client.notify("initialized", {}); const response = (await client.request("model/list", {})) as { data?: Array }; @@ -3689,4 +3724,7 @@ export const __codexAppServerInternals = { codexModelSupportsFastMode, CodexAppServerAgentSession, mapCodexPatchNotificationToToolCall, + planStepsToMarkdown, + mapCodexPlanToToolCall, + threadItemToTimeline, }; diff --git a/packages/server/src/server/agent/providers/codex-feature-definitions.ts b/packages/server/src/server/agent/providers/codex-feature-definitions.ts new file mode 100644 index 000000000..16554d3e4 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-feature-definitions.ts @@ -0,0 +1,66 @@ +import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js"; + +const CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES = [ + "gpt-5", + "gpt-4.1", + "o3", + "o4-mini", +] as const; + +export const CODEX_FAST_MODE_FEATURE: Omit = { + type: "toggle", + id: "fast_mode", + label: "Fast", + description: "Priority inference at 2x usage", + tooltip: "Toggle fast mode", + icon: "zap", +}; + +export const CODEX_PLAN_MODE_FEATURE: Omit = { + type: "toggle", + id: "plan_mode", + label: "Plan", + description: "Switch Codex into planning-only collaboration mode", + tooltip: "Toggle plan mode", + icon: "list-todo", +}; + +function normalizeCodexModelId(modelId: string | null | undefined): string | null { + const normalized = typeof modelId === "string" ? modelId.trim() : ""; + return normalized.length > 0 ? normalized : null; +} + +export function codexModelSupportsFastMode(modelId: string | null | undefined): boolean { + const normalizedModelId = normalizeCodexModelId(modelId); + if (!normalizedModelId) { + return false; + } + return CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES.some( + (prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(prefix), + ); +} + +export function buildCodexFeatures(input: { + modelId: string | null | undefined; + fastModeEnabled: boolean; + planModeEnabled: boolean; + planModeAvailable?: boolean; +}): AgentFeature[] { + const features: AgentFeature[] = []; + + if (codexModelSupportsFastMode(input.modelId)) { + features.push({ + ...CODEX_FAST_MODE_FEATURE, + value: input.fastModeEnabled, + }); + } + + if (input.planModeAvailable !== false) { + features.push({ + ...CODEX_PLAN_MODE_FEATURE, + value: input.planModeEnabled, + }); + } + + return features; +} diff --git a/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts new file mode 100644 index 000000000..1846281b6 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "codex-plan-mode-real-")); +} + +describe("Codex app-server provider (real) plan mode", () => { + test.runIf(isProviderAvailable("codex"))( + "maps gpt-5.4 markdown plans to a plan tool call instead of todo items", + async () => { + const cwd = tmpCwd(); + const client = new CodexAppServerAgentClient(createTestLogger()); + + try { + const session = await client.createSession({ + provider: "codex", + cwd, + modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "medium", + }); + + try { + await session.setFeature?.("plan_mode", true); + + const result = await session.run( + "You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.", + ); + + expect(result.timeline).not.toContainEqual( + expect.objectContaining({ + type: "todo", + }), + ); + + const planCall = result.timeline.find( + (item) => item.type === "tool_call" && item.detail.type === "plan", + ); + + expect(planCall).toBeDefined(); + if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") { + throw new Error("Expected a plan tool call"); + } + + expect(planCall.detail.text).toContain("Login"); + expect(planCall.detail.text).toContain("- "); + expect(result.finalText).toBe(planCall.detail.text); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 240_000, + ); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6d2320935..cc2096383 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1700,6 +1700,10 @@ export class Session { await this.handleListProviderModesRequest(msg); break; + case "list_provider_features_request": + await this.handleListProviderFeaturesRequest(msg); + break; + case "list_available_providers_request": await this.handleListAvailableProvidersRequest(msg); break; @@ -3116,6 +3120,60 @@ export class Session { } } + private buildDraftAgentSessionConfig(draftConfig: { + provider: AgentProvider; + cwd: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; + featureValues?: Record; + }): AgentSessionConfig { + return { + provider: draftConfig.provider, + cwd: expandTilde(draftConfig.cwd), + ...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}), + ...(draftConfig.model ? { model: draftConfig.model } : {}), + ...(draftConfig.thinkingOptionId + ? { thinkingOptionId: draftConfig.thinkingOptionId } + : {}), + ...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}), + }; + } + + private async handleListProviderFeaturesRequest( + msg: Extract, + ): Promise { + const fetchedAt = new Date().toISOString(); + try { + const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig); + const features = await this.agentManager.listDraftFeatures(sessionConfig); + this.emit({ + type: "list_provider_features_response", + payload: { + provider: msg.draftConfig.provider, + features, + error: null, + fetchedAt, + requestId: msg.requestId, + }, + }); + } catch (error) { + this.sessionLogger.error( + { err: error, provider: msg.draftConfig.provider, draftConfig: msg.draftConfig }, + `Failed to list features for ${msg.draftConfig.provider}`, + ); + this.emit({ + type: "list_provider_features_response", + payload: { + provider: msg.draftConfig.provider, + error: (error as Error)?.message ?? String(error), + fetchedAt, + requestId: msg.requestId, + }, + }); + } + } + private async handleListAvailableProvidersRequest( msg: Extract, ): Promise { @@ -3632,16 +3690,7 @@ export class Session { } if (!agent && draftConfig) { - const sessionConfig: AgentSessionConfig = { - provider: draftConfig.provider, - cwd: expandTilde(draftConfig.cwd), - ...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}), - ...(draftConfig.model ? { model: draftConfig.model } : {}), - ...(draftConfig.thinkingOptionId - ? { thinkingOptionId: draftConfig.thinkingOptionId } - : {}), - }; - + const sessionConfig = this.buildDraftAgentSessionConfig(draftConfig); const commands = await this.agentManager.listDraftCommands(sessionConfig); this.emit({ type: "list_commands_response", diff --git a/packages/server/src/shared/agent-feature-schemas.test.ts b/packages/server/src/shared/agent-feature-schemas.test.ts index f761bb4c1..5a01bf75d 100644 --- a/packages/server/src/shared/agent-feature-schemas.test.ts +++ b/packages/server/src/shared/agent-feature-schemas.test.ts @@ -14,6 +14,7 @@ describe("agent feature schemas", () => { id: "fast_mode", label: "Fast mode", description: "Uses lower latency service tier", + tooltip: "Toggle fast mode", icon: "bolt", value: true, }); @@ -31,6 +32,7 @@ describe("agent feature schemas", () => { id: "service_tier", label: "Service tier", description: "Choose a processing tier", + tooltip: "Select service tier", icon: "gauge", value: "flex", options: [ @@ -112,6 +114,7 @@ describe("agent feature schemas", () => { type: "toggle", id: "fast_mode", label: "Fast mode", + tooltip: "Toggle fast mode", value: false, }, ], diff --git a/packages/server/src/shared/messages.list-commands.test.ts b/packages/server/src/shared/messages.list-commands.test.ts index 42a610d66..0896c4250 100644 --- a/packages/server/src/shared/messages.list-commands.test.ts +++ b/packages/server/src/shared/messages.list-commands.test.ts @@ -27,6 +27,9 @@ describe("list_commands_request schema", () => { modeId: "bypassPermissions", model: "gpt-5", thinkingOptionId: "off", + featureValues: { + plan_mode: true, + }, }, requestId: "req-456", }); @@ -41,6 +44,9 @@ describe("list_commands_request schema", () => { modeId: "bypassPermissions", model: "gpt-5", thinkingOptionId: "off", + featureValues: { + plan_mode: true, + }, }); }); }); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 4ccb0e657..0773bfbc7 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -82,6 +82,7 @@ export const AgentFeatureToggleSchema = z.object({ id: z.string(), label: z.string(), description: z.string().optional(), + tooltip: z.string().optional(), icon: z.string().optional(), value: z.boolean(), }); @@ -91,6 +92,7 @@ export const AgentFeatureSelectSchema = z.object({ id: z.string(), label: z.string(), description: z.string().optional(), + tooltip: z.string().optional(), icon: z.string().optional(), value: z.string().nullable(), options: z.array(AgentSelectOptionSchema), @@ -315,6 +317,10 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUn text: z.string().optional(), icon: z.enum(TOOL_CALL_ICON_NAMES).optional(), }), + z.object({ + type: z.literal("plan"), + text: z.string(), + }), z.object({ type: z.literal("unknown"), input: UnknownValueSchema, @@ -1154,6 +1160,13 @@ const ListCommandsDraftConfigSchema = z.object({ modeId: z.string().optional(), model: z.string().optional(), thinkingOptionId: z.string().optional(), + featureValues: z.record(z.unknown()).optional(), +}); + +export const ListProviderFeaturesRequestMessageSchema = z.object({ + type: z.literal("list_provider_features_request"), + draftConfig: ListCommandsDraftConfigSchema, + requestId: z.string(), }); export const ListCommandsRequestSchema = z.object({ @@ -1260,6 +1273,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CreateAgentRequestMessageSchema, ListProviderModelsRequestMessageSchema, ListProviderModesRequestMessageSchema, + ListProviderFeaturesRequestMessageSchema, ListAvailableProvidersRequestMessageSchema, ResumeAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, @@ -2179,6 +2193,17 @@ export const ListProviderModesResponseMessageSchema = z.object({ }), }); +export const ListProviderFeaturesResponseMessageSchema = z.object({ + type: z.literal("list_provider_features_response"), + payload: z.object({ + provider: AgentProviderSchema, + features: z.array(AgentFeatureSchema).optional(), + error: z.string().nullable().optional(), + fetchedAt: z.string(), + requestId: z.string(), + }), +}); + const ProviderAvailabilitySchema = z.object({ provider: AgentProviderSchema, available: z.boolean(), @@ -2386,6 +2411,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FileDownloadTokenResponseSchema, ListProviderModelsResponseMessageSchema, ListProviderModesResponseMessageSchema, + ListProviderFeaturesResponseMessageSchema, ListAvailableProvidersResponseSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, @@ -2462,6 +2488,9 @@ export type ListProviderModelsResponseMessage = z.infer< export type ListProviderModesResponseMessage = z.infer< typeof ListProviderModesResponseMessageSchema >; +export type ListProviderFeaturesResponseMessage = z.infer< + typeof ListProviderFeaturesResponseMessageSchema +>; export type ListAvailableProvidersResponse = z.infer; export type ChatCreateResponse = z.infer; export type ChatListResponse = z.infer; @@ -2504,6 +2533,9 @@ export type ListProviderModelsRequestMessage = z.infer< export type ListProviderModesRequestMessage = z.infer< typeof ListProviderModesRequestMessageSchema >; +export type ListProviderFeaturesRequestMessage = z.infer< + typeof ListProviderFeaturesRequestMessageSchema +>; export type ListAvailableProvidersRequestMessage = z.infer< typeof ListAvailableProvidersRequestMessageSchema >; diff --git a/packages/server/src/shared/tool-call-display.test.ts b/packages/server/src/shared/tool-call-display.test.ts index 136d9e851..d0e80c6b2 100644 --- a/packages/server/src/shared/tool-call-display.test.ts +++ b/packages/server/src/shared/tool-call-display.test.ts @@ -140,4 +140,20 @@ describe("shared tool-call display mapping", () => { summary: "npm run test", }); }); + + it("labels plan detail rows as Plan", () => { + const display = buildToolCallDisplayModel({ + name: "plan", + status: "completed", + error: null, + detail: { + type: "plan", + text: "### Login Screen\n- Build layout", + }, + }); + + expect(display).toEqual({ + displayName: "Plan", + }); + }); }); diff --git a/packages/server/src/shared/tool-call-display.ts b/packages/server/src/shared/tool-call-display.ts index 1ba55b734..899c54d0b 100644 --- a/packages/server/src/shared/tool-call-display.ts +++ b/packages/server/src/shared/tool-call-display.ts @@ -109,6 +109,10 @@ function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay return { summary: input.detail.label, }; + case "plan": + return { + displayName: "Plan", + }; case "unknown": return {}; }