aion-server 0.29.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! What the translator must and must not produce.

use aion_core::{ActivityId, RunId, WorkflowId};

use super::*;

fn event(kind: ActivityEventKind) -> ActivityEvent {
    ActivityEvent {
        workflow_id: WorkflowId::new(uuid::Uuid::nil()),
        run_id: RunId::new(uuid::Uuid::from_u128(1)),
        activity_id: ActivityId::from_sequence_position(1),
        attempt: 1,
        agent_id: uuid::Uuid::from_u128(2),
        agent_role: "assistant".to_owned(),
        emitted_at: chrono::Utc::now(),
        worker_seq: 0,
        store_seq: None,
        ephemeral: false,
        kind,
    }
}

#[test]
fn a_token_delta_becomes_a_delta_frame() {
    let mut frames = TurnFrames::new("t-1");
    let produced = frames.translate(event(ActivityEventKind::Delta {
        message_id: "m-1".to_owned(),
        text_fragment: "hel".to_owned(),
    }));
    assert_eq!(
        produced,
        vec![AssistantSessionEvent::Delta {
            turn_id: "t-1".to_owned(),
            text: "hel".to_owned(),
        }]
    );
}

#[test]
fn a_completed_assistant_message_is_not_streamed_twice() {
    let mut frames = TurnFrames::new("t-1");
    let produced = frames.translate(event(ActivityEventKind::Message {
        role: MessageRole::Assistant,
        text: "hello".to_owned(),
    }));
    assert!(
        produced.is_empty(),
        "the deltas already carried this text; a second frame would double it"
    );
}

#[test]
fn a_progress_note_becomes_a_thought() {
    let mut frames = TurnFrames::new("t-1");
    let produced = frames.translate(event(ActivityEventKind::Progress {
        detail: ProgressDetail::Note {
            text: "considering".to_owned(),
        },
    }));
    assert_eq!(
        produced,
        vec![AssistantSessionEvent::Thought {
            turn_id: "t-1".to_owned(),
            text: "considering".to_owned(),
        }]
    );
}

#[test]
fn a_tool_result_is_named_by_the_call_that_produced_it() {
    let mut frames = TurnFrames::new("t-1");
    let started = frames.translate(event(ActivityEventKind::ToolCall {
        tool: "check_document".to_owned(),
        call_id: "c-1".to_owned(),
        input: serde_json::json!({ "path": "a.awl" }),
    }));
    assert_eq!(
        started,
        vec![AssistantSessionEvent::ToolCall {
            turn_id: "t-1".to_owned(),
            call_id: "c-1".to_owned(),
            name: "check_document".to_owned(),
            status: AssistantToolCallStatus::Started,
            input: Some(serde_json::json!({ "path": "a.awl" })),
            output: None,
        }]
    );
    let finished = frames.translate(event(ActivityEventKind::ToolResult {
        call_id: "c-1".to_owned(),
        output: serde_json::json!({ "diagnostics": [] }),
        is_error: false,
    }));
    assert_eq!(
        finished,
        vec![AssistantSessionEvent::ToolCall {
            turn_id: "t-1".to_owned(),
            call_id: "c-1".to_owned(),
            name: "check_document".to_owned(),
            status: AssistantToolCallStatus::Completed,
            input: None,
            output: Some(serde_json::json!({ "diagnostics": [] })),
        }]
    );
}

#[test]
fn a_result_with_no_remembered_call_says_so_rather_than_inventing_a_tool() {
    let mut frames = TurnFrames::new("t-1");
    let produced = frames.translate(event(ActivityEventKind::ToolResult {
        call_id: "orphan".to_owned(),
        output: serde_json::Value::Null,
        is_error: true,
    }));
    match produced.as_slice() {
        [AssistantSessionEvent::ToolCall { name, status, .. }] => {
            assert_eq!(name, UNMATCHED_TOOL);
            assert_eq!(*status, AssistantToolCallStatus::Failed);
        }
        other => panic_free_failure(other),
    }
}

#[test]
fn a_permission_request_reaches_the_transcript_joined_to_its_decision() {
    let mut frames = TurnFrames::new("t-1");
    let held = frames.translate(event(ActivityEventKind::Raw {
        source: "session/request_permission".to_owned(),
        value: serde_json::json!({ "toolCall": { "toolCallId": "c-1" } }),
    }));
    assert!(
        held.is_empty(),
        "the ask alone says nothing about what was answered"
    );
    let decided = frames.translate(event(ActivityEventKind::Raw {
        source: "session/request_permission/decision".to_owned(),
        value: serde_json::json!({
            "policy": "deny",
            "toolCallId": "c-1",
            "outcome": { "outcome": "selected", "optionId": "reject-1" }
        }),
    }));
    assert_eq!(
        decided,
        vec![AssistantSessionEvent::PermissionAsk {
            turn_id: "t-1".to_owned(),
            request: serde_json::json!({ "toolCall": { "toolCallId": "c-1" } }),
            decided: AssistantPermissionDecision::Deny,
        }]
    );
}

