Lifei/delete tauri backend acp (#8582)

Co-authored-by: Jack Amadeo <jackamadeo@squareup.com>
This commit is contained in:
Lifei Zhou
2026-04-17 02:46:37 +10:00
committed by GitHub
parent 67b90205ed
commit e7a91c7d4f
48 changed files with 289 additions and 12972 deletions
+15 -3
View File
@@ -5,10 +5,12 @@ on:
branches: [main]
paths:
- "ui/goose2/**"
- "ui/sdk/**"
pull_request:
branches: [main]
paths:
- "ui/goose2/**"
- "ui/sdk/**"
merge_group:
branches: [main]
workflow_dispatch:
@@ -35,8 +37,11 @@ jobs:
with:
node-version: 24
cache: pnpm
cache-dependency-path: ui/goose2/pnpm-lock.yaml
cache-dependency-path: ui/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- name: Build SDK
run: pnpm build
working-directory: ui/sdk
- run: pnpm check
- run: pnpm typecheck
@@ -53,8 +58,11 @@ jobs:
with:
node-version: 24
cache: pnpm
cache-dependency-path: ui/goose2/pnpm-lock.yaml
cache-dependency-path: ui/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- name: Build SDK
run: pnpm build
working-directory: ui/sdk
- run: pnpm test
desktop:
@@ -70,7 +78,7 @@ jobs:
with:
node-version: 24
cache: pnpm
cache-dependency-path: ui/goose2/pnpm-lock.yaml
cache-dependency-path: ui/pnpm-lock.yaml
- name: Install system dependencies
run: |
@@ -96,6 +104,10 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Build SDK
run: pnpm build
working-directory: ui/sdk
- name: Build frontend
run: pnpm build
+2 -3
View File
@@ -134,9 +134,8 @@ ThemeProvider manages three axes:
## Backend Architecture
All AI communication goes through **ACP (Agent Client Protocol)**:
- The Rust backend spawns ACP agent binaries as child processes and communicates via **stdin/stdout JSON-RPC**.
- Responses stream back to the frontend through **Tauri events** (`acp:text`, `acp:tool_call`, `acp:tool_result`, `acp:done`, etc.).
- The frontend listens to these events via `@tauri-apps/api/event` (see `useAcpStream` hook).
- The Tauri app starts a long-lived `goose serve` process and exposes its WebSocket URL.
- The frontend connects directly to `goose serve` over WebSocket and handles ACP notifications in TypeScript.
For non AI communication, such as configuration:
- Use **Tauri commands** (`invoke()` from `@tauri-apps/api/core`) for request/response operations (sessions, personas, skills, projects, etc.).
+2 -1
View File
@@ -8,9 +8,10 @@ default:
# ── Dev Environment ──────────────────────────────────────────
# Install dependencies
# Install dependencies and build workspace packages
setup:
pnpm install
cd ../sdk && pnpm build
cd src-tauri && cargo build
# ── Build & Check ────────────────────────────────────────────
+2 -2
View File
@@ -27,7 +27,7 @@
"test-driver": "tsx tests/app-e2e/lib/test-driver-cli.ts"
},
"dependencies": {
"@aaif/goose-acp": "^0.16.0",
"@aaif/goose-sdk": "workspace:*",
"@agentclientprotocol/sdk": "^0.19.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -65,7 +65,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-dialog": "~2.6.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@xyflow/react": "^12.10.2",
"ai": "^6.0.142",
-7615
View File
File diff suppressed because it is too large Load Diff
+2 -27
View File
@@ -26,19 +26,9 @@ const EXCEPTIONS = {
"Search-as-you-type filtering and draft-aware sidebar highlight logic.",
},
"src/app/AppShell.tsx": {
limit: 640,
limit: 650,
justification:
"Shell still coordinates ACP session loading, project reassignment, and app-level chat routing.",
},
"src/features/chat/hooks/useAcpStream.ts": {
limit: 580,
justification:
"ACP replay, streaming, session binding, model-state event handling, and replay timeout are still centralized here.",
},
"src/features/chat/hooks/__tests__/useAcpStream.test.ts": {
limit: 570,
justification:
"Covers replay buffering, timeout error state, streaming edge cases, and provider identity persistence in one cohesive suite.",
"Shell still coordinates ACP session loading, replay-buffer cleanup on load failure, project reassignment, and app-level chat routing.",
},
"src/features/chat/stores/__tests__/chatSessionStore.test.ts": {
limit: 540,
@@ -55,21 +45,6 @@ const EXCEPTIONS = {
justification:
"Project CRUD plus reorder_projects command for sidebar drag-and-drop ordering.",
},
"src-tauri/src/services/acp/manager/dispatcher.rs": {
limit: 540,
justification:
"ACP replay and live-stream event fan-out share one dispatcher with replay event counting for drain stabilisation.",
},
"src-tauri/src/services/acp/manager.rs": {
limit: 630,
justification:
"ACP manager command dispatch loop — export/import/fork session ext_method dispatch adds boilerplate.",
},
"src-tauri/src/services/acp/manager/session_ops.rs": {
limit: 620,
justification:
"Session prepare/load/list logic, working-dir updates, wait_for_replay_drain helper with iteration cap, and composite prepared-session reuse remain colocated while ACP session ownership stabilizes.",
},
"src-tauri/src/commands/system.rs": {
limit: 640,
justification:
+2 -178
View File
@@ -2,23 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "acp-client"
version = "0.1.0"
source = "git+https://github.com/block/builderbot?rev=db184d20cb48e0c90bbd3fea4a4a871fc9d8a6ad#db184d20cb48e0c90bbd3fea4a4a871fc9d8a6ad"
dependencies = [
"agent-client-protocol",
"anyhow",
"async-trait",
"blox-cli",
"log",
"nix",
"serde",
"serde_json",
"tokio",
"tokio-util",
]
[[package]]
name = "adler2"
version = "2.0.1"
@@ -36,37 +19,6 @@ dependencies = [
"cpufeatures",
]
[[package]]
name = "agent-client-protocol"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10eeef5e80864f9c3c148a3f395c3e35a66d37ec7561c7845b2bffae8e841759"
dependencies = [
"agent-client-protocol-schema",
"anyhow",
"async-broadcast",
"async-trait",
"derive_more 2.1.1",
"futures",
"log",
"serde",
"serde_json",
]
[[package]]
name = "agent-client-protocol-schema"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca68e7e55681ce56546c0cecc6bc8f20493d24b44c6d93ec46174f310730bba2"
dependencies = [
"anyhow",
"derive_more 2.1.1",
"schemars 1.2.1",
"serde",
"serde_json",
"strum",
]
[[package]]
name = "ahash"
version = "0.7.8"
@@ -400,18 +352,6 @@ dependencies = [
"piper",
]
[[package]]
name = "blox-cli"
version = "0.1.0"
source = "git+https://github.com/block/builderbot?rev=db184d20cb48e0c90bbd3fea4a4a871fc9d8a6ad#db184d20cb48e0c90bbd3fea4a4a871fc9d8a6ad"
dependencies = [
"log",
"serde",
"serde_json",
"thiserror 2.0.18",
"wait-timeout",
]
[[package]]
name = "borsh"
version = "1.6.1"
@@ -702,15 +642,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
@@ -917,12 +848,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "dbus"
version = "0.9.10"
@@ -968,7 +893,7 @@ version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"convert_case 0.4.0",
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
@@ -990,12 +915,10 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case 0.10.0",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.117",
"unicode-xid",
]
[[package]]
@@ -1372,21 +1295,6 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -1394,7 +1302,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1462,7 +1369,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1751,15 +1657,11 @@ dependencies = [
name = "goose2"
version = "0.1.0"
dependencies = [
"acp-client",
"agent-client-protocol",
"async-trait",
"base64 0.22.1",
"chrono",
"dirs",
"doctor",
"etcetera",
"futures",
"ignore",
"keyring",
"log",
@@ -1776,8 +1678,6 @@ dependencies = [
"tauri-plugin-window-state",
"tempfile",
"tokio",
"tokio-tungstenite",
"tokio-util",
"uuid",
]
@@ -3826,7 +3726,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"indexmap 1.9.3",
"schemars_derive 0.8.22",
"schemars_derive",
"serde",
"serde_json",
"url",
@@ -3853,7 +3753,6 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"dyn-clone",
"ref-cast",
"schemars_derive 1.2.1",
"serde",
"serde_json",
]
@@ -3870,18 +3769,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "schemars_derive"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.117",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -4375,27 +4262,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -5034,18 +4900,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -5054,9 +4908,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-io",
"futures-sink",
"futures-util",
"pin-project-lite",
"tokio",
]
@@ -5270,25 +5122,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"url",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -5476,15 +5309,6 @@ dependencies = [
"libc",
]
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"
-6
View File
@@ -32,12 +32,6 @@ uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
serde_yaml = "0.9"
etcetera = "0.8"
futures = "0.3"
tokio-util = { version = "0.7", features = ["compat", "rt"] }
async-trait = "0.1"
agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork"] }
tokio-tungstenite = "0.21.0"
acp-client = { git = "https://github.com/block/builderbot", rev = "db184d20cb48e0c90bbd3fea4a4a871fc9d8a6ad" }
doctor = { git = "https://github.com/block/builderbot", rev = "8e1c3ec145edc0df5f04b4427cfd758378036862" }
ignore = "0.4.25"
base64 = "0.22"
+1 -330
View File
@@ -1,84 +1,4 @@
use serde::Serialize;
use std::path::PathBuf;
use std::sync::Arc;
use tauri::{AppHandle, State};
use crate::services::acp::{
make_composite_key, search_sessions_via_exports, AcpRunningSession, AcpService, AcpSessionInfo,
AcpSessionRegistry, GooseAcpManager, GooseServeProcess, SessionSearchResult,
};
const DEPRECATED_PROVIDER_IDS: &[&str] = &["claude-code", "codex", "gemini-cli"];
/// Response type for an ACP provider, sent to the frontend.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpProviderResponse {
id: String,
label: String,
}
fn should_include_provider(provider_id: &str) -> bool {
!DEPRECATED_PROVIDER_IDS.contains(&provider_id)
}
fn default_artifacts_working_dir() -> PathBuf {
if let Some(home_dir) = dirs::home_dir() {
return home_dir.join(".goose").join("artifacts");
}
PathBuf::from("/tmp").join(".goose").join("artifacts")
}
fn expand_home_dir(path: PathBuf) -> PathBuf {
let path_string = path.to_string_lossy();
if path_string == "~" {
return dirs::home_dir().unwrap_or(path);
}
if let Some(stripped) = path_string
.strip_prefix("~/")
.or_else(|| path_string.strip_prefix("~\\"))
{
if let Some(home_dir) = dirs::home_dir() {
return home_dir.join(stripped);
}
}
path
}
fn resolve_working_dir(
working_dir: Option<String>,
current_dir: &std::path::Path,
) -> Result<PathBuf, String> {
let working_dir = working_dir
.map(|dir| dir.trim().to_string())
.filter(|dir| !dir.is_empty())
.map(PathBuf::from)
.unwrap_or_else(default_artifacts_working_dir);
let working_dir = expand_home_dir(working_dir);
let working_dir = if working_dir.is_relative() {
current_dir.join(&working_dir)
} else {
working_dir
};
std::fs::create_dir_all(&working_dir).map_err(|error| {
format!(
"Failed to create working directory '{}': {error}",
working_dir.display()
)
})?;
std::fs::canonicalize(&working_dir).map_err(|error| {
format!(
"Failed to resolve working directory '{}': {error}",
working_dir.display()
)
})
}
use crate::services::acp::GooseServeProcess;
#[tauri::command]
pub async fn get_goose_serve_url() -> Result<String, String> {
@@ -86,252 +6,3 @@ pub async fn get_goose_serve_url() -> Result<String, String> {
let process = GooseServeProcess::get()?;
Ok(process.ws_url())
}
/// Return the list of providers available through goose serve.
#[tauri::command]
pub async fn discover_acp_providers(
app_handle: AppHandle,
) -> Result<Vec<AcpProviderResponse>, String> {
let manager = GooseAcpManager::start(app_handle).await?;
let providers = manager.list_providers().await?;
Ok(providers
.into_iter()
.filter(|provider| should_include_provider(&provider.id))
.map(|provider| AcpProviderResponse {
id: provider.id,
label: provider.label,
})
.collect())
}
/// Send a prompt to an ACP agent and stream the response via Tauri events.
///
/// The actual content arrives asynchronously through `acp:text`, `acp:tool_call`,
/// `acp:tool_result`, and `acp:done` events.
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn acp_send_message(
app_handle: AppHandle,
registry: State<'_, Arc<AcpSessionRegistry>>,
session_id: String,
provider_id: String,
prompt: String,
system_prompt: Option<String>,
working_dir: Option<String>,
persona_id: Option<String>,
persona_name: Option<String>,
images: Vec<(String, String)>,
) -> Result<(), String> {
let current_dir = std::env::current_dir()
.map_err(|error| format!("Failed to determine current working directory: {error}"))?;
let working_dir = resolve_working_dir(working_dir, &current_dir)?;
AcpService::send_prompt(
app_handle,
Arc::clone(&registry),
session_id,
provider_id,
prompt,
working_dir,
system_prompt,
persona_id,
persona_name,
images,
)
.await
}
#[tauri::command]
pub async fn acp_prepare_session(
app_handle: AppHandle,
session_id: String,
provider_id: String,
working_dir: Option<String>,
persona_id: Option<String>,
) -> Result<(), String> {
let current_dir = std::env::current_dir()
.map_err(|error| format!("Failed to determine current working directory: {error}"))?;
let working_dir = resolve_working_dir(working_dir, &current_dir)?;
AcpService::prepare_session(app_handle, session_id, provider_id, working_dir, persona_id).await
}
#[tauri::command]
pub async fn acp_set_model(
app_handle: AppHandle,
session_id: String,
model_id: String,
) -> Result<(), String> {
let manager = GooseAcpManager::start(app_handle).await?;
manager.set_model(session_id, model_id).await
}
/// List all sessions known to the goose binary.
#[tauri::command]
pub async fn acp_list_sessions(app_handle: AppHandle) -> Result<Vec<AcpSessionInfo>, String> {
let manager = GooseAcpManager::start(app_handle).await?;
manager.list_sessions().await
}
/// Search session message content via exported Goose sessions.
#[tauri::command]
pub async fn acp_search_sessions(
app_handle: AppHandle,
query: String,
session_ids: Vec<String>,
) -> Result<Vec<SessionSearchResult>, String> {
let manager = GooseAcpManager::start(app_handle).await?;
search_sessions_via_exports(&manager, &query, &session_ids).await
}
/// Load an existing session, replaying its messages as Tauri events.
///
/// The goose binary sends `SessionNotification` events for each message in
/// the session history. The frontend's `useAcpStream` hook picks these up
/// the same way it handles live streaming.
#[tauri::command]
pub async fn acp_load_session(
app_handle: AppHandle,
session_id: String,
goose_session_id: String,
working_dir: Option<String>,
) -> Result<(), String> {
let current_dir = std::env::current_dir()
.map_err(|error| format!("Failed to determine current working directory: {error}"))?;
let working_dir = resolve_working_dir(working_dir, &current_dir)?;
let manager = GooseAcpManager::start(app_handle).await?;
manager
.load_session(session_id, goose_session_id, working_dir)
.await
}
#[cfg(test)]
mod tests {
use super::{expand_home_dir, resolve_working_dir, should_include_provider};
use std::path::PathBuf;
#[test]
fn resolve_working_dir_returns_absolute_path_for_existing_relative_directory() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let nested_dir = temp_dir.path().join("nested");
std::fs::create_dir(&nested_dir).expect("create nested dir");
let resolved =
resolve_working_dir(Some("nested".to_string()), temp_dir.path()).expect("resolve path");
assert!(resolved.is_absolute());
assert_eq!(
resolved,
std::fs::canonicalize(&nested_dir).expect("canonical nested dir")
);
}
#[test]
fn resolve_working_dir_creates_missing_directory() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let missing_dir = temp_dir.path().join("missing");
let resolved = resolve_working_dir(Some("missing".to_string()), temp_dir.path())
.expect("resolve path");
assert!(missing_dir.exists());
assert_eq!(
resolved,
std::fs::canonicalize(&missing_dir).expect("canonical missing dir")
);
}
#[test]
fn expand_home_dir_replaces_leading_tilde() {
let home_dir = dirs::home_dir().expect("home dir");
assert_eq!(expand_home_dir(PathBuf::from("~")), home_dir);
assert_eq!(
expand_home_dir(PathBuf::from("~/Code/goose2")),
home_dir.join("Code/goose2")
);
}
#[test]
fn provider_discovery_hides_deprecated_cli_providers() {
assert!(!should_include_provider("claude-code"));
assert!(!should_include_provider("codex"));
assert!(!should_include_provider("gemini-cli"));
assert!(should_include_provider("goose"));
assert!(should_include_provider("claude-acp"));
assert!(should_include_provider("codex-acp"));
}
}
/// Cancel a running ACP session.
///
/// When `persona_id` is provided the composite key `{session_id}__{persona_id}`
/// is used so only that persona's stream is cancelled.
#[tauri::command]
pub async fn acp_cancel_session(
app_handle: AppHandle,
registry: State<'_, Arc<AcpSessionRegistry>>,
session_id: String,
persona_id: Option<String>,
) -> Result<bool, String> {
let key = make_composite_key(&session_id, persona_id.as_deref());
let _assistant_message_id = registry.cancel(&key);
let manager = GooseAcpManager::start(app_handle).await?;
let was_cancelled = manager.cancel_session(key).await?;
Ok(was_cancelled)
}
/// List all currently running ACP sessions.
#[tauri::command]
pub async fn acp_list_running(
registry: State<'_, Arc<AcpSessionRegistry>>,
) -> Result<Vec<AcpRunningSession>, String> {
Ok(registry.list_running())
}
/// Cancel all running ACP sessions (used during shutdown).
#[tauri::command]
pub async fn acp_cancel_all(registry: State<'_, Arc<AcpSessionRegistry>>) -> Result<(), String> {
registry.cancel_all();
Ok(())
}
/// Export a session as JSON via the goose binary.
///
/// The goose binary reads the session from its database and returns
/// an OG-goose-compatible JSON string with a `conversation` field.
#[tauri::command]
pub async fn acp_export_session(
app_handle: AppHandle,
session_id: String,
) -> Result<String, String> {
let manager = GooseAcpManager::start(app_handle).await?;
manager.export_session(session_id).await
}
/// Import a session from JSON via the goose binary.
///
/// The goose binary parses the JSON (detecting OG vs v1 format),
/// creates a new session, populates messages, and returns metadata.
#[tauri::command]
pub async fn acp_import_session(
app_handle: AppHandle,
json: String,
) -> Result<AcpSessionInfo, String> {
let manager = GooseAcpManager::start(app_handle).await?;
manager.import_session(json).await
}
/// Duplicate (fork) a session via the goose binary.
///
/// The goose binary reads the source session, creates a new one
/// with the same messages, and returns the new session's metadata.
#[tauri::command]
pub async fn acp_duplicate_session(
app_handle: AppHandle,
session_id: String,
) -> Result<AcpSessionInfo, String> {
let manager = GooseAcpManager::start(app_handle).await?;
manager.fork_session(session_id).await
}
+2 -26
View File
@@ -2,18 +2,12 @@ mod commands;
mod services;
mod types;
use std::sync::Arc;
use services::acp::AcpSessionRegistry;
use services::goose_config::GooseConfig;
use services::personas::PersonaStore;
use tauri_plugin_window_state::StateFlags;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let acp_registry = Arc::new(AcpSessionRegistry::new());
let acp_registry_for_exit = Arc::clone(&acp_registry);
let builder = tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
@@ -31,8 +25,7 @@ pub fn run() {
.build(),
)
.manage(PersonaStore::new())
.manage(GooseConfig::new())
.manage(acp_registry);
.manage(GooseConfig::new());
#[cfg(feature = "app-test-driver")]
let builder = builder.plugin(tauri_plugin_app_test_driver::init());
@@ -50,19 +43,6 @@ pub fn run() {
commands::agents::save_persona_avatar_bytes,
commands::agents::get_avatars_dir,
commands::acp::get_goose_serve_url,
commands::acp::discover_acp_providers,
commands::acp::acp_prepare_session,
commands::acp::acp_set_model,
commands::acp::acp_send_message,
commands::acp::acp_cancel_session,
commands::acp::acp_list_sessions,
commands::acp::acp_search_sessions,
commands::acp::acp_load_session,
commands::acp::acp_list_running,
commands::acp::acp_cancel_all,
commands::acp::acp_export_session,
commands::acp::acp_import_session,
commands::acp::acp_duplicate_session,
commands::skills::create_skill,
commands::skills::list_skills,
commands::skills::delete_skill,
@@ -114,9 +94,5 @@ pub fn run() {
.setup(|_app| Ok(()))
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(move |_app, event| {
if let tauri::RunEvent::Exit = event {
acp_registry_for_exit.cancel_all();
}
});
.run(|_app, _event| {});
}
@@ -1,16 +1,18 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use futures::{SinkExt, StreamExt};
use tokio::process::{Child, Command};
use tokio::sync::OnceCell;
use tokio_tungstenite::connect_async;
const GOOSE_SERVE_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const GOOSE_SERVE_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(100);
const LOCALHOST: &str = "127.0.0.1";
pub(crate) const WS_BRIDGE_BUFFER_BYTES: usize = 64 * 1024;
const COMMON_GOOSE_PATHS: &[&str] = &[
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/home/linuxbrew/.linuxbrew/bin",
];
// ---------------------------------------------------------------------------
// GooseServeProcess — singleton that owns the long-lived `goose serve` child
// ---------------------------------------------------------------------------
@@ -97,9 +99,7 @@ impl GooseServeProcess {
)
})?;
// Wait for the server to become ready by polling the WebSocket endpoint.
let ws_url = format!("ws://{LOCALHOST}:{port}/acp");
wait_for_server_ready(&ws_url, &mut child).await?;
wait_for_server_ready(port, &mut child).await?;
log::info!("Goose serve is ready on port {port}");
@@ -110,22 +110,14 @@ impl GooseServeProcess {
}
}
/// Wait for the goose serve process to accept WebSocket connections.
///
/// We do a connect-then-immediately-close loop until the server responds,
/// the child exits, or we time out.
async fn wait_for_server_ready(ws_url: &str, child: &mut Child) -> Result<(), String> {
async fn wait_for_server_ready(port: u16, child: &mut Child) -> Result<(), String> {
let deadline = Instant::now() + GOOSE_SERVE_CONNECT_TIMEOUT;
let addr = format!("{LOCALHOST}:{port}");
loop {
match connect_async(ws_url).await {
Ok((ws_stream, _)) => {
// Server is up — close the probe connection.
let (mut writer, _) = ws_stream.split();
let _ = writer.close().await;
return Ok(());
}
Err(connect_error) => {
match tokio::net::TcpStream::connect(&addr).await {
Ok(_) => return Ok(()),
Err(_) => {
if let Some(status) = child
.try_wait()
.map_err(|e| format!("Failed to poll goose serve process: {e}"))?
@@ -136,9 +128,7 @@ async fn wait_for_server_ready(ws_url: &str, child: &mut Child) -> Result<(), St
}
if Instant::now() >= deadline {
return Err(format!(
"Timed out waiting for goose serve at {ws_url}: {connect_error}"
));
return Err(format!("Timed out waiting for goose serve on port {port}"));
}
tokio::time::sleep(GOOSE_SERVE_CONNECT_RETRY_DELAY).await;
@@ -175,21 +165,21 @@ pub(crate) fn resolve_goose_binary() -> Result<PathBuf, String> {
log::info!("Using GOOSE_BIN override: {override_path}");
path
} else {
let agent = acp_client::find_acp_agent_by_id("goose")
let path = find_goose_binary()
.ok_or_else(|| "Unknown or unavailable agent provider: goose".to_string())?;
if !goose_binary_supports_serve(&agent.binary_path)? {
if !goose_binary_supports_serve(&path)? {
return Err(format!(
"Resolved goose binary does not support `serve`: {}. Set GOOSE_BIN to a newer goose binary.",
agent.binary_path.display()
path.display()
));
}
log::info!(
"Resolved goose binary via login-shell discovery: {}",
agent.binary_path.display()
"Resolved goose binary via local discovery: {}",
path.display()
);
agent.binary_path
path
};
// Log the binary version for debugging.
@@ -217,6 +207,62 @@ pub(crate) fn resolve_goose_binary() -> Result<PathBuf, String> {
Ok(binary_path)
}
fn find_goose_binary() -> Option<PathBuf> {
find_goose_via_login_shell().or_else(find_goose_in_common_paths)
}
fn find_goose_via_login_shell() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
None
}
#[cfg(not(target_os = "windows"))]
{
for shell in ["/bin/zsh", "/bin/bash"] {
let Ok(output) = std::process::Command::new(shell)
.args(["-l", "-c", "which goose"])
.output()
else {
continue;
};
if !output.status.success() {
continue;
}
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(path_str) = stdout.lines().rfind(|line| !line.trim().is_empty()) {
let path = PathBuf::from(path_str.trim());
if path.is_file() {
return Some(path);
}
}
}
None
}
}
fn find_goose_in_common_paths() -> Option<PathBuf> {
COMMON_GOOSE_PATHS
.iter()
.map(|dir| Path::new(dir).join(goose_binary_name()))
.find(|path| path.is_file())
}
fn goose_binary_name() -> &'static str {
#[cfg(target_os = "windows")]
{
"goose.exe"
}
#[cfg(not(target_os = "windows"))]
{
"goose"
}
}
fn goose_binary_supports_serve(binary_path: &PathBuf) -> Result<bool, String> {
let output = std::process::Command::new(binary_path)
.env("GOOSE_PATH_ROOT", goose_probe_root())
@@ -1,308 +0,0 @@
mod command_dispatch;
mod dispatcher;
mod session_ops;
mod thread;
use std::path::PathBuf;
use std::sync::Arc;
use acp_client::MessageWriter;
use agent_client_protocol::{Agent, ClientSideConnection, ExtRequest};
use serde::Deserialize;
use serde_json::value::RawValue;
use tokio::sync::{mpsc, oneshot, OnceCell};
pub use session_ops::AcpSessionInfo;
enum ManagerCommand {
ListProviders {
response: oneshot::Sender<Result<Vec<GooseAcpProvider>, String>>,
},
ListSessions {
response: oneshot::Sender<Result<Vec<AcpSessionInfo>, String>>,
},
LoadSession {
local_session_id: String,
goose_session_id: String,
working_dir: PathBuf,
response: oneshot::Sender<Result<(), String>>,
},
PrepareSession {
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
response: oneshot::Sender<Result<(), String>>,
},
SendPrompt {
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
writer: Arc<dyn MessageWriter>,
prompt: String,
images: Vec<(String, String)>,
response: oneshot::Sender<Result<(), String>>,
},
CancelSession {
composite_key: String,
response: oneshot::Sender<Result<bool, String>>,
},
ExportSession {
session_id: String,
response: oneshot::Sender<Result<String, String>>,
},
ImportSession {
json: String,
response: oneshot::Sender<Result<AcpSessionInfo, String>>,
},
ForkSession {
session_id: String,
response: oneshot::Sender<Result<AcpSessionInfo, String>>,
},
SetModel {
local_session_id: String,
model_id: String,
response: oneshot::Sender<Result<(), String>>,
},
}
pub struct GooseAcpManager {
command_tx: mpsc::UnboundedSender<ManagerCommand>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GooseAcpProvider {
pub id: String,
pub label: String,
}
#[derive(Debug, Deserialize)]
struct GooseProvidersResponse {
providers: Vec<GooseAcpProvider>,
}
static GOOSE_ACP_MANAGER: OnceCell<Arc<GooseAcpManager>> = OnceCell::const_new();
impl GooseAcpManager {
pub async fn start(app_handle: tauri::AppHandle) -> Result<Arc<Self>, String> {
GOOSE_ACP_MANAGER
.get_or_try_init(|| async move {
let (command_tx, command_rx) = mpsc::unbounded_channel();
let manager = Arc::new(Self { command_tx });
let (ready_tx, ready_rx) = oneshot::channel();
std::thread::Builder::new()
.name("goose-acp-manager".to_string())
.spawn(move || run_manager_thread(app_handle, command_rx, ready_tx))
.map_err(|error| {
format!("Failed to spawn Goose ACP manager thread: {error}")
})?;
ready_rx
.await
.map_err(|_| "Goose ACP manager failed before initialization".to_string())??;
Ok(manager)
})
.await
.map(Arc::clone)
}
pub async fn list_sessions(&self) -> Result<Vec<AcpSessionInfo>, String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::ListSessions {
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped list sessions request".to_string())?
}
pub async fn load_session(
&self,
local_session_id: String,
goose_session_id: String,
working_dir: PathBuf,
) -> Result<(), String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::LoadSession {
local_session_id,
goose_session_id,
working_dir,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped load session request".to_string())?
}
pub async fn prepare_session(
&self,
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
) -> Result<(), String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::PrepareSession {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped prepare request".to_string())?
}
pub async fn list_providers(&self) -> Result<Vec<GooseAcpProvider>, String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::ListProviders {
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped provider discovery request".to_string())?
}
#[allow(clippy::too_many_arguments)]
pub async fn send_prompt(
&self,
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
writer: Arc<dyn MessageWriter>,
prompt: String,
images: Vec<(String, String)>,
) -> Result<(), String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::SendPrompt {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
writer,
prompt,
images,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped prompt request".to_string())?
}
pub async fn cancel_session(&self, composite_key: String) -> Result<bool, String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::CancelSession {
composite_key,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped cancel request".to_string())?
}
pub async fn set_model(
&self,
local_session_id: String,
model_id: String,
) -> Result<(), String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::SetModel {
local_session_id,
model_id,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped set model request".to_string())?
}
/// Export a session as JSON via the goose binary.
pub async fn export_session(&self, session_id: String) -> Result<String, String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::ExportSession {
session_id,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped export session request".to_string())?
}
/// Import a session from JSON via the goose binary.
pub async fn import_session(&self, json: String) -> Result<AcpSessionInfo, String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::ImportSession {
json,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped import session request".to_string())?
}
/// Fork (duplicate) a session via the goose binary.
pub async fn fork_session(&self, session_id: String) -> Result<AcpSessionInfo, String> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(ManagerCommand::ForkSession {
session_id,
response: response_tx,
})
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
response_rx
.await
.map_err(|_| "Goose ACP manager dropped fork session request".to_string())?
}
}
/// Call a goose ext_method and return the raw JSON response string.
async fn call_ext_method(
connection: &Arc<ClientSideConnection>,
method: &str,
params_json: serde_json::Value,
) -> Result<String, String> {
let params = RawValue::from_string(params_json.to_string())
.map_err(|e| format!("Failed to build {method} request: {e}"))?;
let resp = connection
.ext_method(ExtRequest::new(method, params.into()))
.await
.map_err(|e| format!("{method} failed via Goose ACP: {e:?}"))?;
Ok(resp.0.get().to_string())
}
fn run_manager_thread(
app_handle: tauri::AppHandle,
command_rx: mpsc::UnboundedReceiver<ManagerCommand>,
ready_tx: oneshot::Sender<Result<(), String>>,
) {
thread::run_manager_thread(app_handle, command_rx, ready_tx);
}
@@ -1,258 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use agent_client_protocol::{Agent, ClientSideConnection, ExtRequest, ForkSessionRequest};
use serde_json::value::RawValue;
use tokio::sync::{mpsc, Mutex};
use super::dispatcher::SessionEventDispatcher;
use super::session_ops::{
cancel_session_inner, list_sessions_inner, load_session_inner, prepare_session_inner,
send_prompt_inner, set_model_inner, AcpSessionInfo, ManagerState, PrepareSessionInput,
};
use super::{call_ext_method, GooseProvidersResponse, ManagerCommand};
pub(super) async fn dispatch_commands(
mut command_rx: mpsc::UnboundedReceiver<ManagerCommand>,
connection: Arc<ClientSideConnection>,
dispatcher: Arc<SessionEventDispatcher>,
) {
let state = Arc::new(Mutex::new(ManagerState {
sessions: HashMap::new(),
op_locks: HashMap::new(),
pending_cancels: HashSet::new(),
preparing_sessions: HashSet::new(),
}));
while let Some(command) = command_rx.recv().await {
match command {
ManagerCommand::ListProviders { response } => {
let connection = Arc::clone(&connection);
tokio::task::spawn_local(async move {
let result = async {
let params = RawValue::from_string("{}".to_string()).map_err(|error| {
format!("Failed to build ACP request body: {error}")
})?;
let response_value = connection
.ext_method(ExtRequest::new("goose/providers/list", params.into()))
.await
.map_err(|error| {
format!("Failed to list providers via Goose ACP: {error:?}")
})?;
let parsed: GooseProvidersResponse =
serde_json::from_str(response_value.0.get()).map_err(|error| {
format!("Failed to decode Goose provider list: {error}")
})?;
Ok::<_, String>(parsed.providers)
}
.await;
let _ = response.send(result);
});
}
ManagerCommand::ListSessions { response } => {
let connection = Arc::clone(&connection);
tokio::task::spawn_local(async move {
let result = list_sessions_inner(&connection).await;
let _ = response.send(result);
});
}
ManagerCommand::LoadSession {
local_session_id,
goose_session_id,
working_dir,
response,
} => {
let connection = Arc::clone(&connection);
let dispatcher = dispatcher.clone();
let state = Arc::clone(&state);
tokio::task::spawn_local(async move {
let result = load_session_inner(
&connection,
&dispatcher,
&state,
&local_session_id,
&goose_session_id,
working_dir,
)
.await;
let _ = response.send(result);
});
}
ManagerCommand::PrepareSession {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
response,
} => {
let connection = Arc::clone(&connection);
let dispatcher = dispatcher.clone();
let state = Arc::clone(&state);
tokio::task::spawn_local(async move {
let result = prepare_session_inner(
&connection,
&dispatcher,
&state,
PrepareSessionInput {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
},
)
.await
.map(|_| ());
let _ = response.send(result);
});
}
ManagerCommand::SendPrompt {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
writer,
prompt,
images,
response,
} => {
let connection = Arc::clone(&connection);
let dispatcher = dispatcher.clone();
let state = Arc::clone(&state);
tokio::task::spawn_local(async move {
let result = send_prompt_inner(
&connection,
&dispatcher,
&state,
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
writer,
prompt,
images,
)
.await;
let _ = response.send(result);
});
}
ManagerCommand::CancelSession {
composite_key,
response,
} => {
let connection = Arc::clone(&connection);
let dispatcher = dispatcher.clone();
let state = Arc::clone(&state);
tokio::task::spawn_local(async move {
let result =
cancel_session_inner(&connection, &dispatcher, &state, &composite_key)
.await;
let _ = response.send(result);
});
}
ManagerCommand::ExportSession {
session_id,
response,
} => {
let connection = Arc::clone(&connection);
tokio::task::spawn_local(async move {
let result = async {
let raw = call_ext_method(
&connection,
"goose/session/export",
serde_json::json!({ "sessionId": session_id }),
)
.await?;
// Backend returns { "data": "<json string>" }
let resp: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("Failed to decode export response: {e}"))?;
resp.get("data")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "Export response missing 'data' field".to_string())
}
.await;
let _ = response.send(result);
});
}
ManagerCommand::ImportSession { json, response } => {
let connection = Arc::clone(&connection);
tokio::task::spawn_local(async move {
let result = async {
let raw = call_ext_method(
&connection,
"goose/session/import",
serde_json::json!({ "data": json }),
)
.await?;
serde_json::from_str(&raw)
.map_err(|e| format!("Failed to decode import response: {e}"))
}
.await;
let _ = response.send(result);
});
}
ManagerCommand::ForkSession {
session_id,
response,
} => {
let connection = Arc::clone(&connection);
tokio::task::spawn_local(async move {
let result = async {
let req = ForkSessionRequest::new(
session_id,
std::env::current_dir().unwrap_or_default(),
);
let resp = connection
.fork_session(req)
.await
.map_err(|e| format!("session/fork failed: {e:?}"))?;
let message_count = resp
.meta
.as_ref()
.and_then(|m| m.get("messageCount"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let title = resp
.meta
.as_ref()
.and_then(|m| m.get("title"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(AcpSessionInfo {
session_id: resp.session_id.to_string(),
title,
updated_at: None,
message_count,
})
}
.await;
let _ = response.send(result);
});
}
ManagerCommand::SetModel {
local_session_id,
model_id,
response,
} => {
let connection = Arc::clone(&connection);
let dispatcher = dispatcher.clone();
let state = Arc::clone(&state);
tokio::task::spawn_local(async move {
let result = set_model_inner(
&connection,
&dispatcher,
&state,
&local_session_id,
&model_id,
)
.await;
let _ = response.send(result);
});
}
}
}
}
@@ -1,532 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use acp_client::MessageWriter;
use agent_client_protocol::{
Client, ContentBlock as AcpContentBlock, PermissionOptionId, RequestPermissionOutcome,
RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome,
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory,
SessionConfigSelectOptions, SessionInfoUpdate, SessionModelState, SessionNotification,
SessionUpdate,
};
use async_trait::async_trait;
use tauri::Emitter;
use tokio::sync::Mutex;
use crate::services::acp::payloads::{
model_options_from_state, DonePayload, MessageCreatedPayload, ModelOption, ModelStatePayload,
ReplayCompletePayload, SessionBoundPayload, SessionInfoPayload, TextPayload, ToolCallPayload,
ToolResultPayload, ToolTitlePayload,
};
fn extract_user_message(raw: &str) -> &str {
const OPEN: &str = "<user-message>\n";
const CLOSE: &str = "\n</user-message>";
if let Some(start) = raw.find(OPEN) {
let inner = start + OPEN.len();
if raw[inner..].ends_with(CLOSE) {
return &raw[inner..raw.len() - CLOSE.len()];
}
}
raw
}
fn model_options_from_select_options(options: &SessionConfigSelectOptions) -> Vec<ModelOption> {
match options {
SessionConfigSelectOptions::Ungrouped(values) => values
.iter()
.map(|value| ModelOption {
id: value.value.to_string(),
name: value.name.clone(),
})
.collect(),
SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.flat_map(|group| group.options.iter())
.map(|value| ModelOption {
id: value.value.to_string(),
name: value.name.clone(),
})
.collect(),
_ => Vec::new(),
}
}
#[derive(Clone)]
pub(super) struct SessionRoute {
pub(super) local_session_id: String,
pub(super) provider_id: Option<String>,
pub(super) writer: Option<Arc<dyn MessageWriter>>,
pub(super) canceled: bool,
pub(super) replay_message_id: Option<String>,
pub(super) replay_events: u32,
}
pub(super) struct SessionEventDispatcher {
app_handle: tauri::AppHandle,
routes: Arc<Mutex<HashMap<String, SessionRoute>>>,
}
impl SessionEventDispatcher {
pub(super) fn new(
app_handle: tauri::AppHandle,
routes: Arc<Mutex<HashMap<String, SessionRoute>>>,
) -> Self {
Self { app_handle, routes }
}
pub(super) async fn bind_session(
&self,
goose_session_id: &str,
local_session_id: &str,
provider_id: Option<&str>,
) {
let mut routes = self.routes.lock().await;
routes
.entry(goose_session_id.to_string())
.and_modify(|route| {
route.local_session_id = local_session_id.to_string();
if let Some(pid) = provider_id {
route.provider_id = Some(pid.to_string());
}
})
.or_insert_with(|| SessionRoute {
local_session_id: local_session_id.to_string(),
provider_id: provider_id.map(ToString::to_string),
writer: None,
canceled: false,
replay_message_id: None,
replay_events: 0,
});
let _ = self.app_handle.emit(
"acp:session_bound",
SessionBoundPayload {
session_id: local_session_id.to_string(),
goose_session_id: goose_session_id.to_string(),
},
);
}
pub(super) async fn attach_writer(
&self,
goose_session_id: &str,
local_session_id: &str,
provider_id: Option<&str>,
writer: Arc<dyn MessageWriter>,
) {
let mut routes = self.routes.lock().await;
routes.insert(
goose_session_id.to_string(),
SessionRoute {
local_session_id: local_session_id.to_string(),
provider_id: provider_id.map(ToString::to_string),
writer: Some(writer),
canceled: false,
replay_message_id: None,
replay_events: 0,
},
);
}
pub(super) async fn clear_writer(&self, goose_session_id: &str) {
let mut routes = self.routes.lock().await;
if let Some(route) = routes.get_mut(goose_session_id) {
route.writer = None;
route.canceled = false;
}
}
pub(super) async fn mark_canceled(&self, goose_session_id: &str) -> bool {
let mut routes = self.routes.lock().await;
if let Some(route) = routes.get_mut(goose_session_id) {
let had_writer = route.writer.is_some();
route.writer = None;
route.canceled = had_writer;
return had_writer;
}
false
}
pub(super) async fn is_canceled(&self, goose_session_id: &str) -> bool {
let routes = self.routes.lock().await;
routes
.get(goose_session_id)
.is_some_and(|route| route.canceled)
}
/// Finalize any in-progress replay message and clear the replay state.
/// Called after `load_session` completes to mark the last replayed
/// assistant message as done.
pub(super) async fn finalize_replay(&self, goose_session_id: &str) {
let mut routes = self.routes.lock().await;
if let Some(route) = routes.get_mut(goose_session_id) {
if let Some(message_id) = route.replay_message_id.take() {
let _ = self.app_handle.emit(
"acp:done",
DonePayload {
session_id: route.local_session_id.clone(),
message_id,
},
);
}
}
}
pub(super) fn emit_session_info(&self, local_session_id: &str, info: &SessionInfoUpdate) {
let _ = self.app_handle.emit(
"acp:session_info",
SessionInfoPayload {
session_id: local_session_id.to_string(),
title: info.title.value().cloned(),
},
);
}
pub(super) fn emit_model_state(
&self,
local_session_id: &str,
provider_id: Option<&str>,
state: &SessionModelState,
) {
let current_model_name = state
.available_models
.iter()
.find(|model| model.model_id == state.current_model_id)
.map(|model| model.name.clone());
let available_models = model_options_from_state(state);
let _ = self.app_handle.emit(
"acp:model_state",
ModelStatePayload {
session_id: local_session_id.to_string(),
provider_id: provider_id.map(ToString::to_string),
current_model_id: state.current_model_id.to_string(),
current_model_name,
available_models,
},
);
}
pub(super) fn emit_model_state_from_options(
&self,
local_session_id: &str,
provider_id: Option<&str>,
options: &[SessionConfigOption],
) {
let Some(option) = options
.iter()
.find(|option| option.category == Some(SessionConfigOptionCategory::Model))
else {
return;
};
let SessionConfigKind::Select(select) = &option.kind else {
return;
};
let current_model_id = select.current_value.to_string();
let available_models = model_options_from_select_options(&select.options);
let current_model_name = match &select.options {
SessionConfigSelectOptions::Ungrouped(values) => values
.iter()
.find(|value| value.value == select.current_value)
.map(|value| value.name.clone()),
SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.flat_map(|group| group.options.iter())
.find(|value| value.value == select.current_value)
.map(|value| value.name.clone()),
_ => None,
};
let _ = self.app_handle.emit(
"acp:model_state",
ModelStatePayload {
session_id: local_session_id.to_string(),
provider_id: provider_id.map(ToString::to_string),
current_model_id,
current_model_name,
available_models,
},
);
}
pub(super) fn emit_replay_complete(&self, local_session_id: &str) {
let _ = self.app_handle.emit(
"acp:replay_complete",
ReplayCompletePayload {
session_id: local_session_id.to_string(),
},
);
}
pub(super) async fn get_replay_event_count(&self, goose_session_id: &str) -> u32 {
let routes = self.routes.lock().await;
routes
.get(goose_session_id)
.map(|r| r.replay_events)
.unwrap_or(0)
}
}
fn extract_content_preview(content: &[agent_client_protocol::ToolCallContent]) -> Option<String> {
for item in content {
match item {
agent_client_protocol::ToolCallContent::Content(content_item) => {
if let AcpContentBlock::Text(text) = &content_item.content {
let preview: String = text.text.chars().take(500).collect();
return Some(if text.text.len() > 500 {
format!("{preview}")
} else {
preview
});
}
}
agent_client_protocol::ToolCallContent::Diff(diff) => {
return Some(format!(
"{}{}",
diff.path.display(),
if diff.old_text.is_some() {
" (modified)"
} else {
" (new)"
}
));
}
agent_client_protocol::ToolCallContent::Terminal(terminal) => {
return Some(format!("Terminal: {}", terminal.terminal_id.0));
}
_ => {}
}
}
None
}
#[async_trait(?Send)]
impl Client for SessionEventDispatcher {
async fn request_permission(
&self,
args: RequestPermissionRequest,
) -> agent_client_protocol::Result<RequestPermissionResponse> {
let option_id = args
.options
.first()
.map(|option| option.option_id.clone())
.unwrap_or_else(|| PermissionOptionId::new("approve"));
Ok(RequestPermissionResponse::new(
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)),
))
}
async fn session_notification(
&self,
notification: SessionNotification,
) -> agent_client_protocol::Result<()> {
let goose_session_id = notification.session_id.to_string();
let route = {
let routes = self.routes.lock().await;
routes.get(&goose_session_id).cloned()
};
let Some(route) = route else {
return Ok(());
};
match &notification.update {
SessionUpdate::SessionInfoUpdate(info) => {
self.emit_session_info(&route.local_session_id, info);
return Ok(());
}
SessionUpdate::ConfigOptionUpdate(update) => {
self.emit_model_state_from_options(
&route.local_session_id,
route.provider_id.as_deref(),
&update.config_options,
);
return Ok(());
}
_ => {}
}
// Live streaming path — writer is present
if let Some(writer) = route.writer {
match &notification.update {
SessionUpdate::AgentMessageChunk(chunk) => {
if let AcpContentBlock::Text(text) = &chunk.content {
writer.append_text(&text.text).await;
}
}
SessionUpdate::ToolCall(tool_call) => {
writer
.record_tool_call(
&tool_call.tool_call_id.0,
&tool_call.title,
tool_call.raw_input.as_ref(),
)
.await;
}
SessionUpdate::ToolCallUpdate(update) => {
if update.fields.title.is_some() || update.fields.raw_input.is_some() {
writer
.update_tool_call_title(
&update.tool_call_id.0,
update.fields.title.as_deref(),
update.fields.raw_input.as_ref(),
)
.await;
}
if let Some(content) = &update.fields.content {
if let Some(result) = extract_content_preview(content) {
writer.record_tool_result(&result).await;
}
}
}
_ => {}
}
return Ok(());
}
// Replay path — no writer, emit Tauri events directly.
// This handles messages replayed by load_session.
let local_session_id = route.local_session_id.clone();
match &notification.update {
SessionUpdate::UserMessageChunk(chunk) => {
if let AcpContentBlock::Text(text) = &chunk.content {
// Finalize any in-progress assistant message and count replay event
{
let mut routes = self.routes.lock().await;
if let Some(route) = routes.get_mut(&goose_session_id) {
if let Some(prev_msg_id) = route.replay_message_id.take() {
let _ = self.app_handle.emit(
"acp:done",
DonePayload {
session_id: local_session_id.clone(),
message_id: prev_msg_id,
},
);
}
route.replay_events += 1;
}
}
let display_text = extract_user_message(&text.text);
let message_id = uuid::Uuid::new_v4().to_string();
let _ = self.app_handle.emit(
"acp:replay_user_message",
serde_json::json!({
"sessionId": local_session_id,
"messageId": message_id,
"text": display_text,
}),
);
}
}
SessionUpdate::AgentMessageChunk(chunk) => {
if let AcpContentBlock::Text(text) = &chunk.content {
// Check if we already have a replay message in progress
let replay_msg_id = {
let routes = self.routes.lock().await;
routes
.get(&goose_session_id)
.and_then(|r| r.replay_message_id.clone())
};
let message_id = if let Some(id) = replay_msg_id {
id
} else {
// Start a new assistant message
let new_id = uuid::Uuid::new_v4().to_string();
let _ = self.app_handle.emit(
"acp:message_created",
MessageCreatedPayload {
session_id: local_session_id.clone(),
message_id: new_id.clone(),
persona_id: None,
persona_name: None,
},
);
let mut routes = self.routes.lock().await;
if let Some(route) = routes.get_mut(&goose_session_id) {
route.replay_message_id = Some(new_id.clone());
route.replay_events += 1;
}
new_id
};
let _ = self.app_handle.emit(
"acp:text",
TextPayload {
session_id: local_session_id,
message_id: message_id.clone(),
text: text.text.clone(),
},
);
}
}
SessionUpdate::ToolCall(tool_call) => {
let replay_msg_id = {
let routes = self.routes.lock().await;
routes
.get(&goose_session_id)
.and_then(|r| r.replay_message_id.clone())
};
if let Some(message_id) = replay_msg_id {
let _ = self.app_handle.emit(
"acp:tool_call",
ToolCallPayload {
session_id: local_session_id,
message_id,
tool_call_id: tool_call.tool_call_id.0.to_string(),
title: tool_call.title.clone(),
},
);
}
}
SessionUpdate::ToolCallUpdate(update) => {
let replay_msg_id = {
let routes = self.routes.lock().await;
routes
.get(&goose_session_id)
.and_then(|r| r.replay_message_id.clone())
};
if let Some(message_id) = replay_msg_id {
if let Some(title) = &update.fields.title {
let _ = self.app_handle.emit(
"acp:tool_title",
ToolTitlePayload {
session_id: local_session_id.clone(),
message_id: message_id.clone(),
tool_call_id: update.tool_call_id.0.to_string(),
title: title.clone(),
},
);
}
if let Some(content) = &update.fields.content {
// During replay, always emit a tool_result so the
// frontend can mark the tool request as completed.
// Fall back to a generic summary when the content
// type has no preview extractor.
let result =
extract_content_preview(content).unwrap_or_else(|| "Done".to_string());
let _ = self.app_handle.emit(
"acp:tool_result",
ToolResultPayload {
session_id: local_session_id,
message_id,
content: result,
},
);
}
}
}
_ => {}
}
Ok(())
}
}
#[cfg(test)]
#[path = "dispatcher_tests.rs"]
mod tests;
@@ -1,28 +0,0 @@
use super::*;
#[test]
fn extract_user_message_strips_xml_wrapper() {
let wrapped = "<persona-instructions>\nYou are a helpful assistant.\n</persona-instructions>\n\n<user-message>\nhello\n</user-message>";
assert_eq!(extract_user_message(wrapped), "hello");
}
#[test]
fn extract_user_message_multiline() {
let wrapped = "<persona-instructions>\nstuff\n</persona-instructions>\n\n<user-message>\nline one\nline two\n</user-message>";
assert_eq!(extract_user_message(wrapped), "line one\nline two");
}
#[test]
fn extract_user_message_no_wrapper() {
assert_eq!(extract_user_message("plain text"), "plain text");
}
#[test]
fn extract_user_message_preserves_inner_delimiter() {
// User literally typed "</user-message>" in their message — must not truncate.
let wrapped = "<persona-instructions>\nstuff\n</persona-instructions>\n\n<user-message>\ncheck this tag: </user-message> cool right?\n</user-message>";
assert_eq!(
extract_user_message(wrapped),
"check this tag: </user-message> cool right?"
);
}
@@ -1,611 +0,0 @@
mod prompt_ops;
#[cfg(test)]
mod tests;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use agent_client_protocol::{
Agent, CancelNotification, ClientSideConnection, ExtRequest, ListSessionsRequest,
LoadSessionRequest, NewSessionRequest, SessionConfigKind, SessionConfigOption,
SessionConfigSelectOptions, SetSessionConfigOptionRequest,
};
use serde_json::value::RawValue;
use tokio::sync::Mutex;
use super::dispatcher::SessionEventDispatcher;
use crate::services::acp::split_composite_key;
pub(super) use prompt_ops::send_prompt_inner;
/// Lightweight session metadata returned by `list_sessions`.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpSessionInfo {
pub session_id: String,
pub title: Option<String>,
pub updated_at: Option<String>,
pub message_count: usize,
}
#[derive(Clone)]
pub(super) struct PreparedSession {
goose_session_id: String,
provider_id: String,
working_dir: PathBuf,
}
pub(super) struct ManagerState {
pub(super) sessions: HashMap<String, PreparedSession>,
pub(super) op_locks: HashMap<String, Arc<Mutex<()>>>,
pub(super) pending_cancels: HashSet<String>,
pub(super) preparing_sessions: HashSet<String>,
}
impl ManagerState {
pub(super) fn session_lock(&mut self, composite_key: &str) -> Arc<Mutex<()>> {
self.op_locks
.entry(composite_key.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
pub(super) fn mark_cancel_requested(&mut self, composite_key: &str) {
self.pending_cancels.insert(composite_key.to_string());
}
pub(super) fn take_cancel_requested(&mut self, composite_key: &str) -> bool {
self.pending_cancels.remove(composite_key)
}
}
fn extract_current_select_value(
options: &[SessionConfigOption],
option_id: &str,
) -> Option<String> {
let option = options
.iter()
.find(|candidate| candidate.id.0.as_ref() == option_id)?;
let SessionConfigKind::Select(select) = &option.kind else {
return None;
};
let current_value = select.current_value.to_string();
match &select.options {
SessionConfigSelectOptions::Ungrouped(values) => values
.iter()
.any(|value| value.value == select.current_value)
.then_some(current_value),
SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.flat_map(|group| group.options.iter())
.any(|value| value.value == select.current_value)
.then_some(current_value),
_ => Some(current_value),
}
}
fn needs_provider_update(current_provider_id: Option<&str>, requested_provider_id: &str) -> bool {
current_provider_id != Some(requested_provider_id)
}
fn prepared_session_for_key(
sessions: &HashMap<String, PreparedSession>,
composite_key: &str,
local_session_id: &str,
) -> Option<PreparedSession> {
sessions
.get(composite_key)
.cloned()
.or_else(|| sessions.get(local_session_id).cloned())
}
fn register_prepared_session_keys(
sessions: &mut HashMap<String, PreparedSession>,
composite_key: &str,
local_session_id: &str,
prepared: PreparedSession,
) {
sessions.insert(composite_key.to_string(), prepared.clone());
sessions.insert(local_session_id.to_string(), prepared);
}
async fn update_working_dir_inner(
connection: &Arc<ClientSideConnection>,
goose_session_id: &str,
working_dir: &PathBuf,
) -> Result<(), String> {
let params = RawValue::from_string(
serde_json::json!({
"sessionId": goose_session_id,
"workingDir": working_dir,
})
.to_string(),
)
.map_err(|error| format!("Failed to build working dir update request: {error}"))?;
connection
.ext_method(ExtRequest::new("goose/working_dir/update", params.into()))
.await
.map_err(|error| format!("Failed to update Goose ACP working directory: {error:?}"))?;
Ok(())
}
pub(super) struct PrepareSessionInput {
pub(super) composite_key: String,
pub(super) local_session_id: String,
pub(super) provider_id: String,
pub(super) working_dir: PathBuf,
pub(super) existing_agent_session_id: Option<String>,
}
async fn try_load_existing_session(
connection: &Arc<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
local_session_id: &str,
candidate_session_id: &str,
provider_id: &str,
working_dir: &PathBuf,
) -> Result<Option<String>, String> {
let response = match connection
.load_session(LoadSessionRequest::new(
candidate_session_id.to_string(),
working_dir.clone(),
))
.await
{
Ok(response) => response,
Err(_) => return Ok(None),
};
dispatcher
.bind_session(candidate_session_id, local_session_id, Some(provider_id))
.await;
if let Some(models) = &response.models {
dispatcher.emit_model_state(local_session_id, Some(provider_id), models);
}
if let Some(options) = &response.config_options {
dispatcher.emit_model_state_from_options(local_session_id, Some(provider_id), options);
}
update_working_dir_inner(connection, candidate_session_id, working_dir).await?;
let loaded_provider_id = response
.config_options
.as_deref()
.and_then(|options| extract_current_select_value(options, "provider"));
if needs_provider_update(loaded_provider_id.as_deref(), provider_id) {
let response = connection
.set_session_config_option(SetSessionConfigOptionRequest::new(
candidate_session_id.to_string(),
"provider",
provider_id,
))
.await
.map_err(|error| format!("Failed to update provider via Goose ACP: {error:?}"))?;
dispatcher.emit_model_state_from_options(
local_session_id,
Some(provider_id),
&response.config_options,
);
}
Ok(Some(candidate_session_id.to_string()))
}
pub(super) async fn prepare_session_inner(
connection: &Arc<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
state: &Arc<Mutex<ManagerState>>,
input: PrepareSessionInput,
) -> Result<String, String> {
let PrepareSessionInput {
composite_key,
local_session_id,
provider_id,
working_dir,
existing_agent_session_id,
} = input;
let session_lock = {
let mut guard = state.lock().await;
guard.session_lock(&composite_key)
};
let _lock_guard = session_lock.lock().await;
{
let mut guard = state.lock().await;
guard.preparing_sessions.insert(composite_key.clone());
}
let prepare_result: Result<(String, Option<PreparedSession>), String> = async {
let existing_prepared = {
let guard = state.lock().await;
prepared_session_for_key(&guard.sessions, &composite_key, &local_session_id)
};
if let Some(prepared) = existing_prepared {
dispatcher
.bind_session(
&prepared.goose_session_id,
&local_session_id,
Some(&provider_id),
)
.await;
{
let mut guard = state.lock().await;
register_prepared_session_keys(
&mut guard.sessions,
&composite_key,
&local_session_id,
prepared.clone(),
);
}
if prepared.working_dir != working_dir {
update_working_dir_inner(connection, &prepared.goose_session_id, &working_dir)
.await?;
let mut guard = state.lock().await;
register_prepared_session_keys(
&mut guard.sessions,
&composite_key,
&local_session_id,
PreparedSession {
goose_session_id: prepared.goose_session_id.clone(),
provider_id: prepared.provider_id.clone(),
working_dir: working_dir.clone(),
},
);
}
if needs_provider_update(Some(&prepared.provider_id), &provider_id) {
let response = connection
.set_session_config_option(SetSessionConfigOptionRequest::new(
prepared.goose_session_id.clone(),
"provider",
provider_id.as_str(),
))
.await
.map_err(|error| {
format!("Failed to update provider via Goose ACP: {error:?}")
})?;
dispatcher.emit_model_state_from_options(
&local_session_id,
Some(&provider_id),
&response.config_options,
);
let mut guard = state.lock().await;
let updated_working_dir = guard
.sessions
.get(&composite_key)
.map(|session| session.working_dir.clone())
.unwrap_or_else(|| prepared.working_dir.clone());
let updated = PreparedSession {
goose_session_id: prepared.goose_session_id.clone(),
provider_id: provider_id.clone(),
working_dir: updated_working_dir,
};
register_prepared_session_keys(
&mut guard.sessions,
&composite_key,
&local_session_id,
updated,
);
}
return Ok((prepared.goose_session_id, None));
}
let goose_session_id = if let Some(existing_id) = existing_agent_session_id {
try_load_existing_session(
connection,
dispatcher,
&local_session_id,
&existing_id,
&provider_id,
&working_dir,
)
.await?
.ok_or_else(|| format!("Failed to load Goose session '{existing_id}'"))?
} else if let Some(existing_id) = try_load_existing_session(
connection,
dispatcher,
&local_session_id,
&local_session_id,
&provider_id,
&working_dir,
)
.await?
{
existing_id
} else {
let mut request = NewSessionRequest::new(working_dir.clone());
if provider_id != "goose" {
let mut meta = serde_json::Map::new();
meta.insert(
"provider".into(),
serde_json::Value::String(provider_id.clone()),
);
request = request.meta(meta);
}
let response = connection
.new_session(request)
.await
.map_err(|error| format!("Failed to create Goose session: {error:?}"))?;
let new_id = response.session_id.to_string();
dispatcher
.bind_session(&new_id, &local_session_id, Some(&provider_id))
.await;
if let Some(models) = &response.models {
dispatcher.emit_model_state(&local_session_id, Some(&provider_id), models);
}
if let Some(options) = &response.config_options {
dispatcher.emit_model_state_from_options(
&local_session_id,
Some(&provider_id),
options,
);
}
new_id
};
Ok((
goose_session_id.clone(),
Some(PreparedSession {
goose_session_id,
provider_id,
working_dir,
}),
))
}
.await;
{
let mut guard = state.lock().await;
guard.preparing_sessions.remove(&composite_key);
}
let (goose_session_id, prepared_session) = prepare_result?;
if let Some(prepared_session) = prepared_session {
let mut guard = state.lock().await;
guard.sessions.insert(composite_key, prepared_session);
}
Ok(goose_session_id)
}
/// List all sessions known to the goose binary.
pub(super) async fn list_sessions_inner(
connection: &Arc<ClientSideConnection>,
) -> Result<Vec<AcpSessionInfo>, String> {
let response = connection
.list_sessions(ListSessionsRequest::default())
.await
.map_err(|error| format!("Failed to list sessions via Goose ACP: {error:?}"))?;
Ok(response
.sessions
.into_iter()
.map(|info| {
let message_count = info
.meta
.as_ref()
.and_then(|m| m.get("messageCount"))
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
AcpSessionInfo {
session_id: info.session_id.to_string(),
title: info.title,
updated_at: info.updated_at,
message_count,
}
})
.collect())
}
/// Load an existing session from the goose binary.
///
/// This binds the goose session ID to the local session ID in the dispatcher
/// so that replayed `SessionNotification` events are routed to the correct
/// frontend session. It also registers the session in the manager state so
/// that subsequent `send_prompt` calls can reuse the goose session.
pub(super) async fn load_session_inner(
connection: &Arc<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
state: &Arc<Mutex<ManagerState>>,
local_session_id: &str,
goose_session_id: &str,
working_dir: PathBuf,
) -> Result<(), String> {
dispatcher
.bind_session(goose_session_id, local_session_id, None)
.await;
let response = connection
.load_session(LoadSessionRequest::new(
goose_session_id.to_string(),
working_dir.clone(),
))
.await
.map_err(|error| format!("Failed to load Goose session: {error:?}"))?;
// The ACP RPC layer resolves responses synchronously but dispatches
// notifications asynchronously via spawned tasks. After load_session
// returns, replay notifications may still be queued. Yield repeatedly
// to let the single-threaded runtime drain them before counting.
wait_for_replay_drain(|| async { dispatcher.get_replay_event_count(goose_session_id).await })
.await;
// Finalize any in-progress replay assistant message
dispatcher.finalize_replay(goose_session_id).await;
dispatcher.emit_replay_complete(local_session_id);
if let Some(models) = &response.models {
dispatcher.emit_model_state(local_session_id, None, models);
}
if let Some(options) = &response.config_options {
dispatcher.emit_model_state_from_options(local_session_id, None, options);
}
// Register the session so future prompts reuse this goose session
let mut guard = state.lock().await;
guard.sessions.insert(
local_session_id.to_string(),
PreparedSession {
goose_session_id: goose_session_id.to_string(),
provider_id: "goose".to_string(), // will be updated on next prepare
working_dir,
},
);
Ok(())
}
pub(super) async fn cancel_session_inner(
connection: &Arc<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
state: &Arc<Mutex<ManagerState>>,
composite_key: &str,
) -> Result<bool, String> {
let (goose_session_id, is_preparing) = {
let mut guard = state.lock().await;
let goose_session_id = guard
.sessions
.get(composite_key)
.map(|session| session.goose_session_id.clone());
let is_preparing = guard.preparing_sessions.contains(composite_key);
if goose_session_id.is_some() || is_preparing {
guard.mark_cancel_requested(composite_key);
}
(goose_session_id, is_preparing)
};
let Some(goose_session_id) = goose_session_id else {
return Ok(is_preparing);
};
let had_writer = dispatcher.mark_canceled(&goose_session_id).await;
if !had_writer {
return Ok(true);
}
connection
.cancel(CancelNotification::new(goose_session_id))
.await
.map_err(|error| format!("Failed to cancel Goose ACP session: {error:?}"))?;
Ok(true)
}
pub(super) async fn set_model_inner(
connection: &Arc<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
state: &Arc<Mutex<ManagerState>>,
local_session_id: &str,
model_id: &str,
) -> Result<(), String> {
let prepared_sessions = {
let guard = state.lock().await;
guard
.sessions
.iter()
.filter_map(|(composite_key, session)| {
let (session_id, _) = split_composite_key(composite_key);
(session_id == local_session_id).then_some((
composite_key.clone(),
session.goose_session_id.clone(),
session.provider_id.clone(),
))
})
.collect::<Vec<_>>()
};
if prepared_sessions.is_empty() {
return Err(format!(
"Failed to update model for session '{local_session_id}': no prepared ACP session"
));
}
let mut updated_goose_sessions = HashSet::new();
for (composite_key, goose_session_id, provider_id) in prepared_sessions {
if !updated_goose_sessions.insert(goose_session_id.clone()) {
continue;
}
let session_lock = {
let mut guard = state.lock().await;
guard.session_lock(&composite_key)
};
let _lock_guard = session_lock.lock().await;
let response = connection
.set_session_config_option(SetSessionConfigOptionRequest::new(
goose_session_id,
"model",
model_id,
))
.await
.map_err(|error| format!("Failed to update model via Goose ACP: {error:?}"))?;
dispatcher.emit_model_state_from_options(
local_session_id,
Some(&provider_id),
&response.config_options,
);
}
Ok(())
}
/// Yield repeatedly until an async counter stabilises for 3 consecutive rounds.
///
/// After `load_session` returns, the ACP RPC layer may still have spawned
/// notification tasks that haven't run yet. This function yields to the
/// runtime between polls so those tasks get a chance to execute, and only
/// returns once the count has been stable for 3 consecutive yields — giving
/// us confidence that all replay events have been dispatched.
///
/// A safety cap of 100 iterations prevents infinite spinning if a bug causes
/// the counter to increment indefinitely.
const MAX_DRAIN_ITERATIONS: u32 = 100;
async fn wait_for_replay_drain<F, Fut>(mut get_count: F) -> u32
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = u32>,
{
let mut prev_total = 0u32;
let mut stable_rounds = 0u8;
let mut iterations = 0u32;
loop {
tokio::task::yield_now().await;
let total = get_count().await;
iterations += 1;
if total == prev_total {
stable_rounds += 1;
if stable_rounds >= 3 {
return total;
}
} else {
stable_rounds = 0;
prev_total = total;
}
if iterations >= MAX_DRAIN_ITERATIONS {
log::warn!(
"wait_for_replay_drain hit iteration cap ({MAX_DRAIN_ITERATIONS}); \
returning partial count {total}"
);
return total;
}
}
}
@@ -1,112 +0,0 @@
use std::path::PathBuf;
use std::sync::Arc;
use acp_client::MessageWriter;
use agent_client_protocol::{
Agent, ClientSideConnection, ContentBlock as AcpContentBlock, ImageContent, PromptRequest,
TextContent,
};
use tokio::sync::Mutex;
use super::super::dispatcher::SessionEventDispatcher;
use super::{prepare_session_inner, ManagerState, PrepareSessionInput};
async fn take_cancel_requested(state: &Arc<Mutex<ManagerState>>, composite_key: &str) -> bool {
let mut guard = state.lock().await;
guard.take_cancel_requested(composite_key)
}
async fn clear_cancel_requested(state: &Arc<Mutex<ManagerState>>, composite_key: &str) {
let mut guard = state.lock().await;
guard.pending_cancels.remove(composite_key);
}
pub(super) fn build_content_blocks(
prompt: String,
images: Vec<(String, String)>,
) -> Vec<AcpContentBlock> {
let mut content_blocks = Vec::with_capacity(images.len() + 1);
for (data, mime_type) in images {
content_blocks.push(AcpContentBlock::Image(ImageContent::new(
data.as_str(),
mime_type.as_str(),
)));
}
content_blocks.push(AcpContentBlock::Text(TextContent::new(prompt)));
content_blocks
}
#[allow(clippy::too_many_arguments)]
pub(in super::super) async fn send_prompt_inner(
connection: &Arc<ClientSideConnection>,
dispatcher: &Arc<SessionEventDispatcher>,
state: &Arc<Mutex<ManagerState>>,
composite_key: String,
local_session_id: String,
provider_id: String,
working_dir: PathBuf,
existing_agent_session_id: Option<String>,
writer: Arc<dyn MessageWriter>,
prompt: String,
images: Vec<(String, String)>,
) -> Result<(), String> {
let provider_id_for_writer = provider_id.clone();
let goose_session_id = match prepare_session_inner(
connection,
dispatcher,
state,
PrepareSessionInput {
composite_key: composite_key.clone(),
local_session_id: local_session_id.clone(),
provider_id,
working_dir,
existing_agent_session_id,
},
)
.await
{
Ok(goose_session_id) => goose_session_id,
Err(error) => {
clear_cancel_requested(state, &composite_key).await;
return Err(error);
}
};
if take_cancel_requested(state, &composite_key).await {
return Ok(());
}
dispatcher
.attach_writer(
&goose_session_id,
&local_session_id,
Some(&provider_id_for_writer),
writer.clone(),
)
.await;
if dispatcher.is_canceled(&goose_session_id).await
|| take_cancel_requested(state, &composite_key).await
{
dispatcher.clear_writer(&goose_session_id).await;
return Ok(());
}
let content_blocks = build_content_blocks(prompt, images);
let result = connection
.prompt(PromptRequest::new(goose_session_id.clone(), content_blocks))
.await
.map(|_| ())
.map_err(|error| format!("Prompt failed via Goose ACP: {error:?}"));
let canceled = dispatcher.is_canceled(&goose_session_id).await;
dispatcher.clear_writer(&goose_session_id).await;
clear_cancel_requested(state, &composite_key).await;
if result.is_ok() && !canceled {
writer.finalize().await;
}
result
}
@@ -1,192 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use agent_client_protocol::ContentBlock as AcpContentBlock;
use super::{
needs_provider_update, prepared_session_for_key, prompt_ops::build_content_blocks,
register_prepared_session_keys, wait_for_replay_drain, ManagerState, PreparedSession,
MAX_DRAIN_ITERATIONS,
};
use crate::services::acp::split_composite_key;
#[test]
fn provider_update_detects_switch_back_to_goose() {
assert!(needs_provider_update(Some("openai"), "goose"));
assert!(needs_provider_update(Some("claude-acp"), "goose"));
assert!(!needs_provider_update(Some("goose"), "goose"));
assert!(needs_provider_update(None, "goose"));
}
#[test]
fn pending_cancel_is_consumed_once() {
let mut state = ManagerState {
sessions: HashMap::new(),
op_locks: HashMap::new(),
pending_cancels: HashSet::new(),
preparing_sessions: HashSet::new(),
};
state.mark_cancel_requested("session-1");
assert!(state.take_cancel_requested("session-1"));
assert!(!state.take_cancel_requested("session-1"));
}
#[test]
fn split_composite_key_extracts_local_session_id() {
assert_eq!(
split_composite_key("session-1__persona-1"),
("session-1", Some("persona-1"))
);
assert_eq!(split_composite_key("session-1"), ("session-1", None));
}
#[test]
fn prepared_session_lookup_falls_back_to_local_session_id() {
let mut sessions = HashMap::new();
sessions.insert(
"session-1".to_string(),
PreparedSession {
goose_session_id: "goose-1".to_string(),
provider_id: "goose".to_string(),
working_dir: PathBuf::from("/tmp/project"),
},
);
let prepared = prepared_session_for_key(&sessions, "session-1__persona-1", "session-1")
.expect("prepared session");
assert_eq!(prepared.goose_session_id, "goose-1");
assert_eq!(prepared.provider_id, "goose");
assert_eq!(prepared.working_dir, PathBuf::from("/tmp/project"));
}
#[test]
fn register_prepared_session_keys_preserves_composite_and_local_entries() {
let mut sessions = HashMap::new();
let prepared = PreparedSession {
goose_session_id: "goose-1".to_string(),
provider_id: "goose".to_string(),
working_dir: PathBuf::from("/tmp/project"),
};
register_prepared_session_keys(&mut sessions, "session-1__persona-1", "session-1", prepared);
assert!(sessions.contains_key("session-1__persona-1"));
assert!(sessions.contains_key("session-1"));
assert_eq!(
sessions
.get("session-1__persona-1")
.expect("composite session")
.goose_session_id,
"goose-1"
);
}
#[tokio::test]
async fn replay_drain_returns_immediately_when_count_is_zero() {
let final_count = wait_for_replay_drain(|| async { 0u32 }).await;
assert_eq!(final_count, 0);
}
#[tokio::test]
async fn replay_drain_returns_stable_count() {
let counter = Arc::new(AtomicU32::new(42));
let c = counter.clone();
let final_count = wait_for_replay_drain(|| {
let c = c.clone();
async move { c.load(Ordering::SeqCst) }
})
.await;
assert_eq!(final_count, 42);
}
#[tokio::test]
async fn replay_drain_waits_for_spawned_notifications() {
let counter = Arc::new(AtomicU32::new(0));
let c = counter.clone();
// Simulate async notifications arriving over multiple yields, like
// the real ACP RPC layer does after load_session returns.
tokio::spawn(async move {
for i in 1..=5 {
tokio::task::yield_now().await;
c.store(i, Ordering::SeqCst);
}
});
let c2 = counter.clone();
let final_count = wait_for_replay_drain(|| {
let c = c2.clone();
async move { c.load(Ordering::SeqCst) }
})
.await;
assert_eq!(final_count, 5);
}
#[tokio::test]
async fn replay_drain_resets_stability_on_late_arrival() {
// Simulate: counter jumps to 3, stabilises for 2 rounds, then a late
// notification bumps it to 4. The drain must NOT stop at 3.
let poll_count = Arc::new(AtomicU32::new(0));
let pc = poll_count.clone();
let final_count = wait_for_replay_drain(|| {
let pc = pc.clone();
async move {
let poll = pc.fetch_add(1, Ordering::SeqCst);
// Polls 0..2 return 3 (2 stable rounds), then poll 3 bumps
// to 4 — simulating a late notification just before the drain
// would have declared stability. The drain must reset and
// wait for 4 to stabilise.
if poll < 3 {
3
} else {
4
}
}
})
.await;
// Must see the late arrival, not stop at 3
assert_eq!(final_count, 4);
// Verify the stability window truly reset: 3 polls to see 3, 1 poll
// to see the bump to 4, then 3 more polls for 4 to stabilise = 7 min.
assert!(
poll_count.load(Ordering::SeqCst) >= 7,
"expected at least 7 polls to confirm stability window reset, got {}",
poll_count.load(Ordering::SeqCst)
);
}
#[tokio::test]
async fn replay_drain_caps_iterations_on_runaway_counter() {
// Simulate a counter that never stabilises — increments every poll.
let poll_count = Arc::new(AtomicU32::new(0));
let pc = poll_count.clone();
let final_count = wait_for_replay_drain(|| {
let pc = pc.clone();
async move { pc.fetch_add(1, Ordering::SeqCst) + 1 }
})
.await;
// Should have stopped at the cap rather than spinning forever.
assert_eq!(final_count, MAX_DRAIN_ITERATIONS);
assert_eq!(poll_count.load(Ordering::SeqCst), MAX_DRAIN_ITERATIONS);
}
#[test]
fn build_content_blocks_places_images_before_prompt_text() {
let blocks = build_content_blocks(
"Please inspect all three attachments".to_string(),
vec![("abc123".to_string(), "image/png".to_string())],
);
assert_eq!(blocks.len(), 2);
assert!(matches!(blocks[0], AcpContentBlock::Image(_)));
assert!(matches!(blocks[1], AcpContentBlock::Text(_)));
}
@@ -1,169 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use agent_client_protocol::{
Agent, ClientSideConnection, Implementation, InitializeRequest, ProtocolVersion,
};
use futures::{SinkExt, StreamExt};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, oneshot, Mutex};
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use crate::services::acp::goose_serve::{GooseServeProcess, WS_BRIDGE_BUFFER_BYTES};
use super::command_dispatch;
use super::dispatcher::{SessionEventDispatcher, SessionRoute};
use super::ManagerCommand;
pub(super) fn run_manager_thread(
app_handle: tauri::AppHandle,
command_rx: mpsc::UnboundedReceiver<ManagerCommand>,
ready_tx: oneshot::Sender<Result<(), String>>,
) {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
let _ = ready_tx.send(Err(format!(
"Failed to build Goose ACP manager runtime: {error}"
)));
return;
}
};
let local = tokio::task::LocalSet::new();
local.block_on(&runtime, async move {
let setup_result = async {
GooseServeProcess::start().await?;
let process = GooseServeProcess::get()?;
let ws_stream = connect_async(process.ws_url())
.await
.map(|(stream, _)| stream)
.map_err(|error| format!("Failed to connect to goose serve: {error}"))?;
let (mut ws_writer, mut ws_reader) = ws_stream.split();
let (connection_outgoing, bridge_outgoing_reader) =
tokio::io::duplex(WS_BRIDGE_BUFFER_BYTES);
let (bridge_incoming_writer, connection_incoming) =
tokio::io::duplex(WS_BRIDGE_BUFFER_BYTES);
tokio::task::spawn_local(async move {
let mut reader = BufReader::new(bridge_outgoing_reader);
let mut line = String::new();
loop {
line.clear();
let bytes_read = match reader.read_line(&mut line).await {
Ok(bytes_read) => bytes_read,
Err(error) => {
log::error!("Failed to read ACP output for Goose WebSocket: {error}");
break;
}
};
if bytes_read == 0 {
break;
}
let payload = line.trim_end_matches(['\r', '\n']);
if payload.is_empty() {
continue;
}
if let Err(error) = ws_writer.send(Message::Text(payload.to_string())).await {
log::error!("Failed to write Goose WebSocket frame: {error}");
break;
}
}
let _ = ws_writer.close().await;
});
tokio::task::spawn_local(async move {
let mut writer = bridge_incoming_writer;
while let Some(message) = ws_reader.next().await {
match message {
Ok(Message::Text(text)) => {
if let Err(error) = writer.write_all(text.as_bytes()).await {
log::error!(
"Failed to forward Goose WebSocket payload to ACP client: {error}"
);
break;
}
if let Err(error) = writer.write_all(b"\n").await {
log::error!(
"Failed to delimit Goose WebSocket payload for ACP client: {error}"
);
break;
}
if let Err(error) = writer.flush().await {
log::error!("Failed to flush Goose WebSocket payload: {error}");
break;
}
}
Ok(Message::Close(_)) => break,
Ok(Message::Binary(_))
| Ok(Message::Ping(_))
| Ok(Message::Pong(_))
| Ok(Message::Frame(_)) => {}
Err(error) => {
log::error!("Goose WebSocket read failed: {error}");
break;
}
}
}
let _ = writer.shutdown().await;
});
let routes = Arc::new(Mutex::new(HashMap::<String, SessionRoute>::new()));
let dispatcher = Arc::new(SessionEventDispatcher::new(
app_handle.clone(),
Arc::clone(&routes),
));
let (connection, io_future) = ClientSideConnection::new(
dispatcher.clone(),
connection_outgoing.compat_write(),
connection_incoming.compat(),
|future| {
tokio::task::spawn_local(future);
},
);
let connection = Arc::new(connection);
tokio::task::spawn_local(async move {
if let Err(error) = io_future.await {
log::error!("Goose ACP IO error: {error:?}");
}
});
connection
.initialize(
InitializeRequest::new(ProtocolVersion::LATEST)
.client_info(Implementation::new("goose2", env!("CARGO_PKG_VERSION"))),
)
.await
.map_err(|error| format!("Goose ACP initialize failed: {error:?}"))?;
Ok::<_, String>((connection, dispatcher))
}
.await;
let (connection, dispatcher) = match setup_result {
Ok(parts) => {
let _ = ready_tx.send(Ok(()));
parts
}
Err(error) => {
let _ = ready_tx.send(Err(error));
return;
}
};
command_dispatch::dispatch_commands(command_rx, connection, dispatcher).await;
});
}
-144
View File
@@ -1,148 +1,4 @@
pub(crate) mod goose_serve;
mod manager;
mod payloads;
mod registry;
mod search;
mod writer;
pub(crate) use goose_serve::resolve_goose_binary;
pub(crate) use goose_serve::GooseServeProcess;
pub use manager::{AcpSessionInfo, GooseAcpManager};
pub use registry::{AcpRunningSession, AcpSessionRegistry};
pub use search::{search_sessions_via_exports, SessionSearchResult};
pub use writer::TauriMessageWriter;
use std::path::PathBuf;
use std::sync::Arc;
use acp_client::MessageWriter;
use tauri::Emitter;
/// Build a composite registry key: `{session_id}__{persona_id}` when a
/// persona is active, or plain `session_id` for backward compatibility.
///
/// This assumes neither component contains `__`.
pub fn make_composite_key(session_id: &str, persona_id: Option<&str>) -> String {
match persona_id {
Some(pid) if !pid.is_empty() => format!("{session_id}__{pid}"),
_ => session_id.to_string(),
}
}
pub fn split_composite_key(key: &str) -> (&str, Option<&str>) {
match key.split_once("__") {
Some((session_id, persona_id)) if !persona_id.is_empty() => (session_id, Some(persona_id)),
_ => (key, None),
}
}
/// High-level service for running ACP prompts through an agent driver.
///
/// The actual response content is streamed to the frontend via Tauri events
/// emitted by [`TauriMessageWriter`]; the returned `Result` only signals
/// whether the request was successfully dispatched.
pub struct AcpService;
impl AcpService {
pub async fn prepare_session(
app_handle: tauri::AppHandle,
session_id: String,
provider_id: String,
working_dir: PathBuf,
persona_id: Option<String>,
) -> Result<(), String> {
let manager = GooseAcpManager::start(app_handle).await?;
manager
.prepare_session(
make_composite_key(&session_id, persona_id.as_deref()),
session_id,
provider_id,
working_dir,
None, // no existing agent session ID — goose binary owns sessions
)
.await
}
/// Send a prompt to the given ACP provider and stream the response via
/// Tauri events.
#[allow(clippy::too_many_arguments)]
pub async fn send_prompt(
app_handle: tauri::AppHandle,
registry: Arc<AcpSessionRegistry>,
session_id: String,
provider_id: String,
prompt: String,
working_dir: PathBuf,
system_prompt: Option<String>,
persona_id: Option<String>,
persona_name: Option<String>,
images: Vec<(String, String)>,
) -> Result<(), String> {
let registry_key = make_composite_key(&session_id, persona_id.as_deref());
let cancel_token = registry.register(&registry_key, &provider_id);
let writer_impl = Arc::new(TauriMessageWriter::new(
app_handle.clone(),
session_id.clone(),
Some(provider_id.clone()),
persona_id.clone(),
persona_name.clone(),
));
registry.set_assistant_message_id(
&registry_key,
writer_impl.assistant_message_id().to_string(),
);
let writer: Arc<dyn MessageWriter> = writer_impl.clone();
// Build the effective prompt, including persona instructions when
// available. When there is no extra context we pass the raw prompt
// for backward compatibility.
let has_system = system_prompt.as_ref().is_some_and(|s| !s.is_empty());
let effective_prompt = if has_system {
let mut parts = Vec::new();
if let Some(ref sp) = system_prompt {
if !sp.is_empty() {
parts.push(format!(
"<persona-instructions>\n{sp}\n</persona-instructions>"
));
}
}
parts.push(format!("<user-message>\n{prompt}\n</user-message>"));
parts.join("\n\n")
} else {
prompt.clone()
};
let manager = GooseAcpManager::start(app_handle.clone()).await?;
let result = manager
.send_prompt(
registry_key.clone(),
session_id.clone(),
provider_id.clone(),
working_dir,
None, // no existing agent session ID — goose binary owns sessions
writer,
effective_prompt,
images,
)
.await;
registry.deregister(&registry_key);
drop(cancel_token);
if let Err(ref error) = result {
// Emit an error event so the frontend can display it
let _ = app_handle.emit(
"acp:error",
serde_json::json!({
"sessionId": session_id,
"messageId": writer_impl.assistant_message_id(),
"error": error,
}),
);
}
result
}
}
@@ -1,106 +0,0 @@
use acp_client::SessionModelState;
use serde::Serialize;
/// Payload for the `acp:message_created` event.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MessageCreatedPayload {
pub session_id: String,
pub message_id: String,
pub persona_id: Option<String>,
pub persona_name: Option<String>,
}
/// Payload for the `acp:text` event.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TextPayload {
pub session_id: String,
pub message_id: String,
pub text: String,
}
/// Payload for the `acp:done` event.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DonePayload {
pub session_id: String,
pub message_id: String,
}
/// Payload for the `acp:tool_call` event.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ToolCallPayload {
pub session_id: String,
pub message_id: String,
pub tool_call_id: String,
pub title: String,
}
/// Payload for the `acp:tool_title` event.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ToolTitlePayload {
pub session_id: String,
pub message_id: String,
pub tool_call_id: String,
pub title: String,
}
/// Payload for the `acp:tool_result` event.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ToolResultPayload {
pub session_id: String,
pub message_id: String,
pub content: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SessionInfoPayload {
pub session_id: String,
pub title: Option<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SessionBoundPayload {
pub session_id: String,
pub goose_session_id: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReplayCompletePayload {
pub session_id: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ModelOption {
pub id: String,
pub name: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ModelStatePayload {
pub session_id: String,
pub provider_id: Option<String>,
pub current_model_id: String,
pub current_model_name: Option<String>,
pub available_models: Vec<ModelOption>,
}
pub(crate) fn model_options_from_state(state: &SessionModelState) -> Vec<ModelOption> {
state
.available_models
.iter()
.map(|model| ModelOption {
id: model.model_id.to_string(),
name: model.name.clone(),
})
.collect()
}
@@ -1,114 +0,0 @@
use std::collections::HashMap;
use serde::Serialize;
use tokio_util::sync::CancellationToken;
use super::split_composite_key;
/// Info about a running ACP session, returned to the frontend.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpRunningSession {
pub session_id: String,
pub persona_id: Option<String>,
pub provider_id: String,
pub running_for_secs: u64,
}
struct AcpSessionEntry {
cancel_token: CancellationToken,
provider_id: String,
started_at: std::time::Instant,
assistant_message_id: Option<String>,
/// PID of the process that owns this session (for orphan detection).
#[allow(dead_code)]
owner_pid: u32,
}
/// Tracks running ACP sessions for cancellation and cleanup.
///
/// Each running session is registered with a `CancellationToken` so it can
/// be cancelled from the frontend or cleaned up on shutdown.
pub struct AcpSessionRegistry {
sessions: std::sync::Mutex<HashMap<String, AcpSessionEntry>>,
}
impl AcpSessionRegistry {
pub fn new() -> Self {
Self {
sessions: std::sync::Mutex::new(HashMap::new()),
}
}
/// Register a new session and return its cancellation token.
pub fn register(&self, session_id: &str, provider_id: &str) -> CancellationToken {
let token = CancellationToken::new();
let entry = AcpSessionEntry {
cancel_token: token.clone(),
provider_id: provider_id.to_string(),
started_at: std::time::Instant::now(),
assistant_message_id: None,
owner_pid: std::process::id(),
};
self.sessions
.lock()
.expect("session registry lock")
.insert(session_id.to_string(), entry);
token
}
/// Deregister a session (called when it completes or errors).
pub fn deregister(&self, session_id: &str) {
self.sessions
.lock()
.expect("session registry lock")
.remove(session_id);
}
/// Cancel a running session by signalling its cancellation token.
pub fn cancel(&self, session_id: &str) -> Option<String> {
let guard = self.sessions.lock().expect("session registry lock");
if let Some(entry) = guard.get(session_id) {
entry.cancel_token.cancel();
entry.assistant_message_id.clone()
} else {
None
}
}
pub fn set_assistant_message_id(&self, session_id: &str, message_id: String) {
if let Some(entry) = self
.sessions
.lock()
.expect("session registry lock")
.get_mut(session_id)
{
entry.assistant_message_id = Some(message_id);
}
}
/// Cancel all running sessions (used during app shutdown).
pub fn cancel_all(&self) {
let guard = self.sessions.lock().expect("session registry lock");
for entry in guard.values() {
entry.cancel_token.cancel();
}
}
/// Return info about all currently running sessions (for the frontend).
pub fn list_running(&self) -> Vec<AcpRunningSession> {
let guard = self.sessions.lock().expect("session registry lock");
guard
.iter()
.map(|(id, entry)| {
let (session_id, persona_id) = split_composite_key(id);
AcpRunningSession {
session_id: session_id.to_string(),
persona_id: persona_id.map(str::to_string),
provider_id: entry.provider_id.clone(),
running_for_secs: entry.started_at.elapsed().as_secs(),
}
})
.collect()
}
}
@@ -1,467 +0,0 @@
use std::collections::HashSet;
use serde::Serialize;
use serde_json::{Map, Value};
use super::GooseAcpManager;
const SNIPPET_PREFIX_BYTES: usize = 40;
const SNIPPET_SUFFIX_BYTES: usize = 60;
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SessionSearchResult {
pub session_id: String,
pub snippet: String,
pub message_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_role: Option<String>,
pub match_count: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ExportedMessage {
id: String,
role: Option<String>,
searchable_texts: Vec<String>,
}
pub async fn search_sessions_via_exports(
manager: &GooseAcpManager,
query: &str,
session_ids: &[String],
) -> Result<Vec<SessionSearchResult>, String> {
let trimmed = query.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
let mut seen = HashSet::new();
let mut results = Vec::new();
for session_id in session_ids {
if !seen.insert(session_id.clone()) {
continue;
}
let exported = manager.export_session(session_id.clone()).await?;
if let Some(result) = search_exported_session(session_id, &exported, trimmed)? {
results.push(result);
}
}
Ok(results)
}
fn search_exported_session(
session_id: &str,
exported_json: &str,
query: &str,
) -> Result<Option<SessionSearchResult>, String> {
let root: Value = serde_json::from_str(exported_json)
.map_err(|error| format!("Failed to parse exported session JSON: {error}"))?;
let Some(conversation) = root.get("conversation").or_else(|| root.get("messages")) else {
return Ok(None);
};
let messages = extract_messages(conversation);
if messages.is_empty() {
return Ok(None);
}
let mut first_match: Option<(String, Option<String>, String)> = None;
let mut match_count = 0;
for message in messages {
for text in message.searchable_texts {
let occurrence_count = count_occurrences(&text, query);
if occurrence_count == 0 {
continue;
}
match_count += occurrence_count;
if first_match.is_none() {
first_match = Some((
message.id.clone(),
message.role.clone(),
build_snippet(&text, query),
));
}
}
}
let Some((message_id, message_role, snippet)) = first_match else {
return Ok(None);
};
Ok(Some(SessionSearchResult {
session_id: session_id.to_string(),
snippet,
message_id,
message_role,
match_count,
}))
}
fn extract_messages(value: &Value) -> Vec<ExportedMessage> {
let mut messages = Vec::new();
collect_messages(value, &mut messages);
messages
}
fn collect_messages(value: &Value, messages: &mut Vec<ExportedMessage>) {
match value {
Value::Array(items) => {
for item in items {
collect_messages(item, messages);
}
}
Value::Object(map) => {
if let Some(message_value) = map.get("message") {
collect_messages(message_value, messages);
return;
}
if let Some(messages_value) = map.get("messages") {
collect_messages(messages_value, messages);
return;
}
if looks_like_message(map) {
let fallback_id = format!("message-{}", messages.len());
if let Some(message) = extract_message(map, fallback_id) {
messages.push(message);
}
}
}
_ => {}
}
}
fn looks_like_message(map: &Map<String, Value>) -> bool {
map.contains_key("role") && (map.contains_key("content") || map.contains_key("text"))
}
fn extract_message(map: &Map<String, Value>, fallback_id: String) -> Option<ExportedMessage> {
let role = normalize_role(map.get("role").and_then(Value::as_str));
let mut searchable_texts = Vec::new();
if let Some(content) = map.get("content") {
searchable_texts.extend(extract_searchable_texts(content, role.as_deref()));
} else if let Some(text) = map.get("text").and_then(Value::as_str) {
if role.is_some() && !text.trim().is_empty() {
searchable_texts.push(text.trim().to_string());
}
}
if searchable_texts.is_empty() {
return None;
}
Some(ExportedMessage {
id: map
.get("id")
.and_then(Value::as_str)
.unwrap_or(&fallback_id)
.to_string(),
role,
searchable_texts,
})
}
fn extract_searchable_texts(value: &Value, role: Option<&str>) -> Vec<String> {
match value {
Value::String(text) => role
.filter(|supported_role| is_searchable_role(supported_role))
.and_then(|_| normalized_text(text))
.into_iter()
.collect(),
Value::Array(items) => items
.iter()
.flat_map(|item| extract_searchable_block_text(item, role))
.collect(),
Value::Object(_) => extract_searchable_block_text(value, role),
_ => Vec::new(),
}
}
fn extract_searchable_block_text(value: &Value, role: Option<&str>) -> Vec<String> {
let Value::Object(map) = value else {
return Vec::new();
};
let block_type = map.get("type").and_then(Value::as_str);
let text = map.get("text").and_then(Value::as_str);
match block_type {
Some("text") | Some("input_text") | Some("output_text") => {
text.and_then(normalized_text).into_iter().collect()
}
Some("systemNotification") | Some("system_notification") => {
text.and_then(normalized_text).into_iter().collect()
}
Some("toolRequest")
| Some("toolResponse")
| Some("thinking")
| Some("redactedThinking")
| Some("reasoning")
| Some("image") => Vec::new(),
_ => {
if role.is_some_and(is_searchable_role) {
return text.and_then(normalized_text).into_iter().collect();
}
Vec::new()
}
}
}
fn normalize_role(role: Option<&str>) -> Option<String> {
let normalized = role?.trim();
if normalized.eq_ignore_ascii_case("user") {
return Some("user".to_string());
}
if normalized.eq_ignore_ascii_case("assistant") {
return Some("assistant".to_string());
}
if normalized.eq_ignore_ascii_case("system") {
return Some("system".to_string());
}
None
}
fn is_searchable_role(role: &str) -> bool {
matches!(role, "user" | "assistant" | "system")
}
fn normalized_text(text: &str) -> Option<String> {
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn count_occurrences(text: &str, query: &str) -> usize {
let haystack = text.to_ascii_lowercase();
let needle = query.to_ascii_lowercase();
if needle.is_empty() {
return 0;
}
let mut count = 0;
let mut search_start = 0;
while let Some(relative_index) = haystack[search_start..].find(&needle) {
count += 1;
search_start += relative_index + needle.len();
}
count
}
fn build_snippet(text: &str, query: &str) -> String {
let haystack = text.to_ascii_lowercase();
let needle = query.to_ascii_lowercase();
let match_index = haystack.find(&needle).unwrap_or(0);
let start = floor_char_boundary(text, match_index.saturating_sub(SNIPPET_PREFIX_BYTES));
let end = ceil_char_boundary(
text,
match_index
.saturating_add(query.len())
.saturating_add(SNIPPET_SUFFIX_BYTES)
.min(text.len()),
);
let prefix = if start > 0 { "..." } else { "" };
let suffix = if end < text.len() { "..." } else { "" };
let body = text.get(start..end).unwrap_or(text).trim();
format!("{prefix}{body}{suffix}")
}
fn floor_char_boundary(text: &str, mut index: usize) -> usize {
index = index.min(text.len());
while index > 0 && !text.is_char_boundary(index) {
index -= 1;
}
index
}
fn ceil_char_boundary(text: &str, mut index: usize) -> usize {
index = index.min(text.len());
while index < text.len() && !text.is_char_boundary(index) {
index += 1;
}
index
}
#[cfg(test)]
mod tests {
use super::{build_snippet, search_exported_session, SessionSearchResult};
#[test]
fn finds_user_and_assistant_text_matches() {
let exported = serde_json::json!({
"conversation": [
{
"id": "user-1",
"role": "user",
"content": [{ "type": "text", "text": "searchable user prompt" }]
},
{
"id": "assistant-1",
"role": "assistant",
"content": [{ "type": "text", "text": "assistant searchable response" }]
}
]
})
.to_string();
let user_result = search_exported_session("session-1", &exported, "prompt")
.expect("search succeeds")
.expect("user result");
let assistant_result = search_exported_session("session-1", &exported, "response")
.expect("search succeeds")
.expect("assistant result");
assert_eq!(user_result.message_id, "user-1");
assert_eq!(user_result.message_role.as_deref(), Some("user"));
assert_eq!(assistant_result.message_id, "assistant-1");
assert_eq!(assistant_result.message_role.as_deref(), Some("assistant"));
}
#[test]
fn includes_system_notifications() {
let exported = serde_json::json!({
"conversation": [
{
"id": "system-1",
"role": "system",
"content": [{
"type": "systemNotification",
"text": "Compaction completed successfully"
}]
}
]
})
.to_string();
let result = search_exported_session("session-1", &exported, "completed")
.expect("search succeeds")
.expect("system result");
assert_eq!(result.message_id, "system-1");
assert_eq!(result.message_role.as_deref(), Some("system"));
}
#[test]
fn skips_tool_and_reasoning_content() {
let exported = serde_json::json!({
"conversation": [
{
"id": "assistant-1",
"role": "assistant",
"content": [
{ "type": "toolRequest", "text": "tool request text" },
{ "type": "toolResponse", "text": "tool response text" },
{ "type": "thinking", "text": "private thinking" },
{ "type": "reasoning", "text": "private reasoning" }
]
}
]
})
.to_string();
let result =
search_exported_session("session-1", &exported, "tool").expect("search succeeds");
assert!(result.is_none());
}
#[test]
fn skips_single_object_tool_and_reasoning_blocks() {
let exported = serde_json::json!({
"conversation": [
{
"id": "assistant-1",
"role": "assistant",
"content": { "type": "toolResponse", "text": "tool response text" }
},
{
"id": "assistant-2",
"role": "assistant",
"content": { "type": "reasoning", "text": "private reasoning" }
}
]
})
.to_string();
let tool_result =
search_exported_session("session-1", &exported, "tool").expect("search succeeds");
let reasoning_result =
search_exported_session("session-1", &exported, "reasoning").expect("search succeeds");
assert!(tool_result.is_none());
assert!(reasoning_result.is_none());
}
#[test]
fn includes_single_object_text_blocks() {
let exported = serde_json::json!({
"conversation": [
{
"id": "assistant-1",
"role": "assistant",
"content": { "type": "text", "text": "needle in a single object block" }
}
]
})
.to_string();
let result = search_exported_session("session-1", &exported, "needle")
.expect("search succeeds")
.expect("text result");
assert_eq!(result.message_id, "assistant-1");
assert_eq!(result.message_role.as_deref(), Some("assistant"));
}
#[test]
fn counts_multiple_matches_in_one_session() {
let exported = serde_json::json!({
"conversation": [
{
"id": "assistant-1",
"role": "assistant",
"content": [
{ "type": "text", "text": "needle once" },
{ "type": "text", "text": "needle twice needle" }
]
}
]
})
.to_string();
let result = search_exported_session("session-1", &exported, "needle")
.expect("search succeeds")
.expect("result");
assert_eq!(
result,
SessionSearchResult {
session_id: "session-1".to_string(),
snippet: "needle once".to_string(),
message_id: "assistant-1".to_string(),
message_role: Some("assistant".to_string()),
match_count: 3,
}
);
}
#[test]
fn builds_trimmed_snippets_around_first_match() {
let text = "abcdefghijklmnopqrstuvwxyz0123456789prefix padding before needle and some trailing text that keeps going";
let snippet = build_snippet(text, "needle");
assert!(snippet.starts_with("..."));
assert!(snippet.contains("needle and some trailing text"));
}
}
@@ -1,156 +0,0 @@
use async_trait::async_trait;
use tauri::Emitter;
use acp_client::{MessageWriter, SessionInfoUpdate, SessionModelState};
use super::payloads::{
model_options_from_state, DonePayload, MessageCreatedPayload, ModelStatePayload,
SessionInfoPayload, TextPayload, ToolCallPayload, ToolResultPayload, ToolTitlePayload,
};
/// A [`MessageWriter`] implementation that streams ACP output to the frontend
/// via Tauri events. No local persistence — the goose binary is the sole
/// source of truth for messages.
pub struct TauriMessageWriter {
app_handle: tauri::AppHandle,
session_id: String,
provider_id: Option<String>,
assistant_message_id: String,
}
impl TauriMessageWriter {
pub fn new(
app_handle: tauri::AppHandle,
session_id: String,
provider_id: Option<String>,
persona_id: Option<String>,
persona_name: Option<String>,
) -> Self {
let assistant_message_id = uuid::Uuid::new_v4().to_string();
let _ = app_handle.emit(
"acp:message_created",
MessageCreatedPayload {
session_id: session_id.clone(),
message_id: assistant_message_id.clone(),
persona_id: persona_id.clone(),
persona_name: persona_name.clone(),
},
);
Self {
app_handle,
session_id,
provider_id,
assistant_message_id,
}
}
pub fn assistant_message_id(&self) -> &str {
&self.assistant_message_id
}
}
#[async_trait]
impl MessageWriter for TauriMessageWriter {
async fn append_text(&self, text: &str) {
if text.is_empty() {
return;
}
let _ = self.app_handle.emit(
"acp:text",
TextPayload {
session_id: self.session_id.clone(),
message_id: self.assistant_message_id.clone(),
text: text.to_string(),
},
);
}
async fn finalize(&self) {
let _ = self.app_handle.emit(
"acp:done",
DonePayload {
session_id: self.session_id.clone(),
message_id: self.assistant_message_id.clone(),
},
);
}
async fn record_tool_call(
&self,
tool_call_id: &str,
title: &str,
_raw_input: Option<&serde_json::Value>,
) {
let _ = self.app_handle.emit(
"acp:tool_call",
ToolCallPayload {
session_id: self.session_id.clone(),
message_id: self.assistant_message_id.clone(),
tool_call_id: tool_call_id.to_string(),
title: title.to_string(),
},
);
}
async fn update_tool_call_title(
&self,
tool_call_id: &str,
title: Option<&str>,
_raw_input: Option<&serde_json::Value>,
) {
if let Some(title) = title {
let _ = self.app_handle.emit(
"acp:tool_title",
ToolTitlePayload {
session_id: self.session_id.clone(),
message_id: self.assistant_message_id.clone(),
tool_call_id: tool_call_id.to_string(),
title: title.to_string(),
},
);
}
}
async fn record_tool_result(&self, content: &str) {
let _ = self.app_handle.emit(
"acp:tool_result",
ToolResultPayload {
session_id: self.session_id.clone(),
message_id: self.assistant_message_id.clone(),
content: content.to_string(),
},
);
}
async fn on_session_info_update(&self, info: &SessionInfoUpdate) {
let _ = self.app_handle.emit(
"acp:session_info",
SessionInfoPayload {
session_id: self.session_id.clone(),
title: info.title.value().cloned(),
},
);
}
async fn on_model_state_update(&self, state: &SessionModelState) {
let current_model_name = state
.available_models
.iter()
.find(|m| m.model_id == state.current_model_id)
.map(|m| m.name.clone());
let available_models = model_options_from_state(state);
let _ = self.app_handle.emit(
"acp:model_state",
ModelStatePayload {
session_id: self.session_id.clone(),
provider_id: self.provider_id.clone(),
current_model_id: state.current_model_id.to_string(),
current_model_name,
available_models,
},
);
}
}
+9 -3
View File
@@ -10,7 +10,6 @@ import type { SectionId } from "@/features/settings/ui/SettingsModal";
import { TopBar } from "./ui/TopBar";
import { useChatStore } from "@/features/chat/stores/chatStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import { useAcpStream } from "@/features/chat/hooks/useAcpStream";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { findExistingDraft } from "@/features/chat/lib/newChat";
@@ -18,6 +17,10 @@ import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle";
import { useAppStartup } from "./hooks/useAppStartup";
import { AppShellContent } from "./ui/AppShellContent";
import { acpPrepareSession } from "@/shared/api/acp";
import {
clearReplayBuffer,
getAndDeleteReplayBuffer,
} from "@/features/chat/hooks/replayBuffer";
import { getHomeDir } from "@/shared/api/system";
import { resolveEffectiveWorkingDir } from "@/features/projects/lib/chatProjectContext";
@@ -58,8 +61,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const agentStore = useAgentStore();
const projectStore = useProjectStore();
useAcpStream(true);
const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>(
null,
);
@@ -99,12 +100,17 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
: undefined);
await acpLoadSession(sessionId, gooseSessionId, workingDir);
useChatStore.getState().setSessionLoading(sessionId, false);
const buffer = getAndDeleteReplayBuffer(sessionId);
if (buffer && buffer.length > 0) {
useChatStore.getState().setMessages(sessionId, buffer);
}
const t3 = performance.now();
console.log(
`[perf:load] ${sessionId.slice(0, 8)} acpLoadSession resolved in ${(t3 - t2).toFixed(1)}ms (total ${(t3 - t0).toFixed(1)}ms)`,
);
} catch (err) {
console.error("Failed to load session messages:", err);
clearReplayBuffer(sessionId);
useChatStore.getState().setSessionLoading(sessionId, false);
}
}, []);
+5 -8
View File
@@ -1,20 +1,17 @@
import { useEffect } from "react";
import { useAgentStore } from "@/features/agents/stores/agentStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import { USE_DIRECT_ACP } from "@/shared/api/acpFeatureFlag";
import { setNotificationHandler, getClient } from "@/shared/api/acpConnection";
import notificationHandler from "@/shared/api/acpNotificationHandler";
export function useAppStartup() {
useEffect(() => {
(async () => {
if (USE_DIRECT_ACP) {
try {
setNotificationHandler(notificationHandler);
await getClient();
} catch (err) {
console.error("Failed to initialize ACP connection:", err);
}
try {
setNotificationHandler(notificationHandler);
await getClient();
} catch (err) {
console.error("Failed to initialize ACP connection:", err);
}
const store = useAgentStore.getState();
@@ -1,567 +0,0 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Message } from "@/shared/types/messages";
import { useChatSessionStore } from "../../stores/chatSessionStore";
import { useChatStore } from "../../stores/chatStore";
type EventCallback = (event: { payload: Record<string, unknown> }) => void;
const listeners = new Map<string, EventCallback[]>();
function emit(eventName: string, payload: Record<string, unknown>) {
const callbacks = listeners.get(eventName) ?? [];
for (const callback of callbacks) {
callback({ payload });
}
}
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(
(eventName: string, callback: EventCallback): Promise<() => void> => {
const callbacks = listeners.get(eventName) ?? [];
callbacks.push(callback);
listeners.set(eventName, callbacks);
return Promise.resolve(() => {
const current = listeners.get(eventName) ?? [];
listeners.set(
eventName,
current.filter((entry) => entry !== callback),
);
});
},
),
}));
import { useAcpStream } from "../useAcpStream";
function makeStreamingMessage(overrides: Partial<Message> = {}): Message {
return {
id: "msg-1",
role: "assistant",
created: Date.now(),
content: [{ type: "text", text: "" }],
metadata: { userVisible: true },
...overrides,
};
}
function setupStreaming(sessionId: string, messageId = "msg-1") {
const store = useChatStore.getState();
store.addMessage(sessionId, makeStreamingMessage({ id: messageId }));
store.setStreamingMessageId(sessionId, messageId);
store.setChatState(sessionId, "streaming");
}
describe("useAcpStream", () => {
const sessionId = "test-session";
beforeEach(() => {
listeners.clear();
cleanup();
useChatStore.setState({
messagesBySession: {},
sessionStateById: {},
activeSessionId: sessionId,
isConnected: true,
});
useChatSessionStore.setState({
sessions: [
{
id: sessionId,
title: "New Chat",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messageCount: 0,
},
],
activeSessionId: sessionId,
isLoading: false,
modelsBySession: {},
});
});
it("accumulates text chunks for the targeted session", async () => {
setupStreaming(sessionId);
renderHook(() => useAcpStream(true));
await vi.waitFor(() => expect(listeners.get("acp:text")).toBeDefined());
act(() => {
emit("acp:text", { sessionId, messageId: "msg-1", text: "Hello" });
emit("acp:text", { sessionId, messageId: "msg-1", text: " world" });
});
const messages = useChatStore.getState().messagesBySession[sessionId];
const text = messages[0].content.find((content) => content.type === "text");
if (text && "text" in text) {
expect(text.text).toBe("Hello world");
}
});
it("clears runtime state on acp:done for the completed session", async () => {
setupStreaming(sessionId);
renderHook(() => useAcpStream(true));
await vi.waitFor(() => expect(listeners.get("acp:done")).toBeDefined());
act(() => {
emit("acp:text", { sessionId, messageId: "msg-1", text: "partial" });
emit("acp:done", { sessionId, messageId: "msg-1" });
});
const runtime = useChatStore.getState().getSessionRuntime(sessionId);
const message = useChatStore.getState().messagesBySession[sessionId][0];
expect(runtime.chatState).toBe("idle");
expect(runtime.streamingMessageId).toBeNull();
expect(runtime.hasUnread).toBe(false);
expect(message.metadata?.completionStatus).toBe("completed");
});
it("marks a background session unread when it completes", async () => {
const backgroundSessionId = "background-session";
setupStreaming(backgroundSessionId);
useChatSessionStore.setState((state) => ({
...state,
sessions: [
...state.sessions,
{
id: backgroundSessionId,
title: "Background Chat",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messageCount: 0,
},
],
activeSessionId: sessionId,
}));
renderHook(() => useAcpStream(true));
await vi.waitFor(() => expect(listeners.get("acp:done")).toBeDefined());
act(() => {
emit("acp:done", { sessionId: backgroundSessionId, messageId: "msg-1" });
});
expect(
useChatStore.getState().getSessionRuntime(backgroundSessionId).hasUnread,
).toBe(true);
expect(useChatStore.getState().getSessionRuntime(sessionId).hasUnread).toBe(
false,
);
});
it("updates multiple sessions independently", async () => {
const otherSessionId = "other-session";
setupStreaming(sessionId, "msg-1");
setupStreaming(otherSessionId, "msg-2");
renderHook(() => useAcpStream(true));
await vi.waitFor(() => expect(listeners.get("acp:text")).toBeDefined());
act(() => {
emit("acp:text", { sessionId, messageId: "msg-1", text: "Alpha" });
emit("acp:text", {
sessionId: otherSessionId,
messageId: "msg-2",
text: "Beta",
});
emit("acp:done", { sessionId: otherSessionId, messageId: "msg-2" });
});
const primary = useChatStore.getState().messagesBySession[sessionId];
const secondary = useChatStore.getState().messagesBySession[otherSessionId];
const primaryText = primary[0].content.find(
(content) => content.type === "text",
);
const secondaryText = secondary[0].content.find(
(content) => content.type === "text",
);
if (primaryText && "text" in primaryText) {
expect(primaryText.text).toBe("Alpha");
}
if (secondaryText && "text" in secondaryText) {
expect(secondaryText.text).toBe("Beta");
}
expect(useChatStore.getState().getSessionRuntime(sessionId).chatState).toBe(
"streaming",
);
expect(
useChatStore.getState().getSessionRuntime(otherSessionId).chatState,
).toBe("idle");
});
it("does not register listeners when disabled", () => {
renderHook(() => useAcpStream(false));
expect(listeners.size).toBe(0);
});
it("flushes replayed history only after acp:replay_complete", async () => {
useChatStore.getState().setSessionLoading(sessionId, true);
renderHook(() => useAcpStream(true));
await vi.waitFor(() =>
expect(listeners.get("acp:message_created")).toBeDefined(),
);
act(() => {
emit("acp:message_created", {
sessionId,
messageId: "replay-msg",
});
emit("acp:text", {
sessionId,
messageId: "replay-msg",
text: "replayed history",
});
emit("acp:done", {
sessionId,
messageId: "replay-msg",
});
});
expect(
useChatStore.getState().messagesBySession[sessionId],
).toBeUndefined();
expect(useChatStore.getState().loadingSessionIds.has(sessionId)).toBe(true);
act(() => {
emit("acp:replay_complete", { sessionId });
});
const messages = useChatStore.getState().messagesBySession[sessionId];
expect(useChatStore.getState().loadingSessionIds.has(sessionId)).toBe(
false,
);
expect(messages).toHaveLength(1);
const text = messages[0].content.find((content) => content.type === "text");
if (text && "text" in text) {
expect(text.text).toBe("replayed history");
}
});
it("stores model state for the targeted session", async () => {
renderHook(() => useAcpStream(true));
await vi.waitFor(() =>
expect(listeners.get("acp:model_state")).toBeDefined(),
);
act(() => {
emit("acp:model_state", {
sessionId,
currentModelId: "claude-sonnet-4",
currentModelName: "Claude Sonnet 4",
availableModels: [
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "gpt-4o", name: "GPT-4o" },
],
});
});
expect(
useChatSessionStore.getState().getSession(sessionId)?.modelName,
).toBe("Claude Sonnet 4");
expect(useChatSessionStore.getState().getSession(sessionId)?.modelId).toBe(
"claude-sonnet-4",
);
expect(useChatSessionStore.getState().getSessionModels(sessionId)).toEqual([
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "gpt-4o", name: "GPT-4o" },
]);
});
it("accepts providerless model state when the session already has a provider", async () => {
useChatSessionStore.setState((state) => ({
...state,
sessions: state.sessions.map((session) =>
session.id === sessionId
? { ...session, providerId: "openai" }
: session,
),
}));
renderHook(() => useAcpStream(true));
await vi.waitFor(() =>
expect(listeners.get("acp:model_state")).toBeDefined(),
);
act(() => {
emit("acp:model_state", {
sessionId,
currentModelId: "gpt-4.1",
currentModelName: "GPT-4.1",
availableModels: [
{ id: "gpt-4.1", name: "GPT-4.1" },
{ id: "gpt-4o", name: "GPT-4o" },
],
});
});
expect(
useChatSessionStore.getState().getSession(sessionId)?.modelName,
).toBe("GPT-4.1");
expect(useChatSessionStore.getState().getSession(sessionId)?.modelId).toBe(
"gpt-4.1",
);
expect(useChatSessionStore.getState().getSessionModels(sessionId)).toEqual([
{ id: "gpt-4.1", name: "GPT-4.1" },
{ id: "gpt-4o", name: "GPT-4o" },
]);
});
it("creates the streaming assistant message from backend metadata", async () => {
useChatStore.getState().setChatState(sessionId, "streaming");
useChatStore
.getState()
.setPendingAssistantProvider(sessionId, "claude-acp");
renderHook(() => useAcpStream(true));
await vi.waitFor(() =>
expect(listeners.get("acp:message_created")).toBeDefined(),
);
act(() => {
emit("acp:message_created", {
sessionId,
messageId: "msg-created",
personaId: "persona-1",
personaName: "Planner",
});
});
const messages = useChatStore.getState().messagesBySession[sessionId];
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
id: "msg-created",
role: "assistant",
metadata: {
personaId: "persona-1",
personaName: "Planner",
providerId: "claude-acp",
completionStatus: "inProgress",
},
});
expect(
useChatStore.getState().getSessionRuntime(sessionId).streamingMessageId,
).toBe("msg-created");
});
it("keeps the original pending provider when the session provider changes before message creation", async () => {
useChatStore.getState().setChatState(sessionId, "streaming");
useChatStore
.getState()
.setPendingAssistantProvider(sessionId, "claude-acp");
useChatSessionStore.setState((state) => ({
...state,
sessions: state.sessions.map((session) =>
session.id === sessionId
? { ...session, providerId: "codex-acp" }
: session,
),
}));
renderHook(() => useAcpStream(true));
await vi.waitFor(() =>
expect(listeners.get("acp:message_created")).toBeDefined(),
);
act(() => {
emit("acp:message_created", {
sessionId,
messageId: "msg-provider-lock",
});
});
const message = useChatStore.getState().messagesBySession[sessionId][0];
const runtime = useChatStore.getState().getSessionRuntime(sessionId);
expect(message.metadata?.providerId).toBe("claude-acp");
expect(runtime.pendingAssistantProviderId).toBeNull();
});
it("ignores late message_created events after local streaming state is cleared", async () => {
renderHook(() => useAcpStream(true));
await vi.waitFor(() =>
expect(listeners.get("acp:message_created")).toBeDefined(),
);
act(() => {
emit("acp:message_created", {
sessionId,
messageId: "late-msg",
personaId: "persona-1",
personaName: "Planner",
});
});
expect(
useChatStore.getState().messagesBySession[sessionId],
).toBeUndefined();
expect(
useChatStore.getState().getSessionRuntime(sessionId).streamingMessageId,
).toBeNull();
});
it("unregisters listeners on unmount", async () => {
const { unmount } = renderHook(() => useAcpStream(true));
await vi.waitFor(() => expect(listeners.get("acp:text")).toBeDefined());
expect(listeners.get("acp:text")?.length).toBe(1);
unmount();
await vi.waitFor(() =>
expect(listeners.get("acp:text")?.length ?? 0).toBe(0),
);
});
it("ignores late tool title updates after a message is completed", async () => {
const store = useChatStore.getState();
store.addMessage(sessionId, {
id: "msg-1",
role: "assistant",
created: Date.now(),
content: [
{
type: "toolRequest",
id: "tool-1",
name: "Original title",
arguments: {},
status: "executing",
},
],
metadata: {
userVisible: true,
completionStatus: "completed",
},
});
renderHook(() => useAcpStream(true));
await vi.waitFor(() =>
expect(listeners.get("acp:tool_title")).toBeDefined(),
);
act(() => {
emit("acp:tool_title", {
sessionId,
messageId: "msg-1",
toolCallId: "tool-1",
title: "Late title",
});
});
const message = useChatStore.getState().messagesBySession[sessionId][0];
expect(message.content).toContainEqual({
type: "toolRequest",
id: "tool-1",
name: "Original title",
arguments: {},
status: "executing",
});
});
it("shows an error state when replay_complete is not received within the timeout", async () => {
vi.useFakeTimers();
try {
renderHook(() => useAcpStream(true));
await vi.waitFor(() =>
expect(listeners.get("acp:replay_complete")).toBeDefined(),
);
// Start loading AFTER the hook subscribes so the subscription sees the transition
act(() => {
useChatStore.getState().setSessionLoading(sessionId, true);
});
// Simulate time passing without replay_complete
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(useChatStore.getState().loadingSessionIds.has(sessionId)).toBe(
false,
);
const runtime = useChatStore.getState().getSessionRuntime(sessionId);
expect(runtime.error).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it("clears the replay timeout when replay_complete arrives in time", async () => {
vi.useFakeTimers();
try {
renderHook(() => useAcpStream(true));
await vi.waitFor(() =>
expect(listeners.get("acp:replay_complete")).toBeDefined(),
);
act(() => {
useChatStore.getState().setSessionLoading(sessionId, true);
});
// replay_complete arrives before timeout
act(() => {
emit("acp:replay_complete", { sessionId });
});
// Advance past the timeout — should NOT set error
act(() => {
vi.advanceTimersByTime(30_000);
});
const runtime = useChatStore.getState().getSessionRuntime(sessionId);
expect(runtime.error).toBeFalsy();
expect(useChatStore.getState().loadingSessionIds.has(sessionId)).toBe(
false,
);
} finally {
vi.useRealTimers();
}
});
it("ignores late tool results after a message is stopped", async () => {
const store = useChatStore.getState();
store.addMessage(sessionId, {
id: "msg-1",
role: "assistant",
created: Date.now(),
content: [
{
type: "toolRequest",
id: "tool-1",
name: "Lookup",
arguments: {},
status: "executing",
},
],
metadata: {
userVisible: true,
completionStatus: "stopped",
},
});
renderHook(() => useAcpStream(true));
await vi.waitFor(() =>
expect(listeners.get("acp:tool_result")).toBeDefined(),
);
act(() => {
emit("acp:tool_result", {
sessionId,
messageId: "msg-1",
content: "late result",
});
});
const message = useChatStore.getState().messagesBySession[sessionId][0];
expect(message.content).toHaveLength(1);
expect(message.content[0]).toMatchObject({
type: "toolRequest",
id: "tool-1",
status: "executing",
});
});
});
@@ -94,11 +94,9 @@ describe("useChat attachments", () => {
]);
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"goose",
"Attached items:\n- [file] /tmp/report.pdf\n- [directory] /tmp/screenshots\nPlease review these",
{
systemPrompt: undefined,
workingDir: undefined,
personaId: undefined,
personaName: undefined,
images: undefined,
@@ -147,11 +145,9 @@ describe("useChat attachments", () => {
]);
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"goose",
"Attached items:\n- [image] diagram.png (image attached)\n ",
{
systemPrompt: undefined,
workingDir: undefined,
personaId: undefined,
personaName: undefined,
images: [["abc123", "image/png"]],
@@ -196,11 +192,9 @@ describe("useChat attachments", () => {
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"goose",
"Attached items:\n- [file] /tmp/mobile-confirmation.html\n- [directory] /tmp/neighborhood block\n- [image] Screenshot 2026-04-09 at 1.25.32 PM.png (image attached)\ncan you see the attachments i attached?",
{
systemPrompt: undefined,
workingDir: undefined,
personaId: undefined,
personaName: undefined,
images: [["abc123", "image/png"]],
@@ -238,11 +232,9 @@ describe("useChat attachments", () => {
]);
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"goose",
"Attached items:\n- [file] report.pdf\nPlease review this",
{
systemPrompt: undefined,
workingDir: undefined,
personaId: undefined,
personaName: undefined,
images: undefined,
@@ -125,18 +125,12 @@ describe("useChat", () => {
result.current.stopGeneration();
});
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"goose",
"Hello",
{
systemPrompt: undefined,
workingDir: undefined,
personaId: "persona-b",
personaName: "Persona B",
images: undefined,
},
);
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "Hello", {
systemPrompt: undefined,
personaId: "persona-b",
personaName: "Persona B",
images: undefined,
});
expect(mockAcpCancelSession).toHaveBeenCalledWith("session-1", "persona-b");
deferred.resolve();
@@ -316,11 +310,9 @@ describe("useChat", () => {
expect(mockAcpSendMessage).toHaveBeenNthCalledWith(
1,
"session-1",
"goose",
"First",
{
systemPrompt: undefined,
workingDir: undefined,
personaId: undefined,
personaName: undefined,
images: undefined,
@@ -329,11 +321,9 @@ describe("useChat", () => {
expect(mockAcpSendMessage).toHaveBeenNthCalledWith(
2,
"session-2",
"goose",
"Second",
{
systemPrompt: undefined,
workingDir: undefined,
personaId: undefined,
personaName: undefined,
images: undefined,
@@ -374,18 +364,12 @@ describe("useChat", () => {
personaId: undefined,
});
expect(mockAcpSetModel).toHaveBeenCalledWith("session-1", "gpt-4.1");
expect(mockAcpSendMessage).toHaveBeenCalledWith(
"session-1",
"openai",
"Hello",
{
systemPrompt: undefined,
workingDir: undefined,
personaId: undefined,
personaName: undefined,
images: undefined,
},
);
expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "Hello", {
systemPrompt: undefined,
personaId: undefined,
personaName: undefined,
images: undefined,
});
});
it("appends an error message and removes the empty assistant placeholder when send fails", async () => {
@@ -1,67 +0,0 @@
import type { ModelOption } from "../types";
export interface AcpMessageCreatedPayload {
sessionId: string;
messageId: string;
personaId?: string;
personaName?: string;
}
export interface AcpTextPayload {
sessionId: string;
messageId: string;
text: string;
}
export interface AcpDonePayload {
sessionId: string;
messageId: string;
}
export interface AcpToolCallPayload {
sessionId: string;
messageId: string;
toolCallId: string;
title: string;
}
export interface AcpToolTitlePayload {
sessionId: string;
messageId: string;
toolCallId: string;
title: string;
}
export interface AcpToolResultPayload {
sessionId: string;
messageId: string;
content: string;
}
export interface AcpSessionInfoPayload {
sessionId: string;
title?: string;
}
export interface AcpSessionBoundPayload {
sessionId: string;
gooseSessionId: string;
}
export interface AcpModelStatePayload {
sessionId: string;
providerId?: string | null;
currentModelId: string;
currentModelName?: string;
availableModels: ModelOption[];
}
export interface AcpUsageUpdatePayload {
sessionId: string;
used: number;
size: number;
}
export interface AcpReplayCompletePayload {
sessionId: string;
}
@@ -1,553 +0,0 @@
import { useEffect } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useChatStore } from "../stores/chatStore";
import { useChatSessionStore } from "../stores/chatSessionStore";
import { isDefaultChatTitle } from "../lib/sessionTitle";
import type {
Message,
MessageCompletionStatus,
ToolRequestContent,
ToolResponseContent,
} from "@/shared/types/messages";
import {
ensureReplayBuffer,
getBufferedMessage,
getAndDeleteReplayBuffer,
findLatestUnpairedToolRequest,
} from "./replayBuffer";
import type {
AcpMessageCreatedPayload,
AcpTextPayload,
AcpDonePayload,
AcpToolCallPayload,
AcpToolTitlePayload,
AcpToolResultPayload,
AcpSessionInfoPayload,
AcpSessionBoundPayload,
AcpModelStatePayload,
AcpUsageUpdatePayload,
AcpReplayCompletePayload,
} from "./acpStreamTypes";
function getAssistantProviderId(sessionId: string): string | undefined {
const pending = useChatStore
.getState()
.getSessionRuntime(sessionId).pendingAssistantProviderId;
if (pending) return pending;
return useChatSessionStore.getState().getSession(sessionId)?.providerId;
}
function updateCompletionStatus(
message: Message,
completionStatus: MessageCompletionStatus,
): Message {
if (
completionStatus === "completed" &&
(message.metadata?.completionStatus === "stopped" ||
message.metadata?.completionStatus === "error")
) {
return message;
}
return {
...message,
metadata: {
...message.metadata,
completionStatus,
},
};
}
function shouldTrackStreamingEvent(
store: ReturnType<typeof useChatStore.getState>,
sessionId: string,
messageId: string,
): boolean {
const runtime = store.getSessionRuntime(sessionId);
const existingMessage = store.messagesBySession[sessionId]?.find(
(message) => message.id === messageId,
);
if (
existingMessage &&
(existingMessage.metadata?.completionStatus === "completed" ||
existingMessage.metadata?.completionStatus === "stopped" ||
existingMessage.metadata?.completionStatus === "error")
) {
return false;
}
if (existingMessage || runtime.streamingMessageId === messageId) {
return true;
}
return runtime.chatState === "thinking" || runtime.chatState === "streaming";
}
export function useAcpStream(enabled: boolean): void {
useEffect(() => {
if (!enabled) return;
let active = true;
const unlisteners: Promise<UnlistenFn>[] = [];
const replayTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const REPLAY_TIMEOUT_MS = 30_000;
const unsubscribeFlush = useChatStore.subscribe((state, prevState) => {
if (!active) return;
// Start timeout for newly-loading sessions
for (const sid of state.loadingSessionIds) {
if (!prevState.loadingSessionIds.has(sid) && !replayTimeouts.has(sid)) {
const timer = setTimeout(() => {
replayTimeouts.delete(sid);
const store = useChatStore.getState();
if (store.loadingSessionIds.has(sid)) {
console.warn(
`[stream] ${sid.slice(0, 8)} replay_complete not received within ${REPLAY_TIMEOUT_MS / 1000}s — showing error`,
);
store.setSessionLoading(sid, false);
store.setError(
sid,
"Session history failed to load. Try reloading.",
);
}
}, REPLAY_TIMEOUT_MS);
replayTimeouts.set(sid, timer);
}
}
// Clear timeouts and flush buffers for sessions that finished loading
for (const sid of prevState.loadingSessionIds) {
if (!state.loadingSessionIds.has(sid)) {
const timer = replayTimeouts.get(sid);
if (timer) {
clearTimeout(timer);
replayTimeouts.delete(sid);
}
const buffer = getAndDeleteReplayBuffer(sid);
if (buffer && buffer.length > 0) {
useChatStore.getState().setMessages(sid, buffer);
}
}
}
});
unlisteners.push(
listen<AcpReplayCompletePayload>("acp:replay_complete", (event) => {
if (!active) return;
useChatStore
.getState()
.setSessionLoading(event.payload.sessionId, false);
}),
);
unlisteners.push(
listen<AcpMessageCreatedPayload>("acp:message_created", (event) => {
if (!active) return;
console.log(
`[perf:stream] ${event.payload.sessionId.slice(0, 8)} message_created mid=${event.payload.messageId.slice(0, 8)} at ${performance.now().toFixed(1)}ms`,
);
const store = useChatStore.getState();
const { sessionId, messageId, personaId, personaName } = event.payload;
const providerId = getAssistantProviderId(sessionId);
if (store.loadingSessionIds.has(sessionId)) {
if (!getBufferedMessage(sessionId, messageId)) {
ensureReplayBuffer(sessionId).push({
id: messageId,
role: "assistant",
created: Date.now(),
content: [],
metadata: {
userVisible: true,
agentVisible: true,
personaId,
personaName,
providerId,
completionStatus: "inProgress",
},
});
}
return;
}
if (!shouldTrackStreamingEvent(store, sessionId, messageId)) {
return;
}
const existing = store.messagesBySession[sessionId]?.find(
(message) => message.id === messageId,
);
if (existing) {
store.updateMessage(sessionId, messageId, (message) => ({
...message,
metadata: {
...message.metadata,
personaId: message.metadata?.personaId ?? personaId,
personaName: message.metadata?.personaName ?? personaName,
providerId: message.metadata?.providerId ?? providerId,
},
}));
} else {
store.addMessage(sessionId, {
id: messageId,
role: "assistant",
created: Date.now(),
content: [],
metadata: {
userVisible: true,
agentVisible: true,
personaId,
personaName,
providerId,
completionStatus: "inProgress",
},
});
}
store.setPendingAssistantProvider(sessionId, null);
store.setStreamingMessageId(sessionId, messageId);
}),
);
unlisteners.push(
listen<AcpTextPayload>("acp:text", (event) => {
if (!active) return;
console.log(
`[perf:stream] ${event.payload.sessionId.slice(0, 8)} text mid=${event.payload.messageId.slice(0, 8)} len=${event.payload.text.length} at ${performance.now().toFixed(1)}ms`,
);
const store = useChatStore.getState();
const { sessionId, messageId, text } = event.payload;
if (store.loadingSessionIds.has(sessionId)) {
const msg = getBufferedMessage(sessionId, messageId);
if (msg) {
const last = msg.content[msg.content.length - 1];
if (last?.type === "text") {
(last as { type: "text"; text: string }).text += text;
} else {
msg.content.push({ type: "text", text });
}
}
return;
}
if (!shouldTrackStreamingEvent(store, sessionId, messageId)) {
return;
}
store.setStreamingMessageId(sessionId, messageId);
store.updateStreamingText(sessionId, text);
}),
);
unlisteners.push(
listen<AcpDonePayload>("acp:done", (event) => {
if (!active) return;
console.log(
`[perf:stream] ${event.payload.sessionId.slice(0, 8)} done mid=${event.payload.messageId.slice(0, 8)} at ${performance.now().toFixed(1)}ms`,
);
const store = useChatStore.getState();
const { sessionId, messageId } = event.payload;
const isLoading = store.loadingSessionIds.has(sessionId);
if (isLoading) {
const msg = getBufferedMessage(sessionId, messageId);
if (msg) {
msg.content = msg.content.map((block) =>
block.type === "toolRequest" && block.status === "executing"
? { ...block, status: "completed" as const }
: block,
);
const updated = updateCompletionStatus(msg, "completed");
if (updated !== msg && updated.metadata) {
msg.metadata = updated.metadata;
}
}
return;
}
store.updateMessage(sessionId, messageId, (message) => {
const content = message.content.map((block) =>
block.type === "toolRequest" && block.status === "executing"
? { ...block, status: "completed" as const }
: block,
);
return updateCompletionStatus({ ...message, content }, "completed");
});
store.setPendingAssistantProvider(sessionId, null);
store.setStreamingMessageId(sessionId, null);
store.setChatState(sessionId, "idle");
if (useChatSessionStore.getState().activeSessionId !== sessionId) {
store.markSessionUnread(sessionId);
}
const sessionStore = useChatSessionStore.getState();
const session = sessionStore.getSession(sessionId);
if (session && isDefaultChatTitle(session.title)) {
const messages = store.messagesBySession[sessionId];
const firstUserMsg = messages?.find((m) => m.role === "user");
if (firstUserMsg) {
const textContent = firstUserMsg.content.find(
(c) => c.type === "text" && "text" in c,
);
if (textContent && "text" in textContent) {
const title = textContent.text.slice(0, 100);
sessionStore.updateSession(
sessionId,
{
title,
updatedAt: new Date().toISOString(),
},
{ persistOverlay: false },
);
}
}
}
}),
);
unlisteners.push(
listen<AcpToolCallPayload>("acp:tool_call", (event) => {
if (!active) return;
const store = useChatStore.getState();
const { sessionId, messageId, toolCallId, title } = event.payload;
if (store.loadingSessionIds.has(sessionId)) {
const msg = getBufferedMessage(sessionId, messageId);
if (msg) {
msg.content.push({
type: "toolRequest",
id: toolCallId,
name: title,
arguments: {},
status: "executing",
startedAt: Date.now(),
});
}
return;
}
if (!shouldTrackStreamingEvent(store, sessionId, messageId)) {
return;
}
const toolRequest: ToolRequestContent = {
type: "toolRequest",
id: toolCallId,
name: title,
arguments: {},
status: "executing",
startedAt: Date.now(),
};
store.setStreamingMessageId(sessionId, messageId);
store.appendToStreamingMessage(sessionId, toolRequest);
}),
);
unlisteners.push(
listen<AcpToolTitlePayload>("acp:tool_title", (event) => {
if (!active) return;
const { sessionId: sid, messageId, toolCallId, title } = event.payload;
const store = useChatStore.getState();
if (store.loadingSessionIds.has(sid)) {
const msg = getBufferedMessage(sid, messageId);
if (msg) {
const tc = msg.content.find(
(c) => c.type === "toolRequest" && c.id === toolCallId,
);
if (tc && tc.type === "toolRequest") {
(tc as ToolRequestContent).name = title;
}
}
return;
}
if (!shouldTrackStreamingEvent(store, sid, messageId)) {
return;
}
store.updateMessage(sid, messageId, (msg) => ({
...msg,
content: msg.content.map((c) =>
c.type === "toolRequest" && c.id === toolCallId
? { ...c, name: title }
: c,
),
}));
}),
);
unlisteners.push(
listen<AcpToolResultPayload>("acp:tool_result", (event) => {
if (!active) return;
const store = useChatStore.getState();
const { sessionId, messageId, content } = event.payload;
if (store.loadingSessionIds.has(sessionId)) {
const msg = getBufferedMessage(sessionId, messageId);
if (msg) {
const toolRequest = findLatestUnpairedToolRequest(msg.content);
if (toolRequest) {
const idx = msg.content.indexOf(toolRequest);
if (idx >= 0) {
msg.content[idx] = { ...toolRequest, status: "completed" };
}
}
msg.content.push({
type: "toolResponse",
id: toolRequest?.id ?? crypto.randomUUID(),
name: toolRequest?.name ?? "",
result: content,
isError: false,
});
}
return;
}
if (!shouldTrackStreamingEvent(store, sessionId, messageId)) {
return;
}
const streamingMessage = messageId
? store.messagesBySession[sessionId]?.find(
(message) => message.id === messageId,
)
: undefined;
const toolRequest = streamingMessage
? findLatestUnpairedToolRequest(streamingMessage.content)
: null;
store.updateMessage(sessionId, messageId, (message) => ({
...message,
content: message.content.map((block) =>
block.type === "toolRequest" && block.id === toolRequest?.id
? { ...block, status: "completed" }
: block,
),
}));
const toolResponse: ToolResponseContent = {
type: "toolResponse",
id: toolRequest?.id ?? crypto.randomUUID(),
name: toolRequest?.name ?? "",
result: content,
isError: false,
};
store.setStreamingMessageId(sessionId, messageId);
store.appendToStreamingMessage(sessionId, toolResponse);
}),
);
unlisteners.push(
listen<{ sessionId: string; messageId: string; text: string }>(
"acp:replay_user_message",
(event) => {
if (!active) return;
console.log(
`[perf:stream] ${event.payload.sessionId.slice(0, 8)} replay_user_message at ${performance.now().toFixed(1)}ms`,
);
const { sessionId, messageId, text } = event.payload;
ensureReplayBuffer(sessionId).push({
id: messageId,
role: "user",
created: Date.now(),
content: [{ type: "text", text }],
metadata: { userVisible: true, agentVisible: true },
});
},
),
);
unlisteners.push(
listen<AcpSessionBoundPayload>("acp:session_bound", (event) => {
if (!active) return;
useChatSessionStore
.getState()
.setSessionAcpId(
event.payload.sessionId,
event.payload.gooseSessionId,
);
}),
);
unlisteners.push(
listen<AcpSessionInfoPayload>("acp:session_info", (event) => {
if (!active) return;
const session = useChatSessionStore
.getState()
.getSession(event.payload.sessionId);
if (event.payload.title && !session?.userSetName) {
useChatSessionStore.getState().updateSession(
event.payload.sessionId,
{
title: event.payload.title,
},
{ persistOverlay: false },
);
}
}),
);
unlisteners.push(
listen<AcpModelStatePayload>("acp:model_state", (event) => {
if (!active) return;
const {
sessionId,
providerId,
currentModelId,
currentModelName,
availableModels,
} = event.payload;
const sessionStore = useChatSessionStore.getState();
if (providerId) {
sessionStore.cacheModelsForProvider(providerId, availableModels);
}
const session = sessionStore.getSession(sessionId);
const sessionProvider = session?.providerId;
if (providerId && sessionProvider && providerId !== sessionProvider) {
console.debug(
`[acp:model_state] Ignoring event: provider mismatch (event: ${providerId}, session: ${sessionProvider})`,
);
return;
}
const modelName = currentModelName ?? currentModelId;
sessionStore.setSessionModels(sessionId, availableModels);
if (!providerId && session?.modelId) {
return;
}
sessionStore.updateSession(
sessionId,
{
modelId: currentModelId,
modelName,
},
{ persistOverlay: false },
);
}),
);
unlisteners.push(
listen<AcpUsageUpdatePayload>("acp:usage_update", (event) => {
if (!active) return;
useChatStore.getState().updateTokenState(event.payload.sessionId, {
accumulatedTotal: event.payload.used,
contextLimit: event.payload.size,
});
}),
);
return () => {
active = false;
unsubscribeFlush();
for (const timer of replayTimeouts.values()) {
clearTimeout(timer);
}
replayTimeouts.clear();
for (const unlistenPromise of unlisteners) {
unlistenPromise.then((unlisten) => unlisten());
}
};
}, [enabled]);
}
+1 -4
View File
@@ -227,8 +227,6 @@ export function useChat(
}
}
// Send via ACP — response streams back through Tauri events
// which are handled by the global useAcpStream listener in AppShell.
store.setChatState(sessionId, "streaming");
// When images are present with no text, pass a single space so the ACP
// driver doesn't send an empty text content block that goose rejects.
@@ -236,9 +234,8 @@ export function useChat(
buildAttachmentPromptPreamble(attachments);
const promptBody = text.trim() || (images?.length ? " " : text);
const acpPrompt = `${attachmentPromptPreamble}${promptBody}`;
await acpSendMessage(sessionId, providerId, acpPrompt, {
await acpSendMessage(sessionId, acpPrompt, {
systemPrompt,
workingDir: workingDirOverride,
personaId: effectivePersonaInfo?.id,
personaName: effectivePersonaInfo?.name,
images: images?.map(
@@ -1,74 +0,0 @@
import { useEffect, useRef, useState, useCallback } from "react";
interface UseSSEOptions {
onMessage?: (event: MessageEvent) => void;
onError?: (error: Event) => void;
onOpen?: () => void;
enabled?: boolean;
}
interface UseSSEReturn {
close: () => void;
readyState: number;
}
/**
* Hook for managing an EventSource (SSE) connection lifecycle.
*
* Automatically connects when `enabled` is true (default) and disconnects
* on unmount or when `enabled` becomes false.
*/
export function useSSE(url: string, options?: UseSSEOptions): UseSSEReturn {
const { onMessage, onError, onOpen, enabled = true } = options ?? {};
const sourceRef = useRef<EventSource | null>(null);
const [readyState, setReadyState] = useState<number>(EventSource.CLOSED);
// Stable refs for callbacks to avoid reconnecting when handlers change
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const onOpenRef = useRef(onOpen);
onOpenRef.current = onOpen;
const close = useCallback(() => {
if (sourceRef.current) {
sourceRef.current.close();
sourceRef.current = null;
setReadyState(EventSource.CLOSED);
}
}, []);
useEffect(() => {
if (!enabled || !url) {
close();
return;
}
const source = new EventSource(url);
sourceRef.current = source;
source.onopen = () => {
setReadyState(EventSource.OPEN);
onOpenRef.current?.();
};
source.onmessage = (event) => {
onMessageRef.current?.(event);
};
source.onerror = (event) => {
setReadyState(source.readyState);
onErrorRef.current?.(event);
};
setReadyState(EventSource.CONNECTING);
return () => {
source.close();
sourceRef.current = null;
};
}, [url, enabled, close]);
return { close, readyState };
}
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { listen } from "@tauri-apps/api/event";
import { FilesList } from "./FilesList";
import { useGitState } from "@/shared/hooks/useGitState";
import { useChangedFiles } from "@/shared/hooks/useChangedFiles";
@@ -161,17 +160,6 @@ export function ContextPanel({
void refetchAll();
}, [refetchAll]);
useEffect(() => {
const unlisten = listen<{ sessionId: string }>("acp:done", (event) => {
if (event.payload.sessionId === sessionId) {
void refetchFiles();
}
});
return () => {
void unlisten.then((fn) => fn());
};
}, [sessionId, refetchFiles]);
return (
<Tabs
value={activeTab}
@@ -28,10 +28,6 @@ vi.mock("@/shared/hooks/useChangedFiles", () => ({
}),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: () => Promise.resolve(() => {}),
}));
vi.mock("@tauri-apps/plugin-opener", () => ({
openPath: vi.fn(),
}));
+62 -65
View File
@@ -491,73 +491,70 @@ export function Sidebar({
})}
</div>
{!collapsed && (
<>
{sidebarSearch.submittedQuery ? (
<div className="relative z-10 space-y-2">
{sidebarSearch.error && (
<p className="px-1 text-xs text-danger">
{t("search.error")}
</p>
{!collapsed &&
(sidebarSearch.submittedQuery ? (
<div className="relative z-10 space-y-2">
{sidebarSearch.error && (
<p className="px-1 text-xs text-danger">
{t("search.error")}
</p>
)}
{sidebarSearch.isSearching &&
sidebarSearch.results.length === 0 && (
<div className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-xs text-muted-foreground">
{t("search.searching")}
</div>
)}
{sidebarSearch.isSearching &&
sidebarSearch.results.length === 0 && (
<div className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-xs text-muted-foreground">
{t("search.searching")}
</div>
)}
{(!sidebarSearch.isSearching ||
sidebarSearch.results.length > 0) && (
<SidebarSearchResults
results={sidebarSearch.results}
activeSessionId={activeSessionId}
onSelectResult={(sessionId, messageId) => {
if (messageId) {
onSelectSearchResult?.(
sessionId,
messageId,
sidebarSearch.submittedQuery,
);
return;
}
onSelectSession?.(sessionId);
}}
getPersonaName={sidebarResolvers.getPersonaName}
getProjectName={sidebarResolvers.getProjectName}
/>
)}
</div>
) : (
<SidebarProjectsSection
projects={projects}
projectSessions={projectSessions}
expandedProjects={expandedProjects}
toggleProject={toggleProject}
collapsed={collapsed}
labelTransition={labelTransition}
labelVisible={labelVisible}
activeSessionId={activeSessionId}
activeProjectId={activeProjectId}
onNavigate={onNavigate}
onSelectSession={onSelectSession}
onNewChatInProject={onNewChatInProject}
onNewChat={onNewChat}
onCreateProject={onCreateProject}
onEditProject={onEditProject}
onArchiveProject={onArchiveProject}
onArchiveChat={onArchiveChat}
onRenameChat={onRenameChat}
onMoveToProject={onMoveToProject}
onReorderProject={onReorderProject}
onItemMouseEnter={onItemMouseEnter}
activeSessionRefCallback={activeSessionRefCallback}
activeProjectRefCallback={activeProjectRefCallback}
/>
)}
</>
)}
{(!sidebarSearch.isSearching ||
sidebarSearch.results.length > 0) && (
<SidebarSearchResults
results={sidebarSearch.results}
activeSessionId={activeSessionId}
onSelectResult={(sessionId, messageId) => {
if (messageId) {
onSelectSearchResult?.(
sessionId,
messageId,
sidebarSearch.submittedQuery,
);
return;
}
onSelectSession?.(sessionId);
}}
getPersonaName={sidebarResolvers.getPersonaName}
getProjectName={sidebarResolvers.getProjectName}
/>
)}
</div>
) : (
<SidebarProjectsSection
projects={projects}
projectSessions={projectSessions}
expandedProjects={expandedProjects}
toggleProject={toggleProject}
collapsed={collapsed}
labelTransition={labelTransition}
labelVisible={labelVisible}
activeSessionId={activeSessionId}
activeProjectId={activeProjectId}
onNavigate={onNavigate}
onSelectSession={onSelectSession}
onNewChatInProject={onNewChatInProject}
onNewChat={onNewChat}
onCreateProject={onCreateProject}
onEditProject={onEditProject}
onArchiveProject={onArchiveProject}
onArchiveChat={onArchiveChat}
onRenameChat={onRenameChat}
onMoveToProject={onMoveToProject}
onReorderProject={onReorderProject}
onItemMouseEnter={onItemMouseEnter}
activeSessionRefCallback={activeSessionRefCallback}
activeProjectRefCallback={activeProjectRefCallback}
/>
))}
</nav>
</div>
</div>
+49 -122
View File
@@ -1,5 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import { USE_DIRECT_ACP } from "./acpFeatureFlag";
import type { ContentBlock } from "@agentclientprotocol/sdk";
import * as directAcp from "./acpApi";
import * as sessionTracker from "./acpSessionTracker";
import {
@@ -15,7 +14,6 @@ export interface AcpProvider {
export interface AcpSendMessageOptions {
systemPrompt?: string;
workingDir?: string;
personaId?: string;
personaName?: string;
/** Image attachments as [base64Data, mimeType] pairs. */
@@ -29,63 +27,40 @@ export interface AcpPrepareSessionOptions {
/** Discover ACP providers installed on the system. */
export async function discoverAcpProviders(): Promise<AcpProvider[]> {
if (USE_DIRECT_ACP) {
return directAcp.listProviders();
}
return invoke("discover_acp_providers");
return directAcp.listProviders();
}
/** Send a message to an ACP agent. Response streams via Tauri events. */
export async function acpSendMessage(
sessionId: string,
providerId: string,
prompt: string,
options: AcpSendMessageOptions = {},
): Promise<void> {
if (USE_DIRECT_ACP) {
const { systemPrompt, personaId, images } = options;
const { systemPrompt, personaId, images } = options;
const gooseSessionId = sessionTracker.getGooseSessionId(
sessionId,
personaId,
);
if (!gooseSessionId) {
throw new Error("Session not prepared. Call acpPrepareSession first.");
}
const hasSystem = systemPrompt && systemPrompt.trim().length > 0;
const effectivePrompt = hasSystem
? `<persona-instructions>\n${systemPrompt}\n</persona-instructions>\n\n<user-message>\n${prompt}\n</user-message>`
: prompt;
const content: import("@agentclientprotocol/sdk").ContentBlock[] = [
{ type: "text", text: effectivePrompt },
];
if (images) {
for (const [data, mimeType] of images) {
content.push({ type: "image", data, mimeType } as any);
}
}
const messageId = crypto.randomUUID();
setActiveMessageId(gooseSessionId, messageId);
await directAcp.prompt(gooseSessionId, content);
clearActiveMessageId(gooseSessionId);
return;
const gooseSessionId = sessionTracker.getGooseSessionId(sessionId, personaId);
if (!gooseSessionId) {
throw new Error("Session not prepared. Call acpPrepareSession first.");
}
const { systemPrompt, workingDir, personaId, personaName, images } = options;
return invoke("acp_send_message", {
sessionId,
providerId,
prompt,
systemPrompt: systemPrompt ?? null,
workingDir: workingDir ?? null,
personaId: personaId ?? null,
personaName: personaName ?? null,
images: images ?? [],
});
const hasSystem = systemPrompt && systemPrompt.trim().length > 0;
const effectivePrompt = hasSystem
? `<persona-instructions>\n${systemPrompt}\n</persona-instructions>\n\n<user-message>\n${prompt}\n</user-message>`
: prompt;
const content: ContentBlock[] = [{ type: "text", text: effectivePrompt }];
if (images) {
for (const [data, mimeType] of images) {
content.push({ type: "image", data, mimeType } as ContentBlock);
}
}
const messageId = crypto.randomUUID();
setActiveMessageId(gooseSessionId, messageId);
await directAcp.prompt(gooseSessionId, content);
clearActiveMessageId(gooseSessionId);
}
/** Prepare or warm an ACP session ahead of the first prompt. */
@@ -94,37 +69,21 @@ export async function acpPrepareSession(
providerId: string,
options: AcpPrepareSessionOptions = {},
): Promise<void> {
if (USE_DIRECT_ACP) {
const workingDir = options.workingDir ?? "~/.goose/artifacts";
await sessionTracker.prepareSession(
sessionId,
providerId,
workingDir,
options.personaId,
);
return;
}
const { workingDir, personaId } = options;
return invoke("acp_prepare_session", {
const workingDir = options.workingDir ?? "~/.goose/artifacts";
await sessionTracker.prepareSession(
sessionId,
providerId,
workingDir: workingDir ?? null,
personaId: personaId ?? null,
});
workingDir,
options.personaId,
);
}
export async function acpSetModel(
sessionId: string,
modelId: string,
): Promise<void> {
if (USE_DIRECT_ACP) {
const gooseSessionId = sessionTracker.getGooseSessionId(sessionId);
return directAcp.setModel(gooseSessionId ?? sessionId, modelId);
}
return invoke("acp_set_model", {
sessionId,
modelId,
});
const gooseSessionId = sessionTracker.getGooseSessionId(sessionId);
return directAcp.setModel(gooseSessionId ?? sessionId, modelId);
}
/** Session info returned by the goose binary's list_sessions. */
@@ -145,77 +104,54 @@ export interface AcpSessionSearchResult {
/** List all sessions known to the goose binary. */
export async function acpListSessions(): Promise<AcpSessionInfo[]> {
if (USE_DIRECT_ACP) {
return directAcp.listSessions();
}
return invoke("acp_list_sessions");
return directAcp.listSessions();
}
export async function acpSearchSessions(
query: string,
sessionIds: string[],
): Promise<AcpSessionSearchResult[]> {
if (USE_DIRECT_ACP) {
return searchSessionsViaExports(query, sessionIds);
}
return invoke("acp_search_sessions", { query, sessionIds });
return searchSessionsViaExports(query, sessionIds);
}
/**
* Load an existing session from the goose binary.
*
* This triggers message replay via SessionNotification events that the
* frontend's useAcpStream hook picks up automatically.
* notification handler picks up automatically.
*/
export async function acpLoadSession(
sessionId: string,
gooseSessionId: string,
workingDir?: string,
): Promise<void> {
if (USE_DIRECT_ACP) {
const effectiveWorkingDir = workingDir ?? "~/.goose/artifacts";
await directAcp.loadSession(gooseSessionId, effectiveWorkingDir);
sessionTracker.registerSession(
sessionId,
gooseSessionId,
"goose",
effectiveWorkingDir,
);
return;
}
return invoke("acp_load_session", {
const effectiveWorkingDir = workingDir ?? "~/.goose/artifacts";
await directAcp.loadSession(gooseSessionId, effectiveWorkingDir);
sessionTracker.registerSession(
sessionId,
gooseSessionId,
workingDir: workingDir ?? null,
});
"goose",
effectiveWorkingDir,
);
}
/** Export a session as JSON via the goose binary. */
export async function acpExportSession(sessionId: string): Promise<string> {
if (USE_DIRECT_ACP) {
return directAcp.exportSession(sessionId);
}
return invoke("acp_export_session", { sessionId });
return directAcp.exportSession(sessionId);
}
/** Import a session from JSON via the goose binary. Returns new session metadata. */
export async function acpImportSession(json: string): Promise<AcpSessionInfo> {
if (USE_DIRECT_ACP) {
return directAcp.importSession(json);
}
return invoke("acp_import_session", { json });
return directAcp.importSession(json);
}
/** Duplicate (fork) a session via the goose binary. Returns new session metadata. */
export async function acpDuplicateSession(
sessionId: string,
): Promise<AcpSessionInfo> {
if (USE_DIRECT_ACP) {
const gooseSessionId =
sessionTracker.getGooseSessionId(sessionId) ?? sessionId;
return directAcp.forkSession(gooseSessionId);
}
return invoke("acp_duplicate_session", { sessionId });
const gooseSessionId =
sessionTracker.getGooseSessionId(sessionId) ?? sessionId;
return directAcp.forkSession(gooseSessionId);
}
/** Cancel an in-progress ACP session so the backend stops streaming. */
@@ -223,16 +159,7 @@ export async function acpCancelSession(
sessionId: string,
personaId?: string,
): Promise<boolean> {
if (USE_DIRECT_ACP) {
const gooseSessionId = sessionTracker.getGooseSessionId(
sessionId,
personaId,
);
await directAcp.cancelSession(gooseSessionId ?? sessionId);
return true;
}
return invoke("acp_cancel_session", {
sessionId,
personaId: personaId ?? null,
});
const gooseSessionId = sessionTracker.getGooseSessionId(sessionId, personaId);
await directAcp.cancelSession(gooseSessionId ?? sessionId);
return true;
}
+20 -8
View File
@@ -23,28 +23,40 @@ const DEPRECATED_PROVIDER_IDS = new Set(["claude-code", "codex", "gemini-cli"]);
export async function listProviders(): Promise<AcpProvider[]> {
const client = await getClient();
const result = await client.goose.GooseProvidersList({});
// biome-ignore lint/suspicious/noExplicitAny: ACP SDK types don't expose providers field
return (result as any).providers
.filter((p: any) => !DEPRECATED_PROVIDER_IDS.has(p.id))
.map((p: any) => ({ id: p.id, label: p.label }));
.filter(
(p: { id: string; label: string }) => !DEPRECATED_PROVIDER_IDS.has(p.id),
)
.map((p: { id: string; label: string }) => ({ id: p.id, label: p.label }));
}
export async function listSessions(): Promise<AcpSessionInfo[]> {
const client = await getClient();
// GooseClient.unstable_listSessions doesn't work with SDK 0.19 (renamed to listSessions).
// Bypass GooseClient and call the connection directly. Fix when ui/acp is updated.
// biome-ignore lint/suspicious/noExplicitAny: SDK doesn't expose conn property
const conn = (client as any).conn;
const response = await conn.listSessions({});
return response.sessions.map((info: any) => ({
sessionId: info.sessionId,
title: info.title ?? null,
updatedAt: info.updatedAt ?? null,
messageCount: (info._meta?.messageCount as number) ?? 0,
}));
return response.sessions.map(
(info: {
sessionId: string;
title?: string;
updatedAt?: string;
_meta?: Record<string, unknown>;
}) => ({
sessionId: info.sessionId,
title: info.title ?? null,
updatedAt: info.updatedAt ?? null,
messageCount: (info._meta?.messageCount as number) ?? 0,
}),
);
}
export async function exportSession(sessionId: string): Promise<string> {
const client = await getClient();
const result = await client.goose.GooseSessionExport({ sessionId });
// biome-ignore lint/suspicious/noExplicitAny: SDK doesn't expose data field on export result
return (result as any).data;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { GooseClient } from "@aaif/goose-acp";
import { GooseClient } from "@aaif/goose-sdk";
import {
PROTOCOL_VERSION,
type Client,
@@ -1 +0,0 @@
export const USE_DIRECT_ACP = true;
@@ -235,7 +235,7 @@ function handleLive(
...msg,
content: msg.content.map((c) =>
c.type === "toolRequest" && c.id === update.toolCallId
? { ...c, name: update.title! }
? { ...c, name: update.title ?? "" }
: c,
),
}));
@@ -310,7 +310,8 @@ function handleShared(sessionId: string, update: SessionUpdate): void {
};
if ("options" in configUpdate && Array.isArray(configUpdate.options)) {
const modelOption = configUpdate.options.find(
(opt: any) => opt.category === "model",
(opt: { category?: string; kind?: Record<string, unknown> }) =>
opt.category === "model",
);
if (modelOption?.kind?.type === "select") {
const select = modelOption.kind;
@@ -391,6 +392,7 @@ function findMessageWithToolCall(
}
function extractToolResultText(update: {
// biome-ignore lint/suspicious/noExplicitAny: ACP SDK ToolCallContent type is complex
content?: Array<any> | null;
rawOutput?: unknown;
}): string {
@@ -55,6 +55,7 @@ export function createWebSocketStream(wsUrl: string): Stream {
async pull(controller) {
await waitForMessage();
while (incoming.length > 0) {
// biome-ignore lint/style/noNonNullAssertion: length checked in while condition
controller.enqueue(incoming.shift()!);
}
if (closed && incoming.length === 0) {
+1 -1
View File
@@ -106,7 +106,7 @@ function searchSession(
};
}
function safeParse(json: string): Record<string, any> | null {
function safeParse(json: string): Record<string, unknown> | null {
try {
return JSON.parse(json);
} catch {
+3 -5
View File
@@ -1,8 +1,6 @@
// Provider types — these map to goose serve provider names.
// All sessions run through goose serve; the provider ID selects which
// backend provider goose uses for inference. The list is dynamic
// (fetched from the backend via discover_acp_providers) so this is a
// plain string rather than a narrow union.
// Provider types map to goose serve provider names.
// The provider list is dynamic, so this remains a plain string rather than
// a narrow union.
export type ProviderType = string;
export interface ProviderConfig {
+7
View File
@@ -0,0 +1,7 @@
export class GooseClient {
closed = Promise.resolve();
constructor(..._args: unknown[]) {}
async initialize(..._args: unknown[]): Promise<void> {}
}
@@ -124,8 +124,6 @@ export function buildInitScript(options?: {
return Promise.resolve("/tmp/avatars");
case "save_persona_avatar_bytes":
return Promise.resolve("avatar.png");
case "discover_acp_providers":
return Promise.resolve([]);
case "list_files_for_mentions":
return Promise.resolve([]);
case "get_home_dir":
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
"@aaif/goose-sdk": resolve(__dirname, "./src/test/mocks/goose-sdk.ts"),
},
},
test: {
+8 -18
View File
@@ -353,9 +353,9 @@ importers:
goose2:
dependencies:
'@aaif/goose-acp':
specifier: ^0.16.0
version: 0.16.0(@agentclientprotocol/sdk@0.19.0(zod@4.3.6))
'@aaif/goose-sdk':
specifier: workspace:*
version: link:../sdk
'@agentclientprotocol/sdk':
specifier: ^0.19.0
version: 0.19.0(zod@4.3.6)
@@ -468,8 +468,8 @@ importers:
specifier: ^2
version: 2.10.1
'@tauri-apps/plugin-dialog':
specifier: ^2.6.0
version: 2.7.0
specifier: ~2.6.0
version: 2.6.0
'@tauri-apps/plugin-opener':
specifier: ^2.5.3
version: 2.5.3
@@ -735,11 +735,6 @@ importers:
packages:
'@aaif/goose-acp@0.16.0':
resolution: {integrity: sha512-LJLn01pqobvQAsagScROFOnjKRSQPaDuP7v17oyrK1IDfvZzOM20ZkQcvGDWhYCRUHXWBJgJ9dD6iZkVtNYYwg==}
peerDependencies:
'@agentclientprotocol/sdk': '*'
'@acemir/cssom@0.9.31':
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
@@ -3500,8 +3495,8 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-dialog@2.7.0':
resolution: {integrity: sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==}
'@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
'@tauri-apps/plugin-opener@2.5.3':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
@@ -8854,11 +8849,6 @@ packages:
snapshots:
'@aaif/goose-acp@0.16.0(@agentclientprotocol/sdk@0.19.0(zod@4.3.6))':
dependencies:
'@agentclientprotocol/sdk': 0.19.0(zod@4.3.6)
zod: 3.25.76
'@acemir/cssom@0.9.31': {}
'@adobe/css-tools@4.4.4': {}
@@ -11804,7 +11794,7 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
'@tauri-apps/plugin-dialog@2.7.0':
'@tauri-apps/plugin-dialog@2.6.0':
dependencies:
'@tauri-apps/api': 2.10.1