#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod edge;
pub use edge::{EdgeAdapter, build_attribution};
use std::sync::Arc;
use connectrpc::client::{ClientConfig, HttpClient};
use futures::Stream;
use polyc_agent::text_message;
use polyc_proto::proto::polychrome::agent::v1::{
AgentEnd, AgentRequest, AgentServiceClient, AgentStart, ClassifyRequest, Message,
ParticipantMessage, Verdict, agent_response, content, thought_summary_content,
tool_call_content,
};
use polyc_proto::proto::polychrome::approval::v1::{
ApprovalResponseRequest, ApprovalServiceClient,
};
use polyc_proto::proto::polychrome::persona::v1::{
CompleteLinkRequest, DescribeRequest, LinkOutcome, PersonaServiceClient, StartDeepLinkRequest,
StartLinkRequest,
};
#[derive(Debug, thiserror::Error)]
pub enum DialError {
#[error("invalid agent address {addr:?}: {source}")]
InvalidAddress {
addr: String,
#[source]
source: http::uri::InvalidUri,
},
#[error("tls setup failed for agent address: {0}")]
Tls(String),
#[error(transparent)]
Connect(#[from] connectrpc::ConnectError),
}
impl DialError {
#[must_use]
pub const fn code(&self) -> Option<connectrpc::ErrorCode> {
match self {
Self::Connect(e) => Some(e.code),
Self::InvalidAddress { .. } | Self::Tls(_) => None,
}
}
#[must_use]
pub const fn is_retryable(&self) -> bool {
matches!(
self.code(),
Some(
connectrpc::ErrorCode::Unavailable
| connectrpc::ErrorCode::DeadlineExceeded
| connectrpc::ErrorCode::ResourceExhausted
| connectrpc::ErrorCode::Aborted
)
)
}
}
const AGENT_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(3);
const CONTROL_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
fn http_client_for(uri: &http::Uri) -> Result<HttpClient, DialError> {
if uri.scheme_str() == Some("https") {
use rustls_platform_verifier::ConfigVerifierExt;
let tls = rustls::ClientConfig::with_platform_verifier()
.map_err(|e| DialError::Tls(e.to_string()))?;
Ok(HttpClient::with_tls(std::sync::Arc::new(tls)))
} else {
Ok(HttpClient::plaintext())
}
}
fn traced_options() -> connectrpc::client::CallOptions {
let mut headers = http::HeaderMap::new();
polyc_runtime::propagation::inject_current_span_into(&mut headers);
connectrpc::client::CallOptions::default()
.with_headers(headers.into_iter().filter_map(|(n, v)| n.map(|n| (n, v))))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TurnEvent {
TextDelta(String),
ToolStarted {
name: String,
},
ApprovalPending {
request_id: String,
tool_name: String,
title: String,
args_json: String,
},
HandoffStarted {
child_agent_id: String,
reason: String,
},
Done,
}
#[derive(Clone)]
pub struct AgentDialer {
client: Arc<AgentServiceClient<HttpClient>>,
}
impl AgentDialer {
pub fn new(addr: &str) -> Result<Self, DialError> {
let uri = addr
.parse::<http::Uri>()
.map_err(|source| DialError::InvalidAddress {
addr: addr.to_owned(),
source,
})?;
let http = http_client_for(&uri)?;
let config = ClientConfig::new(uri).with_default_timeout(AGENT_DIAL_TIMEOUT);
let client = AgentServiceClient::new(http, config);
Ok(Self {
client: Arc::new(client),
})
}
pub fn approval_dialer(addr: &str) -> Result<ApprovalDialer, DialError> {
ApprovalDialer::new(addr)
}
pub async fn run_turn(
&self,
conversation_id: &str,
exec_id: &str,
user_text: &str,
) -> Result<String, DialError> {
self.run_turn_with(conversation_id, exec_id, user_text, Attribution::default())
.await
}
pub async fn run_turn_with(
&self,
conversation_id: &str,
exec_id: &str,
user_text: &str,
attribution: Attribution,
) -> Result<String, DialError> {
let request = build_request(
conversation_id,
exec_id,
vec![text_message("user", user_text)],
None,
attribution,
);
let mut stream = self
.client
.connect_with_options(request, traced_options())
.await?;
let mut text_parts: Vec<String> = Vec::new();
let mut scaffolding: Vec<String> = Vec::new();
while let Some(view) = stream.message().await? {
let response = view.to_owned_message();
match response.r#type {
Some(agent_response::Type::Outputs(outputs)) => {
for msg in outputs.messages {
let is_assistant = matches!(msg.role.as_str(), "model" | "assistant");
let Some(content_block) = msg.content.into_option() else {
continue;
};
match content_block.r#type {
Some(content::Type::Text(t)) if is_assistant && !t.text.is_empty() => {
text_parts.push(t.text);
}
Some(content::Type::Text(_)) => {}
other => {
if let Some(rendered) = render_content(other) {
scaffolding.push(rendered);
}
}
}
}
}
Some(agent_response::Type::End(_)) => break,
None => {
}
}
}
let reply = if text_parts.is_empty() {
scaffolding.join("\n")
} else {
text_parts.join("\n")
};
Ok(reply)
}
pub async fn run_turn_streaming(
&self,
conversation_id: &str,
exec_id: &str,
user_text: &str,
) -> Result<impl Stream<Item = Result<TurnEvent, DialError>>, DialError> {
self.run_turn_streaming_messages(
conversation_id,
exec_id,
vec![text_message("user", user_text)],
)
.await
}
pub async fn run_turn_streaming_messages(
&self,
conversation_id: &str,
exec_id: &str,
messages: Vec<Message>,
) -> Result<impl Stream<Item = Result<TurnEvent, DialError>>, DialError> {
self.run_turn_streaming_messages_with(
conversation_id,
exec_id,
messages,
None,
Attribution::default(),
)
.await
}
pub async fn run_turn_streaming_messages_with(
&self,
conversation_id: &str,
exec_id: &str,
messages: Vec<Message>,
payment_receipt: Option<PaymentReceipt>,
attribution: Attribution,
) -> Result<impl Stream<Item = Result<TurnEvent, DialError>>, DialError> {
let request = build_request(
conversation_id,
exec_id,
messages,
payment_receipt,
attribution,
);
self.run_turn_streaming_request(request).await
}
async fn run_turn_streaming_request(
&self,
request: AgentRequest,
) -> Result<impl Stream<Item = Result<TurnEvent, DialError>>, DialError> {
let mut stream = self
.client
.connect_with_options(request, traced_options())
.await?;
Ok(async_stream::try_stream! {
while let Some(view) = stream.message().await? {
let response = view.to_owned_message();
match response.r#type {
Some(agent_response::Type::Outputs(outputs)) => {
for msg in outputs.messages {
if let Some(event) = message_to_event(msg) {
yield event;
}
}
}
Some(agent_response::Type::End(end)) => {
for event in events_from_end(*end) {
yield event;
}
return;
}
None => {}
}
}
yield TurnEvent::Done;
})
}
pub async fn should_respond(
&self,
conversation_id: &str,
bot_name: &str,
transcript: Vec<ParticipantMessage>,
) -> Result<bool, DialError> {
let request = ClassifyRequest {
conversation_id: conversation_id.to_owned(),
bot_name: bot_name.to_owned(),
transcript,
..Default::default()
};
let resp = self
.client
.classify_with_options(request, traced_options())
.await?
.into_owned();
Ok(resp.verdict.to_i32() == Verdict::VERDICT_RESPOND as i32)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApprovalOutcome {
pub persisted: bool,
pub signature_hex: String,
pub signed_by_hex: String,
}
#[derive(Clone)]
pub struct ApprovalDialer {
client: Arc<ApprovalServiceClient<HttpClient>>,
}
impl ApprovalDialer {
pub fn new(addr: &str) -> Result<Self, DialError> {
let uri = addr
.parse::<http::Uri>()
.map_err(|source| DialError::InvalidAddress {
addr: addr.to_owned(),
source,
})?;
let http = http_client_for(&uri)?;
let client = ApprovalServiceClient::new(
http,
ClientConfig::new(uri).with_default_timeout(CONTROL_DIAL_TIMEOUT),
);
Ok(Self {
client: Arc::new(client),
})
}
pub async fn respond(
&self,
request_id: &str,
approved: bool,
reason: &str,
conversation_id: &str,
) -> Result<ApprovalOutcome, DialError> {
let request = ApprovalResponseRequest {
request_id: request_id.to_owned(),
approved,
reason: reason.to_owned(),
conversation_id: conversation_id.to_owned(),
..Default::default()
};
let reply = self
.client
.respond_with_options(request, traced_options())
.await?
.into_owned();
Ok(ApprovalOutcome {
persisted: reply.persisted,
signature_hex: reply.signature_hex,
signed_by_hex: reply.signed_by_hex,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StartedLink {
pub code: String,
pub expires_at_ms: u64,
pub persona_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StartedDeepLink {
pub token: String,
pub expires_at_ms: u64,
pub persona_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkCeremony {
Linked {
persona_id: String,
},
AlreadyLinked {
persona_id: String,
},
InvalidOrExpired,
Throttled,
Failed,
}
impl LinkCeremony {
#[must_use]
pub const fn user_message(&self) -> &'static str {
match self {
Self::Linked { .. } => {
"✅ Linked — this account now shares one Polychrome persona with your other channels."
}
Self::AlreadyLinked { .. } => {
"✅ Already linked — this account was already on that persona."
}
Self::InvalidOrExpired => {
"That link is invalid or expired. Start a fresh one from your other channel and try again."
}
Self::Throttled => "Too many attempts. Please wait a few minutes and try again.",
Self::Failed => "Sorry — something went wrong completing that link. Please try again.",
}
}
}
#[derive(Clone)]
pub struct PersonaDialer {
client: Arc<PersonaServiceClient<HttpClient>>,
}
impl PersonaDialer {
pub fn new(addr: &str) -> Result<Self, DialError> {
let uri = addr
.parse::<http::Uri>()
.map_err(|source| DialError::InvalidAddress {
addr: addr.to_owned(),
source,
})?;
let http = http_client_for(&uri)?;
let client = PersonaServiceClient::new(
http,
ClientConfig::new(uri).with_default_timeout(CONTROL_DIAL_TIMEOUT),
);
Ok(Self {
client: Arc::new(client),
})
}
pub async fn start_link(&self, identity: ExternalIdentity) -> Result<StartedLink, DialError> {
let request = StartLinkRequest {
identity: buffa::MessageField::some(identity),
..Default::default()
};
let reply = self
.client
.start_link_with_options(request, traced_options())
.await?
.into_owned();
Ok(StartedLink {
code: reply.code,
expires_at_ms: reply.expires_at_ms,
persona_id: reply.persona_id,
})
}
pub async fn start_deeplink(
&self,
identity: ExternalIdentity,
) -> Result<StartedDeepLink, DialError> {
let request = StartDeepLinkRequest {
identity: buffa::MessageField::some(identity),
..Default::default()
};
let reply = self
.client
.start_deep_link_with_options(request, traced_options())
.await?
.into_owned();
Ok(StartedDeepLink {
token: reply.token,
expires_at_ms: reply.expires_at_ms,
persona_id: reply.persona_id,
})
}
pub async fn complete_link(
&self,
code: &str,
identity: ExternalIdentity,
) -> Result<LinkCeremony, DialError> {
let request = CompleteLinkRequest {
code: code.to_owned(),
identity: buffa::MessageField::some(identity),
..Default::default()
};
let reply = self
.client
.complete_link_with_options(request, traced_options())
.await?
.into_owned();
Ok(match reply.outcome.as_known() {
Some(LinkOutcome::Linked) => LinkCeremony::Linked {
persona_id: reply.persona_id,
},
Some(LinkOutcome::AlreadyLinked) => LinkCeremony::AlreadyLinked {
persona_id: reply.persona_id,
},
Some(LinkOutcome::InvalidCode | LinkOutcome::Expired) => LinkCeremony::InvalidOrExpired,
Some(LinkOutcome::Throttled) => LinkCeremony::Throttled,
Some(LinkOutcome::Unspecified) | None => LinkCeremony::Failed,
})
}
pub async fn describe(&self, identity: ExternalIdentity) -> Result<PersonaView, DialError> {
let request = DescribeRequest {
identity: buffa::MessageField::some(identity),
..Default::default()
};
let reply = self
.client
.describe_with_options(request, traced_options())
.await?
.into_owned();
let Some(profile) = reply.profile.into_option() else {
return Ok(PersonaView {
persona_id: reply.persona_id,
..Default::default()
});
};
Ok(PersonaView {
persona_id: reply.persona_id,
status: profile.status,
identities: profile
.identities
.into_iter()
.map(|id| LinkedIdentity {
provider: id.provider,
external_id: id.external_id,
display_name: id.display_name,
})
.collect(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkedIdentity {
pub provider: String,
pub external_id: String,
pub display_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PersonaView {
pub persona_id: String,
pub status: String,
pub identities: Vec<LinkedIdentity>,
}
pub use polyc_proto::proto::polychrome::agent::v1::Message as TurnMessage;
pub use polyc_proto::proto::polychrome::agent::v1::ParticipantMessage as GateMessage;
pub use polyc_proto::proto::polychrome::agent::v1::PaymentReceipt;
pub use polyc_proto::proto::polychrome::persona::v1::ExternalIdentity;
#[derive(Debug, Clone, Default)]
pub struct Attribution {
pub caller: Option<ExternalIdentity>,
pub participants: Vec<ExternalIdentity>,
}
#[must_use]
pub fn attributed_message(speaker: &str, text: &str) -> TurnMessage {
text_message("user", &format!("{speaker}: {text}"))
}
#[must_use]
pub fn user_message(text: &str) -> TurnMessage {
text_message("user", text)
}
#[must_use]
pub fn namespaced_id(namespace: &str, native_id: &str) -> String {
format!("{namespace}:{native_id}")
}
#[must_use]
pub fn hashed_conversation_id(namespace: uuid::Uuid, parts: &[&str]) -> String {
let joined = parts.join(":");
uuid::Uuid::new_v5(&namespace, joined.as_bytes())
.hyphenated()
.to_string()
}
#[must_use]
pub fn framed_conversation_id(namespace: uuid::Uuid, parts: &[&str]) -> String {
let mut framed = String::new();
for p in parts {
framed.push_str(&p.len().to_string());
framed.push(':');
framed.push_str(p);
}
uuid::Uuid::new_v5(&namespace, framed.as_bytes())
.hyphenated()
.to_string()
}
fn build_request(
conversation_id: &str,
exec_id: &str,
messages: Vec<Message>,
payment_receipt: Option<PaymentReceipt>,
attribution: Attribution,
) -> AgentRequest {
AgentRequest {
conversation_id: conversation_id.to_owned(),
exec_id: exec_id.to_owned(),
start: buffa::MessageField::some(AgentStart {
agent_id: String::new(),
agent_config: Vec::new(),
messages,
payment_receipt: payment_receipt
.map_or_else(buffa::MessageField::none, buffa::MessageField::some),
caller: attribution
.caller
.map_or_else(buffa::MessageField::none, buffa::MessageField::some),
participants: attribution.participants,
..Default::default()
}),
..Default::default()
}
}
fn events_from_end(end: AgentEnd) -> Vec<TurnEvent> {
let mut events: Vec<TurnEvent> = end
.pending_approvals
.into_iter()
.map(|pa| TurnEvent::ApprovalPending {
request_id: pa.request_id,
tool_name: pa.tool_name,
title: pa.title,
args_json: pa.args_json,
})
.collect();
if let Some(h) = end.handoff.into_option() {
events.push(TurnEvent::HandoffStarted {
child_agent_id: h.child_agent_id,
reason: h.reason,
});
}
events.push(TurnEvent::Done);
events
}
fn message_to_event(msg: Message) -> Option<TurnEvent> {
let content_block = msg.content.into_option()?;
match content_block.r#type? {
content::Type::ToolCall(tc) => {
let name = match tc.r#type.as_ref() {
Some(tool_call_content::Type::FunctionCall(fc)) if !fc.name.is_empty() => {
fc.name.clone()
}
_ => tc.id.clone(),
};
Some(TurnEvent::ToolStarted { name })
}
content::Type::Text(t) => {
if is_assistant_role(&msg.role) && !t.text.is_empty() {
Some(TurnEvent::TextDelta(t.text))
} else {
None
}
}
_ => None,
}
}
fn is_assistant_role(role: &str) -> bool {
role == "model" || role == "assistant"
}
fn render_content(ty: Option<content::Type>) -> Option<String> {
match ty? {
content::Type::Text(t) => {
if t.text.is_empty() {
None
} else {
Some(t.text)
}
}
content::Type::ToolCall(tc) => {
let name = match tc.r#type.as_ref() {
Some(tool_call_content::Type::FunctionCall(fc)) => fc.name.as_str(),
None => "",
};
if name.is_empty() {
Some(format!("[tool_call:{}]", tc.id))
} else {
Some(format!("[tool_call:{name} {}]", tc.id))
}
}
content::Type::ToolResult(tr) => Some(format!("[tool_result:{}]", tr.call_id)),
content::Type::Thought(t) => {
let mut buf = String::new();
for s in t.summary {
if let Some(thought_summary_content::Type::Text(text)) = s.r#type
&& !text.text.is_empty()
{
if !buf.is_empty() {
buf.push(' ');
}
buf.push_str(&text.text);
}
}
if buf.is_empty() {
None
} else {
Some(format!("[thought] {buf}"))
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use polyc_proto::proto::polychrome::agent::v1::{
Content, FunctionCallContent, TextContent, ThoughtContent, ThoughtSummaryContent,
ToolCallContent, ToolResultContent,
};
#[test]
fn dial_error_retryable_classification() {
use connectrpc::ErrorCode;
for code in [
ErrorCode::Unavailable,
ErrorCode::DeadlineExceeded,
ErrorCode::ResourceExhausted,
ErrorCode::Aborted,
] {
let err = DialError::Connect(connectrpc::ConnectError::new(code, "x"));
assert_eq!(err.code(), Some(code));
assert!(err.is_retryable(), "{code:?} should be retryable");
}
for code in [
ErrorCode::InvalidArgument,
ErrorCode::Unauthenticated,
ErrorCode::NotFound,
ErrorCode::PermissionDenied,
ErrorCode::Internal,
] {
let err = DialError::Connect(connectrpc::ConnectError::new(code, "x"));
assert_eq!(err.code(), Some(code));
assert!(!err.is_retryable(), "{code:?} should not be retryable");
}
let bad_addr = DialError::InvalidAddress {
addr: "http://a b".to_owned(),
source: "http://a b".parse::<http::Uri>().unwrap_err(),
};
assert_eq!(bad_addr.code(), None);
assert!(!bad_addr.is_retryable());
let tls = DialError::Tls("no provider".to_owned());
assert_eq!(tls.code(), None);
assert!(!tls.is_retryable());
}
fn text(s: &str) -> Option<content::Type> {
Some(content::Type::Text(Box::new(TextContent {
text: s.to_owned(),
..Default::default()
})))
}
fn tool_call(id: &str, name: &str) -> Option<content::Type> {
Some(content::Type::ToolCall(Box::new(ToolCallContent {
id: id.to_owned(),
r#type: Some(tool_call_content::Type::FunctionCall(Box::new(
FunctionCallContent {
name: name.to_owned(),
..Default::default()
},
))),
..Default::default()
})))
}
fn tool_result(call_id: &str) -> Option<content::Type> {
Some(content::Type::ToolResult(Box::new(ToolResultContent {
call_id: call_id.to_owned(),
..Default::default()
})))
}
fn thought(summary: &str) -> Option<content::Type> {
Some(content::Type::Thought(Box::new(ThoughtContent {
summary: vec![ThoughtSummaryContent {
r#type: Some(thought_summary_content::Type::Text(Box::new(TextContent {
text: summary.to_owned(),
..Default::default()
}))),
..Default::default()
}],
..Default::default()
})))
}
#[test]
fn renders_text_verbatim() {
assert_eq!(render_content(text("hi")), Some("hi".to_owned()));
}
#[test]
fn renders_tool_call_with_name_and_id() {
assert_eq!(
render_content(tool_call("call_42", "search")),
Some("[tool_call:search call_42]".to_owned())
);
}
#[test]
fn renders_tool_call_without_function_name() {
assert_eq!(
render_content(Some(content::Type::ToolCall(Box::new(ToolCallContent {
id: "call_bare".to_owned(),
r#type: None,
..Default::default()
})))),
Some("[tool_call:call_bare]".to_owned())
);
}
#[test]
fn renders_tool_result_by_call_id() {
assert_eq!(
render_content(tool_result("call_42")),
Some("[tool_result:call_42]".to_owned())
);
}
#[test]
fn renders_thought_summary() {
assert_eq!(
render_content(thought("considering options")),
Some("[thought] considering options".to_owned())
);
}
#[test]
fn empty_text_skipped() {
assert_eq!(render_content(text("")), None);
}
#[test]
fn unknown_variant_skipped() {
assert_eq!(render_content(None), None);
}
fn aggregate(blocks: Vec<Option<content::Type>>) -> String {
let mut parts: Vec<String> = Vec::new();
for b in blocks {
if let Some(s) = render_content(b) {
parts.push(s);
}
}
parts.join("\n")
}
#[test]
fn aggregate_pure_text_turn() {
assert_eq!(
aggregate(vec![text("hello"), text("world")]),
"hello\nworld"
);
}
#[test]
fn aggregate_tool_call_only_turn() {
assert_eq!(
aggregate(vec![tool_call("call_1", "lookup")]),
"[tool_call:lookup call_1]"
);
}
#[test]
fn aggregate_mixed_text_and_tool_call() {
assert_eq!(
aggregate(vec![text("thinking..."), tool_call("call_1", "search")]),
"thinking...\n[tool_call:search call_1]"
);
}
fn message(role: &str, ty: Option<content::Type>) -> Message {
Message {
role: role.to_owned(),
content: buffa::MessageField::some(Content {
r#type: ty,
..Default::default()
}),
..Default::default()
}
}
#[test]
fn assistant_text_becomes_text_delta() {
assert_eq!(
message_to_event(message("assistant", text("hello"))),
Some(TurnEvent::TextDelta("hello".to_owned()))
);
}
#[test]
fn model_role_also_counts_as_assistant() {
assert_eq!(
message_to_event(message("model", text("hi"))),
Some(TurnEvent::TextDelta("hi".to_owned()))
);
}
#[test]
fn tool_role_text_is_skipped() {
assert_eq!(message_to_event(message("tool", text("result blob"))), None);
}
#[test]
fn empty_assistant_text_is_skipped() {
assert_eq!(message_to_event(message("assistant", text(""))), None);
}
#[test]
fn tool_call_becomes_tool_started_with_name() {
assert_eq!(
message_to_event(message("model", tool_call("call_7", "search"))),
Some(TurnEvent::ToolStarted {
name: "search".to_owned()
})
);
}
#[test]
fn tool_call_falls_back_to_call_id() {
assert_eq!(
message_to_event(message(
"tool",
Some(content::Type::ToolCall(Box::new(ToolCallContent {
id: "call_bare".to_owned(),
r#type: None,
..Default::default()
})))
)),
Some(TurnEvent::ToolStarted {
name: "call_bare".to_owned()
})
);
}
#[test]
fn tool_result_produces_no_event() {
assert_eq!(
message_to_event(message("tool", tool_result("call_7"))),
None
);
}
fn map_turn(msgs: Vec<Message>) -> Vec<TurnEvent> {
let mut events: Vec<TurnEvent> = msgs.into_iter().filter_map(message_to_event).collect();
events.push(TurnEvent::Done);
events
}
#[test]
fn synthetic_turn_yields_expected_event_sequence() {
let turn = vec![
message("model", text("Let me look that up.")),
message("tool", tool_call("call_1", "search")),
message("model", text("Found it.")),
];
assert_eq!(
map_turn(turn),
vec![
TurnEvent::TextDelta("Let me look that up.".to_owned()),
TurnEvent::ToolStarted {
name: "search".to_owned()
},
TurnEvent::TextDelta("Found it.".to_owned()),
TurnEvent::Done,
]
);
}
use polyc_proto::proto::polychrome::agent::v1::{
Handoff as WireHandoff, PendingApproval as WirePendingApproval,
};
#[test]
fn end_with_nothing_yields_only_done() {
assert_eq!(events_from_end(AgentEnd::default()), vec![TurnEvent::Done]);
}
#[test]
fn end_with_handoff_yields_handoff_then_done() {
let end = AgentEnd {
handoff: buffa::MessageField::some(WireHandoff {
call_id: "call_1".to_owned(),
child_agent_id: "researcher".to_owned(),
reason: "needs deep dive".to_owned(),
..Default::default()
}),
..Default::default()
};
assert_eq!(
events_from_end(end),
vec![
TurnEvent::HandoffStarted {
child_agent_id: "researcher".to_owned(),
reason: "needs deep dive".to_owned(),
},
TurnEvent::Done,
]
);
}
#[test]
fn end_orders_approvals_before_handoff_before_done() {
let end = AgentEnd {
pending_approvals: vec![WirePendingApproval {
request_id: "r1".to_owned(),
tool_name: "delete_file".to_owned(),
args_json: "{}".to_owned(),
title: "Delete a file".to_owned(),
..Default::default()
}],
handoff: buffa::MessageField::some(WireHandoff {
child_agent_id: "child".to_owned(),
..Default::default()
}),
..Default::default()
};
assert_eq!(
events_from_end(end),
vec![
TurnEvent::ApprovalPending {
request_id: "r1".to_owned(),
tool_name: "delete_file".to_owned(),
title: "Delete a file".to_owned(),
args_json: "{}".to_owned(),
},
TurnEvent::HandoffStarted {
child_agent_id: "child".to_owned(),
reason: String::new(),
},
TurnEvent::Done,
]
);
}
#[test]
fn namespaced_id_is_prefix_colon_native() {
assert_eq!(
namespaced_id("github", "owner/repo#42"),
"github:owner/repo#42"
);
assert_eq!(namespaced_id("web", "abc-123"), "web:abc-123");
}
#[test]
fn hashed_conversation_id_is_deterministic_v5() {
let ns = uuid::Uuid::from_u128(0x1234_5678_9abc_4def_8123_4567_89ab_cdef);
let a = hashed_conversation_id(ns, &["T1", "C1", "169.000"]);
let b = hashed_conversation_id(ns, &["T1", "C1", "169.000"]);
assert_eq!(a, b);
assert_ne!(a, hashed_conversation_id(ns, &["T1", "C2", "169.000"]));
let parsed: uuid::Uuid = a.parse().unwrap();
assert_eq!(parsed.get_version_num(), 5);
}
#[test]
fn framed_conversation_id_resists_separator_collisions() {
let ns = uuid::Uuid::from_u128(0x99);
assert_ne!(
framed_conversation_id(ns, &["a:b", "c"]),
framed_conversation_id(ns, &["a", "b:c"]),
);
assert_eq!(
hashed_conversation_id(ns, &["a:b", "c"]),
hashed_conversation_id(ns, &["a", "b:c"]),
);
let id = framed_conversation_id(ns, &["mail", "<abc@x>"]);
assert_eq!(id, framed_conversation_id(ns, &["mail", "<abc@x>"]));
assert_eq!(id.parse::<uuid::Uuid>().unwrap().get_version_num(), 5);
}
#[test]
fn hashed_conversation_id_matches_slacks_inline_algorithm() {
let ns = uuid::Uuid::from_u128(0xa1b2_c3d4_e5f6_4789_abcd_ef01_2345_6789);
let parts = ["T01234ABCD", "C0B5V0JA401", "1700000000.000100"];
let inline = uuid::Uuid::new_v5(&ns, parts.join(":").as_bytes())
.hyphenated()
.to_string();
assert_eq!(hashed_conversation_id(ns, &parts), inline);
}
#[test]
fn link_outcome_messages_honor_presentation_rules() {
assert!(
LinkCeremony::InvalidOrExpired
.user_message()
.contains("invalid or expired")
);
assert!(LinkCeremony::Throttled.user_message().contains("wait"));
assert!(
LinkCeremony::Linked {
persona_id: "p".to_owned()
}
.user_message()
.contains("Linked")
);
}
}