#[test]
fn an_allow_once_policy_is_recorded_as_allow_once() {
    let mut frames = TurnFrames::new("t-1");
    let _held = frames.translate(event(ActivityEventKind::Raw {
        source: "session/request_permission".to_owned(),
        value: serde_json::Value::Null,
    }));
    let decided = frames.translate(event(ActivityEventKind::Raw {
        source: "session/request_permission/decision".to_owned(),
        value: serde_json::json!({
            "policy": "allow_once",
            "outcome": { "outcome": "selected", "optionId": "allow-1" }
        }),
    }));
    match decided.as_slice() {
        [AssistantSessionEvent::PermissionAsk { decided, .. }] => {
            assert_eq!(*decided, AssistantPermissionDecision::AllowOnce);
        }
        other => panic_free_failure(other),
    }
}

#[test]
fn a_cancelled_outcome_is_recorded_verbatim_rather_than_reported_as_a_denial() {
    let mut frames = TurnFrames::new("t-1");
    let decided = frames.translate(event(ActivityEventKind::Raw {
        source: "session/request_permission/decision".to_owned(),
        value: serde_json::json!({
            "policy": "allow_once",
            "outcome": { "outcome": "cancelled" }
        }),
    }));
    match decided.as_slice() {
        [AssistantSessionEvent::Raw { source, value, .. }] => {
            assert_eq!(source, "session/request_permission/decision");
            assert_eq!(value["decision"]["outcome"]["outcome"], "cancelled");
        }
        // A `permission_ask` here would put a decision on the record that was
        // never taken, and the wire carries only `allow_once` and `deny`.
        other => panic_free_failure(other),
    }
}

#[test]
fn an_unanswered_permission_request_still_reaches_the_record() {
    let mut frames = TurnFrames::new("t-1");
    let _held = frames.translate(event(ActivityEventKind::Raw {
        source: "session/request_permission".to_owned(),
        value: serde_json::json!({ "toolCall": { "toolCallId": "c-9" } }),
    }));
    let flushed = frames.flush();
    match flushed.as_slice() {
        [AssistantSessionEvent::Raw { source, value, .. }] => {
            assert_eq!(source, "session/request_permission/unanswered");
            assert_eq!(value["toolCall"]["toolCallId"], "c-9");
        }
        other => panic_free_failure(other),
    }
    assert!(
        frames.flush().is_empty(),
        "a flushed request is not flushed twice"
    );
}

#[test]
fn an_unmapped_frame_passes_through_verbatim_rather_than_vanishing() {
    let mut frames = TurnFrames::new("t-1");
    let produced = frames.translate(event(ActivityEventKind::Raw {
        source: "session/update/undecodable".to_owned(),
        value: serde_json::json!({ "error": "bad frame" }),
    }));
    assert_eq!(
        produced,
        vec![AssistantSessionEvent::Raw {
            turn_id: Some("t-1".to_owned()),
            source: "session/update/undecodable".to_owned(),
            value: serde_json::json!({ "error": "bad frame" }),
        }]
    );
}

#[test]
fn a_usage_estimate_is_kept_as_a_raw_frame_rather_than_dropped() {
    let mut frames = TurnFrames::new("t-1");
    let produced = frames.translate(event(ActivityEventKind::Progress {
        detail: ProgressDetail::UsageEstimate {
            input_tokens: Some(10),
            output_tokens: Some(3),
        },
    }));
    match produced.as_slice() {
        [AssistantSessionEvent::Raw { source, value, .. }] => {
            assert_eq!(source, "progress");
            assert_eq!(value["input_tokens"], serde_json::json!(10));
        }
        other => panic_free_failure(other),
    }
}

#[test]
fn the_terminal_stop_is_left_to_the_turns_own_result() {
    let mut frames = TurnFrames::new("t-1");
    let produced = frames.translate(event(ActivityEventKind::Stop {
        reason: aion_core::StopKind::EndTurn,
    }));
    assert!(
        produced.is_empty(),
        "the turn's result carries the stop reason AND the final message"
    );
}

/// Fails a test that matched the wrong shape, without a `panic!` macro.
///
/// The workspace denies `panic`, and a wrong-shape match still has to fail
/// loudly: `assert_eq!` against a rendering of what was produced does both.
fn panic_free_failure(produced: &[AssistantSessionEvent]) {
    assert_eq!(
        format!("{produced:?}"),
        "<the expected frame shape>",
        "the translator produced an unexpected shape"
    );
}

