From cdfb7d9596ea7725cb06a42849cc37b879f1c54a Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 12 Jun 2026 23:26:39 +0800 Subject: [PATCH] feat(ai): provider trait + ACP session options batch Copilot binary constants fix, ACP NewSessionOptions (mcpServers + system prompt meta), attachment fields on the chat turn surface. --- crates/op-acp/src/client.rs | 127 ++++++++++++++- crates/op-acp/src/lib.rs | 2 +- crates/op-ai/src/chat_history.rs | 246 ++++++++++++++++++++++++++++++ crates/op-ai/src/chat_provider.rs | 121 ++++++++++++++- crates/op-ai/src/lib.rs | 1 + 5 files changed, 494 insertions(+), 3 deletions(-) create mode 100644 crates/op-ai/src/chat_history.rs diff --git a/crates/op-acp/src/client.rs b/crates/op-acp/src/client.rs index 117d73ca0..578c98086 100644 --- a/crates/op-acp/src/client.rs +++ b/crates/op-acp/src/client.rs @@ -24,6 +24,31 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); /// A prompt turn can run a long while — generous ceiling. const PROMPT_TIMEOUT: Duration = Duration::from_secs(600); +/// One MCP server endpoint advertised to the agent in `session/new` +/// (`mcpServers[]`). Serialized as `{ name, type: "http", url, +/// headers: [] }` — the shape `claude-agent-acp` accepts (TS parity: +/// `apps/web/server/api/ai/agent.ts:513-521`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpHttpServer { + /// Server name the agent prefixes tool ids with + /// (`mcp____*`). + pub name: String, + /// HTTP endpoint, e.g. `http://127.0.0.1:3100/mcp`. + pub url: String, +} + +/// Extra `session/new` payload — MCP tool endpoints + the optional +/// `_meta.systemPrompt` override. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct NewSessionOptions { + /// MCP servers the agent should connect to for tools. + pub mcp_servers: Vec, + /// Override the agent's default system prompt via + /// `_meta.systemPrompt` (claude-agent-acp honors this; agents + /// that don't simply ignore the unknown `_meta` key). + pub system_prompt_meta: Option, +} + /// A live ACP connection to one agent. pub struct AcpConnection { engine: JsonRpcEngine, @@ -108,10 +133,39 @@ impl AcpConnection { /// Open a new session, returning its id. pub async fn new_session(&self) -> Result { + self.new_session_with(&NewSessionOptions::default()).await + } + + /// Open a new session carrying MCP server endpoints + an optional + /// `_meta.systemPrompt` override, returning the session id. + /// + /// Mirrors the TS host's `session/new` payload + /// (`apps/web/server/api/ai/agent.ts:576-580`): `cwd` + + /// `mcpServers` (HTTP endpoints the agent connects to for tools) + /// + `_meta: { systemPrompt }` (claude-agent-acp honors it; other + /// agents ignore unknown `_meta`). + pub async fn new_session_with(&self, options: &NewSessionOptions) -> Result { let cwd = std::env::current_dir() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| ".".to_string()); - let params = serde_json::json!({ "cwd": cwd, "mcpServers": [] }); + let servers: Vec = options + .mcp_servers + .iter() + .map(|server| { + // NOTE: claude-agent-acp expects `type: 'http' | 'sse'` + // (not `transport`) — TS agent.ts:507 comment. + serde_json::json!({ + "name": server.name, + "type": "http", + "url": server.url, + "headers": [], + }) + }) + .collect(); + let mut params = serde_json::json!({ "cwd": cwd, "mcpServers": servers }); + if let Some(prompt) = &options.system_prompt_meta { + params["_meta"] = serde_json::json!({ "systemPrompt": prompt }); + } let result = self .engine .call(METHOD_SESSION_NEW, params, HANDSHAKE_TIMEOUT) @@ -352,6 +406,77 @@ mod tests { assert_eq!(note.session_id.as_deref(), Some("sess-1")); } + /// A mock agent that asserts the `session/new` params carry the + /// MCP server list + `_meta.systemPrompt`, encoding the verdict in + /// the returned session id. + async fn mock_agent_checking_session_new( + read: impl AsyncRead + Unpin, + mut write: impl AsyncWrite + Unpin, + ) { + let mut buf = BufReader::new(read); + while let Ok(Some(frame)) = read_frame(&mut buf).await { + let id = frame.get("id").cloned().unwrap_or(Value::Null); + let method = frame.get("method").and_then(|m| m.as_str()).unwrap_or(""); + match method { + "initialize" => { + let resp = serde_json::json!({ + "jsonrpc": "2.0", "id": id, + "result": { "protocolVersion": 1 } + }); + write_frame(&mut write, &resp).await.unwrap(); + } + "session/new" => { + let params = frame.get("params").cloned().unwrap_or(Value::Null); + let server = ¶ms["mcpServers"][0]; + let ok = server["name"] == "openpencil" + && server["type"] == "http" + && server["url"] == "http://127.0.0.1:3100/mcp" + && server["headers"].as_array().is_some_and(Vec::is_empty) + && params["_meta"]["systemPrompt"] == "use the canvas tools" + && params["cwd"].as_str().is_some_and(|c| !c.is_empty()); + let session_id = if ok { "sess-mcp-ok" } else { "sess-bad" }; + let resp = serde_json::json!({ + "jsonrpc": "2.0", "id": id, + "result": { "sessionId": session_id } + }); + write_frame(&mut write, &resp).await.unwrap(); + } + _ => break, + } + } + } + + #[tokio::test] + async fn session_new_carries_mcp_servers_and_system_prompt_meta() { + let (client_w, agent_r) = tokio::io::duplex(8192); + let (agent_w, client_r) = tokio::io::duplex(8192); + tokio::spawn(mock_agent_checking_session_new(agent_r, agent_w)); + + let mut conn = AcpConnection::new(client_r, client_w, None); + conn.initialize("fallback").await.expect("initialize"); + let options = NewSessionOptions { + mcp_servers: vec![McpHttpServer { + name: "openpencil".into(), + url: "http://127.0.0.1:3100/mcp".into(), + }], + system_prompt_meta: Some("use the canvas tools".into()), + }; + let session = conn.new_session_with(&options).await.expect("new_session"); + assert_eq!( + session, "sess-mcp-ok", + "agent saw a TS-shaped mcpServers + _meta.systemPrompt payload" + ); + } + + #[tokio::test] + async fn plain_new_session_sends_empty_server_list_and_no_meta() { + // The default payload must stay byte-compatible with the old + // `{ cwd, mcpServers: [] }` wire (no `_meta` key at all). + let options = NewSessionOptions::default(); + assert!(options.mcp_servers.is_empty()); + assert!(options.system_prompt_meta.is_none()); + } + #[tokio::test] async fn in_flight_call_fails_fast_when_agent_exits() { // Agent end is dropped immediately — no response will come. diff --git a/crates/op-acp/src/lib.rs b/crates/op-acp/src/lib.rs index 9c2eace73..9b644e9de 100644 --- a/crates/op-acp/src/lib.rs +++ b/crates/op-acp/src/lib.rs @@ -24,7 +24,7 @@ pub mod protocol; pub mod transport; pub mod types; -pub use client::{connect_acp_agent, AcpConnection}; +pub use client::{connect_acp_agent, AcpConnection, McpHttpServer, NewSessionOptions}; pub use event_adapter::session_update_to_delta; pub use protocol::{SessionNotification, SessionUpdate}; pub use types::{AcpAgentConfig, AcpAgentInfo, AcpConnectResult, AcpError, ConnectionType}; diff --git a/crates/op-ai/src/chat_history.rs b/crates/op-ai/src/chat_history.rs new file mode 100644 index 000000000..b6f2efbd4 --- /dev/null +++ b/crates/op-ai/src/chat_history.rs @@ -0,0 +1,246 @@ +//! Chat-history trimming + digest helpers. +//! +//! Ports the TS sliding-window policy from +//! `apps/web/src/services/ai/context-optimizer.ts::trimChatHistory`: +//! keep at most [`DEFAULT_MAX_MESSAGES`] recent turns within a +//! [`DEFAULT_MAX_CHARS`] character budget, always preserving the +//! first user message for context continuity. The Rust history is +//! text-only (attachments stay current-turn-only on `ChatRequest`), +//! so the TS attachment-stripping pass has no equivalent here. +//! +//! [`history_digest`] renders a trimmed history into a compact text +//! block for prompt-only transports (CLI subprocess / Copilot / ACP) +//! whose wire carries a single prompt string instead of `messages[]`. +//! +//! DELIBERATE DIVERGENCE from TS: budgets count Rust `char`s (Unicode +//! scalar values), not JS UTF-16 code units, and truncation never +//! splits a character — TS `slice()` can cut a surrogate pair in +//! half. Counts differ only for non-BMP text; the budget is a +//! heuristic, safety wins. + +use crate::chat_provider::ChatHistoryRole; + +/// Sliding-window message cap (TS `DEFAULT_MAX_MESSAGES`). +pub const DEFAULT_MAX_MESSAGES: usize = 10; +/// Sliding-window character budget (TS `DEFAULT_MAX_CHARS`). +pub const DEFAULT_MAX_CHARS: usize = 32_000; +/// Per-message cap inside [`history_digest`] so one giant turn can't +/// eat the whole digest budget. +const DIGEST_PER_MESSAGE_CHARS: usize = 400; +/// Total digest budget — digests ride inside a prompt string, so they +/// stay much smaller than the full `messages[]` window. +pub const DEFAULT_DIGEST_CHARS: usize = 4_000; + +/// Sliding window for chat history (TS `trimChatHistory` port). +/// Keeps the most recent `max_messages` while respecting `max_chars`; +/// always preserves the first user message. A message that overflows +/// the budget is truncated (with a marker) when at least 200 chars of +/// budget remain, else dropped — byte-for-byte the TS policy. +pub fn trim_chat_history( + messages: &[(ChatHistoryRole, String)], + max_messages: usize, + max_chars: usize, +) -> Vec<(ChatHistoryRole, String)> { + if messages.len() <= max_messages { + let total: usize = messages.iter().map(|(_, c)| c.chars().count()).sum(); + if total <= max_chars { + return messages.to_vec(); + } + } + + // Always keep the first user message for context continuity. + let first_user = messages + .iter() + .position(|(role, _)| *role == ChatHistoryRole::User); + let recent_start = messages.len().saturating_sub(max_messages); + + let mut window: Vec<(ChatHistoryRole, String)> = Vec::new(); + let mut char_count = 0usize; + + if let Some(idx) = first_user { + if idx < recent_start { + let (role, content) = &messages[idx]; + window.push((*role, content.clone())); + char_count += content.chars().count(); + } + } + + for (role, content) in &messages[recent_start..] { + let msg_chars = content.chars().count(); + if char_count + msg_chars > max_chars { + // Truncate this message to fit (TS: only when >200 chars + // of budget remain, else stop). + let remaining = max_chars.saturating_sub(char_count); + if remaining > 200 { + let clipped: String = content.chars().take(remaining).collect(); + window.push((*role, format!("{clipped}\n[...truncated...]"))); + } + break; + } + window.push((*role, content.clone())); + char_count += msg_chars; + } + + window +} + +/// Render `history` into a compact transcript digest for transports +/// whose wire is a single prompt string. Empty input → empty string +/// (callers skip the prepend). Each message is clipped to a few +/// hundred chars; the whole digest stays within `max_chars`. +pub fn history_digest(history: &[(ChatHistoryRole, String)], max_chars: usize) -> String { + if history.is_empty() { + return String::new(); + } + let mut out = + String::from("Previous conversation (context only — reply to the final message):"); + let mut used = out.chars().count(); + for (role, content) in history { + let label = match role { + ChatHistoryRole::User => "User", + ChatHistoryRole::Assistant => "Assistant", + }; + let body = content.trim(); + if body.is_empty() { + continue; + } + let clipped: String = if body.chars().count() > DIGEST_PER_MESSAGE_CHARS { + let mut c: String = body.chars().take(DIGEST_PER_MESSAGE_CHARS).collect(); + c.push('…'); + c + } else { + body.to_string() + }; + let line = format!("\n{label}: {clipped}"); + let line_chars = line.chars().count(); + if used + line_chars > max_chars { + break; + } + out.push_str(&line); + used += line_chars; + } + // Header-only digest means every message was empty / over budget — + // nothing useful to prepend. + if out.chars().count() <= 70 && !out.contains('\n') { + return String::new(); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat_provider::ChatHistoryRole::{Assistant, User}; + + fn msg( + role: crate::chat_provider::ChatHistoryRole, + text: &str, + ) -> (crate::chat_provider::ChatHistoryRole, String) { + (role, text.to_string()) + } + + #[test] + fn trim_keeps_short_histories_verbatim() { + let h = vec![msg(User, "a"), msg(Assistant, "b"), msg(User, "c")]; + let out = trim_chat_history(&h, DEFAULT_MAX_MESSAGES, DEFAULT_MAX_CHARS); + assert_eq!(out, h); + } + + #[test] + fn trim_window_keeps_most_recent_messages() { + let h: Vec<_> = (0..20) + .map(|i| { + let role = if i % 2 == 0 { User } else { Assistant }; + msg(role, &format!("m{i}")) + }) + .collect(); + let out = trim_chat_history(&h, 10, DEFAULT_MAX_CHARS); + // First user message ("m0") survives ahead of the recent window. + assert_eq!(out[0].1, "m0"); + // The recent window is the last 10 messages, in order. + assert_eq!(out.len(), 11); + assert_eq!(out[1].1, "m10"); + assert_eq!(out.last().unwrap().1, "m19"); + } + + #[test] + fn trim_first_user_not_duplicated_when_inside_window() { + let h = vec![ + msg(User, "first"), + msg(Assistant, "a1"), + msg(User, "u2"), + msg(Assistant, "a2"), + ]; + // max_messages covers everything but the char budget forces the + // trim path; "first" sits inside the recent window so it must + // not be prepended twice. + let out = trim_chat_history(&h, 10, 8); + let firsts = out.iter().filter(|(_, c)| c == "first").count(); + assert_eq!(firsts, 1, "first user message must appear exactly once"); + } + + #[test] + fn trim_truncates_overflowing_message_with_marker() { + let big = "x".repeat(1_000); + let h = vec![msg(User, "intro"), msg(Assistant, &big)]; + let out = trim_chat_history(&h, 10, 500); + let last = &out.last().unwrap().1; + assert!( + last.ends_with("\n[...truncated...]"), + "overflow message must carry the TS truncation marker, got tail {:?}", + &last[last.len().saturating_sub(30)..] + ); + // 500-char budget minus "intro" (5) leaves 495 chars of body. + assert!(last.chars().count() < 600); + } + + #[test] + fn trim_drops_overflow_message_when_budget_remainder_too_small() { + let h = vec![ + msg(User, &"a".repeat(31_900)), + msg(Assistant, &"b".repeat(500)), + ]; + // Remaining budget after the first message is 100 (< 200), so + // the overflowing second message is dropped, not truncated. + let out = trim_chat_history(&h, 1, 32_000); + assert_eq!(out.len(), 1); + assert!(out[0].1.starts_with('a')); + } + + #[test] + fn digest_renders_role_labelled_lines() { + let h = vec![ + msg(User, "make a card"), + msg(Assistant, "done — added a card"), + ]; + let d = history_digest(&h, DEFAULT_DIGEST_CHARS); + assert!(d.contains("User: make a card")); + assert!(d.contains("Assistant: done — added a card")); + assert!(d.starts_with("Previous conversation")); + } + + #[test] + fn digest_empty_for_empty_history() { + assert_eq!(history_digest(&[], DEFAULT_DIGEST_CHARS), ""); + } + + #[test] + fn digest_clips_long_messages_and_respects_total_budget() { + let h = vec![ + msg(User, &"y".repeat(2_000)), + msg(Assistant, &"z".repeat(2_000)), + ]; + let d = history_digest(&h, 600); + assert!(d.chars().count() <= 600); + assert!( + d.contains('…'), + "long messages are clipped with an ellipsis" + ); + } + + #[test] + fn digest_skips_blank_messages() { + let h = vec![msg(User, " "), msg(Assistant, "")]; + assert_eq!(history_digest(&h, DEFAULT_DIGEST_CHARS), ""); + } +} diff --git a/crates/op-ai/src/chat_provider.rs b/crates/op-ai/src/chat_provider.rs index e2dc0842d..e2cee2b7e 100644 --- a/crates/op-ai/src/chat_provider.rs +++ b/crates/op-ai/src/chat_provider.rs @@ -55,7 +55,10 @@ impl CliName { match self { CliName::ClaudeCode => "claude", CliName::Gemini => "gemini", - CliName::Copilot => "gh-copilot", + // The standalone `copilot` CLI (the `gh-copilot` gh + // extension is retired; the official SDK + model + // discovery both target `copilot`). + CliName::Copilot => "copilot", CliName::Codex => "codex", CliName::OpenCode => "opencode", } @@ -230,10 +233,35 @@ impl ChatAttachment { } } +/// Author of one prior chat turn carried in [`ChatRequest::history`]. +/// Mirrors the TS chat wire's `role: 'user' | 'assistant'`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatHistoryRole { + User, + Assistant, +} + +impl ChatHistoryRole { + /// Lowercase wire token (`"user"` / `"assistant"`). + pub fn as_str(self) -> &'static str { + match self { + ChatHistoryRole::User => "user", + ChatHistoryRole::Assistant => "assistant", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ChatRequest { pub system_prompt: String, pub user_message: String, + /// Prior conversation turns (oldest first), excluding + /// `user_message` itself. Transports that speak a real messages + /// wire (builtin Anthropic / OpenAI-compatible HTTP) send these + /// as full `messages[]` entries; prompt-only CLI transports fold + /// them into a compact digest (see `op_ai::chat_history`). + /// Text-only by design — attachments stay current-turn-only. + pub history: Vec<(ChatHistoryRole, String)>, pub max_output_tokens: u32, /// Thinking-mode control for this turn (default `Adaptive`). pub thinking: ThinkingMode, @@ -242,6 +270,27 @@ pub struct ChatRequest { /// Files attached to this turn (images, …). Empty for a plain /// text turn. Each provider maps these onto its own wire format. pub attachments: Vec, + /// Model id the user picked in the chat model picker (e.g. + /// `gpt-5.5`, `claude-sonnet-4-6`). Each transport forwards it on + /// its own knob (`--model` for Codex, `-m` for Gemini, SDK + /// `options.model` for Claude Code / Copilot). `None` keeps the + /// provider's own default — TS parity: every provider in + /// `apps/web/server/api/ai/chat.ts` only sets the model when one + /// was supplied. + pub model: Option, +} + +impl ChatRequest { + /// The selected model id, trimmed. Returns `None` when unset or + /// blank so transports never emit an empty model flag. + pub fn model_id(&self) -> Option<&str> { + let m = self.model.as_deref()?.trim(); + if m.is_empty() { + None + } else { + Some(m) + } + } } /// Provider abstraction the widget host calls. Implementations live @@ -254,6 +303,42 @@ pub trait ChatProvider: Send + Sync { fn send(&self, request: ChatRequest) -> Box + Send>; } +/// One canvas tool exposed to a tool-capable chat transport (the +/// builtin Anthropic / OpenAI-compatible agent loop). Mirrors the TS +/// `ToolDef` in `apps/web/src/services/ai/agent-tools.ts` — name, +/// description, auth level, and the JSON Schema the wire carries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatToolDef { + pub name: String, + pub description: String, + /// TS `AuthLevel` token: `read` / `create` / `modify` / `delete`. + /// Rides into the transcript tool-card envelope so the chat panel + /// can pick its collapsed/expanded default per level. + pub level: String, + /// Pre-serialized JSON Schema object for the tool's arguments. + pub input_schema_json: String, +} + +/// Result of executing one chat tool call. `content` is the JSON the +/// model sees as the tool result (TS shape: `{"success":true,"data":…}` +/// or `{"success":false,"error":…}`); `is_error` marks transport-level +/// failure so Anthropic `tool_result` blocks can set `is_error`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatToolResult { + pub content: String, + pub is_error: bool, +} + +/// Executes one canvas tool call on behalf of a chat agent loop. The +/// desktop host implements this with a channel bridge that forwards +/// the call to the UI thread (mutations must run against the live +/// `EditorState`), mirroring how the design orchestrator's +/// `RemoteDocSink` forwards `EditorCommand`s. Called from a worker +/// thread — implementations may block until the host replies. +pub trait ChatToolExecutor: Send + Sync { + fn execute(&self, name: &str, args_json: &str) -> ChatToolResult; +} + /// Test double — replays a fixed delta script. Lets the chat widget /// run unit tests without spinning up agent-rs / a CLI subprocess / /// an HTTP server. @@ -390,6 +475,40 @@ mod tests { assert!(req.attachments.is_empty()); } + #[test] + fn chat_request_model_defaults_to_none() { + // No model selected = the CLI keeps its own default; no flag + // is ever emitted for the unset case. + let req = ChatRequest::default(); + assert!(req.model.is_none()); + assert!(req.model_id().is_none()); + } + + #[test] + fn chat_request_model_id_trims_and_rejects_blank() { + let mut req = ChatRequest { + model: Some(" gpt-5.5 ".into()), + ..Default::default() + }; + assert_eq!(req.model_id(), Some("gpt-5.5")); + // Blank / whitespace-only ids are treated as unset so a bare + // `--model` flag can never reach a CLI. + req.model = Some(" ".into()); + assert!(req.model_id().is_none()); + req.model = Some(String::new()); + assert!(req.model_id().is_none()); + } + + #[test] + fn chat_request_history_defaults_empty_and_roles_have_wire_tokens() { + // A defaulted request is single-shot — no prior turns. The + // role tokens match the TS chat wire vocabulary. + let req = ChatRequest::default(); + assert!(req.history.is_empty()); + assert_eq!(ChatHistoryRole::User.as_str(), "user"); + assert_eq!(ChatHistoryRole::Assistant.as_str(), "assistant"); + } + #[test] fn chat_attachment_is_image_checks_media_type() { let png = ChatAttachment { diff --git a/crates/op-ai/src/lib.rs b/crates/op-ai/src/lib.rs index e8cbaebbe..29958bc4e 100644 --- a/crates/op-ai/src/lib.rs +++ b/crates/op-ai/src/lib.rs @@ -16,5 +16,6 @@ //! and web shells can build against it. pub mod agent_settings_state; +pub mod chat_history; pub mod chat_models; pub mod chat_provider;