From f6ea13a1b6df4142da3a13a162713dc00e88d52a Mon Sep 17 00:00:00 2001 From: spytensor <17600413737@163.com> Date: Sun, 24 May 2026 00:30:21 +0400 Subject: [PATCH] feat: add conversation visibility model --- src/console_snapshot.rs | 64 ++++++ src/conversation_visibility.rs | 219 ++++++++++++++++++++ src/lib.rs | 1 + tests/conversation_visibility_test.rs | 106 ++++++++++ tests/fixtures/console_snapshot_v08.toml | 14 ++ tests/fixtures/conversation_visibility.toml | 98 +++++++++ 6 files changed, 502 insertions(+) create mode 100644 src/conversation_visibility.rs create mode 100644 tests/conversation_visibility_test.rs create mode 100644 tests/fixtures/conversation_visibility.toml diff --git a/src/console_snapshot.rs b/src/console_snapshot.rs index d1194b0..8d83490 100644 --- a/src/console_snapshot.rs +++ b/src/console_snapshot.rs @@ -251,12 +251,28 @@ pub struct ConversationSnapshot { /// Count of internal delegations hidden from the public transcript. #[serde(default)] pub internal_delegation_count: u32, + /// Side-rail activity rows for internal delegation. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub internal_activity: Vec, } impl ConversationSnapshot { fn validate(&self) -> Result<()> { for turn in &self.public_turns { turn.validate()?; + if matches!( + turn.visibility, + ConversationVisibility::InternalDelegation | ConversationVisibility::DebugLog + ) { + bail!( + "conversation.publicTurns cannot contain {:?} turn from `{}`", + turn.visibility, + turn.speaker + ); + } + } + for activity in &self.internal_activity { + activity.validate()?; } Ok(()) } @@ -295,6 +311,54 @@ pub enum ConversationVisibility { DebugLog, } +/// Side-rail activity for host-managed internal delegation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InternalDelegationActivity { + /// Delegated role. + pub role: String, + /// Related WorkOrder, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub work_order: Option, + /// Activity state. + pub state: InternalDelegationState, + /// Compact side-rail summary. + pub summary: String, + /// Log/Xray reference for details on demand. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xray_ref: Option, +} + +impl InternalDelegationActivity { + fn validate(&self) -> Result<()> { + ensure_nonempty("internalActivity.role", &self.role)?; + ensure_nonempty("internalActivity.summary", &self.summary)?; + if let Some(work_order) = &self.work_order { + ensure_work_order_id(work_order)?; + } + if self.xray_ref.as_deref().is_some_and(str::is_empty) { + bail!("internalActivity.xrayRef cannot be empty"); + } + Ok(()) + } +} + +/// Internal delegation side-rail state. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum InternalDelegationState { + /// Delegation was queued or dispatched. + Dispatched, + /// Role is actively working. + Working, + /// Role is reviewing a plan/work item. + Reviewing, + /// Delegation is blocked. + Blocked, + /// Delegation completed and is awaiting host synthesis. + Completed, +} + /// One WorkOrder row in the console. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/src/conversation_visibility.rs b/src/conversation_visibility.rs new file mode 100644 index 0000000..a61e230 --- /dev/null +++ b/src/conversation_visibility.rs @@ -0,0 +1,219 @@ +//! Conversation visibility rules for the future CoreRoom console. +//! +//! The public conversation defaults to `User <-> @host`. Specialist role +//! collaboration remains host-managed internal delegation unless the user +//! explicitly addressed that role, or `@host` surfaces critical output. + +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; + +use crate::console_snapshot::ConversationVisibility; + +/// Reason `@host` may surface role output in the public transcript. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum HostSurfaceReason { + /// A role found a critical risk the user must see. + CriticalRisk, + /// A role has veto authority and blocked the plan/work. + Veto, + /// User confirmation is needed before continuing. + ConfirmationRequired, + /// Final evidence summary is being returned to the user. + FinalEvidenceSummary, +} + +impl HostSurfaceReason { + /// Stable label. + pub const fn label(self) -> &'static str { + match self { + Self::CriticalRisk => "critical-risk", + Self::Veto => "veto", + Self::ConfirmationRequired => "confirmation-required", + Self::FinalEvidenceSummary => "final-evidence-summary", + } + } +} + +/// Input event category for visibility routing. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde( + tag = "kind", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum ConversationVisibilityInput { + /// User sent a message. + UserMessage { + /// Raw target role if the user explicitly addressed a specialist. + #[serde(default, skip_serializing_if = "Option::is_none")] + addressed_role: Option, + }, + /// Host replied directly to the user. + HostResponse, + /// Host delegated work to a role. + HostToRole { + /// Target role. + role: String, + /// Related WorkOrder. + #[serde(default, skip_serializing_if = "Option::is_none")] + work_order: Option, + }, + /// Role returned output to host from an internal delegation. + RoleToHost { + /// Source role. + role: String, + /// Whether host is intentionally surfacing this output publicly. + #[serde(default, skip_serializing_if = "Option::is_none")] + surfaced_by_host: Option, + }, + /// Role replied directly because the user explicitly addressed it. + RoleAddressedByUser { + /// Source role. + role: String, + }, + /// Compact side-rail status row. + SideRailSummary { + /// Side-rail source label. + source: String, + }, + /// Debug/log/Xray event. + DebugLog { + /// Debug source label. + source: String, + }, +} + +/// Visibility decision for one conversation-like event. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationVisibilityDecision { + /// Surface that should receive the event. + pub visibility: ConversationVisibility, + /// Whether the event belongs in the main public transcript. + pub public_transcript: bool, + /// Whether the event should update side-rail activity state. + pub side_rail_activity: bool, + /// Whether details remain available through logs/Xray. + pub xray_available: bool, + /// Human-readable reason for the routing decision. + pub reason: String, +} + +/// Decide where an event belongs. +pub fn decide_visibility( + input: &ConversationVisibilityInput, +) -> Result { + let decision = match input { + ConversationVisibilityInput::UserMessage { addressed_role } => { + if let Some(role) = addressed_role { + ensure_role(role)?; + ConversationVisibilityDecision::public(format!("user explicitly addressed @{role}")) + } else { + ConversationVisibilityDecision::public( + "user message defaults to public transcript".to_owned(), + ) + } + } + ConversationVisibilityInput::HostResponse => ConversationVisibilityDecision::public( + "@host response defaults to public transcript".to_owned(), + ), + ConversationVisibilityInput::HostToRole { role, work_order } => { + ensure_role(role)?; + if let Some(work_order) = work_order { + ensure_work_order_id(work_order)?; + } + ConversationVisibilityDecision::internal(format!( + "@host delegated to @{role}; show compact side-rail activity" + )) + } + ConversationVisibilityInput::RoleToHost { + role, + surfaced_by_host, + } => { + ensure_role(role)?; + if let Some(reason) = surfaced_by_host { + ConversationVisibilityDecision::public(format!( + "@host surfaced @{role} output for {}", + reason.label() + )) + } else { + ConversationVisibilityDecision::internal(format!( + "@{role} replied to @host internal delegation" + )) + } + } + ConversationVisibilityInput::RoleAddressedByUser { role } => { + ensure_role(role)?; + ConversationVisibilityDecision::public(format!( + "@{role} replied because the user addressed that role" + )) + } + ConversationVisibilityInput::SideRailSummary { source } => { + ensure_nonempty("sideRail.source", source)?; + ConversationVisibilityDecision { + visibility: ConversationVisibility::SideRail, + public_transcript: false, + side_rail_activity: true, + xray_available: true, + reason: format!("{source} is a compact side-rail summary"), + } + } + ConversationVisibilityInput::DebugLog { source } => { + ensure_nonempty("debugLog.source", source)?; + ConversationVisibilityDecision { + visibility: ConversationVisibility::DebugLog, + public_transcript: false, + side_rail_activity: false, + xray_available: true, + reason: format!("{source} belongs in debug/log/Xray"), + } + } + }; + Ok(decision) +} + +impl ConversationVisibilityDecision { + fn public(reason: String) -> Self { + Self { + visibility: ConversationVisibility::PublicTranscript, + public_transcript: true, + side_rail_activity: false, + xray_available: true, + reason, + } + } + + fn internal(reason: String) -> Self { + Self { + visibility: ConversationVisibility::InternalDelegation, + public_transcript: false, + side_rail_activity: true, + xray_available: true, + reason, + } + } +} + +fn ensure_role(value: &str) -> Result<()> { + ensure_nonempty("role", value)?; + if value.starts_with('@') { + bail!("role `{value}` must not include leading @"); + } + Ok(()) +} + +fn ensure_work_order_id(value: &str) -> Result<()> { + ensure_nonempty("workOrder", value)?; + if !value.starts_with("WO-") { + bail!("WorkOrder id `{value}` must start with `WO-`"); + } + Ok(()) +} + +fn ensure_nonempty(field: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + bail!("{field} cannot be empty"); + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index df3532c..b30bf21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub mod config_cmd; pub mod config_layered; pub mod console_snapshot; pub mod context_pack; +pub mod conversation_visibility; pub mod cost; pub mod crep; pub mod detect; diff --git a/tests/conversation_visibility_test.rs b/tests/conversation_visibility_test.rs new file mode 100644 index 0000000..fa1b93d --- /dev/null +++ b/tests/conversation_visibility_test.rs @@ -0,0 +1,106 @@ +//! Public transcript and internal delegation visibility fixtures. + +use coreroom::console_snapshot::ConversationVisibility; +use coreroom::conversation_visibility::{ + decide_visibility, ConversationVisibilityInput, HostSurfaceReason, +}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VisibilityFixture { + cases: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VisibilityCase { + name: String, + input: ConversationVisibilityInput, + expected_visibility: ConversationVisibility, + expected_public: bool, + expected_side_rail: bool, + expected_xray: bool, +} + +#[test] +fn visibility_fixture_proves_public_and_internal_routing() { + let fixture: VisibilityFixture = + toml::from_str(include_str!("fixtures/conversation_visibility.toml")).expect("fixture"); + + for case in fixture.cases { + let decision = decide_visibility(&case.input).expect("decision"); + assert_eq!( + decision.visibility, case.expected_visibility, + "{}", + case.name + ); + assert_eq!( + decision.public_transcript, case.expected_public, + "{}", + case.name + ); + assert_eq!( + decision.side_rail_activity, case.expected_side_rail, + "{}", + case.name + ); + assert_eq!(decision.xray_available, case.expected_xray, "{}", case.name); + assert!(!decision.reason.is_empty(), "{}", case.name); + } +} + +#[test] +fn host_internal_delegation_does_not_pollute_public_transcript() { + let decision = decide_visibility(&ConversationVisibilityInput::HostToRole { + role: "security".to_owned(), + work_order: Some("WO-0244".to_owned()), + }) + .expect("decision"); + + assert_eq!( + decision.visibility, + ConversationVisibility::InternalDelegation + ); + assert!(!decision.public_transcript); + assert!(decision.side_rail_activity); + assert!(decision.xray_available); +} + +#[test] +fn host_can_surface_critical_role_output_publicly() { + let decision = decide_visibility(&ConversationVisibilityInput::RoleToHost { + role: "security".to_owned(), + surfaced_by_host: Some(HostSurfaceReason::Veto), + }) + .expect("decision"); + + assert_eq!( + decision.visibility, + ConversationVisibility::PublicTranscript + ); + assert!(decision.public_transcript); + assert!(!decision.side_rail_activity); + assert!(decision.reason.contains("veto")); +} + +#[test] +fn console_snapshot_public_turns_reject_internal_delegation_pollution() { + let mut snapshot: coreroom::console_snapshot::CoreRoomSnapshot = + toml::from_str(include_str!("fixtures/console_snapshot_v08.toml")).expect("snapshot"); + snapshot + .conversation + .public_turns + .push(coreroom::console_snapshot::ConversationTurn { + speaker: "reviewer".to_owned(), + body: "Internal review detail should not appear in public turns.".to_owned(), + visibility: ConversationVisibility::InternalDelegation, + }); + + let err = snapshot + .validate() + .expect_err("internal public turn rejected"); + assert!(err + .to_string() + .contains("conversation.publicTurns cannot contain")); +} diff --git a/tests/fixtures/console_snapshot_v08.toml b/tests/fixtures/console_snapshot_v08.toml index cc4ef0e..7d490a9 100644 --- a/tests/fixtures/console_snapshot_v08.toml +++ b/tests/fixtures/console_snapshot_v08.toml @@ -56,6 +56,20 @@ lastActivity = "Waiting on source citation model" [conversation] internalDelegationCount = 3 +[[conversation.internalActivity]] +role = "reviewer" +workOrder = "WO-0242" +state = "reviewing" +summary = "Reviewing snapshot schema without entering public transcript." +xrayRef = "xray:thread-v08-console-fixture/reviewer" + +[[conversation.internalActivity]] +role = "security" +workOrder = "WO-0243" +state = "blocked" +summary = "Waiting on observation/citation model before final risk review." +xrayRef = "xray:thread-v08-console-fixture/security" + [[conversation.publicTurns]] speaker = "user" body = "设计 0.8/0.9 的 CoreRoom console,主对话必须清晰,其他角色只在需要时显式出现。" diff --git a/tests/fixtures/conversation_visibility.toml b/tests/fixtures/conversation_visibility.toml new file mode 100644 index 0000000..ae71c15 --- /dev/null +++ b/tests/fixtures/conversation_visibility.toml @@ -0,0 +1,98 @@ +[[cases]] +name = "user-default-public" +expectedVisibility = "public-transcript" +expectedPublic = true +expectedSideRail = false +expectedXray = true + +[cases.input] +kind = "user-message" + +[[cases]] +name = "host-default-public" +expectedVisibility = "public-transcript" +expectedPublic = true +expectedSideRail = false +expectedXray = true + +[cases.input] +kind = "host-response" + +[[cases]] +name = "user-addressed-role-public" +expectedVisibility = "public-transcript" +expectedPublic = true +expectedSideRail = false +expectedXray = true + +[cases.input] +kind = "user-message" +addressedRole = "security" + +[[cases]] +name = "role-addressed-by-user-public" +expectedVisibility = "public-transcript" +expectedPublic = true +expectedSideRail = false +expectedXray = true + +[cases.input] +kind = "role-addressed-by-user" +role = "security" + +[[cases]] +name = "host-to-role-internal" +expectedVisibility = "internal-delegation" +expectedPublic = false +expectedSideRail = true +expectedXray = true + +[cases.input] +kind = "host-to-role" +role = "reviewer" +workOrder = "WO-0244" + +[[cases]] +name = "role-to-host-internal" +expectedVisibility = "internal-delegation" +expectedPublic = false +expectedSideRail = true +expectedXray = true + +[cases.input] +kind = "role-to-host" +role = "reviewer" + +[[cases]] +name = "host-surfaces-critical-risk" +expectedVisibility = "public-transcript" +expectedPublic = true +expectedSideRail = false +expectedXray = true + +[cases.input] +kind = "role-to-host" +role = "security" +surfacedByHost = "critical-risk" + +[[cases]] +name = "side-rail-summary" +expectedVisibility = "side-rail" +expectedPublic = false +expectedSideRail = true +expectedXray = true + +[cases.input] +kind = "side-rail-summary" +source = "role-lanes" + +[[cases]] +name = "debug-log" +expectedVisibility = "debug-log" +expectedPublic = false +expectedSideRail = false +expectedXray = true + +[cases.input] +kind = "debug-log" +source = "crep-jsonl"