diff --git a/docs/superpowers/plans/2026-06-13-pr1-fabro-chat-crate.md b/docs/superpowers/plans/2026-06-13-pr1-fabro-chat-crate.md new file mode 100644 index 000000000..f58cbdf1f --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-pr1-fabro-chat-crate.md @@ -0,0 +1,730 @@ +# PR 1: `fabro-chat` Crate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create `lib/crates/fabro-chat/` with the `ChatService` and `ChatEventContext` traits, all shared types, and test-support helpers — the foundation every subsequent PR builds on. + +**Architecture:** A new crate between `fabro-interview` and the provider crates. Defines the trait contract only — no implementations, no server wiring. Mirrors the `fabro-sandbox` / `SandboxProvider` pattern exactly. + +**Tech Stack:** Rust, `async-trait`, `bytes`, `http` (via `axum::http` re-exports), `serde`, `strum`, `insta` (snapshot tests in dev-deps) + +**Spec:** `docs/superpowers/specs/2026-06-13-chat-service-trait-and-providers.md` — §`fabro-chat` — Trait and Shared Types + +--- + +## File Structure + +**Create:** +- `lib/crates/fabro-chat/Cargo.toml` +- `lib/crates/fabro-chat/src/lib.rs` — public re-exports +- `lib/crates/fabro-chat/src/service.rs` — `ChatService` trait +- `lib/crates/fabro-chat/src/context.rs` — `ChatEventContext` trait +- `lib/crates/fabro-chat/src/types.rs` — `ChatProviderKind`, `AnswerSubmission`, `WebhookOutcome` +- `lib/crates/fabro-chat/src/test_support.rs` — `MockChatEventContext`, `assert_chat_service_contract` + +--- + +### Task 1: Create the crate skeleton + +**Files:** +- Create: `lib/crates/fabro-chat/Cargo.toml` +- Create: `lib/crates/fabro-chat/src/lib.rs` + +- [ ] **Step 1: Write `Cargo.toml`** + +```toml +[package] +name = "fabro-chat" +edition.workspace = true +version.workspace = true +publish = false +license.workspace = true +description = "ChatService trait and shared types for Fabro chat integrations" + +[features] +test-support = [] + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +bytes.workspace = true +serde.workspace = true +strum.workspace = true +fabro-types = { path = "../fabro-types" } +fabro-interview = { path = "../fabro-interview" } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util", "macros"] } +serde_json.workspace = true +``` + +- [ ] **Step 2: Write `src/lib.rs` with empty module declarations** + +```rust +pub mod context; +pub mod service; +pub mod types; + +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; + +pub use context::ChatEventContext; +pub use service::ChatService; +pub use types::{AnswerSubmission, ChatProviderKind, WebhookOutcome}; +``` + +- [ ] **Step 3: Create placeholder source files so the crate compiles** + +Create `lib/crates/fabro-chat/src/context.rs` with empty content: +```rust +``` + +Create `lib/crates/fabro-chat/src/service.rs` with empty content: +```rust +``` + +Create `lib/crates/fabro-chat/src/types.rs` with empty content: +```rust +``` + +- [ ] **Step 4: Verify the crate is picked up by the workspace** + +The workspace `Cargo.toml` uses `members = ["lib/crates/*"]` so no changes are needed. Run: + +``` +cargo build -p fabro-chat +``` + +Expected: compiles (with unused import warnings — fine for now). + +- [ ] **Step 5: Commit** + +```bash +git add lib/crates/fabro-chat/ +git commit -m "feat(chat): scaffold fabro-chat crate" +``` + +--- + +### Task 2: Shared types + +**Files:** +- Modify: `lib/crates/fabro-chat/src/types.rs` + +- [ ] **Step 1: Write the failing test** + +Add to `lib/crates/fabro-chat/src/types.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chat_provider_kind_display() { + assert_eq!(ChatProviderKind::Slack.to_string(), "slack"); + assert_eq!(ChatProviderKind::Mattermost.to_string(), "mattermost"); + assert_eq!(ChatProviderKind::Teams.to_string(), "teams"); + } + + #[test] + fn chat_provider_kind_as_str() { + assert_eq!(ChatProviderKind::Slack.as_str(), "slack"); + } + + #[test] + fn webhook_outcome_default_body_is_none() { + let outcome = WebhookOutcome { status: 200, body: None }; + assert_eq!(outcome.status, 200); + assert!(outcome.body.is_none()); + } + + #[test] + fn answer_submission_round_trips_json() { + use fabro_interview::{Answer, AnswerSubmission}; + use fabro_types::Principal; + + let sub = AnswerSubmission { + run_id: "run_01abc".to_string(), + qid: "q1".to_string(), + answer: Answer::yes(), + actor: Principal::Slack { + user_id: "U123".to_string(), + user_name: Some("alice".to_string()), + }, + }; + let json = serde_json::to_string(&sub).unwrap(); + let back: AnswerSubmission = serde_json::from_str(&json).unwrap(); + assert_eq!(back.run_id, "run_01abc"); + assert_eq!(back.qid, "q1"); + } +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +``` +cargo nextest run -p fabro-chat +``` + +Expected: FAIL — types not defined yet. + +- [ ] **Step 3: Implement the types** + +Replace the body of `lib/crates/fabro-chat/src/types.rs`: + +```rust +use fabro_interview::{Answer, AnswerSubmission as InterviewAnswerSubmission}; +use fabro_types::Principal; +use serde::{Deserialize, Serialize}; +use strum::{Display, IntoStaticStr}; + +/// Discriminant for webhook dispatch and logging. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub enum ChatProviderKind { + Slack, + Mattermost, + Teams, +} + +impl ChatProviderKind { + pub fn as_str(self) -> &'static str { + self.into() + } +} + +/// An answer submission from a chat platform, enriched with routing information. +/// +/// Distinct from `fabro_interview::AnswerSubmission` which lacks `run_id`/`qid`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnswerSubmission { + pub run_id: String, + pub qid: String, + pub answer: Answer, + pub actor: Principal, +} + +impl AnswerSubmission { + /// Convert into an `fabro_interview::AnswerSubmission` for passing to the interview layer. + pub fn into_interview_submission(self) -> InterviewAnswerSubmission { + InterviewAnswerSubmission::new(self.answer, self.actor) + } +} + +/// Lightweight HTTP response returned by `ChatService::handle_webhook`. +/// +/// Keeps provider crates free of axum; `fabro-server` converts this into an Axum response. +#[derive(Debug, Clone)] +pub struct WebhookOutcome { + pub status: u16, + pub body: Option, +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +``` +cargo nextest run -p fabro-chat +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/crates/fabro-chat/src/types.rs +git commit -m "feat(chat): add ChatProviderKind, AnswerSubmission, WebhookOutcome types" +``` + +--- + +### Task 3: `ChatEventContext` trait + +**Files:** +- Modify: `lib/crates/fabro-chat/src/context.rs` + +- [ ] **Step 1: Write the failing test** + +Add to `lib/crates/fabro-chat/src/context.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + struct AlwaysNoneContext; + + #[async_trait::async_trait] + impl ChatEventContext for AlwaysNoneContext { + async fn get_run_projection( + &self, + _run_id: fabro_types::RunId, + ) -> Option> { + None + } + fn resolve_env(&self, _name: &str) -> Option { + None + } + fn run_web_url(&self, _run_id: &fabro_types::RunId) -> Option { + None + } + fn canonical_origin(&self) -> Option { + None + } + } + + #[tokio::test] + async fn context_methods_callable() { + let ctx = AlwaysNoneContext; + let run_id = fabro_types::RunId::new(); + assert!(ctx.get_run_projection(run_id.clone()).await.is_none()); + assert!(ctx.resolve_env("FOO").is_none()); + assert!(ctx.run_web_url(&run_id).is_none()); + assert!(ctx.canonical_origin().is_none()); + } +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +``` +cargo nextest run -p fabro-chat +``` + +Expected: FAIL — `ChatEventContext` not defined. + +- [ ] **Step 3: Implement `ChatEventContext`** + +Replace the body of `lib/crates/fabro-chat/src/context.rs`: + +```rust +use std::sync::Arc; + +use async_trait::async_trait; +use fabro_types::{RunId, RunProjection}; + +#[async_trait] +pub trait ChatEventContext: Send + Sync { + /// Fetch the current projection for a run (used to build notification content). + async fn get_run_projection(&self, run_id: RunId) -> Option>; + + /// Look up a named environment variable (used to resolve per-run config). + fn resolve_env(&self, name: &str) -> Option; + + /// Build a web UI deep link for a run, e.g. `https://app.example.com/runs/01abc`. + fn run_web_url(&self, run_id: &RunId) -> Option; + + /// The canonical server origin, e.g. `https://fabro.example.com`. + /// Used by Mattermost to build button `integration.url` values. + fn canonical_origin(&self) -> Option; +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +``` +cargo nextest run -p fabro-chat +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/crates/fabro-chat/src/context.rs +git commit -m "feat(chat): add ChatEventContext trait" +``` + +--- + +### Task 4: `ChatService` trait + +**Files:** +- Modify: `lib/crates/fabro-chat/src/service.rs` + +- [ ] **Step 1: Write the failing test** + +Add to `lib/crates/fabro-chat/src/service.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use bytes::Bytes; + use fabro_types::{ + IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus, + }; + use crate::context::ChatEventContext; + use crate::types::{AnswerSubmission, ChatProviderKind, WebhookOutcome}; + + struct SlackStub; + + #[async_trait::async_trait] + impl ChatService for SlackStub { + fn kind(&self) -> ChatProviderKind { + ChatProviderKind::Slack + } + async fn start(&self, _on_submit: Arc) {} + async fn handle_event( + &self, + _envelope: &fabro_types::EventEnvelope, + _context: &dyn ChatEventContext, + ) { + } + async fn handle_webhook( + &self, + _sub_path: &str, + _query_string: &str, + _headers: &http::HeaderMap, + _body: &Bytes, + ) -> WebhookOutcome { + WebhookOutcome { status: 404, body: None } + } + fn connection_status(&self) -> IntegrationConnectionStatus { + IntegrationConnectionStatus { + kind: IntegrationConnectionKind::SocketMode, + status: IntegrationConnectionState::Connected, + last_connected_at: None, + last_error: None, + } + } + } + + #[tokio::test] + async fn trait_is_object_safe() { + let svc: Arc = Arc::new(SlackStub); + assert_eq!(svc.kind(), ChatProviderKind::Slack); + } +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +``` +cargo nextest run -p fabro-chat +``` + +Expected: FAIL — `ChatService` not defined, `http` crate not available. + +- [ ] **Step 3: Add `http` to `Cargo.toml`** + +The `http` crate ships as part of axum's public API surface but is also a standalone crate. Add it to `lib/crates/fabro-chat/Cargo.toml` directly so provider crates don't need axum: + +```toml +[dependencies] +# existing entries ... +http = "1" +``` + +Check the version axum uses: + +``` +cargo tree -p axum --depth 1 | grep "^http" +``` + +Use whatever version is listed (axum 0.8 uses `http` 1.x). + +- [ ] **Step 4: Implement `ChatService`** + +Replace the body of `lib/crates/fabro-chat/src/service.rs`: + +```rust +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use fabro_types::{EventEnvelope, IntegrationConnectionStatus}; +use http::HeaderMap; + +use crate::context::ChatEventContext; +use crate::types::{AnswerSubmission, ChatProviderKind, WebhookOutcome}; + +#[async_trait] +pub trait ChatService: Send + Sync { + fn kind(&self) -> ChatProviderKind; + + /// Start the provider's inbound event loop. + /// - Slack: spawns Socket Mode WebSocket loop. + /// - Mattermost: spawns WebSocket loop. + /// - Teams: documented no-op — delivers events via inbound HTTP only. + async fn start(&self, on_submit: Arc); + + /// Handle an outbound Fabro event (send notifications, post interview questions). + async fn handle_event(&self, envelope: &EventEnvelope, context: &dyn ChatEventContext); + + /// Handle an inbound HTTP webhook (button actions, Teams activities). + /// `query_string` is the raw URI query, e.g. `"token=abc&foo=bar"`. + /// Slack always returns 404 — it has no inbound HTTP surface. + async fn handle_webhook( + &self, + sub_path: &str, + query_string: &str, + headers: &HeaderMap, + body: &Bytes, + ) -> WebhookOutcome; + + fn connection_status(&self) -> IntegrationConnectionStatus; +} +``` + +- [ ] **Step 5: Run tests to confirm they pass** + +``` +cargo nextest run -p fabro-chat +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add lib/crates/fabro-chat/src/service.rs lib/crates/fabro-chat/Cargo.toml +git commit -m "feat(chat): add ChatService trait" +``` + +--- + +### Task 5: Test-support helpers + +**Files:** +- Modify: `lib/crates/fabro-chat/src/test_support.rs` + +These helpers are gated behind `#[cfg(any(test, feature = "test-support"))]` and are used by `fabro-slack`, `fabro-mattermost`, and `fabro-teams` in their unit tests. + +- [ ] **Step 1: Write the failing test** + +Add a test to `lib/crates/fabro-chat/src/test_support.rs` that imports and exercises the helpers: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use bytes::Bytes; + use fabro_types::{ + IntegrationConnectionKind, IntegrationConnectionState, IntegrationConnectionStatus, + RunId, + }; + use crate::context::ChatEventContext; + use crate::service::ChatService; + use crate::types::{AnswerSubmission, ChatProviderKind, WebhookOutcome}; + + struct TeamsStub; + + #[async_trait::async_trait] + impl ChatService for TeamsStub { + fn kind(&self) -> ChatProviderKind { ChatProviderKind::Teams } + async fn start(&self, _on_submit: Arc) {} + async fn handle_event( + &self, + _envelope: &fabro_types::EventEnvelope, + _context: &dyn ChatEventContext, + ) {} + async fn handle_webhook( + &self, + _sub_path: &str, + _query_string: &str, + _headers: &http::HeaderMap, + _body: &Bytes, + ) -> WebhookOutcome { + WebhookOutcome { status: 200, body: None } + } + fn connection_status(&self) -> IntegrationConnectionStatus { + IntegrationConnectionStatus { + kind: IntegrationConnectionKind::SocketMode, + status: IntegrationConnectionState::Connected, + last_connected_at: None, + last_error: None, + } + } + } + + #[tokio::test] + async fn mock_context_get_run_projection_returns_none_by_default() { + let ctx = MockChatEventContext::default(); + assert!(ctx.get_run_projection(RunId::new()).await.is_none()); + } + + #[test] + fn mock_context_resolve_env_returns_configured_value() { + let ctx = MockChatEventContext::default() + .with_env("FABRO_URL", "https://app.example.com"); + assert_eq!(ctx.resolve_env("FABRO_URL").as_deref(), Some("https://app.example.com")); + assert!(ctx.resolve_env("OTHER").is_none()); + } + + #[test] + fn mock_context_canonical_origin() { + let ctx = MockChatEventContext::default() + .with_canonical_origin("https://fabro.example.com"); + assert_eq!(ctx.canonical_origin().as_deref(), Some("https://fabro.example.com")); + } + + #[tokio::test] + async fn contract_checker_passes_for_teams_stub() { + let svc = TeamsStub; + assert_chat_service_contract(&svc).await; + } +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +``` +cargo nextest run -p fabro-chat +``` + +Expected: FAIL — `MockChatEventContext` and `assert_chat_service_contract` not defined. + +- [ ] **Step 3: Implement the test-support helpers** + +Write `lib/crates/fabro-chat/src/test_support.rs`: + +```rust +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use fabro_types::{RunId, RunProjection}; +use http::HeaderMap; + +use crate::context::ChatEventContext; +use crate::service::ChatService; + +/// A configurable `ChatEventContext` for use in provider unit tests. +#[derive(Default)] +pub struct MockChatEventContext { + env: HashMap, + canonical_origin: Option, + run_web_url: Option, +} + +impl MockChatEventContext { + pub fn with_env(mut self, name: impl Into, value: impl Into) -> Self { + self.env.insert(name.into(), value.into()); + self + } + + pub fn with_canonical_origin(mut self, origin: impl Into) -> Self { + self.canonical_origin = Some(origin.into()); + self + } + + pub fn with_run_web_url(mut self, url: impl Into) -> Self { + self.run_web_url = Some(url.into()); + self + } +} + +#[async_trait] +impl ChatEventContext for MockChatEventContext { + async fn get_run_projection(&self, _run_id: RunId) -> Option> { + None + } + + fn resolve_env(&self, name: &str) -> Option { + self.env.get(name).cloned() + } + + fn run_web_url(&self, _run_id: &RunId) -> Option { + self.run_web_url.clone() + } + + fn canonical_origin(&self) -> Option { + self.canonical_origin.clone() + } +} + +/// Thin conformance checker: verifies a `ChatService` implementation satisfies +/// the basic contract without panicking or returning obviously invalid values. +pub async fn assert_chat_service_contract(service: &dyn ChatService) { + // kind() returns a valid variant (doesn't panic) + let _ = service.kind(); + + // connection_status() doesn't panic + let _ = service.connection_status(); + + // handle_webhook with empty inputs returns a plausible HTTP status + let outcome = service + .handle_webhook("", "", &HeaderMap::new(), &Bytes::new()) + .await; + assert!( + outcome.status >= 200 && outcome.status < 600, + "handle_webhook returned invalid HTTP status {}", + outcome.status + ); +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +``` +cargo nextest run -p fabro-chat +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/crates/fabro-chat/src/test_support.rs +git commit -m "feat(chat): add MockChatEventContext and assert_chat_service_contract" +``` + +--- + +### Task 6: Full workspace build check + +- [ ] **Step 1: Build the full workspace without test-support** + +``` +cargo build --workspace +``` + +Expected: PASS. `fabro-chat` compiles; no other crates affected yet. + +- [ ] **Step 2: Run the full workspace test suite** + +``` +cargo nextest run --workspace +``` + +Expected: PASS. New `fabro-chat` tests pass; no existing tests broken. + +- [ ] **Step 3: Check formatting** + +``` +cargo +nightly-2026-04-14 fmt --check --all +``` + +If formatting issues are reported, fix them: + +``` +cargo +nightly-2026-04-14 fmt --all +git add -u +git commit -m "style: fmt fabro-chat" +``` + +- [ ] **Step 4: Run clippy** + +``` +cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings +``` + +Expected: PASS. Fix any warnings before proceeding. + +- [ ] **Step 5: Final commit (if needed after fmt/clippy)** + +```bash +git add -u +git commit -m "fix(chat): address clippy warnings in fabro-chat" +```