use serde::{Deserialize, Serialize};
use crate::canonical::{Canonical, CanonicalError};
use crate::digest::Digest;
use crate::grant::GrantSet;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Intent {
pub kind: String,
pub payload: ciborium::Value,
}
impl Intent {
pub fn new<T: Serialize>(kind: impl Into<String>, payload: &T) -> Result<Self, CanonicalError> {
Ok(Self {
kind: kind.into(),
payload: ciborium::Value::serialized(payload)
.map_err(|e| CanonicalError::Encode(e.to_string()))?,
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
pub tool: String,
pub args: ciborium::Value,
}
impl ToolCall {
pub fn new<T: Serialize>(tool: impl Into<String>, args: &T) -> Result<Self, CanonicalError> {
Ok(Self {
tool: tool.into(),
args: ciborium::Value::serialized(args)
.map_err(|e| CanonicalError::Encode(e.to_string()))?,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProposalOrigin {
pub model: String,
pub sampling: Vec<(String, String)>,
pub prompt: Digest,
}
impl ProposalOrigin {
pub fn new(
model: impl Into<String>,
sampling: impl IntoIterator<Item = (String, String)>,
prompt: Digest,
) -> Self {
let mut sampling: Vec<_> = sampling.into_iter().collect();
sampling.sort();
Self {
model: model.into(),
sampling,
prompt,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WorkOrder {
pub intent: Intent,
pub calls: Vec<ToolCall>,
pub requests: GrantSet,
pub origin: ProposalOrigin,
}
impl WorkOrder {
pub fn new(
intent: Intent,
calls: Vec<ToolCall>,
requests: GrantSet,
origin: ProposalOrigin,
) -> Self {
Self {
intent,
calls,
requests,
origin,
}
}
pub fn id(&self) -> Result<Digest, CanonicalError> {
self.digest()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::grant::GrantAtom;
fn order() -> WorkOrder {
WorkOrder::new(
Intent::new("patch.apply", &"fix parse_header").unwrap(),
vec![ToolCall::new("write_file", &("/src/a.rs", "contents")).unwrap()],
GrantSet::new([GrantAtom::new("fs", "write", "/src/**")]),
ProposalOrigin::new(
"claude-opus-5",
[("temperature".into(), "0".into())],
Digest::of(b"prompt"),
),
)
}
#[test]
fn carries_intent_calls_requests_and_origin() {
let o = order();
assert_eq!(o.intent.kind, "patch.apply");
assert_eq!(o.calls.len(), 1);
assert_eq!(o.requests.len(), 1);
assert_eq!(o.origin.model, "claude-opus-5");
}
#[test]
fn origin_traces_back_to_what_produced_it() {
let o = order();
assert_eq!(o.origin.prompt, Digest::of(b"prompt"));
assert_eq!(o.origin.sampling, vec![("temperature".into(), "0".into())]);
}
#[test]
fn sampling_order_does_not_change_identity() {
let prompt = Digest::of(b"p");
let a = ProposalOrigin::new(
"m",
[("a".into(), "1".into()), ("b".into(), "2".into())],
prompt,
);
let b = ProposalOrigin::new(
"m",
[("b".into(), "2".into()), ("a".into(), "1".into())],
prompt,
);
assert_eq!(a.digest().unwrap(), b.digest().unwrap());
}
#[test]
fn has_a_stable_digest() {
assert_eq!(order().digest().unwrap(), order().digest().unwrap());
}
#[test]
fn changing_the_request_changes_the_digest() {
let base = order().digest().unwrap();
let mut widened = order();
widened.requests = GrantSet::new([GrantAtom::new("fs", "write", "/**")]);
assert_ne!(base, widened.digest().unwrap());
}
#[test]
fn round_trips_through_canonical_cbor() {
let o = order();
let bytes = crate::to_canonical_cbor(&o).unwrap();
let back: WorkOrder = crate::from_canonical_cbor(&bytes).unwrap();
assert_eq!(o, back);
}
#[test]
fn unrepresentable_payloads_are_a_typed_error_not_a_panic() {
let bad = Intent::new("x", &std::collections::HashMap::<Vec<u8>, u8>::new());
assert!(bad.is_ok(), "sanity: ordinary payloads succeed");
struct Fails;
impl Serialize for Fails {
fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom("nope"))
}
}
assert!(matches!(
Intent::new("x", &Fails),
Err(CanonicalError::Encode(_))
));
}
}