diff --git a/executor/src/agents/codex.rs b/executor/src/agents/codex.rs index ed1d96afa97..02386e5ef04 100644 --- a/executor/src/agents/codex.rs +++ b/executor/src/agents/codex.rs @@ -836,6 +836,21 @@ impl CodexRunState { .unwrap_or("") .replace('_', "") .to_ascii_lowercase(); + if item_type == "plan" { + if let Some(text) = extract_text(item) { + log_codex_run_state_text( + "completed", + "set_plan", + phase.as_deref(), + params, + item, + &text, + ); + self.final_text = text; + self.saw_delta = true; + } + return; + } if !matches!(item_type.as_str(), "agentmessage" | "message") { log_codex_run_state_text("completed", "skip_non_message", None, params, item, ""); return; @@ -2450,6 +2465,42 @@ mod tests { ); } + #[test] + fn codex_run_state_uses_completed_plan_as_final_content() { + let mut state = CodexRunState::default(); + + assert!(state + .handle_message(&json!({ + "method": "item/completed", + "params": { + "item": { + "id": "turn-1-plan", + "type": "plan", + "text": "# Plan\n\n- Execute the steps." + } + } + })) + .is_none()); + + let outcome = state + .handle_message(&json!({ + "method": "turn/completed", + "params": { + "turn": { + "status": "completed" + } + } + })) + .expect("turn completion should produce an outcome"); + + assert_eq!( + outcome, + ExecutionOutcome::Completed { + content: "# Plan\n\n- Execute the steps.".to_owned() + } + ); + } + #[test] fn codex_run_state_routes_item_id_deltas_by_started_phase() { let mut state = CodexRunState::default(); diff --git a/executor/src/runtime_work/events.rs b/executor/src/runtime_work/events.rs index 8ba0a012655..a537d6f14a2 100644 --- a/executor/src/runtime_work/events.rs +++ b/executor/src/runtime_work/events.rs @@ -18,7 +18,10 @@ use crate::{ use super::{ codex_notifications::{codex_notification, debug_ignored_codex_notification}, transcript::{tool_block_from_notification, tool_update_from_notification}, - util::{extract_text, now_ms, raw_string_field, reasoning_content, string_field}, + util::{ + completed_plan_item_text, extract_text, now_ms, raw_string_field, reasoning_content, + string_field, + }, }; pub(crate) fn emit_response_event( @@ -182,6 +185,19 @@ impl CodexNotificationEventMapper { self.agent_message_phases.forget_item(notification.params); return; } + if let Some(text) = completed_plan_item_text(notification.params) { + self.reset_process_text(); + emit_response_event( + event_tx, + device_id, + "response.output_text.delta", + local_task_id, + request, + json!({"delta": text}), + ); + self.agent_message_phases.forget_item(notification.params); + return; + } emit_tool_done( event_tx, device_id, @@ -1339,6 +1355,40 @@ mod tests { assert_eq!(event["payload"]["data"]["delta"], "Done."); } + #[test] + fn maps_codex_completed_plan_items_to_output_text_delta() { + let (event_tx, mut event_rx) = broadcast::channel(4); + let request = ExecutionRequest { + task_id: 7, + subtask_id: 8, + ..ExecutionRequest::default() + }; + + map_codex_notification( + &Some(event_tx), + "device-1", + "local-1", + &request, + json!({ + "method": "item/completed", + "params": { + "item": { + "id": "turn-1-plan", + "type": "plan", + "text": "# Plan\n\n- Inspect the repo." + } + } + }), + ); + + let event = event_rx.try_recv().expect("event should be emitted"); + assert_eq!(event["event"], "response.output_text.delta"); + assert_eq!( + event["payload"]["data"]["delta"], + "# Plan\n\n- Inspect the repo." + ); + } + #[test] fn emits_codex_subagent_activity_events() { let (event_tx, mut event_rx) = broadcast::channel(4); diff --git a/executor/src/runtime_work/runtime_handle_messages.rs b/executor/src/runtime_work/runtime_handle_messages.rs index 17582d82b32..07270d7df8f 100644 --- a/executor/src/runtime_work/runtime_handle_messages.rs +++ b/executor/src/runtime_work/runtime_handle_messages.rs @@ -20,7 +20,8 @@ use super::{ store::RuntimeWorkStore, transcript::{tool_block_from_notification, tool_update_from_notification}, util::{ - extract_text, integer_field, now_ms, raw_string_field, reasoning_content, string_field, + completed_plan_item_text, extract_text, integer_field, now_ms, raw_string_field, + reasoning_content, string_field, }, }; @@ -158,6 +159,11 @@ impl CodexNotificationCacheMapper { self.agent_message_phases.forget_item(params); return; } + if let Some(text) = completed_plan_item_text(params) { + append_runtime_assistant_content_delta(store, local_task_id, request, text); + self.agent_message_phases.forget_item(params); + return; + } if let Some((block_id, updates)) = tool_update_from_notification(params) { update_runtime_assistant_block( store, @@ -1252,6 +1258,49 @@ mod tests { let _ = fs::remove_file(index_path); } + #[test] + fn cache_codex_notification_appends_completed_plan_item_as_content() { + let index_path = temp_index_path("completed-plan-item"); + let store = RuntimeWorkStore::new(index_path.clone()); + let local_task_id = "runtime-cache"; + store.upsert_task(RuntimeTaskLink::new_pending( + local_task_id.to_owned(), + "/tmp/project".to_owned(), + "Runtime cache".to_owned(), + )); + let request = ExecutionRequest { + task_id: 1, + subtask_id: 42, + ..ExecutionRequest::default() + }; + + cache_codex_notification( + &store, + local_task_id, + &request, + &json!({ + "method": "item/completed", + "params": { + "item": { + "id": "turn-1-plan", + "type": "plan", + "text": "# Plan\n\n- Inspect the repo." + } + } + }), + ); + + let link = store + .get_task(local_task_id) + .expect("runtime task should exist"); + let messages = cached_messages(&link); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["content"], "# Plan\n\n- Inspect the repo."); + assert_eq!(messages[0]["blocks"].as_array().map(Vec::len), Some(0)); + + let _ = fs::remove_file(index_path); + } + #[test] fn cache_codex_notification_ignores_subagent_agent_message_deltas() { let index_path = temp_index_path("subagent-agent-delta"); diff --git a/executor/src/runtime_work/util.rs b/executor/src/runtime_work/util.rs index 8c1b533650b..0b43b28d130 100644 --- a/executor/src/runtime_work/util.rs +++ b/executor/src/runtime_work/util.rs @@ -422,6 +422,15 @@ pub(crate) fn extract_text(item: &Value) -> Option { } } +pub(crate) fn completed_plan_item_text(params: &Value) -> Option { + let item = params.get("item").unwrap_or(params); + if item_type(item).as_str() != "plan" { + return None; + } + + extract_text(item) +} + pub(crate) fn reasoning_content(item: &Value) -> Option { if let Some(summary) = item.get("summary").and_then(Value::as_array) { let text = summary diff --git a/wework/src/components/chat/AssistantMarkdown.tsx b/wework/src/components/chat/AssistantMarkdown.tsx index cf10ae7e6a5..ac9fa9af337 100644 --- a/wework/src/components/chat/AssistantMarkdown.tsx +++ b/wework/src/components/chat/AssistantMarkdown.tsx @@ -19,6 +19,7 @@ const ASSISTANT_MARKDOWN_LINK_CLASS = [ 'dark:text-blue-300 dark:hover:text-blue-200', '[&_code]:!rounded-none [&_code]:!bg-transparent [&_code]:!px-0 [&_code]:!py-0 [&_code]:!font-[inherit] [&_code]:!text-inherit', ].join(' ') +const CODEX_PLAN_TAG_PATTERN = /<\/?\s*proposed_plan\s*>/gi interface AssistantMarkdownProps { content: string @@ -26,6 +27,8 @@ interface AssistantMarkdownProps { } export function AssistantMarkdown({ content, onOpenFile }: AssistantMarkdownProps) { + const displayContent = normalizeAssistantMarkdownContent(content) + return (
, }} > - {content} + {displayContent}
) } +function normalizeAssistantMarkdownContent(content: string): string { + return content.replace(CODEX_PLAN_TAG_PATTERN, '') +} + function formatMarkdownLineLabel(target: Extract): string { if (typeof target.lineStart !== 'number') return '' if (typeof target.lineEnd === 'number' && target.lineEnd !== target.lineStart) { diff --git a/wework/src/components/chat/FileChangesReviewPanel.test.tsx b/wework/src/components/chat/FileChangesReviewPanel.test.tsx index f3dfd4db829..493c95a88c6 100644 --- a/wework/src/components/chat/FileChangesReviewPanel.test.tsx +++ b/wework/src/components/chat/FileChangesReviewPanel.test.tsx @@ -156,6 +156,7 @@ describe('FileChangesReviewPanel', () => { expect(screen.getByText(/This diff is large|此差异较大/)).toBeInTheDocument() expect(screen.getByTestId('file-changes-review-toolbar')).toHaveTextContent('上轮对话') expect(screen.queryByTestId('file-changes-review-file-tree')).not.toBeInTheDocument() + expect(screen.queryByTestId('toggle-file-tree-button')).not.toBeInTheDocument() const diff = screen.getByTestId('file-changes-review-diff') await waitFor(() => { @@ -164,9 +165,6 @@ describe('FileChangesReviewPanel', () => { expect(getRenderedDiffText()).not.toContain('new 2') }) - await userEvent.click(screen.getByTestId('toggle-file-tree-button')) - expect(screen.getByTestId('pierre-file-tree')).toBeInTheDocument() - rerender( { await userEvent.click(screen.getByTestId('refresh-review-diff-button')) expect(onRefresh).toHaveBeenCalledTimes(1) - - await userEvent.click(screen.getByTestId('toggle-file-tree-button')) - expect(screen.queryByTestId('file-changes-review-file-tree')).not.toBeInTheDocument() - await userEvent.click(screen.getByTestId('toggle-file-tree-button')) + expect(screen.queryByTestId('toggle-file-tree-button')).not.toBeInTheDocument() expect(screen.getByTestId('file-changes-review-file-tree')).toBeInTheDocument() await userEvent.click(screen.getByTestId('toggle-line-wrap-button')) diff --git a/wework/src/components/chat/FileChangesReviewPanel.tsx b/wework/src/components/chat/FileChangesReviewPanel.tsx index 218900996f7..9510778cf7d 100644 --- a/wework/src/components/chat/FileChangesReviewPanel.tsx +++ b/wework/src/components/chat/FileChangesReviewPanel.tsx @@ -8,8 +8,6 @@ import { GitBranch, GitCompareArrows, ListCollapse, - PanelRightClose, - PanelRightOpen, RefreshCw, Search, WrapText, @@ -178,11 +176,6 @@ export function FileChangesReviewPanel({ focusFilePath?: string index: number }>({ diff: '', index: 0 }) - const [fileTreeVisibility, setFileTreeVisibility] = useState({ - diff, - defaultFileTreeVisible, - visible: defaultFileTreeVisible, - }) const [wrapLines, setWrapLines] = useState(false) const [hunksCollapsed, setHunksCollapsed] = useState(false) const [collapsedSections, setCollapsedSections] = useState>(new Set()) @@ -203,11 +196,7 @@ export function FileChangesReviewPanel({ const diffStats = useMemo(() => getSectionsDiffStats(sections), [sections]) const isLargeDiff = useMemo(() => isLargeReviewDiff(sections), [sections]) const displayedSections = isLargeDiff && selectedSection ? [selectedSection] : sections - const fileTreeVisible = - fileTreeVisibility.diff === diff && - fileTreeVisibility.defaultFileTreeVisible === defaultFileTreeVisible - ? fileTreeVisibility.visible - : defaultFileTreeVisible + const fileTreeVisible = defaultFileTreeVisible const selectSection = (index: number) => { setSelection({ diff, focusFilePath, index }) @@ -250,18 +239,10 @@ export function FileChangesReviewPanel({ viewOptions={viewOptions} additions={diffStats.additions} deletions={diffStats.deletions} - fileTreeVisible={fileTreeVisible} wrapLines={wrapLines} hunksCollapsed={hunksCollapsed} canRefresh={Boolean(onRefresh)} onRefresh={onRefresh} - onToggleFileTree={() => - setFileTreeVisibility({ - diff, - defaultFileTreeVisible, - visible: !fileTreeVisible, - }) - } onToggleWrap={() => setWrapLines(value => !value)} onToggleHunks={() => setHunksCollapsed(value => !value)} onCopyGitApplyCommand={copyGitApplyCommand} @@ -315,12 +296,10 @@ function ReviewToolbar({ viewOptions, additions, deletions, - fileTreeVisible, wrapLines, hunksCollapsed, canRefresh, onRefresh, - onToggleFileTree, onToggleWrap, onToggleHunks, onCopyGitApplyCommand, @@ -331,12 +310,10 @@ function ReviewToolbar({ viewOptions?: FileChangesReviewViewOption[] additions: number deletions: number - fileTreeVisible: boolean wrapLines: boolean hunksCollapsed: boolean canRefresh: boolean onRefresh?: () => void - onToggleFileTree: () => void onToggleWrap: () => void onToggleHunks: () => void onCopyGitApplyCommand: () => void @@ -438,17 +415,6 @@ function ReviewToolbar({ onClick={onCopyGitApplyCommand} icon={Copy} /> - {hasBranchContext ? ( diff --git a/wework/src/components/chat/MessageList.test.tsx b/wework/src/components/chat/MessageList.test.tsx index 2dee0354c71..810f0f3ec7f 100644 --- a/wework/src/components/chat/MessageList.test.tsx +++ b/wework/src/components/chat/MessageList.test.tsx @@ -157,6 +157,121 @@ describe('MessageList', () => { expect(screen.getByTestId('assistant-stopped-notice')).toHaveTextContent('你在 9m 18s 后停止了') }) + test('renders Codex proposed plan content from tagged plan mode output', () => { + render( + \n1. Inspect the desktop chat width.\n2. Match the reference task.\n', + status: 'streaming', + createdAt: '2026-06-11T10:00:00Z', + }, + ]} + /> + ) + + expect(screen.getByText('Inspect the desktop chat width.')).toBeInTheDocument() + expect(screen.getByText('Match the reference task.')).toBeInTheDocument() + expect(screen.queryByText(/proposed_plan/)).not.toBeInTheDocument() + }) + + test('renders answered request user input as an assistant question summary', () => { + render( + + ) + + expect(screen.getByTestId('request-user-input-summary')).toHaveTextContent('已询问 1 个问题') + expect(screen.getByText('这个计划想落在哪个方向?')).toBeInTheDocument() + expect(screen.getByText('随便,我就想看看样式')).toBeInTheDocument() + expect(screen.queryByTestId('message-user')).not.toBeInTheDocument() + }) + + test('renders plan output as a card and expands it into a reading panel', async () => { + const user = userEvent.setup() + const writeText = vi.fn().mockResolvedValue(undefined) + stubClipboardWriteText(writeText) + const onOpenAssistantPlan = vi.fn() + render( + + ) + + expect(screen.getByTestId('assistant-plan-card')).toHaveTextContent('计划') + expect(screen.queryByText('套餐')).not.toBeInTheDocument() + expect(screen.queryByTestId('assistant-plan-like-button')).not.toBeInTheDocument() + expect(screen.queryByTestId('assistant-plan-dislike-button')).not.toBeInTheDocument() + await user.click(screen.getByTestId('assistant-plan-copy-button')) + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining('Wegent 代码质量与前端一致性巡检计划') + ) + expect(await screen.findByTestId('assistant-plan-copy-success')).toHaveTextContent('已复制') + await user.click(screen.getByTestId('assistant-plan-expand-button')) + expect(onOpenAssistantPlan).toHaveBeenCalledWith( + expect.stringContaining('Wegent 代码质量与前端一致性巡检计划') + ) + expect(screen.queryByTestId('assistant-plan-reading-panel')).not.toBeInTheDocument() + }) + test('shows stopped duration from completed time and keeps partial assistant content', () => { render( void onRequestUserInputSubmit?: (response: RequestUserInputResponse) => void onRequestUserInputIgnore?: (payload: RequestUserInputPayload) => void + onOpenAssistantPlan?: (content: string) => void hideRequestUserInputBlocks?: boolean hiddenRequestUserInputIds?: ReadonlySet renderGapAfterMessage?: ( @@ -72,6 +76,8 @@ const USER_MESSAGE_COLLAPSE_CHARACTERS = 600 const CODEX_FILE_MENTIONS_HEADER_PATTERN = /^\s*# Files mentioned by the user:\s*/i const CODEX_REQUEST_MARKER_PATTERN = /^## My request for Codex:\s*$/im const CODEX_FILE_MENTION_LINE_PATTERN = /^##\s+(.+?):\s+(.+)$/gm +const CODEX_PLAN_TAG_PATTERN = /<\/?\s*proposed_plan\s*>/gi +const CODEX_PLAN_SECTION_PATTERN = /^##\s+(Summary|Key Changes|Test Plan|Assumptions)\s*$/im const LOCAL_IMAGE_EXTENSION_PATTERN = /\.(?:apng|avif|gif|jpe?g|png|webp|bmp|svg)$/i const CODEX_TRANSIENT_CLIPBOARD_IMAGE_PATTERN = /\/(?:var\/folders|private\/var\/folders)\/.*\/codex-clipboard-[^/]+\.(?:apng|avif|gif|jpe?g|png|webp|bmp|svg)$/i @@ -102,6 +108,7 @@ export const MessageList = memo(function MessageList({ onOpenWorkspaceFile, onRequestUserInputSubmit, onRequestUserInputIgnore, + onOpenAssistantPlan, hideRequestUserInputBlocks, hiddenRequestUserInputIds, renderGapAfterMessage, @@ -150,6 +157,7 @@ export const MessageList = memo(function MessageList({ onOpenWorkspaceFile={onOpenWorkspaceFile} onRequestUserInputSubmit={onRequestUserInputSubmit} onRequestUserInputIgnore={onRequestUserInputIgnore} + onOpenAssistantPlan={onOpenAssistantPlan} hideRequestUserInputBlocks={hideRequestUserInputBlocks} hiddenRequestUserInputIds={hiddenRequestUserInputIds} /> @@ -542,6 +550,137 @@ function UserMessage({ ) } +function AssistantPlanCard({ + content, + onOpenPlan, +}: { + content: string + onOpenPlan?: (content: string) => void +}) { + const { t } = useTranslation('chat') + + const handleDownload = () => { + const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = 'plan.md' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + return ( +
+
+
+
+ onOpenPlan?.(content)} + /> +
+
+
+ +
+
+
+
+ ) +} + +function PlanCardActions({ + content, + onDownload, + onExpand, +}: { + content: string + onDownload: () => void + onExpand: () => void +}) { + const { t } = useTranslation('chat') + const [copied, setCopied] = useState(false) + useEffect(() => { + if (!copied) return + const timer = window.setTimeout(() => setCopied(false), 1400) + return () => window.clearTimeout(timer) + }, [copied]) + + const handleCopy = () => { + void copyText(content).then(() => setCopied(true)) + } + + const actions = [ + { + key: 'download', + label: t('plan_card.download'), + icon: