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.
This commit is contained in:
Kayshen-X 2026-06-12 23:26:39 +08:00
parent 994b55f928
commit cdfb7d9596
5 changed files with 494 additions and 3 deletions

View file

@ -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__<name>__*`).
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<McpHttpServer>,
/// 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<String>,
}
/// 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<String, AcpError> {
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<String, AcpError> {
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<Value> = 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 = &params["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.

View file

@ -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};

View file

@ -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), "");
}
}

View file

@ -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<ChatAttachment>,
/// 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<String>,
}
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<dyn Iterator<Item = ChatDelta> + 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 {

View file

@ -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;