Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,9 @@ aura/

### Shared Event Types (`aura-events`)
- Lightweight crate defining `AuraStreamEvent` and `OrchestrationStreamEvent` enums
- `run::SessionEvent` is a session's own envelope: its `SessionId`, the `RunId` when the event belongs to a run, a `SequenceNumber` dense per session, a `Timestamp`, and either an `agent::AgentEvent` or a `LifecycleEvent` (started, observer attached/detached, claims exhausted, liveness decided, parked, finished, cancelled, failed). `RunId` is a UUID (v7 when minted); there is one per run. No producer emits it yet
- Both `Serialize + Deserialize` — used by the web server (producer) and CLI (consumer)
- No agent, MCP, or provider dependencies — only `serde` and `serde_json`
- No agent, MCP, or provider dependencies — only `serde`, `serde_json`, and `uuid`
- `ProgressToken` type uses a local wire-compatible definition by default; enables `rmcp-types` feature for direct rmcp interop (used by the `aura` crate)

## Environment Setup
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 8 additions & 23 deletions crates/aura-cli/src/api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ pub struct ShellCallDetail {
pub command_name: String,
pub full_command: String,
pub result: String,
#[serde(with = "duration_millis")]
#[serde(with = "aura_events::duration_ms")]
pub duration: Duration,
}

Expand All @@ -233,7 +233,7 @@ pub enum DisplayEvent {
ToolCall {
tool_name: String,
arguments: BTreeMap<String, serde_json::Value>,
#[serde(with = "duration_millis")]
#[serde(with = "aura_events::duration_ms")]
duration: Duration,
result: Option<String>,
},
Expand Down Expand Up @@ -270,7 +270,7 @@ pub enum DisplayEvent {
diff_text: String,
lines_added: usize,
lines_removed: usize,
#[serde(with = "duration_millis")]
#[serde(with = "aura_events::duration_ms")]
duration: Duration,
},
// Bullet colors are derived at render time via `task_color_for(key)`
Expand Down Expand Up @@ -349,20 +349,6 @@ pub fn snake_to_pascal_case(s: &str) -> String {
.collect()
}

pub mod duration_millis {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::Duration;

pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
d.as_millis().serialize(s)
}

pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
let millis = u64::deserialize(d)?;
Ok(Duration::from_millis(millis))
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -674,21 +660,20 @@ mod tests {
}

// -----------------------------------------------------------------------
// duration_millis serde module
// ShellCallDetail
// -----------------------------------------------------------------------

#[test]
fn duration_millis_roundtrip() {
// Test via ShellCallDetail which uses #[serde(with = "duration_millis")]
fn shell_call_duration_is_milliseconds() {
let detail = ShellCallDetail {
command_name: "ls".to_string(),
full_command: "ls -la".to_string(),
result: "output".to_string(),
duration: Duration::from_millis(1234),
};
let json = serde_json::to_string(&detail).unwrap();
assert!(json.contains("1234"));
let parsed: ShellCallDetail = serde_json::from_str(&json).unwrap();
let json = serde_json::to_value(&detail).unwrap();
assert_eq!(json["duration"], 1234);
let parsed: ShellCallDetail = serde_json::from_value(json).unwrap();
assert_eq!(parsed.duration, Duration::from_millis(1234));
}
}
1 change: 1 addition & 0 deletions crates/aura-events/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ repository.workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }

# Optional: re-export rmcp's ProgressToken for interop with the aura crate.
# When rmcp is not available, we define compatible local types.
Expand Down
18 changes: 18 additions & 0 deletions crates/aura-events/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@ pub enum AgentEventPayload {
Synthesizing {
iteration: usize,
},

/// Any other value on the wire, read by a version of this crate that does
/// not know it. It cannot be written back.
Comment on lines +233 to +234

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Variant comments describe runtime behavior

The new Unknown comment explains how serde reads and writes values. CLAUDE.md requires type comments to describe only what the value means, not runtime behavior. Remove the read/write clauses. The same wording appears on ObserverKind::Unknown and LifecycleEvent::Unknown, with the pattern repeated elsewhere in run.rs.

This repository requirement must be satisfied before merging.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

#[serde(other, skip_serializing)]
Unknown,
}

#[cfg(test)]
Expand Down Expand Up @@ -414,4 +419,17 @@ mod tests {
assert_eq!(content, "the answer");
assert_eq!(usage.total_tokens.get(), 15);
}

/// A reader older than its producer reads a type it does not know as
/// `Unknown` rather than failing the event, and cannot write it back.
#[test]
fn an_unknown_type_reads_as_unknown_and_cannot_be_serialized() {
let event: AgentEvent = serde_json::from_value(json!({
"agent": serde_json::to_value(AgentContext::single_agent()).unwrap(),
"payload": { "type": "telemetry", "cpu": 0.5 }
}))
.unwrap();
assert!(matches!(event.payload, AgentEventPayload::Unknown));
assert!(serde_json::to_value(&event).is_err());
}
}
97 changes: 97 additions & 0 deletions crates/aura-events/src/duration_ms.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//! Serializes a [`Duration`] as whole milliseconds, the unit every duration on
//! the wire is expressed in. Use it as
//! `#[serde(with = "aura_events::duration_ms")]`, or `duration_ms::option` for
//! an `Option<Duration>`.

use std::time::Duration;

use serde::{Deserialize, Deserializer, Serialize, Serializer};

pub fn serialize<S: Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
u64::try_from(duration.as_millis())
.unwrap_or(u64::MAX)
.serialize(serializer)
}

pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
u64::deserialize(deserializer).map(Duration::from_millis)
}

pub mod option {
use std::time::Duration;

use serde::{Deserialize, Deserializer, Serialize, Serializer};

pub fn serialize<S: Serializer>(
duration: &Option<Duration>,
serializer: S,
) -> Result<S::Ok, S::Error> {
duration
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
.serialize(serializer)
}

pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<Duration>, D::Error> {
Ok(Option::<u64>::deserialize(deserializer)?.map(Duration::from_millis))
}
}

#[cfg(test)]
mod tests {
use std::time::Duration;

use serde::{Deserialize, Serialize};
use serde_json::json;

#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Timed {
#[serde(with = "super")]
elapsed: Duration,
#[serde(with = "super::option")]
limit: Option<Duration>,
}

#[test]
fn a_duration_is_whole_milliseconds() {
let timed = Timed {
elapsed: Duration::from_micros(1_500_999),
limit: Some(Duration::from_millis(30_000)),
};
let json = serde_json::to_value(&timed).unwrap();
assert_eq!(json, json!({ "elapsed": 1500, "limit": 30_000 }));
assert_eq!(
serde_json::from_value::<Timed>(json).unwrap(),
Timed {
elapsed: Duration::from_millis(1500),
limit: Some(Duration::from_millis(30_000)),
}
);
}

#[test]
fn an_absent_duration_is_null() {
let timed = Timed {
elapsed: Duration::ZERO,
limit: None,
};
let json = serde_json::to_value(&timed).unwrap();
assert_eq!(json, json!({ "elapsed": 0, "limit": null }));
assert_eq!(serde_json::from_value::<Timed>(json).unwrap(), timed);
}

/// A duration too long for a u64 of milliseconds saturates rather than
/// failing to serialize.
#[test]
fn a_duration_past_u64_milliseconds_saturates() {
let timed = Timed {
elapsed: Duration::MAX,
limit: Some(Duration::MAX),
};
assert_eq!(
serde_json::to_value(&timed).unwrap(),
json!({ "elapsed": u64::MAX, "limit": u64::MAX })
);
}
}
Loading
Loading