Skip to content
6 changes: 2 additions & 4 deletions src-tauri/src/acp/delegation/broker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -984,9 +984,8 @@ fn classify_locked(inner: &PendingInner, parent_connection_id: &str, task_id: &s
}

/// Map a terminal [`DelegationTaskReport`] back to a [`DelegationOutcome`] for
/// the test-only `handle_request` shim (so pre-async tests keep asserting on
/// the old outcome shape).
#[cfg(any(test, feature = "test-utils"))]
/// the `handle_request` entry point (so callers can await a single outcome
/// instead of driving the start/poll/collect shape by hand).
fn report_to_outcome(report: &DelegationTaskReport) -> DelegationOutcome {
use crate::acp::delegation::types::DelegationSuccess;
match report.status {
Expand Down Expand Up @@ -3573,7 +3572,6 @@ impl DelegationBroker {
/// the terminal report back to a `DelegationOutcome`. Keeps the broker's
/// extensive setup-window race tests exercising the same lifecycle without
/// each rewriting to the start/poll/collect shape.
#[cfg(any(test, feature = "test-utils"))]
pub async fn handle_request(&self, req: DelegationRequest) -> DelegationOutcome {
let parent_connection_id = req.parent_connection_id.clone();
let parent_conversation_id = Some(req.parent_conversation_id);
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub mod remote_proxy;
#[cfg(feature = "tauri-runtime")]
pub mod remote_workspace;
pub mod science;
pub mod semantic;
pub mod session_info;
pub mod system_settings;
pub mod terminal;
Expand Down
182 changes: 182 additions & 0 deletions src-tauri/src/commands/semantic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
//! `semantic_submit` — the Tauri command + Axum web handler that exposes
//! `run_semantic_core` (Task 4) to the frontend.
//!
//! Two surfaces live here, mirroring `crate::commands::chat_authoring` /
//! `crate::web::handlers::chat_authoring`:
//!
//! * [`semantic_submit_core`] — the transport-agnostic core. Builds a
//! `ConnectionSpawner` + `ConversationDepthLookup` and hands them to
//! `run_semantic_core`.
//! * [`semantic_submit`] — the `#[tauri::command]` wrapper (desktop only).
//! * [`semantic_submit_handler`] — the Axum `POST /semantic_submit` handler
//! (server mode), wired in `web::router`.

use std::path::PathBuf;
use std::sync::Arc;

use async_trait::async_trait;
use serde::Deserialize;

use crate::acp::delegation::broker::ConversationDepthLookup;
use crate::acp::delegation::spawner::ConnectionSpawner;
use crate::acp::manager::ConnectionManagerSpawner;
use crate::acp::delegation::types::DelegationError;
use crate::acp::manager::ConnectionManager;
use crate::db::AppDatabase;
use crate::semantic::broker::{run_semantic_core, SemanticRequest};
use crate::semantic::envelope::IntentEnvelope;

/// Concrete [`ConversationDepthLookup`] used by both the Tauri command and the
/// web handler. v1 has no conversation-tree semantics, so every id is its own
/// root (`parent_of` always returns `None`).
pub struct RootDepth;

#[async_trait]
impl ConversationDepthLookup for RootDepth {
async fn parent_of(&self, _id: i32) -> Result<Option<i32>, DelegationError> {
Ok(None)
}
}

/// Transport-agnostic core. Builds the delegation broker inside
/// [`run_semantic_core`] from the supplied spawner + depth and returns the
/// fully-populated [`IntentEnvelope`].
pub async fn semantic_submit_core(
spawner: Arc<dyn ConnectionSpawner>,
depth: Arc<dyn ConversationDepthLookup>,
req: SemanticRequest,
) -> Result<IntentEnvelope, String> {
Ok(run_semantic_core(spawner, depth, req).await)
}

// ===========================================================================
// Tauri command (desktop)
// ===========================================================================

#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn semantic_submit(
#[cfg(feature = "tauri-runtime")] manager: tauri::State<'_, ConnectionManager>,
#[cfg(feature = "tauri-runtime")] db: tauri::State<'_, AppDatabase>,
req: SemanticRequest,
) -> Result<IntentEnvelope, String> {
#[cfg(feature = "tauri-runtime")]
{
// `AppState` is not handed to commands as a single managed value, so we
// reassemble the production `ConnectionSpawner` from the managed
// `ConnectionManager` + `AppDatabase` + effective data dir (set as the
// `CODEG_DATA_DIR` env var at bootstrap).
let data_dir = Arc::new(PathBuf::from(
std::env::var("CODEG_DATA_DIR").unwrap_or_default(),
));
let spawner = Arc::new(ConnectionManagerSpawner {
manager: Arc::new(manager.inner().clone_ref()),
db: Arc::new(AppDatabase {
conn: db.inner().conn.clone(),
}),
data_dir,
}) as Arc<dyn ConnectionSpawner>;
semantic_submit_core(spawner, Arc::new(RootDepth), req).await
}
#[cfg(not(feature = "tauri-runtime"))]
{
let _ = req;
Err("semantic_submit is only available under the tauri runtime".into())
}
}

// ===========================================================================
// Web handler (server mode)
// ===========================================================================

#[derive(Deserialize)]
pub struct SemanticSubmitParams {
pub req: SemanticRequest,
}

pub use self::web_handler::semantic_submit_handler;

mod web_handler {
use super::*;
use axum::{extract::Extension, Json};
use crate::app_error::AppCommandError;
use crate::app_state::AppState;

pub async fn semantic_submit_handler(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<SemanticSubmitParams>,
) -> Result<Json<IntentEnvelope>, AppCommandError> {
let spawner = Arc::new(ConnectionManagerSpawner {
manager: Arc::new(state.connection_manager.clone_ref()),
db: Arc::new(AppDatabase {
conn: state.db.conn.clone(),
}),
data_dir: Arc::new(state.data_dir.clone()),
}) as Arc<dyn ConnectionSpawner>;
let out = semantic_submit_core(spawner, Arc::new(RootDepth), params.req)
.await
.map_err(AppCommandError::configuration_invalid)?;
Ok(Json(out))
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::acp::delegation::spawner::mock::MockSpawner;
use crate::acp::delegation::spawner::SpawnerError;
use crate::models::agent::AgentType;
use crate::semantic::envelope::{AcceptState, Op};

/// Drive `semantic_submit_core` through the operator-failure path: a
/// `MockSpawner` whose queued spawn errors out. `run_semantic_core` (called
/// inside the core) returns a `Denied` envelope immediately — no pending
/// delegation is ever parked, so the test does not hang and exercises the
/// real spawner → broker → error-surface path.
#[tokio::test]
async fn submit_returns_denied_envelope_on_spawn_failure() {
let mock = Arc::new(MockSpawner::new());
mock.queue_spawn(Err(SpawnerError::Spawn("boom".into()))).await;

let spawner = mock as Arc<dyn ConnectionSpawner>;
let depth = Arc::new(RootDepth) as Arc<dyn ConversationDepthLookup>;

let req = SemanticRequest {
intent: "list files".into(),
why: "see layout".into(),
ops: vec![Op {
tool: "shell".into(),
params: serde_json::json!({"cmd":"ls"}),
}],
working_dir: Some("/tmp".into()),
agent_type: AgentType::ClaudeCode,
};

let out = semantic_submit_core(spawner, depth, req).await.unwrap();
assert!(matches!(out.accept, AcceptState::Accepted | AcceptState::Denied));
assert_eq!(out.accept, AcceptState::Denied);
assert!(out.result.as_ref().unwrap().contains("boom"));
}

/// A well-formed request still flows through the core and yields a typed
/// envelope (the `Denied` here is the spawn-error path again, but the
/// point is the request deserializes and the core returns a real struct).
#[tokio::test]
async fn submit_returns_envelope_for_valid_request() {
let mock = Arc::new(MockSpawner::new());
mock.queue_spawn(Err(SpawnerError::Send("no child".into()))).await;

let spawner = mock as Arc<dyn ConnectionSpawner>;
let depth = Arc::new(RootDepth) as Arc<dyn ConversationDepthLookup>;

let req = SemanticRequest {
intent: "summarize".into(),
why: "catch up".into(),
ops: vec![],
working_dir: None,
agent_type: AgentType::OpenCode,
};

let out = semantic_submit_core(spawner, depth, req).await.unwrap();
assert!(matches!(out.accept, AcceptState::Accepted | AcceptState::Denied));
}
}
12 changes: 12 additions & 0 deletions src-tauri/src/db/entities/folder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ pub enum FolderKind {
Regular,
#[sea_orm(string_value = "chat")]
Chat,
#[sea_orm(string_value = "semantic")]
Semantic,
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn semantic_is_a_variant() {
let k = FolderKind::Semantic;
assert_eq!(serde_json::to_string(&k).unwrap(), "\"semantic\"");
}
}

#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub mod pets;
#[cfg(feature = "tauri-runtime")]
pub mod preferences;
pub mod process;
pub mod semantic;
pub mod supervise;
mod terminal;
pub mod turn_timings;
Expand Down
73 changes: 73 additions & 0 deletions src-tauri/src/semantic/aggregate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use crate::semantic::envelope::{AcceptState, IntentEnvelope};
use std::collections::BTreeMap;

/// Group envelopes that share an `intent` into a single envelope: ops are
/// concatenated, results joined with a separator. Raw is joined the same way
/// but kept for storage only (never shown by the UI).
pub fn aggregate(envelopes: Vec<IntentEnvelope>) -> Vec<IntentEnvelope> {
if envelopes.len() <= 1 {
return envelopes;
}
let mut groups: BTreeMap<String, IntentEnvelope> = BTreeMap::new();
for e in envelopes {
let entry = groups.entry(e.intent.clone()).or_insert(IntentEnvelope {
intent: e.intent.clone(),
why: e.why.clone(),
ops: vec![],
accept: AcceptState::Accepted,
result: Some(String::new()),
raw: Some(String::new()),
});
entry.ops.extend(e.ops);
if let Some(r) = e.result {
let cur = entry.result.as_mut().unwrap();
if !cur.is_empty() {
cur.push_str(" | ");
}
cur.push_str(&r);
}
if let Some(raw) = e.raw {
let cur = entry.raw.as_mut().unwrap();
if !cur.is_empty() {
cur.push_str("\n---\n");
}
cur.push_str(&raw);
}
}
groups.into_values().collect()
}

#[cfg(test)]
mod tests {
use super::*;
use crate::semantic::envelope::{AcceptState, IntentEnvelope, Op};

fn env(intent: &str, op_tool: &str, result: &str) -> IntentEnvelope {
IntentEnvelope {
intent: intent.into(),
why: String::new(),
ops: vec![Op {
tool: op_tool.into(),
params: serde_json::json!({}),
}],
accept: AcceptState::Accepted,
result: Some(result.into()),
raw: Some(format!("raw-{result}")),
}
}

#[test]
fn parallel_ops_same_intent_merge_to_one() {
let out = aggregate(vec![
env("build", "shell", "compiled a"),
env("build", "shell", "compiled b"),
env("test", "shell", "ran t"),
]);
// two distinct intents -> two envelopes
assert_eq!(out.len(), 2);
let build = out.iter().find(|e| e.intent == "build").unwrap();
assert_eq!(build.ops.len(), 2);
assert!(build.result.as_ref().unwrap().contains("compiled a"));
assert!(build.result.as_ref().unwrap().contains("compiled b"));
}
}
Loading
Loading