mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
Make a plan
This commit is contained in:
@@ -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:<port>/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:<port>/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:<port>/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 |
|
||||
@@ -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<String, String> {
|
||||
// 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.
|
||||
@@ -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<AnyMessage>` and `WritableStream<AnyMessage>`) 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.
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<GooseClient> | 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<RequestPermissionResponse> => {
|
||||
// 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<void> => {
|
||||
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<GooseClient> {
|
||||
// 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<GooseClient> {
|
||||
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<Arc<GooseAcpManager>>` 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")))`.
|
||||
@@ -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<String, SessionRoute>` 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<string, SessionRoute>();
|
||||
```
|
||||
|
||||
### 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<string, Message[]>();
|
||||
```
|
||||
|
||||
## 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 = "<user-message>\n";
|
||||
const closeTag = "\n</user-message>";
|
||||
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<void>;
|
||||
```
|
||||
|
||||
## 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<Mutex<HashMap>>` 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.
|
||||
@@ -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<string, PreparedSession>();
|
||||
```
|
||||
|
||||
### 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<string, RunningSession>();
|
||||
```
|
||||
|
||||
## 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<string> {
|
||||
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<string, unknown> = {};
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<AcpSessionInfo[]> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
const client = await getClient();
|
||||
const result = await client.goose.gooseSessionExport({ sessionId });
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function importSession(json: string): Promise<AcpSessionInfo> {
|
||||
const client = await getClient();
|
||||
return await client.goose.gooseSessionImport({ data: json });
|
||||
}
|
||||
|
||||
export async function forkSession(sessionId: string): Promise<AcpSessionInfo> {
|
||||
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<void> {
|
||||
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<AcpProvider[]> {
|
||||
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 [
|
||||
`<persona-instructions>\n${systemPrompt}\n</persona-instructions>`,
|
||||
`<user-message>\n${prompt}\n</user-message>`,
|
||||
].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<string>` 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.
|
||||
@@ -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<SessionSearchResult[]> {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
|
||||
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<string, unknown>): boolean {
|
||||
return "role" in obj && ("content" in obj || "text" in obj);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. `extractMessage`
|
||||
|
||||
```typescript
|
||||
function extractMessage(
|
||||
obj: Record<string, unknown>,
|
||||
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<string, unknown>;
|
||||
|
||||
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.
|
||||
@@ -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<AcpProvider[]> {
|
||||
return invoke("discover_acp_providers");
|
||||
}
|
||||
|
||||
export async function acpSendMessage(sessionId, providerId, prompt, options): Promise<void> {
|
||||
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<AcpProvider[]> {
|
||||
return listProviders();
|
||||
}
|
||||
```
|
||||
|
||||
#### `acpSendMessage`
|
||||
|
||||
```typescript
|
||||
export async function acpSendMessage(
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
prompt: string,
|
||||
options: AcpSendMessageOptions = {},
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
return setModel(sessionId, modelId);
|
||||
}
|
||||
```
|
||||
|
||||
#### `acpListSessions`
|
||||
|
||||
```typescript
|
||||
export async function acpListSessions(): Promise<AcpSessionInfo[]> {
|
||||
return listSessions();
|
||||
}
|
||||
```
|
||||
|
||||
#### `acpSearchSessions`
|
||||
|
||||
```typescript
|
||||
export async function acpSearchSessions(
|
||||
query: string,
|
||||
sessionIds: string[],
|
||||
): Promise<AcpSessionSearchResult[]> {
|
||||
return searchSessionsViaExports(query, sessionIds);
|
||||
}
|
||||
```
|
||||
|
||||
#### `acpLoadSession`
|
||||
|
||||
```typescript
|
||||
export async function acpLoadSession(
|
||||
sessionId: string,
|
||||
gooseSessionId: string,
|
||||
workingDir?: string,
|
||||
): Promise<void> {
|
||||
return loadSession(sessionId, gooseSessionId, workingDir ?? "~/.goose/artifacts");
|
||||
}
|
||||
```
|
||||
|
||||
#### `acpExportSession`
|
||||
|
||||
```typescript
|
||||
export async function acpExportSession(sessionId: string): Promise<string> {
|
||||
return exportSession(sessionId);
|
||||
}
|
||||
```
|
||||
|
||||
#### `acpImportSession`
|
||||
|
||||
```typescript
|
||||
export async function acpImportSession(json: string): Promise<AcpSessionInfo> {
|
||||
return importSession(json);
|
||||
}
|
||||
```
|
||||
|
||||
#### `acpDuplicateSession`
|
||||
|
||||
```typescript
|
||||
export async function acpDuplicateSession(sessionId: string): Promise<AcpSessionInfo> {
|
||||
return forkSession(sessionId);
|
||||
}
|
||||
```
|
||||
|
||||
#### `acpCancelSession`
|
||||
|
||||
```typescript
|
||||
export async function acpCancelSession(
|
||||
sessionId: string,
|
||||
personaId?: string,
|
||||
): Promise<boolean> {
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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<String, String> {
|
||||
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.
|
||||
@@ -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/<domain>/<action>", 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.<method>()` 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.<method>()` 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.
|
||||
Reference in New Issue
Block a user