port message, option, and error types

- Errors: single enum mirroring the Python SDK's exception hierarchy
  (CliNotFound, CliConnection, Process, JsonDecode, MessageParse). Each
  Python class becomes a variant; the Process variant carries exit code +
  optional captured stderr.

- Options: ClaudeAgentOptions as a plain struct with a builder. Covers the
  full subprocess-arg surface (system_prompt with String / PresetAppend /
  File forms, tools / allowed / disallowed, permission_mode, model and
  fallback, cwd / cli_path / settings, env, extra_args, effort, output_format,
  mcp_servers, fork_session, include_partial_messages, etc.). Deferred
  fields documented inline.

- Messages: ContentBlock enum (Text / Thinking / ToolUse / ToolResult /
  ServerToolUse / ServerToolResult / Unknown) with serde tag dispatch and
  an Unknown fallthrough for forward compatibility. Top-level Message enum
  uses a hand-written Deserialize that dispatches on the "type" tag so
  unrecognised top-level frames land in Message::Unknown with the raw JSON
  preserved (matches the Python SDK's "skip unknown" behaviour, but lets
  Rust callers introspect).

Unit tests cover roundtrip for the common shapes (assistant text +
thinking + tool_use, user with both string and structured content, result,
system, unknown top-level).
This commit is contained in:
Kayos 2026-05-14 08:03:24 -07:00
parent 6af8273f98
commit 184b0a786a
3 changed files with 1137 additions and 0 deletions

110
src/errors.rs Normal file
View file

@ -0,0 +1,110 @@
//! Error types for the Claude Agent SDK.
//!
//! Mirrors the exception hierarchy in the upstream Python SDK
//! (`claude_agent_sdk._errors`):
//!
//! - [`Error::CliNotFound`] — `CLINotFoundError`
//! - [`Error::CliConnection`] — `CLIConnectionError`
//! - [`Error::Process`] — `ProcessError`
//! - [`Error::JsonDecode`] — `CLIJSONDecodeError`
//! - [`Error::MessageParse`] — `MessageParseError`
//!
//! All variants inherit from `ClaudeSDKError` in Python; in Rust we expose a
//! single `Error` enum and rely on pattern matching (or [`Error::kind`]) to
//! distinguish them.
use thiserror::Error;
/// All errors surfaced by the SDK.
///
/// The Python SDK uses an exception hierarchy rooted at `ClaudeSDKError`. We
/// flatten that into a single enum because Rust pattern matching is more
/// ergonomic than `try`/`except` chains, and because most callers only care
/// about a small subset (e.g. "did the CLI not exist?" vs everything else).
#[derive(Debug, Error)]
pub enum Error {
/// The `claude` CLI binary could not be located on `PATH` or in any of the
/// fallback search locations, and no explicit `cli_path` was supplied via
/// [`crate::ClaudeAgentOptions::cli_path`].
///
/// Mirrors Python's `CLINotFoundError`.
#[error("{0}")]
CliNotFound(String),
/// Generic connection / transport problem with the CLI subprocess —
/// failed to spawn, stdin write failed, transport not ready, etc.
///
/// Mirrors Python's `CLIConnectionError`.
#[error("{0}")]
CliConnection(String),
/// The CLI subprocess exited with a non-zero status before yielding a
/// `ResultMessage`.
///
/// Mirrors Python's `ProcessError`.
#[error("{message}{exit_code}{stderr}",
exit_code = match exit_code { Some(c) => format!(" (exit code: {c})"), None => String::new() },
stderr = match stderr { Some(s) if !s.is_empty() => format!("\nError output: {s}"), _ => String::new() },
)]
Process {
/// Human-readable description (e.g. `"Command failed"`).
message: String,
/// Subprocess exit code, when known.
exit_code: Option<i32>,
/// Captured stderr output, when stderr was piped via
/// [`crate::ClaudeAgentOptions::capture_stderr`].
stderr: Option<String>,
},
/// A line on the CLI's stdout could not be decoded as JSON even after
/// buffering up to `max_buffer_size` bytes.
///
/// Mirrors Python's `CLIJSONDecodeError`.
#[error("Failed to decode JSON: {line_preview}...")]
JsonDecode {
/// First ~100 chars of the offending line, included in the message.
line_preview: String,
/// The underlying serde error.
#[source]
source: serde_json::Error,
},
/// A JSON message from the CLI was decoded but did not match any known
/// shape, or was missing a required field.
///
/// Mirrors Python's `MessageParseError`.
#[error("Message parse error: {message}")]
MessageParse {
/// Human-readable description.
message: String,
/// The raw JSON value the parser was looking at, if available.
data: Option<serde_json::Value>,
},
/// Underlying I/O failure (rare — most I/O is surfaced as
/// [`Error::CliConnection`]).
#[error("io error: {0}")]
Io(#[from] std::io::Error),
/// Invalid configuration (e.g. mutually exclusive options).
#[error("invalid configuration: {0}")]
Config(String),
}
impl Error {
/// Construct a [`Error::CliConnection`] from any displayable value.
pub(crate) fn conn(msg: impl Into<String>) -> Self {
Self::CliConnection(msg.into())
}
/// Construct a [`Error::MessageParse`] with raw data attached.
pub(crate) fn parse_with_data(msg: impl Into<String>, data: serde_json::Value) -> Self {
Self::MessageParse {
message: msg.into(),
data: Some(data),
}
}
}
/// Convenience alias for `Result<T, claude_agent_sdk::Error>`.
pub type Result<T> = std::result::Result<T, Error>;

