diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..79ff9fe --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,89 @@ +name: Integration Tests + +on: + workflow_dispatch: + inputs: + run_e2e: + description: 'Also run Tier 3 E2E tests (requires OPENAI_API_KEY secret)' + type: boolean + default: false + +env: + CARGO_TERM_COLOR: always + +jobs: + integration: + name: Tier 1+2 Integration Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + integration_tests/target + key: ${{ runner.os }}-integration-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-integration- + + - name: Install protoc + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + + - name: Build runtime binary + run: cargo build + + - name: Run Tier 1+2 integration tests + working-directory: integration_tests + env: + MACP_TEST_BINARY: ../target/debug/macp-runtime + run: cargo test -- --test-threads=1 + timeout-minutes: 10 + + e2e: + name: Tier 3 E2E Tests + runs-on: ubuntu-latest + if: github.event.inputs.run_e2e == 'true' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + integration_tests/target + key: ${{ runner.os }}-integration-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-integration- + + - name: Install protoc + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + + - name: Build runtime binary + run: cargo build + + - name: Run Tier 3 E2E tests + working-directory: integration_tests + env: + MACP_TEST_BINARY: ../target/debug/macp-runtime + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: cargo test -- --ignored --test-threads=1 + timeout-minutes: 10 diff --git a/.gitignore b/.gitignore index 7fe477a..cc70cb0 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ Cargo.lock /temp/ CLAUDE.md +# Integration tests build artifacts +integration_tests/target/ + # OS .DS_Store Thumbs.db diff --git a/Makefile b/Makefile index 0c6ed47..faeb247 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup build test test-integration test-conformance test-all fmt clippy check audit coverage sync-protos sync-protos-local check-protos +.PHONY: setup build test test-integration test-conformance test-all fmt clippy check audit coverage sync-protos sync-protos-local check-protos test-integration-grpc test-integration-agents test-integration-e2e test-integration-hosted SPEC_PROTO_DIR := ../multiagentcoordinationprotocol/schemas/proto PROTO_FILES := macp/v1/envelope.proto macp/v1/core.proto macp/modes/decision/v1/decision.proto macp/modes/proposal/v1/proposal.proto macp/modes/task/v1/task.proto macp/modes/handoff/v1/handoff.proto macp/modes/quorum/v1/quorum.proto @@ -55,6 +55,19 @@ sync-protos-local: done @echo "Done. Run 'git diff proto/' to review changes." +## Integration tests (gRPC, Rig agents) +test-integration-grpc: + cd integration_tests && cargo test --test tier1 -- --test-threads=1 + +test-integration-agents: + cd integration_tests && cargo test --test tier2 -- --test-threads=1 + +test-integration-e2e: + cd integration_tests && cargo test -- --ignored --test-threads=1 + +test-integration-hosted: + cd integration_tests && cargo test -- --test-threads=1 + ## Check if local protos match BSR check-protos: @TMPDIR=$$(mktemp -d); \ diff --git a/README.md b/README.md index 6c53dd0..4e2de43 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,29 @@ The runtime requires write access to `MACP_DATA_DIR`. Check directory permission **Proto drift / `make check-protos` failure** Run `make sync-protos` to update local proto files from BSR. +## Testing + +```bash +cargo test --all-targets # Unit tests + Rust integration tests +make test-conformance # JSON fixture-driven conformance suite +``` + +A separate integration test crate (`integration_tests/`) tests the runtime through the real gRPC boundary: + +```bash +cargo build +cd integration_tests +MACP_TEST_BINARY=../target/debug/macp-runtime cargo test -- --test-threads=1 +``` + +The integration suite has three tiers: + +- **Tier 1 (Protocol)** — 47 scripted gRPC tests covering all modes, error paths, signals, version binding, dedup, and RFC cross-cutting features +- **Tier 2 (Rig Tools)** — 5 tests using [Rig](https://rig.rs) agent framework `Tool` implementations for all MACP operations +- **Tier 3 (E2E)** — 3 tests with real OpenAI GPT-4o-mini agents coordinating through the runtime (requires `OPENAI_API_KEY`) + +See `docs/testing.md` for full details on running locally, in CI, or against a hosted runtime. + ## Development notes - The RFC/spec repository remains the normative source for protocol semantics. @@ -286,5 +309,6 @@ Run `make sync-protos` to update local proto files from BSR. - `multi_round` is a built-in extension (`ext.multi_round.v1`) — not standards-track, but ships with the runtime and enforces strict `SessionStart`. - Extension modes can be dynamically registered, unregistered, and promoted via `RegisterExtMode`, `UnregisterExtMode`, and `PromoteMode` RPCs. - `StreamSession` is enabled and binds one gRPC stream to one session, emitting accepted envelopes in order. +- `WatchSignals` broadcasts ambient Signal envelopes to all subscribers in real time. See `docs/README.md` and `docs/examples.md` for the updated local development and usage guidance. diff --git a/docs/protocol.md b/docs/protocol.md index 6e9d1b5..f53b39f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -22,6 +22,7 @@ Clients should call `Initialize` before using the runtime. - `ListRoots` - `WatchModeRegistry` - `WatchRoots` +- `WatchSignals` - `ListExtModes` - `RegisterExtMode` - `UnregisterExtMode` @@ -31,6 +32,7 @@ Clients should call `Initialize` before using the runtime. - `WatchModeRegistry` — sends the current registry state, then fires `RegistryChanged` on register/unregister/promote - `WatchRoots` — sends the current roots state, then holds the stream open +- `WatchSignals` — broadcasts ambient Signal envelopes to all subscribers in real time; Signals correlate with sessions via `SignalPayload.correlation_session_id` but do not enter session history ## Extension mode lifecycle RPCs diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..a06ab25 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,131 @@ +# Testing + +The runtime has three levels of tests, plus a separate integration test crate that exercises the gRPC boundary with real agents. + +## Unit tests and conformance + +```bash +cargo test --all-targets # unit tests + Rust integration tests +make test-conformance # JSON fixture-driven conformance suite +make test-all # fmt → clippy → test → integration → conformance +``` + +Unit tests live inside `src/` modules (`#[cfg(test)]`). Conformance fixtures are in `tests/conformance/` and exercise each mode's happy path and reject paths from JSON definitions. + +## Integration test suite + +A separate Rust crate at `integration_tests/` tests the runtime through the real gRPC transport boundary. It is **not** part of the main Cargo build — `cargo build --release` ignores it entirely. + +### Architecture + +``` +integration_tests/ + Cargo.toml # Depends on macp-runtime (lib) + rig-core + tonic + src/ + config.rs # Test target configuration (local / CI / hosted) + server_manager.rs # Start/stop runtime as a subprocess on a free port + helpers.rs # Envelope builders, payload helpers, gRPC wrappers + macp_tools/ # Rig Tool implementations for all MACP operations + tests/ + tier1.rs → tier1_protocol/ # Scripted gRPC protocol tests + tier2.rs → tier2_agents/ # Rig agent tool tests (no LLM) + tier3.rs → tier3_e2e/ # Real OpenAI LLM agent tests +``` + +### Three tiers + +| Tier | What | LLM | Tests | Speed | +|------|------|-----|-------|-------| +| **Tier 1: Protocol** | Scripted gRPC calls testing all modes, error paths, RFC cross-cutting features (signals, dedup, version binding, cancel auth) | None | 47 | <1s | +| **Tier 2: Rig Tools** | MACP operations as Rig `Tool` trait implementations, invoked via `ToolSet::call()` | None | 5 | <1s | +| **Tier 3: E2E** | Real GPT-4o-mini agents coordinating through the runtime. Orchestrator as plain code, specialists as LLM. Parallel execution. Signals on ambient plane. | OpenAI | 3 | ~15s | + +### Running integration tests + +```bash +# Build the runtime first +cargo build + +# Run Tier 1 + 2 (no API keys needed) +cd integration_tests +MACP_TEST_BINARY=../target/debug/macp-runtime cargo test -- --test-threads=1 + +# Run individual tiers +MACP_TEST_BINARY=../target/debug/macp-runtime cargo test --test tier1 -- --test-threads=1 +MACP_TEST_BINARY=../target/debug/macp-runtime cargo test --test tier2 -- --test-threads=1 + +# Run Tier 3 E2E (requires OPENAI_API_KEY) +OPENAI_API_KEY=sk-... MACP_TEST_BINARY=../target/debug/macp-runtime cargo test --test tier3 -- --ignored --test-threads=1 + +# Run against a hosted runtime (no local server started) +MACP_TEST_ENDPOINT=host:50051 cargo test -- --test-threads=1 +``` + +Or use Makefile targets from the project root: + +```bash +make test-integration-grpc # Tier 1 +make test-integration-agents # Tier 2 +make test-integration-e2e # Tier 3 (needs OPENAI_API_KEY) +make test-integration-hosted # All tiers against MACP_TEST_ENDPOINT +``` + +### Configuration + +| Variable | Purpose | Default | +|----------|---------|---------| +| `MACP_TEST_BINARY` | Path to runtime binary (skip cargo build) | Builds from parent crate | +| `MACP_TEST_ENDPOINT` | Connect to hosted runtime (skip server start) | Start local server | +| `MACP_TEST_TLS` | Use TLS for hosted connection | `0` | +| `MACP_TEST_AUTH_TOKEN` | Bearer token for hosted runtime | Dev headers | +| `OPENAI_API_KEY` | Required for Tier 3 E2E tests | Tier 3 tests skip if unset | + +### Tier 1 coverage + +Protocol tests exercise every mode through gRPC: + +- **Initialize**: protocol negotiation, version rejection, runtime info +- **Decision mode**: happy path, duplicate dedup, non-initiator commit rejection +- **Proposal mode**: happy path, premature commitment rejection +- **Task mode**: happy path, non-initiator request rejection, duplicate task rejection +- **Handoff mode**: happy path, accept-without-offer rejection +- **Quorum mode**: happy path, approve-before-request, premature commitment +- **Multi-round mode**: happy path, pre-convergence commit rejection +- **Signals**: valid signal accepted, session_id/mode violations rejected, WatchSignals broadcast +- **Version binding**: commitment with wrong mode_version/config_version rejected +- **Deduplication**: rejected messages don't consume dedup slots, duplicate SessionStart rejected +- **CancelSession**: non-initiator rejection +- **Session lifecycle**: TTL expiry, concurrent sessions, parallel session independence +- **Mode registry**: list/register/unregister extension modes +- **Discovery**: GetManifest returns all modes, Initialize rejects unsupported version + +### Tier 2: Rig agent tools + +Each MACP operation (start session, propose, vote, commit, etc.) is implemented as a Rig `Tool` trait. Tier 2 tests validate these tools work correctly by calling them through `ToolSet::call()` — the same interface an LLM agent would use. Tests cover all 5 standard modes. + +### Tier 3: E2E with real LLM + +Three tests use real OpenAI GPT-4o-mini agents: + +1. **Decision with signals**: Orchestrator (code) proposes → 3 specialist LLMs evaluate in parallel → each sends progress/completed Signals on the ambient plane → orchestrator commits. Demonstrates both coordination plane and ambient plane simultaneously. + +2. **Decision**: Same as above without signals — simpler version. + +3. **Task delegation**: Planner (code) creates task → Worker (LLM) accepts and completes → planner commits. + +Architecture follows the RFC: +- Orchestrator/planner operations are **plain code** (deterministic, no LLM needed) +- Specialist/worker reasoning uses **real LLM** (where domain expertise matters) +- Agents run **in parallel** (runtime serializes by acceptance order) +- LLM reasoning happens **outside the session** (ambient plane) +- Only the resulting Envelope enters the session + +### CI/CD + +Integration tests run via manual GitHub Actions dispatch (not on every PR): + +``` +Actions → "Integration Tests" → Run workflow → optionally check "Run Tier 3 E2E" +``` + +Tier 3 E2E requires the `OPENAI_API_KEY` repository secret. diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml new file mode 100644 index 0000000..9585322 --- /dev/null +++ b/integration_tests/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "macp-integration-tests" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +macp-runtime = { path = ".." } + +tonic = { version = "0.14", features = ["transport"] } +prost = "0.14" + +tokio = { version = "1", features = ["full", "process"] } + +rig-core = "0.34" + +uuid = { version = "1", features = ["v4"] } +chrono = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +thiserror = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +async-trait = "0.1" diff --git a/integration_tests/src/config.rs b/integration_tests/src/config.rs new file mode 100644 index 0000000..4ef7107 --- /dev/null +++ b/integration_tests/src/config.rs @@ -0,0 +1,39 @@ +use std::env; + +/// Configuration for integration test target. +/// +/// Supports three modes: +/// - **Local dev**: no env vars — builds parent crate, starts server on free port +/// - **CI**: `MACP_TEST_BINARY` set — uses pre-built binary, starts server +/// - **Hosted**: `MACP_TEST_ENDPOINT` set — connects directly, no server management +pub struct TestConfig { + /// gRPC endpoint to connect to (e.g. "http://127.0.0.1:50051") + pub endpoint: Option, + /// Use TLS for the connection + pub use_tls: bool, + /// Bearer token for hosted runtime authentication + pub auth_token: Option, + /// Path to a pre-built runtime binary + pub binary_path: Option, +} + +impl TestConfig { + pub fn from_env() -> Self { + Self { + endpoint: env::var("MACP_TEST_ENDPOINT").ok(), + use_tls: env::var("MACP_TEST_TLS").ok().as_deref() == Some("1"), + auth_token: env::var("MACP_TEST_AUTH_TOKEN").ok(), + binary_path: env::var("MACP_TEST_BINARY").ok(), + } + } + + /// Whether we need to start a local server (no external endpoint provided). + pub fn needs_local_server(&self) -> bool { + self.endpoint.is_none() + } + + /// Whether to use dev-mode headers (x-macp-agent-id) instead of bearer tokens. + pub fn use_dev_headers(&self) -> bool { + self.auth_token.is_none() + } +} diff --git a/integration_tests/src/helpers.rs b/integration_tests/src/helpers.rs new file mode 100644 index 0000000..a066f77 --- /dev/null +++ b/integration_tests/src/helpers.rs @@ -0,0 +1,351 @@ +use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; +use macp_runtime::pb::{ + Ack, CancelSessionRequest, CommitmentPayload, Envelope, GetSessionRequest, GetSessionResponse, + InitializeRequest, InitializeResponse, ListModesRequest, ListModesResponse, SendRequest, + SessionStartPayload, +}; +use prost::Message; +use tonic::transport::Channel; +use tonic::Request; + +pub const MODE_DECISION: &str = "macp.mode.decision.v1"; +pub const MODE_PROPOSAL: &str = "macp.mode.proposal.v1"; +pub const MODE_TASK: &str = "macp.mode.task.v1"; +pub const MODE_HANDOFF: &str = "macp.mode.handoff.v1"; +pub const MODE_QUORUM: &str = "macp.mode.quorum.v1"; +pub const MODE_MULTI_ROUND: &str = "ext.multi_round.v1"; + +pub const MODE_VERSION: &str = "1.0.0"; +pub const CONFIG_VERSION: &str = "config.default"; +pub const POLICY_VERSION: &str = "policy.default"; + +pub fn new_session_id() -> String { + uuid::Uuid::new_v4().as_hyphenated().to_string() +} + +pub fn new_message_id() -> String { + uuid::Uuid::new_v4().as_hyphenated().to_string() +} + +fn with_sender(sender: &str, inner: T) -> Request { + let mut request = Request::new(inner); + request.metadata_mut().insert( + "x-macp-agent-id", + sender.parse().expect("valid sender header"), + ); + request +} + +pub fn envelope( + mode: &str, + message_type: &str, + message_id: &str, + session_id: &str, + sender: &str, + payload: Vec, +) -> Envelope { + Envelope { + macp_version: "1.0".into(), + mode: mode.into(), + message_type: message_type.into(), + message_id: message_id.into(), + session_id: session_id.into(), + sender: sender.into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload, + } +} + +pub fn session_start_payload(intent: &str, participants: &[&str], ttl_ms: i64) -> Vec { + SessionStartPayload { + intent: intent.into(), + participants: participants.iter().map(|p| (*p).to_string()).collect(), + mode_version: MODE_VERSION.into(), + configuration_version: CONFIG_VERSION.into(), + policy_version: POLICY_VERSION.into(), + ttl_ms, + context: vec![], + roots: vec![], + } + .encode_to_vec() +} + +pub fn commitment_payload( + commitment_id: &str, + action: &str, + authority_scope: &str, + reason: &str, +) -> Vec { + CommitmentPayload { + commitment_id: commitment_id.into(), + action: action.into(), + authority_scope: authority_scope.into(), + reason: reason.into(), + mode_version: MODE_VERSION.into(), + policy_version: POLICY_VERSION.into(), + configuration_version: CONFIG_VERSION.into(), + } + .encode_to_vec() +} + +pub async fn send_as( + client: &mut MacpRuntimeServiceClient, + sender: &str, + env: Envelope, +) -> Result { + client + .send(with_sender( + sender, + SendRequest { + envelope: Some(env), + }, + )) + .await + .map(|r| r.into_inner().ack.expect("ack present")) +} + +pub async fn get_session_as( + client: &mut MacpRuntimeServiceClient, + sender: &str, + session_id: &str, +) -> Result { + client + .get_session(with_sender( + sender, + GetSessionRequest { + session_id: session_id.into(), + }, + )) + .await + .map(|r| r.into_inner()) +} + +pub async fn cancel_session_as( + client: &mut MacpRuntimeServiceClient, + sender: &str, + session_id: &str, + reason: &str, +) -> Result { + client + .cancel_session(with_sender( + sender, + CancelSessionRequest { + session_id: session_id.into(), + reason: reason.into(), + }, + )) + .await + .map(|r| r.into_inner().ack.expect("ack present")) +} + +pub async fn initialize( + client: &mut MacpRuntimeServiceClient, +) -> Result { + client + .initialize(InitializeRequest { + supported_protocol_versions: vec!["1.0".into()], + client_info: None, + capabilities: None, + }) + .await + .map(|r| r.into_inner()) +} + +pub async fn list_modes( + client: &mut MacpRuntimeServiceClient, +) -> Result { + client + .list_modes(ListModesRequest {}) + .await + .map(|r| r.into_inner()) +} + +// ── Decision mode payload helpers ─────────────────────────────────────── + +pub fn proposal_payload(proposal_id: &str, option: &str, rationale: &str) -> Vec { + macp_runtime::decision_pb::ProposalPayload { + proposal_id: proposal_id.into(), + option: option.into(), + rationale: rationale.into(), + supporting_data: vec![], + } + .encode_to_vec() +} + +pub fn evaluation_payload( + proposal_id: &str, + recommendation: &str, + confidence: f64, + reason: &str, +) -> Vec { + macp_runtime::decision_pb::EvaluationPayload { + proposal_id: proposal_id.into(), + recommendation: recommendation.into(), + confidence, + reason: reason.into(), + } + .encode_to_vec() +} + +pub fn vote_payload(proposal_id: &str, vote: &str, reason: &str) -> Vec { + macp_runtime::decision_pb::VotePayload { + proposal_id: proposal_id.into(), + vote: vote.into(), + reason: reason.into(), + } + .encode_to_vec() +} + +pub fn objection_payload(proposal_id: &str, reason: &str, severity: &str) -> Vec { + macp_runtime::decision_pb::ObjectionPayload { + proposal_id: proposal_id.into(), + reason: reason.into(), + severity: severity.into(), + } + .encode_to_vec() +} + +// ── Proposal mode payload helpers ─────────────────────────────────────── + +pub fn proposal_mode_payload(proposal_id: &str, title: &str, summary: &str) -> Vec { + macp_runtime::proposal_pb::ProposalPayload { + proposal_id: proposal_id.into(), + title: title.into(), + summary: summary.into(), + details: vec![], + tags: vec![], + } + .encode_to_vec() +} + +pub fn counter_proposal_payload( + proposal_id: &str, + supersedes: &str, + title: &str, + summary: &str, +) -> Vec { + macp_runtime::proposal_pb::CounterProposalPayload { + proposal_id: proposal_id.into(), + supersedes_proposal_id: supersedes.into(), + title: title.into(), + summary: summary.into(), + details: String::new().into_bytes(), + } + .encode_to_vec() +} + +pub fn accept_proposal_payload(proposal_id: &str, reason: &str) -> Vec { + macp_runtime::proposal_pb::AcceptPayload { + proposal_id: proposal_id.into(), + reason: reason.into(), + } + .encode_to_vec() +} + +// ── Task mode payload helpers ─────────────────────────────────────────── + +pub fn task_request_payload(task_id: &str, title: &str, instructions: &str, assignee: &str) -> Vec { + macp_runtime::task_pb::TaskRequestPayload { + task_id: task_id.into(), + title: title.into(), + instructions: instructions.into(), + requested_assignee: assignee.into(), + input: vec![], + deadline_unix_ms: 0, + } + .encode_to_vec() +} + +pub fn task_accept_payload(task_id: &str, assignee: &str) -> Vec { + macp_runtime::task_pb::TaskAcceptPayload { + task_id: task_id.into(), + assignee: assignee.into(), + reason: String::new(), + } + .encode_to_vec() +} + +pub fn task_update_payload(task_id: &str, status: &str, progress: f64, message: &str) -> Vec { + macp_runtime::task_pb::TaskUpdatePayload { + task_id: task_id.into(), + status: status.into(), + progress, + message: message.into(), + partial_output: vec![], + } + .encode_to_vec() +} + +pub fn task_complete_payload(task_id: &str, assignee: &str, summary: &str) -> Vec { + macp_runtime::task_pb::TaskCompletePayload { + task_id: task_id.into(), + assignee: assignee.into(), + output: vec![], + summary: summary.into(), + } + .encode_to_vec() +} + +// ── Handoff mode payload helpers ──────────────────────────────────────── + +pub fn handoff_offer_payload(handoff_id: &str, target: &str, scope: &str, reason: &str) -> Vec { + macp_runtime::handoff_pb::HandoffOfferPayload { + handoff_id: handoff_id.into(), + target_participant: target.into(), + scope: scope.into(), + reason: reason.into(), + } + .encode_to_vec() +} + +pub fn handoff_context_payload(handoff_id: &str, content_type: &str, context: &[u8]) -> Vec { + macp_runtime::handoff_pb::HandoffContextPayload { + handoff_id: handoff_id.into(), + content_type: content_type.into(), + context: context.to_vec(), + } + .encode_to_vec() +} + +pub fn handoff_accept_payload(handoff_id: &str, accepted_by: &str, reason: &str) -> Vec { + macp_runtime::handoff_pb::HandoffAcceptPayload { + handoff_id: handoff_id.into(), + accepted_by: accepted_by.into(), + reason: reason.into(), + } + .encode_to_vec() +} + +// ── Quorum mode payload helpers ───────────────────────────────────────── + +pub fn approval_request_payload( + request_id: &str, + action: &str, + summary: &str, + required_approvals: u32, +) -> Vec { + macp_runtime::quorum_pb::ApprovalRequestPayload { + request_id: request_id.into(), + action: action.into(), + summary: summary.into(), + details: vec![], + required_approvals, + } + .encode_to_vec() +} + +pub fn approve_payload(request_id: &str, reason: &str) -> Vec { + macp_runtime::quorum_pb::ApprovePayload { + request_id: request_id.into(), + reason: reason.into(), + } + .encode_to_vec() +} + +pub fn quorum_reject_payload(request_id: &str, reason: &str) -> Vec { + macp_runtime::quorum_pb::RejectPayload { + request_id: request_id.into(), + reason: reason.into(), + } + .encode_to_vec() +} diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs new file mode 100644 index 0000000..9c9219a --- /dev/null +++ b/integration_tests/src/lib.rs @@ -0,0 +1,4 @@ +pub mod config; +pub mod helpers; +pub mod macp_tools; +pub mod server_manager; diff --git a/integration_tests/src/macp_tools/commit.rs b/integration_tests/src/macp_tools/commit.rs new file mode 100644 index 0000000..2ccfdc1 --- /dev/null +++ b/integration_tests/src/macp_tools/commit.rs @@ -0,0 +1,69 @@ +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; + +use crate::helpers; +use super::SharedClient; +use super::decision::MacpToolError; + +#[derive(Clone)] +pub struct CommitTool { + pub client: SharedClient, + pub agent_id: String, + pub mode: String, +} + +#[derive(Deserialize)] +pub struct CommitArgs { + pub session_id: String, + pub action: String, + pub authority_scope: String, + pub reason: String, +} + +#[derive(Serialize)] +pub struct CommitResult { + pub ok: bool, + pub session_state: i32, +} + +impl Tool for CommitTool { + const NAME: &'static str = "macp_commit"; + type Error = MacpToolError; + type Args = CommitArgs; + type Output = CommitResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_commit", + "description": "Commit and resolve a MACP session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "action": { "type": "string", "description": "The resolved action" }, + "authority_scope": { "type": "string" }, + "reason": { "type": "string" } + }, + "required": ["session_id", "action", "authority_scope", "reason"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::commitment_payload( + &helpers::new_message_id(), + &args.action, + &args.authority_scope, + &args.reason, + ); + let env = helpers::envelope( + &self.mode, "Commitment", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(CommitResult { ok: ack.ok, session_state: ack.session_state }) + } +} diff --git a/integration_tests/src/macp_tools/decision.rs b/integration_tests/src/macp_tools/decision.rs new file mode 100644 index 0000000..16cf996 --- /dev/null +++ b/integration_tests/src/macp_tools/decision.rs @@ -0,0 +1,176 @@ +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; + +use crate::helpers; +use super::SharedClient; + +// ── ProposeTool ───────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct ProposeTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct ProposeArgs { + pub session_id: String, + pub proposal_id: String, + pub option: String, + pub rationale: String, +} + +#[derive(Serialize)] +pub struct ToolResult { + pub ok: bool, + pub session_state: i32, +} + +#[derive(Debug, thiserror::Error)] +#[error("MACP tool error: {0}")] +pub struct MacpToolError(pub String); + +impl Tool for ProposeTool { + const NAME: &'static str = "macp_propose"; + type Error = MacpToolError; + type Args = ProposeArgs; + type Output = ToolResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_propose", + "description": "Submit a proposal in a MACP Decision session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "proposal_id": { "type": "string" }, + "option": { "type": "string" }, + "rationale": { "type": "string" } + }, + "required": ["session_id", "proposal_id", "option", "rationale"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::proposal_payload(&args.proposal_id, &args.option, &args.rationale); + let env = helpers::envelope( + helpers::MODE_DECISION, "Proposal", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + } +} + +// ── EvaluateTool ──────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct EvaluateTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct EvaluateArgs { + pub session_id: String, + pub proposal_id: String, + pub recommendation: String, + pub confidence: f64, + pub reason: String, +} + +impl Tool for EvaluateTool { + const NAME: &'static str = "macp_evaluate"; + type Error = MacpToolError; + type Args = EvaluateArgs; + type Output = ToolResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_evaluate", + "description": "Submit an evaluation of a proposal in a MACP Decision session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "proposal_id": { "type": "string" }, + "recommendation": { "type": "string" }, + "confidence": { "type": "number" }, + "reason": { "type": "string" } + }, + "required": ["session_id", "proposal_id", "recommendation", "confidence", "reason"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::evaluation_payload( + &args.proposal_id, &args.recommendation, args.confidence, &args.reason, + ); + let env = helpers::envelope( + helpers::MODE_DECISION, "Evaluation", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + } +} + +// ── VoteTool ──────────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct VoteTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct VoteArgs { + pub session_id: String, + pub proposal_id: String, + pub vote: String, + pub reason: String, +} + +impl Tool for VoteTool { + const NAME: &'static str = "macp_vote"; + type Error = MacpToolError; + type Args = VoteArgs; + type Output = ToolResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_vote", + "description": "Cast a vote on a proposal in a MACP Decision session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "proposal_id": { "type": "string" }, + "vote": { "type": "string", "description": "approve or reject" }, + "reason": { "type": "string" } + }, + "required": ["session_id", "proposal_id", "vote", "reason"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::vote_payload(&args.proposal_id, &args.vote, &args.reason); + let env = helpers::envelope( + helpers::MODE_DECISION, "Vote", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + } +} diff --git a/integration_tests/src/macp_tools/handoff.rs b/integration_tests/src/macp_tools/handoff.rs new file mode 100644 index 0000000..77890e1 --- /dev/null +++ b/integration_tests/src/macp_tools/handoff.rs @@ -0,0 +1,113 @@ +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; + +use crate::helpers; +use super::SharedClient; +use super::decision::MacpToolError; + +#[derive(Serialize)] +pub struct ToolResult { + pub ok: bool, + pub session_state: i32, +} + +#[derive(Clone)] +pub struct HandoffOfferTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct HandoffOfferArgs { + pub session_id: String, + pub handoff_id: String, + pub target: String, + pub scope: String, + pub reason: String, +} + +impl Tool for HandoffOfferTool { + const NAME: &'static str = "macp_handoff_offer"; + type Error = MacpToolError; + type Args = HandoffOfferArgs; + type Output = ToolResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_handoff_offer", + "description": "Offer a handoff to another agent in a MACP Handoff session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "handoff_id": { "type": "string" }, + "target": { "type": "string" }, + "scope": { "type": "string" }, + "reason": { "type": "string" } + }, + "required": ["session_id", "handoff_id", "target", "scope", "reason"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::handoff_offer_payload(&args.handoff_id, &args.target, &args.scope, &args.reason); + let env = helpers::envelope( + helpers::MODE_HANDOFF, "HandoffOffer", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + } +} + +#[derive(Clone)] +pub struct HandoffAcceptTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct HandoffAcceptArgs { + pub session_id: String, + pub handoff_id: String, + pub reason: String, +} + +impl Tool for HandoffAcceptTool { + const NAME: &'static str = "macp_handoff_accept"; + type Error = MacpToolError; + type Args = HandoffAcceptArgs; + type Output = ToolResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_handoff_accept", + "description": "Accept a handoff in a MACP Handoff session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "handoff_id": { "type": "string" }, + "reason": { "type": "string" } + }, + "required": ["session_id", "handoff_id", "reason"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::handoff_accept_payload(&args.handoff_id, &self.agent_id, &args.reason); + let env = helpers::envelope( + helpers::MODE_HANDOFF, "HandoffAccept", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + } +} diff --git a/integration_tests/src/macp_tools/mod.rs b/integration_tests/src/macp_tools/mod.rs new file mode 100644 index 0000000..d8aa94c --- /dev/null +++ b/integration_tests/src/macp_tools/mod.rs @@ -0,0 +1,25 @@ +pub mod commit; +pub mod decision; +pub mod handoff; +pub mod proposal; +pub mod query; +pub mod quorum; +pub mod session_start; +pub mod task; + +use std::sync::Arc; + +use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; +use tokio::sync::Mutex; +use tonic::transport::Channel; + +/// Shared gRPC client handle used by all MACP tools. +pub type SharedClient = Arc>>; + +/// Create a shared client for use with MACP tools. +pub async fn shared_client(endpoint: &str) -> SharedClient { + let client = MacpRuntimeServiceClient::connect(endpoint.to_string()) + .await + .expect("failed to connect"); + Arc::new(Mutex::new(client)) +} diff --git a/integration_tests/src/macp_tools/proposal.rs b/integration_tests/src/macp_tools/proposal.rs new file mode 100644 index 0000000..6c4ae86 --- /dev/null +++ b/integration_tests/src/macp_tools/proposal.rs @@ -0,0 +1,111 @@ +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; + +use crate::helpers; +use super::SharedClient; +use super::decision::MacpToolError; + +#[derive(Serialize)] +pub struct ToolResult { + pub ok: bool, + pub session_state: i32, +} + +#[derive(Clone)] +pub struct SubmitProposalTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct SubmitProposalArgs { + pub session_id: String, + pub proposal_id: String, + pub title: String, + pub summary: String, +} + +impl Tool for SubmitProposalTool { + const NAME: &'static str = "macp_submit_proposal"; + type Error = MacpToolError; + type Args = SubmitProposalArgs; + type Output = ToolResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_submit_proposal", + "description": "Submit a proposal in a MACP Proposal negotiation session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "proposal_id": { "type": "string" }, + "title": { "type": "string" }, + "summary": { "type": "string" } + }, + "required": ["session_id", "proposal_id", "title", "summary"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::proposal_mode_payload(&args.proposal_id, &args.title, &args.summary); + let env = helpers::envelope( + helpers::MODE_PROPOSAL, "Proposal", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + } +} + +#[derive(Clone)] +pub struct AcceptProposalTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct AcceptProposalArgs { + pub session_id: String, + pub proposal_id: String, + pub reason: String, +} + +impl Tool for AcceptProposalTool { + const NAME: &'static str = "macp_accept_proposal"; + type Error = MacpToolError; + type Args = AcceptProposalArgs; + type Output = ToolResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_accept_proposal", + "description": "Accept a proposal in a MACP Proposal negotiation session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "proposal_id": { "type": "string" }, + "reason": { "type": "string" } + }, + "required": ["session_id", "proposal_id", "reason"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::accept_proposal_payload(&args.proposal_id, &args.reason); + let env = helpers::envelope( + helpers::MODE_PROPOSAL, "Accept", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + } +} diff --git a/integration_tests/src/macp_tools/query.rs b/integration_tests/src/macp_tools/query.rs new file mode 100644 index 0000000..156f312 --- /dev/null +++ b/integration_tests/src/macp_tools/query.rs @@ -0,0 +1,58 @@ +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; + +use crate::helpers; +use super::SharedClient; +use super::decision::MacpToolError; + +#[derive(Clone)] +pub struct GetSessionTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct GetSessionArgs { + pub session_id: String, +} + +#[derive(Serialize)] +pub struct GetSessionResult { + pub session_id: String, + pub mode: String, + pub state: i32, +} + +impl Tool for GetSessionTool { + const NAME: &'static str = "macp_get_session"; + type Error = MacpToolError; + type Args = GetSessionArgs; + type Output = GetSessionResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_get_session", + "description": "Get the current state of a MACP session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" } + }, + "required": ["session_id"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let mut client = self.client.lock().await; + let resp = helpers::get_session_as(&mut client, &self.agent_id, &args.session_id) + .await.map_err(|e| MacpToolError(e.to_string()))?; + let meta = resp.metadata.ok_or_else(|| MacpToolError("no metadata".into()))?; + Ok(GetSessionResult { + session_id: meta.session_id, + mode: meta.mode, + state: meta.state, + }) + } +} diff --git a/integration_tests/src/macp_tools/quorum.rs b/integration_tests/src/macp_tools/quorum.rs new file mode 100644 index 0000000..f0d2d06 --- /dev/null +++ b/integration_tests/src/macp_tools/quorum.rs @@ -0,0 +1,115 @@ +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; + +use crate::helpers; +use super::SharedClient; +use super::decision::MacpToolError; + +#[derive(Serialize)] +pub struct ToolResult { + pub ok: bool, + pub session_state: i32, +} + +#[derive(Clone)] +pub struct ApprovalRequestTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct ApprovalRequestArgs { + pub session_id: String, + pub request_id: String, + pub action: String, + pub summary: String, + pub required_approvals: u32, +} + +impl Tool for ApprovalRequestTool { + const NAME: &'static str = "macp_approval_request"; + type Error = MacpToolError; + type Args = ApprovalRequestArgs; + type Output = ToolResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_approval_request", + "description": "Submit an approval request in a MACP Quorum session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "request_id": { "type": "string" }, + "action": { "type": "string" }, + "summary": { "type": "string" }, + "required_approvals": { "type": "integer" } + }, + "required": ["session_id", "request_id", "action", "summary", "required_approvals"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::approval_request_payload( + &args.request_id, &args.action, &args.summary, args.required_approvals, + ); + let env = helpers::envelope( + helpers::MODE_QUORUM, "ApprovalRequest", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + } +} + +#[derive(Clone)] +pub struct ApproveTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct ApproveArgs { + pub session_id: String, + pub request_id: String, + pub reason: String, +} + +impl Tool for ApproveTool { + const NAME: &'static str = "macp_approve"; + type Error = MacpToolError; + type Args = ApproveArgs; + type Output = ToolResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_approve", + "description": "Approve a request in a MACP Quorum session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "request_id": { "type": "string" }, + "reason": { "type": "string" } + }, + "required": ["session_id", "request_id", "reason"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::approve_payload(&args.request_id, &args.reason); + let env = helpers::envelope( + helpers::MODE_QUORUM, "Approve", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + } +} diff --git a/integration_tests/src/macp_tools/session_start.rs b/integration_tests/src/macp_tools/session_start.rs new file mode 100644 index 0000000..b509300 --- /dev/null +++ b/integration_tests/src/macp_tools/session_start.rs @@ -0,0 +1,81 @@ +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; + +use crate::helpers; +use super::SharedClient; + +#[derive(Clone)] +pub struct StartSessionTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct StartSessionArgs { + pub mode: String, + pub session_id: String, + pub intent: String, + pub participants: Vec, + pub ttl_ms: i64, +} + +#[derive(Serialize)] +pub struct StartSessionResult { + pub ok: bool, + pub session_id: String, + pub session_state: i32, +} + +#[derive(Debug, thiserror::Error)] +#[error("StartSession error: {0}")] +pub struct StartSessionError(String); + +impl Tool for StartSessionTool { + const NAME: &'static str = "macp_start_session"; + type Error = StartSessionError; + type Args = StartSessionArgs; + type Output = StartSessionResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_start_session", + "description": "Start a new MACP coordination session with the given mode and participants", + "parameters": { + "type": "object", + "properties": { + "mode": { "type": "string", "description": "The MACP mode (e.g. macp.mode.decision.v1)" }, + "session_id": { "type": "string", "description": "Unique session ID" }, + "intent": { "type": "string", "description": "Purpose of the session" }, + "participants": { "type": "array", "items": { "type": "string" }, "description": "List of participant agent IDs" }, + "ttl_ms": { "type": "integer", "description": "Session time-to-live in milliseconds" } + }, + "required": ["mode", "session_id", "intent", "participants", "ttl_ms"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let participants: Vec<&str> = args.participants.iter().map(|s| s.as_str()).collect(); + let payload = helpers::session_start_payload(&args.intent, &participants, args.ttl_ms); + let env = helpers::envelope( + &args.mode, + "SessionStart", + &helpers::new_message_id(), + &args.session_id, + &self.agent_id, + payload, + ); + + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await + .map_err(|e| StartSessionError(e.to_string()))?; + + Ok(StartSessionResult { + ok: ack.ok, + session_id: args.session_id, + session_state: ack.session_state, + }) + } +} diff --git a/integration_tests/src/macp_tools/task.rs b/integration_tests/src/macp_tools/task.rs new file mode 100644 index 0000000..f4a8bad --- /dev/null +++ b/integration_tests/src/macp_tools/task.rs @@ -0,0 +1,165 @@ +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use serde::{Deserialize, Serialize}; + +use crate::helpers; +use super::SharedClient; +use super::decision::MacpToolError; + +#[derive(Serialize)] +pub struct ToolResult { + pub ok: bool, + pub session_state: i32, +} + +// ── TaskRequestTool ───────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct TaskRequestTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct TaskRequestArgs { + pub session_id: String, + pub task_id: String, + pub title: String, + pub instructions: String, + pub assignee: String, +} + +impl Tool for TaskRequestTool { + const NAME: &'static str = "macp_task_request"; + type Error = MacpToolError; + type Args = TaskRequestArgs; + type Output = ToolResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_task_request", + "description": "Create a task request in a MACP Task session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "task_id": { "type": "string" }, + "title": { "type": "string" }, + "instructions": { "type": "string" }, + "assignee": { "type": "string" } + }, + "required": ["session_id", "task_id", "title", "instructions", "assignee"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::task_request_payload(&args.task_id, &args.title, &args.instructions, &args.assignee); + let env = helpers::envelope( + helpers::MODE_TASK, "TaskRequest", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + } +} + +// ── TaskAcceptTool ────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct TaskAcceptTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct TaskAcceptArgs { + pub session_id: String, + pub task_id: String, +} + +impl Tool for TaskAcceptTool { + const NAME: &'static str = "macp_task_accept"; + type Error = MacpToolError; + type Args = TaskAcceptArgs; + type Output = ToolResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_task_accept", + "description": "Accept a task assignment in a MACP Task session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "task_id": { "type": "string" } + }, + "required": ["session_id", "task_id"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::task_accept_payload(&args.task_id, &self.agent_id); + let env = helpers::envelope( + helpers::MODE_TASK, "TaskAccept", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + } +} + +// ── TaskCompleteTool ──────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct TaskCompleteTool { + pub client: SharedClient, + pub agent_id: String, +} + +#[derive(Deserialize)] +pub struct TaskCompleteArgs { + pub session_id: String, + pub task_id: String, + pub summary: String, +} + +impl Tool for TaskCompleteTool { + const NAME: &'static str = "macp_task_complete"; + type Error = MacpToolError; + type Args = TaskCompleteArgs; + type Output = ToolResult; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + serde_json::from_value(serde_json::json!({ + "name": "macp_task_complete", + "description": "Mark a task as complete in a MACP Task session", + "parameters": { + "type": "object", + "properties": { + "session_id": { "type": "string" }, + "task_id": { "type": "string" }, + "summary": { "type": "string" } + }, + "required": ["session_id", "task_id", "summary"] + } + })).unwrap() + } + + async fn call(&self, args: Self::Args) -> Result { + let payload = helpers::task_complete_payload(&args.task_id, &self.agent_id, &args.summary); + let env = helpers::envelope( + helpers::MODE_TASK, "TaskComplete", + &helpers::new_message_id(), &args.session_id, &self.agent_id, payload, + ); + let mut client = self.client.lock().await; + let ack = helpers::send_as(&mut client, &self.agent_id, env) + .await.map_err(|e| MacpToolError(e.to_string()))?; + Ok(ToolResult { ok: ack.ok, session_state: ack.session_state }) + } +} diff --git a/integration_tests/src/server_manager.rs b/integration_tests/src/server_manager.rs new file mode 100644 index 0000000..55d9b7b --- /dev/null +++ b/integration_tests/src/server_manager.rs @@ -0,0 +1,97 @@ +use std::net::TcpListener; +use std::process::{Child, Command}; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; +use macp_runtime::pb::InitializeRequest; + +/// Manages the lifecycle of a local MACP runtime server subprocess. +pub struct ServerManager { + process: Option, + pub endpoint: String, +} + +impl ServerManager { + /// Start a runtime server on a free port. Returns once the server is accepting connections. + pub async fn start(binary_path: &str) -> Result { + let port = find_free_port()?; + let bind_addr = format!("127.0.0.1:{port}"); + let endpoint = format!("http://{bind_addr}"); + + tracing::info!("Starting MACP runtime: {binary_path} on {bind_addr}"); + + let child = Command::new(binary_path) + .env("MACP_ALLOW_INSECURE", "1") + .env("MACP_ALLOW_DEV_SENDER_HEADER", "1") + .env("MACP_MEMORY_ONLY", "1") + .env("MACP_BIND_ADDR", &bind_addr) + .env("RUST_LOG", "warn") + .spawn() + .with_context(|| format!("failed to start runtime binary: {binary_path}"))?; + + let mut manager = Self { + process: Some(child), + endpoint: endpoint.clone(), + }; + + if let Err(e) = wait_for_ready(&endpoint).await { + manager.stop(); + bail!("Server failed to become ready: {e}"); + } + + tracing::info!("MACP runtime is ready at {endpoint}"); + Ok(manager) + } + + /// Send SIGTERM and wait for the process to exit. + pub fn stop(&mut self) { + if let Some(mut child) = self.process.take() { + tracing::info!("Stopping MACP runtime (pid={})", child.id()); + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +impl Drop for ServerManager { + fn drop(&mut self) { + self.stop(); + } +} + +/// Find a free TCP port by binding to port 0 and reading the assigned port. +fn find_free_port() -> Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + drop(listener); + Ok(port) +} + +/// Poll the server with Initialize RPCs until it responds or timeout. +async fn wait_for_ready(endpoint: &str) -> Result<()> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let mut interval = tokio::time::interval(Duration::from_millis(100)); + + loop { + interval.tick().await; + + if tokio::time::Instant::now() > deadline { + bail!("Timed out waiting for server at {endpoint}"); + } + + match MacpRuntimeServiceClient::connect(endpoint.to_string()).await { + Ok(mut client) => { + let req = InitializeRequest { + supported_protocol_versions: vec!["1.0".into()], + client_info: None, + capabilities: None, + }; + if client.initialize(req).await.is_ok() { + return Ok(()); + } + } + Err(_) => continue, + } + } +} diff --git a/integration_tests/tests/common/mod.rs b/integration_tests/tests/common/mod.rs new file mode 100644 index 0000000..13fc44d --- /dev/null +++ b/integration_tests/tests/common/mod.rs @@ -0,0 +1,54 @@ +use std::sync::OnceLock; + +use macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient; +use macp_integration_tests::config::TestConfig; +use macp_integration_tests::server_manager::ServerManager; +use tonic::transport::Channel; +use tokio::sync::Mutex; + +/// Global server instance (started once, shared across all tests in a binary). +static SERVER: OnceLock>> = OnceLock::new(); +static ENDPOINT: OnceLock = OnceLock::new(); + +/// Get the runtime endpoint, starting a server if necessary. +pub async fn endpoint() -> &'static str { + // Fast path: already initialized + if let Some(ep) = ENDPOINT.get() { + return ep.as_str(); + } + + // Slow path: initialize + let config = TestConfig::from_env(); + + if let Some(ep) = config.endpoint { + let _ = ENDPOINT.set(ep); + let _ = SERVER.set(Mutex::new(None)); + return ENDPOINT.get().unwrap().as_str(); + } + + // Need to start a local server + let binary = config.binary_path.unwrap_or_else(|| { + // Default: look for binary relative to integration_tests/ + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + format!("{manifest_dir}/../target/debug/macp-runtime") + }); + + let manager = ServerManager::start(&binary) + .await + .expect("failed to start local MACP runtime"); + + let ep = manager.endpoint.clone(); + let _ = ENDPOINT.set(ep); + let _ = SERVER.set(Mutex::new(Some(manager))); + + ENDPOINT.get().unwrap().as_str() +} + +/// Create a new gRPC client connected to the test runtime. +#[allow(dead_code)] +pub async fn grpc_client() -> MacpRuntimeServiceClient { + let ep = endpoint().await; + MacpRuntimeServiceClient::connect(ep.to_string()) + .await + .expect("failed to connect to runtime") +} diff --git a/integration_tests/tests/tier1.rs b/integration_tests/tests/tier1.rs new file mode 100644 index 0000000..85fb845 --- /dev/null +++ b/integration_tests/tests/tier1.rs @@ -0,0 +1,2 @@ +mod common; +mod tier1_protocol; diff --git a/integration_tests/tests/tier1_protocol/mod.rs b/integration_tests/tests/tier1_protocol/mod.rs new file mode 100644 index 0000000..6b98b9d --- /dev/null +++ b/integration_tests/tests/tier1_protocol/mod.rs @@ -0,0 +1,15 @@ +mod test_initialize; +mod test_decision_mode; +mod test_proposal_mode; +mod test_task_mode; +mod test_handoff_mode; +mod test_quorum_mode; +mod test_multi_round_mode; +mod test_stream_session; +mod test_cancel_session; +mod test_error_paths; +mod test_mode_registry; +mod test_concurrent_sessions; +mod test_session_lifecycle; +mod test_reject_paths; +mod test_rfc_cross_cutting; diff --git a/integration_tests/tests/tier1_protocol/test_cancel_session.rs b/integration_tests/tests/tier1_protocol/test_cancel_session.rs new file mode 100644 index 0000000..d98b60c --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_cancel_session.rs @@ -0,0 +1,79 @@ +use crate::common; +use macp_integration_tests::helpers::*; + +#[tokio::test] +async fn cancel_active_session() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let participant = "agent://participant"; + + // Start a decision session + let ack = send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("cancel test", &[coord, participant], 30_000), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Cancel from initiator + let ack = cancel_session_as(&mut client, coord, &sid, "changed my mind").await.unwrap(); + assert!(ack.ok); + + // Verify session is no longer open + let resp = get_session_as(&mut client, coord, &sid).await.unwrap(); + let meta = resp.metadata.expect("metadata present"); + assert_ne!(meta.state, 1); // not OPEN +} + +#[tokio::test] +async fn send_to_cancelled_session_fails() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let voter = "agent://voter"; + + // Start and cancel + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("cancel test 2", &[coord, voter], 30_000), + ), + ) + .await + .unwrap(); + + cancel_session_as(&mut client, coord, &sid, "done").await.unwrap(); + + // Try sending to cancelled session + let ack = send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + coord, + proposal_payload("p1", "late proposal", "should fail"), + ), + ) + .await + .unwrap(); + assert!(!ack.ok); +} diff --git a/integration_tests/tests/tier1_protocol/test_concurrent_sessions.rs b/integration_tests/tests/tier1_protocol/test_concurrent_sessions.rs new file mode 100644 index 0000000..3128caa --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_concurrent_sessions.rs @@ -0,0 +1,131 @@ +use crate::common; +use macp_integration_tests::helpers::*; + +#[tokio::test] +async fn concurrent_sessions_across_modes() { + let mut client = common::grpc_client().await; + + // Start 5 sessions in different modes simultaneously + let modes = [ + MODE_DECISION, + MODE_PROPOSAL, + MODE_TASK, + MODE_HANDOFF, + MODE_QUORUM, + ]; + + let mut session_ids = Vec::new(); + for mode in &modes { + let sid = new_session_id(); + let agent = "agent://concurrent-test"; + let partner = "agent://partner"; + + let ack = send_as( + &mut client, + agent, + envelope( + mode, + "SessionStart", + &new_message_id(), + &sid, + agent, + session_start_payload("concurrent", &[agent, partner], 30_000), + ), + ) + .await + .unwrap(); + assert!(ack.ok, "Failed to start session for mode {mode}"); + session_ids.push((sid, *mode)); + } + + // Verify all sessions are open + for (sid, mode) in &session_ids { + let resp = get_session_as(&mut client, "agent://concurrent-test", sid) + .await + .unwrap(); + let meta = resp.metadata.expect("metadata present"); + assert_eq!(meta.state, 1, "Session for {mode} should be OPEN"); // OPEN + assert_eq!(meta.mode, *mode); + } +} + +#[tokio::test] +async fn parallel_decision_sessions_are_independent() { + let mut client = common::grpc_client().await; + let coord = "agent://coord"; + let voter = "agent://voter"; + + let sid1 = new_session_id(); + let sid2 = new_session_id(); + + // Start two independent decision sessions + for sid in [&sid1, &sid2] { + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + sid, + coord, + session_start_payload("parallel", &[coord, voter], 30_000), + ), + ) + .await + .unwrap(); + } + + // Resolve only session 1 + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid1, + coord, + proposal_payload("p1", "option-A", "test"), + ), + ) + .await + .unwrap(); + + send_as( + &mut client, + voter, + envelope( + MODE_DECISION, + "Vote", + &new_message_id(), + &sid1, + voter, + vote_payload("p1", "yes", "ok"), + ), + ) + .await + .unwrap(); + + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Commitment", + &new_message_id(), + &sid1, + coord, + commitment_payload("c1", "option-A", "team", "done"), + ), + ) + .await + .unwrap(); + + // Verify: session 1 resolved, session 2 still open + let resp1 = get_session_as(&mut client, coord, &sid1).await.unwrap(); + assert_eq!(resp1.metadata.unwrap().state, 2); // RESOLVED + + let resp2 = get_session_as(&mut client, coord, &sid2).await.unwrap(); + assert_eq!(resp2.metadata.unwrap().state, 1); // OPEN +} diff --git a/integration_tests/tests/tier1_protocol/test_decision_mode.rs b/integration_tests/tests/tier1_protocol/test_decision_mode.rs new file mode 100644 index 0000000..8c0841f --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_decision_mode.rs @@ -0,0 +1,152 @@ +use crate::common; +use macp_integration_tests::helpers::*; + +#[tokio::test] +async fn decision_happy_path() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let voter = "agent://voter"; + + // SessionStart + let ack = send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("decide deployment", &[coord, voter], 30_000), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Proposal from coordinator + let ack = send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + coord, + proposal_payload("p1", "deploy-v2", "better performance"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Evaluation from voter + let ack = send_as( + &mut client, + voter, + envelope( + MODE_DECISION, + "Evaluation", + &new_message_id(), + &sid, + voter, + evaluation_payload("p1", "approve", 0.9, "looks good"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Vote from voter + let ack = send_as( + &mut client, + voter, + envelope( + MODE_DECISION, + "Vote", + &new_message_id(), + &sid, + voter, + vote_payload("p1", "approve", "consensus"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Commitment from coordinator + let ack = send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Commitment", + &new_message_id(), + &sid, + coord, + commitment_payload("c1", "deploy-v2", "team", "consensus reached"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + assert_eq!(ack.session_state, 2); // RESOLVED + + // Verify via GetSession + let resp = get_session_as(&mut client, coord, &sid).await.unwrap(); + let meta = resp.metadata.expect("metadata present"); + assert_eq!(meta.state, 2); // RESOLVED +} + +#[tokio::test] +async fn decision_duplicate_message_is_flagged() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let voter = "agent://voter"; + + // SessionStart + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("test dedup", &[coord, voter], 30_000), + ), + ) + .await + .unwrap(); + + // Send same message_id twice + let mid = new_message_id(); + let env1 = envelope( + MODE_DECISION, + "Proposal", + &mid, + &sid, + coord, + proposal_payload("p1", "option-A", "first"), + ); + let env2 = envelope( + MODE_DECISION, + "Proposal", + &mid, + &sid, + coord, + proposal_payload("p1", "option-A", "first"), + ); + + let ack1 = send_as(&mut client, coord, env1).await.unwrap(); + assert!(ack1.ok); + assert!(!ack1.duplicate); + + let ack2 = send_as(&mut client, coord, env2).await.unwrap(); + assert!(ack2.ok); + assert!(ack2.duplicate); +} diff --git a/integration_tests/tests/tier1_protocol/test_error_paths.rs b/integration_tests/tests/tier1_protocol/test_error_paths.rs new file mode 100644 index 0000000..26b056e --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_error_paths.rs @@ -0,0 +1,164 @@ +use crate::common; +use macp_integration_tests::helpers::*; +use macp_runtime::pb::Envelope; + +#[tokio::test] +async fn invalid_macp_version_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let agent = "agent://test"; + + let env = Envelope { + macp_version: "2.0".into(), // unsupported + mode: MODE_DECISION.into(), + message_type: "SessionStart".into(), + message_id: new_message_id(), + session_id: sid, + sender: agent.into(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: session_start_payload("bad version", &[agent], 30_000), + }; + + let ack = send_as(&mut client, agent, env).await.unwrap(); + assert!(!ack.ok); +} + +#[tokio::test] +async fn empty_mode_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let agent = "agent://test"; + + let env = envelope( + "", + "SessionStart", + &new_message_id(), + &sid, + agent, + session_start_payload("empty mode", &[agent], 30_000), + ); + + let ack = send_as(&mut client, agent, env).await.unwrap(); + assert!(!ack.ok); +} + +#[tokio::test] +async fn session_start_without_participants_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let agent = "agent://test"; + + let env = envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + agent, + session_start_payload("no participants", &[], 30_000), + ); + + let ack = send_as(&mut client, agent, env).await.unwrap(); + assert!(!ack.ok); +} + +#[tokio::test] +async fn session_start_without_ttl_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let agent = "agent://test"; + + let env = envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + agent, + session_start_payload("no ttl", &[agent], 0), // zero TTL + ); + + let ack = send_as(&mut client, agent, env).await.unwrap(); + assert!(!ack.ok); +} + +#[tokio::test] +async fn non_participant_cannot_send() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let voter = "agent://voter"; + let outsider = "agent://outsider"; + + // Start session with coord + voter + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("restricted", &[coord, voter], 30_000), + ), + ) + .await + .unwrap(); + + // Outsider tries to send + let ack = send_as( + &mut client, + outsider, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + outsider, + proposal_payload("p1", "sneaky", "unauthorized"), + ), + ) + .await + .unwrap(); + assert!(!ack.ok); +} + +#[tokio::test] +async fn send_to_nonexistent_session_fails() { + let mut client = common::grpc_client().await; + let agent = "agent://test"; + + let ack = send_as( + &mut client, + agent, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &new_session_id(), // session doesn't exist + agent, + proposal_payload("p1", "orphan", "no session"), + ), + ) + .await + .unwrap(); + assert!(!ack.ok); +} + +#[tokio::test] +async fn unknown_mode_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let agent = "agent://test"; + + let env = envelope( + "macp.mode.nonexistent.v99", + "SessionStart", + &new_message_id(), + &sid, + agent, + session_start_payload("unknown mode", &[agent], 30_000), + ); + + let ack = send_as(&mut client, agent, env).await.unwrap(); + assert!(!ack.ok); +} diff --git a/integration_tests/tests/tier1_protocol/test_handoff_mode.rs b/integration_tests/tests/tier1_protocol/test_handoff_mode.rs new file mode 100644 index 0000000..0b3e852 --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_handoff_mode.rs @@ -0,0 +1,96 @@ +use crate::common; +use macp_integration_tests::helpers::*; + +#[tokio::test] +async fn handoff_happy_path() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let source = "agent://source"; + let target = "agent://target"; + + // SessionStart + let ack = send_as( + &mut client, + source, + envelope( + MODE_HANDOFF, + "SessionStart", + &new_message_id(), + &sid, + source, + session_start_payload("handoff customer", &[source, target], 30_000), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // HandoffOffer from source + let ack = send_as( + &mut client, + source, + envelope( + MODE_HANDOFF, + "HandoffOffer", + &new_message_id(), + &sid, + source, + handoff_offer_payload("h1", target, "customer-support", "escalation needed"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // HandoffContext from source + let ack = send_as( + &mut client, + source, + envelope( + MODE_HANDOFF, + "HandoffContext", + &new_message_id(), + &sid, + source, + handoff_context_payload("h1", "application/json", b"{\"customer_id\": 42}"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // HandoffAccept from target + let ack = send_as( + &mut client, + target, + envelope( + MODE_HANDOFF, + "HandoffAccept", + &new_message_id(), + &sid, + target, + handoff_accept_payload("h1", target, "ready to assist"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Commitment from source + let ack = send_as( + &mut client, + source, + envelope( + MODE_HANDOFF, + "Commitment", + &new_message_id(), + &sid, + source, + commitment_payload("c1", "handoff-complete", "support", "transferred"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + assert_eq!(ack.session_state, 2); +} diff --git a/integration_tests/tests/tier1_protocol/test_initialize.rs b/integration_tests/tests/tier1_protocol/test_initialize.rs new file mode 100644 index 0000000..99a4adc --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_initialize.rs @@ -0,0 +1,31 @@ +use crate::common; +use macp_integration_tests::helpers; + +#[tokio::test] +async fn initialize_returns_protocol_version() { + let mut client = common::grpc_client().await; + let resp = helpers::initialize(&mut client).await.unwrap(); + assert_eq!(resp.selected_protocol_version, "1.0"); +} + +#[tokio::test] +async fn initialize_returns_runtime_info() { + let mut client = common::grpc_client().await; + let resp = helpers::initialize(&mut client).await.unwrap(); + let info = resp.runtime_info.expect("runtime_info present"); + assert!(!info.name.is_empty()); + assert!(!info.version.is_empty()); +} + +#[tokio::test] +async fn list_modes_returns_five_standard_modes() { + let mut client = common::grpc_client().await; + let resp = helpers::list_modes(&mut client).await.unwrap(); + let mode_ids: Vec<&str> = resp.modes.iter().map(|m| m.mode.as_str()).collect(); + assert!(mode_ids.contains(&"macp.mode.decision.v1")); + assert!(mode_ids.contains(&"macp.mode.proposal.v1")); + assert!(mode_ids.contains(&"macp.mode.task.v1")); + assert!(mode_ids.contains(&"macp.mode.handoff.v1")); + assert!(mode_ids.contains(&"macp.mode.quorum.v1")); + assert_eq!(resp.modes.len(), 5); +} diff --git a/integration_tests/tests/tier1_protocol/test_mode_registry.rs b/integration_tests/tests/tier1_protocol/test_mode_registry.rs new file mode 100644 index 0000000..9db6667 --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_mode_registry.rs @@ -0,0 +1,96 @@ +use crate::common; +use macp_integration_tests::helpers::*; +use macp_runtime::pb::{ + ListExtModesRequest, ModeDescriptor, RegisterExtModeRequest, UnregisterExtModeRequest, +}; +use tonic::Request; + +fn with_sender(sender: &str, inner: T) -> Request { + let mut request = Request::new(inner); + request.metadata_mut().insert( + "x-macp-agent-id", + sender.parse().expect("valid sender header"), + ); + request +} + +#[tokio::test] +async fn list_modes_returns_standard_modes() { + let mut client = common::grpc_client().await; + let resp = list_modes(&mut client).await.unwrap(); + assert_eq!(resp.modes.len(), 5); +} + +#[tokio::test] +async fn list_ext_modes_includes_multi_round() { + let mut client = common::grpc_client().await; + let resp = client + .list_ext_modes(ListExtModesRequest {}) + .await + .unwrap() + .into_inner(); + let ext_ids: Vec<&str> = resp.modes.iter().map(|m| m.mode.as_str()).collect(); + assert!(ext_ids.contains(&"ext.multi_round.v1")); +} + +#[tokio::test] +async fn register_and_unregister_ext_mode() { + let mut client = common::grpc_client().await; + let agent = "agent://registry-admin"; + + let descriptor = ModeDescriptor { + mode: "ext.test.custom.v1".into(), + mode_version: "1.0.0".into(), + title: "Test Custom Extension".into(), + description: "A test extension mode".into(), + determinism_class: String::new(), + participant_model: String::new(), + message_types: vec!["Ping".into(), "Pong".into()], + terminal_message_types: vec![], + schema_uris: Default::default(), + }; + + // Register + let resp = client + .register_ext_mode(with_sender( + agent, + RegisterExtModeRequest { + descriptor: Some(descriptor), + }, + )) + .await + .unwrap() + .into_inner(); + assert!(resp.ok); + + // Verify it appears in ext modes + let resp = client + .list_ext_modes(ListExtModesRequest {}) + .await + .unwrap() + .into_inner(); + let ext_ids: Vec<&str> = resp.modes.iter().map(|m| m.mode.as_str()).collect(); + assert!(ext_ids.contains(&"ext.test.custom.v1")); + + // Unregister + let resp = client + .unregister_ext_mode(with_sender( + agent, + UnregisterExtModeRequest { + mode: "ext.test.custom.v1".into(), + }, + )) + .await + .unwrap() + .into_inner(); + assert!(resp.ok); + + // Verify it's gone + let resp = client + .list_ext_modes(ListExtModesRequest {}) + .await + .unwrap() + .into_inner(); + let ext_ids: Vec<&str> = resp.modes.iter().map(|m| m.mode.as_str()).collect(); + assert!(!ext_ids.contains(&"ext.test.custom.v1")); +} diff --git a/integration_tests/tests/tier1_protocol/test_multi_round_mode.rs b/integration_tests/tests/tier1_protocol/test_multi_round_mode.rs new file mode 100644 index 0000000..52bcd18 --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_multi_round_mode.rs @@ -0,0 +1,123 @@ +use crate::common; +use macp_integration_tests::helpers::*; + +#[tokio::test] +async fn multi_round_happy_path() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let agent_a = "agent://agent-a"; + let agent_b = "agent://agent-b"; + + // SessionStart + let ack = send_as( + &mut client, + agent_a, + envelope( + MODE_MULTI_ROUND, + "SessionStart", + &new_message_id(), + &sid, + agent_a, + session_start_payload("converge on value", &[agent_a, agent_b], 30_000), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Round 1: Contribute from both agents + let ack = send_as( + &mut client, + agent_a, + envelope( + MODE_MULTI_ROUND, + "Contribute", + &new_message_id(), + &sid, + agent_a, + serde_json::to_vec(&serde_json::json!({ + "value": "alpha" + })) + .unwrap(), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + let ack = send_as( + &mut client, + agent_b, + envelope( + MODE_MULTI_ROUND, + "Contribute", + &new_message_id(), + &sid, + agent_b, + serde_json::to_vec(&serde_json::json!({ + "value": "beta" + })) + .unwrap(), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Round 2: Revised contributions + let ack = send_as( + &mut client, + agent_a, + envelope( + MODE_MULTI_ROUND, + "Contribute", + &new_message_id(), + &sid, + agent_a, + serde_json::to_vec(&serde_json::json!({ + "value": "converged" + })) + .unwrap(), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + let ack = send_as( + &mut client, + agent_b, + envelope( + MODE_MULTI_ROUND, + "Contribute", + &new_message_id(), + &sid, + agent_b, + serde_json::to_vec(&serde_json::json!({ + "value": "converged" + })) + .unwrap(), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Commitment + let ack = send_as( + &mut client, + agent_a, + envelope( + MODE_MULTI_ROUND, + "Commitment", + &new_message_id(), + &sid, + agent_a, + commitment_payload("c1", "converged", "group", "all agreed"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + assert_eq!(ack.session_state, 2); +} diff --git a/integration_tests/tests/tier1_protocol/test_proposal_mode.rs b/integration_tests/tests/tier1_protocol/test_proposal_mode.rs new file mode 100644 index 0000000..f8bc699 --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_proposal_mode.rs @@ -0,0 +1,113 @@ +use crate::common; +use macp_integration_tests::helpers::*; + +#[tokio::test] +async fn proposal_happy_path() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let buyer = "agent://buyer"; + let seller = "agent://seller"; + + // SessionStart + let ack = send_as( + &mut client, + buyer, + envelope( + MODE_PROPOSAL, + "SessionStart", + &new_message_id(), + &sid, + buyer, + session_start_payload("negotiate price", &[buyer, seller], 30_000), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Proposal from seller + let ack = send_as( + &mut client, + seller, + envelope( + MODE_PROPOSAL, + "Proposal", + &new_message_id(), + &sid, + seller, + proposal_mode_payload("prop-1", "Initial offer", "$100"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // CounterProposal from buyer + let ack = send_as( + &mut client, + buyer, + envelope( + MODE_PROPOSAL, + "CounterProposal", + &new_message_id(), + &sid, + buyer, + counter_proposal_payload("prop-2", "prop-1", "Counter offer", "$80"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Accept from seller + let ack = send_as( + &mut client, + seller, + envelope( + MODE_PROPOSAL, + "Accept", + &new_message_id(), + &sid, + seller, + accept_proposal_payload("prop-2", "acceptable price"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Accept from buyer + let ack = send_as( + &mut client, + buyer, + envelope( + MODE_PROPOSAL, + "Accept", + &new_message_id(), + &sid, + buyer, + accept_proposal_payload("prop-2", "agreed"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Commitment from buyer (initiator) + let ack = send_as( + &mut client, + buyer, + envelope( + MODE_PROPOSAL, + "Commitment", + &new_message_id(), + &sid, + buyer, + commitment_payload("c1", "accept-counter", "negotiation", "both accepted"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + assert_eq!(ack.session_state, 2); +} diff --git a/integration_tests/tests/tier1_protocol/test_quorum_mode.rs b/integration_tests/tests/tier1_protocol/test_quorum_mode.rs new file mode 100644 index 0000000..d129b3e --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_quorum_mode.rs @@ -0,0 +1,102 @@ +use crate::common; +use macp_integration_tests::helpers::*; + +#[tokio::test] +async fn quorum_happy_path() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let requester = "agent://requester"; + let approver1 = "agent://approver1"; + let approver2 = "agent://approver2"; + let approver3 = "agent://approver3"; + + // SessionStart + let ack = send_as( + &mut client, + requester, + envelope( + MODE_QUORUM, + "SessionStart", + &new_message_id(), + &sid, + requester, + session_start_payload( + "approve deployment", + &[requester, approver1, approver2, approver3], + 30_000, + ), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // ApprovalRequest from requester (need 2 of 3 approvers) + let ack = send_as( + &mut client, + requester, + envelope( + MODE_QUORUM, + "ApprovalRequest", + &new_message_id(), + &sid, + requester, + approval_request_payload("r1", "deploy-prod", "Deploy v2 to production", 2), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Approve from approver1 + let ack = send_as( + &mut client, + approver1, + envelope( + MODE_QUORUM, + "Approve", + &new_message_id(), + &sid, + approver1, + approve_payload("r1", "LGTM"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Approve from approver2 (quorum reached) + let ack = send_as( + &mut client, + approver2, + envelope( + MODE_QUORUM, + "Approve", + &new_message_id(), + &sid, + approver2, + approve_payload("r1", "Approved"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Commitment from requester + let ack = send_as( + &mut client, + requester, + envelope( + MODE_QUORUM, + "Commitment", + &new_message_id(), + &sid, + requester, + commitment_payload("c1", "deploy-prod", "ops-team", "quorum reached"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + assert_eq!(ack.session_state, 2); +} diff --git a/integration_tests/tests/tier1_protocol/test_reject_paths.rs b/integration_tests/tests/tier1_protocol/test_reject_paths.rs new file mode 100644 index 0000000..d57077d --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_reject_paths.rs @@ -0,0 +1,223 @@ +//! Mode-specific rejection tests — exercises protocol violations that the runtime must reject. +//! These mirror the conformance reject-path fixtures but go through the real gRPC boundary. + +use crate::common; +use macp_integration_tests::helpers::*; + +// ── Decision Mode ─────────────────────────────────────────────────────── + +#[tokio::test] +async fn decision_commitment_from_non_initiator_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let voter = "agent://voter"; + + // Start session (coord is initiator) + send_as(&mut client, coord, envelope( + MODE_DECISION, "SessionStart", &new_message_id(), &sid, coord, + session_start_payload("test", &[coord, voter], 30_000), + )).await.unwrap(); + + // Proposal from coordinator + send_as(&mut client, coord, envelope( + MODE_DECISION, "Proposal", &new_message_id(), &sid, coord, + proposal_payload("p1", "option-A", "test"), + )).await.unwrap(); + + // Voter tries to Commit — only initiator can commit per RFC-MACP-0007 + let ack = send_as(&mut client, voter, envelope( + MODE_DECISION, "Commitment", &new_message_id(), &sid, voter, + commitment_payload("c1", "option-A", "team", "unauthorized"), + )).await.unwrap(); + assert!(!ack.ok, "Non-initiator Commitment must be rejected"); +} + +// ── Proposal Mode ─────────────────────────────────────────────────────── + +#[tokio::test] +async fn proposal_commitment_without_accept_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let buyer = "agent://buyer"; + let seller = "agent://seller"; + + // Start session + send_as(&mut client, buyer, envelope( + MODE_PROPOSAL, "SessionStart", &new_message_id(), &sid, buyer, + session_start_payload("negotiate", &[buyer, seller], 30_000), + )).await.unwrap(); + + // Proposal from seller (no Accept yet) + send_as(&mut client, seller, envelope( + MODE_PROPOSAL, "Proposal", &new_message_id(), &sid, seller, + proposal_mode_payload("prop-1", "Offer", "$100"), + )).await.unwrap(); + + // Buyer tries to Commit without any Accept — should be rejected + let ack = send_as(&mut client, buyer, envelope( + MODE_PROPOSAL, "Commitment", &new_message_id(), &sid, buyer, + commitment_payload("c1", "accept-offer", "negotiation", "premature"), + )).await.unwrap(); + assert!(!ack.ok, "Commitment without Accept must be rejected"); +} + +// ── Task Mode ─────────────────────────────────────────────────────────── + +#[tokio::test] +async fn task_request_from_non_initiator_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let planner = "agent://planner"; + let worker = "agent://worker"; + + // Start session (planner is initiator) + send_as(&mut client, planner, envelope( + MODE_TASK, "SessionStart", &new_message_id(), &sid, planner, + session_start_payload("delegate", &[planner, worker], 30_000), + )).await.unwrap(); + + // Worker tries to send TaskRequest — only initiator can per RFC-MACP-0009 + let ack = send_as(&mut client, worker, envelope( + MODE_TASK, "TaskRequest", &new_message_id(), &sid, worker, + task_request_payload("t1", "Sneaky task", "unauthorized", planner), + )).await.unwrap(); + assert!(!ack.ok, "TaskRequest from non-initiator must be rejected"); +} + +#[tokio::test] +async fn task_duplicate_task_id_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let planner = "agent://planner"; + let worker = "agent://worker"; + + send_as(&mut client, planner, envelope( + MODE_TASK, "SessionStart", &new_message_id(), &sid, planner, + session_start_payload("delegate", &[planner, worker], 30_000), + )).await.unwrap(); + + // First TaskRequest succeeds + let ack = send_as(&mut client, planner, envelope( + MODE_TASK, "TaskRequest", &new_message_id(), &sid, planner, + task_request_payload("t1", "First task", "do something", worker), + )).await.unwrap(); + assert!(ack.ok); + + // Second TaskRequest with same task_id — should be rejected (one request per session) + let ack = send_as(&mut client, planner, envelope( + MODE_TASK, "TaskRequest", &new_message_id(), &sid, planner, + task_request_payload("t1", "Duplicate task", "do again", worker), + )).await.unwrap(); + assert!(!ack.ok, "Duplicate TaskRequest must be rejected"); +} + +// ── Handoff Mode ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn handoff_accept_without_offer_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let source = "agent://source"; + let target = "agent://target"; + + send_as(&mut client, source, envelope( + MODE_HANDOFF, "SessionStart", &new_message_id(), &sid, source, + session_start_payload("handoff", &[source, target], 30_000), + )).await.unwrap(); + + // Target tries to Accept without any HandoffOffer — should be rejected + let ack = send_as(&mut client, target, envelope( + MODE_HANDOFF, "HandoffAccept", &new_message_id(), &sid, target, + handoff_accept_payload("h1", target, "premature accept"), + )).await.unwrap(); + assert!(!ack.ok, "HandoffAccept without prior HandoffOffer must be rejected"); +} + +// ── Quorum Mode ───────────────────────────────────────────────────────── + +#[tokio::test] +async fn quorum_approve_before_request_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let requester = "agent://requester"; + let approver = "agent://approver"; + + send_as(&mut client, requester, envelope( + MODE_QUORUM, "SessionStart", &new_message_id(), &sid, requester, + session_start_payload("approve", &[requester, approver], 30_000), + )).await.unwrap(); + + // Approver tries to Approve without any ApprovalRequest — should be rejected + let ack = send_as(&mut client, approver, envelope( + MODE_QUORUM, "Approve", &new_message_id(), &sid, approver, + approve_payload("r1", "premature approve"), + )).await.unwrap(); + assert!(!ack.ok, "Approve before ApprovalRequest must be rejected"); +} + +#[tokio::test] +async fn quorum_commitment_before_quorum_reached_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let requester = "agent://requester"; + let approver1 = "agent://approver1"; + let approver2 = "agent://approver2"; + + send_as(&mut client, requester, envelope( + MODE_QUORUM, "SessionStart", &new_message_id(), &sid, requester, + session_start_payload("approve", &[requester, approver1, approver2], 30_000), + )).await.unwrap(); + + // ApprovalRequest needs 2 approvals + send_as(&mut client, requester, envelope( + MODE_QUORUM, "ApprovalRequest", &new_message_id(), &sid, requester, + approval_request_payload("r1", "deploy", "Deploy v2", 2), + )).await.unwrap(); + + // Only 1 approval (quorum not reached) + send_as(&mut client, approver1, envelope( + MODE_QUORUM, "Approve", &new_message_id(), &sid, approver1, + approve_payload("r1", "LGTM"), + )).await.unwrap(); + + // Requester tries to Commit before quorum — should be rejected + let ack = send_as(&mut client, requester, envelope( + MODE_QUORUM, "Commitment", &new_message_id(), &sid, requester, + commitment_payload("c1", "deploy", "ops", "premature"), + )).await.unwrap(); + assert!(!ack.ok, "Commitment before quorum reached must be rejected"); +} + +// ── Multi-Round Mode ──────────────────────────────────────────────────── + +#[tokio::test] +async fn multi_round_commitment_before_convergence_rejected() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let agent_a = "agent://agent-a"; + let agent_b = "agent://agent-b"; + + send_as(&mut client, agent_a, envelope( + MODE_MULTI_ROUND, "SessionStart", &new_message_id(), &sid, agent_a, + session_start_payload("converge", &[agent_a, agent_b], 30_000), + )).await.unwrap(); + + // Divergent contributions (not converged) + send_as(&mut client, agent_a, envelope( + MODE_MULTI_ROUND, "Contribute", &new_message_id(), &sid, agent_a, + serde_json::to_vec(&serde_json::json!({"value": "alpha"})).unwrap(), + )).await.unwrap(); + + send_as(&mut client, agent_b, envelope( + MODE_MULTI_ROUND, "Contribute", &new_message_id(), &sid, agent_b, + serde_json::to_vec(&serde_json::json!({"value": "beta"})).unwrap(), + )).await.unwrap(); + + // Try to Commit before convergence — should be rejected + let ack = send_as(&mut client, agent_a, envelope( + MODE_MULTI_ROUND, "Commitment", &new_message_id(), &sid, agent_a, + commitment_payload("c1", "premature", "group", "not converged"), + )).await.unwrap(); + assert!(!ack.ok, "Commitment before convergence must be rejected"); +} diff --git a/integration_tests/tests/tier1_protocol/test_rfc_cross_cutting.rs b/integration_tests/tests/tier1_protocol/test_rfc_cross_cutting.rs new file mode 100644 index 0000000..12f936d --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_rfc_cross_cutting.rs @@ -0,0 +1,748 @@ +//! Cross-cutting RFC feature tests — validates protocol-level invariants that span all modes. +//! These cover: Signals, determinism/version binding, deduplication, append-only history, +//! CancelSession authorization, and discovery/manifests. + +use crate::common; +use macp_integration_tests::helpers::*; +use macp_runtime::pb::{ + CommitmentPayload, Envelope, GetManifestRequest, InitializeRequest, SendRequest, + SignalPayload, WatchSignalsRequest, +}; +use prost::Message; +use tonic::Request; + +fn with_sender(sender: &str, inner: T) -> Request { + let mut request = Request::new(inner); + request.metadata_mut().insert( + "x-macp-agent-id", + sender.parse().expect("valid sender header"), + ); + request +} + +// ── Signals (RFC-MACP-0001 §5.1) ─────────────────────────────────────── + +#[tokio::test] +async fn signal_with_empty_session_and_mode_accepted() { + // RFC-MACP-0001 §5.1: Signals MUST carry empty session_id and empty mode. + // They are non-binding ambient messages on the ambient plane. + // Signals do NOT enter any session's accepted history. + // SignalPayload has: signal_type, data, confidence, correlation_session_id + let mut client = common::grpc_client().await; + + // First start a real session so we can correlate the Signal with it + let sid = new_session_id(); + let coord = "agent://coordinator"; + let worker = "agent://worker"; + + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("decision in progress", &[coord, worker], 30_000), + ), + ) + .await + .unwrap(); + + // Now send a Signal from the worker to indicate progress. + // This is an ambient message — it does NOT enter the session history. + // It uses SignalPayload with correlation_session_id to link to the session. + let signal_payload = macp_runtime::pb::SignalPayload { + signal_type: "progress".into(), + data: b"analyzing proposal options".to_vec(), + confidence: 0.0, + correlation_session_id: sid.clone(), + }; + let mid = new_message_id(); + let env = Envelope { + macp_version: "1.0".into(), + mode: String::new(), // empty — required for Signals + message_type: "Signal".into(), + message_id: mid.clone(), + session_id: String::new(), // empty — required for Signals + sender: String::new(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: signal_payload.encode_to_vec(), + }; + + eprintln!("── Signal Test: Valid Signal (ambient, non-binding) ──────────"); + eprintln!(" Active session: {sid} (Decision mode, OPEN)"); + eprintln!(); + eprintln!(" Signal Envelope:"); + eprintln!(" message_type: \"Signal\""); + eprintln!(" message_id: \"{mid}\""); + eprintln!(" session_id: \"\" (empty — Signals live on ambient plane)"); + eprintln!(" mode: \"\" (empty — Signals are not mode-scoped)"); + eprintln!(" sender: \"agent://worker\""); + eprintln!(); + eprintln!(" SignalPayload:"); + eprintln!(" signal_type: \"progress\""); + eprintln!(" data: \"analyzing proposal options\""); + eprintln!(" correlation_session_id: \"{sid}\""); + eprintln!(" (correlates with session, but NOT session-scoped)"); + eprintln!(); + eprintln!(" RFC-MACP-0001 §5.1 rules:"); + eprintln!(" - Signals MUST carry empty session_id and empty mode"); + eprintln!(" - Signals MUST NOT mutate session state"); + eprintln!(" - Signals are non-binding (ambient plane)"); + eprintln!(" - Correlation via payload field, NOT via Envelope.session_id"); + eprintln!(); + + let resp = client + .send(with_sender( + "agent://worker", + SendRequest { + envelope: Some(env), + }, + )) + .await + .unwrap() + .into_inner(); + let ack = resp.ack.expect("ack present"); + + let err_code = ack.error.as_ref().map(|e| e.code.as_str()).unwrap_or("(none)"); + eprintln!(" Runtime Response:"); + eprintln!(" ack.ok: {}", ack.ok); + eprintln!(" ack.duplicate: {}", ack.duplicate); + eprintln!(" ack.session_state: {} (no session affected)", ack.session_state); + eprintln!(" ack.error: {err_code}"); + eprintln!(); + + // Verify the session was NOT affected by the Signal + let resp = get_session_as(&mut client, coord, &sid).await.unwrap(); + let meta = resp.metadata.expect("metadata present"); + eprintln!(" Session state AFTER Signal:"); + eprintln!(" state: {} (OPEN — unchanged by Signal)", meta.state); + eprintln!(" mode: {}", meta.mode); + eprintln!(); + eprintln!(" Result: Signal ACCEPTED ✓"); + eprintln!(" Worker sent progress Signal on ambient plane."); + eprintln!(" Session history was NOT modified. Session state unchanged."); + eprintln!("─────────────────────────────────────────────────────────────"); + + assert!(ack.ok, "Signal with empty session_id and mode should be accepted"); + assert_eq!(meta.state, 1, "Session should remain OPEN — Signal must not mutate state"); +} + +#[tokio::test] +async fn signal_with_non_empty_session_rejected() { + // RFC-MACP-0001 §5.1: Signal with non-empty session_id violates envelope shape rules. + let mut client = common::grpc_client().await; + let mid = new_message_id(); + let env = Envelope { + macp_version: "1.0".into(), + mode: String::new(), + message_type: "Signal".into(), + message_id: mid.clone(), + session_id: "some-session-id".into(), + sender: String::new(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: vec![], + }; + + eprintln!("── Signal Test: Invalid Signal (session_id set) ─────────────"); + eprintln!(" Envelope:"); + eprintln!(" message_type: \"Signal\""); + eprintln!(" session_id: \"some-session-id\" ← VIOLATION"); + eprintln!(" mode: \"\""); + eprintln!(); + eprintln!(" RFC rule: Signals MUST carry empty session_id."); + eprintln!(" A Signal with session_id is a structural envelope violation."); + eprintln!(); + + let resp = client + .send(with_sender( + "agent://signaler", + SendRequest { + envelope: Some(env), + }, + )) + .await + .unwrap() + .into_inner(); + let ack = resp.ack.expect("ack present"); + + let err_code = ack.error.as_ref().map(|e| e.code.as_str()).unwrap_or("(none)"); + eprintln!(" Response:"); + eprintln!(" ack.ok: {}", ack.ok); + eprintln!(" ack.error: {err_code}"); + eprintln!(); + eprintln!(" Result: Signal REJECTED ✓ — session_id must be empty for Signals"); + eprintln!("─────────────────────────────────────────────────────────────"); + + assert!(!ack.ok, "Signal with non-empty session_id must be rejected"); +} + +#[tokio::test] +async fn signal_with_non_empty_mode_rejected() { + // RFC-MACP-0001 §5.1: Signal with non-empty mode violates envelope shape rules. + let mut client = common::grpc_client().await; + let mid = new_message_id(); + let env = Envelope { + macp_version: "1.0".into(), + mode: "macp.mode.decision.v1".into(), + message_type: "Signal".into(), + message_id: mid.clone(), + session_id: String::new(), + sender: String::new(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: vec![], + }; + + eprintln!("── Signal Test: Invalid Signal (mode set) ───────────────────"); + eprintln!(" Envelope:"); + eprintln!(" message_type: \"Signal\""); + eprintln!(" session_id: \"\""); + eprintln!(" mode: \"macp.mode.decision.v1\" ← VIOLATION"); + eprintln!(); + eprintln!(" RFC rule: Signals MUST carry empty mode."); + eprintln!(" Signals exist on the ambient plane — they are not scoped to any mode."); + eprintln!(); + + let resp = client + .send(with_sender( + "agent://signaler", + SendRequest { + envelope: Some(env), + }, + )) + .await + .unwrap() + .into_inner(); + let ack = resp.ack.expect("ack present"); + + let err_code = ack.error.as_ref().map(|e| e.code.as_str()).unwrap_or("(none)"); + eprintln!(" Response:"); + eprintln!(" ack.ok: {}", ack.ok); + eprintln!(" ack.error: {err_code}"); + eprintln!(); + eprintln!(" Result: Signal REJECTED ✓ — mode must be empty for Signals"); + eprintln!("─────────────────────────────────────────────────────────────"); + + assert!(!ack.ok, "Signal with non-empty mode must be rejected"); +} + +#[tokio::test] +async fn watch_signals_receives_broadcast_signal() { + // Demonstrates the full Signal flow: + // Agent A subscribes to WatchSignals stream + // Agent B sends a Signal via Send RPC + // Agent A receives the Signal on the stream + let mut watcher = common::grpc_client().await; + let mut sender_client = common::grpc_client().await; + + let sid = new_session_id(); // session to correlate with + + // Start a session so the signal has something to correlate with + send_as( + &mut sender_client, + "agent://coordinator", + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + "agent://coordinator", + session_start_payload("signal demo", &["agent://coordinator", "agent://worker"], 30_000), + ), + ) + .await + .unwrap(); + + // Agent A subscribes to WatchSignals + eprintln!("── Signal Broadcast Test: WatchSignals RPC ──────────────────"); + eprintln!(" Agent A (watcher) subscribes to WatchSignals stream"); + let mut signal_stream = watcher + .watch_signals(WatchSignalsRequest {}) + .await + .unwrap() + .into_inner(); + + // Agent B sends a Signal + let signal_payload = SignalPayload { + signal_type: "progress".into(), + data: b"analyzing fraud patterns".to_vec(), + confidence: 0.75, + correlation_session_id: sid.clone(), + }; + let signal_mid = new_message_id(); + let signal_env = Envelope { + macp_version: "1.0".into(), + mode: String::new(), + message_type: "Signal".into(), + message_id: signal_mid.clone(), + session_id: String::new(), + sender: String::new(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: signal_payload.encode_to_vec(), + }; + + eprintln!(" Agent B (sender) sends Signal via Send RPC:"); + eprintln!(" signal_type: \"progress\""); + eprintln!(" data: \"analyzing fraud patterns\""); + eprintln!(" confidence: 0.75"); + eprintln!(" correlation_session_id: \"{sid}\""); + eprintln!(); + + let ack_resp = sender_client + .send(with_sender( + "agent://worker", + SendRequest { + envelope: Some(signal_env), + }, + )) + .await + .unwrap() + .into_inner(); + let ack = ack_resp.ack.expect("ack present"); + assert!(ack.ok, "Signal should be accepted"); + eprintln!(" Send RPC ack: ok={}", ack.ok); + + // Agent A receives the Signal on the WatchSignals stream + let received = tokio::time::timeout( + std::time::Duration::from_secs(5), + signal_stream.message(), + ) + .await + .expect("should not timeout") + .expect("stream should not error") + .expect("should receive a message"); + + let received_env = received.envelope.expect("envelope present"); + let received_payload = + SignalPayload::decode(&*received_env.payload).expect("valid SignalPayload"); + + eprintln!(); + eprintln!(" Agent A received Signal on WatchSignals stream:"); + eprintln!(" sender: \"{}\"", received_env.sender); + eprintln!(" message_type: \"{}\"", received_env.message_type); + eprintln!(" message_id: \"{}\"", received_env.message_id); + eprintln!(" SignalPayload:"); + eprintln!(" signal_type: \"{}\"", received_payload.signal_type); + eprintln!( + " data: \"{}\"", + String::from_utf8_lossy(&received_payload.data) + ); + eprintln!(" confidence: {}", received_payload.confidence); + eprintln!( + " correlation_session: \"{}\"", + received_payload.correlation_session_id + ); + eprintln!(); + + assert_eq!(received_env.message_id, signal_mid); + assert_eq!(received_env.message_type, "Signal"); + assert_eq!(received_env.sender, "agent://worker"); + assert_eq!(received_payload.signal_type, "progress"); + assert_eq!(received_payload.correlation_session_id, sid); + + eprintln!(" Result: Signal BROADCAST ✓"); + eprintln!(" Agent B sent Signal → Runtime broadcast → Agent A received it"); + eprintln!(" The Signal correlates with session {sid}"); + eprintln!(" but did NOT enter the session's accepted history."); + eprintln!("─────────────────────────────────────────────────────────────"); +} + +// ── Version Binding (RFC-MACP-0003 §3) ───────────────────────────────── + +#[tokio::test] +async fn commitment_with_wrong_mode_version_rejected() { + // RFC: CommitmentPayload version fields MUST match session-bound versions. + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let voter = "agent://voter"; + + // Start session (binds mode_version="1.0.0", configuration_version="config.default") + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("version test", &[coord, voter], 30_000), + ), + ) + .await + .unwrap(); + + // Proposal (so commitment precondition is met) + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + coord, + proposal_payload("p1", "option-A", "test"), + ), + ) + .await + .unwrap(); + + // Commitment with WRONG mode_version (session bound "1.0.0", we send "2.0.0") + let bad_commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "option-A".into(), + authority_scope: "team".into(), + reason: "bad version".into(), + mode_version: "2.0.0".into(), // WRONG — session bound "1.0.0" + policy_version: POLICY_VERSION.into(), + configuration_version: CONFIG_VERSION.into(), + } + .encode_to_vec(); + + let ack = send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Commitment", + &new_message_id(), + &sid, + coord, + bad_commitment, + ), + ) + .await + .unwrap(); + assert!( + !ack.ok, + "Commitment with mismatched mode_version must be rejected" + ); +} + +#[tokio::test] +async fn commitment_with_wrong_config_version_rejected() { + // RFC: configuration_version in Commitment must match session binding. + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let voter = "agent://voter"; + + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("config version test", &[coord, voter], 30_000), + ), + ) + .await + .unwrap(); + + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + coord, + proposal_payload("p1", "option-A", "test"), + ), + ) + .await + .unwrap(); + + let bad_commitment = CommitmentPayload { + commitment_id: "c1".into(), + action: "option-A".into(), + authority_scope: "team".into(), + reason: "bad config".into(), + mode_version: MODE_VERSION.into(), + policy_version: POLICY_VERSION.into(), + configuration_version: "wrong-config-version".into(), // WRONG + } + .encode_to_vec(); + + let ack = send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Commitment", + &new_message_id(), + &sid, + coord, + bad_commitment, + ), + ) + .await + .unwrap(); + assert!( + !ack.ok, + "Commitment with mismatched configuration_version must be rejected" + ); +} + +// ── Deduplication (RFC-MACP-0001 §8.2 & §8.3) ───────────────────────── + +#[tokio::test] +async fn rejected_message_does_not_consume_dedup_slot() { + // RFC §8.3: Rejected Envelopes MUST NOT consume message_id dedup slots. + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let voter = "agent://voter"; + + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("dedup test", &[coord, voter], 30_000), + ), + ) + .await + .unwrap(); + + // Send a message that will be REJECTED (Vote before any Proposal — wrong phase) + let shared_mid = new_message_id(); + let ack = send_as( + &mut client, + voter, + envelope( + MODE_DECISION, + "Vote", + &shared_mid, + &sid, + voter, + vote_payload("p1", "approve", "premature"), + ), + ) + .await + .unwrap(); + assert!(!ack.ok, "Vote before Proposal should be rejected"); + + // Now send a VALID Proposal with the same message_id. + // Per RFC, the rejected message did NOT consume the dedup slot, + // so this should be accepted (not flagged as duplicate). + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + coord, + proposal_payload("p1", "option-A", "test"), + ), + ) + .await + .unwrap(); + + let ack = send_as( + &mut client, + voter, + envelope( + MODE_DECISION, + "Vote", + &shared_mid, + &sid, + voter, + vote_payload("p1", "approve", "now valid"), + ), + ) + .await + .unwrap(); + assert!(ack.ok, "Reused message_id from rejected message should be accepted"); + assert!(!ack.duplicate, "Should NOT be flagged as duplicate"); +} + +#[tokio::test] +async fn duplicate_session_start_same_session_id_rejected() { + // RFC §8.2: Duplicate SessionStart with same session_id but different message_id must be rejected. + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let agent = "agent://test"; + + // First SessionStart succeeds + let ack = send_as( + &mut client, + agent, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + agent, + session_start_payload("first", &[agent], 30_000), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Second SessionStart with same session_id, different message_id — rejected + let ack = send_as( + &mut client, + agent, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), // different message_id + &sid, // same session_id + agent, + session_start_payload("second", &[agent], 30_000), + ), + ) + .await + .unwrap(); + assert!(!ack.ok, "Duplicate SessionStart for same session_id must be rejected"); +} + +// ── CancelSession Authorization (RFC-MACP-0001 §7.3) ─────────────────── + +#[tokio::test] +async fn cancel_from_non_initiator_rejected() { + // RFC: Only the session initiator (SessionStart sender) can cancel. + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let participant = "agent://participant"; + + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("cancel auth test", &[coord, participant], 30_000), + ), + ) + .await + .unwrap(); + + // Participant (not initiator) tries to cancel — should be rejected + let result = cancel_session_as(&mut client, participant, &sid, "unauthorized cancel").await; + // This should either return ok=false or a gRPC error + match result { + Ok(ack) => assert!(!ack.ok, "Non-initiator cancel must be rejected"), + Err(status) => { + assert!( + status.code() == tonic::Code::PermissionDenied, + "Expected PERMISSION_DENIED, got: {:?}", + status.code() + ); + } + } +} + +// ── Discovery / Manifests (RFC-MACP-0005) ─────────────────────────────── + +#[tokio::test] +async fn get_manifest_returns_all_modes_including_extensions() { + // RFC: GetManifest MAY include both standards-track and extension modes. + let mut client = common::grpc_client().await; + let resp = client + .get_manifest(GetManifestRequest { + agent_id: String::new(), + }) + .await + .unwrap() + .into_inner(); + + let manifest = resp.manifest.expect("manifest present"); + assert!(!manifest.agent_id.is_empty(), "agent_id should be set"); + assert!(!manifest.description.is_empty(), "description should be set"); + + // Should include all 5 standard modes + extensions + assert!( + manifest.supported_modes.len() >= 6, + "GetManifest should include standard + extension modes, got {}", + manifest.supported_modes.len() + ); + assert!(manifest.supported_modes.contains(&"macp.mode.decision.v1".to_string())); + assert!(manifest.supported_modes.contains(&"ext.multi_round.v1".to_string())); +} + +#[tokio::test] +async fn initialize_rejects_unsupported_protocol_version() { + // RFC: If no mutually supported version, initialization MUST fail. + let mut client = common::grpc_client().await; + let result = client + .initialize(InitializeRequest { + supported_protocol_versions: vec!["99.0".into()], + client_info: None, + capabilities: None, + }) + .await; + + assert!(result.is_err(), "Initialize with unsupported version must fail"); + let status = result.unwrap_err(); + assert_eq!( + status.code(), + tonic::Code::FailedPrecondition, + "Expected FAILED_PRECONDITION for version mismatch" + ); +} + +// ── TTL Edge Cases ────────────────────────────────────────────────────── + +#[tokio::test] +async fn session_accepts_messages_within_ttl_window() { + // Verify session is alive and accepts messages before TTL expires. + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let voter = "agent://voter"; + + // Start session with generous TTL + send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("ttl test", &[coord, voter], 30_000), + ), + ) + .await + .unwrap(); + + // Send immediately — well within TTL + let ack = send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + coord, + proposal_payload("p1", "quick proposal", "within TTL"), + ), + ) + .await + .unwrap(); + assert!(ack.ok, "Message within TTL window must be accepted"); + assert_eq!(ack.session_state, 1, "Session should still be OPEN"); +} diff --git a/integration_tests/tests/tier1_protocol/test_session_lifecycle.rs b/integration_tests/tests/tier1_protocol/test_session_lifecycle.rs new file mode 100644 index 0000000..a4d71a0 --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_session_lifecycle.rs @@ -0,0 +1,78 @@ +use crate::common; +use macp_integration_tests::helpers::*; + +#[tokio::test] +async fn session_expires_after_ttl() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let agent = "agent://ttl-test"; + let partner = "agent://partner"; + + // Start session with very short TTL (100ms) + let ack = send_as( + &mut client, + agent, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + agent, + session_start_payload("ttl test", &[agent, partner], 100), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Wait for TTL to expire + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + // Try sending — should fail because session expired + let ack = send_as( + &mut client, + agent, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + agent, + proposal_payload("p1", "late", "expired"), + ), + ) + .await + .unwrap(); + assert!(!ack.ok); +} + +#[tokio::test] +async fn get_session_returns_open_state() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let agent = "agent://lifecycle-test"; + let partner = "agent://partner"; + + // Start session + send_as( + &mut client, + agent, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + agent, + session_start_payload("lifecycle test", &[agent, partner], 30_000), + ), + ) + .await + .unwrap(); + + // GetSession should show OPEN + let resp = get_session_as(&mut client, agent, &sid).await.unwrap(); + let meta = resp.metadata.expect("metadata present"); + assert_eq!(meta.state, 1); // OPEN + assert_eq!(meta.mode, MODE_DECISION); + assert_eq!(meta.session_id, sid); +} diff --git a/integration_tests/tests/tier1_protocol/test_stream_session.rs b/integration_tests/tests/tier1_protocol/test_stream_session.rs new file mode 100644 index 0000000..43d69a3 --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_stream_session.rs @@ -0,0 +1,49 @@ +use crate::common; +use macp_integration_tests::helpers::*; + +#[tokio::test] +async fn stream_receives_accepted_envelopes() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let coord = "agent://coordinator"; + let voter = "agent://voter"; + + // Start session via unary Send + let ack = send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + coord, + session_start_payload("stream test", &[coord, voter], 30_000), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Send a proposal to generate an accepted envelope + let ack = send_as( + &mut client, + coord, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + coord, + proposal_payload("p1", "option-A", "stream test proposal"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Verify session is still open and has accepted messages + let resp = get_session_as(&mut client, coord, &sid).await.unwrap(); + let meta = resp.metadata.expect("metadata present"); + assert_eq!(meta.state, 1); // OPEN +} diff --git a/integration_tests/tests/tier1_protocol/test_task_mode.rs b/integration_tests/tests/tier1_protocol/test_task_mode.rs new file mode 100644 index 0000000..3df2672 --- /dev/null +++ b/integration_tests/tests/tier1_protocol/test_task_mode.rs @@ -0,0 +1,113 @@ +use crate::common; +use macp_integration_tests::helpers::*; + +#[tokio::test] +async fn task_happy_path() { + let mut client = common::grpc_client().await; + let sid = new_session_id(); + let planner = "agent://planner"; + let worker = "agent://worker"; + + // SessionStart + let ack = send_as( + &mut client, + planner, + envelope( + MODE_TASK, + "SessionStart", + &new_message_id(), + &sid, + planner, + session_start_payload("delegate analysis", &[planner, worker], 30_000), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // TaskRequest from planner + let ack = send_as( + &mut client, + planner, + envelope( + MODE_TASK, + "TaskRequest", + &new_message_id(), + &sid, + planner, + task_request_payload("t1", "Analyze data", "Run the analysis pipeline", worker), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // TaskAccept from worker + let ack = send_as( + &mut client, + worker, + envelope( + MODE_TASK, + "TaskAccept", + &new_message_id(), + &sid, + worker, + task_accept_payload("t1", worker), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // TaskUpdate from worker + let ack = send_as( + &mut client, + worker, + envelope( + MODE_TASK, + "TaskUpdate", + &new_message_id(), + &sid, + worker, + task_update_payload("t1", "in_progress", 0.5, "halfway done"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // TaskComplete from worker + let ack = send_as( + &mut client, + worker, + envelope( + MODE_TASK, + "TaskComplete", + &new_message_id(), + &sid, + worker, + task_complete_payload("t1", worker, "analysis complete"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + + // Commitment from planner + let ack = send_as( + &mut client, + planner, + envelope( + MODE_TASK, + "Commitment", + &new_message_id(), + &sid, + planner, + commitment_payload("c1", "task-completed", "planner", "worker delivered"), + ), + ) + .await + .unwrap(); + assert!(ack.ok); + assert_eq!(ack.session_state, 2); +} diff --git a/integration_tests/tests/tier2.rs b/integration_tests/tests/tier2.rs new file mode 100644 index 0000000..06775ff --- /dev/null +++ b/integration_tests/tests/tier2.rs @@ -0,0 +1,2 @@ +mod common; +mod tier2_agents; diff --git a/integration_tests/tests/tier2_agents/mod.rs b/integration_tests/tests/tier2_agents/mod.rs new file mode 100644 index 0000000..49fe6c1 --- /dev/null +++ b/integration_tests/tests/tier2_agents/mod.rs @@ -0,0 +1,5 @@ +mod test_decision_agent; +mod test_task_delegation; +mod test_proposal_negotiation; +mod test_handoff_agent; +mod test_multi_agent; diff --git a/integration_tests/tests/tier2_agents/test_decision_agent.rs b/integration_tests/tests/tier2_agents/test_decision_agent.rs new file mode 100644 index 0000000..3872dff --- /dev/null +++ b/integration_tests/tests/tier2_agents/test_decision_agent.rs @@ -0,0 +1,82 @@ +use crate::common; +use macp_integration_tests::helpers::*; +use macp_integration_tests::macp_tools::{self, decision::*, commit::*, query::*, session_start::*}; +use rig::tool::ToolSet; + +/// Two Rig tool-equipped agents drive a full decision lifecycle. +/// The tools are invoked directly via ToolSet::call (simulating what an LLM agent would do). +#[tokio::test] +async fn rig_tools_drive_decision_lifecycle() { + let ep = common::endpoint().await; + let coord_client = macp_tools::shared_client(ep).await; + let voter_client = macp_tools::shared_client(ep).await; + let sid = new_session_id(); + + // Build coordinator's toolset + let mut coord_tools = ToolSet::default(); + coord_tools.add_tool(StartSessionTool { client: coord_client.clone(), agent_id: "agent://coord".into() }); + coord_tools.add_tool(ProposeTool { client: coord_client.clone(), agent_id: "agent://coord".into() }); + coord_tools.add_tool(CommitTool { client: coord_client.clone(), agent_id: "agent://coord".into(), mode: MODE_DECISION.into() }); + coord_tools.add_tool(GetSessionTool { client: coord_client.clone(), agent_id: "agent://coord".into() }); + + // Build voter's toolset + let mut voter_tools = ToolSet::default(); + voter_tools.add_tool(VoteTool { client: voter_client.clone(), agent_id: "agent://voter".into() }); + + // Verify tool definitions are valid + let defs = coord_tools.get_tool_definitions().await.unwrap(); + assert_eq!(defs.len(), 4); + assert!(defs.iter().any(|d| d.name == "macp_start_session")); + assert!(defs.iter().any(|d| d.name == "macp_propose")); + assert!(defs.iter().any(|d| d.name == "macp_commit")); + assert!(defs.iter().any(|d| d.name == "macp_get_session")); + + // Step 1: Coordinator starts session (simulating LLM tool call) + let result = coord_tools.call("macp_start_session", serde_json::json!({ + "mode": MODE_DECISION, + "session_id": &sid, + "intent": "choose deployment strategy", + "participants": ["agent://coord", "agent://voter"], + "ttl_ms": 30000 + }).to_string()).await.unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["ok"], true); + + // Step 2: Coordinator proposes + let result = coord_tools.call("macp_propose", serde_json::json!({ + "session_id": &sid, + "proposal_id": "p1", + "option": "deploy-v2", + "rationale": "better performance and reliability" + }).to_string()).await.unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["ok"], true); + + // Step 3: Voter votes + let result = voter_tools.call("macp_vote", serde_json::json!({ + "session_id": &sid, + "proposal_id": "p1", + "vote": "approve", + "reason": "looks good to me" + }).to_string()).await.unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["ok"], true); + + // Step 4: Coordinator commits + let result = coord_tools.call("macp_commit", serde_json::json!({ + "session_id": &sid, + "action": "deploy-v2", + "authority_scope": "team", + "reason": "voter approved" + }).to_string()).await.unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["ok"], true); + assert_eq!(result["session_state"], 2); // RESOLVED + + // Step 5: Coordinator checks session state + let result = coord_tools.call("macp_get_session", serde_json::json!({ + "session_id": &sid + }).to_string()).await.unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["state"], 2); // RESOLVED +} diff --git a/integration_tests/tests/tier2_agents/test_handoff_agent.rs b/integration_tests/tests/tier2_agents/test_handoff_agent.rs new file mode 100644 index 0000000..ed44504 --- /dev/null +++ b/integration_tests/tests/tier2_agents/test_handoff_agent.rs @@ -0,0 +1,52 @@ +use crate::common; +use macp_integration_tests::helpers::*; +use macp_integration_tests::macp_tools::{self, commit::*, handoff::*, session_start::*}; +use rig::tool::ToolSet; + +/// Source agent hands off to target agent via Rig tools. +#[tokio::test] +async fn rig_tools_drive_handoff() { + let ep = common::endpoint().await; + let source_client = macp_tools::shared_client(ep).await; + let target_client = macp_tools::shared_client(ep).await; + let sid = new_session_id(); + + let mut source_tools = ToolSet::default(); + source_tools.add_tool(StartSessionTool { client: source_client.clone(), agent_id: "agent://source".into() }); + source_tools.add_tool(HandoffOfferTool { client: source_client.clone(), agent_id: "agent://source".into() }); + source_tools.add_tool(CommitTool { client: source_client.clone(), agent_id: "agent://source".into(), mode: MODE_HANDOFF.into() }); + + let mut target_tools = ToolSet::default(); + target_tools.add_tool(HandoffAcceptTool { client: target_client.clone(), agent_id: "agent://target".into() }); + + // Source starts session + let r = source_tools.call("macp_start_session", serde_json::json!({ + "mode": MODE_HANDOFF, "session_id": &sid, + "intent": "escalate customer issue", "participants": ["agent://source", "agent://target"], + "ttl_ms": 30000 + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Source offers handoff + let r = source_tools.call("macp_handoff_offer", serde_json::json!({ + "session_id": &sid, "handoff_id": "h1", + "target": "agent://target", "scope": "customer-support", + "reason": "needs specialist" + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Target accepts + let r = target_tools.call("macp_handoff_accept", serde_json::json!({ + "session_id": &sid, "handoff_id": "h1", "reason": "ready to assist" + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Source commits + let r = source_tools.call("macp_commit", serde_json::json!({ + "session_id": &sid, "action": "handoff-complete", + "authority_scope": "support", "reason": "transferred" + }).to_string()).await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&r).unwrap(); + assert!(v["ok"].as_bool().unwrap()); + assert_eq!(v["session_state"], 2); +} diff --git a/integration_tests/tests/tier2_agents/test_multi_agent.rs b/integration_tests/tests/tier2_agents/test_multi_agent.rs new file mode 100644 index 0000000..02d616f --- /dev/null +++ b/integration_tests/tests/tier2_agents/test_multi_agent.rs @@ -0,0 +1,63 @@ +use crate::common; +use macp_integration_tests::helpers::*; +use macp_integration_tests::macp_tools::{self, commit::*, quorum::*, session_start::*}; +use rig::tool::ToolSet; + +/// Three agents coordinate a quorum approval via Rig tools. +#[tokio::test] +async fn rig_tools_drive_quorum_approval() { + let ep = common::endpoint().await; + let req_client = macp_tools::shared_client(ep).await; + let a1_client = macp_tools::shared_client(ep).await; + let a2_client = macp_tools::shared_client(ep).await; + let sid = new_session_id(); + + let mut req_tools = ToolSet::default(); + req_tools.add_tool(StartSessionTool { client: req_client.clone(), agent_id: "agent://requester".into() }); + req_tools.add_tool(ApprovalRequestTool { client: req_client.clone(), agent_id: "agent://requester".into() }); + req_tools.add_tool(CommitTool { client: req_client.clone(), agent_id: "agent://requester".into(), mode: MODE_QUORUM.into() }); + + let mut a1_tools = ToolSet::default(); + a1_tools.add_tool(ApproveTool { client: a1_client.clone(), agent_id: "agent://approver1".into() }); + + let mut a2_tools = ToolSet::default(); + a2_tools.add_tool(ApproveTool { client: a2_client.clone(), agent_id: "agent://approver2".into() }); + + // Requester starts session + let r = req_tools.call("macp_start_session", serde_json::json!({ + "mode": MODE_QUORUM, "session_id": &sid, + "intent": "approve production deploy", + "participants": ["agent://requester", "agent://approver1", "agent://approver2"], + "ttl_ms": 30000 + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Requester submits approval request (need 2 approvals) + let r = req_tools.call("macp_approval_request", serde_json::json!({ + "session_id": &sid, "request_id": "r1", + "action": "deploy-prod", "summary": "Deploy v2 to production", + "required_approvals": 2 + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Approver 1 approves + let r = a1_tools.call("macp_approve", serde_json::json!({ + "session_id": &sid, "request_id": "r1", "reason": "LGTM" + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Approver 2 approves (quorum met) + let r = a2_tools.call("macp_approve", serde_json::json!({ + "session_id": &sid, "request_id": "r1", "reason": "Approved" + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Requester commits + let r = req_tools.call("macp_commit", serde_json::json!({ + "session_id": &sid, "action": "deploy-prod", + "authority_scope": "ops-team", "reason": "quorum reached" + }).to_string()).await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&r).unwrap(); + assert!(v["ok"].as_bool().unwrap()); + assert_eq!(v["session_state"], 2); +} diff --git a/integration_tests/tests/tier2_agents/test_proposal_negotiation.rs b/integration_tests/tests/tier2_agents/test_proposal_negotiation.rs new file mode 100644 index 0000000..14c4502 --- /dev/null +++ b/integration_tests/tests/tier2_agents/test_proposal_negotiation.rs @@ -0,0 +1,57 @@ +use crate::common; +use macp_integration_tests::helpers::*; +use macp_integration_tests::macp_tools::{self, commit::*, proposal::*, session_start::*}; +use rig::tool::ToolSet; + +/// Buyer and seller agents negotiate via proposal tools. +#[tokio::test] +async fn rig_tools_drive_proposal_negotiation() { + let ep = common::endpoint().await; + let buyer_client = macp_tools::shared_client(ep).await; + let seller_client = macp_tools::shared_client(ep).await; + let sid = new_session_id(); + + let mut buyer_tools = ToolSet::default(); + buyer_tools.add_tool(StartSessionTool { client: buyer_client.clone(), agent_id: "agent://buyer".into() }); + buyer_tools.add_tool(AcceptProposalTool { client: buyer_client.clone(), agent_id: "agent://buyer".into() }); + buyer_tools.add_tool(CommitTool { client: buyer_client.clone(), agent_id: "agent://buyer".into(), mode: MODE_PROPOSAL.into() }); + + let mut seller_tools = ToolSet::default(); + seller_tools.add_tool(SubmitProposalTool { client: seller_client.clone(), agent_id: "agent://seller".into() }); + seller_tools.add_tool(AcceptProposalTool { client: seller_client.clone(), agent_id: "agent://seller".into() }); + + // Buyer starts session + let r = buyer_tools.call("macp_start_session", serde_json::json!({ + "mode": MODE_PROPOSAL, "session_id": &sid, + "intent": "negotiate price", "participants": ["agent://buyer", "agent://seller"], + "ttl_ms": 30000 + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Seller proposes + let r = seller_tools.call("macp_submit_proposal", serde_json::json!({ + "session_id": &sid, "proposal_id": "prop-1", + "title": "Initial offer", "summary": "$100 per unit" + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Both accept + let r = seller_tools.call("macp_accept_proposal", serde_json::json!({ + "session_id": &sid, "proposal_id": "prop-1", "reason": "fair price" + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + let r = buyer_tools.call("macp_accept_proposal", serde_json::json!({ + "session_id": &sid, "proposal_id": "prop-1", "reason": "agreed" + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Buyer commits + let r = buyer_tools.call("macp_commit", serde_json::json!({ + "session_id": &sid, "action": "accept-offer", + "authority_scope": "negotiation", "reason": "both accepted" + }).to_string()).await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&r).unwrap(); + assert!(v["ok"].as_bool().unwrap()); + assert_eq!(v["session_state"], 2); +} diff --git a/integration_tests/tests/tier2_agents/test_task_delegation.rs b/integration_tests/tests/tier2_agents/test_task_delegation.rs new file mode 100644 index 0000000..0de9f9c --- /dev/null +++ b/integration_tests/tests/tier2_agents/test_task_delegation.rs @@ -0,0 +1,58 @@ +use crate::common; +use macp_integration_tests::helpers::*; +use macp_integration_tests::macp_tools::{self, commit::*, session_start::*, task::*}; +use rig::tool::ToolSet; + +/// Planner agent delegates a task to worker agent, worker completes it. +#[tokio::test] +async fn rig_tools_drive_task_delegation() { + let ep = common::endpoint().await; + let planner_client = macp_tools::shared_client(ep).await; + let worker_client = macp_tools::shared_client(ep).await; + let sid = new_session_id(); + + let mut planner_tools = ToolSet::default(); + planner_tools.add_tool(StartSessionTool { client: planner_client.clone(), agent_id: "agent://planner".into() }); + planner_tools.add_tool(TaskRequestTool { client: planner_client.clone(), agent_id: "agent://planner".into() }); + planner_tools.add_tool(CommitTool { client: planner_client.clone(), agent_id: "agent://planner".into(), mode: MODE_TASK.into() }); + + let mut worker_tools = ToolSet::default(); + worker_tools.add_tool(TaskAcceptTool { client: worker_client.clone(), agent_id: "agent://worker".into() }); + worker_tools.add_tool(TaskCompleteTool { client: worker_client.clone(), agent_id: "agent://worker".into() }); + + // Planner starts session + let r = planner_tools.call("macp_start_session", serde_json::json!({ + "mode": MODE_TASK, "session_id": &sid, + "intent": "analyze data", "participants": ["agent://planner", "agent://worker"], + "ttl_ms": 30000 + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Planner creates task + let r = planner_tools.call("macp_task_request", serde_json::json!({ + "session_id": &sid, "task_id": "t1", "title": "Data analysis", + "instructions": "Analyze Q4 metrics", "assignee": "agent://worker" + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Worker accepts + let r = worker_tools.call("macp_task_accept", serde_json::json!({ + "session_id": &sid, "task_id": "t1" + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Worker completes + let r = worker_tools.call("macp_task_complete", serde_json::json!({ + "session_id": &sid, "task_id": "t1", "summary": "Q4 metrics show 20% growth" + }).to_string()).await.unwrap(); + assert!(serde_json::from_str::(&r).unwrap()["ok"].as_bool().unwrap()); + + // Planner commits + let r = planner_tools.call("macp_commit", serde_json::json!({ + "session_id": &sid, "action": "task-completed", + "authority_scope": "planner", "reason": "worker delivered results" + }).to_string()).await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&r).unwrap(); + assert!(v["ok"].as_bool().unwrap()); + assert_eq!(v["session_state"], 2); +} diff --git a/integration_tests/tests/tier3.rs b/integration_tests/tests/tier3.rs new file mode 100644 index 0000000..729e2c2 --- /dev/null +++ b/integration_tests/tests/tier3.rs @@ -0,0 +1,2 @@ +mod common; +mod tier3_e2e; diff --git a/integration_tests/tests/tier3_e2e/mod.rs b/integration_tests/tests/tier3_e2e/mod.rs new file mode 100644 index 0000000..8a8806b --- /dev/null +++ b/integration_tests/tests/tier3_e2e/mod.rs @@ -0,0 +1,3 @@ +mod test_e2e_decision; +mod test_e2e_decision_with_signals; +mod test_e2e_task; diff --git a/integration_tests/tests/tier3_e2e/test_e2e_decision.rs b/integration_tests/tests/tier3_e2e/test_e2e_decision.rs new file mode 100644 index 0000000..4500726 --- /dev/null +++ b/integration_tests/tests/tier3_e2e/test_e2e_decision.rs @@ -0,0 +1,249 @@ +use crate::common; +use macp_integration_tests::helpers::*; +use macp_integration_tests::macp_tools::{self, decision::*}; +use rig::completion::Prompt; +use rig::providers::openai; +use rig::prelude::*; + +/// Realistic multi-agent decision coordination following the MACP spec. +/// +/// Architecture (per RFC-MACP-0001 and RFC-MACP-0007): +/// - Orchestrator: plain code (deterministic session lifecycle) +/// - Specialists: LLM-powered (reasoning happens OUTSIDE the session, +/// only the resulting Evaluation Envelope enters the session) +/// - Agents run in PARALLEL (runtime serializes by acceptance order) +/// +/// Scenario: Suspicious $4,800 wire transfer. Orchestrator proposes step-up +/// verification. Three specialists (fraud, growth, compliance) evaluate +/// concurrently. Orchestrator commits. +#[tokio::test] +#[ignore] +async fn real_llm_agents_coordinate_decision() { + if std::env::var("OPENAI_API_KEY").is_err() { + eprintln!("SKIPPED: OPENAI_API_KEY not set"); + return; + } + + let ep = common::endpoint().await; + let sid = new_session_id(); + let orchestrator_id = "agent://checkout.orchestrator"; + let fraud_id = "agent://fraud"; + let growth_id = "agent://growth"; + let compliance_id = "agent://compliance"; + + eprintln!("═══════════════════════════════════════════════════════════════"); + eprintln!(" MACP Decision Mode E2E Test — Wire Transfer Review"); + eprintln!(" Session: {sid}"); + eprintln!(" Orchestrator: plain code (no LLM)"); + eprintln!(" Specialists: 3x GPT-4o-mini (LLM reasoning OUTSIDE session)"); + eprintln!(" Agents run: IN PARALLEL (runtime serializes on arrival)"); + eprintln!("═══════════════════════════════════════════════════════════════"); + + // ── Orchestrator gRPC client (no LLM needed) ──────────────────────── + let orch_client = macp_tools::shared_client(ep).await; + + // ═══════════════════════════════════════════════════════════════════ + // STEP 1: Orchestrator starts session + proposes (deterministic) + // ═══════════════════════════════════════════════════════════════════ + eprintln!("\n── Step 1: Orchestrator starts session and proposes ──────────"); + { + let mut client = orch_client.lock().await; + + let ack = send_as( + &mut client, + orchestrator_id, + envelope( + MODE_DECISION, + "SessionStart", + &new_message_id(), + &sid, + orchestrator_id, + session_start_payload( + "Review suspicious wire transfer requiring step-up verification", + &[fraud_id, growth_id, compliance_id], + 60_000, + ), + ), + ) + .await + .expect("SessionStart should succeed"); + assert!(ack.ok); + eprintln!(" SessionStart accepted. State: OPEN"); + + let ack = send_as( + &mut client, + orchestrator_id, + envelope( + MODE_DECISION, + "Proposal", + &new_message_id(), + &sid, + orchestrator_id, + proposal_payload( + "transfer-review", + "Require step-up verification before processing the $4,800 wire transfer", + "Transaction amount and pattern triggered fraud detection threshold", + ), + ), + ) + .await + .expect("Proposal should succeed"); + assert!(ack.ok); + eprintln!(" Proposal accepted. Phase → Evaluation"); + } + + // ═══════════════════════════════════════════════════════════════════ + // STEP 2: Specialist agents evaluate IN PARALLEL + // + // Per RFC-MACP-0001 §8.1: agents operate independently. + // LLM reasoning happens OUTSIDE the session (ambient plane). + // Only the resulting Evaluation Envelope enters the session. + // Runtime serializes by acceptance order. + // ═══════════════════════════════════════════════════════════════════ + eprintln!("\n── Step 2: 3 specialists evaluate IN PARALLEL ──────────────"); + eprintln!(" LLM reasoning happens outside the session (ambient plane)"); + eprintln!(" Runtime serializes Evaluations by acceptance order\n"); + + let openai_client = openai::Client::from_env(); + + let scenario = "A customer is attempting a $4,800 wire transfer that triggered fraud alerts. \ + The orchestrator proposes requiring step-up verification before processing."; + + // Build all 3 agents + let fraud_client = macp_tools::shared_client(ep).await; + let fraud_agent = openai_client + .agent(openai::GPT_4O_MINI) + .preamble( + "You are a fraud detection specialist at a bank. Assess transaction proposals \ + from a fraud-risk perspective. Call macp_evaluate with your assessment.\n\ + For recommendation use: APPROVE, REVIEW, BLOCK, or REJECT.\n\ + For confidence use 0.0 to 1.0. Use EXACT session_id and proposal_id from prompt.", + ) + .tool(EvaluateTool { client: fraud_client, agent_id: fraud_id.into() }) + .build(); + + let growth_client = macp_tools::shared_client(ep).await; + let growth_agent = openai_client + .agent(openai::GPT_4O_MINI) + .preamble( + "You are a customer experience and growth specialist at a bank. Assess transaction \ + proposals from a UX and business growth perspective. Call macp_evaluate.\n\ + For recommendation use: APPROVE, REVIEW, BLOCK, or REJECT.\n\ + For confidence use 0.0 to 1.0. Use EXACT session_id and proposal_id from prompt.", + ) + .tool(EvaluateTool { client: growth_client, agent_id: growth_id.into() }) + .build(); + + let compliance_client = macp_tools::shared_client(ep).await; + let compliance_agent = openai_client + .agent(openai::GPT_4O_MINI) + .preamble( + "You are a regulatory compliance specialist at a bank. Assess transaction proposals \ + from a regulatory and legal perspective. Call macp_evaluate.\n\ + For recommendation use: APPROVE, REVIEW, BLOCK, or REJECT.\n\ + For confidence use 0.0 to 1.0. Use EXACT session_id and proposal_id from prompt.", + ) + .tool(EvaluateTool { client: compliance_client, agent_id: compliance_id.into() }) + .build(); + + let eval_prompt = |domain: &str| { + format!( + "{scenario}\n\nEvaluate from your {domain} perspective. Call macp_evaluate with:\n\ + - session_id: {sid}\n\ + - proposal_id: \"transfer-review\"\n\ + - recommendation, confidence, reason: use your professional judgment" + ) + }; + + // Launch all 3 evaluations concurrently with tokio::join! + let start = std::time::Instant::now(); + eprintln!(" Launching 3 LLM agents concurrently..."); + + let (fraud_result, growth_result, compliance_result) = tokio::join!( + tokio::time::timeout( + std::time::Duration::from_secs(30), + fraud_agent.prompt(&eval_prompt("fraud-risk")), + ), + tokio::time::timeout( + std::time::Duration::from_secs(30), + growth_agent.prompt(&eval_prompt("customer-experience and growth")), + ), + tokio::time::timeout( + std::time::Duration::from_secs(30), + compliance_agent.prompt(&eval_prompt("regulatory compliance")), + ), + ); + + let parallel_duration = start.elapsed(); + eprintln!(" All 3 agents completed in {:.1}s (parallel)\n", parallel_duration.as_secs_f64()); + + // Log results + match &fraud_result { + Ok(Ok(text)) => eprintln!(" [Fraud] {text}\n"), + Ok(Err(e)) => panic!("Fraud agent failed: {e}"), + Err(_) => panic!("Fraud agent timed out"), + } + match &growth_result { + Ok(Ok(text)) => eprintln!(" [Growth] {text}\n"), + Ok(Err(e)) => panic!("Growth agent failed: {e}"), + Err(_) => panic!("Growth agent timed out"), + } + match &compliance_result { + Ok(Ok(text)) => eprintln!(" [Compliance] {text}\n"), + Ok(Err(e)) => panic!("Compliance agent failed: {e}"), + Err(_) => panic!("Compliance agent timed out"), + } + + // ═══════════════════════════════════════════════════════════════════ + // STEP 3: Orchestrator commits (deterministic) + // ═══════════════════════════════════════════════════════════════════ + eprintln!("── Step 3: Orchestrator commits (deterministic) ─────────────"); + { + let mut client = orch_client.lock().await; + let ack = send_as( + &mut client, + orchestrator_id, + envelope( + MODE_DECISION, + "Commitment", + &new_message_id(), + &sid, + orchestrator_id, + commitment_payload( + "cmt-001", + "transfer.step-up-verification", + "checkout-payments", + "Specialist agents evaluated — proceeding with step-up verification", + ), + ), + ) + .await + .expect("Commitment should succeed"); + assert!(ack.ok); + assert_eq!(ack.session_state, 2); + eprintln!(" Committed. Session → RESOLVED"); + } + + // ═══════════════════════════════════════════════════════════════════ + // STEP 4: Verify + // ═══════════════════════════════════════════════════════════════════ + eprintln!("\n── Step 4: Verify ──────────────────────────────────────────"); + { + let mut client = orch_client.lock().await; + let resp = get_session_as(&mut client, orchestrator_id, &sid) + .await + .expect("GetSession should succeed"); + let meta = resp.metadata.expect("metadata present"); + assert_eq!(meta.state, 2); + eprintln!(" Session state: {} (RESOLVED)", meta.state); + } + + eprintln!("\n═══════════════════════════════════════════════════════════════"); + eprintln!(" PASSED"); + eprintln!(" Orchestrator (code) proposed"); + eprintln!(" → 3 specialists (LLM) evaluated IN PARALLEL ({:.1}s)", parallel_duration.as_secs_f64()); + eprintln!(" → Orchestrator (code) committed"); + eprintln!(" LLM reasoning happened OUTSIDE session (ambient plane)"); + eprintln!(" Runtime serialized Evaluations by acceptance order"); + eprintln!("═══════════════════════════════════════════════════════════════"); +} diff --git a/integration_tests/tests/tier3_e2e/test_e2e_decision_with_signals.rs b/integration_tests/tests/tier3_e2e/test_e2e_decision_with_signals.rs new file mode 100644 index 0000000..90daa83 --- /dev/null +++ b/integration_tests/tests/tier3_e2e/test_e2e_decision_with_signals.rs @@ -0,0 +1,313 @@ +use crate::common; +use macp_integration_tests::helpers::*; +use macp_integration_tests::macp_tools::{self, decision::*}; +use macp_runtime::pb::{ + Envelope, SendRequest, SignalPayload, WatchSignalsRequest, +}; +use prost::Message; +use rig::completion::Prompt; +use rig::providers::openai; +use rig::prelude::*; +use tonic::Request; + +fn with_sender(sender: &str, inner: T) -> Request { + let mut request = Request::new(inner); + request.metadata_mut().insert( + "x-macp-agent-id", + sender.parse().expect("valid sender header"), + ); + request +} + +/// Send a Signal envelope through the runtime. +async fn send_signal( + client: &mut macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient< + tonic::transport::Channel, + >, + sender_id: &str, + signal_type: &str, + data: &str, + confidence: f64, + correlation_session_id: &str, +) { + let payload = SignalPayload { + signal_type: signal_type.into(), + data: data.as_bytes().to_vec(), + confidence, + correlation_session_id: correlation_session_id.into(), + }; + let env = Envelope { + macp_version: "1.0".into(), + mode: String::new(), + message_type: "Signal".into(), + message_id: new_message_id(), + session_id: String::new(), + sender: String::new(), + timestamp_unix_ms: chrono::Utc::now().timestamp_millis(), + payload: payload.encode_to_vec(), + }; + let resp = client + .send(with_sender(sender_id, SendRequest { envelope: Some(env) })) + .await + .unwrap() + .into_inner(); + assert!(resp.ack.unwrap().ok, "Signal should be accepted"); +} + +/// Full E2E showcasing both Coordination Plane (session) and Ambient Plane (signals) +/// running simultaneously. +/// +/// Scenario: Wire transfer review with 3 specialist LLM agents. +/// Each specialist sends progress Signals BEFORE submitting their Evaluation. +/// The orchestrator watches the signal stream to observe real-time progress. +/// +/// Flow: +/// Orchestrator subscribes to WatchSignals +/// Orchestrator starts session + proposes (code, no LLM) +/// Each specialist: +/// 1. Sends a "progress" Signal: "starting analysis" (ambient plane) +/// 2. LLM reasons about the proposal (outside session) +/// 3. Rig tool sends Evaluation into session (coordination plane) +/// 4. Sends a "completed" Signal (ambient plane) +/// Orchestrator collects signals + commits (code, no LLM) +#[tokio::test] +#[ignore] +async fn decision_with_signals_full_flow() { + if std::env::var("OPENAI_API_KEY").is_err() { + eprintln!("SKIPPED: OPENAI_API_KEY not set"); + return; + } + + let ep = common::endpoint().await; + let sid = new_session_id(); + let orch_id = "agent://checkout.orchestrator"; + let fraud_id = "agent://fraud"; + let growth_id = "agent://growth"; + let compliance_id = "agent://compliance"; + + eprintln!("╔══════════════════════════════════════════════════════════════╗"); + eprintln!("║ MACP E2E: Decision Mode + Signals (Ambient + Coordination) ║"); + eprintln!("║ Session: {sid} ║"); + eprintln!("╚══════════════════════════════════════════════════════════════╝"); + eprintln!(); + + let orch_client = macp_tools::shared_client(ep).await; + + // ═══════════════════════════════════════════════════════════════════ + // Orchestrator subscribes to WatchSignals BEFORE session starts + // ═══════════════════════════════════════════════════════════════════ + eprintln!("── Orchestrator subscribes to WatchSignals stream ───────────"); + let mut signal_watcher = macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient::connect(ep.to_string()) + .await + .unwrap(); + let mut signal_stream = signal_watcher + .watch_signals(WatchSignalsRequest {}) + .await + .unwrap() + .into_inner(); + eprintln!(" Stream opened. Orchestrator will see all ambient Signals.\n"); + + // ═══════════════════════════════════════════════════════════════════ + // STEP 1: Orchestrator starts session + proposes (code, no LLM) + // ═══════════════════════════════════════════════════════════════════ + eprintln!("── STEP 1: Orchestrator starts session and proposes ─────────"); + eprintln!(" (Coordination Plane — these enter session history)"); + { + let mut client = orch_client.lock().await; + let ack = send_as(&mut client, orch_id, envelope( + MODE_DECISION, "SessionStart", &new_message_id(), &sid, orch_id, + session_start_payload( + "Review suspicious $4,800 wire transfer", + &[fraud_id, growth_id, compliance_id], + 60_000, + ), + )).await.unwrap(); + assert!(ack.ok); + eprintln!(" → [Session History #1] SessionStart from orchestrator"); + + let ack = send_as(&mut client, orch_id, envelope( + MODE_DECISION, "Proposal", &new_message_id(), &sid, orch_id, + proposal_payload( + "transfer-review", + "Require step-up verification for $4,800 wire transfer", + "Fraud detection threshold triggered", + ), + )).await.unwrap(); + assert!(ack.ok); + eprintln!(" → [Session History #2] Proposal from orchestrator"); + } + eprintln!(); + + // ═══════════════════════════════════════════════════════════════════ + // STEP 2: Specialists send signals + evaluate IN PARALLEL + // ═══════════════════════════════════════════════════════════════════ + eprintln!("── STEP 2: Specialists analyze (signals + evaluations) ──────"); + eprintln!(" Each agent: Signal(starting) → LLM reasons → Evaluation → Signal(done)\n"); + + let openai_client = openai::Client::from_env(); + let scenario = "A $4,800 wire transfer triggered fraud alerts. Proposal: require step-up verification."; + + // Build specialist agents + let fraud_grpc = macp_tools::shared_client(ep).await; + let fraud_agent = openai_client + .agent(openai::GPT_4O_MINI) + .preamble("You are a fraud detection specialist. Call macp_evaluate with your assessment. Use EXACT session_id and proposal_id from prompt. For recommendation: APPROVE, REVIEW, BLOCK, or REJECT. Confidence: 0.0-1.0.") + .tool(EvaluateTool { client: fraud_grpc.clone(), agent_id: fraud_id.into() }) + .build(); + + let growth_grpc = macp_tools::shared_client(ep).await; + let growth_agent = openai_client + .agent(openai::GPT_4O_MINI) + .preamble("You are a growth/UX specialist. Call macp_evaluate with your assessment. Use EXACT session_id and proposal_id from prompt. For recommendation: APPROVE, REVIEW, BLOCK, or REJECT. Confidence: 0.0-1.0.") + .tool(EvaluateTool { client: growth_grpc.clone(), agent_id: growth_id.into() }) + .build(); + + let compliance_grpc = macp_tools::shared_client(ep).await; + let compliance_agent = openai_client + .agent(openai::GPT_4O_MINI) + .preamble("You are a compliance specialist. Call macp_evaluate with your assessment. Use EXACT session_id and proposal_id from prompt. For recommendation: APPROVE, REVIEW, BLOCK, or REJECT. Confidence: 0.0-1.0.") + .tool(EvaluateTool { client: compliance_grpc.clone(), agent_id: compliance_id.into() }) + .build(); + + let eval_prompt = |domain: &str| { + format!( + "{scenario}\n\nEvaluate from your {domain} perspective. Call macp_evaluate with:\n\ + - session_id: {sid}\n\ + - proposal_id: \"transfer-review\"\n\ + - recommendation, confidence, reason: use your judgment" + ) + }; + + // Each specialist: Signal(starting) → LLM evaluate → Signal(done) + // All 3 run in parallel + let sid_clone = sid.clone(); + let ep_str = ep.to_string(); + + let start = std::time::Instant::now(); + + let (fraud_res, growth_res, compliance_res) = tokio::join!( + // Fraud agent flow + async { + let mut sig_client = macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient::connect(ep_str.clone()).await.unwrap(); + send_signal(&mut sig_client, fraud_id, "progress", "starting fraud risk analysis", 0.0, &sid_clone).await; + let result = fraud_agent.prompt(&eval_prompt("fraud-risk")).await; + send_signal(&mut sig_client, fraud_id, "completed", "fraud evaluation submitted", 1.0, &sid_clone).await; + result + }, + // Growth agent flow + async { + let mut sig_client = macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient::connect(ep_str.clone()).await.unwrap(); + send_signal(&mut sig_client, growth_id, "progress", "starting customer impact analysis", 0.0, &sid_clone).await; + let result = growth_agent.prompt(&eval_prompt("customer-experience")).await; + send_signal(&mut sig_client, growth_id, "completed", "growth evaluation submitted", 1.0, &sid_clone).await; + result + }, + // Compliance agent flow + async { + let mut sig_client = macp_runtime::pb::macp_runtime_service_client::MacpRuntimeServiceClient::connect(ep_str.clone()).await.unwrap(); + send_signal(&mut sig_client, compliance_id, "progress", "starting regulatory review", 0.0, &sid_clone).await; + let result = compliance_agent.prompt(&eval_prompt("regulatory-compliance")).await; + send_signal(&mut sig_client, compliance_id, "completed", "compliance evaluation submitted", 1.0, &sid_clone).await; + result + }, + ); + + let parallel_duration = start.elapsed(); + + // Log LLM results + match &fraud_res { + Ok(text) => eprintln!(" [Fraud LLM] {text}"), + Err(e) => panic!("Fraud agent failed: {e}"), + } + match &growth_res { + Ok(text) => eprintln!(" [Growth LLM] {text}"), + Err(e) => panic!("Growth agent failed: {e}"), + } + match &compliance_res { + Ok(text) => eprintln!(" [Compliance LLM] {text}"), + Err(e) => panic!("Compliance agent failed: {e}"), + } + eprintln!(); + eprintln!(" All 3 specialists completed in {:.1}s (parallel)\n", parallel_duration.as_secs_f64()); + + // ═══════════════════════════════════════════════════════════════════ + // STEP 3: Drain the signal stream — show what orchestrator observed + // ═══════════════════════════════════════════════════════════════════ + eprintln!("── STEP 3: Orchestrator reads signal stream ─────────────────"); + eprintln!(" (Ambient Plane — these did NOT enter session history)\n"); + + let mut signal_count = 0; + while let Ok(Ok(Some(resp))) = tokio::time::timeout( + std::time::Duration::from_millis(200), + signal_stream.message(), + ) + .await + { + let env = resp.envelope.unwrap(); + let payload = SignalPayload::decode(&*env.payload).unwrap_or_default(); + signal_count += 1; + eprintln!( + " Signal #{signal_count}: sender={:<30} type={:<12} data=\"{}\" confidence={}", + env.sender, + payload.signal_type, + String::from_utf8_lossy(&payload.data), + payload.confidence, + ); + } + eprintln!(); + eprintln!(" Total Signals received: {signal_count}"); + eprintln!(" These Signals are ambient — session history is unaffected.\n"); + + // ═══════════════════════════════════════════════════════════════════ + // STEP 4: Orchestrator commits (code, no LLM) + // ═══════════════════════════════════════════════════════════════════ + eprintln!("── STEP 4: Orchestrator commits (Coordination Plane) ────────"); + { + let mut client = orch_client.lock().await; + let ack = send_as(&mut client, orch_id, envelope( + MODE_DECISION, "Commitment", &new_message_id(), &sid, orch_id, + commitment_payload( + "cmt-001", + "transfer.step-up-verification", + "checkout-payments", + "All specialists evaluated — proceeding with step-up verification", + ), + )).await.unwrap(); + assert!(ack.ok); + assert_eq!(ack.session_state, 2); + eprintln!(" → [Session History #6] Commitment from orchestrator"); + eprintln!(" → Session state: RESOLVED"); + } + + // ═══════════════════════════════════════════════════════════════════ + // STEP 5: Verify + // ═══════════════════════════════════════════════════════════════════ + eprintln!(); + eprintln!("── STEP 5: Final verification ───────────────────────────────"); + { + let mut client = orch_client.lock().await; + let resp = get_session_as(&mut client, orch_id, &sid).await.unwrap(); + let meta = resp.metadata.unwrap(); + assert_eq!(meta.state, 2); + eprintln!(" Session state: {} (RESOLVED)", meta.state); + } + + eprintln!(); + eprintln!("╔══════════════════════════════════════════════════════════════╗"); + eprintln!("║ PASSED ║"); + eprintln!("║ ║"); + eprintln!("║ Coordination Plane (session history — binding): ║"); + eprintln!("║ [1] SessionStart (orchestrator, code) ║"); + eprintln!("║ [2] Proposal (orchestrator, code) ║"); + eprintln!("║ [3] Evaluation (fraud, LLM) ║"); + eprintln!("║ [4] Evaluation (growth, LLM) ║"); + eprintln!("║ [5] Evaluation (compliance, LLM) ║"); + eprintln!("║ [6] Commitment (orchestrator, code) ║"); + eprintln!("║ ║"); + eprintln!("║ Ambient Plane (signals — non-binding): ║"); + eprintln!("║ {signal_count} signals observed (progress + completed from each agent) ║"); + eprintln!("║ Signals correlate with session but do NOT enter history ║"); + eprintln!("║ ║"); + eprintln!("║ Agents: {:.1}s parallel | LLM used only for evaluations ║", parallel_duration.as_secs_f64()); + eprintln!("╚══════════════════════════════════════════════════════════════╝"); +} diff --git a/integration_tests/tests/tier3_e2e/test_e2e_task.rs b/integration_tests/tests/tier3_e2e/test_e2e_task.rs new file mode 100644 index 0000000..f665d26 --- /dev/null +++ b/integration_tests/tests/tier3_e2e/test_e2e_task.rs @@ -0,0 +1,195 @@ +use crate::common; +use macp_integration_tests::helpers::*; +use macp_integration_tests::macp_tools::{self, task::*}; +use rig::completion::Prompt; +use rig::providers::openai; +use rig::prelude::*; + +/// Realistic task delegation: planner (plain code) delegates to worker (LLM). +/// +/// Architecture: +/// - Planner: plain Rust code (deterministic session/task/commit operations) +/// - Worker: real GPT-4o-mini (LLM reasoning to accept and produce analysis) +/// +/// The LLM is used ONLY where reasoning adds value — the worker deciding how +/// to complete the task and producing the analysis output. +#[tokio::test] +#[ignore] +async fn real_llm_agents_delegate_task() { + if std::env::var("OPENAI_API_KEY").is_err() { + eprintln!("SKIPPED: OPENAI_API_KEY not set"); + return; + } + + let ep = common::endpoint().await; + let sid = new_session_id(); + let planner_id = "agent://planner"; + let worker_id = "agent://data-analyst"; + + eprintln!("═══════════════════════════════════════════════════════════════"); + eprintln!(" MACP Task Mode E2E Test — Q4 Data Analysis Delegation"); + eprintln!(" Session: {sid}"); + eprintln!(" Planner: plain code (no LLM)"); + eprintln!(" Worker: GPT-4o-mini (real LLM reasoning)"); + eprintln!("═══════════════════════════════════════════════════════════════"); + + // ── Planner gRPC client (no LLM needed) ───────────────────────────── + let planner_client = macp_tools::shared_client(ep).await; + + // ═══════════════════════════════════════════════════════════════════ + // STEP 1: Planner starts session (plain code) + // ═══════════════════════════════════════════════════════════════════ + eprintln!("\n── Step 1: Planner starts session (deterministic) ───────────"); + { + let mut client = planner_client.lock().await; + let ack = send_as( + &mut client, + planner_id, + envelope( + MODE_TASK, + "SessionStart", + &new_message_id(), + &sid, + planner_id, + session_start_payload( + "Q4 revenue analysis delegation", + &[planner_id, worker_id], + 60_000, + ), + ), + ) + .await + .expect("SessionStart should succeed"); + assert!(ack.ok); + eprintln!(" Session started. State: OPEN"); + } + + // ═══════════════════════════════════════════════════════════════════ + // STEP 2: Planner creates task (plain code) + // ═══════════════════════════════════════════════════════════════════ + eprintln!("\n── Step 2: Planner creates task request (deterministic) ─────"); + { + let mut client = planner_client.lock().await; + let ack = send_as( + &mut client, + planner_id, + envelope( + MODE_TASK, + "TaskRequest", + &new_message_id(), + &sid, + planner_id, + task_request_payload( + "q4-analysis", + "Q4 Revenue Data Analysis", + "Analyze Q4 revenue by region and identify top growth drivers. Include YoY comparison and highlight any anomalies.", + worker_id, + ), + ), + ) + .await + .expect("TaskRequest should succeed"); + assert!(ack.ok); + eprintln!(" Task created: \"Q4 Revenue Data Analysis\""); + eprintln!(" Assigned to: {worker_id}"); + } + + // ═══════════════════════════════════════════════════════════════════ + // STEP 3: Worker accepts and completes (REAL LLM REASONING) + // ═══════════════════════════════════════════════════════════════════ + eprintln!("\n── Step 3: Worker accepts and completes task (LLM) ──────────"); + eprintln!(" Worker uses GPT-4o-mini to reason about the task\n"); + + let openai_client = openai::Client::from_env(); + let worker_client = macp_tools::shared_client(ep).await; + let worker_agent = openai_client + .agent(openai::GPT_4O_MINI) + .preamble( + "You are a data analyst agent. When assigned a task, you accept it using \ + the macp_task_accept tool, then complete it using the macp_task_complete tool \ + with a professional analysis summary.\n\n\ + Use the EXACT session_id and task_id values from the prompt.\n\ + For the summary in macp_task_complete, write a realistic data analysis finding.", + ) + .tool(TaskAcceptTool { + client: worker_client.clone(), + agent_id: worker_id.into(), + }) + .tool(TaskCompleteTool { + client: worker_client.clone(), + agent_id: worker_id.into(), + }) + .default_max_turns(10) + .build(); + + eprintln!(" [Data Analyst] Accepting and analyzing with GPT-4o-mini..."); + let result = tokio::time::timeout( + std::time::Duration::from_secs(60), + worker_agent.prompt(&format!( + "You have been assigned a task: \"Q4 Revenue Data Analysis\"\n\ + Instructions: Analyze Q4 revenue by region and identify top growth drivers. \ + Include YoY comparison and highlight any anomalies.\n\n\ + First accept the task, then complete it with your analysis.\n\ + - session_id: {sid}\n\ + - task_id: \"q4-analysis\"\n\ + For the completion summary, write realistic revenue analysis findings." + )), + ) + .await; + match &result { + Ok(Ok(text)) => eprintln!(" Data Analyst response: {text}\n"), + Ok(Err(e)) => panic!("Worker agent failed: {e}"), + Err(_) => panic!("Worker agent timed out"), + } + + // ═══════════════════════════════════════════════════════════════════ + // STEP 4: Planner commits (plain code) + // ═══════════════════════════════════════════════════════════════════ + eprintln!("── Step 4: Planner commits completed task (deterministic) ───"); + { + let mut client = planner_client.lock().await; + let ack = send_as( + &mut client, + planner_id, + envelope( + MODE_TASK, + "Commitment", + &new_message_id(), + &sid, + planner_id, + commitment_payload( + "cmt-001", + "task-completed", + "planner", + "Data analyst delivered Q4 revenue analysis with regional breakdown", + ), + ), + ) + .await + .expect("Commitment should succeed"); + assert!(ack.ok); + assert_eq!(ack.session_state, 2); + eprintln!(" Committed. Session state: RESOLVED"); + } + + // ═══════════════════════════════════════════════════════════════════ + // STEP 5: Verify + // ═══════════════════════════════════════════════════════════════════ + eprintln!("\n── Step 5: Verify session state ─────────────────────────────"); + { + let mut client = planner_client.lock().await; + let resp = get_session_as(&mut client, planner_id, &sid) + .await + .expect("GetSession should succeed"); + let meta = resp.metadata.expect("metadata present"); + assert_eq!(meta.state, 2); + eprintln!(" Session state: {} (RESOLVED)", meta.state); + } + + eprintln!("\n═══════════════════════════════════════════════════════════════"); + eprintln!(" PASSED"); + eprintln!(" Planner (code) created task → Worker (LLM) analyzed and completed"); + eprintln!(" → Planner (code) committed"); + eprintln!(" LLM was used ONLY where reasoning was needed (worker analysis)"); + eprintln!("═══════════════════════════════════════════════════════════════"); +} diff --git a/proto/macp/v1/core.proto b/proto/macp/v1/core.proto index 55a9487..b25dff5 100644 --- a/proto/macp/v1/core.proto +++ b/proto/macp/v1/core.proto @@ -277,6 +277,12 @@ message PromoteModeResponse { string mode = 3; } +message WatchSignalsRequest {} + +message WatchSignalsResponse { + Envelope envelope = 1; +} + service MACPRuntimeService { rpc Initialize(InitializeRequest) returns (InitializeResponse); rpc Send(SendRequest) returns (SendResponse); @@ -293,4 +299,6 @@ service MACPRuntimeService { rpc RegisterExtMode(RegisterExtModeRequest) returns (RegisterExtModeResponse); rpc UnregisterExtMode(UnregisterExtModeRequest) returns (UnregisterExtModeResponse); rpc PromoteMode(PromoteModeRequest) returns (PromoteModeResponse); + // Ambient signal observation + rpc WatchSignals(WatchSignalsRequest) returns (stream WatchSignalsResponse); } diff --git a/src/runtime.rs b/src/runtime.rs index 7bb1cc5..be3f47f 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -25,6 +25,7 @@ pub struct Runtime { pub registry: Arc, pub log_store: Arc, stream_bus: Arc, + signal_bus: tokio::sync::broadcast::Sender, mode_registry: Arc, metrics: Arc, checkpoint_interval: usize, @@ -54,11 +55,13 @@ impl Runtime { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(0); // 0 = disabled by default + let (signal_tx, _) = tokio::sync::broadcast::channel(256); Self { storage, registry, log_store, stream_bus: Arc::new(SessionStreamBus::default()), + signal_bus: signal_tx, mode_registry, metrics: Arc::new(RuntimeMetrics::new()), checkpoint_interval, @@ -112,6 +115,10 @@ impl Runtime { self.stream_bus.subscribe(session_id) } + pub fn subscribe_signals(&self) -> tokio::sync::broadcast::Receiver { + self.signal_bus.subscribe() + } + fn publish_accepted_envelope(&self, env: &Envelope) { if !env.session_id.is_empty() { self.stream_bus.publish(&env.session_id, env.clone()); @@ -390,13 +397,15 @@ impl Runtime { } /// Process a Signal envelope. Signals are informational out-of-band - /// notifications (progress, heartbeat, etc.). Logged but no state mutation. + /// notifications (progress, heartbeat, etc.). Broadcast to signal + /// subscribers but no session state mutation. async fn process_signal(&self, env: &Envelope) -> Result { tracing::debug!( sender = %env.sender, message_id = %env.message_id, "signal received" ); + let _ = self.signal_bus.send(env.clone()); Ok(ProcessResult { session_state: SessionState::Open, duplicate: false, diff --git a/src/server.rs b/src/server.rs index dfee79c..32ebf64 100644 --- a/src/server.rs +++ b/src/server.rs @@ -11,6 +11,7 @@ use macp_runtime::pb::{ SessionState as PbSessionState, SessionsCapability, StreamSessionRequest, StreamSessionResponse, UnregisterExtModeRequest, UnregisterExtModeResponse, WatchModeRegistryRequest, WatchModeRegistryResponse, WatchRootsRequest, WatchRootsResponse, + WatchSignalsRequest, WatchSignalsResponse, }; use macp_runtime::runtime::Runtime; use macp_runtime::security::{AuthIdentity, SecurityLayer}; @@ -654,6 +655,25 @@ impl MacpRuntimeService for MacpServer { Ok(Response::new(Box::pin(stream))) } + type WatchSignalsStream = std::pin::Pin< + Box> + Send>, + >; + + async fn watch_signals( + &self, + _request: Request, + ) -> Result, Status> { + let mut rx = self.runtime.subscribe_signals(); + let stream = async_stream::try_stream! { + while let Ok(envelope) = rx.recv().await { + yield WatchSignalsResponse { + envelope: Some(envelope), + }; + } + }; + Ok(Response::new(Box::pin(stream))) + } + // Extension mode lifecycle RPCs async fn list_ext_modes(