diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 656a127b8e..7cbe8c8b7f 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -2951,6 +2951,8 @@ 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; + // Same reclaim for a parked `render_page` answer. + inj.renders.cancel_render_pages_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 @@ -7687,6 +7689,10 @@ pub struct DelegationInjection { /// of the three is on, and the companion's `--features` lists `ask` to expose /// the `ask_user_question` tool. pub ask: crate::acp::question::QuestionRuntimeConfig, + /// Hot-swappable "is render-page enabled?" flag. Read at injection time + /// alongside the others so `codeg-mcp` is injected when ANY is on, and the + /// companion's `--features` lists `render` to expose the `render_page` tool. + pub render: crate::acp::render_page::RenderPageRuntimeConfig, /// Hot-swappable "is get-session-info enabled?" flag. Read at injection time /// alongside the other three so `codeg-mcp` is injected when ANY of the four /// is on, and the companion's `--features` lists `sessions` to expose the @@ -7715,6 +7721,11 @@ pub struct DelegationInjection { /// the delegation `broker.cancel_by_parent` cleanup. Shares the same backing /// `ConnectionManager` as the listener's question lookup. pub questions: Arc, + /// Rendered-page registry handle for the teardown cascade — the awaiting- + /// answer half of `render_page` parks a listener round-trip exactly like a + /// pending ask, so the same sweeps (`run_connection` cleanup guard, turn + /// exit, steer, cancel) call `cancel_render_pages_by_parent` through this. + pub renders: 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 @@ -7813,6 +7824,10 @@ struct CompanionFeatureFlags { delegation: bool, feedback: bool, ask: bool, + /// `render_page`, gated by its own setting. Its own flag, not folded into + /// `ask`: the ask card and the agent-authored page are different effects on + /// the user's chat, and settings describe effects. + render: bool, sessions: bool, /// Per-spawn (task-engine launches only), not a settings toggle — on its own /// it still injects the companion so a task session always has its reporting @@ -7860,6 +7875,9 @@ fn companion_features_arg(flags: CompanionFeatureFlags) -> Option { if flags.ask { features.push("ask"); } + if flags.render { + features.push("render"); + } if flags.sessions { features.push("sessions"); } @@ -7999,6 +8017,7 @@ where delegation: delegation_enabled, feedback: feedback_enabled, ask: injection.ask.is_enabled().await, + render: injection.render.is_enabled().await, sessions: injection.sessions.is_enabled().await, tasks: groups.tasks, result: groups.result, @@ -8031,7 +8050,7 @@ where tracing::warn!( "[delegation][WARN] codeg-mcp companion binary not found (checked CODEG_MCP_BIN, \ exe sibling, and PATH); skipping delegate_to_agent / check_user_feedback / \ - ask_user_question / get_session_info tool injection for connection \ + ask_user_question / render_page / get_session_info tool injection for connection \ {parent_connection_id}. Reinstall codeg or set CODEG_MCP_BIN to fix." ); return None; @@ -8310,7 +8329,14 @@ async fn run_connection( // pre-bridge rendering. let native_ask_access = delegation_injection .as_ref() - .map(|inj| (Arc::clone(&inj.questions), inj.ask.clone())); + .map(|inj| { + ( + Arc::clone(&inj.questions), + inj.ask.clone(), + Arc::clone(&inj.renders), + inj.render.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 @@ -8387,7 +8413,7 @@ async fn run_connection( // An approval gating codeg's OWN ask tool is a dialog asking // permission to show a dialog; allow it so the user sees only // the interactive question card (see - // `codeg_ask_auto_allow_option`). + // `codeg_dialog_auto_allow_option`). let responder = match try_auto_allow_codeg_ask(&perm_ask_access, &req, responder).await { Ok(()) => return Ok(()), @@ -9932,6 +9958,18 @@ struct GrokExitPlanModeRequest(serde_json::Value); #[serde(transparent)] struct CodexElicitationRequest(serde_json::Value); +/// The interactive-question plumbing a native bridge (grok's +/// `_x.ai/ask_user_question`, codex's `elicitation/create`, the render_page +/// mirror) needs: the shared question and render-page access handles, each with +/// its runtime toggle. Carried as `Option` so a connection built without them +/// keeps the pre-bridge fallback behavior. +type NativeAskAccess = ( + Arc, + crate::acp::question::QuestionRuntimeConfig, + Arc, + crate::acp::render_page::RenderPageRuntimeConfig, +); + /// 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`] — @@ -9942,17 +9980,14 @@ struct CodexElicitationRequest(serde_json::Value); /// back to its inert fire-and-forget rendering — exactly the pre-bridge behavior, /// so no path here can regress it. async fn handle_grok_ask_user_question( - access: &Option<( - Arc, - crate::acp::question::QuestionRuntimeConfig, - )>, + access: &Option, connection_id: &str, state: &Arc>, emitter: &EventEmitter, req: GrokAskUserQuestionRequest, responder: Responder, ) { - let Some((questions, ask_cfg)) = access else { + let Some((questions, ask_cfg, ..)) = access else { let _ = responder.respond_with_internal_error("ask_user_question bridge unavailable"); return; }; @@ -10041,13 +10076,26 @@ async fn handle_grok_ask_user_question( /// only gating codeg's OWN `ask_user_question` companion tool, or `None` to /// leave the request on the ordinary approval-card path. /// +/// Which of codeg-mcp's own dialog tools a permission request is gating: +/// [`crate::acp::question::is_codeg_ask_tool_name`] and +/// [`crate::acp::render_page::is_codeg_render_tool_name`] each answer for one. +/// The distinction matters for the kill switch below — each tool is only +/// auto-allowed while ITS feature is on, since each is only exposed to the +/// agent while it is. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum CodegDialogTool { + Ask, + Render, +} + /// An agent whose permission mode consults the user before every MCP tool call /// (claude-agent-acp's default) gates the ask tool too, so asking the user a /// question used to cost TWO dialogs: a raw "run mcp__codeg-mcp__ask_user_question?" /// approval dumping the questions as JSON, and only after "Yes" the real /// interactive card. The first one carries no decision the second doesn't — see /// [`crate::acp::question::is_codeg_ask_tool_name`] for why answering it is the -/// user's consent either way. +/// user's consent either way. `render_page` is in the same boat: the page (and +/// for a waiting page the answer card) IS the dialog. /// /// The tool is identified by NAME, read from the two places a host puts it: /// `toolCall.title` (claude-agent-acp's `toolInfoFromToolUse` falls through to @@ -10061,12 +10109,38 @@ async fn handle_grok_ask_user_question( /// durable permission rule into the user's own agent settings — a decision that /// outlives this turn and this connection, so it stays theirs to make. With no /// such option (an agent that offers only "always"), `None` keeps today's card. -fn codeg_ask_auto_allow_option(req: &RequestPermissionRequest) -> Option { - codeg_tool_auto_allow_option(req, crate::acp::question::is_codeg_ask_tool_name) +fn codeg_dialog_auto_allow_option( + req: &RequestPermissionRequest, +) -> Option<(CodegDialogTool, String)> { + let permission_title = crate::acp::air_contract::permission_record(req.meta.as_ref()) + .and_then(|p| p.get("title")) + .and_then(serde_json::Value::as_str); + let titles = [req.tool_call.fields.title.as_deref(), permission_title]; + let kind = if titles + .into_iter() + .flatten() + .any(crate::acp::question::is_codeg_ask_tool_name) + { + CodegDialogTool::Ask + } else if titles + .into_iter() + .flatten() + .any(crate::acp::render_page::is_codeg_render_tool_name) + { + CodegDialogTool::Render + } else { + return None; + }; + let option_id = req + .options + .iter() + .find(|opt| opt.kind == PermissionOptionKind::AllowOnce) + .map(|opt| opt.option_id.to_string())?; + Some((kind, option_id)) } /// The `allow_once` option of a request gating the codeg-mcp tool `is_tool` -/// names, as [`codeg_ask_auto_allow_option`] reads it. +/// names, as [`codeg_submit_auto_allow_option`] reads it. fn codeg_tool_auto_allow_option( req: &RequestPermissionRequest, is_tool: fn(&str) -> bool, @@ -10081,10 +10155,12 @@ fn codeg_tool_auto_allow_option( if !is_it { return None; } - req.options + let option_id = req + .options .iter() .find(|opt| opt.kind == PermissionOptionKind::AllowOnce) - .map(|opt| opt.option_id.to_string()) + .map(|opt| opt.option_id.to_string())?; + Some(option_id) } /// The option that allows a request gating `submit_result` — on a connection @@ -10126,41 +10202,42 @@ async fn try_auto_allow_codeg_submit( Ok(()) } -/// Answer a permission request that is merely gating codeg's own ask tool, so -/// the interactive question card is the only thing the user ever sees. +/// Answer a permission request that is merely gating one of codeg's own dialog +/// tools, so the interactive card is the only thing the user ever sees. /// /// `Err(responder)` hands the request back for the ordinary permission path — /// the outcome for every other tool, and the deliberate fallback whenever the -/// auto-allow cannot be taken (the ask feature is off, or the agent offered no -/// allow-once option). -// `result_large_err`: agent-client-protocol's `Responder` (since 2.x) is past -// clippy's size limit, but this `Err` is the responder handed back once per -// permission request, not an error bubbled through `?` — boxing it would only -// add an allocation. +/// auto-allow cannot be taken (the tool's own feature is off, or the agent +/// offered no allow-once option). +// `result_large_err`: agent-client-protocol 2.x's `Responder` is past clippy's +// size limit, but this `Err` is the responder handed back once per permission +// request, not an error bubbled through `?` — boxing it would only add an +// allocation. #[allow(clippy::result_large_err)] async fn try_auto_allow_codeg_ask( - access: &Option<( - Arc, - crate::acp::question::QuestionRuntimeConfig, - )>, + access: &Option, req: &RequestPermissionRequest, responder: Responder, ) -> Result<(), Responder> { - let Some(option_id) = codeg_ask_auto_allow_option(req) else { + let Some((kind, option_id)) = codeg_dialog_auto_allow_option(req) else { return Err(responder); }; - // Same kill switch as the ask tool itself (and as `try_bridge_pi_select_ask`): - // with the feature off codeg-mcp never exposed `ask_user_question`, so a - // request naming it is not the tool this shortcut is allowed to speak for. - let Some((_, ask_cfg)) = access else { + // Same kill switch as each tool itself (and as `try_bridge_pi_select_ask`): + // with the feature off codeg-mcp never exposed the tool, so a request + // naming it is not the tool this shortcut is allowed to speak for. + let Some((_, ask_cfg, _, render_cfg)) = access else { return Err(responder); }; - if !ask_cfg.is_enabled().await { + let (tool_name, enabled) = match kind { + CodegDialogTool::Ask => ("ask_user_question", ask_cfg.is_enabled().await), + CodegDialogTool::Render => ("render_page", render_cfg.is_enabled().await), + }; + if !enabled { return Err(responder); } tracing::debug!( - "[ACP] auto-allowing the permission request for codeg's own ask_user_question tool \ - (option {option_id}); the interactive question card is the actual prompt" + "[ACP] auto-allowing the permission request for codeg's own {tool_name} tool \ + (option {option_id}); the interactive card is the actual prompt" ); let _ = responder.respond(RequestPermissionResponse::new( RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)), @@ -10194,10 +10271,7 @@ async fn try_auto_allow_codeg_ask( // Same `Err` hand-back as `try_auto_allow_codeg_ask`; see its allow note. #[allow(clippy::result_large_err)] async fn try_bridge_pi_select_ask( - access: &Option<( - Arc, - crate::acp::question::QuestionRuntimeConfig, - )>, + access: &Option, connection_id: &str, state: &Arc>, emitter: &EventEmitter, @@ -10213,7 +10287,7 @@ async fn try_bridge_pi_select_ask( if !tool_call_id.starts_with(crate::acp::question::PI_EXTENSION_UI_ID_PREFIX) { return Err(responder); } - let Some((questions, ask_cfg)) = access else { + let Some((questions, ask_cfg, ..)) = access else { return Err(responder); }; // Same kill switch as the codeg-mcp ask tool: when the feature is off, the @@ -10357,10 +10431,7 @@ async fn try_bridge_pi_select_ask( // Same `Err` hand-back as `try_auto_allow_codeg_ask`; see its allow note. #[allow(clippy::result_large_err)] async fn try_bridge_antigravity_ask( - access: &Option<( - Arc, - crate::acp::question::QuestionRuntimeConfig, - )>, + access: &Option, connection_id: &str, state: &Arc>, emitter: &EventEmitter, @@ -10374,7 +10445,7 @@ async fn try_bridge_antigravity_ask( { return Err(responder); } - let Some((questions, ask_cfg)) = access else { + let Some((questions, ask_cfg, ..)) = access else { return Err(responder); }; // Same kill switch as the codeg-mcp ask tool and every other native ask @@ -10578,10 +10649,7 @@ async fn handle_grok_exit_plan_mode( /// dispatch loop, reply in Cursor's option-id envelope. Early returns use /// `skipped` so the agent continues instead of hanging on `-32601`. async fn handle_cursor_ask_question( - access: &Option<( - Arc, - crate::acp::question::QuestionRuntimeConfig, - )>, + access: &Option, connection_id: &str, state: &Arc>, emitter: &EventEmitter, @@ -10596,7 +10664,7 @@ async fn handle_cursor_ask_question( // doubles as "the user dismissed the card", and an agent that can't tell // that apart from "this host never showed it" will proceed as if the user // had a say. The reason text is structural — never any of the payload. - let Some((questions, ask_cfg)) = access else { + let Some((questions, ask_cfg, ..)) = access else { let _ = responder.respond(crate::acp::cursor_ext::cursor_ask_skip_response_with_reason( "the host's question bridge is unavailable; the user was not asked", )); @@ -10755,10 +10823,7 @@ fn handle_cursor_generate_image( #[allow(clippy::too_many_arguments)] async fn handle_elicitation_request( cx: &ConnectionTo, - access: &Option<( - Arc, - crate::acp::question::QuestionRuntimeConfig, - )>, + access: &Option, perms: &PendingPermissions, state: &Arc>, emitter: &EventEmitter, @@ -10896,7 +10961,7 @@ async fn handle_elicitation_request( // Question-style (codex `request_user_input`, generic MCP forms): // bridge into the same ask card as the codeg-mcp ask tool. crate::acp::question::ElicitationPlan::Questions(questions) => { - let Some((question_access, ask_cfg)) = access else { + let Some((question_access, ask_cfg, ..)) = access else { let _ = responder.respond(unanswered()); return; }; @@ -15115,6 +15180,11 @@ async fn run_conversation_loop( // background. if let Some(inj) = delegation_injection { inj.questions.cancel_questions_by_parent(conn_id).await; + // `render_page` answers ride the same MCP call + // channel, so they get the same turn-exit sweep. + inj.renders + .cancel_render_pages_by_parent(conn_id) + .await; } // Cascade-cancel any pending delegations whenever // the parent's turn ended for a reason other than @@ -15309,6 +15379,11 @@ async fn run_conversation_loop( // decline beside the steer. if let Some(inj) = delegation_injection { inj.questions.cancel_questions_by_parent(conn_id).await; + // A parked `render_page` answer is + // dropped by the redirect too. + inj.renders + .cancel_render_pages_by_parent(conn_id) + .await; } let outcome = send_steer_request(&cx, &sid, &blocks).await; // A steered message still lands in the @@ -15459,14 +15534,19 @@ async fn run_conversation_loop( // 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. + // `register_{question,plan_approval,render_page}` + // 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.renders + .cancel_render_pages_by_parent(conn_id) + .await; inj.plan_approvals .cancel_plan_approvals_by_parent(conn_id) .await; @@ -23606,8 +23686,8 @@ async fn close_grok_agent_turn( true } -/// Answer, declined, a question or plan approval an agent-initiated turn is -/// parked on, once codeg has ended that turn early (Stop, or a prompt that has +/// Answer, declined, a question, rendered-page answer or plan approval an +/// agent-initiated turn is parked on, once codeg has ended that turn early (Stop, or a prompt that has /// to run next). Both cards close with the turn, so nothing could ever answer /// them, and Grok would stay blocked — holding the one-per-connection slot /// every later ask needs, and, when a prompt is queued behind the turn, that @@ -23618,6 +23698,7 @@ async fn cancel_grok_agent_turn_asks( ) { if let Some(inj) = delegation_injection { inj.questions.cancel_questions_by_parent(conn_id).await; + inj.renders.cancel_render_pages_by_parent(conn_id).await; inj.plan_approvals .cancel_plan_approvals_by_parent(conn_id) .await; @@ -26638,6 +26719,28 @@ mod tests { async fn cancel_questions_by_parent(&self, _parent_connection_id: &str) {} } + /// Render-page access the connection tests can hand to the widened ask / + /// permission tuples without exercising them: registers nothing, so a + /// waiting page never appears and nothing needs canceling. + #[derive(Default)] + struct NoopRenderPages; + + #[async_trait::async_trait] + impl crate::acp::render_page::SessionRenderPageAccess for NoopRenderPages { + async fn register_render_page( + &self, + _parent_connection_id: &str, + _title: String, + _html: String, + ) -> Option { + None + } + + async fn cancel_render_page(&self, _parent_connection_id: &str, _page_id: &str) {} + + async fn cancel_render_pages_by_parent(&self, _parent_connection_id: &str) {} + } + /// codex abandons a `request_user_input` it stops waiting for — its /// `autoResolutionMs` expired, or the turn was interrupted — with /// `$/cancel_request`. The question card must go at once (it used to wait @@ -26660,10 +26763,12 @@ mod tests { ask_config .set(crate::acp::question::QuestionConfig { enabled: true }) .await; - let access: Option<( - Arc, - crate::acp::question::QuestionRuntimeConfig, - )> = Some((questions.clone(), ask_config)); + let access: Option = Some(( + questions.clone(), + ask_config, + Arc::new(NoopRenderPages), + crate::acp::render_page::RenderPageRuntimeConfig::new(), + )); let agent = tokio::spawn(Agent.builder().connect_with( agent_end, @@ -28840,15 +28945,18 @@ mod tests { } #[test] - fn codeg_ask_auto_allow_option_picks_allow_once_for_codegs_own_ask_tool() { - let req = claude_mcp_permission_request( - "mcp__codeg-mcp__ask_user_question", - claude_permission_options(), - ); - assert_eq!( - codeg_ask_auto_allow_option(&req).as_deref(), - Some("allow-once") - ); + fn codeg_dialog_auto_allow_option_picks_allow_once_for_codegs_own_dialog_tools() { + for (tool, kind) in [ + ("mcp__codeg-mcp__ask_user_question", CodegDialogTool::Ask), + ("mcp__codeg-mcp__render_page", CodegDialogTool::Render), + ] { + let req = claude_mcp_permission_request(tool, claude_permission_options()); + assert_eq!( + codeg_dialog_auto_allow_option(&req), + Some((kind, "allow-once".to_string())), + "{tool}" + ); + } // The title alone is enough: an agent that sends no request-level // `_meta.permission` block still gets the shortcut. let mut bare = claude_mcp_permission_request( @@ -28857,23 +28965,24 @@ mod tests { ); bare.meta = None; assert_eq!( - codeg_ask_auto_allow_option(&bare).as_deref(), - Some("allow-once") + codeg_dialog_auto_allow_option(&bare).map(|(kind, _)| kind), + Some(CodegDialogTool::Ask) ); } #[test] - fn codeg_ask_auto_allow_option_leaves_every_other_approval_on_the_card() { + fn codeg_dialog_auto_allow_option_leaves_every_other_approval_on_the_card() { // Another server's ask tool: approving it is the user's call, not // codeg's, even though the tool half of the name matches. for foreign in [ "mcp__other-server__ask_user_question", + "mcp__other-server__render_page", "mcp__codeg-mcp__delegate_to_agent", "Bash", ] { let req = claude_mcp_permission_request(foreign, claude_permission_options()); assert!( - codeg_ask_auto_allow_option(&req).is_none(), + codeg_dialog_auto_allow_option(&req).is_none(), "{foreign} must keep its approval card" ); } @@ -28894,7 +29003,7 @@ mod tests { ), ], ); - assert!(codeg_ask_auto_allow_option(&always_only).is_none()); + assert!(codeg_dialog_auto_allow_option(&always_only).is_none()); } /// `submit_result` is answered by the same rule as the ask tool — codeg's @@ -42632,12 +42741,15 @@ mod tests { agent_availability, feedback: crate::acp::feedback::FeedbackRuntimeConfig::new(), ask: crate::acp::question::QuestionRuntimeConfig::new(), + render: crate::acp::render_page::RenderPageRuntimeConfig::new(), sessions: crate::acp::session_info::SessionInfoRuntimeConfig::new(), authoring: crate::acp::chat_authoring::ChatAuthoringRuntimeConfig::new(), browser: crate::acp::browser_tools::BrowserToolsRuntimeConfig::new(), computer: crate::acp::computer_tools::ComputerToolsRuntimeConfig::new(), questions: Arc::new(TestNoQuestions) as Arc, + renders: Arc::new(NoopRenderPages) + as Arc, plan_approvals: Arc::new(TestNoPlanApprovals) as Arc, } @@ -42852,6 +42964,8 @@ mod tests { assert_eq!(only(|f| f.feedback = true), Some("feedback".to_string())); // Ask only — likewise injects the companion on its own. assert_eq!(only(|f| f.ask = true), Some("ask".to_string())); + // Render only — same, on its own flag. + assert_eq!(only(|f| f.render = true), Some("render".to_string())); // Sessions only — likewise injects the companion on its own. assert_eq!(only(|f| f.sessions = true), Some("sessions".to_string())); // Per-spawn tasks group: injects alone. @@ -42874,6 +42988,7 @@ mod tests { delegation: true, feedback: true, ask: true, + render: true, sessions: true, tasks: true, result: true, @@ -42886,7 +43001,7 @@ mod tests { computer_clipboard: true, }), Some( - "delegation,feedback,ask,sessions,tasks,result,automations,taskboard,browser,\ + "delegation,feedback,ask,render,sessions,tasks,result,automations,taskboard,browser,\ browser_eval,computer,computer_launch,computer_clipboard" .to_string() ) @@ -47864,8 +47979,12 @@ mod tests { config .set(crate::acp::question::QuestionConfig { enabled }) .await; - let access: Option<(Arc, _)> = - Some((Arc::new(AntigravityTestAnswer { decline }), config)); + let access: Option = Some(( + Arc::new(AntigravityTestAnswer { decline }), + config, + Arc::new(NoopRenderPages), + crate::acp::render_page::RenderPageRuntimeConfig::new(), + )); let agent = tokio::spawn(Agent.builder().connect_with( agent_end, async move |cx: ConnectionTo| { @@ -48190,10 +48309,12 @@ mod tests { ask_config .set(crate::acp::question::QuestionConfig { enabled: true }) .await; - let access: Option<( - Arc, - crate::acp::question::QuestionRuntimeConfig, - )> = Some((Arc::new(AnswersFirstOption), ask_config)); + let access: Option = Some(( + Arc::new(AnswersFirstOption), + ask_config, + Arc::new(NoopRenderPages), + crate::acp::render_page::RenderPageRuntimeConfig::new(), + )); let agent = tokio::spawn(Agent.builder().connect_with( agent_end, diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index f7274208a4..2fb416c88d 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -1096,6 +1096,7 @@ fn attach_blocked(report: &mut DelegationTaskReport, blocked: BlockedOn) { (BlockedKind::Permission, None) => "a tool permission".to_string(), (BlockedKind::Question, None) => "a question".to_string(), (BlockedKind::PlanApproval, None) => "a plan approval".to_string(), + (BlockedKind::RenderPage, None) => "an answer on a page".to_string(), }; // The third line is guidance for the orchestrating LLM, in the same spirit // as `running_ack`'s "call get_delegation_status with this id". Keep it in diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index a07065072b..d66453eee0 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -58,22 +58,24 @@ use crate::acp::delegation::transport::{ client_computer_verify_round_trip, client_computer_windows_round_trip, client_create_automation_round_trip, client_create_work_task_round_trip, client_feedback_round_trip, client_list_assistants_round_trip, client_ping, - client_result_schema_round_trip, client_resume_task_round_trip, client_round_trip, - client_session_round_trip, client_status_round_trip, client_submit_result_round_trip, - client_task_complete_round_trip, client_task_progress_round_trip, BrokerAskRequest, - BrokerBrowserActRequest, BrokerBrowserCaptureRequest, BrokerBrowserConsoleRequest, - BrokerBrowserEvalRequest, BrokerBrowserSnapshotRequest, BrokerBrowserTabOpRequest, - BrokerBrowserTabsRequest, BrokerCancelRequest, BrokerCancelTaskRequest, - BrokerCommitFeedbackRequest, BrokerComputerActRequest, BrokerComputerAppsRequest, - BrokerComputerCaptureRequest, BrokerComputerClipboardRequest, BrokerComputerLaunchRequest, - BrokerComputerSnapshotRequest, BrokerComputerVerifyRequest, BrokerComputerWindowsRequest, - BrokerCreateAutomationRequest, BrokerCreateWorkTaskRequest, BrokerFeedbackRequest, - BrokerListAssistantsRequest, BrokerRequest, BrokerResponse, BrokerResultSchemaRequest, + client_render_round_trip, client_result_schema_round_trip, client_resume_task_round_trip, + client_round_trip, client_session_round_trip, client_status_round_trip, + client_submit_result_round_trip, client_task_complete_round_trip, + client_task_progress_round_trip, BrokerAskRequest, BrokerBrowserActRequest, + BrokerBrowserCaptureRequest, BrokerBrowserConsoleRequest, BrokerBrowserEvalRequest, + BrokerBrowserSnapshotRequest, BrokerBrowserTabOpRequest, BrokerBrowserTabsRequest, + BrokerCancelRequest, BrokerCancelTaskRequest, BrokerCommitFeedbackRequest, + BrokerComputerActRequest, BrokerComputerAppsRequest, BrokerComputerCaptureRequest, + BrokerComputerClipboardRequest, BrokerComputerLaunchRequest, BrokerComputerSnapshotRequest, + BrokerComputerVerifyRequest, BrokerComputerWindowsRequest, BrokerCreateAutomationRequest, + BrokerCreateWorkTaskRequest, BrokerFeedbackRequest, BrokerListAssistantsRequest, + BrokerRenderRequest, BrokerRequest, BrokerResponse, BrokerResultSchemaRequest, BrokerResumeTaskRequest, BrokerSessionRequest, BrokerStatusRequest, BrokerSubmitResultRequest, BrokerTaskCompleteRequest, BrokerTaskProgressRequest, }; use crate::acp::delegation::assistants::{roster_for_schema, AssistantRosterEntry}; use crate::acp::question::parse_questions; +use crate::acp::render_page::{parse_render_page_arguments, ANSWERED_NOTE, DECLINED_NOTE, SHOWN_NOTE}; use crate::acp::session_info::MAX_SESSION_MESSAGES; use crate::models::AutomationAction; @@ -163,6 +165,12 @@ pub struct CompanionFeatures { pub delegation: bool, pub feedback: bool, pub ask: bool, + /// `render_page` — the agent draws a self-contained HTML page inline in + /// the chat, optionally waiting for the user's answer on it. Gated apart + /// from `ask` because the effect is different: a page replaces the plain + /// card set with an agent-authored document (sandboxed in an iframe, but + /// still the agent's markup rendered into the user's chat). + pub render: bool, pub sessions: bool, /// Work-task reporting tools (`task_progress` / `task_complete`) — injected /// only into spawns launched by the task engine. @@ -228,6 +236,7 @@ impl CompanionFeatures { delegation: true, feedback: false, ask: false, + render: false, sessions: false, tasks: false, result: false, @@ -244,6 +253,7 @@ impl CompanionFeatures { delegation: false, feedback: false, ask: false, + render: false, sessions: false, tasks: false, result: false, @@ -260,6 +270,7 @@ impl CompanionFeatures { "delegation" => f.delegation = true, "feedback" => f.feedback = true, "ask" => f.ask = true, + "render" => f.render = true, "sessions" => f.sessions = true, "tasks" => f.tasks = true, "result" => f.result = true, @@ -281,6 +292,7 @@ impl CompanionFeatures { match name { "check_user_feedback" => self.feedback, "ask_user_question" => self.ask, + "render_page" => self.render, "get_session_info" => self.sessions, "task_progress" | "task_complete" => self.tasks, "submit_result" => self.result, @@ -1081,6 +1093,36 @@ async fn build_tools_call_spawn( let round_trip = Box::pin(async move { client_ask_round_trip(&socket, &req).await }); register_and_spawn(inflight, id, None, round_trip, render_ask_result).await } + "render_page" => { + // Validate + parse HERE, like the ask arm: a malformed or oversized + // page is a synchronous -32602 the LLM can fix. The two halves of + // the tool then diverge inside the companion, before any socket + // traffic: a SHOW-ONLY page is complete the moment the card is on + // screen — and the card renders straight from this tool call's own + // input, live and on transcript replay alike — so the call answers + // locally without a broker round trip. Only an `awaitAnswer` page + // needs the socket: the listener arms the card for an answer and + // parks this connection until the user submits. + let spec = match parse_render_page_arguments(&arguments) { + Ok(spec) => spec, + Err(msg) => return LineAction::Respond(err(id, -32602, msg)), + }; + if !spec.await_answer { + return LineAction::Respond(ok(id, render_shown_result(&spec.title))); + } + let req = BrokerRenderRequest { + token: ctx.token.clone(), + title: spec.title, + html: spec.html, + }; + // No external_handle, like the ask arm: canceling a waiting page + // only suppresses the response; the dropped future closes the + // socket, and the listener's peer-close read tears the pending + // page down. + let round_trip = + Box::pin(async move { client_render_round_trip(&socket, &req).await }); + register_and_spawn(inflight, id, None, round_trip, render_page_answer_result).await + } "get_session_info" => { // `session_id` is the codeg conversation id the agent read out of a // `codeg://session/` reference. Accept a JSON number or a numeric @@ -2031,6 +2073,53 @@ pub fn render_ask_result(outcome: &Value) -> Value { }) } +/// The tool result for a SHOW-ONLY `render_page` call — answered locally by the +/// companion, no broker round trip. The card is already on the user's screen +/// (rendered from this very tool input), so all the agent needs is the note +/// keeping it from announcing the page it just drew. `title` rides in +/// `structuredContent` so the frontend card header and the agent agree on what +/// the page is called even when the agent gave it none. +pub fn render_shown_result(title: &str) -> Value { + json!({ + "content": [{ "type": "text", "text": SHOWN_NOTE }], + "isError": false, + "structuredContent": { "shown": true, "title": title, "note": SHOWN_NOTE }, + }) +} + +/// Map the awaiting-answer `render_page` round-trip outcome (a serialized +/// [`crate::acp::render_page::RenderPageOutcome`] from the listener) into an MCP +/// `tools/call` result. The `content` text states the answer in words (plus the +/// note that tells the agent what to do with it); on decline it is just the +/// note. The raw envelope rides along in `structuredContent`. `isError` is +/// always `false` — a declined page is a valid result, not an error. +pub fn render_page_answer_result(outcome: &Value) -> Value { + let answered = outcome + .get("answered") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let text = if answered { + let summary = outcome.get("summary").and_then(|v| v.as_str()).unwrap_or(""); + let data = outcome.get("data").cloned().unwrap_or(Value::Null); + let note = outcome + .get("note") + .and_then(|v| v.as_str()) + .unwrap_or(ANSWERED_NOTE); + format!("The user answered on the page.\nsummary: {summary}\ndata: {data}\n{note}") + } else { + outcome + .get("note") + .and_then(|v| v.as_str()) + .unwrap_or(DECLINED_NOTE) + .to_string() + }; + json!({ + "content": [{ "type": "text", "text": text }], + "isError": false, + "structuredContent": outcome, + }) +} + /// Read a required non-empty string argument, trimmed. `Err` carries the /// `-32602` message the dispatcher returns verbatim. fn required_string(arguments: &Value, field: &str, tool: &str) -> Result { @@ -4446,6 +4535,7 @@ mod tests { delegation: true, feedback: false, ask: false, + render: false, sessions: false, tasks: false, result: false, @@ -5077,6 +5167,7 @@ mod tests { delegation: false, feedback: true, ask: false, + render: false, sessions: false, tasks: false, result: false, @@ -5092,6 +5183,7 @@ mod tests { delegation: true, feedback: true, ask: false, + render: false, sessions: false, tasks: false, result: false, @@ -5107,6 +5199,7 @@ mod tests { delegation: false, feedback: false, ask: true, + render: false, sessions: false, tasks: false, result: false, @@ -5122,6 +5215,7 @@ mod tests { delegation: false, feedback: false, ask: false, + render: false, sessions: true, tasks: false, result: false, @@ -5613,6 +5707,7 @@ mod tests { delegation: false, feedback: false, ask: false, + render: false, sessions: false, tasks: false, result: false, @@ -5628,6 +5723,7 @@ mod tests { delegation: false, feedback: false, ask: false, + render: false, sessions: false, tasks: false, result: false, @@ -6452,6 +6548,7 @@ mod tests { delegation: false, feedback: false, ask: false, + render: false, sessions: false, tasks: false, result: false, diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index 2645604487..4eeb9893c2 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -33,7 +33,8 @@ use crate::acp::delegation::transport::{ BrokerComputerAppsRequest, BrokerComputerCaptureRequest, BrokerComputerLaunchRequest, BrokerComputerSnapshotRequest, BrokerComputerVerifyRequest, BrokerComputerWindowsRequest, BrokerCommitFeedbackRequest, - BrokerFeedbackRequest, BrokerListAssistantsRequest, BrokerMessage, BrokerRequest, + BrokerFeedbackRequest, BrokerListAssistantsRequest, BrokerMessage, BrokerRenderRequest, + BrokerRequest, BrokerCreateAutomationRequest, BrokerCreateWorkTaskRequest, BrokerResponse, BrokerResultSchemaRequest, BrokerResumeTaskRequest, BrokerSessionRequest, BrokerStatusRequest, BrokerSubmitResultRequest, BrokerTaskCompleteRequest, @@ -151,6 +152,10 @@ pub struct DelegationListener { /// Registers / cancels the blocking `ask_user_question` tool's pending /// questions. Same `tokens` registry and parent-connection scoping. pub questions: Arc, + /// Registers / cancels the blocking `render_page` tool's awaiting-answer + /// pages. Same shape and scoping as `questions` — the page is a question + /// whose answer is whatever JSON the page's own script composes. + pub renders: Arc, /// Resolves a referenced session for the `get_session_info` tool. Unlike the /// other arms this is NOT parent-scoped — it looks any non-deleted session up /// by its codeg conversation id (still token-gated against an invalid caller). @@ -189,6 +194,7 @@ impl DelegationListener { parent_lookup: Arc, feedback: Arc, questions: Arc, + renders: Arc, session_info: Arc, tasks: Arc, authoring: Arc, @@ -202,6 +208,7 @@ impl DelegationListener { parent_lookup, feedback, questions, + renders, session_info, tasks, authoring, @@ -569,6 +576,59 @@ impl DelegationListener { write_frame(conn, &resp).await?; return Ok(()); } + BrokerMessage::Render(req) => { + // The awaiting-answer half of `render_page`: park on the user's + // submit, racing peer-close exactly like `Ask` above (same + // reasons, same teardown-window re-check). A show-only page + // never sends this message, so every `Render` here means "arm + // the card and wait". Bad token, gone connection, or a + // connection that already has a waiting page yields an + // unanswered outcome so the LLM proceeds. + let Some(parent_conn_id) = self.render_target(&req).await else { + write_frame(conn, &render_declined_response()?).await?; + return Ok(()); + }; + let Some(reg) = self + .renders + .register_render_page(&parent_conn_id, req.title, req.html) + .await + else { + write_frame(conn, &render_declined_response()?).await?; + return Ok(()); + }; + let page_id = reg.page_id; + let mut answer_rx = reg.answer_rx; + // Teardown-window re-check, mirroring the Ask arm: the token is + // revoked before `cancel_render_pages_by_parent` sweeps, so a + // token that is now gone means teardown started between lookup + // and insert. + if self.tokens.lookup(&req.token).await.is_none() { + self.renders + .cancel_render_page(&parent_conn_id, &page_id) + .await; + write_frame(conn, &render_declined_response()?).await?; + return Ok(()); + } + let mut probe = [0u8; 1]; + let outcome = tokio::select! { + biased; + ans = &mut answer_rx => ans.ok(), + _ = conn.read(&mut probe) => { + self.renders + .cancel_render_page(&parent_conn_id, &page_id) + .await; + return Ok(()); + } + }; + let resp = match outcome { + Some(o) => render_response(&o)?, + // Sender dropped without sending (teardown drain): surface + // an unanswered outcome so the tool returns cleanly. + None => render_declined_response()?, + }; + write_frame(conn, &resp).await?; + return Ok(()); + } BrokerMessage::SessionInfo(req) => { // Read-only resolution (DB + a bounded transcript parse). No // peer-close race needed: unlike Status/Ask this never blocks on @@ -863,6 +923,13 @@ impl DelegationListener { Some(entry.parent_connection_id) } + /// Resolve the `Render` arm's parent connection from the per-launch token + /// (same scoping as [`Self::ask_target`]). + async fn render_target(&self, req: &BrokerRenderRequest) -> Option { + let entry = self.tokens.lookup(&req.token).await?; + Some(entry.parent_connection_id) + } + /// Mark the named feedback notes delivered, after the companion confirms it /// returned them to the agent. Token-scoped to the parent connection. Unknown /// tokens are dropped (no LLM on the receiving end to react). @@ -1384,6 +1451,16 @@ fn ask_response(outcome: &QuestionOutcome) -> std::io::Result { }) } +/// Serialize a resolved [`RenderPageOutcome`] into a [`BrokerResponse`] for the +/// `Render` arm — the companion renders it into the `render_page` tool result. +fn render_response(outcome: &crate::acp::render_page::RenderPageOutcome) -> std::io::Result { + Ok(BrokerResponse { + outcome: serde_json::to_value(outcome).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("encode: {e}")) + })?, + }) +} + /// Serialize a resolved [`SessionInfo`] into a [`BrokerResponse`] for the /// `SessionInfo` arm — the companion renders it into the `get_session_info` /// tool result. @@ -1566,6 +1643,18 @@ fn ask_declined_response() -> std::io::Result { }) } +/// The unanswered counterpart for the `Render` arm (bad token / gone parent / +/// already a waiting page / teardown drain / peer-close): `answered: false` +/// carrying the decline note, so the agent proceeds with its own judgment. +fn render_declined_response() -> std::io::Result { + render_response(&crate::acp::render_page::RenderPageOutcome { + answered: false, + summary: String::new(), + data: serde_json::Value::Null, + note: Some(crate::acp::render_page::DECLINED_NOTE.to_string()), + }) +} + /// A `Canceled` report for a setup-side rejection the LLM can't react to (bad /// token, parent gone). Mirrors the old `cancel(..)` DelegationOutcome. fn report_canceled(message: &str) -> DelegationTaskReport { @@ -1862,6 +1951,53 @@ mod tests { } } + /// In-memory rendered-page stub, mirroring `StubQuestion`: register mints a + /// sequential id and stashes the answer sender; cancel removes it. Lets the + /// listener's `Render` arm be driven without a real `ConnectionManager`. + #[derive(Default)] + struct StubRender { + pending: tokio::sync::Mutex< + HashMap>, + >, + registered: tokio::sync::Mutex>, + canceled: tokio::sync::Mutex>, + } + #[async_trait] + impl crate::acp::render_page::SessionRenderPageAccess for StubRender { + async fn register_render_page( + &self, + parent_connection_id: &str, + title: String, + html: String, + ) -> Option { + let page_id = format!("r-{}", self.registered.lock().await.len() + 1); + let (tx, rx) = oneshot::channel(); + self.pending.lock().await.insert(page_id.clone(), tx); + self.registered + .lock() + .await + .push((parent_connection_id.to_string(), title, html)); + Some(crate::acp::render_page::RegisteredRenderPage { + page_id, + answer_rx: rx, + }) + } + async fn cancel_render_page(&self, _parent_connection_id: &str, page_id: &str) { + self.pending.lock().await.remove(page_id); + self.canceled.lock().await.push(page_id.to_string()); + } + async fn cancel_render_pages_by_parent(&self, _parent_connection_id: &str) { + self.pending.lock().await.clear(); + } + } + impl StubRender { + async fn answer(&self, page_id: &str, outcome: crate::acp::render_page::RenderPageOutcome) { + if let Some(tx) = self.pending.lock().await.remove(page_id) { + let _ = tx.send(outcome); + } + } + } + /// In-memory session-info stub. Records every `(session_id, max_messages)` it /// was asked to resolve and returns a seeded outcome — `found` sessions echo /// their id, unknown ids return `not_found`. Default knows about no sessions. @@ -2076,6 +2212,7 @@ mod tests { Arc::new(StaticParentLookup(parent_conversation)), Arc::new(StubFeedback::default()), Arc::new(StubQuestion::default()), + Arc::new(StubRender::default()), Arc::new(StubSessionInfo::default()), Arc::new(StubTaskTools), Arc::new(StubAuthoring::default()), @@ -2101,6 +2238,7 @@ mod tests { Arc::new(StaticParentLookup(Some(1))), feedback, Arc::new(StubQuestion::default()), + Arc::new(StubRender::default()), Arc::new(StubSessionInfo::default()), Arc::new(StubTaskTools), Arc::new(StubAuthoring::default()), @@ -2127,6 +2265,7 @@ mod tests { Arc::new(StaticParentLookup(Some(1))), Arc::new(StubFeedback::default()), questions, + Arc::new(StubRender::default()), Arc::new(StubSessionInfo::default()), Arc::new(StubTaskTools), Arc::new(StubAuthoring::default()), @@ -2152,6 +2291,7 @@ mod tests { Arc::new(StaticParentLookup(Some(1))), Arc::new(StubFeedback::default()), Arc::new(StubQuestion::default()), + Arc::new(StubRender::default()), session_info, Arc::new(StubTaskTools), Arc::new(StubAuthoring::default()), @@ -2179,6 +2319,7 @@ mod tests { Arc::new(StaticParentLookup(parent_conversation)), Arc::new(StubFeedback::default()), Arc::new(StubQuestion::default()), + Arc::new(StubRender::default()), Arc::new(StubSessionInfo::default()), Arc::new(StubTaskTools), authoring, @@ -2205,6 +2346,7 @@ mod tests { Arc::new(StaticParentLookup(Some(1))), Arc::new(StubFeedback::default()), Arc::new(StubQuestion::default()), + Arc::new(StubRender::default()), Arc::new(StubSessionInfo::default()), Arc::new(StubTaskTools), Arc::new(StubAuthoring::default()), @@ -3512,6 +3654,162 @@ mod tests { assert!(questions.registered.lock().await.is_empty()); } + // -- render_page over the listener -------------------------------------- + + /// Build a listener whose render-page access is the given stub, so render + /// tests can register/answer pages and assert the round-trip. Delegation, + /// feedback and question pieces are minimal. + fn make_render_listener( + tokens: Arc, + renders: Arc, + ) -> Arc { + let broker = Arc::new(DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + Arc::new(AlwaysRootLookup) as Arc, + )); + DelegationListener::new( + broker, + tokens, + Arc::new(StaticParentLookup(Some(1))), + Arc::new(StubFeedback::default()), + Arc::new(StubQuestion::default()), + renders, + Arc::new(StubSessionInfo::default()), + Arc::new(StubTaskTools), + Arc::new(StubAuthoring::default()), + Arc::new(NoBrowserTabs), + Arc::new(NoComputerDesktop), + Arc::new(crate::acp::result_tool::NoResultTool), + ) + } + + fn render_msg(token: &str) -> BrokerMessage { + BrokerMessage::Render(BrokerRenderRequest { + token: token.into(), + title: "Theme picker".into(), + html: "

