From 15409b3b0532e07bc11ec3a8d02fca809403195d Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 16 Jul 2026 11:01:05 +0800 Subject: [PATCH 01/15] feat(codex): upgrade codex-acp to 1.1.4 and handle new live signals Pin codex-acp to 1.1.4 (codex 0.144.4) and adapt to the three new runtime signals it emits so the upgrade is regression-free: - Retry: surface `_meta.codex.error` (willRetry only) as a transient TurnRetrying event, reusing the existing retry banner. - Context compaction: render `_meta.contextCompaction` tool calls as a dedicated status card instead of a generic tool shell. - Sub-agent activity: suppress `_meta.codex.subagent` tool calls at the emit point to preserve current live and reload behavior. --- src-tauri/src/acp/connection.rs | 162 ++++++++++++++++++ src-tauri/src/acp/registry.rs | 21 ++- src-tauri/src/acp/session_state.rs | 3 + src-tauri/src/acp/types.rs | 15 ++ .../message/content-parts-renderer.tsx | 11 ++ .../message/context-compaction-card.tsx | 42 +++++ src/contexts/acp-connections-context.tsx | 20 +++ src/i18n/messages/ar.json | 4 + src/i18n/messages/de.json | 4 + src/i18n/messages/en.json | 4 + src/i18n/messages/es.json | 4 + src/i18n/messages/fr.json | 4 + src/i18n/messages/ja.json | 4 + src/i18n/messages/ko.json | 4 + src/i18n/messages/pt.json | 4 + src/i18n/messages/zh-CN.json | 4 + src/i18n/messages/zh-TW.json | 4 + src/lib/types.ts | 10 ++ 18 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 src/components/message/context-compaction-card.tsx diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 35517ec92..2a021325a 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -5238,6 +5238,58 @@ fn codebuddy_meta_marks_subagent( .is_some_and(|s| !s.is_empty()) } +/// True when a Codex live `tool_call` is a `subAgentActivity` mapping +/// (codex-acp #304, v1.1.3+). codex-acp maps codex `subAgentActivity` +/// notifications onto ACP `tool_call(kind:other)` carrying +/// `_meta.codex.subagent = {threadId, path, activity}`. codeg already renders +/// codex collaboration from the `collabAgentToolCall` path (spawnAgent/wait/ +/// closeAgent — see `collab-tool.ts`) and reconstructs the full nested +/// transcript on history reload from `agent-.jsonl` (see +/// `parsers/codex.rs`), so this new live signal is redundant with what codeg +/// already shows. Suppressed at the emit point (keeping live and DB-reload +/// consistent — a suppressed event is never persisted) to preserve the current +/// live behavior. Gated on Codex. +fn is_codex_subagent_activity( + agent_type: AgentType, + meta: Option<&serde_json::Map>, +) -> bool { + if agent_type != AgentType::Codex { + return false; + } + meta.and_then(|m| m.get("codex")) + .and_then(|codex| codex.get("subagent")) + .is_some() +} + +/// Extract a retryable-turn-error indicator from a Codex `session_info_update`'s +/// `_meta` (codex-acp #289, v1.1.3+). codex ships a transient, auto-retried +/// error as `_meta.codex.error = {message, codexErrorInfo, additionalDetails, +/// turnId, willRetry}` and keeps the prompt alive; it emits this only when +/// `willRetry == true`. Returns `(message, http_status)` when a non-empty +/// message is present. `codexErrorInfo` may be a bare string enum, an object +/// variant carrying an inner `httpStatusCode`, or absent — only the object form +/// yields a status. Defensively refuses a `willRetry == false` payload so a +/// terminal error can never render as "retrying". +fn codex_retry_indicator( + meta: Option<&serde_json::Map>, +) -> Option<(String, Option)> { + let err = meta?.get("codex")?.get("error")?; + if err.get("willRetry").and_then(|v| v.as_bool()) == Some(false) { + return None; + } + let message = err.get("message").and_then(|v| v.as_str())?.trim(); + if message.is_empty() { + return None; + } + let http_status = err + .get("codexErrorInfo") + .and_then(|info| info.as_object()) + .and_then(|obj| obj.values().next()) + .and_then(|inner| inner.get("httpStatusCode")) + .and_then(|v| v.as_i64()); + Some((message.to_string(), http_status)) +} + /// True when a CodeBuddy sub-agent tool call's `_meta` marks it as a BACKGROUND /// sub-agent (`codebuddy.ai/isBackground == true`). A background sub-agent runs /// concurrently with the main agent, so the suppression-window invariant (parent @@ -5592,6 +5644,13 @@ async fn emit_conversation_update( // Non-text thought chunks are currently ignored. } SessionUpdate::ToolCall(tc) => { + // codex-acp #304 (v1.1.3+) surfaces codex `subAgentActivity` as a + // live `tool_call`; suppress it — it is redundant with the collab + // capsule and the history reconstruction (see + // `is_codex_subagent_activity`). + if is_codex_subagent_activity(agent_type, tc.meta.as_ref()) { + return; + } let tool_call_id = tc.tool_call_id.to_string(); // CodeBuddy double-wraps a deferred MCP result as a `{type,text}` // content part; peel it (in both the content and raw_output channels) @@ -5715,6 +5774,12 @@ async fn emit_conversation_update( .await; } SessionUpdate::ToolCallUpdate(tcu) => { + // Symmetric with the `ToolCall` arm: a follow-up update for a codex + // `subAgentActivity` still carries `_meta.codex.subagent`, so drop + // it too (see `is_codex_subagent_activity`). + if is_codex_subagent_activity(agent_type, tcu.meta.as_ref()) { + return; + } let tool_call_id = tcu.tool_call_id.to_string(); // Peel CodeBuddy's `{type,text}` deferred-MCP wrapper here too — the // result often arrives on an update (see raw_output below). @@ -5948,6 +6013,21 @@ async fn emit_conversation_update( .await; } } + // codex-acp #289 (v1.1.3+): a retryable turn error rides under + // `_meta.codex.error` (only when `willRetry == true`) and the turn + // stays alive. Surface a transient retry indicator (the frontend + // reuses the Claude API-retry banner); it is NOT a turn failure. + if let Some((message, error_status)) = codex_retry_indicator(info.meta.as_ref()) { + emit_with_state( + state, + emitter, + AcpEvent::TurnRetrying { + message, + error_status, + }, + ) + .await; + } } other => { // Log unhandled update types for debugging @@ -5969,6 +6049,88 @@ mod tests { ToolCallContent::Diff(d) } + /// Clone a `_meta` map out of a JSON object literal, mirroring how codex-acp + /// ships tool-call / session-info `_meta`. + fn meta_map(v: serde_json::Value) -> serde_json::Map { + v.as_object().expect("object").clone() + } + + #[test] + fn codex_subagent_activity_detected_only_for_codex_subagent_meta() { + // codex-acp #304: `_meta.codex.subagent` marks the suppressed activity. + let sub = meta_map(serde_json::json!({ + "codex": { "subagent": { "threadId": "t1", "path": "/root/x", "activity": "started" } } + })); + assert!(is_codex_subagent_activity(AgentType::Codex, Some(&sub))); + // Only Codex is gated — the same meta never suppresses another agent. + assert!(!is_codex_subagent_activity(AgentType::ClaudeCode, Some(&sub))); + // Absent meta and sibling codex meta keys (goal / collaboration) are not + // subagent activity and must render normally. + assert!(!is_codex_subagent_activity(AgentType::Codex, None)); + let goal = meta_map(serde_json::json!({ "codex": { "goal": { "objective": "x" } } })); + assert!(!is_codex_subagent_activity(AgentType::Codex, Some(&goal))); + let collab = meta_map(serde_json::json!({ + "codex": { "collaboration": { "tool": "spawnAgent" } } + })); + assert!(!is_codex_subagent_activity(AgentType::Codex, Some(&collab))); + } + + #[test] + fn codex_retry_indicator_extracts_message_and_object_http_status() { + // codex-acp #289: object-variant `codexErrorInfo` carries an inner + // `httpStatusCode`; the message + status are surfaced. + let m = meta_map(serde_json::json!({ + "codex": { "error": { + "message": "Reconnecting after provider returned 401", + "codexErrorInfo": { "responseStreamDisconnected": { "httpStatusCode": 401 } }, + "additionalDetails": "HTTP status 401", + "turnId": "turn-id", + "willRetry": true + } } + })); + assert_eq!( + codex_retry_indicator(Some(&m)), + Some(( + "Reconnecting after provider returned 401".to_string(), + Some(401) + )) + ); + } + + #[test] + fn codex_retry_indicator_string_enum_yields_no_status() { + // A bare string `codexErrorInfo` yields the message but no http status. + let m = meta_map(serde_json::json!({ + "codex": { "error": { + "message": "Server overloaded", + "codexErrorInfo": "serverOverloaded", + "willRetry": true + } } + })); + assert_eq!( + codex_retry_indicator(Some(&m)), + Some(("Server overloaded".to_string(), None)) + ); + } + + #[test] + fn codex_retry_indicator_refuses_terminal_empty_and_absent() { + // `willRetry: false` (e.g. 401 auth) must never render a retry banner. + let terminal = meta_map(serde_json::json!({ + "codex": { "error": { "message": "unauthorized", "willRetry": false } } + })); + assert_eq!(codex_retry_indicator(Some(&terminal)), None); + // Blank/whitespace message → nothing to show. + let blank = meta_map(serde_json::json!({ + "codex": { "error": { "message": " ", "willRetry": true } } + })); + assert_eq!(codex_retry_indicator(Some(&blank)), None); + // No `codex.error` at all (a goal-only or empty session_info_update). + let goal_only = meta_map(serde_json::json!({ "codex": { "goal": null } })); + assert_eq!(codex_retry_indicator(Some(&goal_only)), None); + assert_eq!(codex_retry_indicator(None), None); + } + #[test] fn classify_load_failure_resource_not_found_maps_to_code() { assert_eq!( diff --git a/src-tauri/src/acp/registry.rs b/src-tauri/src/acp/registry.rs index c21a73add..c3fa77b7a 100644 --- a/src-tauri/src/acp/registry.rs +++ b/src-tauri/src/acp/registry.rs @@ -177,16 +177,23 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta { description: "ACP adapter for OpenAI's coding assistant", // codex-acp moved from zed-industries (Rust binary) to the // agentclientprotocol org (TypeScript rewrite, npx-distributed). - // 1.1.2 depends on `@openai/codex` ^0.144.0 and drives `codex + // 1.1.4 depends on `@openai/codex` ^0.144.4 and drives `codex // app-server`; since 1.0.1 it also resolves the resumed // `model_provider` from `~/.codex/config.toml` (#224), so codeg no // longer injects `MODEL_PROVIDER` to keep resumed sessions on the - // custom provider. 1.1.0 (#263) also reports `/goal` transitions as a + // custom provider. 1.1.0 (#263) reports `/goal` transitions as a // structured `session_info_update` (`_meta.codex.goal`) rather than - // live agent text — see `crate::acp::codex_goal`. + // live agent text — see `crate::acp::codex_goal`. 1.1.3+ adds three + // new live signals codeg handles in `connection::emit_conversation_update`: + // `subAgentActivity` tool calls (#304, suppressed via + // `is_codex_subagent_activity` — redundant with the collab capsule), + // retryable turn errors (#289, `_meta.codex.error` → a transient + // retry banner via `codex_retry_indicator`), and the + // context-compaction lifecycle (#288, `_meta.contextCompaction` tool + // call → a dedicated frontend card). distribution: AgentDistribution::Npx { - version: "1.1.2", - package: "@agentclientprotocol/codex-acp@1.1.2", + version: "1.1.4", + package: "@agentclientprotocol/codex-acp@1.1.4", cmd: "codex-acp", args: &[], env: &[], @@ -518,8 +525,8 @@ mod tests { ); assert_npx_version( AgentType::Codex, - "1.1.2", - "@agentclientprotocol/codex-acp@1.1.2", + "1.1.4", + "@agentclientprotocol/codex-acp@1.1.4", Some("20.0.0"), ); assert_npx_version(AgentType::Pi, "0.0.31", "pi-acp@0.0.31", Some("22.0.0")); diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index bee6aea0a..0891e570d 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -916,9 +916,12 @@ impl SessionState { } AcpEvent::ClaudeSdkMessage { .. } | AcpEvent::SessionLoadFailed { .. } + | AcpEvent::TurnRetrying { .. } | AcpEvent::UserPromptSent { .. } => { // 这些事件不直接修改 SessionState 的可见字段。 // UserPromptSent 是纯通知事件,仅供 chat-channel 推送消费。 + // TurnRetrying 与 Claude 的 api_retry 一样是前端瞬态提示(重试横幅), + // 不进快照——回合边界会清除它。 } } self.last_activity_at = Utc::now(); diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index ce320f7e9..5e41e5508 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -200,6 +200,21 @@ pub enum AcpEvent { #[serde(skip, default)] terminal: bool, }, + /// A retryable turn error that keeps the turn alive (codex-acp #289, + /// v1.1.3+). Codex reports a transient, auto-retried error as + /// `session_info_update._meta.codex.error` (only when `willRetry == true`) + /// and continues the turn rather than terminating it. Surfaced as a + /// transient "retrying" indicator on the active turn — it is NOT a turn + /// failure and must not be rendered as one. The frontend reuses the Claude + /// API-retry banner and clears it at the next turn boundary. + TurnRetrying { + /// Human-readable transient error (`_meta.codex.error.message`). + message: String, + /// HTTP status pulled from a `codexErrorInfo` object variant + /// (e.g. `responseStreamDisconnected.httpStatusCode`), when present. + #[serde(skip_serializing_if = "Option::is_none")] + error_status: Option, + }, /// `session/load` failed in a non-recoverable way (e.g. the agent has no /// record of this `session_id`). Emitted instead of silently falling back /// to `session/new`, so the frontend can surface the failure with reload diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 4276394c2..e33089866 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -42,6 +42,10 @@ import { import { AgentToolCallPart } from "./agent-tool-call" import { AskQuestionResultCard } from "./ask-question-result-card" import { CollabAgentCard } from "./collab-agent-card" +import { + ContextCompactionCard, + isContextCompactionMeta, +} from "./context-compaction-card" import { FeedbackCheckResultCard } from "./feedback-check-result-card" import { COLLAB_AGENT_TOOL_NAME } from "@/lib/collab-tool" import { DelegatedSubThread } from "./delegated-sub-thread" @@ -2304,6 +2308,13 @@ const ToolCallPart = memo(function ToolCallPart({ toolNameLower === "exitplanmode" || isFileTool) && !part.errorText + // codex-acp #288: the context-compaction lifecycle is a `tool_call` tagged + // with `_meta.contextCompaction` (not addressed by tool name) → a subtle + // status card instead of the generic tool shell. + if (isContextCompactionMeta(part.meta)) { + return + } + // Agent/subagent tools get a dedicated container rendering if (toolNameLower === "agent") { return ( diff --git a/src/components/message/context-compaction-card.tsx b/src/components/message/context-compaction-card.tsx new file mode 100644 index 000000000..d3e968398 --- /dev/null +++ b/src/components/message/context-compaction-card.tsx @@ -0,0 +1,42 @@ +"use client" + +/** + * codex-acp #288 (v1.1.3+): the context-compaction lifecycle arrives as an ACP + * `tool_call` (kind "other") tagged with `_meta.contextCompaction = true` — NOT + * under the `codex` namespace, unlike codex's other `_meta` extensions. The pair + * is `Context compacting` (in_progress) → `Context compacted` (completed) sharing + * one `toolCallId`. Rendered as a subtle status row (not the generic tool shell) + * so a routine housekeeping event doesn't read as a real tool call. Recognition + * is by `_meta`, so it works for both the live stream and DB/snapshot reloads. + */ + +import { useTranslations } from "next-intl" +import { Archive } from "lucide-react" + +import type { ToolCallState } from "@/lib/adapters/ai-elements-adapter" + +/** True when a tool call's `_meta` marks it as the codex context-compaction item. */ +export function isContextCompactionMeta(meta: unknown): boolean { + return ( + !!meta && + typeof meta === "object" && + (meta as Record).contextCompaction === true + ) +} + +interface Props { + state?: ToolCallState +} + +export function ContextCompactionCard({ state }: Props) { + const t = useTranslations("Folder.chat.contextCompaction") + const isRunning = state === "input-streaming" || state === "input-available" + return ( +
+ + + {isRunning ? t("compacting") : t("compacted")} + +
+ ) +} diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index a87904fff..088eb6b65 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -3202,6 +3202,26 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { entries: e.entries, }) break + case "turn_retrying": { + // codex-acp #289: a retryable turn error keeps the turn alive (codex + // auto-retries). Reuse the Claude API-retry banner — codex doesn't + // report attempt/limit/delay, so those stay null; the banner clears at + // the next turn boundary like the Claude path. + const retryConn = storeRef.current.connections.get(contextKey) + dispatch({ + type: "CLAUDE_API_RETRY", + contextKey, + retry: { + sessionId: retryConn?.sessionId ?? "", + attempt: null, + maxRetries: null, + error: e.message, + errorStatus: e.error_status ?? null, + retryDelayMs: null, + }, + }) + break + } case "turn_complete": { flushStreamingQueue() flushPendingToolCallUpdates() diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 18044c1da..b88f45744 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2623,6 +2623,10 @@ "opClose": "إغلاق الوكيل الفرعي", "opResume": "استئناف الوكيل الفرعي" }, + "contextCompaction": { + "compacting": "جارٍ ضغط السياق…", + "compacted": "تم ضغط السياق" + }, "backgroundTasks": { "running": "{count, plural, one {مهمة خلفية واحدة قيد التشغيل} two {مهمتا خلفية قيد التشغيل} few {# مهام خلفية قيد التشغيل} other {# مهمة خلفية قيد التشغيل}}", "settling": "جارٍ مزامنة نتائج الخلفية…", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 953805e00..ec91d09f2 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2623,6 +2623,10 @@ "opClose": "Sub-Agent wird geschlossen", "opResume": "Sub-Agent wird fortgesetzt" }, + "contextCompaction": { + "compacting": "Kontext wird komprimiert…", + "compacted": "Kontext komprimiert" + }, "backgroundTasks": { "running": "{count, plural, one {# Hintergrundaufgabe läuft} other {# Hintergrundaufgaben laufen}}", "settling": "Hintergrundergebnisse werden synchronisiert…", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 16cef83ec..73c6f0567 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2623,6 +2623,10 @@ "opClose": "Closing sub-agent", "opResume": "Resuming sub-agent" }, + "contextCompaction": { + "compacting": "Compacting context…", + "compacted": "Context compacted" + }, "backgroundTasks": { "running": "{count, plural, one {# background task running} other {# background tasks running}}", "settling": "Syncing background results…", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 1e946a224..d7efdb18e 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2623,6 +2623,10 @@ "opClose": "Cerrando subagente", "opResume": "Reanudando subagente" }, + "contextCompaction": { + "compacting": "Compactando el contexto…", + "compacted": "Contexto compactado" + }, "backgroundTasks": { "running": "{count, plural, one {# tarea en segundo plano en ejecución} other {# tareas en segundo plano en ejecución}}", "settling": "Sincronizando resultados en segundo plano…", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 8747ac281..b10a0141b 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2623,6 +2623,10 @@ "opClose": "Fermeture du sous-agent", "opResume": "Reprise du sous-agent" }, + "contextCompaction": { + "compacting": "Compactage du contexte…", + "compacted": "Contexte compacté" + }, "backgroundTasks": { "running": "{count, plural, one {# tâche en arrière-plan en cours} other {# tâches en arrière-plan en cours}}", "settling": "Synchronisation des résultats d'arrière-plan…", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index e6e9a0d0e..d2ced7f9b 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2623,6 +2623,10 @@ "opClose": "サブエージェントを終了中", "opResume": "サブエージェントを再開中" }, + "contextCompaction": { + "compacting": "コンテキストを圧縮しています…", + "compacted": "コンテキストを圧縮しました" + }, "backgroundTasks": { "running": "{count} 件のバックグラウンドタスクを実行中", "settling": "バックグラウンド結果を同期中…", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 70e6c3844..d23c2d9e7 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2623,6 +2623,10 @@ "opClose": "서브 에이전트 종료 중", "opResume": "서브 에이전트 재개 중" }, + "contextCompaction": { + "compacting": "컨텍스트 압축 중…", + "compacted": "컨텍스트 압축 완료" + }, "backgroundTasks": { "running": "백그라운드 작업 {count}개 실행 중", "settling": "백그라운드 결과 동기화 중…", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 15941ece7..661896a3b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2623,6 +2623,10 @@ "opClose": "Encerrando subagente", "opResume": "Retomando subagente" }, + "contextCompaction": { + "compacting": "Compactando o contexto…", + "compacted": "Contexto compactado" + }, "backgroundTasks": { "running": "{count, plural, one {# tarefa em segundo plano em execução} other {# tarefas em segundo plano em execução}}", "settling": "Sincronizando resultados em segundo plano…", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 656c04865..01c93960d 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2623,6 +2623,10 @@ "opClose": "结束子智能体", "opResume": "恢复子智能体" }, + "contextCompaction": { + "compacting": "正在压缩上下文…", + "compacted": "上下文已压缩" + }, "backgroundTasks": { "running": "{count} 个后台任务运行中", "settling": "正在同步后台结果…", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 34a7d3d2b..aaf9aa1fa 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2623,6 +2623,10 @@ "opClose": "結束子智能體", "opResume": "恢復子智能體" }, + "contextCompaction": { + "compacting": "正在壓縮上下文…", + "compacted": "上下文已壓縮" + }, "backgroundTasks": { "running": "{count} 個背景任務執行中", "settling": "正在同步背景結果…", diff --git a/src/lib/types.ts b/src/lib/types.ts index 058615442..45f4ee862 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1204,6 +1204,16 @@ export type AcpEvent = /** Stable backend error identifier for localization (e.g. "initialize_timeout"). */ code: string | null } + | { + // codex-acp #289: a retryable turn error that keeps the turn alive (codex + // auto-retries). NOT a turn failure — rendered as a transient retry + // indicator that reuses the Claude API-retry banner and clears at the + // next turn boundary. `error_status` is the HTTP status when codex's + // `codexErrorInfo` carried one. + type: "turn_retrying" + message: string + error_status?: number + } | { type: "session_load_failed" session_id: string From de8ff77e4bd3d902472f2f729790f7553dfcf92f Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 16 Jul 2026 11:35:44 +0800 Subject: [PATCH 02/15] feat(codex): show sub-agent model and reasoning effort on live collab card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the sub-agent's model and reasoningEffort (codex-acp #304, top-level in the collab tool-call rawInput) on the live execution capsule — info that was previously visible only after a history reload. Gated to the spawn capsule so the live and reconstructed wait capsules stay in parity. --- src-tauri/src/parsers/codex.rs | 11 +++-- .../message/collab-agent-card.test.tsx | 39 +++++++++++++++++ src/components/message/collab-agent-card.tsx | 32 +++++++++++--- src/lib/collab-collapse.test.ts | 37 ++++++++++++++++ src/lib/collab-tool.test.ts | 42 +++++++++++++++++-- src/lib/collab-tool.ts | 28 ++++++++++--- 6 files changed, 171 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index 49abbdb6e..4d030b22e 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -894,10 +894,13 @@ impl CodexParser { // - close_agent → folded into the execution capsule (no own capsule); // its result is only a fallback for agents never waited on. // codex-acp 1.0.1 (#223) maps `collabAgentToolCall` onto live ACP - // `tool_call`s and still drops `subAgentActivity`, so the nested - // `agent-.jsonl` stats only exist on history reload. Live and - // reconstructed capsules never double-render (live during streaming, - // this on reload). + // `tool_call`s; 1.1.3+ (#304) additionally emits `subAgentActivity` as a + // SEPARATE live `tool_call` (`_meta.codex.subagent`), but codeg + // suppresses it (redundant with the collab capsule — see + // `is_codex_subagent_activity`) and it carries no transcript content, so + // the nested `agent-.jsonl` stats still only exist on history reload. + // Live and reconstructed capsules never double-render (live during + // streaming, this on reload). let mut spawn_agent_call_ids: HashSet = HashSet::new(); let mut agent_id_to_spawn_call_id: HashMap = HashMap::new(); // Result text used to FILL the execution capsule only as a fallback for diff --git a/src/components/message/collab-agent-card.test.tsx b/src/components/message/collab-agent-card.test.tsx index 36d09f8a6..7bebdeaaf 100644 --- a/src/components/message/collab-agent-card.test.tsx +++ b/src/components/message/collab-agent-card.test.tsx @@ -26,6 +26,8 @@ function collabInput(o: { op?: string agents?: Record status?: string + model?: string + reasoningEffort?: string }) { return JSON.stringify({ prompt: o.prompt ?? "", @@ -33,6 +35,8 @@ function collabInput(o: { receiverThreadIds: Object.keys(o.agents ?? {}), agentsStates: o.agents ?? {}, status: o.status ?? "inProgress", + ...(o.model ? { model: o.model } : {}), + ...(o.reasoningEffort ? { reasoningEffort: o.reasoningEffort } : {}), ...(o.op ? { [COLLAB_OP_KEY]: o.op } : {}), }) } @@ -62,6 +66,41 @@ describe("CollabAgentCard", () => { expect(screen.queryByText("Running")).not.toBeInTheDocument() }) + it("shows the sub-agent model + reasoning effort on a spawn (codex-acp #304)", () => { + renderCard({ + input: collabInput({ + prompt: "Build the app", + op: "spawnAgent", + model: "gpt-5-codex", + reasoningEffort: "high", + agents: { "11110000-aaaa": { status: "running", message: null } }, + }), + state: "input-available", + }) + // Collapsed until expanded; run-meta lives in the body. + fireEvent.click(screen.getByRole("button")) + expect(screen.getByText("gpt-5-codex")).toBeInTheDocument() + expect(screen.getByText("high")).toBeInTheDocument() + }) + + it("does NOT show model/effort on a wait capsule (live/reload parity)", () => { + renderCard({ + input: collabInput({ + op: "wait", + status: "completed", + model: "gpt-5-codex", + reasoningEffort: "high", + agents: { "22220000-bbbb": { status: "completed", message: "done" } }, + }), + state: "output-available", + }) + fireEvent.click(screen.getByRole("button")) + // The reconstructed wait capsule carries no model/effort, so the live wait + // capsule must not render them either. + expect(screen.queryByText("gpt-5-codex")).not.toBeInTheDocument() + expect(screen.queryByText("high")).not.toBeInTheDocument() + }) + it("gives a wait op an op-aware title and badges the returned agent UUID", () => { renderCard({ input: collabInput({ diff --git a/src/components/message/collab-agent-card.tsx b/src/components/message/collab-agent-card.tsx index 59414c640..35545a03c 100644 --- a/src/components/message/collab-agent-card.tsx +++ b/src/components/message/collab-agent-card.tsx @@ -11,14 +11,16 @@ * running, ✓/⚠ suffix, auto-open on error). The sub-agent UUID(s) are shown in * the pill via `idBadge` so execution and wait capsules read uniformly. * - * The full sub-agent transcript is NOT available live — codex-acp drops - * `subAgentActivity` and emits no `rawOutput` for collab calls; the richer - * reconstructed capsule only appears on history reload. Detection, op-merge and - * status classification live in `@/lib/collab-tool`. + * The full sub-agent transcript is NOT available live — codex-acp's separate + * `subAgentActivity` signal (codex-acp #304) is suppressed as redundant and + * carries no transcript content anyway; the richer reconstructed capsule only + * appears on history reload. The sub-agent's `model` / `reasoningEffort` (also + * #304) ARE available live and shown on the execution (spawn) capsule. Detection, + * op-merge and status classification live in `@/lib/collab-tool`. */ import { useMemo } from "react" -import { AlertTriangle, Check } from "lucide-react" +import { AlertTriangle, Check, Cpu } from "lucide-react" import { useTranslations } from "next-intl" import { @@ -48,6 +50,15 @@ export function CollabAgentCard({ input, errorText, state }: Props) { const agents = info?.agents ?? [] const opStatus = info?.status ?? null const op = info?.op ?? null + const model = info?.model ?? null + const reasoningEffort = info?.reasoningEffort ?? null + // model/effort describe HOW the sub-agent runs — meaningful on the execution + // (spawn) capsule, the one collab card that survives live collapse as the + // sub-agent's definition. Gating to spawn also preserves live/reload parity: + // the reconstructed `wait` capsule (Rust `build_collab_wait_input`) carries no + // model/effort, so a live `wait` card must not show them either. + const showRunMeta = + classifyCollabOp(op) === "spawn" && (!!model || !!reasoningEffort) const hasErrorAgent = agents.some((a) => isErrorCollabStatusKind(classifyCollabStatus(a.status)) @@ -108,6 +119,17 @@ export function CollabAgentCard({ input, errorText, state }: Props) { idBadge={idBadge} statusLabel={title} > + {/* Sub-agent model + reasoning effort (codex-acp #304), execution capsule + only. Live-side enrichment: previously visible only after reload. */} + {showRunMeta && ( +
+ + {model && {model}} + {model && reasoningEffort && ·} + {reasoningEffort && {reasoningEffort}} +
+ )} + {/* Prompt — the execution capsule's task (spawn only; wait has none). */} {prompt && (
diff --git a/src/lib/collab-collapse.test.ts b/src/lib/collab-collapse.test.ts index 95aa08822..f238c4170 100644 --- a/src/lib/collab-collapse.test.ts +++ b/src/lib/collab-collapse.test.ts @@ -11,6 +11,8 @@ function collabBlock(opts: { /** ACP tool-call status. */ status?: string prompt?: string + model?: string + reasoningEffort?: string agents: Record }): LiveContentBlock { const agentsStates: Record = {} @@ -30,6 +32,10 @@ function collabBlock(opts: { senderThreadId: "main", receiverThreadIds: Object.keys(opts.agents), agentsStates, + ...(opts.model ? { model: opts.model } : {}), + ...(opts.reasoningEffort + ? { reasoningEffort: opts.reasoningEffort } + : {}), status: "inProgress", }), raw_output_chunks: [], @@ -214,6 +220,37 @@ describe("collapseLiveCollabBlocks", () => { expect(agent?.message).toBe("boom") }) + it("preserves top-level model/reasoningEffort on the execution capsule (codex-acp #304)", () => { + const input: LiveContentBlock[] = [ + collabBlock({ + id: "spawn", + title: "spawnAgent", + prompt: "go", + model: "gpt-5-codex", + reasoningEffort: "high", + agents: { A: { status: "pendingInit" } }, + }), + collabBlock({ + id: "wait", + title: "wait", + agents: { A: { status: "completed", message: "R" } }, + }), + ] + const out = collapseLiveCollabBlocks(input) + const spawn = out.find( + (b) => + b.type === "tool_call" && classifyCollabOp(b.info.title) === "spawn" + ) + // The rewrite rebuilds agentsStates but must carry top-level fields through + // (it spreads `...parsed`), so model/effort survive onto the execution card. + const info = + spawn?.type === "tool_call" + ? parseCollabToolInput(spawn.info.raw_input) + : null + expect(info?.model).toBe("gpt-5-codex") + expect(info?.reasoningEffort).toBe("high") + }) + it("keeps an orphan close that has no spawn to fold into", () => { const input: LiveContentBlock[] = [ collabBlock({ diff --git a/src/lib/collab-tool.test.ts b/src/lib/collab-tool.test.ts index 244957849..995c169a7 100644 --- a/src/lib/collab-tool.test.ts +++ b/src/lib/collab-tool.test.ts @@ -15,6 +15,7 @@ import { // A realistic codex collab tool call rawInput (codex-acp 1.0.1 mapper shape): // agentsStates is a map keyed by sub-agent threadId → { status, message }. +// model/reasoningEffort are top-level siblings (codex-acp #304). const collabRaw = JSON.stringify({ prompt: "在 /Users/x/work/my-app 中运行 `pnpm build`。", senderThreadId: "019f06e2-aaaa", @@ -22,6 +23,8 @@ const collabRaw = JSON.stringify({ agentsStates: { "019f06e3-bbbb": { status: "running", message: "Checking weather" }, }, + model: "gpt-5-codex", + reasoningEffort: "high", status: "inProgress", }) @@ -62,11 +65,13 @@ describe("isCodexCollabInput", () => { }) describe("parseCollabToolInput", () => { - it("extracts prompt, op status, op (null by default), and per-agent states", () => { + it("extracts prompt, op status, op (null by default), model/effort, and per-agent states", () => { expect(parseCollabToolInput(collabRaw)).toEqual({ prompt: "在 /Users/x/work/my-app 中运行 `pnpm build`。", status: "inProgress", op: null, + model: "gpt-5-codex", + reasoningEffort: "high", agents: [ { threadId: "019f06e3-bbbb", @@ -77,6 +82,26 @@ describe("parseCollabToolInput", () => { }) }) + it("reads top-level model and reasoningEffort (codex-acp #304)", () => { + const info = parseCollabToolInput(collabRaw) + expect(info?.model).toBe("gpt-5-codex") + expect(info?.reasoningEffort).toBe("high") + }) + + it("treats absent or blank model/effort as null (not per-agent)", () => { + const info = parseCollabToolInput( + JSON.stringify({ + senderThreadId: "t1", + receiverThreadIds: ["t2"], + // model/effort blank at top level; a per-agent field must NOT be read. + model: " ", + agentsStates: { t2: { status: "running", model: "ignored-per-agent" } }, + }) + ) + expect(info?.model).toBeNull() + expect(info?.reasoningEffort).toBeNull() + }) + it("reads the merged op key", () => { const withOp = JSON.stringify({ prompt: "", @@ -104,6 +129,8 @@ describe("parseCollabToolInput", () => { prompt: "do it", status: "completed", op: null, + model: null, + reasoningEffort: null, agents: [{ threadId: "t2", status: "completed", message: null }], }) }) @@ -118,7 +145,14 @@ describe("parseCollabToolInput", () => { agentsStates: {}, }) ) - ).toEqual({ prompt: null, status: null, op: null, agents: [] }) + ).toEqual({ + prompt: null, + status: null, + op: null, + model: null, + reasoningEffort: null, + agents: [], + }) }) it("yields no agent rows when agentsStates is missing, an array, or malformed", () => { @@ -148,8 +182,10 @@ describe("mergeCollabOp", () => { expect(parsed[COLLAB_OP_KEY]).toBe("spawnAgent") // round-trips through the parser expect(parseCollabToolInput(merged)?.op).toBe("spawnAgent") - // original fields preserved + // original fields preserved (agents + #304 model/effort) expect(parseCollabToolInput(merged)?.agents).toHaveLength(1) + expect(parseCollabToolInput(merged)?.model).toBe("gpt-5-codex") + expect(parseCollabToolInput(merged)?.reasoningEffort).toBe("high") }) it("returns null when there is no op or the input is not a JSON object", () => { diff --git a/src/lib/collab-tool.ts b/src/lib/collab-tool.ts index 344950fc0..b531aba60 100644 --- a/src/lib/collab-tool.ts +++ b/src/lib/collab-tool.ts @@ -15,12 +15,16 @@ * so the live input shaper merges it back in under `COLLAB_OP_KEY` — see * `mergeCollabOp` and `resolveLiveToolInput`. * - * Live ceiling: codex-acp drops `subAgentActivity` (the sub-agent's actual - * streamed messages) and emits no `rawOutput` for collab calls, so the richest - * live signal is `agentsStates[*].{status,message}` (on a `wait` completion the - * `message` carries the sub-agent's full result). The full nested transcript - * only exists on history reload, reconstructed by the Rust parser into the - * richer "Agent" capsule from the on-disk `agent-.jsonl`. + * Live ceiling: codex-acp 1.1.3+ (#304) additionally emits `subAgentActivity` + * as a SEPARATE live `tool_call` (`_meta.codex.subagent`), but codeg suppresses + * it (redundant with this collab capsule — see the Rust `is_codex_subagent_ + * activity`), and it carries no transcript content anyway (only a + * started/interacted/interrupted lifecycle marker). #304 also adds top-level + * `model` / `reasoningEffort` to this collab `rawInput`, surfaced here. The + * richest live signal thus remains `agentsStates[*].{status,message}` (on a + * `wait` completion the `message` carries the sub-agent's full result); the full + * nested transcript only exists on history reload, reconstructed by the Rust + * parser into the richer "Agent" capsule from the on-disk `agent-.jsonl`. */ /** Canonical tool name the live collab path collapses to (see `inferLiveToolName`). */ @@ -50,6 +54,16 @@ export interface CollabToolInfo { status: string | null /** The collab op (`CollabAgentTool`: spawnAgent/wait/closeAgent/…), or null. */ op: string | null + /** + * The sub-agent's model id (codex-acp #304), top-level in the collab + * `rawInput` (a sibling of `prompt`/`agentsStates`, NOT per-agent). May be null. + */ + model: string | null + /** + * The sub-agent's reasoning effort (codex-acp #304), an unconstrained string + * such as "minimal" / "low" / "medium" / "high". Top-level, may be null. + */ + reasoningEffort: string | null /** Per-sub-agent live states, in `agentsStates` insertion order. */ agents: CollabAgentState[] } @@ -242,6 +256,8 @@ export function parseCollabToolInput( prompt: asText(parsed.prompt), status: asText(parsed.status), op: asText(parsed[COLLAB_OP_KEY]), + model: asText(parsed.model), + reasoningEffort: asText(parsed.reasoningEffort), agents, } } From 03fb30bc304d64b9eb329e06c5b0b570630358a4 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 16 Jul 2026 12:18:20 +0800 Subject: [PATCH 03/15] feat(codex): suppress redundant /plan slash command in favor of mode selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex-acp 1.1.4 (#293) advertises `/plan` as an AvailableCommand tagged `_meta.commandAction.kind = "setConfigOption"` (configId `collaboration_mode`, presentation `state`) — codex's signal that the client should render it as state, not an invokable command. codeg already surfaces that state through the generic `collaboration_mode` config-option selector, so listing `/plan` in the slash menu is redundant and its static description is wrong once plan mode is already on. Drop config-option state toggles from the command list, gated on Codex; commands with other action kinds (e.g. `/goal`'s `prefixPrompt`) stay. --- src-tauri/src/acp/connection.rs | 66 ++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 2a021325a..ad809fe62 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -5290,6 +5290,32 @@ fn codex_retry_indicator( Some((message.to_string(), http_status)) } +/// True when an available command is really a config-option state toggle rather +/// than an invokable slash command (codex-acp #293, v1.1.4). codex advertises +/// e.g. `/plan` as an `AvailableCommand` tagged +/// `_meta.commandAction = {kind:"setConfigOption", configId:"collaboration_mode", +/// value:"plan", resetValue:"default", presentation:"state"}` — codex's signal +/// that the client should represent it as STATE. codeg already surfaces that +/// state as the `collaboration_mode` config-option selector (the generic +/// `SessionConfigOption` path), so also listing `/plan` as a slash command is +/// redundant and its static "Turn plan mode on" description is wrong once plan +/// mode is already on. Suppress these from the command list. Commands with any +/// other action kind (e.g. `/goal`'s `prefixPrompt`, which takes an objective +/// argument) are real commands and kept. Gated on Codex — `commandAction` is a +/// codex-private `_meta` extension (the ACP schema has no such type). +fn is_config_option_state_command( + agent_type: AgentType, + meta: Option<&serde_json::Map>, +) -> bool { + if agent_type != AgentType::Codex { + return false; + } + meta.and_then(|m| m.get("commandAction")) + .and_then(|action| action.get("kind")) + .and_then(|kind| kind.as_str()) + == Some("setConfigOption") +} + /// True when a CodeBuddy sub-agent tool call's `_meta` marks it as a BACKGROUND /// sub-agent (`codebuddy.ai/isBackground == true`). A background sub-agent runs /// concurrently with the main agent, so the suppression-window invariant (parent @@ -5937,7 +5963,10 @@ async fn emit_conversation_update( .await; } SessionUpdate::AvailableCommandsUpdate(update) => { - // Some agents (e.g. Claude Code with overlapping user/project slash + // Drop config-option state toggles (codex `/plan` — see + // `is_config_option_state_command`): they're already the + // `collaboration_mode` selector, not invokable commands. Then dedup: + // some agents (e.g. Claude Code with overlapping user/project slash // commands) emit duplicate entries sharing the same name. Keep the // first occurrence so downstream consumers don't render duplicates; // the frontend reducer also dedupes as a defensive measure. @@ -5945,6 +5974,7 @@ async fn emit_conversation_update( let commands: Vec = update .available_commands .iter() + .filter(|cmd| !is_config_option_state_command(agent_type, cmd.meta.as_ref())) .filter(|cmd| seen.insert(cmd.name.clone())) .map(|cmd| { let input_hint = cmd.input.as_ref().map(|input| match input { @@ -6075,6 +6105,40 @@ mod tests { assert!(!is_codex_subagent_activity(AgentType::Codex, Some(&collab))); } + #[test] + fn config_option_state_command_suppressed_only_for_codex_set_config_action() { + // codex-acp #293: `/plan` is a config-option state toggle (rendered as the + // `collaboration_mode` selector), not an invokable slash command. + let plan = meta_map(serde_json::json!({ + "commandAction": { + "kind": "setConfigOption", + "configId": "collaboration_mode", + "value": "plan", + "resetValue": "default", + "presentation": "state" + } + })); + assert!(is_config_option_state_command(AgentType::Codex, Some(&plan))); + // Gated on Codex — the same meta never suppresses another agent's command. + assert!(!is_config_option_state_command( + AgentType::ClaudeCode, + Some(&plan) + )); + // `/goal` uses a `prefixPrompt` action (takes an objective argument) → a + // real command, kept. + let goal = meta_map(serde_json::json!({ + "commandAction": { "kind": "prefixPrompt", "presentation": "state" } + })); + assert!(!is_config_option_state_command(AgentType::Codex, Some(&goal))); + // Ordinary commands (no `commandAction`) and absent meta are kept. + assert!(!is_config_option_state_command(AgentType::Codex, None)); + let plain = meta_map(serde_json::json!({ "somethingElse": true })); + assert!(!is_config_option_state_command( + AgentType::Codex, + Some(&plain) + )); + } + #[test] fn codex_retry_indicator_extracts_message_and_object_http_status() { // codex-acp #289: object-variant `codexErrorInfo` carries an inner From c84f4a34d45fa19078a4f9f0d4752cb4104e66be Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 16 Jul 2026 13:10:40 +0800 Subject: [PATCH 04/15] feat(codex): add goal pause/clear control via _codex/session/goal_control codex-acp 1.1.4 (#293) exposes a bespoke `_codex/session/goal_control` request ({sessionId, action: "pause"|"clear"}) to pause or clear the session's active goal. Wire it end-to-end, mirroring set_config_option: a GoalControlAction enum and ConnectionCommand::GoalControl, a UntypedMessage sender (the escape hatch already used for set_config_option / fork), both connection dispatch arms, a manager method, and the tauri command + HTTP endpoint. The backend sources the sessionId from the live session, so the frontend only supplies connectionId. On the card, GoalCard gains Pause/Clear buttons driven by a goal-control context: the panel decides liveness + ownership once and passes a callback, so the buttons hide on history reload, for viewers, and in the read-only sub-agent dialog (which renders MessageListView with no provider). Pause shows for an active goal, Clear for active or paused; resume is re-issuing the /goal prompt. Also pin 1.1.4's slimmed goal snapshot: it drops tokensUsed and adds createdAt / controlMethod, which pass through goal_marker unchanged while the absent tokensUsed degrades to a hidden stat. --- src-tauri/src/acp/codex_goal.rs | 30 ++++++ src-tauri/src/acp/connection.rs | 102 ++++++++++++++++++ src-tauri/src/acp/manager.rs | 25 ++++- src-tauri/src/commands/acp.rs | 10 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/web/handlers/acp.rs | 19 ++++ src-tauri/src/web/router.rs | 4 + .../conversation-detail-panel.tsx | 58 +++++++--- .../message/goal-control-context.tsx | 27 +++++ .../message/goal-tool-call.test.tsx | 67 ++++++++++++ src/components/message/goal-tool-call.tsx | 39 ++++++- src/contexts/acp-connections-context.tsx | 23 ++++ src/i18n/messages/ar.json | 2 + src/i18n/messages/de.json | 2 + src/i18n/messages/en.json | 2 + src/i18n/messages/es.json | 2 + src/i18n/messages/fr.json | 2 + src/i18n/messages/ja.json | 2 + src/i18n/messages/ko.json | 2 + src/i18n/messages/pt.json | 2 + src/i18n/messages/zh-CN.json | 2 + src/i18n/messages/zh-TW.json | 2 + src/lib/api.ts | 10 ++ 23 files changed, 417 insertions(+), 18 deletions(-) create mode 100644 src/components/message/goal-control-context.tsx diff --git a/src-tauri/src/acp/codex_goal.rs b/src-tauri/src/acp/codex_goal.rs index fd0361a48..4021402f5 100644 --- a/src-tauri/src/acp/codex_goal.rs +++ b/src-tauri/src/acp/codex_goal.rs @@ -248,6 +248,36 @@ mod tests { assert_eq!(input["objective"], "Fix the login bug"); } + #[test] + fn preserves_v114_slimmed_snapshot_fields() { + // codex-acp v1.1.4 (#293) slimmed the goal snapshot: it dropped + // `tokensUsed` and added `createdAt` / `controlMethod` (alongside the + // existing `tokenBudget` / `timeUsedSeconds`). `goal_marker` clones the + // object through, so the new fields survive onto the card output and the + // absent `tokensUsed` is simply not present — `GoalCard` reads it as null + // and hides that stat, so no display breaks. + let goal = json!({ + "objective": " Ship the release ", + "status": "active", + "tokenBudget": 200000, + "timeUsedSeconds": 42, + "createdAt": "2026-07-16T10:00:00Z", + "controlMethod": "_codex/session/goal_control", + }); + let m = goal_marker(&goal).expect("goal marker"); + assert_eq!(m.tool_name, "create_goal"); + let out: Value = serde_json::from_str(&m.output_json).unwrap(); + let g = &out["goal"]; + assert_eq!(g["objective"], "Ship the release"); // trimmed + assert_eq!(g["status"], "active"); + assert_eq!(g["tokenBudget"], 200000); + assert_eq!(g["timeUsedSeconds"], 42); + assert_eq!(g["createdAt"], "2026-07-16T10:00:00Z"); + assert_eq!(g["controlMethod"], "_codex/session/goal_control"); + // Slimmed snapshot carries no tokensUsed → the card hides that stat. + assert!(g.get("tokensUsed").is_none()); + } + #[test] fn goal_tool_call_id_is_occurrence_unique() { // Occurrence-addressed so two runs sharing an objective never collide. diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index ad809fe62..b813f4bec 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -143,6 +143,19 @@ fn prepend_officecli_path(env: &mut BTreeMap) { } } +/// The two actions codex's bespoke `_codex/session/goal_control` request +/// accepts (codex-acp #293, v1.1.4). Start / resume / re-objective are NOT part +/// of this method — those go through the `/goal` prompt (a real slash command; +/// only `/plan`, a config-option state toggle, is suppressed). Serializes to the +/// lowercase wire value codex expects (`"pause"` / `"clear"`) and deserializes +/// from the same string coming off the tauri command / HTTP endpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GoalControlAction { + Pause, + Clear, +} + /// Commands sent from Tauri command handlers to the ACP connection loop. pub enum ConnectionCommand { Prompt { @@ -163,6 +176,9 @@ pub enum ConnectionCommand { config_id: String, value_id: String, }, + GoalControl { + action: GoalControlAction, + }, Cancel, RespondPermission { request_id: String, @@ -3180,6 +3196,34 @@ async fn set_session_config_option_inner( Ok(response.config_options) } +/// Send codex's bespoke `_codex/session/goal_control` extension request to pause +/// or clear the session's active goal (codex-acp #293, v1.1.4). Start / resume / +/// re-objective are NOT this method — they go through the `/goal` prompt. +/// +/// codex replies with an empty object and then pushes the resulting goal +/// snapshot as a normal `session_info_update` (`_meta.codex.goal`, or `null` for +/// a clear), which the existing goal-card path renders — so the response value +/// carries nothing to parse and is intentionally discarded. +/// +/// Sent via `UntypedMessage` because `_codex/…` is a codex-private extension +/// method with no sacp typed variant — the same escape hatch used for +/// `session/set_config_option` and `session/fork`. +async fn send_goal_control( + cx: &ConnectionTo, + session_id: &SessionId, + action: GoalControlAction, +) -> Result<(), sacp::Error> { + let params = serde_json::json!({ + "sessionId": session_id, + "action": action, + }); + let untyped_req = UntypedMessage::new("_codex/session/goal_control", params).map_err(|e| { + sacp::util::internal_error(format!("Failed to build goal_control request: {e}")) + })?; + cx.send_request_to(Agent, untyped_req).block_task().await?; + Ok(()) +} + /// Apply user-saved mode and config-option preferences to a freshly-attached /// session BEFORE the initial `session_modes` / `session_config_options` /// events are emitted to the frontend. @@ -4521,6 +4565,22 @@ async fn run_conversation_loop<'a>( .await; } } + Some(ConnectionCommand::GoalControl { action }) => { + if let Err(e) = send_goal_control(&cx, &sid, action).await { + emit_with_state( + state, + emitter, + AcpEvent::Error { + message: format!("Failed to control goal: {e}"), + agent_type: agent_type.to_string(), + code: None, + // Recoverable: a failed pause/clear leaves the turn alive. + terminal: false, + }, + ) + .await; + } + } Some(ConnectionCommand::Cancel) => { // Send CancelNotification to agent to stop the current turn let _ = cx.send_notification_to( @@ -4699,6 +4759,25 @@ async fn run_conversation_loop<'a>( .await; } } + Some(ConnectionCommand::GoalControl { action }) => { + let cx = session.connection(); + let sid = session.session_id().clone(); + if let Err(e) = send_goal_control(&cx, &sid, action).await { + emit_with_state( + state, + emitter, + AcpEvent::Error { + message: format!("Failed to control goal: {e}"), + agent_type: agent_type.to_string(), + code: None, + // Recoverable: an idle pause/clear failure leaves the + // connection alive. + terminal: false, + }, + ) + .await; + } + } Some(ConnectionCommand::Cancel) => { let cx = session.connection(); let sid = session.session_id().clone(); @@ -6139,6 +6218,29 @@ mod tests { )); } + #[test] + fn goal_control_action_roundtrips_codex_wire_values() { + // codex-acp #293: `_codex/session/goal_control` expects lowercase + // "pause" / "clear" on the wire, and the same strings arrive from the + // tauri command / HTTP endpoint — both directions must match exactly. + assert_eq!( + serde_json::to_value(GoalControlAction::Pause).unwrap(), + serde_json::json!("pause") + ); + assert_eq!( + serde_json::to_value(GoalControlAction::Clear).unwrap(), + serde_json::json!("clear") + ); + assert_eq!( + serde_json::from_value::(serde_json::json!("pause")).unwrap(), + GoalControlAction::Pause + ); + assert_eq!( + serde_json::from_value::(serde_json::json!("clear")).unwrap(), + GoalControlAction::Clear + ); + } + #[test] fn codex_retry_indicator_extracts_message_and_object_http_status() { // codex-acp #289: object-variant `codexErrorInfo` carries an inner diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 86e866cd9..fa1cc2319 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -10,7 +10,9 @@ use sea_orm::{ TransactionTrait, }; -use crate::acp::connection::{spawn_agent_connection, AgentConnection, ConnectionCommand}; +use crate::acp::connection::{ + spawn_agent_connection, AgentConnection, ConnectionCommand, GoalControlAction, +}; use crate::acp::error::AcpError; use crate::acp::feedback::{ bounded_feedback_batch, FeedbackItem, FeedbackStatus, PendingFeedback, @@ -1157,6 +1159,27 @@ impl ConnectionManager { .map_err(|_| AcpError::ProcessExited) } + /// Pause or clear the session's active Codex goal via the connection loop + /// (codex-acp #293). Looked up by connectionId; the loop sources the + /// sessionId from the live session, so callers only supply the action. + pub async fn goal_control( + &self, + conn_id: &str, + action: GoalControlAction, + ) -> Result<(), AcpError> { + let cmd_tx = { + let connections = self.connections.lock().await; + let conn = connections + .get(conn_id) + .ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?; + conn.cmd_tx.clone() + }; + cmd_tx + .send(ConnectionCommand::GoalControl { action }) + .await + .map_err(|_| AcpError::ProcessExited) + } + pub async fn cancel(&self, db: &DatabaseConnection, conn_id: &str) -> Result<(), AcpError> { let (cmd_tx, state_arc, emitter) = { let connections = self.connections.lock().await; diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 3e02ca4ac..eb459feab 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -6068,6 +6068,16 @@ pub async fn acp_set_config_option( .await } +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn acp_goal_control( + connection_id: String, + action: crate::acp::connection::GoalControlAction, + manager: State<'_, ConnectionManager>, +) -> Result<(), AcpError> { + manager.goal_control(&connection_id, action).await +} + /// Spawn a transient ACP connection for `agent_type` with a silent emitter, /// read whatever `SessionConfigOptions` / `SessionModes` the agent advertises, /// and tear it down. The returned snapshot drives the delegation-settings UI diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b8b5340d4..4bf99b575 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1064,6 +1064,7 @@ mod tauri_app { acp_commands::acp_prompt, acp_commands::acp_set_mode, acp_commands::acp_set_config_option, + acp_commands::acp_goal_control, acp_commands::acp_describe_agent_options, acp_commands::acp_cancel, acp_commands::acp_fork, diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 9b6248a3a..fd0d7daa1 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -347,6 +347,25 @@ pub async fn acp_set_config_option( Ok(Json(())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpGoalControlParams { + pub connection_id: String, + pub action: crate::acp::connection::GoalControlAction, +} + +pub async fn acp_goal_control( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let manager = &state.connection_manager; + manager + .goal_control(¶ms.connection_id, params.action) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(())) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct AcpDescribeAgentOptionsParams { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 93c8b0d15..a76f1d434 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -580,6 +580,10 @@ pub fn build_router( "/acp_set_config_option", post(handlers::acp::acp_set_config_option), ) + .route( + "/acp_goal_control", + post(handlers::acp::acp_goal_control), + ) .route( "/acp_describe_agent_options", post(handlers::acp::acp_describe_agent_options), diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 1e133208a..65171fa0c 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -32,6 +32,10 @@ import { cn, copyTextFromMenu, randomUUID } from "@/lib/utils" import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle" import { useMessageQueue, type QueuedMessage } from "@/hooks/use-message-queue" import { MessageListView } from "@/components/message/message-list-view" +import { + GoalControlProvider, + type GoalControlValue, +} from "@/components/message/goal-control-context" import { ConversationShell } from "@/components/chat/conversation-shell" import { SessionConfigStaleBanner } from "@/components/chat/session-config-stale-banner" import { BackgroundTasksChip } from "@/components/chat/background-tasks-chip" @@ -1354,23 +1358,45 @@ const ConversationTabView = memo(function ConversationTabView({ closeTab(tabId) }, [closeTab, folder, openNewConversationTab, tabId, workingDirForConnection]) + // Goal pause/clear is a live, owner-only action, so decide availability once + // here (where the connection is owned) rather than in the deep goal card. + // `null` when the session isn't live or the user is a viewer → the card hides + // its buttons. Codex is the only agent that produces goal cards, so no + // agent-type gate is needed. Provided only around the main panel's list; the + // read-only sub-agent dialog renders its own MessageListView with no provider. + const goalControlValue = useMemo(() => { + const live = + conn.connectionId !== null && + (connStatus === "connected" || connStatus === "prompting") && + !conn.isViewer + return { + onGoalControl: live + ? (action) => { + void acpActions.goalControl(tabId, action) + } + : null, + } + }, [conn.connectionId, conn.isViewer, connStatus, acpActions, tabId]) + const messageListNode = ( - + + + ) // Live-feedback bar gating + the "agent never read your note" resend fallback. diff --git a/src/components/message/goal-control-context.tsx b/src/components/message/goal-control-context.tsx new file mode 100644 index 000000000..9fc048aed --- /dev/null +++ b/src/components/message/goal-control-context.tsx @@ -0,0 +1,27 @@ +"use client" + +import { createContext, useContext } from "react" + +export type GoalControlAction = "pause" | "clear" + +export interface GoalControlValue { + /** + * Fire a pause/clear on the session's currently active Codex goal, or `null` + * when goal control isn't available for this surface — a history reload with + * no live connection, a read-only viewer, or the read-only sub-agent dialog + * (which renders its own `MessageListView` with no provider). The goal card + * hides its control buttons whenever this is `null`, so liveness/ownership are + * decided once at the panel where the connection is owned, not in the card. + */ + onGoalControl: ((action: GoalControlAction) => void) | null +} + +const GoalControlContext = createContext({ + onGoalControl: null, +}) + +export const GoalControlProvider = GoalControlContext.Provider + +export function useGoalControl(): GoalControlValue { + return useContext(GoalControlContext) +} diff --git a/src/components/message/goal-tool-call.test.tsx b/src/components/message/goal-tool-call.test.tsx index 4f5d4f612..f8dd6408e 100644 --- a/src/components/message/goal-tool-call.test.tsx +++ b/src/components/message/goal-tool-call.test.tsx @@ -4,6 +4,7 @@ import { NextIntlClientProvider } from "next-intl" import { describe, expect, it } from "vitest" import { GoalRunPart, GoalToolCallPart } from "./goal-tool-call" +import { GoalControlProvider } from "./goal-control-context" import enMessages from "@/i18n/messages/en.json" import zhMessages from "@/i18n/messages/zh-CN.json" @@ -140,3 +141,69 @@ describe("GoalToolCallPart", () => { expect(screen.getByText("目标:分析 README 文件")).toBeInTheDocument() }) }) + +describe("GoalCard goal control (codex-acp #293)", () => { + function renderGoal( + ui: ReactElement, + onGoalControl: ((action: "pause" | "clear") => void) | null + ) { + return render( + + + {ui} + + + ) + } + + function goalWith(status: string): ReactElement { + const toolName = status === "active" ? "create_goal" : "update_goal" + return ( + + ) + } + + it("offers Pause + Clear on a live active goal and routes the action", () => { + const calls: string[] = [] + renderGoal(goalWith("active"), (a) => calls.push(a)) + // Controls live in the (collapsed) body — expand first. + fireEvent.click(screen.getByRole("button")) + fireEvent.click(screen.getByText("Pause")) + fireEvent.click(screen.getByText("Clear")) + expect(calls).toEqual(["pause", "clear"]) + }) + + it("offers only Clear on a paused goal (codex has no resume control)", () => { + renderGoal(goalWith("paused"), () => {}) + fireEvent.click(screen.getByRole("button")) + expect(screen.getByText("Clear")).toBeInTheDocument() + expect(screen.queryByText("Pause")).not.toBeInTheDocument() + }) + + it("shows no controls on a terminal goal", () => { + renderGoal(goalWith("complete"), () => {}) + fireEvent.click(screen.getByRole("button")) + expect(screen.queryByText("Pause")).not.toBeInTheDocument() + expect(screen.queryByText("Clear")).not.toBeInTheDocument() + }) + + it("hides controls when the session isn't live (no provider callback)", () => { + // Reload / viewer / sub-agent dialog → onGoalControl null → no buttons even + // for an active goal. + renderGoal(goalWith("active"), null) + fireEvent.click(screen.getByRole("button")) + expect(screen.queryByText("Pause")).not.toBeInTheDocument() + expect(screen.queryByText("Clear")).not.toBeInTheDocument() + }) +}) diff --git a/src/components/message/goal-tool-call.tsx b/src/components/message/goal-tool-call.tsx index 0841e09ee..e262c24f2 100644 --- a/src/components/message/goal-tool-call.tsx +++ b/src/components/message/goal-tool-call.tsx @@ -13,8 +13,9 @@ import { import { Shimmer } from "@/components/ai-elements/shimmer" import { normalizeToolName } from "@/lib/tool-call-normalization" import { cn } from "@/lib/utils" -import { ChevronRightIcon } from "lucide-react" +import { ChevronRightIcon, PauseIcon, XIcon } from "lucide-react" import { useTranslations } from "next-intl" +import { useGoalControl } from "./goal-control-context" type GoalToolPart = Extract @@ -236,6 +237,17 @@ function GoalCard({ const errorText = endPart?.errorText ?? startPart.errorText + // Pause/Clear are only offered for a live, controllable goal (the provider + // supplies a callback only when the session is live and the user owns it; it + // is `null` on history reload / for viewers / in the read-only sub-agent + // dialog). Pause applies to an active goal; Clear to active OR paused. Codex + // exposes no "resume" control — resuming is re-issuing the `/goal` prompt. + const { onGoalControl } = useGoalControl() + const showPause = Boolean(onGoalControl) && normalizedStatus === "active" + const showClear = + Boolean(onGoalControl) && + (normalizedStatus === "active" || normalizedStatus === "paused") + return ( + {(showPause || showClear) && ( +
+ {showPause && ( + + )} + {showClear && ( + + )} +
+ )} + {isError && errorText && (
                 {errorText}
diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx
index 088eb6b65..d1969114d 100644
--- a/src/contexts/acp-connections-context.tsx
+++ b/src/contexts/acp-connections-context.tsx
@@ -23,6 +23,7 @@ import {
   acpPrompt,
   acpSetMode,
   acpSetConfigOption,
+  acpGoalControl,
   acpCancel,
   acpRespondPermission,
   acpAnswerQuestion,
@@ -2273,6 +2274,8 @@ export interface AcpActionsValue {
     questionId: string,
     answer: QuestionAnswer
   ): Promise
+  /** Pause or clear the session's active Codex goal (codex-acp #293). */
+  goalControl(contextKey: string, action: "pause" | "clear"): Promise
   setActiveKey(key: string | null): void
   touchActivity(contextKey: string): void
   registerOpenTabKeys(keys: Set): void
@@ -4444,6 +4447,24 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
     await acpCancel(conn.connectionId)
   }, [])
 
+  const goalControl = useCallback(
+    async (contextKey: string, action: "pause" | "clear") => {
+      const conn = storeRef.current.connections.get(contextKey)
+      if (!conn) return
+      // Fire-and-forget: there is no in-flight card UI to settle (unlike
+      // answerQuestion). The resulting goal snapshot arrives as a normal
+      // session_info_update, and a wire failure is surfaced by the backend's
+      // recoverable Error event — so log here and don't rethrow.
+      try {
+        lastActivityRef.current.set(contextKey, Date.now())
+        await acpGoalControl(conn.connectionId, action)
+      } catch (e) {
+        console.error("[AcpConnections] goalControl failed:", e)
+      }
+    },
+    []
+  )
+
   const respondPermission = useCallback(
     async (contextKey: string, requestId: string, optionId: string) => {
       const conn = storeRef.current.connections.get(contextKey)
@@ -4571,6 +4592,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
       setMode,
       setConfigOption,
       cancel,
+      goalControl,
       respondPermission,
       answerQuestion,
       setActiveKey,
@@ -4591,6 +4613,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
       setMode,
       setConfigOption,
       cancel,
+      goalControl,
       respondPermission,
       answerQuestion,
       setActiveKey,
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index b88f45744..805dba528 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -2455,6 +2455,8 @@
           "remaining": "المتبقي",
           "elapsed": "المدة",
           "tokens": "tokens",
+          "pause": "إيقاف مؤقت",
+          "clear": "مسح",
           "status": {
             "active": "نشط",
             "paused": "متوقف مؤقتًا",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index ec91d09f2..e2ddec68f 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -2455,6 +2455,8 @@
           "remaining": "Verbleibend",
           "elapsed": "Verstrichen",
           "tokens": "tokens",
+          "pause": "Pausieren",
+          "clear": "Löschen",
           "status": {
             "active": "aktiv",
             "paused": "pausiert",
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 73c6f0567..6cd826c23 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -2455,6 +2455,8 @@
           "remaining": "Remaining",
           "elapsed": "Elapsed",
           "tokens": "tokens",
+          "pause": "Pause",
+          "clear": "Clear",
           "status": {
             "active": "active",
             "paused": "paused",
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index d7efdb18e..50b91aa2d 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -2455,6 +2455,8 @@
           "remaining": "Restante",
           "elapsed": "Transcurrido",
           "tokens": "tokens",
+          "pause": "Pausar",
+          "clear": "Borrar",
           "status": {
             "active": "activo",
             "paused": "pausado",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index b10a0141b..ec89f6c77 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -2455,6 +2455,8 @@
           "remaining": "Restant",
           "elapsed": "Écoulé",
           "tokens": "tokens",
+          "pause": "Pause",
+          "clear": "Effacer",
           "status": {
             "active": "actif",
             "paused": "en pause",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index d2ced7f9b..d657d14d7 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -2455,6 +2455,8 @@
           "remaining": "残り",
           "elapsed": "経過時間",
           "tokens": "tokens",
+          "pause": "一時停止",
+          "clear": "クリア",
           "status": {
             "active": "実行中",
             "paused": "一時停止",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index d23c2d9e7..4241c3a16 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -2455,6 +2455,8 @@
           "remaining": "남음",
           "elapsed": "경과",
           "tokens": "tokens",
+          "pause": "일시정지",
+          "clear": "지우기",
           "status": {
             "active": "진행 중",
             "paused": "일시 중지",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index 661896a3b..fe7cce905 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -2455,6 +2455,8 @@
           "remaining": "Restante",
           "elapsed": "Decorrido",
           "tokens": "tokens",
+          "pause": "Pausar",
+          "clear": "Limpar",
           "status": {
             "active": "ativo",
             "paused": "pausado",
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index 01c93960d..d97c7ae71 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -2455,6 +2455,8 @@
           "remaining": "剩余",
           "elapsed": "耗时",
           "tokens": "tokens",
+          "pause": "暂停",
+          "clear": "清除",
           "status": {
             "active": "进行中",
             "paused": "已暂停",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index aaf9aa1fa..b15b1012b 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -2455,6 +2455,8 @@
           "remaining": "剩餘",
           "elapsed": "耗時",
           "tokens": "tokens",
+          "pause": "暫停",
+          "clear": "清除",
           "status": {
             "active": "進行中",
             "paused": "已暫停",
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 88443b1bf..9b4583bc0 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -208,6 +208,16 @@ export async function acpSetConfigOption(
   })
 }
 
+/** Pause or clear the session's active Codex goal (codex-acp #293). The backend
+ *  sources the sessionId from the live session, so only the connection + action
+ *  are needed here. */
+export async function acpGoalControl(
+  connectionId: string,
+  action: "pause" | "clear"
+): Promise {
+  return getTransport().call("acp_goal_control", { connectionId, action })
+}
+
 export async function acpCancel(connectionId: string): Promise {
   return getTransport().call("acp_cancel", { connectionId })
 }

From c362a7c7f31aa51fee806c36d4c0f39e135a91c1 Mon Sep 17 00:00:00 2001
From: xintaofei 
Date: Thu, 16 Jul 2026 14:57:08 +0800
Subject: [PATCH 05/15] fix(codex): render a bodyless sub-agent capsule as a
 bare pill
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

AgentCapsule always drew a bordered, scrollable body frame below the pill, so a
capsule whose conditional children were all absent rendered an empty "white
box". The newer codex triggers this on every wait_agent — its wait returns no
per-agent result, leaving the collab wait capsule with nothing to show.

Detect an empty body with Children.toArray (which drops the false/null entries
of the callers' conditional children) and, when there's nothing to show, render
just a bare, non-interactive pill — no chevron, no frame. Fixes the long-standing
sub-agent white box for both the live collab card and the history Agent capsule.
---
 src/components/message/agent-capsule.tsx      | 84 ++++++++++++-------
 .../message/collab-agent-card.test.tsx        | 17 ++++
 2 files changed, 72 insertions(+), 29 deletions(-)

diff --git a/src/components/message/agent-capsule.tsx b/src/components/message/agent-capsule.tsx
index 0e4fec71a..73d80ef53 100644
--- a/src/components/message/agent-capsule.tsx
+++ b/src/components/message/agent-capsule.tsx
@@ -10,7 +10,7 @@
  * data it actually has.
  */
 
-import { useState, type ReactNode } from "react"
+import { Children, useState, type ReactNode } from "react"
 import { ChevronRightIcon } from "lucide-react"
 
 import { Shimmer } from "@/components/ai-elements/shimmer"
@@ -52,6 +52,14 @@ export function AgentCapsule({
   defaultOpen,
   children,
 }: AgentCapsuleProps) {
+  // Whether there's any real body content. `Children.toArray` flattens the
+  // caller's conditional children (`{cond && <…/>}`, `.map(a => … ? <…/> : null)`)
+  // and drops the `false`/`null` entries, so an all-absent body yields length 0.
+  // A bodyless capsule then renders as a bare pill rather than an empty bordered
+  // frame — the long-standing sub-agent "white box", which the newer codex now
+  // triggers on every `wait_agent` (its wait carries no per-agent result).
+  const hasBody = Children.toArray(children).length > 0
+
   const [bodyOpen, setBodyOpen] = useState(defaultOpen ?? isError)
 
   // Respond to prop transitions with the canonical React tracked-previous-state
@@ -76,16 +84,15 @@ export function AgentCapsule({
     }
   }
 
-  return (
-    
-      {/* Pill trigger — matches ToolGroupPart structure with themed emphasis. */}
-      
+  const pillClass = cn(
+    "group inline-flex max-w-full items-center gap-1.5 rounded-full bg-primary/10 px-3.5 py-2 text-xs font-medium text-foreground transition-colors",
+    hasBody && "hover:bg-primary/15",
+    isError && "text-destructive"
+  )
+
+  const pillInner = (
+    <>
+      {hasBody && (
         
+
+
+ + { + handleClaudeEnvFlagChange( + "claudeSendAttributionHeader", + CLAUDE_ATTRIBUTION_HEADER_ENV_KEY, + checked + ) + }} + aria-label={t("claude.sendAttributionHeaderAria")} + /> +
+
+
+
+ + { + handleClaudeEnvFlagChange( + "claudeDisableNonessentialTraffic", + CLAUDE_NONESSENTIAL_TRAFFIC_ENV_KEY, + checked + ) + }} + aria-label={t( + "claude.disableNonessentialTrafficAria" + )} + /> +
+
) : (
@@ -10743,7 +10962,7 @@ supports_websockets = true`} // env→config (never parallel): persistEnv also rewrites // config.env on the backend, so concurrent writes would // interleave two writers of ~/.claude/settings.json. - const configToSave = configTextForClaudeSave( + let configToSave = configTextForClaudeSave( selectedDraft.configText, selectedAgent.agent_type, selectedDraft.modelProviderId, @@ -10751,10 +10970,32 @@ supports_websockets = true`} (p) => p.id === selectedDraft.modelProviderId ) ) + // Materialize the Claude hardening toggles so the shown + // default positions are actually applied on save — + // writing the explicit "1"/"0" into both the native + // config `env` and the DB env overlay — regardless of + // whether the user touched the switches. Invalid JSON is + // left untouched so persistConfig surfaces the error. + let envToSave = selectedDraft.envText + if (selectedAgent.agent_type === "claude_code") { + const materialized = + materializeClaudeHardeningFlags( + configToSave, + envToSave, + { + sendAttributionHeader: + selectedDraft.claudeSendAttributionHeader, + disableNonessentialTraffic: + selectedDraft.claudeDisableNonessentialTraffic, + } + ) + configToSave = materialized.configText + envToSave = materialized.envText + } persistEnv( selectedAgent.agent_type, selectedDraft.enabled, - selectedDraft.envText, + envToSave, selectedDraft.modelProviderId ) .then(() => @@ -10764,21 +11005,40 @@ supports_websockets = true`} ) ) .then(() => { - // Reflect the provider-authoritative rewrite in the - // editor so the textarea doesn't keep showing a - // stale value (e.g. a cleared custom model option) - // until reload — only when the rewrite changed it. - // The inner guard preserves any edit the user typed - // into the still-editable textarea while the save - // was in flight (don't clobber a newer draft). - if (configToSave !== selectedDraft.configText) { - const synced = normalizeConfigText(configToSave) - updateSelectedDraft((current) => - current.configText === - selectedDraft.configText - ? { ...current, configText: synced } - : current - ) + // Reflect the provider-authoritative rewrite AND the + // materialized hardening flags in the editors so the + // textareas don't show stale values until reload — + // and so a later env-only save doesn't persist a + // stale envText that drops the flags from the DB + // overlay. Each inner guard preserves an edit the + // user typed while the save was in flight. + const syncedConfig = + configToSave !== selectedDraft.configText + ? normalizeConfigText(configToSave) + : null + const syncEnv = + envToSave !== selectedDraft.envText + if (syncedConfig !== null || syncEnv) { + updateSelectedDraft((current) => { + let next = current + if ( + syncedConfig !== null && + current.configText === + selectedDraft.configText + ) { + next = { + ...next, + configText: syncedConfig, + } + } + if ( + syncEnv && + current.envText === selectedDraft.envText + ) { + next = { ...next, envText: envToSave } + } + return next + }) } toast.success(t("toasts.configSaved"), { description: t("toasts.configSavedHint"), diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 414dac485..71d7ed193 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -950,7 +950,11 @@ "effortLevel_low": "منخفض", "effortLevel_medium": "متوسط", "effortLevel_high": "مرتفع", - "effortLevel_xhigh": "مرتفع جداً" + "effortLevel_xhigh": "مرتفع جداً", + "sendAttributionHeader": "إرسال معرّف الإسناد/الفوترة إلى الواجهة", + "sendAttributionHeaderAria": "إرسال معرّف الإسناد/الفوترة الخاص بـ Claude Code إلى الواجهة", + "disableNonessentialTraffic": "تعطيل القياس عن بُعد أو طلبات الشبكة غير الضرورية", + "disableNonessentialTrafficAria": "تعطيل القياس عن بُعد أو طلبات الشبكة غير الضرورية في Claude Code" }, "dialogs": { "confirmDeleteProvider": "حذف المزود {providerId}؟", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index ad467e1f6..33a9803db 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -950,7 +950,11 @@ "effortLevel_low": "Niedrig", "effortLevel_medium": "Mittel", "effortLevel_high": "Hoch", - "effortLevel_xhigh": "Sehr Hoch" + "effortLevel_xhigh": "Sehr Hoch", + "sendAttributionHeader": "Attributions-/Abrechnungskennung an die API senden", + "sendAttributionHeaderAria": "Attributions-/Abrechnungskennung von Claude Code an die API senden", + "disableNonessentialTraffic": "Telemetrie oder überflüssige Netzwerkanfragen deaktivieren", + "disableNonessentialTrafficAria": "Telemetrie oder überflüssige Netzwerkanfragen von Claude Code deaktivieren" }, "dialogs": { "confirmDeleteProvider": "Provider {providerId} löschen?", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index bdfd937f5..b34011730 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -950,7 +950,11 @@ "effortLevel_low": "Low", "effortLevel_medium": "Medium", "effortLevel_high": "High", - "effortLevel_xhigh": "Extra High" + "effortLevel_xhigh": "Extra High", + "sendAttributionHeader": "Send attribution/billing identifier to the API", + "sendAttributionHeaderAria": "Send Claude Code attribution/billing identifier to the API", + "disableNonessentialTraffic": "Disable telemetry or redundant network requests", + "disableNonessentialTrafficAria": "Disable Claude Code telemetry or redundant network requests" }, "dialogs": { "confirmDeleteProvider": "Delete Provider {providerId}?", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 26f871e11..d6f51d694 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -950,7 +950,11 @@ "effortLevel_low": "Bajo", "effortLevel_medium": "Medio", "effortLevel_high": "Alto", - "effortLevel_xhigh": "Extra Alto" + "effortLevel_xhigh": "Extra Alto", + "sendAttributionHeader": "Enviar identificador de atribución/facturación a la API", + "sendAttributionHeaderAria": "Enviar el identificador de atribución/facturación de Claude Code a la API", + "disableNonessentialTraffic": "Desactivar la telemetría o las solicitudes de red innecesarias", + "disableNonessentialTrafficAria": "Desactivar la telemetría o las solicitudes de red innecesarias de Claude Code" }, "dialogs": { "confirmDeleteProvider": "¿Eliminar proveedor {providerId}?", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 8b615adab..2f4872d2d 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -950,7 +950,11 @@ "effortLevel_low": "Bas", "effortLevel_medium": "Moyen", "effortLevel_high": "Élevé", - "effortLevel_xhigh": "Très Élevé" + "effortLevel_xhigh": "Très Élevé", + "sendAttributionHeader": "Envoyer l'identifiant d'attribution/facturation à l'API", + "sendAttributionHeaderAria": "Envoyer l'identifiant d'attribution/facturation de Claude Code à l'API", + "disableNonessentialTraffic": "Désactiver la télémétrie ou les requêtes réseau superflues", + "disableNonessentialTrafficAria": "Désactiver la télémétrie ou les requêtes réseau superflues de Claude Code" }, "dialogs": { "confirmDeleteProvider": "Supprimer le provider {providerId} ?", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index a4ff328ae..b967e41b7 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -950,7 +950,11 @@ "effortLevel_low": "低", "effortLevel_medium": "中", "effortLevel_high": "高", - "effortLevel_xhigh": "超高" + "effortLevel_xhigh": "超高", + "sendAttributionHeader": "API に帰属/課金識別子を送信", + "sendAttributionHeaderAria": "API に Claude Code の帰属/課金識別子を送信", + "disableNonessentialTraffic": "テレメトリや不要なネットワークリクエストを無効化", + "disableNonessentialTrafficAria": "Claude Code のテレメトリや不要なネットワークリクエストを無効化" }, "dialogs": { "confirmDeleteProvider": "Provider {providerId} を削除しますか?", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index b99b77753..8afa5f450 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -950,7 +950,11 @@ "effortLevel_low": "낮음", "effortLevel_medium": "중간", "effortLevel_high": "높음", - "effortLevel_xhigh": "매우 높음" + "effortLevel_xhigh": "매우 높음", + "sendAttributionHeader": "API에 속성/청구 식별자 전송", + "sendAttributionHeaderAria": "API에 Claude Code 속성/청구 식별자 전송", + "disableNonessentialTraffic": "텔레메트리 또는 불필요한 네트워크 요청 비활성화", + "disableNonessentialTrafficAria": "Claude Code 텔레메트리 또는 불필요한 네트워크 요청 비활성화" }, "dialogs": { "confirmDeleteProvider": "Provider {providerId}를 삭제하시겠습니까?", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 572880228..ec305ac09 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -950,7 +950,11 @@ "effortLevel_low": "Baixo", "effortLevel_medium": "Médio", "effortLevel_high": "Alto", - "effortLevel_xhigh": "Extra Alto" + "effortLevel_xhigh": "Extra Alto", + "sendAttributionHeader": "Enviar identificador de atribuição/cobrança para a API", + "sendAttributionHeaderAria": "Enviar o identificador de atribuição/cobrança do Claude Code para a API", + "disableNonessentialTraffic": "Desativar a telemetria ou solicitações de rede desnecessárias", + "disableNonessentialTrafficAria": "Desativar a telemetria ou solicitações de rede desnecessárias do Claude Code" }, "dialogs": { "confirmDeleteProvider": "Excluir o provedor {providerId}?", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f96243f57..63bc86ff5 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -950,7 +950,11 @@ "effortLevel_low": "低", "effortLevel_medium": "中", "effortLevel_high": "高", - "effortLevel_xhigh": "超高" + "effortLevel_xhigh": "超高", + "sendAttributionHeader": "向接口发送归属/计费标识", + "sendAttributionHeaderAria": "向接口发送 Claude Code 归属/计费标识", + "disableNonessentialTraffic": "禁用遥测或多余的网络请求", + "disableNonessentialTrafficAria": "禁用 Claude Code 遥测或多余的网络请求" }, "dialogs": { "confirmDeleteProvider": "确认删除 Provider {providerId}?", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 16b45e45e..fa41e0178 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -950,7 +950,11 @@ "effortLevel_low": "低", "effortLevel_medium": "中", "effortLevel_high": "高", - "effortLevel_xhigh": "超高" + "effortLevel_xhigh": "超高", + "sendAttributionHeader": "向介面傳送歸屬/計費識別碼", + "sendAttributionHeaderAria": "向介面傳送 Claude Code 歸屬/計費識別碼", + "disableNonessentialTraffic": "停用遙測或多餘的網路請求", + "disableNonessentialTrafficAria": "停用 Claude Code 遙測或多餘的網路請求" }, "dialogs": { "confirmDeleteProvider": "確認刪除 Provider {providerId}?", From f11735b896ea4497d072fc6ecb1b389d55026d7b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 24 Jul 2026 19:24:46 +0800 Subject: [PATCH 09/15] feat(acp): render Grok plan-mode approval card for exit_plan_mode Grok's native exit_plan_mode sends a blocking _x.ai/exit_plan_mode request to get the user's decision before leaving plan mode. codeg had no handler, so the turn stalled with nothing rendered. Bridge it to an interactive approval card (approve / request changes / abandon), shown in both the main conversation and the delegated sub-agent dialog and carried on the session snapshot so a mid-turn attach recovers it. Cancelling a turn now reclaims the pending question and plan-approval registries so the next request still renders. Also fix a pre-existing test that asserted a conversation status against the enum variant instead of its serialized string. --- src-tauri/src/acp/connection.rs | 158 +++++++++ src-tauri/src/acp/manager.rs | 302 +++++++++++++++++ src-tauri/src/acp/mod.rs | 1 + src-tauri/src/acp/plan_approval.rs | 306 ++++++++++++++++++ src-tauri/src/acp/session_state.rs | 117 +++++++ src-tauri/src/acp/types.rs | 17 + src-tauri/src/app_state.rs | 5 + src-tauri/src/commands/acp.rs | 13 + src-tauri/src/db/service/import_service.rs | 4 +- src-tauri/src/lib.rs | 1 + src-tauri/src/web/handlers/acp.rs | 20 ++ src-tauri/src/web/router.rs | 4 + src/components/chat/conversation-shell.tsx | 22 ++ .../chat/plan-approval-card.test.tsx | 106 ++++++ src/components/chat/plan-approval-card.tsx | 146 +++++++++ .../conversation-detail-panel.tsx | 11 + .../message/delegated-sub-thread.test.tsx | 1 + .../message/sub-agent-session-dialog.test.tsx | 1 + .../message/sub-agent-session-dialog.tsx | 33 +- src/contexts/acp-connections-context.tsx | 114 +++++++ src/hooks/use-connection.ts | 5 + src/i18n/messages/ar.json | 11 + src/i18n/messages/de.json | 11 + src/i18n/messages/en.json | 11 + src/i18n/messages/es.json | 11 + src/i18n/messages/fr.json | 11 + src/i18n/messages/ja.json | 11 + src/i18n/messages/ko.json | 11 + src/i18n/messages/pt.json | 11 + src/i18n/messages/zh-CN.json | 11 + src/i18n/messages/zh-TW.json | 11 + src/lib/api.ts | 20 ++ src/lib/snapshot-denormalize.ts | 7 + src/lib/types.ts | 45 +++ 34 files changed, 1566 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/acp/plan_approval.rs create mode 100644 src/components/chat/plan-approval-card.test.tsx create mode 100644 src/components/chat/plan-approval-card.tsx diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 5a52fda29..b142e845b 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -892,6 +892,11 @@ pub async fn spawn_agent_connection( // companion's ask socket to close (which a reparented/hard-killed // agent may never do); the dropped sender declines the tool cleanly. inj.questions.cancel_questions_by_parent(&conn_id).await; + // Likewise reclaim a parked Grok `exit_plan_mode` approval; the + // dropped sender replies disconnect so grok keeps plan mode active. + inj.plan_approvals + .cancel_plan_approvals_by_parent(&conn_id) + .await; } if let Err(e) = result { @@ -2015,6 +2020,14 @@ pub struct DelegationInjection { /// the delegation `broker.cancel_by_parent` cleanup. Shares the same backing /// `ConnectionManager` as the listener's question lookup. pub questions: Arc, + /// Plan-approval registry handle for Grok's `exit_plan_mode` ext bridge. + /// Unlike delegation / ask / feedback this is NOT a codeg-mcp feature — it is + /// Grok's native plan mode — so it has no runtime on/off flag and is always + /// wired. Shares the same backing `ConnectionManager` as the question lookup. + /// The `run_connection` handler registers approvals + parks on the reply + /// through this, and the cleanup guard calls `cancel_plan_approvals_by_parent` + /// on disconnect (mirroring the question teardown cascade). + pub plan_approvals: Arc, } /// Locate the `codeg-mcp` companion binary across the supported deployment @@ -2352,6 +2365,14 @@ async fn run_connection( .as_ref() .map(|inj| (Arc::clone(&inj.questions), inj.ask.clone())); let grok_ask_conn_id = connection_id.clone(); + // Grok `exit_plan_mode` bridge access — always wired in production (native + // plan mode, no feature flag). `None` only on the test paths that spin up + // `run_connection` without a delegation stack; the handler then replies + // disconnect and grok keeps plan mode active. + let grok_plan_access = delegation_injection + .as_ref() + .map(|inj| Arc::clone(&inj.plan_approvals)); + let grok_plan_conn_id = connection_id.clone(); // The ext handler emits the answered in-stream card (`AskQuestionResultCard`) // itself once the user submits — grok never emits a completed tool result into // the ACP stream — so it needs this connection's session state + emitter. @@ -2515,6 +2536,19 @@ async fn run_connection( }, on_receive_request!(), ) + .on_receive_request( + { + let access = grok_plan_access.clone(); + let conn_id = grok_plan_conn_id.clone(); + async move |req: GrokExitPlanModeRequest, + responder: Responder, + _cx: ConnectionTo| { + handle_grok_exit_plan_mode(&access, &conn_id, req, responder).await; + Ok(()) + } + }, + on_receive_request!(), + ) .connect_with(agent, async move |cx| -> Result<(), sacp::Error> { let state = state_outer; let agent_name_for_log = registry::get_agent_meta(agent_type).name; @@ -3190,6 +3224,18 @@ async fn run_connection( #[serde(transparent)] struct GrokAskUserQuestionRequest(serde_json::Value); +/// Store the plan-approval responder and render the approval card. Grok's native +/// `exit_plan_mode` tool issues this ACP ext request (`_x.ai/exit_plan_mode`) and +/// BLOCKS on the reply — the agent won't leave plan mode until the user acts. +/// Transparent over the raw params object (`{sessionId, toolCallId, planContent}`); +/// the fields codeg needs are read by +/// [`crate::acp::plan_approval::parse_grok_exit_plan_request`]. sacp routes typed +/// handlers on the RAW wire method, so the derive keeps the leading `_`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonRpcRequest)] +#[request(method = "_x.ai/exit_plan_mode", response = serde_json::Value)] +#[serde(transparent)] +struct GrokExitPlanModeRequest(serde_json::Value); + /// Bridge grok's native `_x.ai/ask_user_question` ext request into codeg's /// interactive question card. Grok blocks on the reply, so codeg registers the /// questions through the shared [`crate::acp::question::SessionQuestionAccess`] — @@ -3295,6 +3341,83 @@ async fn handle_grok_ask_user_question( }); } +/// Bridge grok's native `_x.ai/exit_plan_mode` ext request into codeg's +/// interactive plan-approval card. Grok BLOCKS on the reply — it won't leave plan +/// mode until the user acts — so codeg registers the approval through the shared +/// [`crate::acp::plan_approval::SessionPlanApprovalAccess`] (which sets +/// `pending_plan_approval`, broadcasts `PlanApprovalRequest`, and renders the card +/// above the composer), then answers the ext request with the user's decision once +/// they submit. Unlike the ask bridge there is no synthesized in-stream card: +/// grok's own `exit_plan_mode` tool_call renders the plan in the transcript (via +/// `PlanModeCard`), mirroring how the permission dialog coexists with the tool +/// call. Every early return replies with the disconnect-shaped response so grok +/// keeps plan mode active — it can never be read as a silent approval. +async fn handle_grok_exit_plan_mode( + access: &Option>, + connection_id: &str, + req: GrokExitPlanModeRequest, + responder: Responder, +) { + // Log the wire SHAPE (top-level field names), not the raw request — the plan + // body can be large and carry file paths / source. The keys are what + // wire-format verification needs (confirm `sessionId`/`toolCallId`/`planContent` + // on the first real run); the malformed path below logs more if parsing fails. + tracing::info!( + "[grok exit_plan] received _x.ai/exit_plan_mode ext request: keys={:?}", + req.0 + .as_object() + .map(|o| o.keys().map(String::as_str).collect::>()) + ); + let Some(access) = access else { + let _ = + responder.respond(crate::acp::plan_approval::grok_exit_plan_disconnect_response()); + return; + }; + let (plan_markdown, tool_call_id) = + match crate::acp::plan_approval::parse_grok_exit_plan_request(&req.0) { + Ok(parsed) => parsed, + Err(e) => { + tracing::warn!("[grok exit_plan] rejecting malformed ext request: {e}"); + let _ = responder + .respond(crate::acp::plan_approval::grok_exit_plan_disconnect_response()); + return; + } + }; + tracing::info!( + "[grok exit_plan] toolCallId={tool_call_id:?} plan_chars={}", + plan_markdown.chars().count() + ); + let Some(registered) = access + .register_plan_approval(connection_id, tool_call_id, plan_markdown) + .await + else { + // Connection gone, or an approval is already pending on this connection. + let _ = + responder.respond(crate::acp::plan_approval::grok_exit_plan_disconnect_response()); + return; + }; + // The user answers out-of-band (the HTTP `answer_plan_approval` endpoint + // resolves the one-shot below), so await it on a task — keeping the ACP + // dispatch loop free — then reply to grok's blocked ext request. The manager's + // `answer_plan_approval` / teardown emit `PlanApprovalResolved` to clear the + // card; this task only unblocks grok. + tokio::spawn(async move { + match registered.answer_rx.await { + Ok(answer) => { + let _ = responder.respond( + crate::acp::plan_approval::build_grok_exit_plan_response(&answer), + ); + } + // Sender dropped: the approval was canceled or the connection tore + // down — reply disconnect so grok keeps plan mode active. + Err(_) => { + let _ = responder + .respond(crate::acp::plan_approval::grok_exit_plan_disconnect_response()); + } + } + }); +} + async fn handle_permission_request( state: &Arc>, emitter: &EventEmitter, @@ -4952,6 +5075,26 @@ async fn run_conversation_loop<'a>( // DelegationCompleted emit. if let Some(inj) = delegation_injection { inj.broker.cancel_by_parent_turn(conn_id).await; + // Reclaim any parked `ask_user_question` / + // Grok `exit_plan_mode` approval owned by this + // connection. Unlike `perms` (drained inline + // above), these registries live on the manager + // and are otherwise only drained on full + // connection teardown -- but a turn-scoped + // Cancel keeps the connection alive. Without + // this the entry lingers, and the + // one-pending-per-connection guard in + // `register_{question,plan_approval}` then + // silently rejects the NEXT one on this + // connection (its card never shows). Idempotent + // with the cleanup-guard drain (empty map -> + // no-op); the dropped sender declines the tool / + // replies disconnect, exactly like the + // permission drain above. + inj.questions.cancel_questions_by_parent(conn_id).await; + inj.plan_approvals + .cancel_plan_approvals_by_parent(conn_id) + .await; } // Drain the prompt response in the background so // the SACP library doesn't log "receiver dropped" @@ -8688,6 +8831,19 @@ mod tests { async fn cancel_question(&self, _parent_connection_id: &str, _question_id: &str) {} async fn cancel_questions_by_parent(&self, _parent_connection_id: &str) {} } + struct NoPlanApprovals; + #[async_trait::async_trait] + impl crate::acp::plan_approval::SessionPlanApprovalAccess for NoPlanApprovals { + async fn register_plan_approval( + &self, + _parent_connection_id: &str, + _tool_call_id: String, + _plan_markdown: String, + ) -> Option { + None + } + async fn cancel_plan_approvals_by_parent(&self, _parent_connection_id: &str) {} + } let injection = DelegationInjection { broker, tokens: Arc::new(TokenRegistry::default()), @@ -8697,6 +8853,8 @@ mod tests { sessions: crate::acp::session_info::SessionInfoRuntimeConfig::new(), questions: Arc::new(NoQuestions) as Arc, + plan_approvals: Arc::new(NoPlanApprovals) + as Arc, }; let mut servers: Vec = Vec::new(); diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 86e866cd9..1b2074155 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -16,6 +16,9 @@ use crate::acp::feedback::{ bounded_feedback_batch, FeedbackItem, FeedbackStatus, PendingFeedback, SessionFeedbackAccess, MAX_FEEDBACK_CHARS, MAX_FEEDBACK_RESPONSE_BYTES, }; +use crate::acp::plan_approval::{ + PlanApprovalAnswer, RegisteredPlanApproval, SessionPlanApprovalAccess, +}; use crate::acp::question::{ build_outcome, QuestionAnswer, QuestionOutcome, QuestionSpec, RegisteredQuestion, SessionQuestionAccess, @@ -210,6 +213,14 @@ pub struct ConnectionManager { /// no cap, no cumulative growth; entries are removed on answer / cancel / /// connection teardown. pending_questions: Arc>>, + /// In-flight Grok `exit_plan_mode` approvals awaiting the user's decision, + /// keyed by the globally-unique `approval_id`. The connection's ext handler + /// parks on the receiver; the answer / cancel path resolves (and removes) the + /// matching sender. Shared across `clone_ref` clones so the connection-facing + /// `register_plan_approval` and the command-facing `answer_plan_approval` + /// touch the same map. At most one per connection (the agent is blocked in + /// its `exit_plan_mode` call) — no cap, no cumulative growth. + pending_plan_approvals: Arc>>, } /// A parked `ask_user_question` awaiting its answer. The `sender` resolves the @@ -221,6 +232,14 @@ struct PendingQuestionEntry { sender: tokio::sync::oneshot::Sender, } +/// A parked Grok `exit_plan_mode` approval awaiting the user's decision. The +/// `sender` resolves the blocked ext-request round-trip in the connection's +/// handler. `parent_connection_id` routes the resolved event + answer. +struct PendingPlanApprovalEntry { + parent_connection_id: String, + sender: tokio::sync::oneshot::Sender, +} + impl Default for ConnectionManager { fn default() -> Self { Self::new() @@ -236,6 +255,7 @@ impl ConnectionManager { delegation_injection: Arc::new(std::sync::OnceLock::new()), probe_locks: Arc::new(Mutex::new(HashMap::new())), pending_questions: Arc::new(Mutex::new(HashMap::new())), + pending_plan_approvals: Arc::new(Mutex::new(HashMap::new())), } } @@ -248,6 +268,7 @@ impl ConnectionManager { delegation_injection: self.delegation_injection.clone(), probe_locks: self.probe_locks.clone(), pending_questions: self.pending_questions.clone(), + pending_plan_approvals: self.pending_plan_approvals.clone(), } } @@ -273,6 +294,7 @@ impl ConnectionManager { delegation_injection: Arc::new(std::sync::OnceLock::new()), probe_locks: Arc::new(Mutex::new(HashMap::new())), pending_questions: Arc::new(Mutex::new(HashMap::new())), + pending_plan_approvals: Arc::new(Mutex::new(HashMap::new())), } } @@ -2220,6 +2242,159 @@ impl ConnectionManager { } } + /// Register a pending Grok `exit_plan_mode` approval on `conn_id`, broadcast + /// `PlanApprovalRequest` (so every attached client renders the card and a + /// mid-turn attach recovers it from the snapshot), and return the receiver + /// the connection's ext handler awaits. `None` when the connection is gone + /// (nothing to approve) OR when one is already pending on it (the agent is + /// blocked in a single `exit_plan_mode` call; a second would orphan the + /// first). Mirrors [`Self::register_question`]. + pub async fn register_plan_approval( + &self, + conn_id: &str, + tool_call_id: String, + plan_markdown: String, + ) -> Option { + let (state, emitter) = self.get_state_and_emitter(conn_id).await?; + let approval_id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = tokio::sync::oneshot::channel(); + { + let mut reg = self.pending_plan_approvals.lock().await; + if reg.values().any(|e| e.parent_connection_id == conn_id) { + return None; + } + reg.insert( + approval_id.clone(), + PendingPlanApprovalEntry { + parent_connection_id: conn_id.to_string(), + sender: tx, + }, + ); + } + // Ungated emit: the agent is blocked in the tool call, so the card must + // show regardless of any turn-flag timing. + emit_with_state( + &state, + &emitter, + AcpEvent::PlanApprovalRequest { + approval_id: approval_id.clone(), + tool_call_id, + plan_markdown, + }, + ) + .await; + // Teardown event-ordering race (mirrors `register_question`): the + // cleanup guard's `cancel_plan_approvals_by_parent` may have drained this + // entry between the insert above and the emit just now — its + // `PlanApprovalResolved` could then have raced ahead of our + // `PlanApprovalRequest`, leaving a card up with no live backend waiter. + // Emit a compensating `PlanApprovalResolved` (ordered after our request) + // and decline. + if self + .compensate_if_plan_approval_drained(&approval_id, &state, &emitter) + .await + { + return None; + } + Some(RegisteredPlanApproval { + approval_id, + answer_rx: rx, + }) + } + + /// Returns `true` — after emitting a clearing `PlanApprovalResolved` — when + /// `approval_id` is no longer pending, i.e. a teardown sweep drained it in the + /// window after its `PlanApprovalRequest` was broadcast. Mirrors + /// [`Self::compensate_if_question_drained`]. + async fn compensate_if_plan_approval_drained( + &self, + approval_id: &str, + state: &std::sync::Arc>, + emitter: &EventEmitter, + ) -> bool { + if self.pending_plan_approvals.lock().await.contains_key(approval_id) { + return false; + } + emit_with_state( + state, + emitter, + AcpEvent::PlanApprovalResolved { + approval_id: approval_id.to_string(), + }, + ) + .await; + true + } + + /// Resolve a pending plan approval with the user's decision (from any + /// client). Removes the one-shot atomically (first answer wins; a duplicate / + /// already-resolved id is an idempotent no-op), sends the decision to the + /// blocked ext handler, and broadcasts `PlanApprovalResolved` so the card + /// clears on every client. Routing uses the entry's stored parent connection + /// (the `approval_id` is the authoritative key), so a stale `conn_id` from the + /// caller can't misroute. Mirrors [`Self::answer_question`]. + pub async fn answer_plan_approval( + &self, + conn_id: &str, + approval_id: &str, + answer: PlanApprovalAnswer, + ) -> Result<(), AcpError> { + let _ = conn_id; + let entry = self.pending_plan_approvals.lock().await.remove(approval_id); + let Some(entry) = entry else { + // Already answered / canceled / gone elsewhere — idempotent success. + return Ok(()); + }; + // Ignore a dropped receiver: the handler may have abandoned the wait + // (teardown) at the same instant; the resolved event below still clears + // the card. + let _ = entry.sender.send(answer); + if let Some((state, emitter)) = + self.get_state_and_emitter(&entry.parent_connection_id).await + { + emit_with_state( + &state, + &emitter, + AcpEvent::PlanApprovalResolved { + approval_id: approval_id.to_string(), + }, + ) + .await; + } + Ok(()) + } + + /// Cancel every pending plan approval parked on a connection that is tearing + /// down (the `run_connection` cleanup guard calls this). Dropping each + /// entry's sender resolves the handler's await as a disconnect (Grok keeps + /// plan mode active, re-surfaces on reconnect); the `PlanApprovalResolved` + /// broadcast clears the card. Mirrors [`Self::cancel_questions_by_parent`]. + pub async fn cancel_plan_approvals_by_parent(&self, conn_id: &str) { + let drained: Vec = { + let mut reg = self.pending_plan_approvals.lock().await; + let ids: Vec = reg + .iter() + .filter(|(_, e)| e.parent_connection_id == conn_id) + .map(|(id, _)| id.clone()) + .collect(); + for id in &ids { + reg.remove(id); + } + ids + }; + if drained.is_empty() { + return; + } + // Best-effort card clear: the connection may already be out of the map + // (disconnect removes it before this sweep), so tolerate `None`. + if let Some((state, emitter)) = self.get_state_and_emitter(conn_id).await { + for approval_id in drained { + emit_with_state(&state, &emitter, AcpEvent::PlanApprovalResolved { approval_id }) + .await; + } + } + } + /// Resolve a conversation_id to its currently-active connection id, if any. /// Used by the by-conversation snapshot endpoint and the LifecycleSubscriber. /// Per-session state is acquired via `read().await` to avoid the @@ -2538,6 +2713,35 @@ impl SessionQuestionAccess for ConnectionManagerQuestionLookup { } } +/// Production impl of [`SessionPlanApprovalAccess`] for the Grok `exit_plan_mode` +/// ext bridge. Registers / cancels the parent connection's pending plan approval +/// by delegating to `ConnectionManager`. Mirrors `ConnectionManagerQuestionLookup` +/// so the connection handler stays unit-testable with an in-memory stub. +#[derive(Clone)] +pub struct ConnectionManagerPlanApprovalLookup { + pub manager: Arc, +} + +#[async_trait::async_trait] +impl SessionPlanApprovalAccess for ConnectionManagerPlanApprovalLookup { + async fn register_plan_approval( + &self, + parent_connection_id: &str, + tool_call_id: String, + plan_markdown: String, + ) -> Option { + self.manager + .register_plan_approval(parent_connection_id, tool_call_id, plan_markdown) + .await + } + + async fn cancel_plan_approvals_by_parent(&self, parent_connection_id: &str) { + self.manager + .cancel_plan_approvals_by_parent(parent_connection_id) + .await + } +} + #[cfg(test)] mod tests { use super::*; @@ -5509,6 +5713,104 @@ mod tests { assert!(reg_b.answer_rx.await.is_ok()); } + #[tokio::test] + async fn register_then_answer_plan_approval_resolves_and_clears() { + let mgr = ConnectionManager::new(); + mgr.insert_test_connection("pa", AgentType::Grok, None, EventEmitter::Noop) + .await; + let reg = mgr + .register_plan_approval("pa", "call-1".into(), "# Plan\n- step".into()) + .await + .expect("registered"); + // SessionState reflects the pending approval for snapshot recovery. + { + let state = mgr.get_state("pa").await.unwrap(); + let guard = state.read().await; + let pending = guard.pending_plan_approval.as_ref().expect("pending set"); + assert_eq!(pending.plan_markdown, "# Plan\n- step"); + assert_eq!(pending.tool_call_id, "call-1"); + } + + mgr.answer_plan_approval( + "pa", + ®.approval_id, + crate::acp::plan_approval::PlanApprovalAnswer { + decision: crate::acp::plan_approval::PlanApprovalDecision::RequestChanges, + feedback: Some("use SSE".into()), + }, + ) + .await + .unwrap(); + + // The blocked ext handler's receiver resolves with the user's decision. + let got = reg.answer_rx.await.expect("answer delivered"); + assert_eq!( + got.decision, + crate::acp::plan_approval::PlanApprovalDecision::RequestChanges + ); + assert_eq!(got.feedback.as_deref(), Some("use SSE")); + // pending_plan_approval cleared after resolve. + assert!(mgr + .get_state("pa") + .await + .unwrap() + .read() + .await + .pending_plan_approval + .is_none()); + + // Idempotent: answering an already-resolved id is a no-op success. + mgr.answer_plan_approval( + "pa", + ®.approval_id, + crate::acp::plan_approval::PlanApprovalAnswer { + decision: crate::acp::plan_approval::PlanApprovalDecision::Approve, + feedback: None, + }, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn register_plan_approval_refuses_second_pending_on_same_connection() { + // At most one approval per connection (the agent is blocked in exit_plan_mode). + let mgr = ConnectionManager::new(); + mgr.insert_test_connection("pa2", AgentType::Grok, None, EventEmitter::Noop) + .await; + let _reg = mgr + .register_plan_approval("pa2", "c1".into(), "plan".into()) + .await + .expect("first registers"); + assert!(mgr + .register_plan_approval("pa2", "c2".into(), "plan2".into()) + .await + .is_none()); + } + + #[tokio::test] + async fn cancel_plan_approvals_by_parent_drops_sender_and_clears() { + let mgr = ConnectionManager::new(); + mgr.insert_test_connection("pax", AgentType::Grok, None, EventEmitter::Noop) + .await; + let reg = mgr + .register_plan_approval("pax", "c".into(), "plan".into()) + .await + .unwrap(); + mgr.cancel_plan_approvals_by_parent("pax").await; + // Dropping the sender surfaces to the parked handler as a recv error + // (which it renders as a disconnect reply — plan mode stays active). + assert!(reg.answer_rx.await.is_err()); + assert!(mgr + .get_state("pax") + .await + .unwrap() + .read() + .await + .pending_plan_approval + .is_none()); + } + #[tokio::test] async fn compensate_clears_card_when_entry_drained_before_request_emit() { // Regression for the teardown event-ordering race: register inserts, the diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index e2565f193..25007023b 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -16,6 +16,7 @@ pub mod lifecycle; pub mod manager; pub mod opencode_catalog; pub mod opencode_plugins; +pub mod plan_approval; pub mod preflight; pub mod question; pub mod registry; diff --git a/src-tauri/src/acp/plan_approval.rs b/src-tauri/src/acp/plan_approval.rs new file mode 100644 index 000000000..87726e1a7 --- /dev/null +++ b/src-tauri/src/acp/plan_approval.rs @@ -0,0 +1,306 @@ +//! Grok plan-mode approval ("exit plan mode") domain types. +//! +//! When a Grok session is in **plan mode** and the agent finishes planning, it +//! calls its native `exit_plan_mode` tool. That tool reads `plan.md` from disk +//! and issues a BLOCKING ACP ext request (`_x.ai/exit_plan_mode`, +//! `ExitPlanModeExtRequest { sessionId, toolCallId, planContent }`) to the client +//! to get the user's approval before leaving plan mode. Grok waits for the reply. +//! +//! Codeg bridges that ext request into an interactive **plan-approval card** +//! rendered above the composer — the SAME shape as the `ask_user_question` bridge +//! ([`crate::acp::question`]) and the permission dialog: a pending request is +//! captured onto [`crate::acp::session_state::SessionState`] (in-memory, turn +//! scoped, carried on `to_snapshot()` so a mid-turn attach re-renders the card), +//! and the blocked ext request is answered once the user picks an action. +//! +//! Three outcomes exist (per Grok's binary + `~/.grok/docs/user-guide/19-plan-mode.md`): +//! * **Approve** — leave plan mode and start implementing. +//! * **Request changes** — freeform revision notes; the agent revises and plan +//! mode stays active (the user can iterate). +//! * **Abandon** — abandon the plan and turn plan mode off. +//! +//! ## Wire-format note +//! +//! The request shape (`planContent` / `toolCallId`) is high-confidence. The +//! reply `ExitPlanModeExtResponse` is a 2-field struct (a decision + optional +//! feedback) whose exact serde encoding is INFERRED from binary analysis, not yet +//! captured from a live run (the same state `ask_user_question` was in before it +//! was confirmed against Grok 0.2.101). [`build_grok_exit_plan_response`] is the +//! single isolated function to correct once the first real run confirms the shape; +//! a wrong reply only fails Grok's tool (plan mode stays active, reappears on +//! reconnect) — strictly better and recoverable versus the current silent stall. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::oneshot; + +/// Per-field sanity bound (characters) for the plan markdown carried in the +/// request event + snapshot. The plan rides the broadcast `PlanApprovalRequest` +/// and every snapshot, so this caps the blast radius of a pathological payload. +/// Generous — a real `plan.md` is far smaller. +pub const MAX_PLAN_MARKDOWN_CHARS: usize = 262_144; +/// Per-field sanity bound (characters) for the user's freeform "request changes" +/// feedback, echoed back to Grok. Generous enough for real revision notes. +pub const MAX_FEEDBACK_CHARS: usize = 16_384; + +/// The pending (awaiting-decision) plan approval stored on +/// `SessionState.pending_plan_approval` and carried on `to_snapshot()` so a +/// client attaching mid-turn (cold attach, reconnect, another window) re-renders +/// the approval card even though the one-shot `PlanApprovalRequest` event won't +/// replay for it. At most one is pending per connection (the agent is blocked in +/// its `exit_plan_mode` tool call). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PendingPlanApprovalState { + /// Backend-minted correlation key for the answer (NOT Grok's toolCallId). + pub approval_id: String, + /// Grok's `toolCallId` for the `exit_plan_mode` call, so the synthesized + /// in-stream result card can key on the same id as the (suppressed) native + /// tool call. Empty when the request omitted it. + pub tool_call_id: String, + /// The plan markdown read from `plan.md` (Grok's `planContent`). May be empty + /// — an empty/missing plan still opens the approval surface with an + /// empty-state notice (per Grok's plan-mode doc). + pub plan_markdown: String, + pub created_at: DateTime, +} + +/// Which action the user took on the plan-approval card. `snake_case` on the +/// wire (`approve` / `request_changes` / `abandon`) — constructed by the frontend. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PlanApprovalDecision { + /// Approve the plan → Grok leaves plan mode and starts implementing. + Approve, + /// Request changes → Grok revises the plan; plan mode stays active. + RequestChanges, + /// Abandon the plan → plan mode is turned off. + Abandon, +} + +/// The user's submission for a pending plan approval (frontend → backend → the +/// blocked ext request). `feedback` carries the freeform revision notes for +/// `RequestChanges` (ignored / empty for the other decisions). camelCase on the +/// wire — this is constructed by the frontend, not read from an event payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanApprovalAnswer { + pub decision: PlanApprovalDecision, + #[serde(default)] + pub feedback: Option, +} + +impl PlanApprovalAnswer { + /// The trimmed, length-bounded feedback text (empty when none / whitespace). + /// Applied on the answer side so a hand-rolled client hitting the plain + /// `acp_answer_plan_approval` endpoint directly can't ride an unbounded blob + /// back to Grok. + pub fn normalized_feedback(&self) -> String { + self.feedback + .as_deref() + .unwrap_or("") + .trim() + .chars() + .take(MAX_FEEDBACK_CHARS) + .collect() + } +} + +/// What [`SessionPlanApprovalAccess::register_plan_approval`] hands back to the +/// connection's `exit_plan_mode` ext handler: the new approval id plus the +/// receiver to await the user's decision on. Mirrors +/// [`crate::acp::question::RegisteredQuestion`]. +pub struct RegisteredPlanApproval { + pub approval_id: String, + pub answer_rx: oneshot::Receiver, +} + +/// Connection-facing access to register / cancel a pending plan approval on a +/// parent connection. The production impl +/// (`crate::acp::manager::ConnectionManagerPlanApprovalLookup`) wraps the +/// `ConnectionManager`; tests use an in-memory stub. Mirrors +/// [`crate::acp::question::SessionQuestionAccess`] — kept in this neutral module +/// so `connection.rs` depends only on the trait, not on `manager.rs`. +#[async_trait] +pub trait SessionPlanApprovalAccess: Send + Sync { + /// Register a pending plan approval on the parent connection (resolved from + /// the connection id), broadcast `PlanApprovalRequest` to every attached + /// client, and return a receiver that resolves when the user picks an action + /// (or the approval is canceled). `None` when the connection is gone or one + /// is already pending on it — the ext handler then declines to Grok, which + /// keeps plan mode active. + async fn register_plan_approval( + &self, + parent_connection_id: &str, + tool_call_id: String, + plan_markdown: String, + ) -> Option; + + /// Cancel every pending plan approval parked on a connection that is tearing + /// down. Called from the `run_connection` cleanup guard so a pending approval + /// — and the ext handler task parked on it — is reclaimed synchronously on + /// disconnect. Dropping the sender resolves the handler's await as a + /// disconnect (Grok keeps plan mode active, re-surfaces on reconnect). + async fn cancel_plan_approvals_by_parent(&self, parent_connection_id: &str); +} + +/// Parse Grok's `_x.ai/exit_plan_mode` ext-request params into the plan markdown +/// and `toolCallId` codeg needs to render the approval card. Grok's wire shape is +/// `{ sessionId, toolCallId, planContent }`. Lenient: an empty / missing +/// `planContent` is valid (the empty-plan approval surface), so the only hard +/// error is a non-object payload. The markdown is length-bounded to +/// [`MAX_PLAN_MARKDOWN_CHARS`]. +pub fn parse_grok_exit_plan_request(params: &Value) -> Result<(String, String), String> { + let obj = params + .as_object() + .ok_or_else(|| "exit_plan_mode ext request params is not an object".to_string())?; + let plan_markdown: String = obj + .get("planContent") + .and_then(|v| v.as_str()) + .unwrap_or("") + .chars() + .take(MAX_PLAN_MARKDOWN_CHARS) + .collect(); + let tool_call_id = obj + .get("toolCallId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok((plan_markdown, tool_call_id)) +} + +/// Serialize the user's decision into Grok's `ExitPlanModeExtResponse` — the +/// reply to a blocked `_x.ai/exit_plan_mode` ext request. +/// +/// **Verify-on-real-run function.** The reply is a 2-field struct; the exact +/// serde field names/variant strings are inferred (`{ decision, feedback }` with +/// `approve` / `request_changes` / `abandon`) and should be confirmed against the +/// `[grok exit_plan]` request log on the first real run, then corrected HERE if +/// Grok rejects the shape. Everything else in the pipeline is shape-agnostic, so +/// this is the only place to touch. A wrong reply fails Grok's tool (plan mode +/// stays active) rather than corrupting state. +pub fn build_grok_exit_plan_response(answer: &PlanApprovalAnswer) -> Value { + let decision = match answer.decision { + PlanApprovalDecision::Approve => "approve", + PlanApprovalDecision::RequestChanges => "request_changes", + PlanApprovalDecision::Abandon => "abandon", + }; + serde_json::json!({ + "decision": decision, + "feedback": answer.normalized_feedback(), + }) +} + +/// The `_x.ai/exit_plan_mode` reply for a connection tearing down mid-approval +/// (the parked responder is drained without a user decision). Mirrors Grok's own +/// "client disconnected mid-approval; plan mode stays active" behavior: report an +/// error-shaped reply so Grok keeps plan mode active and re-surfaces the approval +/// on reconnect, rather than silently proceeding as if approved. +pub fn grok_exit_plan_disconnect_response() -> Value { + serde_json::json!({ "decision": "cancelled", "feedback": "" }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn grok_params(plan: &str) -> Value { + json!({ + "sessionId": "s-1", + "toolCallId": "call-42", + "planContent": plan, + }) + } + + #[test] + fn parse_reads_plan_and_tool_call_id() { + let (plan, tc) = parse_grok_exit_plan_request(&grok_params("# Plan\n- step")).unwrap(); + assert_eq!(plan, "# Plan\n- step"); + assert_eq!(tc, "call-42"); + } + + #[test] + fn parse_accepts_empty_or_missing_plan() { + let (plan, tc) = parse_grok_exit_plan_request(&grok_params("")).unwrap(); + assert_eq!(plan, ""); + assert_eq!(tc, "call-42"); + // Missing planContent + toolCallId → empty strings, still Ok. + let (plan, tc) = + parse_grok_exit_plan_request(&json!({ "sessionId": "s-1" })).unwrap(); + assert!(plan.is_empty()); + assert!(tc.is_empty()); + } + + #[test] + fn parse_rejects_non_object() { + assert!(parse_grok_exit_plan_request(&json!("nope")).is_err()); + assert!(parse_grok_exit_plan_request(&json!([1, 2, 3])).is_err()); + } + + #[test] + fn parse_bounds_plan_markdown() { + let huge = "x".repeat(MAX_PLAN_MARKDOWN_CHARS + 500); + let (plan, _) = parse_grok_exit_plan_request(&grok_params(&huge)).unwrap(); + assert_eq!(plan.chars().count(), MAX_PLAN_MARKDOWN_CHARS); + } + + #[test] + fn build_response_maps_each_decision() { + let approve = PlanApprovalAnswer { + decision: PlanApprovalDecision::Approve, + feedback: None, + }; + assert_eq!(build_grok_exit_plan_response(&approve)["decision"], "approve"); + assert_eq!(build_grok_exit_plan_response(&approve)["feedback"], ""); + + let changes = PlanApprovalAnswer { + decision: PlanApprovalDecision::RequestChanges, + feedback: Some(" use SSE instead ".into()), + }; + let v = build_grok_exit_plan_response(&changes); + assert_eq!(v["decision"], "request_changes"); + // Feedback is trimmed. + assert_eq!(v["feedback"], "use SSE instead"); + + let abandon = PlanApprovalAnswer { + decision: PlanApprovalDecision::Abandon, + feedback: None, + }; + assert_eq!(build_grok_exit_plan_response(&abandon)["decision"], "abandon"); + } + + #[test] + fn feedback_is_bounded() { + let huge = "y".repeat(MAX_FEEDBACK_CHARS + 100); + let answer = PlanApprovalAnswer { + decision: PlanApprovalDecision::RequestChanges, + feedback: Some(huge), + }; + assert_eq!( + answer.normalized_feedback().chars().count(), + MAX_FEEDBACK_CHARS + ); + } + + #[test] + fn answer_deserializes_from_camel_case_wire() { + let a: PlanApprovalAnswer = + serde_json::from_value(json!({ "decision": "request_changes", "feedback": "x" })) + .unwrap(); + assert_eq!(a.decision, PlanApprovalDecision::RequestChanges); + assert_eq!(a.feedback.as_deref(), Some("x")); + // feedback optional. + let a: PlanApprovalAnswer = + serde_json::from_value(json!({ "decision": "approve" })).unwrap(); + assert_eq!(a.decision, PlanApprovalDecision::Approve); + assert!(a.feedback.is_none()); + } + + #[test] + fn disconnect_response_is_not_an_approval() { + let v = grok_exit_plan_disconnect_response(); + assert_ne!(v["decision"], "approve"); + } +} diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 449bd6e96..bc3ddc53f 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::acp::event_stream::{ConnectionEventStream, RecentEventsBuffer}; use crate::acp::feedback::{FeedbackItem, FeedbackStatus}; +use crate::acp::plan_approval::PendingPlanApprovalState; use crate::acp::question::PendingQuestionState; use crate::acp::types::{ AcpEvent, AvailableCommandInfo, ConfigStaleKind, ConnectionStatus, EventEnvelope, @@ -247,6 +248,16 @@ pub struct SessionState { /// the backend's `pending_questions` registry keys the answer one-shot. pub pending_question: Option, + /// The agent's in-flight Grok `exit_plan_mode` approval (the plan awaiting the + /// user's Approve / Request-changes / Abandon decision). Set by + /// `PlanApprovalRequest`, cleared by a matching `PlanApprovalResolved` (and + /// defensively on `TurnComplete`). Carried on `to_snapshot()` so a client + /// attaching mid-turn re-renders the approval card the one-shot event won't + /// replay for it. At most one is pending (the agent is blocked in its + /// `exit_plan_mode` tool call); the connection parks the ext responder keyed + /// by `approval_id`. + pub pending_plan_approval: Option, + /// In-flight (running) sub-agent delegations keyed by `parent_tool_use_id`. /// `DelegationStarted` inserts; `DelegationCompleted` removes. UNLIKE /// `active_tool_calls`, NOT cleared on `TurnComplete` (an async delegation @@ -446,6 +457,7 @@ impl SessionState { active_tool_calls: BTreeMap::new(), pending_permission: None, pending_question: None, + pending_plan_approval: None, active_delegations: BTreeMap::new(), feedback: Vec::new(), background_outstanding: 0, @@ -713,6 +725,29 @@ impl SessionState { self.pending_question = None; } } + AcpEvent::PlanApprovalRequest { + approval_id, + tool_call_id, + plan_markdown, + } => { + self.pending_plan_approval = Some(PendingPlanApprovalState { + approval_id: approval_id.clone(), + tool_call_id: tool_call_id.clone(), + plan_markdown: plan_markdown.clone(), + created_at: Utc::now(), + }); + } + AcpEvent::PlanApprovalResolved { approval_id } => { + // Mirror `QuestionResolved`: only clear when the resolved id + // matches the current one, so a late event for an already- + // replaced approval can't wipe a live card from under the user. + if matches!( + &self.pending_plan_approval, + Some(p) if p.approval_id == *approval_id, + ) { + self.pending_plan_approval = None; + } + } AcpEvent::TurnComplete { stop_reason, .. } => { // Diagnostic only (no behavior change): pairs with the // StatusChanged log above. This is the ACTUAL point the turn @@ -788,6 +823,10 @@ impl SessionState { // answer one-shot is cleaned via the listener's peer-close race; // this just keeps the snapshot honest. self.pending_question = None; + // Likewise a blocked `exit_plan_mode` approval: the parked ext + // responder is drained by the connection's teardown/cancel path; + // this just keeps the snapshot honest if the turn settles first. + self.pending_plan_approval = None; self.status = ConnectionStatus::Connected; } AcpEvent::UserMessage { message_id, blocks } => { @@ -820,6 +859,11 @@ impl SessionState { self.feedback.clear(); // A new user turn supersedes any stale pending question. self.pending_question = None; + // Likewise a stale plan approval: a new turn started without a + // clean TurnComplete (fork/resume re-prompt, error recovery, or a + // queued prompt sent instead of answering) must not leave a dead + // approval in the snapshot for a mid-turn attach to render. + self.pending_plan_approval = None; } AcpEvent::ConversationLinked { conversation_id, @@ -1214,6 +1258,7 @@ impl SessionState { active_tool_calls: self.active_tool_calls.values().cloned().collect(), pending_permission: self.pending_permission.clone(), pending_question: self.pending_question.clone(), + pending_plan_approval: self.pending_plan_approval.clone(), pending_user_message: self.pending_user_message.clone(), active_delegations: self.active_delegations.values().cloned().collect(), feedback: self.feedback.clone(), @@ -1269,6 +1314,13 @@ pub struct LiveSessionSnapshot { /// the wire so every snapshot stays byte-identical with the pre-feature shape. #[serde(default, skip_serializing_if = "Option::is_none")] pub pending_question: Option, + /// The agent's in-flight Grok `exit_plan_mode` approval (see + /// `SessionState.pending_plan_approval`). `#[serde(default)]` so older + /// payloads deserialize; `skip_serializing_if` keeps the common no-approval + /// case off the wire so every snapshot stays byte-identical with the + /// pre-feature shape. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_plan_approval: Option, /// The in-flight user prompt for the current turn (see /// `SessionState.pending_user_message`). `#[serde(default)]` so older /// payloads still deserialize; `skip_serializing_if` so the no-pending case @@ -1433,6 +1485,71 @@ mod tests { ) } + #[test] + fn plan_approval_applies_clears_by_id_and_survives_snapshot() { + let mut s = fresh_state(); + // Request → pending set + carried on the snapshot for mid-turn attach. + s.apply_event(&AcpEvent::PlanApprovalRequest { + approval_id: "ap-1".into(), + tool_call_id: "call-1".into(), + plan_markdown: "# Plan".into(), + }); + let pending = s.pending_plan_approval.clone().expect("pending set"); + assert_eq!(pending.approval_id, "ap-1"); + assert_eq!(pending.tool_call_id, "call-1"); + assert_eq!(pending.plan_markdown, "# Plan"); + assert!(s.to_snapshot().pending_plan_approval.is_some()); + + // A resolve for a DIFFERENT id must not wipe the live approval. + s.apply_event(&AcpEvent::PlanApprovalResolved { + approval_id: "other".into(), + }); + assert!(s.pending_plan_approval.is_some()); + + // Matching resolve clears it (and the snapshot). + s.apply_event(&AcpEvent::PlanApprovalResolved { + approval_id: "ap-1".into(), + }); + assert!(s.pending_plan_approval.is_none()); + assert!(s.to_snapshot().pending_plan_approval.is_none()); + } + + #[test] + fn turn_complete_clears_pending_plan_approval() { + let mut s = fresh_state(); + s.apply_event(&AcpEvent::PlanApprovalRequest { + approval_id: "ap-1".into(), + tool_call_id: "c".into(), + plan_markdown: String::new(), + }); + assert!(s.pending_plan_approval.is_some()); + s.apply_event(&AcpEvent::TurnComplete { + session_id: "sid".into(), + stop_reason: "end_turn".into(), + agent_type: "grok".into(), + }); + assert!(s.pending_plan_approval.is_none()); + } + + #[test] + fn user_message_supersedes_stale_pending_plan_approval() { + // A new turn starting without a clean TurnComplete (fork/resume re-prompt, + // queued prompt sent instead of answering) must not leave a dead approval + // in the snapshot for a mid-turn attach to render. + let mut s = fresh_state(); + s.apply_event(&AcpEvent::PlanApprovalRequest { + approval_id: "ap-1".into(), + tool_call_id: "c".into(), + plan_markdown: "# plan".into(), + }); + assert!(s.pending_plan_approval.is_some()); + s.apply_event(&AcpEvent::UserMessage { + message_id: "m1".into(), + blocks: vec![], + }); + assert!(s.pending_plan_approval.is_none()); + } + #[test] fn background_activity_mirrors_outstanding_and_gates_keepalive() { let mut s = fresh_state(); diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index 925220f18..282ff42f3 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -331,6 +331,23 @@ pub enum AcpEvent { /// (the tool call was aborted / the connection drained). Carries only the /// `question_id`; clients clear the matching card. Idempotent on apply. QuestionResolved { question_id: String }, + /// A Grok `exit_plan_mode` call: the agent finished planning and is BLOCKED + /// on the user's approval of the plan before it leaves plan mode and starts + /// implementing (Grok's native `_x.ai/exit_plan_mode` ext request). Broadcast + /// so every client viewing this conversation renders the interactive + /// plan-approval card above the input box, and captured into + /// `SessionState.pending_plan_approval` so a client attaching mid-turn (cold + /// attach, reconnect, another window) recovers it from the snapshot. The + /// backend parks the blocked ext-request responder keyed by `approval_id`. + PlanApprovalRequest { + approval_id: String, + tool_call_id: String, + plan_markdown: String, + }, + /// A previously-pending plan approval was answered (from any client) or + /// canceled (the connection drained). Carries only the `approval_id`; clients + /// clear the matching card. Idempotent on apply. + PlanApprovalResolved { approval_id: String }, /// The agent's effective settings (env vars / model provider / native config /// files) changed AFTER this connection was spawned, so the running process /// is still using its launch-time config. Emitted by diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 7d43d873e..dea9673b0 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -170,6 +170,11 @@ pub fn build_delegation_stack( questions: Arc::new(crate::acp::manager::ConnectionManagerQuestionLookup { manager: Arc::new(connection_manager.clone_ref()), }) as Arc, + // Grok `exit_plan_mode` bridge — always wired (native plan mode, no + // feature flag), same backing manager as the question lookup. + plan_approvals: Arc::new(crate::acp::manager::ConnectionManagerPlanApprovalLookup { + manager: Arc::new(connection_manager.clone_ref()), + }) as Arc, }); (broker, tokens, socket_path, feedback, ask, sessions) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index ca58260d0..5f711fd3b 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -7702,6 +7702,19 @@ pub async fn acp_answer_question( .await } +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn acp_answer_plan_approval( + connection_id: String, + approval_id: String, + answer: crate::acp::plan_approval::PlanApprovalAnswer, + manager: State<'_, ConnectionManager>, +) -> Result<(), AcpError> { + manager + .answer_plan_approval(&connection_id, &approval_id, answer) + .await +} + #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn acp_disconnect( diff --git a/src-tauri/src/db/service/import_service.rs b/src-tauri/src/db/service/import_service.rs index 7a7de8725..bba9510f3 100644 --- a/src-tauri/src/db/service/import_service.rs +++ b/src-tauri/src/db/service/import_service.rs @@ -390,7 +390,9 @@ mod tests { .expect("get"); // Imported rows are surfaced for review, not marked done — so they stay // visible even when the sidebar hides completed conversations. - assert_eq!(got.status, conversation::ConversationStatus::PendingReview); + // `DbConversationSummary.status` is the serde-serialized string + // (`rename_all = "snake_case"`), not the enum. + assert_eq!(got.status, "pending_review"); } #[tokio::test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e48efa3c..d811eace8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1093,6 +1093,7 @@ mod tauri_app { acp_commands::acp_fork, acp_commands::acp_respond_permission, acp_commands::acp_answer_question, + acp_commands::acp_answer_plan_approval, acp_commands::acp_disconnect, acp_commands::acp_touch_connection, acp_commands::acp_list_connections, diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 2a536b992..3b5592fd8 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -469,6 +469,26 @@ pub async fn acp_answer_question( Ok(Json(())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpAnswerPlanApprovalParams { + pub connection_id: String, + pub approval_id: String, + pub answer: crate::acp::plan_approval::PlanApprovalAnswer, +} + +pub async fn acp_answer_plan_approval( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let manager = &state.connection_manager; + manager + .answer_plan_approval(¶ms.connection_id, ¶ms.approval_id, params.answer) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(())) +} + pub async fn acp_list_connections( Extension(state): Extension>, ) -> Result>, AppCommandError> { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 249d1b17f..d79d188d9 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -628,6 +628,10 @@ pub fn build_router( "/acp_answer_question", post(handlers::acp::acp_answer_question), ) + .route( + "/acp_answer_plan_approval", + post(handlers::acp::acp_answer_plan_approval), + ) .route( "/acp_list_connections", post(handlers::acp::acp_list_connections), diff --git a/src/components/chat/conversation-shell.tsx b/src/components/chat/conversation-shell.tsx index c976af0bf..2ccf9775a 100644 --- a/src/components/chat/conversation-shell.tsx +++ b/src/components/chat/conversation-shell.tsx @@ -3,7 +3,9 @@ import { useTranslations } from "next-intl" import type { AgentType, ConnectionStatus, + PendingPlanApprovalState, PendingQuestionState, + PlanApprovalAnswer, PromptCapabilitiesInfo, PromptDraft, PromptInputBlock, @@ -23,6 +25,7 @@ import { ChatInput } from "@/components/chat/chat-input" import { PermissionDialog } from "@/components/chat/permission-dialog" import { QuestionDialog } from "@/components/chat/question-dialog" import { AskQuestionCard } from "@/components/chat/ask-question-card" +import { PlanApprovalCard } from "@/components/chat/plan-approval-card" interface ConversationShellProps { status: ConnectionStatus | null @@ -35,6 +38,8 @@ interface ConversationShellProps { pendingQuestion: PendingQuestion | null /** Awaiting-answer multiple-choice `ask_user_question`. */ pendingAskQuestion: PendingQuestionState | null + /** Awaiting-decision Grok `exit_plan_mode` approval. */ + pendingPlanApproval: PendingPlanApprovalState | null onFocus: () => void onSend: (draft: PromptDraft, modeId?: string | null) => void onCancel: () => void @@ -44,6 +49,10 @@ interface ConversationShellProps { questionId: string, answer: QuestionAnswer ) => void | Promise + onAnswerPlanApproval: ( + approvalId: string, + answer: PlanApprovalAnswer + ) => void | Promise children: ReactNode modes?: SessionModeInfo[] configOptions?: SessionConfigOptionInfo[] @@ -99,12 +108,14 @@ export function ConversationShell({ pendingPermission, pendingQuestion, pendingAskQuestion, + pendingPlanApproval, onFocus, onSend, onCancel, onRespondPermission, onAnswerQuestion, onAnswerAskQuestion, + onAnswerPlanApproval, children, modes, configOptions, @@ -219,6 +230,17 @@ export function ConversationShell({ />
)} + {pendingPlanApproval && ( +
+ {/* key on approval_id so the card always remounts (fresh in-flight / + feedback state) if the slot is ever reused for a new approval. */} + +
+ )} {!hideInput && feedbackList && (
{feedbackList}
diff --git a/src/components/chat/plan-approval-card.test.tsx b/src/components/chat/plan-approval-card.test.tsx new file mode 100644 index 000000000..085c1904a --- /dev/null +++ b/src/components/chat/plan-approval-card.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { describe, expect, it, vi } from "vitest" + +// Exercise the real Streamdown pipeline for the plan markdown; only the +// link-safety hook is stubbed (no bearing on plan rendering), mirroring +// plan-mode-card.test.tsx. +vi.mock("@/components/ai-elements/link-safety", () => ({ + useStreamdownLinkSafety: () => ({ enabled: false }), +})) + +import { PlanApprovalCard } from "./plan-approval-card" +import enMessages from "@/i18n/messages/en.json" +import type { PendingPlanApprovalState, PlanApprovalAnswer } from "@/lib/types" + +function make(plan: string): PendingPlanApprovalState { + return { + approval_id: "ap-1", + tool_call_id: "call-1", + plan_markdown: plan, + created_at: "2026-01-01T00:00:00Z", + } +} + +function renderCard( + approval: PendingPlanApprovalState, + onAnswer: ( + approvalId: string, + answer: PlanApprovalAnswer + ) => void | Promise = vi.fn() +) { + render( + + + + ) + return onAnswer +} + +describe("PlanApprovalCard", () => { + it("renders the plan markdown and the three actions", async () => { + renderCard(make("# Migration plan\n\n- step one")) + await waitFor(() => + expect(screen.getByText("Migration plan")).toBeInTheDocument() + ) + expect( + screen.getByRole("button", { name: "Approve & build" }) + ).toBeInTheDocument() + expect( + screen.getByRole("button", { name: "Request changes" }) + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Abandon" })).toBeInTheDocument() + }) + + it("approves with an approve decision", () => { + const onAnswer = renderCard(make("plan")) + fireEvent.click(screen.getByRole("button", { name: "Approve & build" })) + expect(onAnswer).toHaveBeenCalledWith("ap-1", { + decision: "approve", + feedback: null, + }) + }) + + it("abandons with an abandon decision", () => { + const onAnswer = renderCard(make("plan")) + fireEvent.click(screen.getByRole("button", { name: "Abandon" })) + expect(onAnswer).toHaveBeenCalledWith("ap-1", { + decision: "abandon", + feedback: null, + }) + }) + + it("request-changes reveals a textarea and submits the trimmed feedback", () => { + const onAnswer = renderCard(make("plan")) + // Not requested yet: no feedback textarea. + expect(screen.queryByPlaceholderText("What should change?")).toBeNull() + + fireEvent.click(screen.getByRole("button", { name: "Request changes" })) + const textarea = screen.getByPlaceholderText("What should change?") + + // Send stays disabled until non-empty feedback is typed. + const send = screen.getByRole("button", { name: "Send" }) + expect(send).toBeDisabled() + + fireEvent.change(textarea, { target: { value: " use SSE " } }) + expect(send).not.toBeDisabled() + fireEvent.click(send) + expect(onAnswer).toHaveBeenCalledWith("ap-1", { + decision: "request_changes", + feedback: "use SSE", + }) + }) + + it("shows the empty-plan notice when no plan was written", () => { + renderCard(make(" ")) + expect( + screen.getByText( + "The agent didn't write a plan. Approve to start building, or request changes." + ) + ).toBeInTheDocument() + // Actions still available so the user can approve or push back. + expect( + screen.getByRole("button", { name: "Approve & build" }) + ).toBeInTheDocument() + }) +}) diff --git a/src/components/chat/plan-approval-card.tsx b/src/components/chat/plan-approval-card.tsx new file mode 100644 index 000000000..ba3bc3f4d --- /dev/null +++ b/src/components/chat/plan-approval-card.tsx @@ -0,0 +1,146 @@ +"use client" + +import { useRef, useState } from "react" +import { useTranslations } from "next-intl" +import { ListTodoIcon, Loader2 } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { MessageResponse } from "@/components/ai-elements/message" +import type { + PendingPlanApprovalState, + PlanApprovalAnswer, + PlanApprovalDecision, +} from "@/lib/types" + +interface PlanApprovalCardProps { + /** The awaiting-decision plan approval. The shell renders this card only when + * an approval is pending, so the prop is always present. */ + approval: PendingPlanApprovalState + /** Resolves the parked `exit_plan_mode` ext request. Returns a promise so the + * card can hold an in-flight state and surface a retryable error on failure. */ + onAnswer: ( + approvalId: string, + answer: PlanApprovalAnswer + ) => void | Promise +} + +/** + * Grok plan-mode approval card. When the agent finishes planning it calls + * `exit_plan_mode` and BLOCKS on the user's decision; this renders the plan above + * the composer with Approve / Request-changes / Abandon actions (mirroring Grok's + * own TUI approval bar). "Request changes" reveals a textarea for freeform + * revision notes. Modeled on `AskQuestionCard`'s in-flight/disable pattern: on + * success the backend's `plan_approval_resolved` clears `pendingPlanApproval` and + * unmounts this card, so controls stay disabled rather than flashing back on. + */ +export function PlanApprovalCard({ + approval, + onAnswer, +}: PlanApprovalCardProps) { + const t = useTranslations("Folder.chat.planApproval") + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(false) + const [changesOpen, setChangesOpen] = useState(false) + const [feedback, setFeedback] = useState("") + const inFlight = useRef(false) + + const plan = approval.plan_markdown.trim() + + // Run a decision round-trip, holding the card in an in-flight state until it + // resolves. On failure re-enable and surface a retryable error. + const run = async (answer: PlanApprovalAnswer) => { + if (inFlight.current) return + inFlight.current = true + setSubmitting(true) + setError(false) + try { + await onAnswer(approval.approval_id, answer) + inFlight.current = false + } catch { + setError(true) + setSubmitting(false) + inFlight.current = false + } + } + + const decide = (decision: PlanApprovalDecision, fb?: string) => + void run({ decision, feedback: fb ?? null }) + + const locked = submitting + + return ( +
+
+ + {t("title")} +
+ + {plan ? ( +
+ {plan} +
+ ) : ( +

+ {t("emptyPlan")} +

+ )} + + {changesOpen ? ( +
+