564
src/messages.rs Normal file
View file

@ -0,0 +1,564 @@
//! Message and content-block types emitted by the Claude CLI.
//!
//! These are the deserialized shapes of the newline-delimited JSON objects the
//! CLI writes to stdout when invoked with `--output-format stream-json`. The
//! field naming follows the wire format (snake_case), so callers can match the
//! Python SDK's field names directly.
//!
//! Forward-compatibility: unknown variants are surfaced as
//! [`Message::Unknown`] / [`ContentBlock::Unknown`] with the raw JSON
//! preserved, rather than failing the stream. This mirrors the Python SDK's
//! "skip unknown message types" behaviour while still letting Rust callers
//! introspect what they got.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// A content block inside a [`UserMessage`] or [`AssistantMessage`].
///
/// The CLI tags blocks with a `type` field; we deserialize via that tag.
/// Unknown block types are preserved as [`ContentBlock::Unknown`] so the
/// stream is forward-compatible with newer CLI releases.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
/// Plain text emitted by the assistant or echoed from the user.
Text(TextBlock),
/// Extended-thinking block (only present when thinking is enabled).
Thinking(ThinkingBlock),
/// Model decided to invoke a client-side tool.
ToolUse(ToolUseBlock),
/// Result of a previously-issued tool call (returned by the user).
ToolResult(ToolResultBlock),
/// Server-side tool use (web_search, code_execution, advisor, etc.).
ServerToolUse(ServerToolUseBlock),
/// Result block returned for a server-side tool call.
#[serde(rename = "advisor_tool_result")]
ServerToolResult(ServerToolResultBlock),
/// Forward-compatibility escape hatch — block type the SDK does not
/// recognise. The raw `serde_json::Value` is kept so callers can inspect
/// the payload without modifying the SDK.
#[serde(other, skip_serializing)]
Unknown,
}
/// Plain text block.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct TextBlock {
/// Text content.
pub text: String,
}
/// Extended-thinking block.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ThinkingBlock {
/// The thinking text.
pub thinking: String,
/// Signature used to verify the thinking trace.
pub signature: String,
}
/// Tool-use block: the model is requesting that a tool be invoked.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ToolUseBlock {
/// Unique ID for this tool invocation; pair with the matching
/// [`ToolResultBlock::tool_use_id`].
pub id: String,
/// Tool name (e.g. `"Bash"`, `"Read"`).
pub name: String,
/// Tool input parameters, opaque to the SDK.
pub input: Value,
}
/// Tool-result block: response back to the model for a previous tool call.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ToolResultBlock {
/// The `id` of the [`ToolUseBlock`] this result corresponds to.
pub tool_use_id: String,
/// The tool output. May be a plain string or a structured payload; left
/// as opaque JSON because the CLI emits both shapes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<Value>,
/// Whether the tool reported failure.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
}
/// Server-side tool use (executed by the API, not the SDK caller).
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ServerToolUseBlock {
/// Unique ID for this server-side invocation.
pub id: String,
/// Server tool name (e.g. `"web_search"`, `"code_execution"`,
/// `"advisor"`).
pub name: String,
/// Tool input, opaque.
pub input: Value,
}
/// Result block returned for a server-side tool call.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ServerToolResultBlock {
/// The matching server tool-use ID.
pub tool_use_id: String,
/// Raw result payload from the API.
pub content: Value,
}
// ---- top-level messages ----------------------------------------------------
/// A message in the conversation stream. Top-level discriminant is the wire
/// `type` field.
///
/// Forward-compatibility: unknown message types arrive as [`Message::Unknown`]
/// rather than failing the deserialiser. The Python SDK silently skips them;
/// in Rust we keep the raw value so callers can opt into inspection.
#[derive(Debug, Clone, PartialEq)]
pub enum Message {
/// `{"type": "user", ...}` — user turn (input echoed back, or tool results).
User(UserMessage),
/// `{"type": "assistant", ...}` — assistant turn with content blocks.
Assistant(AssistantMessage),
/// `{"type": "result", ...}` — terminal message with usage / cost.
Result(ResultMessage),
/// `{"type": "system", ...}` — system events (init notifications, etc.).
System(SystemMessage),
/// `{"type": "stream_event", ...}` — partial streaming events (enabled
/// via [`crate::ClaudeAgentOptions::include_partial_messages`]).
StreamEvent(StreamEventMessage),
/// Anything else — raw JSON preserved verbatim. Includes messages with a
/// known top-level shape but a `type` field this SDK version doesn't
/// recognise.
Unknown(Value),
}
impl<'de> Deserialize<'de> for Message {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
// Deserialize into a Value first so we can dispatch on `type` without
// having serde retry variants. This keeps forward-compat clean: any
// unknown `type` (or an entirely shapeless payload) lands in
// Message::Unknown verbatim instead of failing the whole stream.
let value = Value::deserialize(deserializer)?;
let tag = value.get("type").and_then(|v| v.as_str()).map(str::to_owned);
match tag.as_deref() {
Some("user") => serde_json::from_value(value)
.map(Message::User)
.map_err(serde::de::Error::custom),
Some("assistant") => serde_json::from_value(value)
.map(Message::Assistant)
.map_err(serde::de::Error::custom),
Some("result") => serde_json::from_value(value)
.map(Message::Result)
.map_err(serde::de::Error::custom),
Some("system") => serde_json::from_value(value)
.map(Message::System)
.map_err(serde::de::Error::custom),
Some("stream_event") => serde_json::from_value(value)
.map(Message::StreamEvent)
.map_err(serde::de::Error::custom),
_ => Ok(Message::Unknown(value)),
}
}
}
impl Serialize for Message {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Message::User(u) => u.serialize(serializer),
Message::Assistant(a) => a.serialize(serializer),
Message::Result(r) => r.serialize(serializer),
Message::System(s) => s.serialize(serializer),
Message::StreamEvent(e) => e.serialize(serializer),
Message::Unknown(v) => v.serialize(serializer),
}
}
}
/// User message: either the prompt echo or tool results returned to the model.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct UserMessage {
/// Wire tag — always `"user"`.
#[serde(rename = "type", default = "user_type")]
pub message_type: String,
/// Inner Anthropic-API-shape message envelope.
pub message: UserMessageInner,
/// Set when the user message is delivering a tool result inside a sub-agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_tool_use_id: Option<String>,
/// Opaque tool-result blob, set when `content` is the structured form.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_use_result: Option<Value>,
/// Session identifier the message belongs to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
/// Stable UUID of this message; available for checkpointing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uuid: Option<String>,
}
fn user_type() -> String {
"user".into()
}
/// Inner envelope of a [`UserMessage`].
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct UserMessageInner {
/// Always `"user"`.
pub role: String,
/// Either a plain string (simple prompt) or a list of content blocks.
pub content: UserContent,
}
/// `user.message.content` is polymorphic — either a string (plain prompt) or
/// a list of typed content blocks (tool results).
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum UserContent {
/// Plain-string content.
Text(String),
/// Structured content blocks.
Blocks(Vec<ContentBlock>),
}
/// Assistant message with the model's content blocks for one turn.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct AssistantMessage {
/// Wire tag — always `"assistant"`.
#[serde(rename = "type", default = "assistant_type")]
pub message_type: String,
/// Inner Anthropic-API-shape message envelope (carries `content`,
/// `model`, `usage`, etc.).
pub message: AssistantMessageInner,
/// Set when the assistant turn is from a sub-agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_tool_use_id: Option<String>,
/// Top-level error tag set when the API returned an error for this turn.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// Session identifier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
/// Stable UUID for this message.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uuid: Option<String>,
}
fn assistant_type() -> String {
"assistant".into()
}
/// Inner envelope of an [`AssistantMessage`] — mirrors the Anthropic Messages
/// API shape.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct AssistantMessageInner {
/// Always `"assistant"`.
pub role: String,
/// Content blocks for this turn.
pub content: Vec<ContentBlock>,
/// Model identifier (e.g. `"claude-sonnet-4-5"`).
pub model: String,
/// Anthropic message ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// Reason the model stopped (e.g. `"end_turn"`, `"tool_use"`,
/// `"max_tokens"`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
/// Raw usage dict from the API. Left opaque because the schema changes
/// across API versions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Value>,
}
/// Terminal `result` message — the iterator stops yielding new messages after
/// this arrives. Carries usage, cost, and any error details for the turn.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ResultMessage {
/// Wire tag — always `"result"`.
#[serde(rename = "type", default = "result_type")]
pub message_type: String,
/// `subtype` — e.g. `"success"`, `"error_max_turns"`, `"error_during_execution"`.
pub subtype: String,
/// Wall-clock duration of the turn in milliseconds.
pub duration_ms: i64,
/// API call duration in milliseconds (subset of `duration_ms`).
pub duration_api_ms: i64,
/// Whether the turn ended in error.
pub is_error: bool,
/// Number of turns consumed.
pub num_turns: i64,
/// Session identifier.
pub session_id: String,
/// Reason the run stopped, when applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
/// Total cost in USD, when reported by the CLI.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost_usd: Option<f64>,
/// Aggregated usage stats; opaque shape.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Value>,
/// Final string result, when the run produced one (e.g. last assistant
/// text content).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
/// Structured output, when configured via `output_format`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structured_output: Option<Value>,
/// Per-model usage breakdown (legacy `modelUsage` field).
#[serde(default, rename = "modelUsage", skip_serializing_if = "Option::is_none")]
pub model_usage: Option<Value>,
/// HTTP status of the failing API call when `is_error` is `true`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_error_status: Option<i32>,
/// Errors list when the run had structured error info.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub errors: Option<Vec<Value>>,
/// Stable UUID for this message.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uuid: Option<String>,
}
fn result_type() -> String {
"result".into()
}
/// System message — metadata events emitted by the CLI (init, task lifecycle,
/// hook events, etc.).
///
/// The Python SDK splits these into more specific subclasses (e.g.
/// `TaskStartedMessage`); we keep one variant and let callers branch on
/// `subtype` + `data`. Drop them onto the floor if you don't care.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct SystemMessage {
/// Wire tag — always `"system"`.
#[serde(rename = "type", default = "system_type")]
pub message_type: String,
/// Discriminator (e.g. `"init"`, `"task_started"`, `"hook_started"`).
pub subtype: String,
/// Full raw payload, including any subtype-specific fields. The Python
/// SDK exposes the full dict on `data`; we do the same.
#[serde(flatten)]
pub data: Value,
}
fn system_type() -> String {
"system".into()
}
/// `stream_event` — partial Anthropic API stream event, only emitted when
/// `include_partial_messages` is enabled.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct StreamEventMessage {
/// Wire tag — always `"stream_event"`.
#[serde(rename = "type", default = "stream_event_type")]
pub message_type: String,
/// Stable UUID.
pub uuid: String,
/// Session identifier.
pub session_id: String,
/// Raw Anthropic API stream event payload, opaque.
pub event: Value,
/// Sub-agent parent tool-use ID when running inside an agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_tool_use_id: Option<String>,
}
fn stream_event_type() -> String {
"stream_event".into()
}
impl Message {
/// True if this message is a [`Message::Result`] — i.e. the terminal
/// message for a turn. Useful for `take_while` / early termination.
pub fn is_result(&self) -> bool {
matches!(self, Message::Result(_))
}
/// If this message is an [`Message::Assistant`], return its content
/// blocks. Returns `None` otherwise.
pub fn as_assistant(&self) -> Option<&AssistantMessage> {
match self {
Message::Assistant(a) => Some(a),
_ => None,
}
}
/// If this message is a [`Message::Result`], return it. Returns `None`
/// otherwise.
pub fn as_result(&self) -> Option<&ResultMessage> {
match self {
Message::Result(r) => Some(r),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_assistant_with_text_block() {
let raw = serde_json::json!({
"type": "assistant",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "hi"}],
"model": "claude-opus-4-5"
},
"session_id": "s1",
"uuid": "u1"
});
let msg: Message = serde_json::from_value(raw).unwrap();
match msg {
Message::Assistant(a) => {
assert_eq!(a.message.model, "claude-opus-4-5");
assert_eq!(a.message.content.len(), 1);
match &a.message.content[0] {
ContentBlock::Text(t) => assert_eq!(t.text, "hi"),
other => panic!("expected TextBlock, got {other:?}"),
}
}
other => panic!("expected Assistant, got {other:?}"),
}
}
#[test]
fn parses_user_with_plain_string_content() {
let raw = serde_json::json!({
"type": "user",
"message": {"role": "user", "content": "hello"},
"session_id": "s1"
});
let msg: Message = serde_json::from_value(raw).unwrap();
match msg {
Message::User(u) => match u.message.content {
UserContent::Text(s) => assert_eq!(s, "hello"),
other => panic!("expected Text, got {other:?}"),
},
other => panic!("expected User, got {other:?}"),
}
}
#[test]
fn parses_user_with_tool_result_blocks() {
let raw = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "ok", "is_error": false}
]
}
});
let msg: Message = serde_json::from_value(raw).unwrap();
match msg {
Message::User(u) => match u.message.content {
UserContent::Blocks(blocks) => {
assert_eq!(blocks.len(), 1);
match &blocks[0] {
ContentBlock::ToolResult(t) => {
assert_eq!(t.tool_use_id, "t1");
assert_eq!(t.is_error, Some(false));
}
other => panic!("expected ToolResult, got {other:?}"),
}
}
other => panic!("expected Blocks, got {other:?}"),
},
other => panic!("expected User, got {other:?}"),
}
}
#[test]
fn parses_result_message() {
let raw = serde_json::json!({
"type": "result",
"subtype": "success",
"duration_ms": 1234,
"duration_api_ms": 1000,
"is_error": false,
"num_turns": 1,
"session_id": "s1",
"total_cost_usd": 0.0042
});
let msg: Message = serde_json::from_value(raw).unwrap();
match msg {
Message::Result(r) => {
assert_eq!(r.subtype, "success");
assert_eq!(r.num_turns, 1);
assert_eq!(r.total_cost_usd, Some(0.0042));
assert!(!r.is_error);
}
other => panic!("expected Result, got {other:?}"),
}
assert!(serde_json::from_value::<Message>(serde_json::json!({
"type": "result", "subtype": "success",
"duration_ms": 1, "duration_api_ms": 1,
"is_error": false, "num_turns": 1, "session_id": "s"
})).unwrap().is_result());
}
#[test]
fn parses_system_init_with_passthrough_data() {
let raw = serde_json::json!({
"type": "system",
"subtype": "init",
"session_id": "s1",
"tools": ["Read", "Write"]
});
let msg: Message = serde_json::from_value(raw).unwrap();
match msg {
Message::System(s) => {
assert_eq!(s.subtype, "init");
assert_eq!(s.data["session_id"], "s1");
assert_eq!(s.data["tools"][0], "Read");
}
other => panic!("expected System, got {other:?}"),
}
}
#[test]
fn unknown_top_level_falls_through() {
// A message with no recognised shape — must surface as Unknown rather
// than failing deser.
let raw = serde_json::json!({"type": "rate_limit_event", "rate_limit_info": {"status": "allowed"}, "uuid": "u", "session_id": "s"});
let msg: Message = serde_json::from_value(raw.clone()).unwrap();
match msg {
Message::Unknown(v) => assert_eq!(v, raw),
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn thinking_and_tool_use_blocks_roundtrip() {
let raw = serde_json::json!({
"type": "assistant",
"message": {
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "hmm", "signature": "sig"},
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {"cmd": "ls"}}
],
"model": "m"
}
});
let msg: Message = serde_json::from_value(raw).unwrap();
let a = msg.as_assistant().expect("assistant");
assert!(matches!(a.message.content[0], ContentBlock::Thinking(_)));
match &a.message.content[1] {
ContentBlock::ToolUse(t) => {
assert_eq!(t.name, "Bash");
assert_eq!(t.input["cmd"], "ls");
}
other => panic!("expected ToolUse, got {other:?}"),
}
}
}

