mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Lifei/delete tauri backend acp (#8582)
Co-authored-by: Jack Amadeo <jackamadeo@squareup.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-7615
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
Generated
+2
-178
@@ -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"
|
||||
|
||||
@@ -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,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, ¤t_dir)?;
|
||||
|
||||
AcpService::send_prompt(
|
||||
app_handle,
|
||||
Arc::clone(®istry),
|
||||
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, ¤t_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, ¤t_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,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 ¬ification.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 ¬ification.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 ¬ification.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;
|
||||
});
|
||||
}
|
||||
@@ -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(®istry_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(
|
||||
®istry_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(®istry_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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
"@aaif/goose-sdk": resolve(__dirname, "./src/test/mocks/goose-sdk.ts"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Generated
+8
-18
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user