delete the goose2 migration plan prompt (#8678)
This commit is contained in:
@@ -1,103 +0,0 @@
|
||||
# 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 WebSocket. 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 (WebSocket)
|
||||
→ goose serve ws://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 (WebSocket)
|
||||
→ goose serve ws://127.0.0.1:<port>/acp
|
||||
← Client callbacks → direct Zustand store updates
|
||||
|
||||
Tauri Rust shell (~200 lines):
|
||||
- Spawn goose serve, expose URL
|
||||
- Window management
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
| Step | File | Summary |
|
||||
|------|------|---------|
|
||||
| 01 | `01-expose-goose-serve-url.md` | Add Tauri command to expose the `goose serve` WebSocket 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 (WebSocket transport), reconnection logic, and feature flag |
|
||||
| 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, though 06 can proceed 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. **WebSocket transport.** `goose serve` exposes a WebSocket endpoint at `/acp`. Each WS text frame is a single JSON-RPC message. This is the same transport the Rust layer already uses — we are moving the WebSocket client from Rust to TypeScript. WebSocket provides true bidirectional streaming with lower overhead than HTTP+SSE.
|
||||
|
||||
2. **Direct store updates over event bus.** The notification handler calls Zustand store methods directly instead of emitting Tauri events. This 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`, generated types, and Zod validators. A `createWebSocketStream` helper will be added (either in `@aaif/goose-acp` or locally in goose2) since the package currently only ships `createHttpStream`.
|
||||
|
||||
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 WebSocket | CSP is already `null` (disabled) in `tauri.conf.json` |
|
||||
| `goose serve` not ready when frontend initializes | Rust still does a readiness check; the URL command only resolves after the server is confirmed ready |
|
||||
| WebSocket disconnection / reconnection | Implement reconnection logic in the connection manager; `GooseClient.closed` signals when the connection drops |
|
||||
| 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 | Feature flag (`useDirectAcp` in `acpFeatureFlag.ts`) routes between old Tauri IPC and new WebSocket path. Default off, flip per-user to test, flip default to on after validation, remove in Step 09 |
|
||||
@@ -1,87 +0,0 @@
|
||||
# Step 01: Expose the `goose serve` URL to the Frontend
|
||||
|
||||
## Objective
|
||||
|
||||
Add a Tauri command that returns the WebSocket URL of the running `goose serve` process so the frontend can connect directly via WebSocket.
|
||||
|
||||
## Why
|
||||
|
||||
The Rust layer currently connects to `goose serve` over WebSocket internally and proxies everything. The frontend never knows the server URL. Exposing it lets the TypeScript ACP client connect directly.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Re-export `GooseServeProcess`
|
||||
|
||||
**File:** `src-tauri/src/services/acp/mod.rs`
|
||||
|
||||
Add a re-export so the command layer can reference the struct:
|
||||
|
||||
```rust
|
||||
pub(crate) use goose_serve::GooseServeProcess;
|
||||
```
|
||||
|
||||
No changes to `GooseServeProcess` itself — the existing `ws_url()` method already returns `ws://127.0.0.1:<port>/acp`.
|
||||
|
||||
### 2. Add the Tauri command
|
||||
|
||||
**File:** `src-tauri/src/commands/acp.rs`
|
||||
|
||||
Add this command alongside the existing ones:
|
||||
|
||||
```rust
|
||||
use crate::services::acp::goose_serve::GooseServeProcess;
|
||||
|
||||
/// Return the WebSocket 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 WebSocket ACP connection.
|
||||
#[tauri::command]
|
||||
pub async fn get_goose_serve_url() -> Result<String, String> {
|
||||
GooseServeProcess::start().await?;
|
||||
let process = GooseServeProcess::get()?;
|
||||
Ok(process.ws_url())
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Register the command
|
||||
|
||||
**File:** `src-tauri/src/lib.rs`
|
||||
|
||||
Add the new command to the `invoke_handler` macro near the other `commands::acp::*` entries:
|
||||
|
||||
```rust
|
||||
commands::acp::get_goose_serve_url,
|
||||
```
|
||||
|
||||
### 4. CSP — no changes needed
|
||||
|
||||
**File:** `src-tauri/tauri.conf.json`
|
||||
|
||||
CSP is currently disabled (`"csp": null`), so the frontend can open WebSocket connections to `ws://127.0.0.1:*` without restriction.
|
||||
|
||||
If CSP is ever re-enabled, add:
|
||||
```
|
||||
connect-src 'self' ws://127.0.0.1:*
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cargo check` in `src-tauri/` — confirms compilation.
|
||||
2. `cargo clippy --all-targets -- -D warnings` in `src-tauri/`.
|
||||
3. `cargo fmt` in `src-tauri/`.
|
||||
4. Add a temporary `console.log(await invoke("get_goose_serve_url"))` in the frontend startup — it should print something like `ws://127.0.0.1:54321/acp`.
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src-tauri/src/services/acp/mod.rs` | Add `pub(crate) use goose_serve::GooseServeProcess` |
|
||||
| `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 are removed in Step 09.
|
||||
- `GooseServeProcess::start()` is idempotent — the first call spawns the process; subsequent calls return immediately.
|
||||
- The readiness check (`wait_for_server_ready`) ensures the URL is only returned after the server is accepting connections.
|
||||
- The URL includes the `/acp` path — the same WebSocket endpoint the Rust layer currently uses in `thread.rs`.
|
||||
@@ -1,115 +0,0 @@
|
||||
# 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`
|
||||
- **`GooseExtClient`** — generated typed client for Goose extension methods (`goose/providers/list`, `goose/session/export`, etc.)
|
||||
- **`createHttpStream`** — an HTTP+SSE transport (we won't use this — we'll use WebSocket instead, see Step 03)
|
||||
- **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`
|
||||
|
||||
goose2 has its own `pnpm-lock.yaml` and is not part of the `ui/pnpm-workspace.yaml` workspace. Use the published npm packages:
|
||||
|
||||
```bash
|
||||
cd ui/goose2
|
||||
pnpm add @aaif/goose-acp @agentclientprotocol/sdk@^0.14.1
|
||||
```
|
||||
|
||||
The `@aaif/goose-acp` package declares `@agentclientprotocol/sdk` as a peer dependency (`"*"`). Pin to `^0.14.1` to match the version used by `ui/acp/package.json`.
|
||||
|
||||
### 2. Verify the dependency resolves
|
||||
|
||||
After installation, verify the imports work:
|
||||
|
||||
```bash
|
||||
cd ui/goose2
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
Create a temporary test file to confirm imports resolve:
|
||||
|
||||
```typescript
|
||||
// src/shared/api/_test_acp_import.ts (DELETE AFTER VERIFICATION)
|
||||
import { GooseClient } from "@aaif/goose-acp";
|
||||
import type { Client, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
|
||||
console.log("GooseClient:", GooseClient);
|
||||
```
|
||||
|
||||
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 } 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";
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
- `GooseClient` wraps `ClientSideConnection` from `@agentclientprotocol/sdk` and adds Goose-specific extension methods via `GooseExtClient`.
|
||||
- The package ships `createHttpStream` (HTTP+SSE transport), but we will use **WebSocket** transport instead. `GooseClient` accepts any `Stream` (a `{ readable, writable }` pair of `ReadableStream<AnyMessage>` and `WritableStream<AnyMessage>`). In Step 03 we'll create a `createWebSocketStream` helper.
|
||||
- The `goose serve` WebSocket endpoint at `/acp` uses simple framing: each WS text frame is a single JSON-RPC message (no newline delimiters needed). This is the same transport the Rust Tauri backend already uses in `thread.rs`.
|
||||
@@ -1,397 +0,0 @@
|
||||
# Step 03: Create the TypeScript ACP Connection Manager
|
||||
|
||||
## Objective
|
||||
|
||||
Create a singleton module that manages the lifecycle of the `GooseClient` connection to `goose serve` over WebSocket. 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` WebSocket URL from the Rust backend (Step 01's command)
|
||||
2. Creates a WebSocket `Stream` for the ACP SDK
|
||||
3. Creates a `GooseClient` with that stream
|
||||
4. Calls `client.initialize()` to complete the ACP handshake
|
||||
5. Provides the initialized client to all other modules
|
||||
|
||||
## New Files
|
||||
|
||||
### 1. `src/shared/api/createWebSocketStream.ts` — WebSocket transport for ACP
|
||||
|
||||
The `@agentclientprotocol/sdk` defines a `Stream` as `{ readable: ReadableStream<AnyMessage>, writable: WritableStream<AnyMessage> }`. The SDK ships `ndJsonStream` for stdio. The `@aaif/goose-acp` package ships `createHttpStream` for HTTP+SSE. Neither provides a WebSocket transport.
|
||||
|
||||
We need a `createWebSocketStream` that bridges a browser `WebSocket` to the ACP `Stream` interface. The `goose serve` WebSocket protocol sends each WS text frame as a single JSON-RPC message (no newline delimiters).
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* WebSocket transport for ACP connections.
|
||||
*
|
||||
* Creates a Stream (readable + writable pair of AnyMessage) backed by a
|
||||
* browser WebSocket connection. Each WS text frame is a single JSON-RPC
|
||||
* message — no newline delimiters needed.
|
||||
*
|
||||
* This matches the framing used by goose serve's /acp WebSocket endpoint
|
||||
* (see crates/goose-acp/src/transport/websocket.rs).
|
||||
*/
|
||||
import type { AnyMessage, Stream } from "@agentclientprotocol/sdk";
|
||||
|
||||
export function createWebSocketStream(wsUrl: string): Stream {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
// Queue of messages received from the server, consumed by the readable stream.
|
||||
const incoming: AnyMessage[] = [];
|
||||
const waiters: Array<() => void> = [];
|
||||
let closed = false;
|
||||
|
||||
function pushMessage(msg: AnyMessage): void {
|
||||
incoming.push(msg);
|
||||
const waiter = waiters.shift();
|
||||
if (waiter) waiter();
|
||||
}
|
||||
|
||||
function waitForMessage(): Promise<void> {
|
||||
if (incoming.length > 0 || closed) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => waiters.push(resolve));
|
||||
}
|
||||
|
||||
// Wait for the WebSocket to open before allowing writes.
|
||||
const openPromise = new Promise<void>((resolve, reject) => {
|
||||
ws.addEventListener("open", () => resolve(), { once: true });
|
||||
ws.addEventListener("error", (event) => {
|
||||
reject(new Error(`WebSocket connection failed: ${event}`));
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
if (typeof event.data !== "string") return;
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as AnyMessage;
|
||||
pushMessage(msg);
|
||||
} catch {
|
||||
// Ignore malformed JSON
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
closed = true;
|
||||
for (const waiter of waiters) waiter();
|
||||
waiters.length = 0;
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
closed = true;
|
||||
for (const waiter of waiters) waiter();
|
||||
waiters.length = 0;
|
||||
});
|
||||
|
||||
const readable = new ReadableStream<AnyMessage>({
|
||||
async pull(controller) {
|
||||
await waitForMessage();
|
||||
while (incoming.length > 0) {
|
||||
controller.enqueue(incoming.shift()!);
|
||||
}
|
||||
if (closed && incoming.length === 0) {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const writable = new WritableStream<AnyMessage>({
|
||||
async write(msg) {
|
||||
await openPromise;
|
||||
ws.send(JSON.stringify(msg));
|
||||
},
|
||||
close() {
|
||||
ws.close();
|
||||
},
|
||||
abort() {
|
||||
ws.close();
|
||||
},
|
||||
});
|
||||
|
||||
return { readable, writable };
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `src/shared/api/acpConnection.ts` — Singleton connection manager
|
||||
|
||||
The module uses a promise-based singleton pattern: `clientPromise` ensures only one initialization runs at a time, `resolvedClient` caches the result for synchronous access, and if initialization fails, `clientPromise` resets so the next call retries. This mirrors the Rust `OnceCell<Arc<GooseAcpManager>>` pattern in `manager.rs`.
|
||||
|
||||
The notification handler is registered separately (via `setNotificationHandler()` in Step 04) rather than passed at construction time. This avoids a circular dependency: `acpConnection.ts` creates the client, but `acpNotificationHandler.ts` both needs the client and must be registered with the connection.
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Singleton ACP connection manager.
|
||||
*
|
||||
* Manages the lifecycle of the GooseClient connection to goose serve
|
||||
* over WebSocket. All ACP operations go through the client returned
|
||||
* by getClient().
|
||||
*/
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { GooseClient } from "@aaif/goose-acp";
|
||||
import type {
|
||||
Client,
|
||||
SessionNotification,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { createWebSocketStream } from "./createWebSocketStream";
|
||||
|
||||
// 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:
|
||||
* - requestPermission: auto-approve with the first option (same as Rust impl)
|
||||
* - sessionUpdate: delegate to the registered notification handler
|
||||
*/
|
||||
function createClientCallbacks(): () => Client {
|
||||
return () => ({
|
||||
requestPermission: async (
|
||||
args: RequestPermissionRequest,
|
||||
): Promise<RequestPermissionResponse> => {
|
||||
const optionId = args.options?.[0]?.optionId ?? "approve";
|
||||
return {
|
||||
outcome: {
|
||||
type: "selected",
|
||||
optionId,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
sessionUpdate: 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 WebSocket URL
|
||||
* 2. Creates a GooseClient with WebSocket transport
|
||||
* 3. Sends the ACP initialize handshake
|
||||
*
|
||||
* This is idempotent — calling it multiple times returns the same client.
|
||||
*/
|
||||
async function initializeConnection(): Promise<GooseClient> {
|
||||
// Returns something like "ws://127.0.0.1:54321/acp"
|
||||
const wsUrl: string = await invoke("get_goose_serve_url");
|
||||
|
||||
const stream = createWebSocketStream(wsUrl);
|
||||
|
||||
const client = new GooseClient(createClientCallbacks(), stream);
|
||||
|
||||
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
|
||||
* WebSocket 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) => {
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Reconnection Logic in `acpConnection.ts`
|
||||
|
||||
The WebSocket can drop (laptop sleep, network blip, goose serve restart). The connection manager must detect this and recover.
|
||||
|
||||
**Strategy: reset singleton on close, reconnect on next `getClient()` call.**
|
||||
|
||||
Add to `acpConnection.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Monitor the WebSocket connection and reset the singleton when it closes.
|
||||
* Called once after successful initialization.
|
||||
*/
|
||||
function monitorConnection(client: GooseClient): void {
|
||||
// GooseClient exposes a `closed` promise that resolves when the
|
||||
// underlying connection terminates.
|
||||
client.closed
|
||||
.then(() => {
|
||||
console.warn("[acp] Connection closed. Will reconnect on next getClient().");
|
||||
resolvedClient = null;
|
||||
clientPromise = null;
|
||||
})
|
||||
.catch(() => {
|
||||
console.warn("[acp] Connection error. Will reconnect on next getClient().");
|
||||
resolvedClient = null;
|
||||
clientPromise = null;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Call `monitorConnection(client)` at the end of `initializeConnection()`, after the handshake succeeds.
|
||||
|
||||
**In-flight cleanup:** When the connection drops mid-stream, any running prompt will reject (the `client.prompt()` promise rejects when the connection closes). The session manager (Step 05) catches this in `sendPrompt()` and calls `clearWriter()` + sets chat state to idle. The notification handler does NOT need special reconnect awareness — it simply stops receiving events because the connection is gone.
|
||||
|
||||
**What this does NOT do:**
|
||||
- Auto-reconnect in the background (no polling/retry loop)
|
||||
- Resume an in-flight prompt after reconnect
|
||||
- Retry failed operations automatically
|
||||
|
||||
It simply ensures the next `getClient()` call creates a fresh connection. The caller (UI layer) decides whether to retry the operation.
|
||||
|
||||
### 4. Feature Flag: `useDirectAcp`
|
||||
|
||||
To enable safe rollback, add a feature flag that controls whether the frontend uses the new direct WebSocket path or the old Tauri IPC path.
|
||||
|
||||
**File:** `src/shared/api/acpFeatureFlag.ts`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Feature flag for direct ACP WebSocket connection.
|
||||
*
|
||||
* When true: frontend talks to goose serve directly via WebSocket
|
||||
* When false: frontend uses the old Tauri invoke() → Rust → WebSocket path
|
||||
*
|
||||
* This flag is used in Step 07 (rewire-shared-api-acp) to route calls.
|
||||
* Remove this file after the migration is validated and Step 09 (cleanup) is done.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "goose2_use_direct_acp";
|
||||
|
||||
export function useDirectAcp(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored !== null) return stored === "true";
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
// Default: off during migration, flip to true when ready
|
||||
return false;
|
||||
}
|
||||
|
||||
export function setUseDirectAcp(enabled: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(enabled));
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This lets us:
|
||||
- Ship Steps 01–06 without affecting any users
|
||||
- Flip the flag per-user or per-session to test the new path
|
||||
- Instantly roll back if something breaks (flip flag, refresh)
|
||||
- Remove the flag in Step 09 when the old Rust code is deleted
|
||||
|
||||
Step 07 will use this flag to route each function:
|
||||
|
||||
```typescript
|
||||
// Example pattern in src/shared/api/acp.ts (Step 07)
|
||||
export async function acpSendMessage(...) {
|
||||
if (useDirectAcp()) {
|
||||
return sendPrompt(...); // new: TS → WebSocket → goose serve
|
||||
}
|
||||
return invoke("acp_send_message", ...); // old: TS → Rust → WebSocket → goose serve
|
||||
}
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. `pnpm typecheck` passes.
|
||||
2. `pnpm check` passes (Biome lint).
|
||||
3. The modules can be imported without side effects — initialization only happens when `getClient()` is called.
|
||||
4. Unit test for `createWebSocketStream`: mock `WebSocket`, verify messages flow bidirectionally.
|
||||
5. Reconnection test: close the WebSocket, verify `resolvedClient` resets to null, verify next `getClient()` creates a fresh connection.
|
||||
6. Feature flag test: verify `useDirectAcp()` reads from localStorage, defaults to false.
|
||||
|
||||
## Files Created
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/shared/api/createWebSocketStream.ts` | WebSocket → ACP Stream adapter |
|
||||
| `src/shared/api/acpConnection.ts` | Singleton ACP connection manager with reconnection |
|
||||
| `src/shared/api/acpFeatureFlag.ts` | Feature flag for old/new path routing |
|
||||
|
||||
## 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 `goose serve` WebSocket endpoint at `/acp` sends one JSON-RPC message per WS text frame (no trailing newline). This is the same framing the Rust Tauri backend uses in `thread.rs`. `createWebSocketStream` performs the same bridging directly in the browser.
|
||||
- WebSocket is used over HTTP+SSE because it is the same transport the Rust layer already uses with `goose serve`, provides true bidirectional communication on a single persistent connection, and avoids the quirks of `createHttpStream` (fire-and-forget POSTs, session header management).
|
||||
- The `Client` interface from `@agentclientprotocol/sdk` uses `sessionUpdate` as the callback method name. The Rust `Client` trait calls it `session_notification` — same callback, different naming convention.
|
||||
- The `protocolVersion` `"2025-03-26"` matches `ProtocolVersion::LATEST` from the Rust `agent-client-protocol` crate. Use `LATEST_PROTOCOL_VERSION` from `@agentclientprotocol/sdk` if exported; otherwise hardcode the string.
|
||||
- If `invoke("get_goose_serve_url")` fails, the error propagates to the caller. The app startup code (Step 08) handles this by showing an error state rather than crashing.
|
||||
- Reconnection is passive (reset-on-close), not active (no polling/retry loop). This keeps the implementation simple while ensuring the app recovers from transient disconnections. The connection is local (same machine), so reconnection typically succeeds immediately.
|
||||
- The feature flag defaults to `false` (old path). During development, enable it via browser console: `localStorage.setItem("goose2_use_direct_acp", "true")`. In production, flip the default to `true` after validation, then remove the flag entirely in Step 09.
|
||||
@@ -1,315 +0,0 @@
|
||||
# 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`.
|
||||
|
||||
## 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;
|
||||
activeMessageId: string | null;
|
||||
canceled: boolean;
|
||||
personaId: string | null;
|
||||
personaName: string | null;
|
||||
}
|
||||
|
||||
const routes = new Map<string, SessionRoute>();
|
||||
```
|
||||
|
||||
### Replay Buffer
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
Extract model options from `SessionConfigSelectOptions` (ungrouped or grouped) and update the session store:
|
||||
|
||||
```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"
|
||||
);
|
||||
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;
|
||||
}
|
||||
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
|
||||
// and append the text chunk to it.
|
||||
}
|
||||
```
|
||||
|
||||
### 4. `ToolCall` and `ToolCallUpdate`
|
||||
|
||||
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`. Extract the inner content:
|
||||
|
||||
```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");
|
||||
}
|
||||
```
|
||||
|
||||
## Public API
|
||||
|
||||
```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
|
||||
|
||||
| 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), register the handler 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
|
||||
|
||||
- In single-threaded JS, a plain `Map` replaces the Rust `Arc<Mutex<HashMap>>` for route storage.
|
||||
- Replay buffering relies on the `replay_complete` signal from the backend. The `loadSession` RPC resolves, the backend sends remaining notifications, then sends `replay_complete`. The handler flushes the buffer at that point.
|
||||
- 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.
|
||||
- Port the `shouldTrackStreamingEvent` guard from `useAcpStream.ts` — it prevents stale events from updating already-completed messages.
|
||||
@@ -1,465 +0,0 @@
|
||||
# Step 05: Create the TypeScript Session Manager
|
||||
|
||||
## Objective
|
||||
|
||||
Port 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, and 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
|
||||
|
||||
```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
|
||||
|
||||
```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
|
||||
|
||||
```typescript
|
||||
interface RunningSession {
|
||||
compositeKey: string;
|
||||
providerId: string;
|
||||
startedAt: number; // Date.now()
|
||||
assistantMessageId: string | null;
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
const runningSessions = new Map<string, RunningSession>();
|
||||
```
|
||||
|
||||
## Core Operations
|
||||
|
||||
### 1. `prepareSession`
|
||||
|
||||
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();
|
||||
|
||||
const existing = preparedSessions.get(compositeKey) ?? preparedSessions.get(localSessionId);
|
||||
if (existing) {
|
||||
bindSession(existing.gooseSessionId, localSessionId, providerId);
|
||||
if (existing.workingDir !== workingDir) {
|
||||
await client.goose.gooseWorkingDirUpdate({ sessionId: existing.gooseSessionId, workingDir });
|
||||
// ... update cache
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const prepared: PreparedSession = { gooseSessionId, providerId, workingDir };
|
||||
preparedSessions.set(compositeKey, prepared);
|
||||
preparedSessions.set(localSessionId, prepared);
|
||||
|
||||
return gooseSessionId;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `sendPrompt`
|
||||
|
||||
```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);
|
||||
|
||||
const effectivePrompt = buildEffectivePrompt(prompt, options.systemPrompt);
|
||||
|
||||
const abort = new AbortController();
|
||||
const assistantMessageId = crypto.randomUUID();
|
||||
runningSessions.set(compositeKey, {
|
||||
compositeKey,
|
||||
providerId,
|
||||
startedAt: Date.now(),
|
||||
assistantMessageId,
|
||||
abortController: abort,
|
||||
});
|
||||
|
||||
try {
|
||||
const workingDir = options.workingDir ?? defaultArtifactsWorkingDir();
|
||||
const gooseSessionId = await prepareSession(compositeKey, sessionId, providerId, workingDir);
|
||||
|
||||
attachWriter(gooseSessionId, sessionId, providerId, assistantMessageId, options.personaId, options.personaName);
|
||||
|
||||
const content: ContentBlock[] = [{ type: "text", text: effectivePrompt }];
|
||||
for (const [data, mimeType] of (options.images ?? [])) {
|
||||
content.push({ type: "image", data, mimeType });
|
||||
}
|
||||
|
||||
await client.prompt({
|
||||
sessionId: gooseSessionId,
|
||||
content,
|
||||
});
|
||||
|
||||
clearWriter(gooseSessionId);
|
||||
finalizeMessage(sessionId, assistantMessageId);
|
||||
} catch (error) {
|
||||
clearWriter(/* gooseSessionId */);
|
||||
throw error;
|
||||
} finally {
|
||||
runningSessions.delete(compositeKey);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `cancelSession`
|
||||
|
||||
```typescript
|
||||
export async function cancelSession(sessionId: string, personaId?: string): Promise<boolean> {
|
||||
const compositeKey = makeCompositeKey(sessionId, personaId);
|
||||
const running = runningSessions.get(compositeKey);
|
||||
|
||||
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`
|
||||
|
||||
```typescript
|
||||
export async function loadSession(
|
||||
localSessionId: string,
|
||||
gooseSessionId: string,
|
||||
workingDir: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient();
|
||||
|
||||
bindSession(gooseSessionId, localSessionId);
|
||||
startReplayBuffer(localSessionId);
|
||||
|
||||
const response = await client.loadSession({
|
||||
sessionId: gooseSessionId,
|
||||
workingDir,
|
||||
});
|
||||
|
||||
// The backend sends replay notifications asynchronously.
|
||||
// The notification handler flushes the replay buffer on replay_complete.
|
||||
|
||||
if (response.models) {
|
||||
handleModelState(localSessionId, null, /* extract from response.models */);
|
||||
}
|
||||
if (response.configOptions) {
|
||||
const modelState = extractModelOptionsFromConfigOptions(response.configOptions);
|
||||
if (modelState) handleModelState(localSessionId, null, modelState);
|
||||
}
|
||||
|
||||
preparedSessions.set(localSessionId, {
|
||||
gooseSessionId,
|
||||
providerId: "goose", // 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();
|
||||
|
||||
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,
|
||||
});
|
||||
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
|
||||
|
||||
```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 {
|
||||
return "~/.goose/artifacts";
|
||||
}
|
||||
```
|
||||
|
||||
The `goose serve` backend handles working directory resolution and `~` expansion. This function only needs to supply 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";
|
||||
```
|
||||
|
||||
## Concurrency
|
||||
|
||||
The Rust code uses per-session `Mutex` locks (`op_locks`) and `pending_cancels` / `preparing_sessions` sets to prevent concurrent mutations and coordinate cancellation during preparation. In single-threaded JS, mutex locks aren't needed for correctness, but a simple promise-based lock prevents concurrent `prepareSession` calls for the same composite key from racing. Port `pending_cancels` and `preparing_sessions` as module-level `Set<string>` variables.
|
||||
|
||||
## Generated Client Method Names
|
||||
|
||||
The `GooseExtClient` methods (e.g., `client.goose.gooseProvidersList()`) are generated from the ACP schema. Verify actual method names in `ui/acp/src/generated/client.gen.ts` — they use camelCase versions of the `goose/providers/list` method name.
|
||||
|
||||
## Streaming Model
|
||||
|
||||
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 matches the Rust flow.
|
||||
|
||||
## 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)
|
||||
@@ -1,357 +0,0 @@
|
||||
# Step 06: Port Session Content Search to TypeScript
|
||||
|
||||
## Objective
|
||||
|
||||
Port the session content search logic to TypeScript. This is pure text processing on exported JSON and requires no native access.
|
||||
|
||||
## Why
|
||||
|
||||
The 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 native 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.
|
||||
*/
|
||||
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
|
||||
|
||||
### 1. `searchSessionsViaExports`
|
||||
|
||||
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
|
||||
|
||||
### `src/features/sessions/lib/__tests__/sessionContentSearch.test.ts`
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
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", () => { /* ... */ });
|
||||
});
|
||||
```
|
||||
|
||||
## 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 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
|
||||
|
||||
- In JavaScript, `String.substring()` operates on UTF-16 code units and is already safe for slicing. Snippet boundaries may differ slightly for multi-byte characters, but this is cosmetic.
|
||||
- The search is sequential (one session at a time). Parallelization via `Promise.all` is a future optimization if search latency becomes a problem.
|
||||
- The `exportSession` call goes through the ACP client to `goose serve`, which reads from its database. There is no change in data source.
|
||||
@@ -1,237 +0,0 @@
|
||||
# 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 TypeScript ACP session manager (Step 05) and search module (Step 06). Keep the same public API signatures so consumers don't need to change. Use the feature flag from Step 03 to route between old and new paths.
|
||||
|
||||
## 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 WebSocket → goose serve.
|
||||
|
||||
The feature flag (`useDirectAcp`) allows both paths to coexist. This means:
|
||||
- The swap is gradual and reversible
|
||||
- We can test the new path per-user without affecting everyone
|
||||
- Instant rollback by flipping the flag
|
||||
|
||||
## Changes
|
||||
|
||||
### `src/shared/api/acp.ts`
|
||||
|
||||
Keep the existing `invoke()` implementations. Add the new direct-ACP implementations alongside them. Route via the feature flag.
|
||||
|
||||
**Pattern:**
|
||||
```typescript
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useDirectAcp } from "./acpFeatureFlag";
|
||||
|
||||
// Lazy imports to avoid loading the new modules when flag is off
|
||||
async function getSessionManager() {
|
||||
return import("./acpSessionManager");
|
||||
}
|
||||
|
||||
export async function discoverAcpProviders(): Promise<AcpProvider[]> {
|
||||
if (useDirectAcp()) {
|
||||
const { listProviders } = await getSessionManager();
|
||||
return listProviders();
|
||||
}
|
||||
return invoke("discover_acp_providers");
|
||||
}
|
||||
```
|
||||
|
||||
Once validated, a follow-up removes the `invoke()` branches and the feature flag (Step 09).
|
||||
|
||||
### Function-by-function rewiring
|
||||
|
||||
#### `discoverAcpProviders`
|
||||
|
||||
```typescript
|
||||
export async function discoverAcpProviders(): Promise<AcpProvider[]> {
|
||||
if (useDirectAcp()) {
|
||||
const { listProviders } = await getSessionManager();
|
||||
return listProviders();
|
||||
}
|
||||
return invoke("discover_acp_providers");
|
||||
}
|
||||
```
|
||||
|
||||
#### `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);
|
||||
}
|
||||
```
|
||||
|
||||
### Interface types
|
||||
|
||||
`AcpSendMessageOptions` and `AcpPrepareSessionOptions` remain defined in this file since they are specific to this API surface. Types originating from the session manager and search module are re-exported:
|
||||
|
||||
```typescript
|
||||
export type { AcpProvider, AcpSessionInfo } from "./acpSessionManager";
|
||||
export type { SessionSearchResult as AcpSessionSearchResult } from "@/features/sessions/lib/sessionContentSearch";
|
||||
|
||||
export interface AcpSendMessageOptions {
|
||||
systemPrompt?: string;
|
||||
workingDir?: string;
|
||||
personaId?: string;
|
||||
personaName?: string;
|
||||
images?: [string, string][];
|
||||
}
|
||||
|
||||
export interface AcpPrepareSessionOptions {
|
||||
workingDir?: string;
|
||||
personaId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### `src/shared/api/index.ts`
|
||||
|
||||
No changes needed — it already re-exports from `./acp`:
|
||||
|
||||
```typescript
|
||||
export * from "./acp";
|
||||
```
|
||||
|
||||
## Consumers
|
||||
|
||||
These files import from `@/shared/api/acp` and require no changes 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 no longer imports from `@tauri-apps/api/core`. Other files (agents, git, system, etc.) still use `invoke()` for non-ACP commands.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `pnpm typecheck` passes — all consumers type-check against the same API.
|
||||
2. `pnpm check` passes.
|
||||
3. `pnpm test` passes — existing tests that mock `invoke()` 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, confirm sessions load, messages send, and 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
|
||||
|
||||
- After this step, the frontend no longer calls any `acp_*` Tauri commands. The only remaining Tauri invoke for ACP infrastructure is `get_goose_serve_url`, called by `acpConnection.ts`.
|
||||
- The old Rust ACP commands still exist and are registered but are no longer called. They are removed in Step 09.
|
||||
- The `@tauri-apps/api/core` import is removed from this file entirely.
|
||||
@@ -1,251 +0,0 @@
|
||||
# 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 `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 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. `AppShell.loadSessionMessages` — no changes needed
|
||||
|
||||
**File:** `src/app/AppShell.tsx`
|
||||
|
||||
The `loadSessionMessages` callback dynamically imports `acpLoadSession` from `@/shared/api/acp`. This still works because Step 07 rewired that function to go through the TS session manager.
|
||||
|
||||
```typescript
|
||||
const { acpLoadSession } = await import("@/shared/api/acp");
|
||||
```
|
||||
|
||||
### 4. `useChat` — no changes needed
|
||||
|
||||
**File:** `src/features/chat/hooks/useChat.ts`
|
||||
|
||||
This hook imports `acpSendMessage`, `acpCancelSession`, `acpPrepareSession`, `acpSetModel` from `@/shared/api/acp`. Step 07 kept the same API surface, so no changes are needed.
|
||||
|
||||
```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 = () => {
|
||||
import("@/shared/api/acpSessionManager").then(({ cancelAll }) => {
|
||||
cancelAll();
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, []);
|
||||
```
|
||||
|
||||
The Rust backend's `acp_registry_for_exit.cancel_all()` on `RunEvent::Exit` becomes a no-op after migration (no sessions are registered in the Rust registry). The TS cleanup above replaces it.
|
||||
|
||||
### 6. Delete old files
|
||||
|
||||
These files are no longer needed:
|
||||
|
||||
- `src/features/chat/hooks/useAcpStream.ts` — replaced by `acpNotificationHandler.ts`
|
||||
- `src/features/chat/hooks/acpStreamTypes.ts` — types moved to the notification handler / SDK imports
|
||||
- `src/features/chat/hooks/replayBuffer.ts` — logic moved into the notification handler
|
||||
- `src/features/chat/hooks/useSSE.ts` — only consumer was `useAcpStream`
|
||||
|
||||
### 7. Update test files
|
||||
|
||||
**Delete:**
|
||||
- `src/features/chat/hooks/__tests__/useAcpStream.test.ts` — the hook no longer exists
|
||||
|
||||
**Update:**
|
||||
- `src/features/chat/hooks/__tests__/useChat.test.ts` — mocks should target the session manager functions instead of `invoke()`.
|
||||
|
||||
Replace any `invoke()`-level mocks:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
// After
|
||||
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),
|
||||
}));
|
||||
```
|
||||
|
||||
### 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`. All of these are gone now.
|
||||
|
||||
Confirm no other code listens to these events:
|
||||
|
||||
```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 (updated/deleted above).
|
||||
|
||||
## 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/useSSE.ts` | Only consumer was `useAcpStream` |
|
||||
| `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
|
||||
|
||||
- `useAcpStream` was the only consumer of the `acp:*` Tauri events. Once removed, no frontend code listens to those events. The Rust backend still emits them until Step 09 removes the Rust code, but they go nowhere — this is harmless.
|
||||
- The `useChat` hook's `sendMessage` function sets `chatState` to `"thinking"` before `acpPrepareSession`, then `"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`, which now goes through the TS session manager calling `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 as `useAcpStream` did.
|
||||
@@ -1,341 +0,0 @@
|
||||
# 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 WebSocket. 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 WebSocket 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 WebSocket ACP connection.
|
||||
#[tauri::command]
|
||||
pub async fn get_goose_serve_url() -> Result<String, String> {
|
||||
GooseServeProcess::start().await?;
|
||||
let process = GooseServeProcess::get()?;
|
||||
Ok(process.ws_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
|
||||
|
||||
### 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 — `PersonaStore` and `GooseConfig` use `tauri::State` which handles the wrapping.
|
||||
|
||||
### 5. Clean up `goose_serve.rs`
|
||||
|
||||
**File:** `src-tauri/src/services/acp/goose_serve.rs`
|
||||
|
||||
1. Remove the `WS_BRIDGE_BUFFER_BYTES` constant (only used by the deleted `thread.rs`):
|
||||
```rust
|
||||
// DELETE:
|
||||
pub(crate) const WS_BRIDGE_BUFFER_BYTES: usize = 64 * 1024;
|
||||
```
|
||||
|
||||
2. Keep `resolve_goose_binary` exported as `pub(crate)` — it is still needed by `model_setup.rs` (which runs `goose configure`).
|
||||
|
||||
3. Replace the WebSocket readiness probe with a TCP connect check. This eliminates the `tokio-tungstenite` and `futures` dependencies:
|
||||
|
||||
```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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Update the `spawn` method to call `wait_for_server_ready(port, &mut child)` instead of `wait_for_server_ready(&ws_url, &mut child)`.
|
||||
|
||||
### 6. Handle `acp-client` binary discovery
|
||||
|
||||
The `acp-client` crate is used by `goose_serve.rs` for `acp_client::find_acp_agent_by_id("goose")` in binary resolution. Two options:
|
||||
|
||||
- **Option A (simplest):** Keep `acp-client` solely for binary discovery. It only uses the `find_acp_agent_by_id` function.
|
||||
- **Option B:** Inline the discovery logic — look for `goose` on PATH and check the `GOOSE_BIN` env var. The `GOOSE_BIN` path is already handled; the `find_acp_agent_by_id` fallback scans the login shell PATH, which can be replaced with a simple `which goose` equivalent.
|
||||
|
||||
Choose one approach and apply it consistently.
|
||||
|
||||
### 7. Remove unused Cargo dependencies
|
||||
|
||||
**File:** `src-tauri/Cargo.toml`
|
||||
|
||||
Remove these dependencies:
|
||||
|
||||
```toml
|
||||
agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork"] }
|
||||
tokio-tungstenite = "0.21.0"
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
tokio-util = { version = "0.7", features = ["compat", "rt"] }
|
||||
```
|
||||
|
||||
If Option A from §6 is chosen, keep `acp-client`. If Option B is chosen, also remove:
|
||||
|
||||
```toml
|
||||
acp-client = { git = "https://github.com/block/builderbot", rev = "db184d20cb48e0c90bbd3fea4a4a871fc9d8a6ad" }
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Run `cargo check` after editing `Cargo.toml` to verify nothing breaks.
|
||||
|
||||
### 8. Run full verification
|
||||
|
||||
```bash
|
||||
cd ui/goose2/src-tauri
|
||||
|
||||
cargo fmt
|
||||
cargo check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
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), `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 (kept if using Option A for binary discovery) |
|
||||
| `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` | Remove `WS_BRIDGE_BUFFER_BYTES` constant, replace readiness probe with TCP connect |
|
||||
| `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 5–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 remaining references.
|
||||
- The `doctor` crate dependency stays — it's used by `commands/doctor.rs` which is not part of this migration.
|
||||
@@ -1,305 +0,0 @@
|
||||
# Step 10: Phase B — Migrate Config, Personas, Skills, Projects, Git, Doctor to `goose serve`
|
||||
|
||||
## Objective
|
||||
|
||||
Migrate each remaining Rust Tauri subsystem behind `goose serve` ACP extension methods, callable from TypeScript via `client.goose.<method>()`. This requires backend changes to the goose crate — adding new ACP extension methods to `goose serve`.
|
||||
|
||||
## Current State After Phase A (Steps 01–09)
|
||||
|
||||
| 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:
|
||||
|
||||
1. **Backend**: Add ACP extension methods to `goose serve` (in `goose-acp` or `goose` crate)
|
||||
2. **Schema**: Regenerate the ACP schema (`npm run build:schema` in `ui/acp/`)
|
||||
3. **Client**: `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, part of the core onboarding flow.
|
||||
|
||||
#### 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 Notes
|
||||
|
||||
- The goose binary already has config management internally (`goose configure`). The extension methods expose the same logic over ACP.
|
||||
- Keyring access happens in the `goose serve` process (which runs natively), so there is no loss of capability.
|
||||
- Move `provider_defs.rs` static definitions to the goose crate.
|
||||
|
||||
#### Frontend Changes
|
||||
|
||||
- `invoke("get_provider_config")` → `client.goose.gooseProviderFields({ providerId })`
|
||||
- `invoke("save_provider_field")` → `client.goose.gooseSecretSet({ key, value })` or `client.goose.gooseConfigSet({ key, value })`
|
||||
- `invoke("delete_provider_config")` → `client.goose.gooseProviderDeleteConfig({ providerId })`
|
||||
- `invoke("check_all_provider_status")` → `client.goose.gooseProviderStatusAll({})`
|
||||
- `invoke("restart_app")` — remains in Rust (native window management)
|
||||
|
||||
#### Files Deleted
|
||||
|
||||
- `src-tauri/src/services/goose_config.rs`
|
||||
- `src-tauri/src/services/provider_defs.rs`
|
||||
- `src-tauri/src/commands/credentials.rs` (except `restart_app`)
|
||||
- `keyring` dependency from `Cargo.toml` (all 3 platform variants)
|
||||
- `etcetera` dependency
|
||||
|
||||
---
|
||||
|
||||
### B2: Personas
|
||||
|
||||
**Priority: Medium** — Used in the chat flow but not on the critical path.
|
||||
|
||||
#### 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 Notes
|
||||
|
||||
- Persona storage (`~/.goose/personas.json`, `~/.goose/agents/*.md`) and avatar handling (`~/.goose/avatars/`) are file-based. The goose binary can read/write these directly.
|
||||
- Move builtin persona definitions from `types/builtin_personas.rs` to the goose crate.
|
||||
|
||||
#### Files Deleted
|
||||
|
||||
- `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**
|
||||
|
||||
#### 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
|
||||
|
||||
- `src-tauri/src/commands/skills.rs`
|
||||
|
||||
---
|
||||
|
||||
### B4: Projects
|
||||
|
||||
**Priority: Low**
|
||||
|
||||
#### 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
|
||||
|
||||
- `src-tauri/src/commands/projects.rs`
|
||||
|
||||
---
|
||||
|
||||
### B5: Git Operations
|
||||
|
||||
**Priority: Medium** — Git state is shown in the workspace widget and context panel.
|
||||
|
||||
#### 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 Notes
|
||||
|
||||
- Git operations run shell commands (`git status`, `git switch`, etc.). The goose binary runs these the same way.
|
||||
- The `ignore` crate for `.gitignore`-aware file scanning in `list_files_for_mentions` moves to goose serve as well.
|
||||
|
||||
#### Files Deleted
|
||||
|
||||
- `src-tauri/src/commands/git.rs`
|
||||
- `src-tauri/src/commands/git_changes.rs`
|
||||
|
||||
---
|
||||
|
||||
### B6: Doctor
|
||||
|
||||
**Priority: Low** — Diagnostic tool, not on the critical path.
|
||||
|
||||
#### Extension Methods
|
||||
|
||||
| Method | Request | Response |
|
||||
|--------|---------|----------|
|
||||
| `goose/doctor/run` | `{}` | `DoctorReport` |
|
||||
| `goose/doctor/fix` | `{ checkId: string, fixType: string }` | `{}` |
|
||||
|
||||
#### Backend Notes
|
||||
|
||||
The `doctor` crate already exists in the goose ecosystem. The extension methods expose it over ACP.
|
||||
|
||||
#### Files Deleted
|
||||
|
||||
- `src-tauri/src/commands/doctor.rs`
|
||||
- `doctor` dependency from `Cargo.toml`
|
||||
|
||||
---
|
||||
|
||||
### B7: Agent & Model Setup
|
||||
|
||||
**Priority: Medium** — Needed for onboarding third-party agents and OAuth flows.
|
||||
|
||||
This subsystem 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`).
|
||||
|
||||
#### Recommendation: Keep in Rust
|
||||
|
||||
These commands remain as Tauri-native commands. They are inherently interactive (opening browsers for OAuth, waiting for user input), are rarely called (only during onboarding), and migrating them would require designing a new ACP streaming notification type. They stay as the last remaining Tauri commands.
|
||||
|
||||
---
|
||||
|
||||
### B8: System Utilities
|
||||
|
||||
**Priority: Low**
|
||||
|
||||
#### 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[] }` |
|
||||
|
||||
#### Stays in Rust: `saveExportedSessionFile`
|
||||
|
||||
This command uses `tauri_plugin_dialog` to show a native save dialog. It cannot move to `goose serve`.
|
||||
|
||||
#### Files Deleted
|
||||
|
||||
- `src-tauri/src/commands/system.rs` (except `save_exported_session_file`)
|
||||
- `ignore` dependency from `Cargo.toml`
|
||||
|
||||
---
|
||||
|
||||
## End State After Phase B
|
||||
|
||||
**Rust Tauri backend (~780 lines):**
|
||||
|
||||
```
|
||||
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)
|
||||
model_setup.rs — model provider auth (~220 lines)
|
||||
services/
|
||||
mod.rs — 1 module
|
||||
acp/
|
||||
mod.rs — 1 module
|
||||
goose_serve.rs — GooseServeProcess (~150 lines)
|
||||
```
|
||||
|
||||
**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 Order
|
||||
|
||||
| Step | Effort | Value | Order |
|
||||
|------|--------|-------|-------|
|
||||
| B1 (Config) | Medium | High (removes keyring dep) | 1st |
|
||||
| B5 (Git) | Medium | Medium | 2nd |
|
||||
| B2 (Personas) | Medium | Medium | 3rd |
|
||||
| B3 (Skills) | Small | Small | 4th |
|
||||
| B4 (Projects) | Small | Small | 5th |
|
||||
| B6 (Doctor) | Small | Small | 6th |
|
||||
| B8 (System utils) | Small | Small | 7th |
|
||||
| B7 (Agent/Model setup) | — | — | Keep in Rust |
|
||||
|
||||
All steps are blocked on implementing the corresponding backend ACP methods, except B7 which remains native.
|
||||
|
||||
## Workflow Per Subsystem
|
||||
|
||||
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 migrates independently. The frontend can use a mix of `invoke()` (not-yet-migrated) and `client.goose.*()` (migrated) during the transition.
|
||||
Reference in New Issue
Block a user