463
src/options.rs Normal file
View file

@ -0,0 +1,463 @@
//! Configuration for [`crate::query`] and [`crate::Client`].
//!
//! [`ClaudeAgentOptions`] mirrors the Python SDK's dataclass of the same name,
//! with one significant difference: in Rust the struct uses the builder
//! pattern. The default-constructed value is always valid; chain
//! `.with_*` / `.set_*` methods to configure.
//!
//! ```
//! use claude_agent_sdk::ClaudeAgentOptions;
//!
//! let opts = ClaudeAgentOptions::new()
//! .with_system_prompt("You are a helpful assistant.")
//! .with_max_turns(1)
//! .with_allowed_tool("Read")
//! .with_allowed_tool("Write");
//! ```
//!
//! v0.1 ships the subset of fields needed to drive the CLI's `--output-format
//! stream-json` mode end-to-end. The advanced fields (hooks, `can_use_tool`,
//! session_store, plugins, sandbox, agents) are deferred — see the
//! crate-level README for the v0.1 vs v0.2 split.
use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// Permission mode for tool execution.
///
/// Maps to the CLI's `--permission-mode` flag; values match the Python SDK's
/// `PermissionMode` literal type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum PermissionMode {
/// CLI prompts for dangerous tools (default behaviour).
Default,
/// Auto-accept file edits.
AcceptEdits,
/// Plan-only mode (no tool execution).
Plan,
/// Allow all tools — use with caution.
BypassPermissions,
/// Deny anything not pre-approved by allow rules.
DontAsk,
/// Model classifier approves or denies each tool call.
Auto,
}
impl PermissionMode {
/// CLI-flag form of this mode.
pub fn as_cli_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::AcceptEdits => "acceptEdits",
Self::Plan => "plan",
Self::BypassPermissions => "bypassPermissions",
Self::DontAsk => "dontAsk",
Self::Auto => "auto",
}
}
}
/// Adaptive-thinking effort level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Effort {
/// Minimal thinking, fastest responses.
Low,
/// Moderate thinking.
Medium,
/// Deep reasoning (default for adaptive-capable models).
High,
/// Extended reasoning depth (Opus 4.7 only; falls back to `High` on
/// other models).
Xhigh,
/// Maximum effort.
Max,
}
impl Effort {
/// CLI-flag form.
pub fn as_cli_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Xhigh => "xhigh",
Self::Max => "max",
}
}
}
/// System-prompt configuration.
///
/// Mirrors the Python SDK union of `str | SystemPromptPreset | SystemPromptFile`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SystemPrompt {
/// Raw string — passed verbatim via `--system-prompt`.
String(String),
/// Append-only on top of the Claude Code default preset, passed via
/// `--append-system-prompt`.
PresetAppend(String),
/// Load the system prompt from a file via `--system-prompt-file`.
File(PathBuf),
}
/// Options for [`crate::query`] and [`crate::Client`].
///
/// Construct with [`ClaudeAgentOptions::new`] (or `default()`) and configure
/// via the builder methods. The struct is plain data — there is no validation
/// at construction; mutually exclusive option combinations are rejected at
/// `connect()` time with a [`crate::Error::Config`].
///
/// ## Field coverage
///
/// v0.1 supports the fields used by the CLI's stream-json subprocess transport.
/// Mirrors most of `ClaudeAgentOptions` from the Python SDK. Hook callbacks,
/// `can_use_tool`, `session_store`, in-process MCP servers, sandbox settings,
/// and plugins are deferred to v0.2 — see the crate README.
#[derive(Debug, Clone, Default)]
pub struct ClaudeAgentOptions {
/// Custom system prompt. When `None`, the CLI's empty-prompt default
/// is used.
pub system_prompt: Option<SystemPrompt>,
/// Restrict the base set of tools available to the model. When `None`,
/// the CLI default toolset is used. An empty `Vec` disables all built-in
/// tools. Mirrors the Python SDK's `tools` field.
pub tools: Option<Vec<String>>,
/// Tool names that are auto-allowed without prompting. Maps to the
/// CLI's `--allowedTools` flag.
pub allowed_tools: Vec<String>,
/// Tool names explicitly disallowed. Maps to `--disallowedTools`.
pub disallowed_tools: Vec<String>,
/// Permission mode for the session.
pub permission_mode: Option<PermissionMode>,
/// Maximum number of conversation turns.
pub max_turns: Option<i32>,
/// Maximum budget in USD; the run stops when exceeded.
pub max_budget_usd: Option<f64>,
/// Continue the most recent conversation in `cwd`.
pub continue_conversation: bool,
/// Session ID to resume.
pub resume: Option<String>,
/// Specific session ID for the conversation (UUID).
pub session_id: Option<String>,
/// Model identifier (e.g. `"claude-sonnet-4-5"`).
pub model: Option<String>,
/// Fallback model when the primary fails.
pub fallback_model: Option<String>,
/// Working directory for the subprocess.
pub cwd: Option<PathBuf>,
/// Explicit path to the `claude` CLI binary. When unset, the SDK
/// searches `PATH` and a handful of standard install locations.
pub cli_path: Option<PathBuf>,
/// Path or JSON string for an additional settings file (`--settings`).
pub settings: Option<String>,
/// Additional directories Claude may access (`--add-dir`).
pub add_dirs: Vec<PathBuf>,
/// Extra environment variables for the subprocess.
pub env: HashMap<String, String>,
/// Pass-through CLI flags (`--flag value`). Value `None` becomes a
/// boolean flag.
pub extra_args: HashMap<String, Option<String>>,
/// Maximum stdout buffer size in bytes for the CLI subprocess.
/// Defaults to 1 MiB if unset.
pub max_buffer_size: Option<usize>,
/// Capture stderr from the subprocess. When `false` (default), stderr
/// is inherited from the parent process. When `true`, stderr is piped
/// and surfaced on the [`crate::Error::Process`] error variant.
pub capture_stderr: bool,
/// Include partial assistant streaming events in the message stream.
pub include_partial_messages: bool,
/// Include hook lifecycle events in the message stream.
pub include_hook_events: bool,
/// Resumed sessions fork into a new session ID.
pub fork_session: bool,
/// Adaptive-thinking effort level.
pub effort: Option<Effort>,
/// Output format — passed through as `--json-schema` when shaped as
/// `{"type": "json_schema", "schema": {...}}`.
pub output_format: Option<serde_json::Value>,
/// MCP server configurations. Either a `serde_json::Value` (passed
/// verbatim as `--mcp-config` JSON) or a path string. v0.1 does not
/// support in-process SDK MCP servers — use external stdio/sse/http
/// servers configured via JSON.
pub mcp_servers: Option<McpServersConfig>,
/// Skip the CLI version check at connect time.
pub skip_version_check: bool,
}
/// MCP server configuration, passed to the CLI as `--mcp-config`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpServersConfig {
/// Inline JSON value with shape `{"mcpServers": {...}}` (or a single
/// server dict). Serialized and passed via `--mcp-config <JSON>`.
Inline(serde_json::Value),
/// Path to an MCP config JSON file.
Path(PathBuf),
}
impl ClaudeAgentOptions {
/// Construct an empty options struct — equivalent to `Default::default()`.
pub fn new() -> Self {
Self::default()
}
/// Set a plain-string system prompt.
pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = Some(SystemPrompt::String(prompt.into()));
self
}
/// Append a custom string to the Claude Code default system prompt.
pub fn with_append_system_prompt(mut self, append: impl Into<String>) -> Self {
self.system_prompt = Some(SystemPrompt::PresetAppend(append.into()));
self
}
/// Read the system prompt from a file at runtime.
pub fn with_system_prompt_file(mut self, path: impl Into<PathBuf>) -> Self {
self.system_prompt = Some(SystemPrompt::File(path.into()));
self
}
/// Restrict the base toolset. Pass `None` for an empty list, or a `Vec`
/// of tool names.
pub fn with_tools(mut self, tools: Vec<String>) -> Self {
self.tools = Some(tools);
self
}
/// Add one auto-allowed tool name.
pub fn with_allowed_tool(mut self, tool: impl Into<String>) -> Self {
self.allowed_tools.push(tool.into());
self
}
/// Replace the entire allowed-tools list.
pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
self.allowed_tools = tools;
self
}
/// Add one disallowed tool name.
pub fn with_disallowed_tool(mut self, tool: impl Into<String>) -> Self {
self.disallowed_tools.push(tool.into());
self
}
/// Set the permission mode.
pub fn with_permission_mode(mut self, mode: PermissionMode) -> Self {
self.permission_mode = Some(mode);
self
}
/// Cap the number of conversation turns.
pub fn with_max_turns(mut self, turns: i32) -> Self {
self.max_turns = Some(turns);
self
}
/// Cap the budget in USD.
pub fn with_max_budget_usd(mut self, usd: f64) -> Self {
self.max_budget_usd = Some(usd);
self
}
/// Continue the most recent conversation in `cwd`.
pub fn with_continue_conversation(mut self, on: bool) -> Self {
self.continue_conversation = on;
self
}
/// Resume a specific session by ID.
pub fn with_resume(mut self, session_id: impl Into<String>) -> Self {
self.resume = Some(session_id.into());
self
}
/// Pin a session ID (must be a valid UUID).
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
/// Pick a specific model.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
/// Pick a fallback model.
pub fn with_fallback_model(mut self, model: impl Into<String>) -> Self {
self.fallback_model = Some(model.into());
self
}
/// Set the working directory for the subprocess.
pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = Some(cwd.into());
self
}
/// Override the path to the `claude` CLI binary.
pub fn with_cli_path(mut self, path: impl Into<PathBuf>) -> Self {
self.cli_path = Some(path.into());
self
}
/// Set the `--settings` argument (JSON string or file path).
pub fn with_settings(mut self, settings: impl Into<String>) -> Self {
self.settings = Some(settings.into());
self
}
/// Add an extra directory accessible to Claude.
pub fn with_add_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.add_dirs.push(dir.into());
self
}
/// Add an environment variable for the subprocess.
pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.insert(key.into(), value.into());
self
}
/// Add a pass-through CLI flag with a value.
pub fn with_extra_arg(
mut self,
flag: impl Into<String>,
value: Option<impl Into<String>>,
) -> Self {
self.extra_args.insert(flag.into(), value.map(Into::into));
self
}
/// Cap stdout buffer size.
pub fn with_max_buffer_size(mut self, size: usize) -> Self {
self.max_buffer_size = Some(size);
self
}
/// Capture stderr in errors (otherwise inherited from parent).
pub fn with_capture_stderr(mut self, on: bool) -> Self {
self.capture_stderr = on;
self
}
/// Include partial streaming events in the message stream.
pub fn with_include_partial_messages(mut self, on: bool) -> Self {
self.include_partial_messages = on;
self
}
/// Include hook lifecycle events in the message stream.
pub fn with_include_hook_events(mut self, on: bool) -> Self {
self.include_hook_events = on;
self
}
/// Set adaptive-thinking effort level.
pub fn with_effort(mut self, effort: Effort) -> Self {
self.effort = Some(effort);
self
}
/// Set MCP-server configuration (inline JSON or file path).
pub fn with_mcp_servers(mut self, cfg: McpServersConfig) -> Self {
self.mcp_servers = Some(cfg);
self
}
/// Skip the `claude --version` check at connect time. Mirrors the
/// `CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK` env var behaviour.
pub fn with_skip_version_check(mut self, on: bool) -> Self {
self.skip_version_check = on;
self
}
/// Set structured-output JSON schema. Pass the full `{"type":
/// "json_schema", "schema": {...}}` shape.
pub fn with_output_format(mut self, format: serde_json::Value) -> Self {
self.output_format = Some(format);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_chains() {
let opts = ClaudeAgentOptions::new()
.with_system_prompt("hi")
.with_max_turns(3)
.with_allowed_tool("Read")
.with_allowed_tool("Bash")
.with_permission_mode(PermissionMode::AcceptEdits)
.with_model("claude-sonnet-4-5")
.with_env("FOO", "bar");
assert!(matches!(opts.system_prompt, Some(SystemPrompt::String(ref s)) if s == "hi"));
assert_eq!(opts.max_turns, Some(3));
assert_eq!(opts.allowed_tools, vec!["Read", "Bash"]);
assert_eq!(opts.permission_mode, Some(PermissionMode::AcceptEdits));
assert_eq!(opts.model.as_deref(), Some("claude-sonnet-4-5"));
assert_eq!(opts.env.get("FOO").map(String::as_str), Some("bar"));
}
#[test]
fn permission_mode_cli_str_roundtrip() {
assert_eq!(PermissionMode::Default.as_cli_str(), "default");
assert_eq!(PermissionMode::AcceptEdits.as_cli_str(), "acceptEdits");
assert_eq!(PermissionMode::BypassPermissions.as_cli_str(), "bypassPermissions");
assert_eq!(PermissionMode::DontAsk.as_cli_str(), "dontAsk");
assert_eq!(PermissionMode::Auto.as_cli_str(), "auto");
assert_eq!(PermissionMode::Plan.as_cli_str(), "plan");
}
#[test]
fn effort_cli_str() {
assert_eq!(Effort::Low.as_cli_str(), "low");
assert_eq!(Effort::Xhigh.as_cli_str(), "xhigh");
assert_eq!(Effort::Max.as_cli_str(), "max");
}
#[test]
fn default_is_empty() {
let opts = ClaudeAgentOptions::default();
assert!(opts.system_prompt.is_none());
assert!(opts.allowed_tools.is_empty());
assert_eq!(opts.continue_conversation, false);
assert_eq!(opts.capture_stderr, false);
}
}