From ac3b8ddc027568fe4ee78b3296f6408cade9ec69 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Sat, 15 Aug 2026 06:58:11 +0400 Subject: [PATCH] refactor(sdk): own capsule-facing wire types Move the IPC and LLM representations currently exposed by astrid_sdk::types into the SDK while preserving their public paths and serialized shapes. Remove the dependency on core-owned astrid-types and pin the compatibility boundary with wire-shape and source-path regressions.\n\nCloses #66. Signed-off-by: Joshua J. Bouw --- CHANGELOG.md | 6 + Cargo.toml | 1 - astrid-sdk/Cargo.toml | 4 +- astrid-sdk/src/lib.rs | 40 +--- astrid-sdk/src/types/ipc.rs | 322 +++++++++++++++++++++++++++++ astrid-sdk/src/types/llm.rs | 306 +++++++++++++++++++++++++++ astrid-sdk/src/types/mod.rs | 30 +++ astrid-sdk/src/types/wire_tests.rs | 104 ++++++++++ astrid-sdk/tests/types_compat.rs | 17 ++ astrid-sys/Cargo.toml | 2 +- astrid-sys/src/lib.rs | 2 +- 11 files changed, 791 insertions(+), 43 deletions(-) create mode 100644 astrid-sdk/src/types/ipc.rs create mode 100644 astrid-sdk/src/types/llm.rs create mode 100644 astrid-sdk/src/types/mod.rs create mode 100644 astrid-sdk/src/types/wire_tests.rs create mode 100644 astrid-sdk/tests/types_compat.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d09728..98491af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,12 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ### Changed +- **The SDK now owns its capsule-facing IPC and LLM Rust types.** The existing + `astrid_sdk::types::{ipc,llm}` paths and direct re-exports are preserved, but + `astrid-sdk` no longer depends on core's separately published `astrid-types` + crate. Canonical WIT and serialized wire shapes remain the compatibility + boundary; golden tests pin the legacy capsule-facing JSON forms. (#66) + - **Bumped the `contracts` submodule to `278dbca`** (canonical wit `main` with #14 un-stubbed persistent stdin, #17 `astrid:http@1.1.0`, #16 session 1.1.0, and #20 — process file-injection moved off the frozen `astrid:process@1.0.0` onto a new additive `astrid:process@1.1.0`). #20 diff --git a/Cargo.toml b/Cargo.toml index 87cc1b1..ec73c5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,6 @@ rust-version = "1.94" astrid-sdk = { path = "astrid-sdk", version = "0.7.1" } astrid-sdk-macros = { path = "astrid-sdk-macros", version = "0.7.1" } astrid-sys = { path = "astrid-sys", version = "0.7.1" } -astrid-types = { version = "0.7" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" diff --git a/astrid-sdk/Cargo.toml b/astrid-sdk/Cargo.toml index 9e309ed..3be0823 100644 --- a/astrid-sdk/Cargo.toml +++ b/astrid-sdk/Cargo.toml @@ -12,8 +12,8 @@ include = ["src/**/*", "wit/**/*", "Cargo.toml", "LICENSE-*", "README.md"] [dependencies] astrid-sdk-macros = { workspace = true, optional = true } astrid-sys = { workspace = true } -astrid-types = { workspace = true } borsh = { version = "1.0.0", features = ["derive"] } +chrono = { workspace = true } schemars = "0.8" serde = { workspace = true } serde_json = { workspace = true } @@ -22,7 +22,7 @@ thiserror = { workspace = true } # routes v4 through `getrandom`, which astrid-sys configures with a # custom backend on `wasm32-unknown-unknown` that calls # `astrid:sys.random-bytes`. -uuid = { version = "1.10", default-features = false, features = ["v4", "rng-getrandom"] } +uuid = { version = "1.10", default-features = false, features = ["v4", "serde", "rng-getrandom"] } [features] default = ["derive"] diff --git a/astrid-sdk/src/lib.rs b/astrid-sdk/src/lib.rs index 0e3a07d..a72d02a 100644 --- a/astrid-sdk/src/lib.rs +++ b/astrid-sdk/src/lib.rs @@ -63,44 +63,8 @@ use borsh::{BorshDeserialize, BorshSerialize}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use thiserror::Error; -/// Shared Astrid data types (IPC payloads, LLM schemas, kernel API). -/// -/// Re-exported from [`astrid_types`]. SDK-specific types like [`CallerContext`] -/// are also available here. -pub mod types { - use serde::{Deserialize, Serialize}; - - // Sub-modules (re-exported for `astrid_sdk::types::ipc::*` access). - // - // `kernel`/`kernel_api` is intentionally NOT re-exported here — - // those CLI ↔ daemon RPC types now live in `astrid_core::kernel_api` - // (post-PR#752 decoupling) and don't belong in capsule space. - // Capsules use `ipc` and `llm` types only. - pub use astrid_types::ipc; - pub use astrid_types::llm; - - // IPC types - pub use astrid_types::ipc::{ - IpcMessage, IpcPayload, OnboardingField, OnboardingFieldType, SelectionOption, - }; - - // LLM types - pub use astrid_types::llm::{ - ContentPart, LlmResponse, LlmToolDefinition, Message, MessageContent, MessageRole, - StopReason, StreamEvent, ToolCall, ToolCallResult, Usage, - }; - - /// Identifies the caller that triggered the current capsule execution. - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct CallerContext { - /// UUID of the capsule that originated the IPC message. - pub source_id: String, - /// The acting principal (user ID), if available. - pub principal: Option, - /// ISO 8601 timestamp of the originating message. - pub timestamp: String, - } -} +/// Capsule-facing IPC and LLM wire types owned by this SDK. +pub mod types; pub use borsh; pub use serde; pub use serde_json; diff --git a/astrid-sdk/src/types/ipc.rs b/astrid-sdk/src/types/ipc.rs new file mode 100644 index 0000000..f840b97 --- /dev/null +++ b/astrid-sdk/src/types/ipc.rs @@ -0,0 +1,322 @@ +//! Cross-boundary IPC message schemas and payloads. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +/// A cross-boundary message sent over the event bus between WASM guests and the host. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct IpcMessage { + /// Topic pattern or exact match (e.g., `astrid.cli.input`). + pub topic: String, + /// Standardized payload structure. + pub payload: IpcPayload, + /// Optional cryptographic signature for stateless verification across a distributed swarm. + #[serde(default)] + pub signature: Option>, + /// Identifier of the sender plugin or agent. + pub source_id: Uuid, + /// Timestamp when the message was dispatched. Missing timestamps fall + /// back to the Unix epoch because capsule code receives host-stamped + /// messages and does not construct fresh bus metadata. + #[serde(default = "default_unix_epoch")] + pub timestamp: DateTime, + /// Monotonic sequence number assigned by the event bus at publish time. + /// Used by the dispatcher to guarantee in-order delivery per capsule. + #[serde(default)] + pub seq: u64, + /// The principal (user identity) this message is acting on behalf of. + /// + /// `String` rather than a core identity type keeps the SDK independent of + /// runtime internals. Validation happens at the kernel boundary. `None` + /// denotes system events (boot, lifecycle). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub principal: Option, +} + +/// `DateTime` at the Unix epoch — used as the serde default for +/// `timestamp` fields when a message arrives without one. Capsule-side code +/// receives the host-stamped timestamp when one is available. +fn default_unix_epoch() -> DateTime { + // Chrono guarantees epoch is representable; `MIN_UTC` is the safe fallback + // if that contract ever changes. + DateTime::::from_timestamp(0, 0).unwrap_or(DateTime::::MIN_UTC) +} + +impl IpcMessage { + /// Attach a signature for swarm verification. + #[must_use] + pub fn with_signature(mut self, signature: Vec) -> Self { + self.signature = Some(signature); + self + } + + /// Set the acting principal for this message. + #[must_use] + pub fn with_principal(mut self, principal: impl Into) -> Self { + self.principal = Some(principal.into()); + self + } +} + +/// Default session ID for conversations. +fn default_session_id() -> String { + "default".into() +} + +/// Standardized cross-boundary payload schemas. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum IpcPayload { + /// Raw, arbitrary JSON. + RawJson(Value), + /// User input provided via a frontend (CLI, Telegram). + UserInput { + /// The raw text input. + text: String, + /// Session ID for conversation continuity. Defaults to `"default"`. + #[serde(default = "default_session_id")] + session_id: String, + /// Optional extra context. + #[serde(default, skip_serializing_if = "Option::is_none")] + context: Option, + }, + /// A response generated by an agent. + AgentResponse { + /// The text output. + text: String, + /// True if this is the final response in a chain. + is_final: bool, + /// Session ID for multi-session attribution. + #[serde(default = "default_session_id")] + session_id: String, + }, + /// An interceptor or capsule request for capability approval. + ApprovalRequired { + /// Opaque correlation ID. + request_id: String, + /// The action being requested (e.g. "git push"). + action: String, + /// The resource target (e.g. full command string). + resource: String, + /// Justification. + reason: String, + }, + /// Response to an [`ApprovalRequired`](IpcPayload::ApprovalRequired). + ApprovalResponse { + /// Must match the `request_id` from the originating request. + request_id: String, + /// The user's decision. + decision: String, + /// Optional reason for the decision. + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + }, + /// A capsule needs environment variables to be provided by the user. + OnboardingRequired { + /// The ID of the capsule requiring onboarding. + capsule_id: String, + /// Rich field descriptors for each missing env var. + fields: Vec, + }, + /// Request an LLM provider capsule to generate a response. + LlmRequest { + /// The unique ID of the request, used for routing the response stream back. + request_id: Uuid, + /// The requested model name (e.g. "claude-3-5-sonnet"). + model: String, + /// The conversation history. + messages: Vec, + /// The tools available to the model. + tools: Vec, + /// The system prompt. + system: String, + }, + /// A stream event from an LLM provider capsule. + LlmStreamEvent { + /// The unique ID of the request this stream belongs to. + request_id: Uuid, + /// The actual stream event (`TokenDelta`, `ToolCallStart`, etc). + event: super::llm::StreamEvent, + }, + /// The final, non-streaming LLM response. + LlmResponse { + /// The unique ID of the request this response belongs to. + request_id: Uuid, + /// The final response object. + response: super::llm::LlmResponse, + }, + /// Request the Tool Router capsule to execute a tool. + ToolExecuteRequest { + /// The unique ID of the tool call. + call_id: String, + /// The name of the tool to execute. + tool_name: String, + /// The JSON arguments. + arguments: Value, + }, + /// The result of a tool execution. + ToolExecuteResult { + /// The unique ID of the tool call. + call_id: String, + /// The result of the execution. + result: super::llm::ToolCallResult, + }, + /// Request cancellation of in-flight tool executions. + ToolCancelRequest { + /// The call IDs of the tool invocations to cancel. + call_ids: Vec, + }, + /// A capsule is requesting the user to select from a list of options. + SelectionRequired { + /// Opaque ID so the capsule can correlate the response. + request_id: String, + /// Title/prompt shown above the list. + title: String, + /// The selectable options. + options: Vec, + /// IPC topic to publish the user's choice back on. + callback_topic: String, + }, + /// A lifecycle hook is requesting user input via the `elicit` API. + ElicitRequest { + /// Correlation ID. + request_id: Uuid, + /// The capsule requesting input. + capsule_id: String, + /// Field descriptor reusing the onboarding schema. + field: OnboardingField, + }, + /// Response to an [`ElicitRequest`](IpcPayload::ElicitRequest). + ElicitResponse { + /// Must match the `request_id` from the originating request. + request_id: Uuid, + /// The user's input. `None` if the user cancelled. + #[serde(default, skip_serializing_if = "Option::is_none")] + value: Option, + /// For `Array`-type fields, the collected items. + #[serde(default, skip_serializing_if = "Option::is_none")] + values: Option>, + }, + /// A client has connected. + Connect, + /// A client is disconnecting gracefully. + Disconnect { + /// Optional reason for disconnection (e.g. "quit", "timeout"). + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + }, + /// Arbitrary JSON data for unstructured plugins. + Custom { + /// Raw data. + data: Value, + }, + /// Unrecognized payload type from a newer protocol version. + #[serde(other)] + Unknown, +} + +impl IpcPayload { + /// Returns `true` if `tag` matches a known serde variant name. + #[must_use] + pub fn is_known_tag(tag: &str) -> bool { + matches!( + tag, + "raw_json" + | "user_input" + | "agent_response" + | "approval_required" + | "approval_response" + | "onboarding_required" + | "llm_request" + | "llm_stream_event" + | "llm_response" + | "tool_execute_request" + | "tool_execute_result" + | "tool_cancel_request" + | "selection_required" + | "elicit_request" + | "elicit_response" + | "connect" + | "disconnect" + | "custom" + ) + } + + /// Deserialize a JSON [`Value`] into an `IpcPayload`, falling back to + /// [`Custom`](Self::Custom) for unrecognised or missing type tags. + pub fn from_json_value(data: Value) -> Self { + let is_known = data + .get("type") + .and_then(|v| v.as_str()) + .is_some_and(Self::is_known_tag); + + if is_known { + serde_json::from_value::(data.clone()).unwrap_or(Self::Custom { data }) + } else { + Self::Custom { data } + } + } + + /// Serialize only the guest-facing payload data. + /// + /// [`Custom`](Self::Custom) and [`RawJson`](Self::RawJson) payloads return + /// the inner data value directly (no `type` wrapper). Structured variants + /// return the full tagged serialization. + /// + /// # Errors + /// + /// Returns `serde_json::Error` if serialization fails. + pub fn to_guest_bytes(&self) -> Result, serde_json::Error> { + match self { + Self::Custom { data } | Self::RawJson(data) => serde_json::to_vec(data), + other => serde_json::to_vec(other), + } + } +} + +/// A single option in a `SelectionRequired` picker. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SelectionOption { + /// Machine-readable identifier sent back to the capsule. + pub id: String, + /// Human-readable label shown in the picker. + pub label: String, + /// Optional description shown alongside the label. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// A field descriptor for capsule onboarding. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OnboardingField { + /// The environment variable key. + pub key: String, + /// The prompt shown to the user. + pub prompt: String, + /// Optional description for additional context. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The input type for this field. + pub field_type: OnboardingFieldType, + /// Optional default value. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, + /// Placeholder hint text shown when the input is empty (e.g. `"sk-..."`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placeholder: Option, +} + +/// The type of input expected for an onboarding field. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum OnboardingFieldType { + /// Free-form text input. + Text, + /// Masked secret input. + Secret, + /// Selection from a fixed set of choices. + Enum(Vec), + /// Multi-value array input (user adds items one at a time). + Array, +} diff --git a/astrid-sdk/src/types/llm.rs b/astrid-sdk/src/types/llm.rs new file mode 100644 index 0000000..db76fcb --- /dev/null +++ b/astrid-sdk/src/types/llm.rs @@ -0,0 +1,306 @@ +//! LLM types for messages, tools, and streaming. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// A message in the conversation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Message { + /// Message role. + pub role: MessageRole, + /// Message content. + pub content: MessageContent, +} + +impl Message { + /// Create a user message. + pub fn user(content: impl Into) -> Self { + Self { + role: MessageRole::User, + content: MessageContent::Text(content.into()), + } + } + + /// Create an assistant message. + pub fn assistant(content: impl Into) -> Self { + Self { + role: MessageRole::Assistant, + content: MessageContent::Text(content.into()), + } + } + + /// Create a system message. + pub fn system(content: impl Into) -> Self { + Self { + role: MessageRole::System, + content: MessageContent::Text(content.into()), + } + } + + /// Create an assistant message with tool calls. + #[must_use] + pub fn assistant_with_tools(tool_calls: Vec) -> Self { + Self { + role: MessageRole::Assistant, + content: MessageContent::ToolCalls(tool_calls), + } + } + + /// Create a tool result message. + #[must_use] + pub fn tool_result(result: ToolCallResult) -> Self { + Self { + role: MessageRole::Tool, + content: MessageContent::ToolResult(result), + } + } + + /// Get text content if this is a text message. + #[must_use] + pub fn text(&self) -> Option<&str> { + match &self.content { + MessageContent::Text(s) => Some(s), + _ => None, + } + } + + /// Get tool calls if this is a tool call message. + #[must_use] + pub fn tool_calls(&self) -> Option<&[ToolCall]> { + match &self.content { + MessageContent::ToolCalls(calls) => Some(calls), + _ => None, + } + } +} + +/// Message role. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MessageRole { + /// System message (instructions). + System, + /// User message. + User, + /// Assistant message. + Assistant, + /// Tool result. + Tool, +} + +/// Message content. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum MessageContent { + /// Plain text content. + Text(String), + /// Tool calls. + ToolCalls(Vec), + /// Tool result. + ToolResult(ToolCallResult), + /// Multi-part content (text + images). + MultiPart(Vec), +} + +/// A part of multi-part content. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ContentPart { + /// Text content. + Text { + /// The text. + text: String, + }, + /// Image content. + Image { + /// Base64-encoded image data. + data: String, + /// MIME type. + media_type: String, + }, +} + +/// A tool call from the assistant. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolCall { + /// Unique call ID. + pub id: String, + /// Tool name. + pub name: String, + /// Tool arguments (JSON). + pub arguments: Value, +} + +impl ToolCall { + /// Create a new tool call. + pub fn new(id: impl Into, name: impl Into) -> Self { + Self { + id: id.into(), + name: name.into(), + arguments: Value::Object(serde_json::Map::new()), + } + } + + /// Set arguments. + #[must_use] + pub fn with_arguments(mut self, args: Value) -> Self { + self.arguments = args; + self + } + + /// Parse the server and tool name from "server:tool" format. + #[must_use] + pub fn parse_name(&self) -> Option<(&str, &str)> { + self.name.split_once(':') + } +} + +/// Result of a tool call. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolCallResult { + /// Tool call ID this is responding to. + pub call_id: String, + /// Result content. + pub content: String, + /// Whether this is an error result. + #[serde(default)] + pub is_error: bool, +} + +impl ToolCallResult { + /// Create a successful result. + pub fn success(call_id: impl Into, content: impl Into) -> Self { + Self { + call_id: call_id.into(), + content: content.into(), + is_error: false, + } + } + + /// Create an error result. + pub fn error(call_id: impl Into, error: impl Into) -> Self { + Self { + call_id: call_id.into(), + content: error.into(), + is_error: true, + } + } +} + +/// Tool definition for the LLM. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LlmToolDefinition { + /// Tool name. + pub name: String, + /// Description. + pub description: Option, + /// Input JSON schema. + pub input_schema: Value, +} + +impl LlmToolDefinition { + /// Create a new tool definition. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + description: None, + input_schema: serde_json::json!({"type": "object"}), + } + } + + /// Set description. + #[must_use] + pub fn with_description(mut self, desc: impl Into) -> Self { + self.description = Some(desc.into()); + self + } + + /// Set input schema. + #[must_use] + pub fn with_schema(mut self, schema: Value) -> Self { + self.input_schema = schema; + self + } +} + +/// Streaming event from the LLM. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum StreamEvent { + /// Partial text output. + TextDelta(String), + /// Tool call started. + ToolCallStart { + /// Call ID. + id: String, + /// Tool name. + name: String, + }, + /// Tool call arguments delta. + ToolCallDelta { + /// Call ID. + id: String, + /// Partial arguments JSON. + args_delta: String, + }, + /// Tool call completed. + ToolCallEnd { + /// Call ID. + id: String, + }, + /// Reasoning/chain-of-thought delta (used by Z.AI, `DeepSeek`, `OpenAI` o-series, etc.). + ReasoningDelta(String), + /// Usage information. + Usage { + /// Input tokens. + input_tokens: usize, + /// Output tokens. + output_tokens: usize, + }, + /// Stream completed. + Done, + /// Error occurred. + Error(String), +} + +/// LLM response (non-streaming). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LlmResponse { + /// Response message. + pub message: Message, + /// Whether the response has tool calls. + pub has_tool_calls: bool, + /// Stop reason. + pub stop_reason: StopReason, + /// Token usage. + pub usage: Usage, +} + +/// Reason the model stopped generating. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum StopReason { + /// Natural end of response. + EndTurn, + /// Hit max tokens. + MaxTokens, + /// Tool use requested. + ToolUse, + /// Stop sequence hit. + StopSequence, +} + +/// Token usage information. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct Usage { + /// Input tokens. + pub input_tokens: usize, + /// Output tokens. + pub output_tokens: usize, +} + +impl Usage { + /// Total tokens. + #[must_use] + pub fn total(&self) -> usize { + self.input_tokens.saturating_add(self.output_tokens) + } +} diff --git a/astrid-sdk/src/types/mod.rs b/astrid-sdk/src/types/mod.rs new file mode 100644 index 0000000..cf400d3 --- /dev/null +++ b/astrid-sdk/src/types/mod.rs @@ -0,0 +1,30 @@ +//! Capsule-facing Rust representations of Astrid wire data. +//! +//! These types belong to the SDK. The canonical compatibility boundary is the +//! serialized wire shape (and the WIT contracts where applicable), not a +//! dependency on Astrid core's internal crate graph. + +use serde::{Deserialize, Serialize}; + +pub mod ipc; +pub mod llm; + +pub use ipc::{IpcMessage, IpcPayload, OnboardingField, OnboardingFieldType, SelectionOption}; +pub use llm::{ + ContentPart, LlmResponse, LlmToolDefinition, Message, MessageContent, MessageRole, StopReason, + StreamEvent, ToolCall, ToolCallResult, Usage, +}; + +/// Identifies the caller that triggered the current capsule execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallerContext { + /// UUID of the capsule that originated the IPC message. + pub source_id: String, + /// The acting principal (user ID), if available. + pub principal: Option, + /// ISO 8601 timestamp of the originating message. + pub timestamp: String, +} + +#[cfg(test)] +mod wire_tests; diff --git a/astrid-sdk/src/types/wire_tests.rs b/astrid-sdk/src/types/wire_tests.rs new file mode 100644 index 0000000..523ccc7 --- /dev/null +++ b/astrid-sdk/src/types/wire_tests.rs @@ -0,0 +1,104 @@ +use serde_json::{Value, json}; +use uuid::Uuid; + +use super::{ + ContentPart, IpcMessage, IpcPayload, LlmResponse, Message, MessageContent, MessageRole, + StopReason, StreamEvent, ToolCall, Usage, +}; + +#[test] +fn user_input_wire_shape_is_stable() { + let payload = IpcPayload::UserInput { + text: "hello".into(), + session_id: "session-7".into(), + context: Some(json!({"frontend": "cli"})), + }; + + assert_eq!( + serde_json::to_value(payload).unwrap(), + json!({ + "type": "user_input", + "text": "hello", + "session_id": "session-7", + "context": {"frontend": "cli"} + }) + ); +} + +#[test] +fn cli_proxy_frame_keeps_legacy_defaults() { + let frame = json!({ + "topic": "agent.v1.response", + "payload": { + "type": "agent_response", + "text": "done", + "is_final": true, + "session_id": "session-7" + }, + "source_id": Uuid::nil() + }); + + let message: IpcMessage = serde_json::from_value(frame).unwrap(); + assert_eq!(message.topic, "agent.v1.response"); + assert!(message.signature.is_none()); + assert_eq!(message.seq, 0); + assert!(message.principal.is_none()); + assert_eq!(message.timestamp.timestamp(), 0); +} + +#[test] +fn llm_tool_message_wire_shape_is_stable() { + let message = Message::assistant_with_tools(vec![ + ToolCall::new("call-1", "filesystem:read_file") + .with_arguments(json!({"path": "/tmp/example"})), + ]); + + assert_eq!( + serde_json::to_value(message).unwrap(), + json!({ + "role": "assistant", + "content": [{ + "id": "call-1", + "name": "filesystem:read_file", + "arguments": {"path": "/tmp/example"} + }] + }) + ); +} + +#[test] +fn llm_stream_and_response_wire_shapes_are_stable() { + assert_eq!( + serde_json::to_value(StreamEvent::ToolCallStart { + id: "call-1".into(), + name: "search".into(), + }) + .unwrap(), + json!({"ToolCallStart": {"id": "call-1", "name": "search"}}) + ); + + let response = LlmResponse { + message: Message { + role: MessageRole::Assistant, + content: MessageContent::MultiPart(vec![ContentPart::Text { + text: "hello".into(), + }]), + }, + has_tool_calls: false, + stop_reason: StopReason::EndTurn, + usage: Usage { + input_tokens: 3, + output_tokens: 2, + }, + }; + let value = serde_json::to_value(response).unwrap(); + assert_eq!(value["stop_reason"], Value::String("EndTurn".into())); + assert_eq!( + value["usage"], + json!({"input_tokens": 3, "output_tokens": 2}) + ); + assert_eq!( + value["message"]["content"], + json!([{"type": "text", "text": "hello"}]) + ); +} diff --git a/astrid-sdk/tests/types_compat.rs b/astrid-sdk/tests/types_compat.rs new file mode 100644 index 0000000..c1f004d --- /dev/null +++ b/astrid-sdk/tests/types_compat.rs @@ -0,0 +1,17 @@ +use astrid_sdk::types::{ + IpcPayload, Message, + ipc::{IpcPayload as ModuleIpcPayload, OnboardingFieldType}, + llm::{Message as ModuleMessage, MessageRole}, +}; + +#[test] +fn legacy_type_paths_remain_available() { + let direct = Message::user("hello"); + let module: ModuleMessage = direct; + assert_eq!(module.role, MessageRole::User); + + let direct = IpcPayload::Connect; + let module: ModuleIpcPayload = direct; + assert!(matches!(module, ModuleIpcPayload::Connect)); + assert_eq!(OnboardingFieldType::Text, OnboardingFieldType::Text); +} diff --git a/astrid-sys/Cargo.toml b/astrid-sys/Cargo.toml index b8a2b1c..ce90c45 100644 --- a/astrid-sys/Cargo.toml +++ b/astrid-sys/Cargo.toml @@ -20,7 +20,7 @@ include = [ [dependencies] wit-bindgen = { version = "0.54", default-features = false, features = ["macros"] } serde = { version = "1.0", features = ["derive"] } -# Pulled in transitively by `astrid-types`/`uuid`. We register a custom +# Pulled in transitively by `uuid`. We register a custom # backend that routes to `astrid:sys/host.random-bytes` so capsules # building for `wasm32-unknown-unknown` (the Astrid-canonical target — # zero `wasi:*` imports) get a working RNG without ever touching the diff --git a/astrid-sys/src/lib.rs b/astrid-sys/src/lib.rs index e285928..a74ac63 100644 --- a/astrid-sys/src/lib.rs +++ b/astrid-sys/src/lib.rs @@ -124,7 +124,7 @@ pub use generated::*; // zero `wasi:*` imports, so every kernel call shows in // `astrid.audit.*` and the WIT imports list IS the capsule's literal // capability list. The price is that `getrandom` (pulled in -// transitively via `uuid` / `astrid-types`) has no platform backend on +// transitively via `uuid`) has no platform backend on // that target — `HashMap` would panic on first insert without this // shim. //