diff --git a/ui/goose2/acp-plus-migration-plan/00-overview.md b/ui/goose2/acp-plus-migration-plan/00-overview.md new file mode 100644 index 0000000000..b70b029069 --- /dev/null +++ b/ui/goose2/acp-plus-migration-plan/00-overview.md @@ -0,0 +1,104 @@ +# ACP-Plus Migration Plan: Overview + +## Goal + +Move all ACP protocol handling from the Rust Tauri backend into the TypeScript/WebView layer, so the frontend communicates directly with `goose serve` over HTTP+SSE. The Rust layer shrinks to a thin native shell responsible only for: + +1. Spawning and managing the `goose serve` child process +2. Providing the server URL to the frontend +3. Window management / OS integration + +Long-term, config, personas, skills, projects, git, doctor, and all other native operations will also move behind `goose serve` ACP extension methods — eliminating the Rust middleware entirely. + +## Current Architecture + +``` +Frontend (TS) + → invoke("acp_send_message") [Tauri IPC] + → GooseAcpManager [Rust singleton, dedicated thread] + → ClientSideConnection [Rust ACP client over WebSocket] + → goose serve ws://127.0.0.1:/acp [child process] + ← SessionNotification [ACP callback in Rust] + ← TauriMessageWriter [emits Tauri events] + ← listen("acp:text", ...) [Tauri event bus] + → Zustand store updates +``` + +## Target Architecture (Phase A) + +``` +Frontend (TS) + → GooseClient (HTTP+SSE) + → goose serve http://127.0.0.1:/acp [child process] + ← Client callbacks → direct Zustand store updates + +Tauri Rust shell: + - Spawn goose serve, expose URL + - Config/personas/skills/projects/git/doctor (temporary — Phase B removes these) + - Window management +``` + +## Target Architecture (Phase B — Long-Term) + +``` +Frontend (TS) + → GooseClient (HTTP+SSE) + → goose serve http://127.0.0.1:/acp + ← Client callbacks → direct Zustand store updates + +Tauri Rust shell (~200 lines): + - Spawn goose serve, expose URL + - Window management + - (nothing else) +``` + +## Steps + +| Step | File | Summary | +|------|------|---------| +| 01 | `01-expose-goose-serve-url.md` | Add Tauri command to expose the `goose serve` HTTP URL to the frontend | +| 02 | `02-add-acp-npm-dependencies.md` | Add `@aaif/goose-acp` and `@agentclientprotocol/sdk` to goose2 | +| 03 | `03-create-ts-acp-connection.md` | Create the singleton TypeScript ACP connection manager | +| 04 | `04-create-ts-notification-handler.md` | Port the Rust `SessionEventDispatcher` to TypeScript | +| 05 | `05-create-ts-session-manager.md` | Port session state management and ACP operations to TypeScript | +| 06 | `06-port-session-search.md` | Port session content search from Rust to TypeScript | +| 07 | `07-rewire-shared-api-acp.md` | Replace `invoke()` wrappers in `src/shared/api/acp.ts` with direct TS ACP calls | +| 08 | `08-rewire-hooks.md` | Remove `useAcpStream`, update `useChat`, `useAppStartup`, `AppShell` | +| 09 | `09-delete-rust-acp-code.md` | Delete the Rust ACP middleware and unused dependencies | +| 10 | `10-phase-b-future-native-migration.md` | Plan for moving config/personas/skills/projects/git/doctor to `goose serve` | + +## Ordering & Dependencies + +``` +01 ──┐ + ├──→ 03 ──→ 04 ──→ 05 ──→ 07 ──→ 08 ──→ 09 +02 ──┘ │ + └──→ 06 ──→ 07 +``` + +Steps 01 and 02 are independent and can be done in parallel. +Steps 03–06 build on each other but 06 can be done in parallel with 04/05. +Step 07 wires everything together. +Step 08 removes the old Tauri event listeners. +Step 09 is cleanup — only after everything works. +Step 10 is the Phase B roadmap. + +## Key Decisions + +1. **HTTP+SSE over WebSocket**: `goose serve` supports both. HTTP+SSE is already battle-tested by `ui/acp`'s `createHttpStream`. Visible in browser DevTools. Can switch to WS later if needed. + +2. **Direct store updates over event bus**: The notification handler calls Zustand store methods directly instead of emitting Tauri events. Eliminates a layer of indirection and the `useAcpStream` hook. + +3. **Reuse `@aaif/goose-acp`**: Already used by `ui/desktop` (Electron) and `ui/text` (Ink TUI). Provides `GooseClient`, `createHttpStream`, generated types, and Zod validators. + +4. **Auto-approve permissions**: Same as the current Rust implementation — accept the first option on all `request_permission` callbacks. + +## Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| Tauri CSP blocks localhost fetch | CSP is already `null` (disabled) in `tauri.conf.json` | +| `goose serve` not ready when frontend initializes | Rust still does readiness check; URL command only resolves after server is confirmed ready | +| HTTP+SSE performance vs WebSocket | Both transports are supported; can switch later. SSE has keep-alive. | +| Replay timing (notifications arriving after `loadSession` resolves) | Port the drain/stabilization logic from Rust, or rely on the `replay_complete` signal from the backend | +| Session state consistency during migration | Can keep old Rust path behind a flag initially; remove after validation | diff --git a/ui/goose2/acp-plus-migration-plan/01-expose-goose-serve-url.md b/ui/goose2/acp-plus-migration-plan/01-expose-goose-serve-url.md new file mode 100644 index 0000000000..9b2d424095 --- /dev/null +++ b/ui/goose2/acp-plus-migration-plan/01-expose-goose-serve-url.md @@ -0,0 +1,117 @@ +# Step 01: Expose the `goose serve` URL to the Frontend + +## Objective + +Add a single new Tauri command that returns the HTTP base URL of the running `goose serve` process. The frontend will use this URL to connect directly via HTTP+SSE. + +## Why + +Currently the Rust layer connects to `goose serve` over WebSocket internally and proxies everything. The frontend never knows the server URL. We need to expose it so the TypeScript ACP client can connect directly. + +## Changes + +### 1. Make `port` accessible on `GooseServeProcess` + +**File:** `src-tauri/src/services/acp/goose_serve.rs` + +Add a public getter for the port: + +```rust +impl GooseServeProcess { + /// Return the HTTP base URL for this server (used by the frontend). + pub fn http_url(&self) -> String { + format!("http://{LOCALHOST}:{}", self.port) + } + + // ... existing methods unchanged ... +} +``` + +The `port` field is already stored on the struct but is only used internally via `ws_url()`. The new `http_url()` method provides the HTTP variant. + +### 2. Add the Tauri command + +**File:** `src-tauri/src/commands/acp.rs` + +Add this command alongside the existing ones (it will coexist during migration): + +```rust +/// Return the HTTP base URL of the running goose serve process. +/// +/// This command blocks until the server is confirmed ready. The frontend +/// uses this URL to establish a direct HTTP+SSE ACP connection. +#[tauri::command] +pub async fn get_goose_serve_url() -> Result { + // GooseServeProcess::start() is idempotent — it returns immediately + // if the process is already running. + GooseServeProcess::start().await?; + let process = GooseServeProcess::get()?; + Ok(process.http_url()) +} +``` + +Add the necessary import at the top of the file if not already present: + +```rust +use crate::services::acp::goose_serve::GooseServeProcess; +``` + +Note: `GooseServeProcess` is currently re-exported from `services::acp` as `resolve_goose_binary` but the struct itself is used via `goose_serve::GooseServeProcess` internally. You may need to add a `pub use` in `services/acp/mod.rs`: + +```rust +pub(crate) use goose_serve::GooseServeProcess; +``` + +### 3. Register the command + +**File:** `src-tauri/src/lib.rs` + +Add the new command to the `invoke_handler` macro: + +```rust +commands::acp::get_goose_serve_url, +``` + +Place it near the other `commands::acp::*` entries. + +### 4. Verify CSP allows localhost fetch + +**File:** `src-tauri/tauri.conf.json` + +Check the `security.csp` field. Currently it is: + +```json +"security": { + "csp": null, + ... +} +``` + +`null` means CSP is disabled — **no changes needed**. The frontend can freely fetch from `http://127.0.0.1:*`. + +If CSP were ever re-enabled, you'd need to add: +``` +connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:* +``` + +## Verification + +1. Run `just tauri-check` (or `cargo check` in `src-tauri/`) to confirm the Rust compiles. +2. Run `cargo clippy --all-targets -- -D warnings` in `src-tauri/`. +3. Run `cargo fmt` in `src-tauri/`. +4. Manually test by adding a temporary `console.log(await invoke("get_goose_serve_url"))` in the frontend startup — it should print something like `http://127.0.0.1:54321`. + +## Files Modified + +| File | Change | +|------|--------| +| `src-tauri/src/services/acp/goose_serve.rs` | Add `http_url()` method | +| `src-tauri/src/services/acp/mod.rs` | Add `pub(crate) use goose_serve::GooseServeProcess` if needed | +| `src-tauri/src/commands/acp.rs` | Add `get_goose_serve_url` command | +| `src-tauri/src/lib.rs` | Register `get_goose_serve_url` in invoke_handler | + +## Notes + +- The existing ACP commands remain functional during migration. They will be removed in Step 09. +- `GooseServeProcess::start()` is idempotent — calling it multiple times is safe. The first call spawns the process; subsequent calls return immediately. +- The readiness check (WebSocket probe loop) ensures the URL is only returned after the server is accepting connections. diff --git a/ui/goose2/acp-plus-migration-plan/02-add-acp-npm-dependencies.md b/ui/goose2/acp-plus-migration-plan/02-add-acp-npm-dependencies.md new file mode 100644 index 0000000000..e069d2cfe1 --- /dev/null +++ b/ui/goose2/acp-plus-migration-plan/02-add-acp-npm-dependencies.md @@ -0,0 +1,150 @@ +# Step 02: Add ACP NPM Dependencies to goose2 + +## Objective + +Add `@aaif/goose-acp` and `@agentclientprotocol/sdk` as dependencies of the goose2 frontend so we can use the TypeScript ACP client. + +## Why + +The `@aaif/goose-acp` package (located at `ui/acp/` in the monorepo) already provides: + +- **`GooseClient`** — a full TypeScript ACP client wrapping `ClientSideConnection` +- **`createHttpStream`** — an HTTP+SSE transport that speaks the same protocol as `goose serve` +- **`GooseExtClient`** — generated typed client for Goose extension methods (`goose/providers/list`, `goose/session/export`, etc.) +- **Generated types + Zod validators** for all Goose ACP extension method request/response shapes + +This package is already used by `ui/desktop` (Electron) and `ui/text` (Ink TUI). goose2 currently does NOT depend on it. + +## Changes + +### 1. Add dependencies + +**File:** `ui/goose2/package.json` + +Run from the `ui/goose2/` directory: + +```bash +source ./bin/activate-hermit +pnpm add @aaif/goose-acp @agentclientprotocol/sdk +``` + +If the packages are not published to npm yet or you want to use the local workspace version, use the workspace protocol instead. Check `ui/pnpm-workspace.yaml` to see if `ui/acp` is included in the workspace. If it is: + +```bash +pnpm add @aaif/goose-acp@workspace:* @agentclientprotocol/sdk +``` + +If `ui/acp` is NOT in the pnpm workspace (goose2 has its own `pnpm-lock.yaml`), you have two options: + +**Option A — Link locally during development:** +```bash +cd ui/acp +npm run build +npm link + +cd ui/goose2 +pnpm link @aaif/goose-acp +pnpm add @agentclientprotocol/sdk +``` + +**Option B — Use the published npm package:** +```bash +cd ui/goose2 +pnpm add @aaif/goose-acp @agentclientprotocol/sdk +``` + +### 2. Verify the dependency resolves + +After installation, verify the imports work: + +```bash +cd ui/goose2 +# Quick typecheck +pnpm typecheck +``` + +Create a temporary test file to confirm imports resolve: + +```typescript +// src/shared/api/_test_acp_import.ts (DELETE AFTER VERIFICATION) +import { GooseClient, createHttpStream } from "@aaif/goose-acp"; +import type { Client, SessionNotification } from "@agentclientprotocol/sdk"; + +console.log("GooseClient:", GooseClient); +console.log("createHttpStream:", createHttpStream); +``` + +Run `pnpm typecheck` to confirm no type errors. Then delete the test file. + +### 3. Verify key exports are available + +The following imports must resolve — these are what Steps 03–06 will use: + +From `@aaif/goose-acp`: +```typescript +import { GooseClient, createHttpStream } from "@aaif/goose-acp"; +``` + +From `@agentclientprotocol/sdk`: +```typescript +import type { + Client, + SessionNotification, + SessionUpdate, + ContentBlock, + ToolCallContent, + RequestPermissionRequest, + RequestPermissionResponse, + NewSessionRequest, + NewSessionResponse, + LoadSessionRequest, + LoadSessionResponse, + PromptRequest, + PromptResponse, + CancelNotification, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, + ForkSessionRequest, + ForkSessionResponse, + ListSessionsRequest, + ListSessionsResponse, + InitializeRequest, + ProtocolVersion, + Implementation, + SessionModelState, + SessionInfoUpdate, + SessionConfigOption, + SessionConfigKind, + SessionConfigSelectOptions, + SessionConfigOptionCategory, +} from "@agentclientprotocol/sdk"; +``` + +### 4. Check `@agentclientprotocol/sdk` version compatibility + +The `@aaif/goose-acp` package declares `@agentclientprotocol/sdk` as a **peer dependency** (`"*"`). The Rust backend currently uses `agent-client-protocol = "0.10.4"`. The TypeScript SDK should be at a compatible version. + +Check `ui/acp/package.json` for the devDependency version — it currently shows `"@agentclientprotocol/sdk": "^0.14.1"`. Install the same or newer version: + +```bash +pnpm add @agentclientprotocol/sdk@^0.14.1 +``` + +## Verification + +1. `pnpm typecheck` passes with no errors related to the new dependencies. +2. `pnpm check` (Biome lint + file sizes) passes. +3. `pnpm test` still passes (no existing tests should break). + +## Files Modified + +| File | Change | +|------|--------| +| `package.json` | Add `@aaif/goose-acp` and `@agentclientprotocol/sdk` to dependencies | +| `pnpm-lock.yaml` | Auto-updated by pnpm | + +## Notes + +- The `@aaif/goose-acp` package exports `GooseClient` which wraps `ClientSideConnection` from `@agentclientprotocol/sdk`. It adds Goose-specific extension methods via `GooseExtClient`. +- The `createHttpStream` function creates a `Stream` (a `{ readable, writable }` pair of `ReadableStream` and `WritableStream`) that communicates with `goose serve` over HTTP POST + SSE. +- The HTTP+SSE transport works by: (1) POSTing JSON-RPC messages to `/acp`, (2) receiving responses and notifications via Server-Sent Events on the same connection. The first POST (initialize) establishes the SSE stream; subsequent POSTs use the `Acp-Session-Id` header. diff --git a/ui/goose2/acp-plus-migration-plan/03-create-ts-acp-connection.md b/ui/goose2/acp-plus-migration-plan/03-create-ts-acp-connection.md new file mode 100644 index 0000000000..5f3aa91f32 --- /dev/null +++ b/ui/goose2/acp-plus-migration-plan/03-create-ts-acp-connection.md @@ -0,0 +1,213 @@ +# Step 03: Create the TypeScript ACP Connection Manager + +## Objective + +Create a singleton module that manages the lifecycle of the `GooseClient` connection to `goose serve`. This is the TypeScript equivalent of the Rust `GooseAcpManager::start()` singleton. + +## Why + +All ACP operations (send prompt, list sessions, export, etc.) need a shared, initialized `GooseClient` instance. This module: + +1. Fetches the `goose serve` URL from the Rust backend (Step 01's command) +2. Creates a `GooseClient` with `createHttpStream` +3. Calls `client.initialize()` to complete the ACP handshake +4. Provides the initialized client to all other modules + +## New File + +### `src/shared/api/acpConnection.ts` + +```typescript +/** + * Singleton ACP connection manager. + * + * Manages the lifecycle of the GooseClient connection to goose serve. + * All ACP operations go through the client returned by getClient(). + */ +import { invoke } from "@tauri-apps/api/core"; +import { GooseClient, createHttpStream } from "@aaif/goose-acp"; +import type { Client, SessionNotification, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"; + +// Will be set by Step 04 — the notification handler +let notificationHandler: AcpNotificationHandler | null = null; + +/** + * Interface for the notification handler that processes ACP session events. + * Implemented in Step 04 (acpNotificationHandler.ts). + */ +export interface AcpNotificationHandler { + handleSessionNotification(notification: SessionNotification): Promise; +} + +/** + * Register the notification handler. Called once during app initialization + * after the handler is created in Step 04. + */ +export function setNotificationHandler(handler: AcpNotificationHandler): void { + notificationHandler = handler; +} + +// Singleton state +let clientPromise: Promise | null = null; +let resolvedClient: GooseClient | null = null; + +/** + * Build the Client implementation that the ACP SDK calls back into. + * + * This handles two callback types: + * - request_permission: auto-approve with the first option (same as Rust impl) + * - session_notification: delegate to the registered notification handler + */ +function createClientCallbacks(): () => Client { + return () => ({ + requestPermission: async ( + args: RequestPermissionRequest, + ): Promise => { + // Auto-approve with the first available option, matching the Rust behavior. + const optionId = args.options?.[0]?.optionId ?? "approve"; + return { + outcome: { + type: "selected", + optionId, + }, + }; + }, + + sessionNotification: async ( + notification: SessionNotification, + ): Promise => { + if (notificationHandler) { + await notificationHandler.handleSessionNotification(notification); + } + }, + }); +} + +/** + * Initialize the ACP connection. + * + * 1. Calls the Rust backend to get the goose serve URL + * 2. Creates a GooseClient with HTTP+SSE transport + * 3. Sends the ACP initialize handshake + * + * This is idempotent — calling it multiple times returns the same client. + */ +async function initializeConnection(): Promise { + // Get the goose serve URL from the Rust backend. + // This blocks until the server is confirmed ready. + const serverUrl: string = await invoke("get_goose_serve_url"); + + const client = new GooseClient( + createClientCallbacks(), + createHttpStream(serverUrl), + ); + + // Perform the ACP initialize handshake + await client.initialize({ + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { + name: "goose2", + version: "0.1.0", + }, + }); + + return client; +} + +/** + * Get the initialized GooseClient singleton. + * + * The first call triggers initialization (fetching the URL, creating the + * connection, running the ACP handshake). Subsequent calls return the + * same client immediately. + * + * Throws if initialization fails (e.g., goose serve is not running). + */ +export async function getClient(): Promise { + if (resolvedClient) { + return resolvedClient; + } + + if (!clientPromise) { + clientPromise = initializeConnection().then((client) => { + resolvedClient = client; + return client; + }).catch((error) => { + // Reset so the next call retries + clientPromise = null; + throw error; + }); + } + + return clientPromise; +} + +/** + * Check if the client has been initialized. + * Useful for guards that need to know if ACP is ready without triggering init. + */ +export function isClientReady(): boolean { + return resolvedClient !== null; +} + +/** + * Get the client synchronously, or null if not yet initialized. + * Use getClient() for the async version that triggers initialization. + */ +export function getClientSync(): GooseClient | null { + return resolvedClient; +} +``` + +## Architecture Notes + +### Singleton Pattern + +The module uses a promise-based singleton pattern: +- `clientPromise` ensures only one initialization runs at a time +- `resolvedClient` caches the result for synchronous access +- If initialization fails, `clientPromise` is reset so the next call retries + +This mirrors the Rust `OnceCell>` pattern in `manager.rs`. + +### Notification Handler Registration + +The notification handler is registered separately (in Step 04) rather than being passed to `createClientCallbacks` at construction time. This avoids a circular dependency: +- `acpConnection.ts` creates the client +- `acpNotificationHandler.ts` needs the client (to know about sessions) +- `acpNotificationHandler.ts` needs to be registered with the connection + +The `setNotificationHandler()` function breaks this cycle. + +### Protocol Version + +The `protocolVersion` in the initialize request should match what `goose serve` expects. The Rust code uses `ProtocolVersion::LATEST` from the `agent-client-protocol` crate. Check the `@agentclientprotocol/sdk` package for the equivalent constant — it may be exported as `LATEST_PROTOCOL_VERSION` or similar. If not, use the string `"2025-03-26"` (or whatever the current latest is). + +### Error Handling + +If `invoke("get_goose_serve_url")` fails (e.g., goose binary not found), the error propagates to the caller. The app startup code (Step 08) should handle this gracefully — showing an error state rather than crashing. + +## Verification + +1. `pnpm typecheck` passes. +2. `pnpm check` passes (Biome lint). +3. The module can be imported without side effects — initialization only happens when `getClient()` is called. + +## Files Created + +| File | Purpose | +|------|---------| +| `src/shared/api/acpConnection.ts` | Singleton ACP connection manager | + +## Dependencies + +- Step 01 (the `get_goose_serve_url` Tauri command must exist) +- Step 02 (`@aaif/goose-acp` and `@agentclientprotocol/sdk` must be installed) + +## Notes + +- The `createHttpStream` transport from `@aaif/goose-acp` handles the HTTP+SSE protocol details: POSTing JSON-RPC to `/acp`, reading SSE responses, managing the `Acp-Session-Id` header. +- The `GooseClient` constructor takes `() => Client` (a factory function that returns the callback object) and a `Stream` (the transport). It wraps `ClientSideConnection` from the SDK. +- The `requestPermission` callback shape may differ slightly between SDK versions. Check the `@agentclientprotocol/sdk` types for the exact interface. The Rust code returns `RequestPermissionResponse::new(RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)))` — the TS equivalent should match. +- The `initialize` request params shape may also vary. Check `InitializeRequest` in the SDK. The Rust code sends `InitializeRequest::new(ProtocolVersion::LATEST).client_info(Implementation::new("goose2", env!("CARGO_PKG_VERSION")))`. diff --git a/ui/goose2/acp-plus-migration-plan/04-create-ts-notification-handler.md b/ui/goose2/acp-plus-migration-plan/04-create-ts-notification-handler.md new file mode 100644 index 0000000000..e29b470ab5 --- /dev/null +++ b/ui/goose2/acp-plus-migration-plan/04-create-ts-notification-handler.md @@ -0,0 +1,340 @@ +# Step 04: Create the TypeScript Notification Handler + +## Objective + +Port the Rust `SessionEventDispatcher` (in `src-tauri/src/services/acp/manager/dispatcher.rs`) to TypeScript. This module receives ACP `SessionNotification` events and updates Zustand stores directly — replacing the current Tauri event bus (`acp:text`, `acp:tool_call`, etc.) and the `useAcpStream` hook. + +## Why + +Currently, ACP notifications flow through three layers: +1. Rust `SessionEventDispatcher` receives the ACP callback +2. Rust emits Tauri events (`acp:text`, `acp:done`, etc.) +3. TypeScript `useAcpStream` hook listens to those events and updates stores + +By handling notifications directly in TypeScript, we eliminate the Tauri event bus intermediary and the `useAcpStream` hook entirely. + +## New File + +### `src/shared/api/acpNotificationHandler.ts` + +This file implements the `AcpNotificationHandler` interface from Step 03 and contains all the logic currently split between `dispatcher.rs`, `writer.rs`, and `useAcpStream.ts`. + +```typescript +/** + * ACP notification handler — processes SessionNotification events from the + * GooseClient and updates Zustand stores directly. + * + * This replaces: + * - Rust: dispatcher.rs (SessionEventDispatcher, Client trait impl) + * - Rust: writer.rs (TauriMessageWriter) + * - TS: useAcpStream.ts (Tauri event listeners) + * - TS: replayBuffer.ts (replay buffering) + */ +``` + +## Key Data Structures to Port + +### Session Route Map + +The Rust `dispatcher.rs` maintains a `HashMap` that maps goose session IDs to local session IDs. Port this as: + +```typescript +interface SessionRoute { + localSessionId: string; + providerId: string | null; + /** When non-null, this session is actively streaming (live path). */ + activeMessageId: string | null; + /** When true, the session was cancelled mid-stream. */ + canceled: boolean; + /** Persona info for the active stream. */ + personaId: string | null; + personaName: string | null; +} + +// Module-level state +const routes = new Map(); +``` + +### Replay Buffer + +Port the replay buffer from `replayBuffer.ts` into this module (or keep it as a separate import). The key insight: during `loadSession`, notifications arrive for historical messages. These are buffered and flushed as a single `store.setMessages()` call when `replay_complete` is signaled. + +```typescript +import type { Message, MessageContent, ToolRequestContent } from "@/shared/types/messages"; + +const replayBuffers = new Map(); +``` + +## Notification Dispatch Logic + +Port the `session_notification` method from `dispatcher.rs`. The Rust code handles these `SessionUpdate` variants: + +### 1. `SessionInfoUpdate` + +```typescript +// Rust: self.emit_session_info(...) +// TS equivalent: +function handleSessionInfoUpdate(localSessionId: string, info: SessionInfoUpdate): void { + const session = useChatSessionStore.getState().getSession(localSessionId); + if (info.title && !session?.userSetName) { + useChatSessionStore.getState().updateSession(localSessionId, { + title: info.title, + }, { persistOverlay: false }); + } +} +``` + +### 2. `ConfigOptionUpdate` (model state) + +Port the `emit_model_state_from_options` logic. The Rust code extracts model options from `SessionConfigSelectOptions` (ungrouped or grouped) and emits them. In TS: + +```typescript +import type { ModelOption } from "@/features/chat/types"; + +function extractModelOptionsFromConfigOptions( + options: SessionConfigOption[], +): { currentModelId: string; currentModelName: string | null; availableModels: ModelOption[] } | null { + const modelOption = options.find( + (opt) => opt.category === "model" // SessionConfigOptionCategory::Model + ); + if (!modelOption || modelOption.kind.type !== "select") return null; + + const select = modelOption.kind; + const currentModelId = select.currentValue; + const availableModels: ModelOption[] = []; + + if (select.options.type === "ungrouped") { + for (const value of select.options.values) { + availableModels.push({ id: value.value, name: value.name }); + } + } else if (select.options.type === "grouped") { + for (const group of select.options.groups) { + for (const value of group.options) { + availableModels.push({ id: value.value, name: value.name }); + } + } + } + + const currentModelName = availableModels.find(m => m.id === currentModelId)?.name ?? null; + return { currentModelId, currentModelName, availableModels }; +} + +function handleModelState( + localSessionId: string, + providerId: string | null, + modelState: { currentModelId: string; currentModelName: string | null; availableModels: ModelOption[] }, +): void { + const sessionStore = useChatSessionStore.getState(); + if (providerId) { + sessionStore.cacheModelsForProvider(providerId, modelState.availableModels); + } + const session = sessionStore.getSession(localSessionId); + const sessionProvider = session?.providerId; + if (providerId && sessionProvider && providerId !== sessionProvider) { + return; // provider mismatch — ignore + } + const modelName = modelState.currentModelName ?? modelState.currentModelId; + sessionStore.setSessionModels(localSessionId, modelState.availableModels); + if (!providerId && session?.modelId) { + return; + } + sessionStore.updateSession(localSessionId, { + modelId: modelState.currentModelId, + modelName, + }, { persistOverlay: false }); +} +``` + +### 3. `AgentMessageChunk` (live streaming — text) + +When a route has an `activeMessageId` (live streaming path): + +```typescript +function handleLiveText(localSessionId: string, text: string): void { + const store = useChatStore.getState(); + store.updateStreamingText(localSessionId, text); +} +``` + +When in replay mode (no `activeMessageId`, session is loading): + +```typescript +function handleReplayText(localSessionId: string, gooseSessionId: string, text: string): void { + const buffer = replayBuffers.get(localSessionId); + if (!buffer) return; + const route = routes.get(gooseSessionId); + // Find or create the current assistant message in the buffer + // (same logic as useAcpStream's acp:text handler for loading sessions) + // ... +} +``` + +### 4. `ToolCall` and `ToolCallUpdate` + +Port the tool call handling from both the live and replay paths. The live path calls: +```typescript +store.appendToStreamingMessage(sessionId, toolRequest); +``` + +The replay path appends to the buffer message. + +### 5. `UserMessageChunk` (replay only) + +During replay, user messages arrive as `UserMessageChunk`. Port the `extract_user_message` helper: + +```typescript +function extractUserMessage(raw: string): string { + const openTag = "\n"; + const closeTag = "\n"; + const startIdx = raw.indexOf(openTag); + if (startIdx >= 0) { + const innerStart = startIdx + openTag.length; + if (raw.substring(innerStart).endsWith(closeTag)) { + return raw.substring(innerStart, raw.length - closeTag.length); + } + } + return raw; +} +``` + +### 6. Done / Finalize + +When a streaming message completes (the `prompt()` call resolves), the session manager (Step 05) calls a finalize method: + +```typescript +export function finalizeMessage(localSessionId: string, messageId: string): void { + const store = useChatStore.getState(); + store.updateMessage(localSessionId, messageId, (message) => { + const content = message.content.map((block) => + block.type === "toolRequest" && block.status === "executing" + ? { ...block, status: "completed" as const } + : block, + ); + return { + ...message, + content, + metadata: { ...message.metadata, completionStatus: "completed" }, + }; + }); + store.setStreamingMessageId(localSessionId, null); + store.setChatState(localSessionId, "idle"); + // ... title update logic from useAcpStream's acp:done handler +} +``` + +## Public API + +The module should export: + +```typescript +/** Register a goose session ID → local session ID binding. */ +export function bindSession(gooseSessionId: string, localSessionId: string, providerId?: string): void; + +/** Attach a "writer" for live streaming — sets the active message ID. */ +export function attachWriter(gooseSessionId: string, localSessionId: string, providerId: string | null, messageId: string, personaId?: string, personaName?: string): void; + +/** Clear the active writer after streaming completes. */ +export function clearWriter(gooseSessionId: string): void; + +/** Mark a session as cancelled. */ +export function markCanceled(gooseSessionId: string): boolean; + +/** Start replay buffering for a session. */ +export function startReplayBuffer(localSessionId: string): void; + +/** Finalize replay — flush buffer to store. */ +export function finalizeReplay(gooseSessionId: string): void; + +/** Flush the replay buffer for a session (called when loading completes). */ +export function flushReplayBuffer(localSessionId: string): void; + +/** Finalize a completed streaming message. */ +export function finalizeMessage(localSessionId: string, messageId: string): void; + +/** The main notification handler — implements AcpNotificationHandler from Step 03. */ +export function handleSessionNotification(notification: SessionNotification): Promise; +``` + +## Porting Checklist + +Port each piece from the Rust source, mapping to the TS equivalent: + +| Rust Source | Rust Function/Method | TS Equivalent | +|-------------|---------------------|---------------| +| `dispatcher.rs` | `SessionEventDispatcher::session_notification` | `handleSessionNotification()` | +| `dispatcher.rs` | `SessionEventDispatcher::bind_session` | `bindSession()` | +| `dispatcher.rs` | `SessionEventDispatcher::attach_writer` | `attachWriter()` | +| `dispatcher.rs` | `SessionEventDispatcher::clear_writer` | `clearWriter()` | +| `dispatcher.rs` | `SessionEventDispatcher::mark_canceled` | `markCanceled()` | +| `dispatcher.rs` | `SessionEventDispatcher::finalize_replay` | `finalizeReplay()` | +| `dispatcher.rs` | `SessionEventDispatcher::emit_session_info` | `handleSessionInfoUpdate()` | +| `dispatcher.rs` | `SessionEventDispatcher::emit_model_state` | `handleModelState()` | +| `dispatcher.rs` | `SessionEventDispatcher::emit_model_state_from_options` | `handleModelState()` via `extractModelOptionsFromConfigOptions()` | +| `dispatcher.rs` | `SessionEventDispatcher::emit_replay_complete` | `flushReplayBuffer()` + `store.setSessionLoading(false)` | +| `dispatcher.rs` | `extract_user_message` | `extractUserMessage()` | +| `dispatcher.rs` | `extract_content_preview` | `extractContentPreview()` | +| `writer.rs` | `TauriMessageWriter::append_text` | Handled inline in `handleSessionNotification` | +| `writer.rs` | `TauriMessageWriter::record_tool_call` | Handled inline in `handleSessionNotification` | +| `writer.rs` | `TauriMessageWriter::record_tool_result` | Handled inline in `handleSessionNotification` | +| `writer.rs` | `TauriMessageWriter::finalize` | `finalizeMessage()` | +| `useAcpStream.ts` | All event listeners | Replaced by `handleSessionNotification()` | +| `replayBuffer.ts` | Buffer management | Inlined or imported | + +## Store Methods Used + +The notification handler calls these existing Zustand store methods (no changes needed to the stores): + +**`useChatStore`:** +- `addMessage(sessionId, message)` +- `updateMessage(sessionId, messageId, updater)` +- `setMessages(sessionId, messages)` — for replay buffer flush +- `updateStreamingText(sessionId, text)` +- `appendToStreamingMessage(sessionId, content)` +- `setStreamingMessageId(sessionId, id)` +- `setChatState(sessionId, state)` +- `setPendingAssistantProvider(sessionId, null)` +- `setSessionLoading(sessionId, loading)` +- `markSessionUnread(sessionId)` +- `setError(sessionId, error)` + +**`useChatSessionStore`:** +- `updateSession(sessionId, patch, opts)` +- `setSessionAcpId(sessionId, acpSessionId)` +- `setSessionModels(sessionId, models)` +- `cacheModelsForProvider(providerId, models)` +- `getSession(sessionId)` + +## Registration + +During app initialization (Step 08), after creating the notification handler, register it with the connection manager: + +```typescript +import { setNotificationHandler } from "@/shared/api/acpConnection"; +import * as notificationHandler from "@/shared/api/acpNotificationHandler"; + +setNotificationHandler(notificationHandler); +``` + +## Verification + +1. `pnpm typecheck` passes. +2. `pnpm check` passes. +3. Unit tests for `extractUserMessage` and `extractContentPreview` (port the Rust tests from `dispatcher_tests.rs`). + +## Files Created + +| File | Purpose | +|------|---------| +| `src/shared/api/acpNotificationHandler.ts` | ACP notification handler — replaces dispatcher.rs + writer.rs + useAcpStream.ts | + +## Dependencies + +- Step 03 (`acpConnection.ts` must exist for the `AcpNotificationHandler` interface) +- Zustand stores (`useChatStore`, `useChatSessionStore`) — no changes needed + +## Notes + +- The Rust dispatcher uses `Arc>` for thread safety. In single-threaded JS, a plain `Map` suffices. +- The Rust dispatcher has a `replay_events` counter and `wait_for_replay_drain` for timing. In TS, we rely on the `replay_complete` signal from the backend (which the Rust code also emits after draining). The `loadSession` RPC resolves, then the backend sends remaining notifications, then sends `replay_complete`. We just need to handle the `replay_complete` notification to flush the buffer. +- The `SessionNotification` type from `@agentclientprotocol/sdk` has a `sessionId` field (the goose session ID) and an `update` field with the variant. Check the SDK types for the exact shape — it may differ slightly from the Rust `agent_client_protocol::SessionNotification`. +- The `shouldTrackStreamingEvent` guard from `useAcpStream.ts` should be ported — it prevents stale events from updating already-completed messages. diff --git a/ui/goose2/acp-plus-migration-plan/05-create-ts-session-manager.md b/ui/goose2/acp-plus-migration-plan/05-create-ts-session-manager.md new file mode 100644 index 0000000000..1b878b2d97 --- /dev/null +++ b/ui/goose2/acp-plus-migration-plan/05-create-ts-session-manager.md @@ -0,0 +1,495 @@ +# Step 05: Create the TypeScript Session Manager + +## Objective + +Port the session state management and ACP operations from the Rust `session_ops.rs`, `command_dispatch.rs`, and `registry.rs` to TypeScript. This module orchestrates all ACP calls (prepare session, send prompt, cancel, load, list, export, import, fork, set model, list providers). + +## Why + +The Rust `GooseAcpManager` + `session_ops` is the core orchestration layer that: +1. Tracks which goose sessions are prepared (composite key → goose session ID) +2. Creates or loads goose sessions on demand +3. Sets provider/model/working-dir on sessions +4. Sends prompts and coordinates with the notification handler for streaming +5. Handles cancellation +6. Provides session CRUD (list, export, import, fork) + +All of this is pure protocol logic with no native OS access — it belongs in TypeScript. + +## New File + +### `src/shared/api/acpSessionManager.ts` + +## Key Data Structures + +### Prepared Session Cache + +Port from `session_ops.rs`: + +```typescript +interface PreparedSession { + gooseSessionId: string; + providerId: string; + workingDir: string; +} + +/** Maps composite key (sessionId or sessionId__personaId) → PreparedSession */ +const preparedSessions = new Map(); +``` + +### Composite Key Helpers + +Port from `mod.rs`: + +```typescript +export function makeCompositeKey(sessionId: string, personaId?: string): string { + if (personaId && personaId.length > 0) { + return `${sessionId}__${personaId}`; + } + return sessionId; +} + +export function splitCompositeKey(key: string): { sessionId: string; personaId: string | null } { + const idx = key.indexOf("__"); + if (idx >= 0) { + const personaId = key.substring(idx + 2); + if (personaId.length > 0) { + return { sessionId: key.substring(0, idx), personaId }; + } + } + return { sessionId: key, personaId: null }; +} +``` + +### Running Session Tracking + +Port from `registry.rs`: + +```typescript +interface RunningSession { + compositeKey: string; + providerId: string; + startedAt: number; // Date.now() + assistantMessageId: string | null; + abortController: AbortController; +} + +const runningSessions = new Map(); +``` + +## Core Operations to Port + +### 1. `prepareSession` + +Port from `prepare_session_inner` in `session_ops.rs`. This is the most complex function — it: + +1. Checks if a session is already prepared for this composite key +2. If yes, reuses it (updating working dir / provider if changed) +3. If no, tries to load an existing goose session by ID +4. If that fails, creates a new goose session via `client.newSession()` +5. Binds the goose session ID to the local session ID in the notification handler +6. Sets the provider via `client.setSessionConfigOption()` if needed +7. Emits model state to the session store + +```typescript +export async function prepareSession( + compositeKey: string, + localSessionId: string, + providerId: string, + workingDir: string, +): Promise { + const client = await getClient(); + + // Check for existing prepared session + const existing = preparedSessions.get(compositeKey) ?? preparedSessions.get(localSessionId); + if (existing) { + bindSession(existing.gooseSessionId, localSessionId, providerId); + // Update working dir if changed + if (existing.workingDir !== workingDir) { + await client.goose.gooseWorkingDirUpdate({ sessionId: existing.gooseSessionId, workingDir }); + // ... update cache + } + // Update provider if changed + if (existing.providerId !== providerId) { + const response = await client.setSessionConfigOption({ + sessionId: existing.gooseSessionId, + optionId: "provider", + value: providerId, + }); + // ... emit model state from response.configOptions + } + return existing.gooseSessionId; + } + + // Try to load existing session + let gooseSessionId: string | null = null; + try { + const loadResponse = await client.loadSession({ + sessionId: localSessionId, + workingDir, + }); + gooseSessionId = localSessionId; + bindSession(gooseSessionId, localSessionId, providerId); + // ... handle model state from loadResponse + // ... update provider if needed + } catch { + // Session doesn't exist — create new + } + + if (!gooseSessionId) { + const meta: Record = {}; + if (providerId !== "goose") { + meta.provider = providerId; + } + const newResponse = await client.newSession({ + workingDir, + ...(Object.keys(meta).length > 0 ? { meta } : {}), + }); + gooseSessionId = newResponse.sessionId; + bindSession(gooseSessionId, localSessionId, providerId); + // ... handle model state from newResponse + } + + // Cache the prepared session + const prepared: PreparedSession = { gooseSessionId, providerId, workingDir }; + preparedSessions.set(compositeKey, prepared); + preparedSessions.set(localSessionId, prepared); + + return gooseSessionId; +} +``` + +### 2. `sendPrompt` + +Port from `send_prompt_inner` in `prompt_ops.rs`: + +```typescript +export async function sendPrompt( + sessionId: string, + providerId: string, + prompt: string, + options: { + workingDir?: string; + systemPrompt?: string; + personaId?: string; + personaName?: string; + images?: [string, string][]; // [base64, mimeType] + } = {}, +): Promise { + const client = await getClient(); + const compositeKey = makeCompositeKey(sessionId, options.personaId); + + // Build effective prompt with persona instructions + const effectivePrompt = buildEffectivePrompt(prompt, options.systemPrompt); + + // Register running session + const abort = new AbortController(); + const assistantMessageId = crypto.randomUUID(); + runningSessions.set(compositeKey, { + compositeKey, + providerId, + startedAt: Date.now(), + assistantMessageId, + abortController: abort, + }); + + try { + // Prepare session (creates/loads goose session if needed) + const workingDir = options.workingDir ?? defaultArtifactsWorkingDir(); + const gooseSessionId = await prepareSession(compositeKey, sessionId, providerId, workingDir); + + // Attach writer in notification handler (so streaming events go to this message) + attachWriter(gooseSessionId, sessionId, providerId, assistantMessageId, options.personaId, options.personaName); + + // Build content blocks + const content: ContentBlock[] = [{ type: "text", text: effectivePrompt }]; + for (const [data, mimeType] of (options.images ?? [])) { + content.push({ type: "image", data, mimeType }); + } + + // Send the prompt — this blocks until the agent finishes + await client.prompt({ + sessionId: gooseSessionId, + content, + }); + + // Finalize the message + clearWriter(gooseSessionId); + finalizeMessage(sessionId, assistantMessageId); + } catch (error) { + clearWriter(/* gooseSessionId */); + throw error; + } finally { + runningSessions.delete(compositeKey); + } +} +``` + +### 3. `cancelSession` + +Port from `cancel_session_inner`: + +```typescript +export async function cancelSession(sessionId: string, personaId?: string): Promise { + const compositeKey = makeCompositeKey(sessionId, personaId); + const running = runningSessions.get(compositeKey); + + // Find the goose session ID + const prepared = preparedSessions.get(compositeKey) ?? preparedSessions.get(sessionId); + if (!prepared) { + return running !== undefined; // still preparing + } + + markCanceled(prepared.gooseSessionId); + + try { + const client = await getClient(); + await client.cancel({ sessionId: prepared.gooseSessionId }); + } catch { + // Best-effort cancellation + } + + return true; +} +``` + +### 4. `listSessions` + +```typescript +export interface AcpSessionInfo { + sessionId: string; + title: string | null; + updatedAt: string | null; + messageCount: number; +} + +export async function listSessions(): Promise { + const client = await getClient(); + const response = await client.unstable_listSessions({}); + return response.sessions.map((info) => ({ + sessionId: info.sessionId, + title: info.title ?? null, + updatedAt: info.updatedAt ?? null, + messageCount: (info.meta?.messageCount as number) ?? 0, + })); +} +``` + +### 5. `loadSession` + +Port from `load_session_inner`: + +```typescript +export async function loadSession( + localSessionId: string, + gooseSessionId: string, + workingDir: string, +): Promise { + const client = await getClient(); + + // Start replay buffering + bindSession(gooseSessionId, localSessionId); + startReplayBuffer(localSessionId); + + const response = await client.loadSession({ + sessionId: gooseSessionId, + workingDir, + }); + + // The backend sends replay notifications asynchronously. + // We wait for the replay_complete signal (handled by the notification handler) + // which calls flushReplayBuffer(). + + // Handle model state from response + if (response.models) { + handleModelState(localSessionId, null, /* extract from response.models */); + } + if (response.configOptions) { + const modelState = extractModelOptionsFromConfigOptions(response.configOptions); + if (modelState) handleModelState(localSessionId, null, modelState); + } + + // Register in prepared sessions cache + preparedSessions.set(localSessionId, { + gooseSessionId, + providerId: "goose", // will be updated on next prepare + workingDir, + }); +} +``` + +### 6. `exportSession`, `importSession`, `forkSession` + +```typescript +export async function exportSession(sessionId: string): Promise { + const client = await getClient(); + const result = await client.goose.gooseSessionExport({ sessionId }); + return result.data; +} + +export async function importSession(json: string): Promise { + const client = await getClient(); + return await client.goose.gooseSessionImport({ data: json }); +} + +export async function forkSession(sessionId: string): Promise { + const client = await getClient(); + const response = await client.unstable_forkSession({ + sessionId, + workingDir: defaultArtifactsWorkingDir(), + }); + return { + sessionId: response.sessionId, + title: (response.meta?.title as string) ?? null, + updatedAt: null, + messageCount: (response.meta?.messageCount as number) ?? 0, + }; +} +``` + +### 7. `setModel` + +```typescript +export async function setModel(localSessionId: string, modelId: string): Promise { + const client = await getClient(); + + // Find all prepared sessions for this local session ID + for (const [key, prepared] of preparedSessions) { + const { sessionId } = splitCompositeKey(key); + if (sessionId !== localSessionId) continue; + + const response = await client.setSessionConfigOption({ + sessionId: prepared.gooseSessionId, + optionId: "model", + value: modelId, + }); + // Emit model state from response + const modelState = extractModelOptionsFromConfigOptions(response.configOptions); + if (modelState) handleModelState(localSessionId, prepared.providerId, modelState); + } +} +``` + +### 8. `listProviders` + +```typescript +const DEPRECATED_PROVIDER_IDS = new Set(["claude-code", "codex", "gemini-cli"]); + +export interface AcpProvider { + id: string; + label: string; +} + +export async function listProviders(): Promise { + const client = await getClient(); + const result = await client.goose.gooseProvidersList({}); + return result.providers + .filter((p: { id: string }) => !DEPRECATED_PROVIDER_IDS.has(p.id)) + .map((p: { id: string; label: string }) => ({ id: p.id, label: p.label })); +} +``` + +### 9. `listRunning` + +```typescript +export interface AcpRunningSession { + sessionId: string; + personaId: string | null; + providerId: string; + runningForSecs: number; +} + +export function listRunning(): AcpRunningSession[] { + const now = Date.now(); + return [...runningSessions.values()].map((entry) => { + const { sessionId, personaId } = splitCompositeKey(entry.compositeKey); + return { + sessionId, + personaId, + providerId: entry.providerId, + runningForSecs: Math.floor((now - entry.startedAt) / 1000), + }; + }); +} +``` + +### 10. `cancelAll` + +```typescript +export function cancelAll(): void { + for (const entry of runningSessions.values()) { + entry.abortController.abort(); + } +} +``` + +## Helper: Build Effective Prompt + +Port the persona instruction wrapping from `mod.rs`: + +```typescript +function buildEffectivePrompt(prompt: string, systemPrompt?: string): string { + if (!systemPrompt || systemPrompt.trim().length === 0) { + return prompt; + } + return [ + `\n${systemPrompt}\n`, + `\n${prompt}\n`, + ].join("\n\n"); +} +``` + +## Helper: Default Artifacts Working Dir + +```typescript +function defaultArtifactsWorkingDir(): string { + // This will be fetched from the Rust backend via getHomeDir() + // or cached at startup. For now, use a reasonable default. + return "~/.goose/artifacts"; +} +``` + +Note: The Rust code resolves `~` to the actual home directory and creates the directory. In the TS version, the `goose serve` backend handles working directory resolution. We just need to pass a reasonable path. + +## Imports from Other Modules + +```typescript +import { getClient } from "./acpConnection"; +import { + bindSession, + attachWriter, + clearWriter, + markCanceled, + startReplayBuffer, + finalizeReplay, + flushReplayBuffer, + finalizeMessage, + handleModelState, + extractModelOptionsFromConfigOptions, +} from "./acpNotificationHandler"; +``` + +## Verification + +1. `pnpm typecheck` passes. +2. `pnpm check` passes. +3. Unit tests for `makeCompositeKey`, `splitCompositeKey`, `buildEffectivePrompt`. +4. Port relevant tests from `session_ops/tests.rs`. + +## Files Created + +| File | Purpose | +|------|---------| +| `src/shared/api/acpSessionManager.ts` | Session state management and ACP operations | + +## Dependencies + +- Step 03 (`acpConnection.ts` — provides `getClient()`) +- Step 04 (`acpNotificationHandler.ts` — provides bind/attach/clear/finalize functions) + +## Notes + +- The Rust code uses per-session `Mutex` locks (`op_locks`) to prevent concurrent mutations. In single-threaded JS, this isn't needed for correctness, but you may want a simple promise-based lock to prevent concurrent `prepareSession` calls for the same composite key from racing. +- The Rust `pending_cancels` / `preparing_sessions` sets coordinate cancellation during preparation. Port these as simple `Set` module-level variables. +- The `GooseExtClient` methods (e.g., `client.goose.gooseProvidersList()`) are generated from the ACP schema. Check the actual method names in `ui/acp/src/generated/client.gen.ts` — they may use camelCase versions of the `goose/providers/list` method name. +- The `client.prompt()` call blocks until the agent finishes responding. During this time, `SessionNotification` events stream in via the `Client` callback (handled by the notification handler). This is the same flow as the Rust code. diff --git a/ui/goose2/acp-plus-migration-plan/06-port-session-search.md b/ui/goose2/acp-plus-migration-plan/06-port-session-search.md new file mode 100644 index 0000000000..2070655651 --- /dev/null +++ b/ui/goose2/acp-plus-migration-plan/06-port-session-search.md @@ -0,0 +1,363 @@ +# Step 06: Port Session Content Search to TypeScript + +## Objective + +Port the session content search logic from `src-tauri/src/services/acp/search.rs` to TypeScript. This is pure text processing on exported JSON — no native access needed. + +## Why + +The Rust search code: +1. Exports each session as JSON via the ACP `goose/session/export` extension method +2. Parses the JSON to extract user/assistant/system messages +3. Performs case-insensitive substring matching +4. Builds snippets around the first match + +All of this is string processing that runs fine in JavaScript. Moving it to TypeScript eliminates the Rust→TS round-trip for each session export during search. + +## New File + +### `src/features/sessions/lib/sessionContentSearch.ts` + +```typescript +/** + * Search session message content via exported Goose sessions. + * + * Ported from src-tauri/src/services/acp/search.rs + */ +import { exportSession } from "@/shared/api/acpSessionManager"; // from Step 05 + +const SNIPPET_PREFIX_BYTES = 40; +const SNIPPET_SUFFIX_BYTES = 60; + +export interface SessionSearchResult { + sessionId: string; + snippet: string; + messageId: string; + messageRole?: "user" | "assistant" | "system"; + matchCount: number; +} +``` + +## Functions to Port + +### 1. `searchSessionsViaExports` + +The top-level function that iterates over session IDs, exports each, and searches: + +```typescript +export async function searchSessionsViaExports( + query: string, + sessionIds: string[], +): Promise { + const trimmed = query.trim(); + if (!trimmed) return []; + + const seen = new Set(); + const results: SessionSearchResult[] = []; + + for (const sessionId of sessionIds) { + if (seen.has(sessionId)) continue; + seen.add(sessionId); + + try { + const exported = await exportSession(sessionId); + const result = searchExportedSession(sessionId, exported, trimmed); + if (result) results.push(result); + } catch { + // Skip sessions that fail to export + } + } + + return results; +} +``` + +### 2. `searchExportedSession` + +```typescript +function searchExportedSession( + sessionId: string, + exportedJson: string, + query: string, +): SessionSearchResult | null { + let root: unknown; + try { + root = JSON.parse(exportedJson); + } catch { + return null; + } + + const obj = root as Record; + const conversation = obj.conversation ?? obj.messages; + if (!conversation) return null; + + const messages = extractMessages(conversation); + if (messages.length === 0) return null; + + let firstMatch: { messageId: string; role: string | null; snippet: string } | null = null; + let matchCount = 0; + + for (const message of messages) { + for (const text of message.searchableTexts) { + const occurrences = countOccurrences(text, query); + if (occurrences === 0) continue; + + matchCount += occurrences; + + if (!firstMatch) { + firstMatch = { + messageId: message.id, + role: message.role, + snippet: buildSnippet(text, query), + }; + } + } + } + + if (!firstMatch) return null; + + return { + sessionId, + snippet: firstMatch.snippet, + messageId: firstMatch.messageId, + messageRole: firstMatch.role as SessionSearchResult["messageRole"], + matchCount, + }; +} +``` + +### 3. `extractMessages` + +Recursively walks the JSON structure to find message objects: + +```typescript +interface ExportedMessage { + id: string; + role: string | null; + searchableTexts: string[]; +} + +function extractMessages(value: unknown): ExportedMessage[] { + const messages: ExportedMessage[] = []; + collectMessages(value, messages); + return messages; +} + +function collectMessages(value: unknown, messages: ExportedMessage[]): void { + if (Array.isArray(value)) { + for (const item of value) { + collectMessages(item, messages); + } + return; + } + + if (typeof value !== "object" || value === null) return; + const obj = value as Record; + + if (obj.message !== undefined) { + collectMessages(obj.message, messages); + return; + } + + if (obj.messages !== undefined) { + collectMessages(obj.messages, messages); + return; + } + + if (looksLikeMessage(obj)) { + const fallbackId = `message-${messages.length}`; + const message = extractMessage(obj, fallbackId); + if (message) messages.push(message); + } +} + +function looksLikeMessage(obj: Record): boolean { + return "role" in obj && ("content" in obj || "text" in obj); +} +``` + +### 4. `extractMessage` + +```typescript +function extractMessage( + obj: Record, + fallbackId: string, +): ExportedMessage | null { + const role = normalizeRole(obj.role as string | undefined); + const searchableTexts: string[] = []; + + if (obj.content !== undefined) { + searchableTexts.push(...extractSearchableTexts(obj.content, role)); + } else if (typeof obj.text === "string") { + if (role && obj.text.trim().length > 0) { + searchableTexts.push(obj.text.trim()); + } + } + + if (searchableTexts.length === 0) return null; + + return { + id: typeof obj.id === "string" ? obj.id : fallbackId, + role, + searchableTexts, + }; +} +``` + +### 5. `extractSearchableTexts` + +```typescript +function extractSearchableTexts(value: unknown, role: string | null): string[] { + if (typeof value === "string") { + if (role && isSearchableRole(role)) { + const trimmed = value.trim(); + return trimmed.length > 0 ? [trimmed] : []; + } + return []; + } + + if (Array.isArray(value)) { + return value.flatMap((item) => extractSearchableBlockText(item, role)); + } + + if (typeof value === "object" && value !== null) { + return extractSearchableBlockText(value, role); + } + + return []; +} + +function extractSearchableBlockText(value: unknown, role: string | null): string[] { + if (typeof value !== "object" || value === null) return []; + const obj = value as Record; + + const blockType = obj.type as string | undefined; + const text = obj.text as string | undefined; + + switch (blockType) { + case "text": + case "input_text": + case "output_text": + case "systemNotification": + case "system_notification": { + const trimmed = text?.trim(); + return trimmed && trimmed.length > 0 ? [trimmed] : []; + } + case "toolRequest": + case "toolResponse": + case "thinking": + case "redactedThinking": + case "reasoning": + case "image": + return []; + default: { + if (role && isSearchableRole(role)) { + const trimmed = text?.trim(); + return trimmed && trimmed.length > 0 ? [trimmed] : []; + } + return []; + } + } +} +``` + +### 6. Helper functions + +```typescript +function normalizeRole(role: string | undefined): string | null { + if (!role) return null; + const trimmed = role.trim().toLowerCase(); + if (trimmed === "user") return "user"; + if (trimmed === "assistant") return "assistant"; + if (trimmed === "system") return "system"; + return null; +} + +function isSearchableRole(role: string): boolean { + return role === "user" || role === "assistant" || role === "system"; +} + +function countOccurrences(text: string, query: string): number { + const haystack = text.toLowerCase(); + const needle = query.toLowerCase(); + if (needle.length === 0) return 0; + + let count = 0; + let searchStart = 0; + + while (true) { + const index = haystack.indexOf(needle, searchStart); + if (index === -1) break; + count += 1; + searchStart = index + needle.length; + } + + return count; +} + +function buildSnippet(text: string, query: string): string { + const haystack = text.toLowerCase(); + const needle = query.toLowerCase(); + const matchIndex = haystack.indexOf(needle); + const effectiveMatchIndex = matchIndex >= 0 ? matchIndex : 0; + + const start = Math.max(0, effectiveMatchIndex - SNIPPET_PREFIX_BYTES); + const end = Math.min( + text.length, + effectiveMatchIndex + query.length + SNIPPET_SUFFIX_BYTES, + ); + + const prefix = start > 0 ? "..." : ""; + const suffix = end < text.length ? "..." : ""; + const body = text.substring(start, end).trim(); + + return `${prefix}${body}${suffix}`; +} +``` + +## Tests to Port + +Port the tests from `search.rs` `mod tests`: + +```typescript +// src/features/sessions/lib/__tests__/sessionContentSearch.test.ts + +import { describe, it, expect } from "vitest"; +// Import the internal functions (may need to export them for testing) + +describe("sessionContentSearch", () => { + it("finds user and assistant text matches", () => { /* ... */ }); + it("includes system notifications", () => { /* ... */ }); + it("skips tool and reasoning content", () => { /* ... */ }); + it("counts multiple matches in one session", () => { /* ... */ }); + it("builds trimmed snippets around first match", () => { /* ... */ }); + // ... port all tests from search.rs +}); +``` + +## Integration with `useSessionSearch` + +The existing `useSessionSearch` hook calls `acpSearchSessions()` from `@/shared/api/acp`. In Step 07, that function will be rewired to call `searchSessionsViaExports` from this module instead of `invoke("acp_search_sessions")`. + +## Verification + +1. `pnpm typecheck` passes. +2. `pnpm check` passes. +3. `pnpm test` — all ported search tests pass. + +## Files Created + +| File | Purpose | +|------|---------| +| `src/features/sessions/lib/sessionContentSearch.ts` | Session content search logic | +| `src/features/sessions/lib/__tests__/sessionContentSearch.test.ts` | Tests | + +## Dependencies + +- Step 05 (`acpSessionManager.ts` — provides `exportSession()`) + +## Notes + +- The Rust `build_snippet` uses byte-level `floor_char_boundary` / `ceil_char_boundary` for UTF-8 safety. In JavaScript, `String.substring()` operates on UTF-16 code units and is already safe for slicing. The snippet boundaries may differ slightly from the Rust version for multi-byte characters, but this is cosmetic. +- The Rust search is sequential (one session at a time). The TS version could parallelize with `Promise.all` for better performance, but sequential is simpler and matches the Rust behavior. Consider parallelizing later if search is slow. +- The `exportSession` call goes through the ACP client to `goose serve`, which reads from its database. This is the same path as the Rust code — no change in data source. diff --git a/ui/goose2/acp-plus-migration-plan/07-rewire-shared-api-acp.md b/ui/goose2/acp-plus-migration-plan/07-rewire-shared-api-acp.md new file mode 100644 index 0000000000..ed3bd46143 --- /dev/null +++ b/ui/goose2/acp-plus-migration-plan/07-rewire-shared-api-acp.md @@ -0,0 +1,248 @@ +# Step 07: Rewire `src/shared/api/acp.ts` to Use the TypeScript ACP Client + +## Objective + +Replace all `invoke()` calls in `src/shared/api/acp.ts` with calls to the new TypeScript ACP session manager (Step 05) and search module (Step 06). Keep the same public API signatures so consumers don't need to change. + +## Why + +`src/shared/api/acp.ts` is the single import point for all ACP operations in the frontend. Currently every function calls `invoke("acp_*")` which goes through Tauri IPC → Rust → WebSocket → goose serve. After this step, they call the TypeScript session manager which goes directly through HTTP+SSE → goose serve. + +## Changes + +### `src/shared/api/acp.ts` + +Replace the entire file contents. The public API (function names, parameter types, return types) stays the same. Only the implementation changes. + +**Before (current):** +```typescript +import { invoke } from "@tauri-apps/api/core"; + +export async function discoverAcpProviders(): Promise { + return invoke("discover_acp_providers"); +} + +export async function acpSendMessage(sessionId, providerId, prompt, options): Promise { + return invoke("acp_send_message", { ... }); +} +// ... etc +``` + +**After:** +```typescript +import { + listProviders, + sendPrompt, + prepareSession, + setModel, + cancelSession, + listSessions, + loadSession, + exportSession, + importSession, + forkSession, + listRunning, + cancelAll, +} from "./acpSessionManager"; +import { searchSessionsViaExports } from "@/features/sessions/lib/sessionContentSearch"; + +// Re-export types (unchanged) +export type { AcpProvider, AcpSessionInfo, AcpRunningSession } from "./acpSessionManager"; +export type { SessionSearchResult as AcpSessionSearchResult } from "@/features/sessions/lib/sessionContentSearch"; +``` + +### Function-by-function rewiring + +#### `discoverAcpProviders` + +```typescript +export async function discoverAcpProviders(): Promise { + return listProviders(); +} +``` + +#### `acpSendMessage` + +```typescript +export async function acpSendMessage( + sessionId: string, + providerId: string, + prompt: string, + options: AcpSendMessageOptions = {}, +): Promise { + return sendPrompt(sessionId, providerId, prompt, { + workingDir: options.workingDir, + systemPrompt: options.systemPrompt, + personaId: options.personaId, + personaName: options.personaName, + images: options.images, + }); +} +``` + +#### `acpPrepareSession` + +```typescript +export async function acpPrepareSession( + sessionId: string, + providerId: string, + options: AcpPrepareSessionOptions = {}, +): Promise { + const { makeCompositeKey } = await import("./acpSessionManager"); + const compositeKey = makeCompositeKey(sessionId, options.personaId); + const workingDir = options.workingDir ?? "~/.goose/artifacts"; + await prepareSession(compositeKey, sessionId, providerId, workingDir); +} +``` + +#### `acpSetModel` + +```typescript +export async function acpSetModel( + sessionId: string, + modelId: string, +): Promise { + return setModel(sessionId, modelId); +} +``` + +#### `acpListSessions` + +```typescript +export async function acpListSessions(): Promise { + return listSessions(); +} +``` + +#### `acpSearchSessions` + +```typescript +export async function acpSearchSessions( + query: string, + sessionIds: string[], +): Promise { + return searchSessionsViaExports(query, sessionIds); +} +``` + +#### `acpLoadSession` + +```typescript +export async function acpLoadSession( + sessionId: string, + gooseSessionId: string, + workingDir?: string, +): Promise { + return loadSession(sessionId, gooseSessionId, workingDir ?? "~/.goose/artifacts"); +} +``` + +#### `acpExportSession` + +```typescript +export async function acpExportSession(sessionId: string): Promise { + return exportSession(sessionId); +} +``` + +#### `acpImportSession` + +```typescript +export async function acpImportSession(json: string): Promise { + return importSession(json); +} +``` + +#### `acpDuplicateSession` + +```typescript +export async function acpDuplicateSession(sessionId: string): Promise { + return forkSession(sessionId); +} +``` + +#### `acpCancelSession` + +```typescript +export async function acpCancelSession( + sessionId: string, + personaId?: string, +): Promise { + return cancelSession(sessionId, personaId); +} +``` + +### Keep the interface types + +The `AcpSendMessageOptions`, `AcpPrepareSessionOptions`, `AcpSessionInfo`, `AcpSessionSearchResult`, `AcpProvider` interfaces should remain exported from this file (or re-exported from the session manager). Consumers import them from `@/shared/api/acp`. + +If the types are now defined in `acpSessionManager.ts` or `sessionContentSearch.ts`, re-export them: + +```typescript +export type { AcpProvider, AcpSessionInfo } from "./acpSessionManager"; +export type { SessionSearchResult as AcpSessionSearchResult } from "@/features/sessions/lib/sessionContentSearch"; + +// Keep these interfaces here since they're specific to this API surface +export interface AcpSendMessageOptions { + systemPrompt?: string; + workingDir?: string; + personaId?: string; + personaName?: string; + images?: [string, string][]; +} + +export interface AcpPrepareSessionOptions { + workingDir?: string; + personaId?: string; +} +``` + +### Update `src/shared/api/index.ts` + +No changes needed — it already re-exports from `./acp`: + +```typescript +export * from "./acp"; +``` + +## Consumers That Import from `@/shared/api/acp` + +Verify these files still work without changes (they should, since the public API is unchanged): + +| File | Imports Used | +|------|-------------| +| `src/features/chat/hooks/useChat.ts` | `acpSendMessage`, `acpCancelSession`, `acpPrepareSession`, `acpSetModel` | +| `src/features/chat/stores/chatSessionStore.ts` | `acpListSessions`, `AcpSessionInfo` | +| `src/features/sessions/hooks/useSessionSearch.ts` | `acpSearchSessions` | +| `src/features/sessions/lib/buildSessionSearchResults.ts` | `AcpSessionSearchResult` | +| `src/app/AppShell.tsx` | `acpPrepareSession`, `acpLoadSession` | +| `src/app/hooks/useAppStartup.ts` | `discoverAcpProviders` | + +## Remove `invoke` Import + +The file should no longer import from `@tauri-apps/api/core` since all `invoke()` calls are replaced. + +## Verification + +1. `pnpm typecheck` passes — all consumers still type-check against the same API. +2. `pnpm check` passes. +3. `pnpm test` passes — existing tests that mock `invoke()` may need updating. Check `src/features/chat/hooks/__tests__/useAcpStream.test.ts` and `src/features/chat/hooks/__tests__/useChat.test.ts`. +4. Manual testing: start the app, verify sessions load, messages send, search works. + +## Files Modified + +| File | Change | +|------|--------| +| `src/shared/api/acp.ts` | Replace all `invoke()` calls with session manager / search calls | + +## Dependencies + +- Step 05 (`acpSessionManager.ts` — all session operations) +- Step 06 (`sessionContentSearch.ts` — search) + +## Notes + +- This is the "flip the switch" step. After this, the frontend no longer calls any `acp_*` Tauri commands (except `get_goose_serve_url` which is called by `acpConnection.ts`). +- The old Rust ACP commands still exist and are registered but are no longer called. They'll be removed in Step 09. +- If you want a gradual rollout, you could add a feature flag that switches between the old `invoke()` path and the new direct path. But since the public API is identical, it's simpler to just swap. +- The `@tauri-apps/api/core` import can be removed from this file entirely. Other files (agents, git, system, etc.) still use `invoke()` for non-ACP commands. diff --git a/ui/goose2/acp-plus-migration-plan/08-rewire-hooks.md b/ui/goose2/acp-plus-migration-plan/08-rewire-hooks.md new file mode 100644 index 0000000000..2841b0cd21 --- /dev/null +++ b/ui/goose2/acp-plus-migration-plan/08-rewire-hooks.md @@ -0,0 +1,259 @@ +# Step 08: Remove `useAcpStream`, Update Hooks and App Initialization + +## Objective + +Remove the `useAcpStream` hook (which listens to Tauri events) since the notification handler (Step 04) now updates stores directly. Update app initialization to set up the new ACP connection and notification handler. Update `useChat` and `AppShell` to use the new code paths. + +## Why + +With the notification handler updating Zustand stores directly from ACP callbacks, the Tauri event bus is no longer in the loop. The `useAcpStream` hook — which listens to `acp:text`, `acp:done`, `acp:tool_call`, etc. — is now dead code. + +## Changes + +### 1. Remove `useAcpStream` from `AppShell` + +**File:** `src/app/AppShell.tsx` + +Remove the import and call: + +```diff +- import { useAcpStream } from "@/features/chat/hooks/useAcpStream"; + + // Inside the component: +- useAcpStream(true); +``` + +### 2. Initialize the ACP connection and notification handler on startup + +**File:** `src/app/hooks/useAppStartup.ts` + +Add ACP initialization as the first step. The notification handler must be registered before any ACP calls are made (so that session notifications from `loadSessions` are handled). + +```typescript +import { useEffect } from "react"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; + +export function useAppStartup() { + useEffect(() => { + (async () => { + // Step 1: Initialize ACP connection and notification handler. + // This must happen before any ACP calls. + try { + const { getClient, setNotificationHandler } = await import( + "@/shared/api/acpConnection" + ); + const notificationHandler = await import( + "@/shared/api/acpNotificationHandler" + ); + setNotificationHandler(notificationHandler); + + // Trigger connection initialization (fetches URL, creates client, handshake). + // This blocks until goose serve is ready. + await getClient(); + } catch (err) { + console.error("Failed to initialize ACP connection:", err); + // The app can still show the UI, but ACP operations will fail. + // Individual operations will retry getClient() and show errors. + } + + // Step 2: Load data in parallel (same as before, but now using the TS ACP client) + const store = useAgentStore.getState(); + + const loadPersonas = async () => { + store.setPersonasLoading(true); + try { + const { listPersonas } = await import("@/shared/api/agents"); + const personas = await listPersonas(); + store.setPersonas(personas); + } catch (err) { + console.error("Failed to load personas on startup:", err); + } finally { + store.setPersonasLoading(false); + } + }; + + const loadProviders = async () => { + store.setProvidersLoading(true); + try { + const { discoverAcpProviders } = await import("@/shared/api/acp"); + const providers = await discoverAcpProviders(); + store.setProviders(providers); + } catch (err) { + console.error("Failed to load ACP providers on startup:", err); + } finally { + store.setProvidersLoading(false); + } + }; + + const loadSessionState = async () => { + const t0 = performance.now(); + console.log("[perf:startup] loadSessionState start"); + const { loadSessions, setActiveSession } = + useChatSessionStore.getState(); + await loadSessions(); + console.log( + `[perf:startup] loadSessions done in ${(performance.now() - t0).toFixed(1)}ms`, + ); + setActiveSession(null); + }; + + await Promise.allSettled([ + loadPersonas(), + loadProviders(), + loadSessionState(), + ]); + })(); + }, []); +} +``` + +### 3. Update `AppShell.loadSessionMessages` + +**File:** `src/app/AppShell.tsx` + +The `loadSessionMessages` callback currently dynamically imports `acpLoadSession` from `@/shared/api/acp`. This still works because Step 07 rewired that function. **No changes needed** to this callback — it already calls `acpLoadSession()` which now goes through the TS session manager. + +However, verify the import path is correct: + +```typescript +const { acpLoadSession } = await import("@/shared/api/acp"); +``` + +This should still work since `acpLoadSession` is still exported from `@/shared/api/acp` (Step 07 kept the same exports). + +### 4. Update `useChat` — no changes needed + +**File:** `src/features/chat/hooks/useChat.ts` + +This hook imports `acpSendMessage`, `acpCancelSession`, `acpPrepareSession`, `acpSetModel` from `@/shared/api/acp`. Since Step 07 kept the same API, **no changes are needed** in this file. + +Verify the imports still resolve: +```typescript +import { + acpSendMessage, + acpCancelSession, + acpPrepareSession, + acpSetModel, +} from "@/shared/api/acp"; +``` + +### 5. Handle app shutdown + +**File:** `src/app/AppShell.tsx` or `src/app/App.tsx` + +Add cleanup on window close to cancel running sessions: + +```typescript +import { useEffect } from "react"; + +useEffect(() => { + const handleBeforeUnload = () => { + // Best-effort cancel all running sessions + import("@/shared/api/acpSessionManager").then(({ cancelAll }) => { + cancelAll(); + }).catch(() => {}); + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); +}, []); +``` + +The Rust backend also cancels all sessions on `RunEvent::Exit` via `acp_registry_for_exit.cancel_all()`. After migration, the Rust registry is empty (no sessions are registered there), so the Rust cleanup is a no-op. The TS cleanup above replaces it. + +### 6. Delete or deprecate old files + +These files are no longer needed after this step: + +**Delete:** +- `src/features/chat/hooks/useAcpStream.ts` — replaced by `acpNotificationHandler.ts` +- `src/features/chat/hooks/acpStreamTypes.ts` — types are now in the notification handler or imported from the SDK +- `src/features/chat/hooks/replayBuffer.ts` — logic moved into the notification handler + +**Keep but verify:** +- `src/features/chat/hooks/useSSE.ts` — check if anything else uses it. If it was only used by `useAcpStream`, delete it. + +### 7. Update test files + +**Files to update or delete:** +- `src/features/chat/hooks/__tests__/useAcpStream.test.ts` — **Delete** (the hook no longer exists) +- `src/features/chat/hooks/__tests__/useChat.test.ts` — **Update** if it mocks `invoke()` for ACP commands. The mocks should now target the session manager functions instead. + +For `useChat.test.ts`, if it currently mocks like: +```typescript +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); +``` + +It should instead mock: +```typescript +vi.mock("@/shared/api/acp", () => ({ + acpSendMessage: vi.fn().mockResolvedValue(undefined), + acpPrepareSession: vi.fn().mockResolvedValue(undefined), + acpSetModel: vi.fn().mockResolvedValue(undefined), + acpCancelSession: vi.fn().mockResolvedValue(true), +})); +``` + +This may already be the case if the tests mock at the `@/shared/api/acp` level rather than `invoke()` directly. + +### 8. Remove Tauri event listener cleanup + +The `useAcpStream` hook registered listeners for `acp:text`, `acp:done`, `acp:tool_call`, `acp:tool_title`, `acp:tool_result`, `acp:message_created`, `acp:session_info`, `acp:session_bound`, `acp:model_state`, `acp:usage_update`, `acp:replay_complete`, `acp:replay_user_message`. These are all gone now. + +No other code should be listening to these events. Verify with a search: + +```bash +cd ui/goose2/src +rg "acp:" --include="*.ts" --include="*.tsx" | grep -v "__tests__" | grep -v "node_modules" +``` + +After this step, the only `acp:` references should be in test files (which should be updated/deleted) and possibly in the notification handler (which doesn't use Tauri events). + +## Verification + +1. `pnpm typecheck` passes. +2. `pnpm check` passes. +3. `pnpm test` passes (after updating/deleting affected tests). +4. Manual testing: + - App starts and shows the home screen + - Session list loads + - Creating a new chat and sending a message works + - Streaming text appears in real-time + - Tool calls display correctly + - Cancelling a running session works + - Loading a historical session replays messages + - Session search returns results + - Model switching works + - Session export/import/duplicate works + +## Files Modified + +| File | Change | +|------|--------| +| `src/app/AppShell.tsx` | Remove `useAcpStream(true)`, add shutdown cleanup | +| `src/app/hooks/useAppStartup.ts` | Add ACP connection + notification handler initialization | + +## Files Deleted + +| File | Reason | +|------|--------| +| `src/features/chat/hooks/useAcpStream.ts` | Replaced by `acpNotificationHandler.ts` | +| `src/features/chat/hooks/acpStreamTypes.ts` | Types moved to notification handler | +| `src/features/chat/hooks/replayBuffer.ts` | Logic moved to notification handler | +| `src/features/chat/hooks/__tests__/useAcpStream.test.ts` | Hook deleted | + +## Dependencies + +- Step 03 (`acpConnection.ts`) +- Step 04 (`acpNotificationHandler.ts`) +- Step 07 (rewired `acp.ts`) + +## Notes + +- The `useAcpStream` hook was the **only** consumer of the `acp:*` Tauri events. Once it's removed, no frontend code listens to those events. The Rust backend will still emit them (until Step 09 removes the Rust code), but they'll go nowhere — this is harmless. +- The `useChat` hook's `sendMessage` function currently sets `chatState` to `"thinking"` before calling `acpPrepareSession`, then to `"streaming"` before `acpSendMessage`. This flow is unchanged — the session manager handles the ACP calls, and the notification handler updates the store as streaming events arrive. +- The `stopGeneration` function in `useChat` calls `acpCancelSession` — this now goes through the TS session manager which calls `client.cancel()` directly. +- The `loadSessionMessages` callback in `AppShell` sets `store.setSessionLoading(sessionId, true)` before calling `acpLoadSession`. The notification handler's replay logic checks `loadingSessionIds` to decide whether to buffer. This flow is preserved — the notification handler reads from `useChatStore.getState().loadingSessionIds` just like `useAcpStream` did. diff --git a/ui/goose2/acp-plus-migration-plan/09-delete-rust-acp-code.md b/ui/goose2/acp-plus-migration-plan/09-delete-rust-acp-code.md new file mode 100644 index 0000000000..85421dfb87 --- /dev/null +++ b/ui/goose2/acp-plus-migration-plan/09-delete-rust-acp-code.md @@ -0,0 +1,393 @@ +# Step 09: Delete the Rust ACP Middleware and Unused Dependencies + +## Objective + +Remove all Rust ACP protocol handling code that is no longer called by the frontend. This is the cleanup step — only do this after Steps 01–08 are working and tested. + +## Why + +After Steps 01–08, the frontend communicates directly with `goose serve` via HTTP+SSE. The Rust ACP middleware (WebSocket bridge, session dispatcher, message writer, session registry, search) is dead code. Removing it: + +- Eliminates ~3,500 lines of Rust +- Removes 5–6 heavy crate dependencies +- Reduces compile times +- Simplifies the codebase + +## Changes + +### 1. Delete the ACP manager subtree + +**Delete these files entirely:** + +``` +src-tauri/src/services/acp/manager/ + command_dispatch.rs + dispatcher.rs + dispatcher_tests.rs + session_ops.rs + session_ops/ + prompt_ops.rs + tests.rs + thread.rs +``` + +**Delete these files:** + +``` +src-tauri/src/services/acp/manager.rs +src-tauri/src/services/acp/writer.rs +src-tauri/src/services/acp/payloads.rs +src-tauri/src/services/acp/registry.rs +src-tauri/src/services/acp/search.rs +``` + +### 2. Simplify `services/acp/mod.rs` + +**File:** `src-tauri/src/services/acp/mod.rs` + +Replace the entire file with: + +```rust +pub(crate) mod goose_serve; + +pub(crate) use goose_serve::GooseServeProcess; +``` + +All the old re-exports (`GooseAcpManager`, `AcpSessionRegistry`, `TauriMessageWriter`, `search_sessions_via_exports`, `make_composite_key`, `split_composite_key`, `AcpService`, `AcpRunningSession`, `AcpSessionInfo`, `SessionSearchResult`) are removed. + +### 3. Simplify `commands/acp.rs` + +**File:** `src-tauri/src/commands/acp.rs` + +Replace the entire file with just the URL command: + +```rust +use crate::services::acp::GooseServeProcess; + +/// Return the HTTP base URL of the running goose serve process. +/// +/// This command blocks until the server is confirmed ready. The frontend +/// uses this URL to establish a direct HTTP+SSE ACP connection. +#[tauri::command] +pub async fn get_goose_serve_url() -> Result { + GooseServeProcess::start().await?; + let process = GooseServeProcess::get()?; + Ok(process.http_url()) +} +``` + +All other ACP commands are deleted: +- `discover_acp_providers` +- `acp_prepare_session` +- `acp_set_model` +- `acp_send_message` +- `acp_cancel_session` +- `acp_list_sessions` +- `acp_search_sessions` +- `acp_load_session` +- `acp_list_running` +- `acp_cancel_all` +- `acp_export_session` +- `acp_import_session` +- `acp_duplicate_session` + +Also delete the helper functions that were only used by those commands: +- `AcpProviderResponse` struct +- `should_include_provider` +- `default_artifacts_working_dir` +- `expand_home_dir` +- `resolve_working_dir` +- The `#[cfg(test)] mod tests` block (the tests tested those helpers) + +### 4. Update `lib.rs` + +**File:** `src-tauri/src/lib.rs` + +Remove the `AcpSessionRegistry` from managed state and remove all old ACP command registrations. + +**Before:** +```rust +use std::sync::Arc; +use services::acp::AcpSessionRegistry; + +// ... +let acp_registry = Arc::new(AcpSessionRegistry::new()); +let acp_registry_for_exit = Arc::clone(&acp_registry); + +let builder = tauri::Builder::default() + // ... + .manage(acp_registry); + +// In invoke_handler: +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, + +// In run closure: +.run(move |_app, event| { + if let tauri::RunEvent::Exit = event { + acp_registry_for_exit.cancel_all(); + } +}); +``` + +**After:** +```rust +// Remove: use std::sync::Arc; +// Remove: use services::acp::AcpSessionRegistry; + +// Remove: let acp_registry = ... +// Remove: let acp_registry_for_exit = ... + +let builder = tauri::Builder::default() + // ... + // Remove: .manage(acp_registry) + ; + +// In invoke_handler, replace all old ACP commands with just: +commands::acp::get_goose_serve_url, + +// Simplify the run closure: +.run(|_app, _event| {}); +``` + +The `Arc` import can be removed if nothing else uses it. Check if `PersonaStore` or `GooseConfig` need it — they don't (they use `tauri::State` which handles the wrapping). + +### 5. Clean up `goose_serve.rs` + +**File:** `src-tauri/src/services/acp/goose_serve.rs` + +The file is mostly kept as-is. Make these changes: + +1. Add the `http_url()` method (from Step 01): +```rust +impl GooseServeProcess { + pub fn http_url(&self) -> String { + format!("http://{LOCALHOST}:{}", self.port) + } +} +``` + +2. Remove the `WS_BRIDGE_BUFFER_BYTES` constant — it was only used by the WebSocket bridge in `thread.rs`: +```rust +// DELETE: +pub(crate) const WS_BRIDGE_BUFFER_BYTES: usize = 64 * 1024; +``` + +3. The `resolve_goose_binary` function is still needed by `model_setup.rs` (which runs `goose configure`). Keep it exported as `pub(crate)`. + +### 6. Remove unused Cargo dependencies + +**File:** `src-tauri/Cargo.toml` + +Remove these dependencies that were only used by the deleted ACP code: + +```toml +# DELETE these lines: +agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork"] } +acp-client = { git = "https://github.com/block/builderbot", rev = "db184d20cb48e0c90bbd3fea4a4a871fc9d8a6ad" } +tokio-tungstenite = "0.21.0" +async-trait = "0.1" +``` + +**Check before removing — these may still be used by remaining code:** + +- `futures = "0.3"` — check if any remaining code uses it. `goose_serve.rs` uses `futures::{SinkExt, StreamExt}` for the WebSocket readiness probe. After removing the WS probe... wait, the readiness probe in `wait_for_server_ready` uses `tokio-tungstenite` and `futures`. We need to replace the readiness check. + +**Replace the WebSocket readiness probe with an HTTP health check:** + +The `wait_for_server_ready` function currently connects via WebSocket to check if the server is up. Replace it with a simple HTTP GET to `/health`: + +```rust +async fn wait_for_server_ready(base_url: &str, child: &mut Child) -> Result<(), String> { + let deadline = Instant::now() + GOOSE_SERVE_CONNECT_TIMEOUT; + let health_url = format!("{base_url}/health"); + + loop { + match reqwest::get(&health_url).await { + Ok(response) if response.status().is_success() => return Ok(()), + Ok(_) | Err(_) => { + if let Some(status) = child + .try_wait() + .map_err(|e| format!("Failed to poll goose serve process: {e}"))? + { + return Err(format!( + "Goose serve exited before becoming ready: {status}" + )); + } + + if Instant::now() >= deadline { + return Err(format!( + "Timed out waiting for goose serve at {health_url}" + )); + } + + tokio::time::sleep(GOOSE_SERVE_CONNECT_RETRY_DELAY).await; + } + } + } +} +``` + +Wait — adding `reqwest` is a new dependency. A simpler approach: use `tokio::net::TcpStream::connect` to check if the port is open: + +```rust +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 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}"))? + { + return Err(format!( + "Goose serve exited before becoming ready: {status}" + )); + } + + if Instant::now() >= deadline { + return Err(format!( + "Timed out waiting for goose serve on port {port}" + )); + } + + tokio::time::sleep(GOOSE_SERVE_CONNECT_RETRY_DELAY).await; + } + } + } +} +``` + +This only needs `tokio` (already a dependency). Update the `spawn` method to call `wait_for_server_ready(port, &mut child)` instead of `wait_for_server_ready(&ws_url, &mut child)`. + +With this change, `futures`, `tokio-tungstenite` can be removed. + +- `tokio-util = { version = "0.7", features = ["compat", "rt"] }` — check if anything else uses it. It was used by `thread.rs` for `TokioAsyncReadCompatExt`/`TokioAsyncWriteCompatExt`. If nothing else uses it, remove it. + +After all removals, the remaining dependencies should be: + +```toml +[dependencies] +tauri = { version = "2", features = ["protocol-asset"] } +tauri-plugin-app-test-driver = { path = "plugins/app-test-driver" } +tauri-plugin-opener = "2" +tauri-plugin-dialog = ">=2,<2.7" +tauri-plugin-window-state = "2" +tauri-plugin-log = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "6.0.0" +log = "0.4.29" +tokio = { version = "1.50.0", features = ["full"] } +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +serde_yaml = "0.9" +etcetera = "0.8" +ignore = "0.4.25" +doctor = { git = "https://github.com/block/builderbot", rev = "8e1c3ec145edc0df5f04b4427cfd758378036862" } +keyring = { ... } # platform-specific +``` + +Use `cargo remove` or edit `Cargo.toml` directly, then run `cargo check` to verify nothing breaks. + +### 7. Run full verification + +```bash +cd ui/goose2/src-tauri + +# Format +cargo fmt + +# Check compilation +cargo check + +# Clippy +cargo clippy --all-targets -- -D warnings + +# Run any remaining Rust tests +cargo test +``` + +Then from the `ui/goose2` directory: + +```bash +source ./bin/activate-hermit +just check +just test +just tauri-check +``` + +## Summary of Deletions + +| Path | Lines | Purpose (was) | +|------|-------|---------------| +| `services/acp/manager/command_dispatch.rs` | ~258 | Command dispatch loop | +| `services/acp/manager/dispatcher.rs` | ~532 | Session event dispatcher + Client trait impl | +| `services/acp/manager/dispatcher_tests.rs` | ~28 | Dispatcher tests | +| `services/acp/manager/session_ops.rs` | ~611 | Session prepare/load/cancel/set-model | +| `services/acp/manager/session_ops/prompt_ops.rs` | ~(inline) | Send prompt logic | +| `services/acp/manager/session_ops/tests.rs` | ~(inline) | Session ops tests | +| `services/acp/manager/thread.rs` | ~169 | Manager thread + WebSocket bridge | +| `services/acp/manager.rs` | ~308 | GooseAcpManager struct + ManagerCommand enum | +| `services/acp/writer.rs` | ~156 | TauriMessageWriter | +| `services/acp/payloads.rs` | ~106 | Tauri event payload structs | +| `services/acp/registry.rs` | ~114 | AcpSessionRegistry | +| `services/acp/search.rs` | ~467 | Session content search | +| **Total** | **~2,749** | | + +Plus significant simplification of `commands/acp.rs` (~330 → ~15 lines) and `services/acp/mod.rs` (~147 → ~4 lines) and `lib.rs` (~114 → ~80 lines). + +## Cargo Dependencies Removed + +| Crate | Why it was needed | +|-------|-------------------| +| `agent-client-protocol` | Rust ACP client types (Agent, ClientSideConnection, etc.) | +| `acp-client` | Agent discovery, MessageWriter trait | +| `tokio-tungstenite` | WebSocket connection to goose serve | +| `async-trait` | MessageWriter + Client trait impls | +| `futures` | WebSocket stream splitting (SinkExt, StreamExt) | +| `tokio-util` | Compat adapters for async read/write | + +## Files Modified + +| File | Change | +|------|--------| +| `src-tauri/src/services/acp/mod.rs` | Simplified to just goose_serve re-export | +| `src-tauri/src/services/acp/goose_serve.rs` | Add `http_url()`, remove WS constants, replace readiness probe | +| `src-tauri/src/commands/acp.rs` | Replaced with single `get_goose_serve_url` command | +| `src-tauri/src/lib.rs` | Remove AcpSessionRegistry, old ACP commands, simplify run closure | +| `src-tauri/Cargo.toml` | Remove 6 dependencies | +| `src-tauri/Cargo.lock` | Auto-updated | + +## Files Deleted + +All files listed in the "Summary of Deletions" table above. + +## Dependencies + +- Steps 01–08 must be working and tested before this cleanup step. + +## Notes + +- Run `cargo check` after each deletion batch to catch any remaining references. +- The `doctor` crate dependency stays — it's used by `commands/doctor.rs` which is not part of this migration. +- The `acp-client` crate was also used by `goose_serve.rs` for `acp_client::find_acp_agent_by_id("goose")` in binary resolution. Check if this import still exists. If so, you need an alternative way to find the goose binary. Options: + - Keep `acp-client` just for binary discovery (lightweight — only uses the `find_acp_agent_by_id` function) + - Inline the binary discovery logic (look for `goose` on PATH, check `GOOSE_BIN` env var) + - The `GOOSE_BIN` env var path is already handled. The `acp_client::find_acp_agent_by_id` fallback scans login shell PATH. You could replace it with a simple `which goose` equivalent. + + If keeping `acp-client` just for binary discovery is acceptable, that's the simplest path. Otherwise, port the discovery logic. diff --git a/ui/goose2/acp-plus-migration-plan/10-phase-b-future-native-migration.md b/ui/goose2/acp-plus-migration-plan/10-phase-b-future-native-migration.md new file mode 100644 index 0000000000..eb5494465a --- /dev/null +++ b/ui/goose2/acp-plus-migration-plan/10-phase-b-future-native-migration.md @@ -0,0 +1,325 @@ +# Step 10: Phase B — Migrate Config, Personas, Skills, Projects, Git, Doctor to `goose serve` + +## Objective + +This step is a roadmap for the long-term migration. Each subsystem currently handled by Rust Tauri commands will move behind `goose serve` ACP extension methods, callable from TypeScript via `client.extMethod("goose//", params)` or via generated `GooseExtClient` methods. + +**This step requires backend changes to the goose crate** — adding new ACP extension methods to `goose serve`. It cannot be done purely in the Tauri app. + +## Current State After Phase A (Steps 01–09) + +After Phase A, the Rust Tauri backend still handles: + +| Module | Rust File(s) | Lines | Native Dependency | +|--------|-------------|-------|-------------------| +| Config (config.yaml, secrets, keyring) | `services/goose_config.rs`, `services/provider_defs.rs` | ~590 | Keyring, file system | +| Credentials commands | `commands/credentials.rs` | ~50 | GooseConfig | +| Personas | `services/personas.rs`, `types/agents.rs`, `types/builtin_personas.rs` | ~920 | File system | +| Persona commands | `commands/agents.rs` | ~210 | PersonaStore | +| Skills | `commands/skills.rs` | ~320 | File system | +| Projects | `commands/projects.rs` | ~495 | File system | +| Git operations | `commands/git.rs`, `commands/git_changes.rs` | ~570 | Shell commands | +| Doctor | `commands/doctor.rs` | ~15 | `doctor` crate | +| Agent setup | `commands/agent_setup.rs` | ~310 | Shell commands, streaming output | +| Model setup | `commands/model_setup.rs` | ~220 | Shell commands, streaming output | +| System utilities | `commands/system.rs` | ~360 | File system, dialog | +| **Total** | | **~4,060** | | + +## Migration Pattern + +For each subsystem, the pattern is: + +1. **Backend**: Add ACP extension methods to `goose serve` (in the `goose-acp` or `goose` crate) +2. **Schema**: Regenerate the ACP schema (`npm run build:schema` in `ui/acp/`) +3. **Client**: The `GooseExtClient` auto-generates typed methods from the schema +4. **Frontend**: Replace `invoke("rust_command")` calls with `client.goose.()` calls +5. **Cleanup**: Delete the Rust Tauri command and service code + +## Subsystem Migration Details + +### B1: Config Management + +**Priority: High** — Config is needed for provider setup, which is part of the core onboarding flow. + +#### New `goose serve` Extension Methods + +| Method | Request | Response | +|--------|---------|----------| +| `goose/config/get` | `{ key: string }` | `{ value: string \| null }` | +| `goose/config/set` | `{ key: string, value: string }` | `{}` | +| `goose/config/delete` | `{ key: string }` | `{ removed: boolean }` | +| `goose/secret/getMasked` | `{ key: string }` | `{ value: string \| null }` | +| `goose/secret/set` | `{ key: string, value: string }` | `{}` | +| `goose/secret/delete` | `{ key: string }` | `{ removed: boolean }` | +| `goose/provider/status` | `{ providerId: string }` | `{ providerId: string, isConfigured: boolean }` | +| `goose/provider/statusAll` | `{}` | `{ providers: [{ providerId: string, isConfigured: boolean }] }` | +| `goose/provider/fields` | `{ providerId: string }` | `{ fields: [{ key: string, value: string \| null, isSet: boolean, isSecret: boolean, required: boolean }] }` | +| `goose/provider/deleteConfig` | `{ providerId: string }` | `{}` | + +#### Backend Implementation Notes + +- The goose binary already has config management internally (`goose configure` command). The extension methods expose the same logic over ACP. +- Keyring access happens in the `goose serve` process (which runs natively), so there's no loss of capability. +- The `provider_defs.rs` static definitions should move to the goose crate (or already exist there). + +#### Frontend Changes + +- Replace `invoke("get_provider_config")` → `client.goose.gooseProviderFields({ providerId })` +- Replace `invoke("save_provider_field")` → `client.goose.gooseSecretSet({ key, value })` or `client.goose.gooseConfigSet({ key, value })` +- Replace `invoke("delete_provider_config")` → `client.goose.gooseProviderDeleteConfig({ providerId })` +- Replace `invoke("check_all_provider_status")` → `client.goose.gooseProviderStatusAll({})` +- Replace `invoke("restart_app")` → keep in Rust (native window management) + +#### Files Deleted After B1 + +- `src-tauri/src/services/goose_config.rs` +- `src-tauri/src/services/provider_defs.rs` +- `src-tauri/src/commands/credentials.rs` (except `restart_app` which stays) +- Remove `keyring` dependency from `Cargo.toml` (all 3 platform variants) +- Remove `etcetera` dependency + +--- + +### B2: Personas + +**Priority: Medium** — Personas are used in the chat flow but not on the critical path. + +#### New `goose serve` Extension Methods + +| Method | Request | Response | +|--------|---------|----------| +| `goose/personas/list` | `{}` | `{ personas: Persona[] }` | +| `goose/personas/create` | `CreatePersonaRequest` | `{ persona: Persona }` | +| `goose/personas/update` | `{ id: string, ...UpdatePersonaRequest }` | `{ persona: Persona }` | +| `goose/personas/delete` | `{ id: string }` | `{}` | +| `goose/personas/refresh` | `{}` | `{ personas: Persona[] }` | +| `goose/personas/export` | `{ id: string }` | `{ json: string, suggestedFilename: string }` | +| `goose/personas/import` | `{ fileBytes: number[], fileName: string }` | `{ personas: Persona[] }` | +| `goose/personas/saveAvatar` | `{ personaId: string, bytes: number[], extension: string }` | `{ filename: string }` | +| `goose/personas/avatarsDir` | `{}` | `{ path: string }` | + +#### Backend Implementation Notes + +- Persona storage (`~/.goose/personas.json`, `~/.goose/agents/*.md`) is file-based. The goose binary can read/write these. +- Avatar handling (`~/.goose/avatars/`) is also file-based. +- Builtin personas are currently defined in `types/builtin_personas.rs` — these should move to the goose crate. + +#### Files Deleted After B2 + +- `src-tauri/src/services/personas.rs` +- `src-tauri/src/types/agents.rs` +- `src-tauri/src/types/builtin_personas.rs` +- `src-tauri/src/types/messages.rs` +- `src-tauri/src/types/mod.rs` +- `src-tauri/src/commands/agents.rs` + +--- + +### B3: Skills + +**Priority: Low** — Skills are a secondary feature. + +#### New `goose serve` Extension Methods + +| Method | Request | Response | +|--------|---------|----------| +| `goose/skills/list` | `{}` | `{ skills: SkillInfo[] }` | +| `goose/skills/create` | `{ name, description, instructions }` | `{}` | +| `goose/skills/update` | `{ name, description, instructions }` | `{ skill: SkillInfo }` | +| `goose/skills/delete` | `{ name: string }` | `{}` | +| `goose/skills/export` | `{ name: string }` | `{ json: string, filename: string }` | +| `goose/skills/import` | `{ fileBytes: number[], fileName: string }` | `{ skills: SkillInfo[] }` | + +#### Files Deleted After B3 + +- `src-tauri/src/commands/skills.rs` + +--- + +### B4: Projects + +**Priority: Low** — Projects are a secondary feature. + +#### New `goose serve` Extension Methods + +| Method | Request | Response | +|--------|---------|----------| +| `goose/projects/list` | `{}` | `{ projects: ProjectInfo[] }` | +| `goose/projects/create` | `{ name, description, prompt, icon, color, ... }` | `{ project: ProjectInfo }` | +| `goose/projects/update` | `{ id, name, description, prompt, icon, color, ... }` | `{ project: ProjectInfo }` | +| `goose/projects/delete` | `{ id: string }` | `{}` | +| `goose/projects/get` | `{ id: string }` | `{ project: ProjectInfo }` | +| `goose/projects/listArchived` | `{}` | `{ projects: ProjectInfo[] }` | +| `goose/projects/archive` | `{ id: string }` | `{}` | +| `goose/projects/restore` | `{ id: string }` | `{}` | + +#### Files Deleted After B4 + +- `src-tauri/src/commands/projects.rs` + +--- + +### B5: Git Operations + +**Priority: Medium** — Git state is shown in the workspace widget and context panel. + +#### New `goose serve` Extension Methods + +| Method | Request | Response | +|--------|---------|----------| +| `goose/git/state` | `{ path: string }` | `GitState` | +| `goose/git/changedFiles` | `{ path: string }` | `{ files: ChangedFile[] }` | +| `goose/git/switchBranch` | `{ path, branch }` | `{}` | +| `goose/git/stash` | `{ path }` | `{}` | +| `goose/git/init` | `{ path }` | `{}` | +| `goose/git/fetch` | `{ path }` | `{}` | +| `goose/git/pull` | `{ path }` | `{}` | +| `goose/git/createBranch` | `{ path, name, baseBranch }` | `{}` | +| `goose/git/createWorktree` | `{ path, name, branch, createBranch, baseBranch? }` | `CreatedWorktree` | + +#### Backend Implementation Notes + +- Git operations run shell commands (`git status`, `git switch`, etc.). The goose binary can run these the same way. +- The `ignore` crate is used for `.gitignore`-aware file scanning in `list_files_for_mentions`. This could also move to goose serve. + +#### Files Deleted After B5 + +- `src-tauri/src/commands/git.rs` +- `src-tauri/src/commands/git_changes.rs` + +--- + +### B6: Doctor + +**Priority: Low** — Doctor is a diagnostic tool, not on the critical path. + +#### New `goose serve` Extension Methods + +| Method | Request | Response | +|--------|---------|----------| +| `goose/doctor/run` | `{}` | `DoctorReport` | +| `goose/doctor/fix` | `{ checkId: string, fixType: string }` | `{}` | + +#### Backend Implementation Notes + +- The `doctor` crate already exists in the goose ecosystem. The extension methods just expose it over ACP. + +#### Files Deleted After B6 + +- `src-tauri/src/commands/doctor.rs` +- Remove `doctor` dependency from `Cargo.toml` + +--- + +### B7: Agent & Model Setup + +**Priority: Medium** — Needed for onboarding third-party agents and OAuth flows. + +This is the trickiest subsystem because it involves **interactive shell commands with streaming output**. The current Rust code spawns a child process and streams stdout/stderr lines as Tauri events (`agent-setup:output`, `model-setup:output`). + +#### Options + +**Option A: ACP extension methods with streaming notifications** + +Add extension methods that return immediately but stream progress via `SessionNotification` events (or a new notification type). The frontend listens for these notifications the same way it listens for chat streaming. + +**Option B: Keep in Rust as the last native commands** + +These commands are inherently interactive (they open browsers for OAuth, wait for user input). They may be better suited to native handling. Keep them as the last remaining Tauri commands. + +**Option C: Move the logic into `goose serve` as a long-running task** + +The goose binary already handles `goose configure` (which is what `model_setup.rs` wraps). Expose a `goose/setup/authenticateProvider` extension method that runs the configure flow and streams output. + +#### Recommendation + +Start with **Option B** (keep in Rust) and migrate to Option A or C later. These commands are rarely called (only during onboarding) and the streaming output pattern would need a new ACP notification type. + +--- + +### B8: System Utilities + +**Priority: Low** — These are helper functions, not core features. + +#### New `goose serve` Extension Methods + +| Method | Request | Response | +|--------|---------|----------| +| `goose/system/homeDir` | `{}` | `{ path: string }` | +| `goose/system/pathExists` | `{ path: string }` | `{ exists: boolean }` | +| `goose/system/listDir` | `{ path: string }` | `{ entries: FileTreeEntry[] }` | +| `goose/system/listFilesForMentions` | `{ roots: string[], maxResults?: number }` | `{ files: string[] }` | + +#### Special Case: `saveExportedSessionFile` + +This command uses `tauri_plugin_dialog` to show a native save dialog. This is inherently a Tauri/native operation — it cannot move to `goose serve`. **Keep this in Rust.** + +#### Files Deleted After B8 + +- `src-tauri/src/commands/system.rs` (except `save_exported_session_file` which stays) +- Remove `ignore` dependency from `Cargo.toml` + +--- + +## End State After All Phase B Steps + +**Rust Tauri backend:** + +``` +src-tauri/src/ + lib.rs — ~40 lines: spawn goose serve, register ~3 commands + main.rs — 6 lines (unchanged) + commands/ + mod.rs — 3 modules + acp.rs — get_goose_serve_url (~15 lines) + system.rs — save_exported_session_file (~40 lines) + agent_setup.rs — install/auth agents (~310 lines, if kept) + model_setup.rs — model provider auth (~220 lines, if kept) + services/ + mod.rs — 1 module + acp/ + mod.rs — 1 module + goose_serve.rs — GooseServeProcess (~150 lines) +``` + +**Total: ~780 lines** if keeping agent/model setup, or **~250 lines** if those also move. + +**Cargo.toml dependencies (minimal):** +```toml +tauri = "2" +tauri-plugin-opener = "2" +tauri-plugin-dialog = ">=2,<2.7" +tauri-plugin-window-state = "2" +tauri-plugin-log = "2" +serde = "1" +serde_json = "1" +tokio = "1" +dirs = "6" +log = "0.4" +``` + +## Migration Priority Summary + +| Step | Effort | Blocking On | Value | Recommended Order | +|------|--------|-------------|-------|-------------------| +| B1 (Config) | Medium | Backend ACP methods | High (removes keyring dep) | 1st | +| B5 (Git) | Medium | Backend ACP methods | Medium | 2nd | +| B2 (Personas) | Medium | Backend ACP methods | Medium | 3rd | +| B3 (Skills) | Small | Backend ACP methods | Small | 4th | +| B4 (Projects) | Small | Backend ACP methods | Small | 5th | +| B6 (Doctor) | Small | Backend ACP methods | Small | 6th | +| B8 (System utils) | Small | Backend ACP methods | Small | 7th | +| B7 (Agent/Model setup) | Large | Streaming notification design | Medium | Last (or keep in Rust) | + +## How to Propose Backend Changes + +For each subsystem, the process is: + +1. **Design the ACP extension method schemas** in `crates/goose-acp/` +2. **Implement the handlers** in the goose serve server +3. **Regenerate the schema**: `cd ui/acp && npm run build:schema` +4. **Rebuild the TS client**: `cd ui/acp && npm run build` +5. **Update goose2**: use the new `client.goose.()` calls +6. **Delete the Rust Tauri code** + +Each subsystem can be migrated independently. The frontend can use a mix of `invoke()` (for not-yet-migrated subsystems) and `client.goose.*()` (for migrated ones) during the transition.