pick

".into(), + }) + } + + /// A `Render` registers the page, parks, and — once the user submits on the + /// card — writes the full `{answered, summary, data}` envelope back over + /// the same socket (show-only pages never send this message, so the whole + /// arm is the await-answer half of the tool). + #[tokio::test] + async fn render_registers_then_answer_resolves_response() { + let renders = Arc::new(StubRender::default()); + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_render_listener(tokens, renders.clone()); + + let (mut client, mut server) = duplex(8 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + write_frame(&mut client, &render_msg("tok")).await.unwrap(); + + // The server must be parked until a submit arrives — no response yet. + let early = tokio::time::timeout(Duration::from_millis(40), async { + read_frame::<_, BrokerResponse>(&mut client).await + }) + .await; + assert!(early.is_err(), "render must block until the user answers"); + + // Wait for the stub to record the registration, then answer it. + while renders.registered.lock().await.is_empty() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert_eq!( + renders.registered.lock().await[0], + ( + "parent-conn".to_string(), + "Theme picker".to_string(), + "

pick

".to_string() + ) + ); + renders + .answer( + "r-1", + crate::acp::render_page::RenderPageOutcome { + answered: true, + summary: "go with B".into(), + data: serde_json::json!({ "choice": "b" }), + note: None, + }, + ) + .await; + + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + assert_eq!(resp.outcome["answered"], true); + assert_eq!(resp.outcome["summary"], "go with B"); + assert_eq!(resp.outcome["data"]["choice"], "b"); + // An answered outcome carries no note (the field is skip-serialized). + assert!(resp.outcome.get("note").is_none()); + } + + /// A dismissed page closes the tool call's socket; the listener observes the + /// peer-close, cancels the pending page, and returns without writing. + #[tokio::test] + async fn render_peer_close_cancels_page() { + let renders = Arc::new(StubRender::default()); + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_render_listener(tokens, renders.clone()); + + let (mut client, mut server) = duplex(8 * 1024); + let server_task = tokio::spawn(async move { listener.serve_one(&mut server).await }); + write_frame(&mut client, &render_msg("tok")).await.unwrap(); + + // Let the server park inside the wait. + while renders.registered.lock().await.is_empty() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + // Companion cancels: drop the request socket. + drop(client); + + let result = tokio::time::timeout(Duration::from_secs(5), server_task) + .await + .expect("serve_one must return after peer close"); + result.unwrap().unwrap(); + assert_eq!(renders.canceled.lock().await.as_slice(), &["r-1".to_string()]); + } + + /// An invalid token never registers a page and returns an unanswered + /// outcome (the LLM proceeds with its own judgment). + #[tokio::test] + async fn render_invalid_token_declined() { + let renders = Arc::new(StubRender::default()); + let listener = make_render_listener(Arc::new(TokenRegistry::default()), renders.clone()); + let (mut client, mut server) = duplex(8 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + write_frame(&mut client, &render_msg("bad-token")) + .await + .unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + assert_eq!(resp.outcome["answered"], false); + assert!(renders.registered.lock().await.is_empty()); + } + // -- browser tools ------------------------------------------------------ async fn browser_tokens() -> Arc { @@ -4120,6 +4418,7 @@ mod tests { Arc::new(StaticParentLookup(Some(1))), Arc::new(StubFeedback::default()), Arc::new(StubQuestion::default()), + Arc::new(StubRender::default()), Arc::new(StubSessionInfo::default()), Arc::new(StubTaskTools), Arc::new(StubAuthoring::default()), @@ -4505,6 +4804,7 @@ mod tests { Arc::new(StaticParentLookup(Some(1))), Arc::new(StubFeedback::default()), Arc::new(StubQuestion::default()), + Arc::new(StubRender::default()), Arc::new(StubSessionInfo::default()), Arc::new(StubTaskTools), Arc::new(StubAuthoring::default()), diff --git a/src-tauri/src/acp/delegation/service.rs b/src-tauri/src/acp/delegation/service.rs index b038e6e707..2406c357a3 100644 --- a/src-tauri/src/acp/delegation/service.rs +++ b/src-tauri/src/acp/delegation/service.rs @@ -309,6 +309,20 @@ mod tests { async fn cancel_questions_by_parent(&self, _parent_connection_id: &str) {} } + #[async_trait] + impl crate::acp::render_page::SessionRenderPageAccess for Stub { + async fn register_render_page( + &self, + _parent_connection_id: &str, + _title: String, + _html: String, + ) -> Option { + None + } + async fn cancel_render_page(&self, _parent_connection_id: &str, _page_id: &str) {} + async fn cancel_render_pages_by_parent(&self, _parent_connection_id: &str) {} + } + #[async_trait] impl crate::acp::session_info::SessionInfoAccess for Stub { async fn resolve( @@ -498,6 +512,7 @@ mod tests { Arc::new(Stub), Arc::new(Stub), Arc::new(Stub), + Arc::new(Stub), Arc::new(crate::acp::computer_tools::NoComputerDesktop), Arc::new(crate::acp::result_tool::NoResultTool), ); diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index 13eb0c243b..c55d852d60 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -1392,5 +1392,31 @@ } } } + }, + { + "name": "render_page", + "description": "Draw a finished HTML page inline in the chat, above your reply, or ask the user to choose on one (awaitAnswer). The page is part of your reply and sits on the chat's background: leave html, body and the outermost element without a background, add no outer card, border or banner title, and set boxes apart by tone. Style with the theme variables listed under html. Read html before writing a page and awaitAnswer before asking on one.", + "inputSchema": { + "type": "object", + "required": [ + "html" + ], + "properties": { + "html": { + "type": "string", + "maxLength": 200000, + "description": "One self-contained HTML document, at most 200000 characters, with inline '+'
';` + + `document.body.appendChild(host);var q=function(s){return sr.querySelector(s)};var cur=document.createElement("style");cur.textContent="";document.head.appendChild(cur);` + + `AR={ac:ac,host:host,cur:cur,lyr:q(".lyr"),hb:q(".box"),hl:q(".lbl"),mb:sr.querySelectorAll(".box")[1],ed:q(".edit"),ta:q("textarea"),desc:null,hov:null,mode:"element",regions:[],strokes:[],ops:[],mq:null,pts:null};` + + `var bs=sr.querySelectorAll(".tools button");bs[0].textContent=lb.element||"Element";bs[1].textContent=lb.region||"Region";bs[2].textContent=lb.draw||"Draw";bs[3].textContent=lb.cancel||"Cancel";AR.ta.placeholder=lb.placeholder||"Note (optional)";q(".snd").textContent=lb.send||"Send";` + + `function setMode(m){AR.mode=m;bs[0].setAttribute("aria-pressed",String(m==="element"));bs[1].setAttribute("aria-pressed",String(m==="region"));bs[2].setAttribute("aria-pressed",String(m==="draw"));cur.textContent=m==="element"?"":"html{cursor:crosshair!important}";AR.hb.style.display="none";arPaint()}` + + `bs[0].addEventListener("click",function(){setMode("element")});bs[1].addEventListener("click",function(){setMode("region")});bs[2].addEventListener("click",function(){setMode("draw")});bs[3].addEventListener("click",function(){arEnd(null)});q(".snd").addEventListener("click",arSend);` + + `AR.ta.addEventListener("keydown",function(e){var z=e.key==="z"||e.key==="Z";if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&z&&AR.ops.length){e.preventDefault();arUndo();return}if(e.key==="Escape"){arEnd(null);return}if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();e.stopPropagation();arSend()}else e.stopPropagation()});` + + `AR.ta.addEventListener("input",function(){AR.ta.style.height="auto";AR.ta.style.height=Math.min(104,AR.ta.scrollHeight)+"px";arEd()});` + + `document.addEventListener("pointerdown",function(e){if(e.button!==0||e.target===host)return;e.preventDefault();e.stopPropagation();if(AR.mode==="element"){var el=arHit(e),pv=AR.desc;if(el){if(pv&&pv.node===el)AR.desc=null;else AR.desc=arDesc(el)}else AR.desc=null;if(AR.desc!==pv)AR.ops.push({k:"e",prev:pv});arPaint()}else if(AR.mode==="region"){AR.mq={x1:e.clientX,y1:e.clientY,x2:e.clientX,y2:e.clientY}}else{AR.pts=[{x:e.clientX,y:e.clientY}];arPaint()}},sig);` + + `document.addEventListener("pointermove",function(e){if(AR.mode==="region"&&AR.mq){AR.mq.x2=e.clientX;AR.mq.y2=e.clientY;arPaint()}else if(AR.mode==="draw"&&AR.pts){if(AR.pts.length<2000)AR.pts.push({x:e.clientX,y:e.clientY});arPaint()}else if(AR.mode==="element"&&!AR.desc){var el=arHit(e);if(el){var b=el.getBoundingClientRect();AR.hb.style.display="block";AR.hb.style.left=Math.round(b.left)+"px";AR.hb.style.top=Math.round(b.top)+"px";AR.hb.style.width=Math.round(b.width)+"px";AR.hb.style.height=Math.round(b.height)+"px"}else AR.hb.style.display="none"}},sig);` + + `document.addEventListener("pointerup",function(){if(AR.mode==="region"&&AR.mq){var m=AR.mq,x=Math.min(m.x1,m.x2),y=Math.min(m.y1,m.y2),w=Math.abs(m.x2-m.x1),h=Math.abs(m.y2-m.y1);if(w>=3&&h>=3){AR.regions.push({id:"region-"+(AR.regions.length+1),rect:arRect(x,y,w,h)});AR.ops.push({k:"r"})}AR.mq=null;arPaint()}else if(AR.mode==="draw"&&AR.pts){if(AR.pts.length>=2){AR.strokes.push({id:"stroke-"+(AR.strokes.length+1),points:AR.pts,bounds:arPB(AR.pts)});AR.ops.push({k:"s"})}AR.pts=null;arPaint()}},sig);` + + `ABLK.forEach(function(t){window.addEventListener(t,function(e){if(e.target!==host){e.preventDefault();e.stopPropagation()}},sig)});` + + `window.addEventListener("keydown",function(e){if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&!e.isComposing&&e.keyCode!==229&&(e.key==="z"||e.key==="Z")&&AR&&AR.ops.length){e.preventDefault();arUndo()}},sig);` + + `window.addEventListener("keydown",function(e){if(e.key==="Escape"&&!e.isComposing&&e.keyCode!==229){e.stopPropagation();arEnd(null)}},sig);` + + `window.addEventListener("scroll",function(){arPaint()},sig);window.addEventListener("resize",function(){arPaint()},sig)}` + +// Runs in the page's head, before its own styles and scripts. It rewrites its +// own `, + ``, + ``, + ``, + ].join("") + const headOpen = /]*)?>/i.exec(scan) + if (headOpen) { + const at = headOpen.index + headOpen[0].length + return html.slice(0, at) + bootstrap + html.slice(at) + } + const htmlOpen = /]*)?>/i.exec(scan) + if (htmlOpen) { + const at = htmlOpen.index + htmlOpen[0].length + return `${html.slice(0, at)}${bootstrap}${html.slice(at)}` + } + const doctype = /^\s*]*>/i.exec(scan) + if (doctype) { + const at = doctype[0].length + return `${html.slice(0, at)}${bootstrap}${html.slice(at)}` + } + return `${bootstrap}${html}` +} + +export const renderPageThemeMessage = (theme: RenderPageTheme) => ({ + type: RENDER_PAGE_THEME_MESSAGE, + ...theme, +}) + +export const renderPageAnswerStateMessage = (state: RenderPageAnswerState) => ({ + type: RENDER_PAGE_ANSWER_STATE_MESSAGE, + ...state, +}) + +/** The host's reply to a page's submit; `message` set = the promise rejects + * with it. */ +export const renderPageSubmitReply = (id: number, message?: string) => + message === undefined + ? { type: RENDER_PAGE_SUBMIT_REPLY_MESSAGE, id, ok: true } + : { type: RENDER_PAGE_SUBMIT_REPLY_MESSAGE, id, ok: false, message } + +/** Tell the page to drop its selection (and not echo the removal back). */ +export const renderPageClearSelectionMessage = () => ({ + type: RENDER_PAGE_CLEAR_SELECTION_MESSAGE, +}) + +export type RenderPageSubmit = { + kind: "submit" + id: number + summary: string + data: RenderPageAnswerData +} + +export type RenderPageSelectionRect = { + x: number + y: number + width: number + height: number +} + +// ── in-page annotations (element / region / draw) ───────────────────── +// +// Mirrors openchamber's annotation payload (its contract.ts), cut to what a +// chat page can carry without a desktop capture path: no screenshot — the +// prompt the host formats says so, exactly like openchamber's own web build. + +export interface RenderPageAnnotationElement { + /** A CSS selector good enough for the agent to find the node again (id + * shortcut, then a ≤5-level path with nth-of-type disambiguation). */ + selector: string + /** A snapshot of the node's own markup, capped — the agent's pages are + * generated, so showing the tag is what stops a "make THIS bigger" note + * from being applied to the whole document. */ + html: string + /** The element's own visible text, whitespace-collapsed, capped. */ + text: string + /** The whitelisted attributes the page saw (id/class/href/… each capped). */ + attributes: Record + /** The element's computed styles at pick time (openchamber's + * BrowserElementTarget.computedStyle): the font/color/box evidence that + * tells the agent what to change on THIS node instead of everywhere. */ + styles: Record + /** Viewport px, rounded at report time. */ + bounds: RenderPageSelectionRect +} + +export interface RenderPageAnnotationRegion { + id: string + rect: RenderPageSelectionRect +} + +export interface RenderPageAnnotationStroke { + id: string + points: { x: number; y: number }[] + bounds: RenderPageSelectionRect +} + +export interface RenderPageAnnotationPayload { + viewport: { width: number; height: number } + comment: string + elements: RenderPageAnnotationElement[] + regions: RenderPageAnnotationRegion[] + strokes: RenderPageAnnotationStroke[] +} + +/** The chrome labels the overlay shows — the host localizes them, the page + * only echoes them back (i18n keys: Folder.chat.renderPage.annotate.*). */ +export interface RenderPageAnnotationLabels { + element: string + region: string + draw: string + placeholder: string + send: string + cancel: string +} + +/** Bounds the page may not exceed; a report that overshoots is dropped whole + * rather than trusted, same posture as every other page message. A page built + * by {@link buildRenderPageDocument} never trips them — its send clips + * regions and strokes to these same bounds — so this only fires on a page + * that replaced its own script. */ +const ANNOTATION_MAX_COMMENT = 4000 +const ANNOTATION_MAX_ELEMENTS = 8 +const ANNOTATION_MAX_REGIONS = 24 +const ANNOTATION_MAX_STROKES = 32 +const ANNOTATION_MAX_POINTS = 2000 +const ANNOTATION_MAX_SELECTOR = 300 +const ANNOTATION_MAX_HTML = 600 +const ANNOTATION_MAX_TEXT = 400 +const ANNOTATION_MAX_ATTRS = 16 +const ANNOTATION_MAX_STYLES = 22 + +const fin = (v: unknown): v is number => + typeof v === "number" && Number.isFinite(v) + +const readAnnotationRect = (v: unknown): RenderPageSelectionRect | null => { + if (!v || typeof v !== "object") return null + const r = v as Record + if (!fin(r.x) || !fin(r.y) || !fin(r.width) || !fin(r.height)) return null + if (r.width < 0 || r.height < 0) return null + return { x: r.x, y: r.y, width: r.width, height: r.height } +} + +/** Validate one annotation report field by field (the payload crosses the + * page boundary; a page can replace its own bootstrap). */ +export function readRenderPageAnnotationPayload( + v: unknown +): RenderPageAnnotationPayload | null { + if (!v || typeof v !== "object") return null + const d = v as Record + const vp = d.viewport as Record | null + if (!vp || typeof vp !== "object" || !fin(vp.width) || !fin(vp.height)) + return null + if ( + typeof d.comment !== "string" || + d.comment.length > ANNOTATION_MAX_COMMENT + ) + return null + const elements = d.elements, + regions = d.regions, + strokes = d.strokes + if ( + !Array.isArray(elements) || + !Array.isArray(regions) || + !Array.isArray(strokes) || + elements.length > ANNOTATION_MAX_ELEMENTS || + regions.length > ANNOTATION_MAX_REGIONS || + strokes.length > ANNOTATION_MAX_STROKES + ) + return null + const outElements: RenderPageAnnotationElement[] = [] + for (const e of elements as unknown[]) { + if (!e || typeof e !== "object") return null + const eRec = e as Record + if ( + typeof eRec.selector !== "string" || + eRec.selector.length > ANNOTATION_MAX_SELECTOR || + typeof eRec.html !== "string" || + eRec.html.length > ANNOTATION_MAX_HTML || + typeof eRec.text !== "string" || + eRec.text.length > ANNOTATION_MAX_TEXT + ) + return null + const attrs: Record = {} + if (eRec.attributes && typeof eRec.attributes === "object") { + for (const [k, val] of Object.entries( + eRec.attributes as Record + )) { + if (Object.keys(attrs).length >= ANNOTATION_MAX_ATTRS) return null + if (typeof val !== "string" || val.length > 200) return null + attrs[k] = val + } + } + const styles: Record = {} + if (eRec.styles && typeof eRec.styles === "object") { + for (const [k, val] of Object.entries( + eRec.styles as Record + )) { + if (Object.keys(styles).length >= ANNOTATION_MAX_STYLES) return null + if (typeof val !== "string" || val.length > 200) return null + styles[k] = val + } + } + const bounds = readAnnotationRect(eRec.bounds) + if (!bounds) return null + outElements.push({ + selector: eRec.selector, + html: eRec.html, + text: eRec.text, + attributes: attrs, + styles, + bounds, + }) + } + const outRegions: RenderPageAnnotationRegion[] = [] + for (const rg of regions as unknown[]) { + if (!rg || typeof rg !== "object") return null + const rRec = rg as Record + if (typeof rRec.id !== "string" || rRec.id.length > 40) return null + const rect = readAnnotationRect(rRec.rect) + if (!rect) return null + outRegions.push({ id: rRec.id, rect }) + } + const outStrokes: RenderPageAnnotationStroke[] = [] + for (const st of strokes as unknown[]) { + if (!st || typeof st !== "object") return null + const sRec = st as Record + if (typeof sRec.id !== "string" || sRec.id.length > 40) return null + if (!Array.isArray(sRec.points) || sRec.points.length < 2) return null + const points: { x: number; y: number }[] = [] + for (const p of sRec.points as unknown[]) { + if (points.length >= ANNOTATION_MAX_POINTS) return null + if (!p || typeof p !== "object") return null + const pRec = p as Record + if (!fin(pRec.x) || !fin(pRec.y)) return null + points.push({ x: pRec.x, y: pRec.y }) + } + const bounds = readAnnotationRect(sRec.bounds) + if (!bounds) return null + outStrokes.push({ id: sRec.id, points, bounds }) + } + return { + viewport: { width: vp.width, height: vp.height }, + comment: d.comment, + elements: outElements, + regions: outRegions, + strokes: outStrokes, + } +} + +/** The text an annotation session places in the composer — the same English + * body openchamber's prompt builder writes, so the agent's reading skill + * transfers. Geometry is rounded; each kind numbers independently. */ +export function formatRenderPageAnnotation( + payload: RenderPageAnnotationPayload, + pageTitle: string +): string { + const lines: string[] = [ + "The user annotated the page below your request. Apply each note ONLY to the element, region, or drawing it marks — never to the whole page — in your next step.", + ] + const title = pageTitle.trim() + if (title) lines.push(`Page: ${title}`) + lines.push( + `Viewport: ${Math.round(payload.viewport.width)}x${Math.round(payload.viewport.height)}`, + "Screenshot: not attached" + ) + const comment = payload.comment.trim() + if (comment) lines.push(`Comment: ${comment}`) + payload.elements.forEach((e, i) => { + lines.push(`Element ${i + 1}: ${e.selector}`) + if (e.html) lines.push(`- HTML: ${e.html}`) + if (e.text) lines.push(`- Text: ${e.text}`) + const attrs = Object.entries(e.attributes) + if (attrs.length) + lines.push( + `- Attributes: ${attrs.map(([k, val]) => `${k}="${val}"`).join(" ")}` + ) + const b = e.bounds + lines.push( + `- Bounds: x=${Math.round(b.x)}, y=${Math.round(b.y)}, width=${Math.round(b.width)}, height=${Math.round(b.height)}` + ) + const st = e.styles + if (Object.keys(st).length) { + lines.push( + `- Styles: display=${st.display ?? ""}; position=${st.position ?? ""}; font=${st.fontWeight ?? ""} ${st.fontSize ?? ""} / ${st.lineHeight ?? ""} ${st.fontFamily ?? ""}; color=${st.color ?? ""}; background=${st.backgroundColor ?? ""}` + ) + } + }) + payload.regions.forEach((rg, i) => { + const r = rg.rect + lines.push( + `Region ${i + 1}: x=${Math.round(r.x)}, y=${Math.round(r.y)}, width=${Math.round(r.width)}, height=${Math.round(r.height)}` + ) + }) + payload.strokes.forEach((st, i) => { + const b = st.bounds + lines.push( + `Drawing ${i + 1}: ${st.points.length} points over x=${Math.round(b.x)}, y=${Math.round(b.y)}, width=${Math.round(b.width)}, height=${Math.round(b.height)}` + ) + }) + return lines.join("\n") +} + +/** Host → page: open the overlay with these chrome labels, or close it. */ +export const renderPageAnnotateMessage = ( + action: "start" | "stop", + labels?: RenderPageAnnotationLabels +) => + labels + ? { type: RENDER_PAGE_ANNOTATE_MESSAGE, action, labels } + : { type: RENDER_PAGE_ANNOTATE_MESSAGE, action } + +export type RenderPageFrameMessage = + | { kind: "size"; height: number } + | { kind: "open-link"; url: string } + | RenderPageSubmit + /** The page's selection changed; empty `text` = it went away. `rect` is in + * the page's own viewport coordinates — the frame fits the page without + * vertical scroll, so they read directly against the frame box. */ + | { kind: "selection"; text: string; rect: RenderPageSelectionRect | null } + /** Escape was pressed while a selection was up and nothing in the page + * consumed it; the host closes its quote bubble. */ + | { kind: "selection-escape" } + /** One annotation session ended: a payload to place in the composer, or a + * null payload when the user cancelled. */ + | { kind: "annotation"; payload: RenderPageAnnotationPayload | null } + +/** The message a page's bootstrap posted, or null for anything else. Hand- + * checked rather than zod-typed: the payloads are three fixed shapes written + * by our own bootstrap. */ +export function readRenderPageFrameMessage( + data: unknown +): RenderPageFrameMessage | null { + if (!data || typeof data !== "object") return null + const d = data as Record + switch (d.type) { + case RENDER_PAGE_SIZE_MESSAGE: + return typeof d.height === "number" && + Number.isFinite(d.height) && + d.height > 0 + ? { kind: "size", height: d.height } + : null + case RENDER_PAGE_OPEN_LINK_MESSAGE: + return typeof d.url === "string" && /^https?:\/\//i.test(d.url) + ? { kind: "open-link", url: d.url } + : null + case RENDER_PAGE_SUBMIT_MESSAGE: + return typeof d.id === "number" && Number.isInteger(d.id) && d.id > 0 + ? { + kind: "submit", + id: d.id, + summary: typeof d.summary === "string" ? d.summary : "", + data: d.data === undefined ? null : d.data, + } + : null + case RENDER_PAGE_SELECTION_MESSAGE: { + if ( + typeof d.text !== "string" || + d.text.length > RENDER_PAGE_SELECTION_MAX + ) + return null + if (!d.text) return { kind: "selection", text: "", rect: null } + const r = d.rect as Record | null + if (!r || typeof r !== "object") return null + const x = r.x, + y = r.y, + width = r.width, + height = r.height + if ( + typeof x !== "number" || + typeof y !== "number" || + typeof width !== "number" || + typeof height !== "number" || + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(width) || + !Number.isFinite(height) || + width < 0 || + height < 0 + ) + return null + return { + kind: "selection", + text: d.text, + rect: { x, y, width, height }, + } + } + case RENDER_PAGE_SELECTION_ESCAPE_MESSAGE: + return { kind: "selection-escape" } + case RENDER_PAGE_ANNOTATION_MESSAGE: { + // A null payload is the cancel; a malformed one is not a report at all. + if (d.payload === null) return { kind: "annotation", payload: null } + const payload = readRenderPageAnnotationPayload(d.payload) + return payload ? { kind: "annotation", payload } : null + } + default: + return null + } +} + +export const clampRenderPageHeight = (height: number): number => + Math.min( + RENDER_PAGE_MAX_HEIGHT, + Math.max(RENDER_PAGE_MIN_HEIGHT, Math.ceil(height)) + ) + +/** The answer submission to `acp_answer_render_page` from a page submit. */ +export const renderPageAnswerFromSubmit = ( + submit: RenderPageSubmit +): RenderPageAnswer => ({ + summary: submit.summary, + data: submit.data, + declined: false, +}) + +// ── persisted tool-call payloads (in-stream record card) ────────────── + +/** The parsed `render_page` tool input, as persisted on the tool call. */ +export interface RenderPageToolInput { + html: string + title: string + awaitAnswer: boolean +} + +/** Lenient parse of the tool input. The only hard requirement is `html` — + * a call whose input hasn't finished streaming, or that a truncated + * transcript cut, reads as "nothing to draw" (null) rather than a broken + * card. `title`/`awaitAnswer` collapse to the tool's own defaults. */ +export function parseRenderPageInput( + input: string | null | undefined +): RenderPageToolInput | null { + if (!input) return null + let parsed: unknown + try { + parsed = JSON.parse(input) + } catch { + return null + } + if (!parsed || typeof parsed !== "object") return null + const d = parsed as Record + const html = typeof d.html === "string" ? d.html : "" + if (!html.trim()) return null + return { + html, + title: typeof d.title === "string" ? d.title.trim() : "", + awaitAnswer: d.awaitAnswer === true, + } +} + +/** What a settled `render_page` call ended as, read back from its output. */ +export type RenderPageToolOutcome = + | { kind: "shown" } + | { kind: "answered"; summary: string } + | { kind: "declined" } + +function outcomeFromEnvelope( + d: Record +): RenderPageToolOutcome | null { + if (d.shown === true) return { kind: "shown" } + if (d.answered === true) { + return { + kind: "answered", + summary: typeof d.summary === "string" ? d.summary : "", + } + } + if (d.answered === false || d.declined === true) return { kind: "declined" } + return null +} + +function outcomeFromText(text: string): RenderPageToolOutcome | null { + if (/The user answered on the page/i.test(text)) { + // The companion's note line: "summary: ". + const m = text.match(/^summary:[ \t](.*)$/m) + return { kind: "answered", summary: (m?.[1] ?? "").trim() } + } + if (/did not answer on the page/i.test(text)) return { kind: "declined" } + if (/Shown to the user in the chat/i.test(text)) return { kind: "shown" } + return null +} + +/** + * Reconstruct the settled outcome from the persisted tool result. + * + * Primary shape — the `structuredContent` envelope the companion returns + * (`{shown:true,…}` for a show-only call; `{answered,summary,data,note}` for + * an awaited one). Fallback — the `content[0].text` note itself, for any CLI + * that stores the text rather than the structured payload (see + * `render_page_answer_result` / `render_shown_result`). Returns `null` while + * the call is still in flight (no output). + */ +export function parseRenderPageOutcome( + output: string | null | undefined +): RenderPageToolOutcome | null { + if (!output || !output.trim()) return null + try { + const parsed = JSON.parse(output) as unknown + if (parsed && typeof parsed === "object") { + const d = parsed as Record + const direct = outcomeFromEnvelope(d) + if (direct) return direct + const inner = d.structuredContent + if (inner && typeof inner === "object") { + const fromInner = outcomeFromEnvelope(inner as Record) + if (fromInner) return fromInner + } + // MCP result wrapper: pull the text parts out and read the notes. + const parts = Array.isArray(d.content) ? d.content : null + if (parts) { + const text = parts + .map((p) => + p && + typeof p === "object" && + typeof (p as Record).text === "string" + ? ((p as Record).text as string) + : "" + ) + .join("\n") + const fromText = outcomeFromText(text) + if (fromText) return fromText + } + } + } catch { + // Not JSON — a plain-text output; fall through to the note matchers. + } + return outcomeFromText(output) +} diff --git a/src/lib/snapshot-denormalize.ts b/src/lib/snapshot-denormalize.ts index 0b7dce9e07..2845fc32bd 100644 --- a/src/lib/snapshot-denormalize.ts +++ b/src/lib/snapshot-denormalize.ts @@ -9,6 +9,7 @@ import type { LiveSessionSnapshot, PendingPlanApprovalState, PendingQuestionState, + PendingRenderPageState, PromptCapabilitiesInfo, RemoteControlInfo, SessionConfigOptionInfo, @@ -70,6 +71,10 @@ export interface SnapshotPatch { * so a client attaching mid-turn re-renders the plan-approval card. `null` * when no approval is pending. */ pendingPlanApproval: PendingPlanApprovalState | null + /** Awaiting-answer `render_page` carried by the snapshot, so a client + * attaching mid-turn re-renders the page card. `null` when no page is + * awaiting an answer. */ + pendingRenderPage: PendingRenderPageState | null /** In-flight user prompt carried by the snapshot, so a client attaching * mid-turn can synthesize the user turn (Bug-2 / cross-client viewing). * `null` when no turn is in flight. */ @@ -167,6 +172,8 @@ export function denormalizeSnapshot(wire: LiveSessionSnapshot): SnapshotPatch { pendingAskQuestion: wire.pending_question ?? null, // The snapshot shape already matches PendingPlanApprovalState; pass through. pendingPlanApproval: wire.pending_plan_approval ?? null, + // Same pass-through for the awaiting-answer rendered page. + pendingRenderPage: wire.pending_render_page ?? null, pendingUserMessage: wire.pending_user_message ? { messageId: wire.pending_user_message.message_id, diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index 75cc9d185e..5d3940ae0c 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -158,6 +158,12 @@ const EXACT_TOOL_NAME_ALIASES: Record = { check_user_feedback: "check_user_feedback", "mcp__codeg-mcp__check_user_feedback": "check_user_feedback", mcp__codeg__check_user_feedback: "check_user_feedback", + // codeg-mcp rendered-page tool (server prefix varies by host; the suffix + // rule in `normalizeToolName` covers the other separators). Its own + // canonical name — the in-place card in `content-parts-renderer` keys on it. + render_page: "render_page", + "mcp__codeg-mcp__render_page": "render_page", + mcp__codeg__render_page: "render_page", // OpenCode // OpenCode 2 renamed `apply_patch` to `patch` (the edit tool its `gpt-*` // models get instead of edit/write) and `task` to `subagent` @@ -521,6 +527,10 @@ function inferFromInput( // stream resolves to "question" before the tool result arrives. if (hasAnyKey(parsed, ["question", "questions"])) return "question" + // `awaitAnswer` is the codeg-mcp `render_page` payload's own key — the live + // stream resolves to "render_page" before the tool result arrives. + if (hasAnyKey(parsed, ["awaitAnswer"])) return "render_page" + // `subagent_type` is the Claude Code Task shape; `subagentType` is Cursor's // task tool (a protobuf-es oneof object on the live wire). if (hasAnyKey(parsed, ["subagent_type", "subagentType"])) { @@ -669,6 +679,11 @@ function normalizeUncached(toolName: string): string { // freeform matcher below intentionally does NOT catch the underscore form. if (/[^a-z0-9]ask_user_question$/.test(canonical)) return "question" + // codeg-mcp rendered-page tool. Same host-prefix story as the ask tool + // above (`mcp____render_page`, `/render_page`, …) — collapse + // every separator to the canonical name the in-place card dispatches on. + if (/[^a-z0-9]render_page$/.test(canonical)) return "render_page" + // codeg-mcp live-feedback poll. Same host-prefix story as the delegation tools // (`mcp____check_user_feedback`, `/check_user_feedback`, …) — // collapse every separator to the canonical name the renderer dispatches on. diff --git a/src/lib/types.ts b/src/lib/types.ts index 0ec29db6a8..31c0cc02c0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1677,6 +1677,30 @@ export interface PlanApprovalAnswer { feedback?: string | null } +// --- render_page (mirror of Rust `crate::acp::render_page`) --- + +/** Awaiting-answer `render_page` on the session (mirror of + * `PendingRenderPageState`). The agent is blocked until the user submits on + * the page or dismisses the card. `html` doubles as the identity an in-place + * tool card matches against to know it is the live, answerable one. */ +export interface PendingRenderPageState { + page_id: string + title: string + html: string + created_at: string +} + +/** The user's submission to `acp_answer_render_page` (mirror of Rust + * `RenderPageAnswer`). `summary` is the page's few-word recap (what the + * page's script passed to `codeg.render.submit`, or the card's fallback); + * `data` is the JSON the page submitted; `declined` is set when the user + * dismissed the card without answering. camelCase on the wire. */ +export interface RenderPageAnswer { + summary?: string | null + data?: unknown + declined: boolean +} + export interface SessionModeInfo { id: string name: string @@ -3390,6 +3414,27 @@ export type AcpEvent = type: "plan_approval_resolved" approval_id: string } + /** + * An agent called `render_page` with `awaitAnswer`: the self-contained HTML + * page is live and the agent is blocked on an answer submitted on it. + * Broadcast so every client renders the pinned page card above the input; + * also captured in the snapshot for mid-turn attach. Only awaiting pages + * broadcast — a show-only page never reaches the wire as an event. + */ + | { + type: "render_page_request" + page_id: string + title: string + html: string + } + /** + * A pending rendered page was answered (from any client) or canceled (tool + * call aborted / connection drained). Clients clear the matching card. + */ + | { + type: "render_page_resolved" + page_id: string + } /** * The agent's effective settings (env vars / model provider / native config) * changed AFTER this connection spawned, so the running process is still on @@ -3769,6 +3814,9 @@ export interface LiveSessionSnapshot { /** Awaiting-decision Grok `exit_plan_mode` approval, recoverable on mid-turn * attach. Absent (omitted) when no approval is pending. */ pending_plan_approval?: PendingPlanApprovalState | null + /** Awaiting-answer `render_page`, recoverable on mid-turn attach. Absent + * (omitted) when no page is awaiting an answer. */ + pending_render_page?: PendingRenderPageState | null /** In-flight user prompt for the current turn — lets a client attaching * mid-turn render the user turn. Absent (omitted) when no turn is in flight. */ pending_user_message?: {