/// T4's classification arm: the harness's own `available_commands_update`
/// becomes a CLASSIFIED frame, not a `raw` a console would have to parse ACP
/// out of.
///
/// The payload is written as the wire form an agent actually sends —
/// `availableCommands` with each entry's `name`, `description`, and the
/// `unstructured` input's `hint` — rather than built from the schema's Rust
/// types, so this tests the reading against the protocol rather than against
/// one crate's own `Serialize`.
#[test]
fn an_available_commands_update_becomes_a_classified_frame() {
    let mut frames = TurnFrames::new("t-1");
    let produced = frames.translate(event(ActivityEventKind::Raw {
        source: AVAILABLE_COMMANDS_SOURCE.to_owned(),
        value: serde_json::json!({
            "availableCommands": [
                { "name": "compact", "description": "compact the conversation" },
                {
                    "name": "plan",
                    "description": "draft a plan",
                    "input": { "hint": "what to plan" },
                },
            ]
        }),
    }));
    assert_eq!(
        produced,
        vec![AssistantSessionEvent::AvailableCommands {
            commands: vec![
                AssistantCommand {
                    name: "compact".to_owned(),
                    description: "compact the conversation".to_owned(),
                    input_hint: None,
                },
                AssistantCommand {
                    name: "plan".to_owned(),
                    description: "draft a plan".to_owned(),
                    input_hint: Some("what to plan".to_owned()),
                },
            ],
        }]
    );
}

/// An EMPTY advertisement is a withdrawal, and it is classified as one: the
/// agent said it now serves nothing, and a console must stop offering what it
/// used to. This is why an unreadable payload cannot be reported as empty.
#[test]
fn an_empty_advertisement_is_a_withdrawal_rather_than_a_raw_frame() {
    let mut frames = TurnFrames::new("t-1");
    let produced = frames.translate(event(ActivityEventKind::Raw {
        source: AVAILABLE_COMMANDS_SOURCE.to_owned(),
        value: serde_json::json!({ "availableCommands": [] }),
    }));
    assert_eq!(
        produced,
        vec![AssistantSessionEvent::AvailableCommands {
            commands: Vec::new(),
        }]
    );
}

/// A payload this server cannot read as a command list passes through VERBATIM.
///
/// Never as an empty advertisement: an empty list means the agent withdrew every
/// command, and reporting a shape change that way would take a working control
/// off an operator's surface for a reason that was never stated. Nothing is
/// dropped either — the frame is on the record for whoever has to work out what
/// the agent actually sent.
#[test]
fn an_unreadable_command_payload_is_kept_verbatim_and_never_read_as_a_withdrawal() {
    let mut frames = TurnFrames::new("t-1");
    let value = serde_json::json!({ "commands": ["compact"] });
    let produced = frames.translate(event(ActivityEventKind::Raw {
        source: AVAILABLE_COMMANDS_SOURCE.to_owned(),
        value: value.clone(),
    }));
    assert_eq!(
        produced,
        vec![AssistantSessionEvent::Raw {
            turn_id: Some("t-1".to_owned()),
            source: AVAILABLE_COMMANDS_SOURCE.to_owned(),
            value,
        }]
    );
}

/// One malformed entry does not take the advertisement down with it: the
/// commands the agent named readably are still offerable, and one it named
/// without a description is one nothing could have offered anyway.
#[test]
fn a_malformed_entry_is_dropped_and_the_rest_of_the_advertisement_stands() {
    let mut frames = TurnFrames::new("t-1");
    let produced = frames.translate(event(ActivityEventKind::Raw {
        source: AVAILABLE_COMMANDS_SOURCE.to_owned(),
        value: serde_json::json!({
            "availableCommands": [
                { "name": "compact" },
                { "name": "plan", "description": "draft a plan" },
            ]
        }),
    }));
    assert_eq!(
        produced,
        vec![AssistantSessionEvent::AvailableCommands {
            commands: vec![AssistantCommand {
                name: "plan".to_owned(),
                description: "draft a plan".to_owned(),
                input_hint: None,
            }],
        }]
    );
}

/// The source label this server classifies on is the one the ADAPTER actually
/// produces — `update_raw`'s prefix plus the ACP `sessionUpdate` discriminator.
/// Written out independently here, so a rename on either side fails rather than
/// silently turning every advertisement back into a `raw` frame nobody reads.
#[test]
fn the_classified_source_is_the_label_the_adapter_emits() {
    assert_eq!(
        AVAILABLE_COMMANDS_SOURCE,
        format!(
            "{}/available_commands_update",
            aion_integration_acp::translate::UPDATE_SOURCE_PREFIX
        )
    );
}