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:
parent
994b55f928
commit
cdfb7d9596
|
|
@ -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 = ¶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.
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
246
crates/op-ai/src/chat_history.rs
Normal file
246
crates/op-ai/src/chat_history.rs
Normal 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), "");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue