use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const ACP_METHOD_INITIALIZE: &str = "initialize";
pub const ACP_METHOD_SESSION_NEW: &str = "session/new";
pub const ACP_METHOD_SESSION_LOAD: &str = "session/load";
pub const ACP_METHOD_SESSION_RESUME: &str = "session/resume";
pub const ACP_METHOD_SESSION_PROMPT: &str = "session/prompt";
pub const ACP_METHOD_SESSION_CANCEL: &str = "session/cancel";
pub const ACP_METHOD_SESSION_CANCEL_TOOL_CALL: &str = "session/cancel_tool_call";
pub const ACP_METHOD_SESSION_CLOSE: &str = "session/close";
pub const ACP_METHOD_SESSION_INJECT: &str = "session/inject";
pub const ACP_METHOD_SESSION_INJECT_HOST_EVENT: &str = "session/inject_host_event";
pub const ACP_METHOD_SESSION_REPLACE_INJECT: &str = "session/replace_inject";
pub const ACP_METHOD_SESSION_REVOKE_INJECT: &str = "session/revoke_inject";
pub const ACP_METHOD_SESSION_PENDING_INJECTIONS: &str = "session/pending_injections";
pub const ACP_METHOD_SESSION_PLAN_DOCUMENT_MUTATE: &str = "session/plan_document/mutate";
pub const ACP_PLAN_REVISION_CONFLICT_CODE: i64 = -32009;
pub const ACP_PLAN_MUTATION_BUSY_CODE: i64 = -32010;
pub const ACP_PLAN_REVISION_CONFLICT_SCHEMA: &str = "harn.plan_document_conflict.v1";
pub const ACP_PROMPT_ERROR_DATA_SCHEMA: &str = "harn.acp.prompt_error.v1";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AcpPlanDocumentMutationParams {
pub session_id: String,
pub document_id: String,
pub expected_revision_id: String,
pub mutation: AcpPlanDocumentMutation,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "kind",
rename_all = "snake_case",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub enum AcpPlanDocumentMutation {
Edit {
markdown: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
plan: Option<Box<harn_vm::llm::plan::PlanArtifact>>,
},
AddComment {
#[serde(default, skip_serializing_if = "Option::is_none")]
comment_id: Option<String>,
anchor: harn_vm::llm::plan::PlanCommentAnchor,
body: String,
},
ChangeCommentState {
comment_id: String,
state: harn_vm::llm::plan::PlanCommentState,
#[serde(default, skip_serializing_if = "Option::is_none")]
agent_run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
explanation: Option<String>,
},
Approve {
#[serde(default, skip_serializing_if = "Option::is_none")]
reviewer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpPlanDocumentMutationResult {
pub plan_document: harn_vm::llm::plan::PlanDocument,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum AcpJsonRpcId {
Number(u64),
String(String),
Null,
}
impl From<u64> for AcpJsonRpcId {
fn from(value: u64) -> Self {
Self::Number(value)
}
}
impl From<&str> for AcpJsonRpcId {
fn from(value: &str) -> Self {
Self::String(value.to_string())
}
}
impl From<String> for AcpJsonRpcId {
fn from(value: String) -> Self {
Self::String(value)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpJsonRpcRequest<P = serde_json::Value> {
pub jsonrpc: String,
pub id: AcpJsonRpcId,
pub method: String,
pub params: P,
}
impl<P> AcpJsonRpcRequest<P> {
pub fn new(id: impl Into<AcpJsonRpcId>, method: impl Into<String>, params: P) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id: id.into(),
method: method.into(),
params,
}
}
}
impl<P: Serialize> AcpJsonRpcRequest<P> {
pub fn into_json_value(self) -> Result<serde_json::Value, serde_json::Error> {
serde_json::to_value(self)
}
pub fn into_json_line(self) -> Result<String, serde_json::Error> {
serde_json::to_string(&self)
}
}
impl AcpJsonRpcRequest<serde_json::Value> {
pub fn initialize(id: impl Into<AcpJsonRpcId>) -> Self {
Self::new(id, ACP_METHOD_INITIALIZE, serde_json::json!({}))
}
}
impl AcpJsonRpcRequest<AcpSessionNewParams> {
pub fn session_new(id: impl Into<AcpJsonRpcId>, params: AcpSessionNewParams) -> Self {
Self::new(id, ACP_METHOD_SESSION_NEW, params)
}
}
impl AcpJsonRpcRequest<AcpSessionPromptParams> {
pub fn session_prompt(id: impl Into<AcpJsonRpcId>, params: AcpSessionPromptParams) -> Self {
Self::new(id, ACP_METHOD_SESSION_PROMPT, params)
}
}
impl AcpJsonRpcRequest<AcpSessionIdParams> {
pub fn session_load(id: impl Into<AcpJsonRpcId>, params: AcpSessionIdParams) -> Self {
Self::new(id, ACP_METHOD_SESSION_LOAD, params)
}
pub fn session_resume(id: impl Into<AcpJsonRpcId>, params: AcpSessionIdParams) -> Self {
Self::new(id, ACP_METHOD_SESSION_RESUME, params)
}
pub fn session_cancel(id: impl Into<AcpJsonRpcId>, params: AcpSessionIdParams) -> Self {
Self::new(id, ACP_METHOD_SESSION_CANCEL, params)
}
pub fn session_close(id: impl Into<AcpJsonRpcId>, params: AcpSessionIdParams) -> Self {
Self::new(id, ACP_METHOD_SESSION_CLOSE, params)
}
pub fn session_pending_injections(
id: impl Into<AcpJsonRpcId>,
params: AcpSessionIdParams,
) -> Self {
Self::new(id, ACP_METHOD_SESSION_PENDING_INJECTIONS, params)
}
}
impl AcpJsonRpcRequest<AcpSessionInjectParams> {
pub fn session_inject(id: impl Into<AcpJsonRpcId>, params: AcpSessionInjectParams) -> Self {
Self::new(id, ACP_METHOD_SESSION_INJECT, params)
}
}
impl AcpJsonRpcRequest<AcpSessionInjectHostEventParams> {
pub fn session_inject_host_event(
id: impl Into<AcpJsonRpcId>,
params: AcpSessionInjectHostEventParams,
) -> Self {
Self::new(id, ACP_METHOD_SESSION_INJECT_HOST_EVENT, params)
}
}
impl AcpJsonRpcRequest<AcpSessionReplaceInjectParams> {
pub fn session_replace_inject(
id: impl Into<AcpJsonRpcId>,
params: AcpSessionReplaceInjectParams,
) -> Self {
Self::new(id, ACP_METHOD_SESSION_REPLACE_INJECT, params)
}
}
impl AcpJsonRpcRequest<AcpSessionMessageIdParams> {
pub fn session_revoke_inject(
id: impl Into<AcpJsonRpcId>,
params: AcpSessionMessageIdParams,
) -> Self {
Self::new(id, ACP_METHOD_SESSION_REVOKE_INJECT, params)
}
}
impl AcpJsonRpcRequest<AcpSessionCancelToolCallParams> {
pub fn session_cancel_tool_call(
id: impl Into<AcpJsonRpcId>,
params: AcpSessionCancelToolCallParams,
) -> Self {
Self::new(id, ACP_METHOD_SESSION_CANCEL_TOOL_CALL, params)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpJsonRpcResponse<R = serde_json::Value> {
pub jsonrpc: String,
pub id: AcpJsonRpcId,
pub result: R,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpJsonRpcError {
pub code: i64,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AcpPromptErrorSchema {
#[serde(rename = "harn.acp.prompt_error.v1")]
V1,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpPromptFailureFacts {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retryable: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_after_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attempts: Vec<AcpRoutingAttempt>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub route_unknown: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpRoutingAttempt {
pub index: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl AcpPromptFailureFacts {
pub fn from_thrown(thrown: &serde_json::Value) -> Self {
let Some(object) = thrown.as_object() else {
return Self::default();
};
let string_field = |key: &str| {
object
.get(key)
.and_then(|value| value.as_str())
.map(str::to_string)
};
let kind = string_field("kind");
let retry_after_ms = object
.get("retry_after_ms")
.and_then(serde_json::Value::as_i64);
let retryable = match kind.as_deref() {
Some("transient") => Some(true),
Some("terminal") => Some(false),
_ => retry_after_ms.map(|_| true),
};
let route_unknown = object
.get("no_single_route")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let attempts = object
.get("attempts")
.and_then(serde_json::Value::as_array)
.map(|items| items.iter().map(AcpRoutingAttempt::from_value).collect())
.unwrap_or_default();
Self {
category: string_field("category"),
kind,
reason: string_field("reason"),
code: string_field("code"),
retryable,
retry_after_ms,
provider: string_field("provider"),
model: string_field("model"),
attempts,
route_unknown,
}
}
}
impl AcpRoutingAttempt {
fn from_value(value: &serde_json::Value) -> Self {
let string_field = |key: &str| {
value
.get(key)
.and_then(serde_json::Value::as_str)
.map(str::to_string)
};
let error = value.get("error");
let error_field = |key: &str| {
error
.and_then(|err| err.get(key))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
};
Self {
index: value
.get("index")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0),
provider: string_field("provider"),
model: string_field("model"),
status: string_field("status"),
category: error_field("category"),
reason: error_field("reason"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpPromptErrorData {
pub schema: AcpPromptErrorSchema,
pub terminal_class: harn_vm::llm::AgentTerminalClass,
#[serde(flatten)]
pub facts: AcpPromptFailureFacts,
}
impl AcpPromptErrorData {
pub fn new(terminal_class: harn_vm::llm::AgentTerminalClass) -> Self {
Self::with_facts(terminal_class, AcpPromptFailureFacts::default())
}
pub fn with_facts(
terminal_class: harn_vm::llm::AgentTerminalClass,
facts: AcpPromptFailureFacts,
) -> Self {
Self {
schema: AcpPromptErrorSchema::V1,
terminal_class,
facts,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpJsonRpcErrorResponse {
pub jsonrpc: String,
pub id: AcpJsonRpcId,
pub error: AcpJsonRpcError,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpSessionEnvironmentConfig {
pub kind: harn_vm::security::EnvironmentPolicyKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub grants: Vec<harn_vm::security::GrantSpec>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpSessionNewParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "environmentPolicy")]
pub environment_policy: Option<AcpSessionEnvironmentConfig>,
#[serde(flatten, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl AcpSessionNewParams {
pub fn cwd(cwd: impl Into<String>) -> Self {
Self {
cwd: Some(cwd.into()),
environment_policy: None,
extra: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpSessionRestoreResult {
#[serde(rename = "sessionId")]
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modes: Option<serde_json::Value>,
#[serde(
rename = "configOptions",
default,
skip_serializing_if = "Option::is_none"
)]
pub config_options: Option<serde_json::Value>,
#[serde(flatten, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpSessionIdParams {
#[serde(rename = "sessionId")]
pub session_id: String,
}
impl AcpSessionIdParams {
pub fn new(session_id: impl Into<String>) -> Self {
Self {
session_id: session_id.into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpSessionPromptParams {
#[serde(rename = "sessionId")]
pub session_id: String,
pub prompt: Vec<AcpContentBlock>,
#[serde(flatten, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl AcpSessionPromptParams {
pub fn new(session_id: impl Into<String>, prompt: Vec<AcpContentBlock>) -> Self {
Self {
session_id: session_id.into(),
prompt,
extra: BTreeMap::new(),
}
}
pub fn text(session_id: impl Into<String>, text: impl Into<String>) -> Self {
Self::new(session_id, vec![AcpContentBlock::text(text)])
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpSessionPromptResult {
#[serde(rename = "stopReason")]
pub stop_reason: String,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<AcpMeta>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum AcpContentBlock {
Text {
text: String,
},
Image {
#[serde(rename = "mimeType", alias = "media_type")]
mime_type: String,
#[serde(default, alias = "base64", skip_serializing_if = "Option::is_none")]
data: Option<String>,
#[serde(
default,
alias = "url",
alias = "source_uri",
skip_serializing_if = "Option::is_none"
)]
uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
Audio {
#[serde(rename = "mimeType", alias = "media_type")]
mime_type: String,
#[serde(default, alias = "base64", skip_serializing_if = "Option::is_none")]
data: Option<String>,
#[serde(
default,
alias = "url",
alias = "source_uri",
skip_serializing_if = "Option::is_none"
)]
uri: Option<String>,
},
Resource {
resource: AcpEmbeddedResource,
},
ResourceLink {
uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(
rename = "mimeType",
alias = "media_type",
default,
skip_serializing_if = "Option::is_none"
)]
mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
size: Option<u64>,
},
}
impl AcpContentBlock {
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
pub fn image_data(mime_type: impl Into<String>, data: impl Into<String>) -> Self {
Self::Image {
mime_type: mime_type.into(),
data: Some(data.into()),
uri: None,
detail: None,
}
}
pub fn image_uri(mime_type: impl Into<String>, uri: impl Into<String>) -> Self {
Self::Image {
mime_type: mime_type.into(),
data: None,
uri: Some(uri.into()),
detail: None,
}
}
pub fn audio_data(mime_type: impl Into<String>, data: impl Into<String>) -> Self {
Self::Audio {
mime_type: mime_type.into(),
data: Some(data.into()),
uri: None,
}
}
pub fn audio_uri(mime_type: impl Into<String>, uri: impl Into<String>) -> Self {
Self::Audio {
mime_type: mime_type.into(),
data: None,
uri: Some(uri.into()),
}
}
pub fn embedded_text_resource(
uri: impl Into<String>,
mime_type: impl Into<String>,
text: impl Into<String>,
) -> Self {
Self::Resource {
resource: AcpEmbeddedResource {
uri: uri.into(),
mime_type: Some(mime_type.into()),
text: Some(text.into()),
blob: None,
},
}
}
pub fn resource_link(uri: impl Into<String>) -> Self {
Self::ResourceLink {
uri: uri.into(),
name: None,
title: None,
description: None,
mime_type: None,
size: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpEmbeddedResource {
pub uri: String,
#[serde(rename = "mimeType", alias = "media_type")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AcpSessionInjectMode {
Queue,
Steer,
#[serde(alias = "interrupt")]
InterruptImmediate,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum AcpSessionInjectContent {
Text(String),
Blocks(Vec<AcpContentBlock>),
}
impl From<String> for AcpSessionInjectContent {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<&str> for AcpSessionInjectContent {
fn from(value: &str) -> Self {
Self::Text(value.to_string())
}
}
impl From<Vec<AcpContentBlock>> for AcpSessionInjectContent {
fn from(value: Vec<AcpContentBlock>) -> Self {
Self::Blocks(value)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpMeta {
pub harn: AcpHarnMeta,
#[serde(flatten, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl AcpMeta {
pub fn actor(actor: serde_json::Value) -> Self {
Self {
harn: AcpHarnMeta {
actor: Some(actor),
terminal: None,
extra: BTreeMap::new(),
},
extra: BTreeMap::new(),
}
}
pub fn terminal(terminal: harn_vm::agent_events::AgentTerminalOutcome) -> Self {
Self {
harn: AcpHarnMeta {
actor: None,
terminal: Some(terminal),
extra: BTreeMap::new(),
},
extra: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpHarnMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terminal: Option<harn_vm::agent_events::AgentTerminalOutcome>,
#[serde(flatten, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpSessionInjectParams {
#[serde(rename = "sessionId")]
pub session_id: String,
pub mode: AcpSessionInjectMode,
pub content: AcpSessionInjectContent,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<AcpMeta>,
#[serde(flatten, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AcpSessionInjectHostEventParams {
#[serde(rename = "sessionId")]
pub session_id: String,
pub event: harn_vm::agent_sessions::HostInjectionRequest,
}
impl AcpSessionInjectHostEventParams {
pub fn new(
session_id: impl Into<String>,
event: harn_vm::agent_sessions::HostInjectionRequest,
) -> Self {
Self {
session_id: session_id.into(),
event,
}
}
}
impl AcpSessionInjectParams {
pub fn new(
session_id: impl Into<String>,
mode: AcpSessionInjectMode,
content: impl Into<AcpSessionInjectContent>,
) -> Self {
Self {
session_id: session_id.into(),
mode,
content: content.into(),
meta: None,
extra: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpSessionReplaceInjectParams {
#[serde(rename = "sessionId")]
pub session_id: String,
#[serde(rename = "messageId")]
pub message_id: String,
pub content: AcpSessionInjectContent,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<AcpMeta>,
#[serde(flatten, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl AcpSessionReplaceInjectParams {
pub fn new(
session_id: impl Into<String>,
message_id: impl Into<String>,
content: impl Into<AcpSessionInjectContent>,
) -> Self {
Self {
session_id: session_id.into(),
message_id: message_id.into(),
content: content.into(),
meta: None,
extra: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpSessionMessageIdParams {
#[serde(rename = "sessionId")]
pub session_id: String,
#[serde(rename = "messageId")]
pub message_id: String,
}
impl AcpSessionMessageIdParams {
pub fn new(session_id: impl Into<String>, message_id: impl Into<String>) -> Self {
Self {
session_id: session_id.into(),
message_id: message_id.into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcpSessionCancelToolCallParams {
#[serde(rename = "sessionId")]
pub session_id: String,
#[serde(rename = "toolCallId")]
pub tool_call_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(
rename = "injectReminder",
default,
skip_serializing_if = "Option::is_none"
)]
pub inject_reminder: Option<bool>,
}
impl AcpSessionCancelToolCallParams {
pub fn new(session_id: impl Into<String>, tool_call_id: impl Into<String>) -> Self {
Self {
session_id: session_id.into(),
tool_call_id: tool_call_id.into(),
reason: None,
inject_reminder: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_new_environment_policy_uses_the_harn_extension_field() {
let params = AcpSessionNewParams {
cwd: Some("/workspace".to_string()),
environment_policy: Some(AcpSessionEnvironmentConfig {
kind: harn_vm::security::EnvironmentPolicyKind::Isolated,
grants: Vec::new(),
}),
extra: BTreeMap::new(),
};
let value = serde_json::to_value(params).unwrap();
assert_eq!(value["environmentPolicy"]["kind"], "isolated");
assert!(value.get("profile").is_none());
}
#[test]
fn session_prompt_request_serializes_to_acp_wire_shape() {
let value =
AcpJsonRpcRequest::session_prompt(7, AcpSessionPromptParams::text("sess-1", "hello"))
.into_json_value()
.expect("request serializes");
assert_eq!(
value,
serde_json::json!({
"jsonrpc": "2.0",
"id": 7,
"method": "session/prompt",
"params": {
"sessionId": "sess-1",
"prompt": [{"type": "text", "text": "hello"}],
},
})
);
}
#[test]
fn session_prompt_result_round_trips_typed_terminal_truth() {
let result = AcpSessionPromptResult {
stop_reason: "max_turn_requests".to_string(),
meta: Some(AcpMeta::terminal(
harn_vm::agent_events::AgentTerminalOutcome::new(
harn_vm::agent_events::AgentTerminalKind::PolicyBudget,
"max_iterations",
),
)),
};
let wire = serde_json::to_value(&result).expect("serialize prompt result");
assert_eq!(
wire,
serde_json::json!({
"stopReason": "max_turn_requests",
"_meta": {
"harn": {
"terminal": {
"kind": "policy_budget",
"reason": "max_iterations",
"owner": "policy",
},
},
},
})
);
let restored: AcpSessionPromptResult =
serde_json::from_value(wire).expect("deserialize prompt result");
assert_eq!(restored, result);
}
#[test]
fn a_failed_turn_carries_its_class_onto_the_prompt_result_wire() {
let result = AcpSessionPromptResult {
stop_reason: "refusal".to_string(),
meta: Some(AcpMeta::terminal(
harn_vm::agent_events::AgentTerminalOutcome::new(
harn_vm::agent_events::AgentTerminalKind::ProviderError,
"exception",
)
.with_terminal_class(Some(
harn_vm::llm::AgentTerminalClass::ProviderMisconfigured,
)),
)),
};
let wire = serde_json::to_value(&result).expect("serialize prompt result");
assert_eq!(
wire,
serde_json::json!({
"stopReason": "refusal",
"_meta": {
"harn": {
"terminal": {
"kind": "provider_error",
"reason": "exception",
"owner": "provider",
"terminalClass": "provider_misconfigured",
},
},
},
})
);
let error_frame = serde_json::to_value(AcpPromptErrorData::new(
harn_vm::llm::AgentTerminalClass::ProviderMisconfigured,
))
.expect("serialize prompt error data");
assert_eq!(
wire["_meta"]["harn"]["terminal"]["terminalClass"],
error_frame["terminalClass"]
);
let restored: AcpSessionPromptResult =
serde_json::from_value(wire).expect("deserialize prompt result");
assert_eq!(restored, result);
}
#[test]
fn a_terminal_without_a_class_keeps_the_bytes_it_already_had() {
let result = AcpSessionPromptResult {
stop_reason: "max_turn_requests".to_string(),
meta: Some(AcpMeta::terminal(
harn_vm::agent_events::AgentTerminalOutcome::new(
harn_vm::agent_events::AgentTerminalKind::PolicyBudget,
"max_iterations",
),
)),
};
assert_eq!(
serde_json::to_value(&result).expect("serialize prompt result"),
serde_json::json!({
"stopReason": "max_turn_requests",
"_meta": {
"harn": {
"terminal": {
"kind": "policy_budget",
"reason": "max_iterations",
"owner": "policy",
},
},
},
}),
"additive means absent, not null, for every outcome that has no class"
);
}
#[test]
fn session_prompt_result_preserves_unknown_exception_and_legacy_shape() {
let unknown: AcpSessionPromptResult = serde_json::from_value(serde_json::json!({
"stopReason": "end_turn",
"_meta": {
"harn": {
"terminal": {
"kind": "unknown",
"reason": "exception",
"owner": "unknown",
},
},
},
}))
.expect("deserialize unknown terminal");
assert_eq!(
unknown.meta.expect("terminal metadata").harn.terminal,
Some(harn_vm::agent_events::AgentTerminalOutcome::new(
harn_vm::agent_events::AgentTerminalKind::Unknown,
"exception",
))
);
let legacy: AcpSessionPromptResult =
serde_json::from_value(serde_json::json!({"stopReason": "end_turn"}))
.expect("deserialize legacy prompt result");
assert!(legacy.meta.is_none());
}
#[test]
fn session_inject_request_serializes_mode_and_content() {
let value = AcpJsonRpcRequest::session_inject(
"inject-1",
AcpSessionInjectParams::new(
"sess-1",
AcpSessionInjectMode::Steer,
vec![AcpContentBlock::text("interrupt after this step")],
),
)
.into_json_value()
.expect("request serializes");
assert_eq!(
value,
serde_json::json!({
"jsonrpc": "2.0",
"id": "inject-1",
"method": "session/inject",
"params": {
"sessionId": "sess-1",
"mode": "steer",
"content": [{"type": "text", "text": "interrupt after this step"}],
},
})
);
}
#[test]
fn session_inject_interrupt_mode_accepts_the_bridge_alias() {
let params: AcpSessionInjectParams = serde_json::from_value(serde_json::json!({
"sessionId": "sess-1",
"mode": "interrupt",
"content": "stop before dispatch",
}))
.expect("interrupt alias deserializes");
assert_eq!(params.mode, AcpSessionInjectMode::InterruptImmediate);
assert_eq!(
serde_json::to_value(params.mode).expect("mode serializes"),
serde_json::json!("interrupt_immediate")
);
assert_eq!(
super::super::bridge_mode_for_session_inject(&serde_json::json!({"mode": "interrupt"})),
Ok("interrupt_immediate")
);
}
#[test]
fn resource_link_content_serializes_to_acp_wire_shape() {
let value = serde_json::to_value(AcpContentBlock::resource_link("file:///tmp/report.md"))
.expect("resource link block serializes");
assert_eq!(
value,
serde_json::json!({
"type": "resource_link",
"uri": "file:///tmp/report.md",
})
);
}
#[test]
fn prompt_failure_facts_project_the_routed_provider_and_model() {
let facts = AcpPromptFailureFacts::from_thrown(&serde_json::json!({
"category": "generic",
"kind": "transient",
"reason": "rate_limit",
"code": "provider_exhausted",
"message": "429 from the backup route",
"retry_after_ms": 1200,
"provider": "backup-provider",
"model": "escalated-model",
}));
assert_eq!(facts.category.as_deref(), Some("generic"));
assert_eq!(facts.kind.as_deref(), Some("transient"));
assert_eq!(facts.reason.as_deref(), Some("rate_limit"));
assert_eq!(facts.code.as_deref(), Some("provider_exhausted"));
assert_eq!(facts.retryable, Some(true));
assert_eq!(facts.retry_after_ms, Some(1200));
assert_eq!(facts.provider.as_deref(), Some("backup-provider"));
assert_eq!(facts.model.as_deref(), Some("escalated-model"));
}
#[test]
fn prompt_failure_facts_are_empty_for_non_object_throws() {
let facts = AcpPromptFailureFacts::from_thrown(&serde_json::json!("bare string throw"));
assert_eq!(facts, AcpPromptFailureFacts::default());
}
#[test]
fn terminal_kind_marks_failure_not_retryable() {
let facts = AcpPromptFailureFacts::from_thrown(&serde_json::json!({
"kind": "terminal",
"reason": "provider_exhausted",
}));
assert_eq!(facts.retryable, Some(false));
}
#[test]
fn prompt_failure_facts_project_the_routing_attempt_ledger() {
let facts = AcpPromptFailureFacts::from_thrown(&serde_json::json!({
"kind": "terminal",
"reason": "provider_exhausted",
"provider": "backup-provider",
"model": "backup-model",
"attempts": [
{
"index": 1,
"provider": "primary-provider",
"model": "primary-model",
"status": "failed",
"error": {"category": "circuit_open", "reason": "overloaded"},
},
{
"index": 2,
"provider": "backup-provider",
"model": "backup-model",
"status": "failed",
"error": {"category": "timeout", "reason": "deadline"},
},
],
}));
assert_eq!(facts.provider.as_deref(), Some("backup-provider"));
assert!(!facts.route_unknown);
assert_eq!(facts.attempts.len(), 2);
assert_eq!(facts.attempts[0].index, 1);
assert_eq!(
facts.attempts[0].provider.as_deref(),
Some("primary-provider")
);
assert_eq!(facts.attempts[0].status.as_deref(), Some("failed"));
assert_eq!(facts.attempts[0].category.as_deref(), Some("circuit_open"));
assert_eq!(facts.attempts[1].model.as_deref(), Some("backup-model"));
assert_eq!(facts.attempts[1].reason.as_deref(), Some("deadline"));
}
#[test]
fn composite_failure_projects_route_unknown_with_no_provider() {
let facts = AcpPromptFailureFacts::from_thrown(&serde_json::json!({
"kind": "terminal",
"reason": "provider_exhausted",
"no_single_route": true,
"attempts": [
{"index": 1, "provider": "primary-provider", "model": "primary-model", "status": "failed"},
{"index": 2, "provider": "backup-provider", "model": "backup-model", "status": "failed"},
],
}));
assert!(facts.route_unknown, "composite failure sets routeUnknown");
assert!(
facts.provider.is_none(),
"composite must not carry a provider"
);
assert!(facts.model.is_none(), "composite must not carry a model");
assert_eq!(facts.attempts.len(), 2);
}
#[test]
fn route_unknown_and_attempts_are_omitted_when_absent() {
let facts = AcpPromptFailureFacts::from_thrown(&serde_json::json!({
"kind": "terminal",
"reason": "timeout",
"provider": "acme",
"model": "acme-large",
}));
assert!(facts.attempts.is_empty());
assert!(!facts.route_unknown);
let wire = serde_json::to_value(&facts).expect("serialize");
let object = wire.as_object().expect("facts serialize to an object");
assert!(!object.contains_key("attempts"));
assert!(!object.contains_key("routeUnknown"));
}
#[test]
fn prompt_error_data_round_trips_through_the_flattened_envelope() {
let data = AcpPromptErrorData::with_facts(
harn_vm::llm::AgentTerminalClass::RateLimited,
AcpPromptFailureFacts::from_thrown(&serde_json::json!({
"kind": "transient",
"reason": "rate_limit",
"provider": "acme",
"model": "acme-large",
})),
);
let wire = serde_json::to_value(&data).expect("serialize");
assert_eq!(
wire,
serde_json::json!({
"schema": ACP_PROMPT_ERROR_DATA_SCHEMA,
"terminalClass": "rate_limited",
"kind": "transient",
"reason": "rate_limit",
"retryable": true,
"provider": "acme",
"model": "acme-large",
})
);
let restored: AcpPromptErrorData = serde_json::from_value(wire).expect("deserialize");
assert_eq!(restored, data);
}
#[test]
fn minimal_prompt_error_data_omits_absent_facts() {
let wire = serde_json::to_value(AcpPromptErrorData::new(
harn_vm::llm::AgentTerminalClass::GenericThrow,
))
.expect("serialize");
assert_eq!(
wire,
serde_json::json!({
"schema": ACP_PROMPT_ERROR_DATA_SCHEMA,
"terminalClass": "generic_throw",
})
);
}
}