Skip to content

speed up agent switching with provider caching and spinning-up indicator#8612

Open
morgmart wants to merge 1 commit intomainfrom
cursor/f6cfc006
Open

speed up agent switching with provider caching and spinning-up indicator#8612
morgmart wants to merge 1 commit intomainfrom
cursor/f6cfc006

Conversation

@morgmart
Copy link
Copy Markdown
Collaborator

Category: improvement
User Impact: Switching to an external AI agent like Claude Code or Codex feels much faster — the model picker fills instantly from cache, the previously-used agent is pre-warmed at server boot, and a clear "Spinning up [Agent]…" indicator appears in chat whenever the backend isn't ready yet.

Problem: Switching to an ACP-based agent took ~25 seconds with almost no feedback. The model picker hung on "Loading…" the entire time, and if you sent a message during that window the chat showed "Responding…" — implying the model was already working — even though the backend was still booting the Node subprocess and doing handshake/entitlements calls.

Solution: Three layers. Backend caches the long-lived AcpProvider (the Node subprocess + handshake) in memory keyed by provider name, persists model lists to disk so the picker has something to render on cold starts, and pre-warms the last-used provider in a background task at server boot. Frontend adds a spinning_up chat state, computes initial loading state up front so it doesn't flash "Thinking…" before transitioning, and de-duplicates concurrent prepare calls so a background prepare from the agent picker and a foreground prepare from sendMessage join the same in-flight promise instead of racing duplicate newSession round-trips.

File changes

crates/goose-acp/src/server.rs
Added in-memory provider_cache (Arc<Mutex<HashMap<String, Arc<dyn Provider>>>>) and a model_cache field on GooseAcpAgent. New get_or_create_provider helper checks the cache before constructing a provider. set_session_config_option now sends a cached ConfigOptionUpdate notification immediately so the picker fills before the real backend confirms. update_provider writes fresh model options to the disk cache after a successful create.

crates/goose-acp/src/model_cache.rs (new)
Disk-persistence layer for model lists. Stores per-provider SessionConfigOption snapshots with cached_at / last_used_at timestamps. Atomic writes via .tmp + fs::rename. Used to (a) seed the picker on cold starts and (b) decide which provider to pre-warm.

crates/goose-acp/src/server_factory.rs
Spawns a tokio task on server boot that looks up the last-used provider from the model cache and primes the in-memory provider cache by calling get_or_create_provider. Best-effort — failures are silently ignored since this is a warm-up.

crates/goose-acp/src/lib.rs / Cargo.toml
Exports the new model_cache module and adds chrono with serde features for the timestamps.

crates/goose/src/acp/provider.rs
Minor: removed verbose tracing-target debug logs that were used during this investigation.

ui/goose2/src/shared/api/acpSessionTracker.ts
Added an inFlight: Map<string, Promise<string>> so concurrent prepareSession calls join the same promise. Exposes isPrepareInFlight() / isSessionPrepared() so useChat can decide when to show the spinning-up indicator.

ui/goose2/src/shared/api/acp.ts
Re-exports the two new readiness checks as acpIsPrepareInFlight / acpIsSessionPrepared.

ui/goose2/src/features/chat/hooks/useChat.ts
Computes needsPrepare / prepareInFlight up front and sets the initial chat state to spinning_up (instead of thinking) when the backend session isn't ready. Always awaits acpPrepareSession if a background prepare is in flight, so sendMessage doesn't fall through to "Responding…" while the backend is actually still booting.

ui/goose2/src/features/chat/ui/LoadingGoose.tsx
Adds spinning_up to LoadingChatState and a providerName prop. Renders "Spinning up [Agent]…" via a new translation key, falling back to a generic message when no provider name is provided.

ui/goose2/src/features/chat/ui/ChatView.tsx
Threads the selected provider's display label into LoadingGoose and includes spinning_up in the indicator visibility condition.

ui/goose2/src/features/chat/lib/sessionActivity.ts
Includes spinning_up in isSessionRunning so the sidebar / activity affordances treat it like other in-progress states.

ui/goose2/src/shared/i18n/locales/en/chat.json
Adds loading.spinningUp and loading.spinningUpFallback strings.

ui/goose2/src/shared/types/chat.ts
Adds spinning_up to the ChatState union.

ui/goose2/src/shared/api/acpApi.ts + acpNotificationHandler.ts
Removed [models-debug] console instrumentation that was only added to investigate the original issue.

ui/goose2/src/features/chat/hooks/tests/*.ts + LoadingGoose.test.tsx
New test for the spinning_upstreaming transition during prepare. Updated mocks to include the two new exports from @/shared/api/acp. New rendering tests for the spinning_up state with and without a provider name.

Reproduction steps

  1. Start fresh (no recently-used external agent in the model cache): cargo build -p goose-cli then GOOSE_BIN="$PWD/target/debug/goose" just goose2 dev.
  2. Open a new chat and switch the agent picker to Claude Code (or Codex). Picker no longer hangs on "Loading…" — model list appears as soon as the backend pushes config options.
  3. Without waiting, type a message and send it. Chat shows "Spinning up Claude Code…" until the backend is ready, then transitions to "Responding…". Previously this would have shown "Responding…" the entire ~25 seconds.
  4. Restart the dev app. Switch to the same agent again — should be much faster on second click because of the in-memory provider cache and the boot-time pre-warm.

Notes for reviewers

  • Per AGENTS.md, backend changes need cargo fmt, cargo clippy --all-targets -- -D warnings, and just generate-openapi. I have not run these in this environment — please run locally before merging.
  • New chats with a cached provider still incur a per-session Anthropic entitlements roundtrip (~few seconds). That's not addressed here; eliminating it would require a per-provider "spare session" pool, which we discussed and decided to defer.

Cache external ACP providers (Claude Code, Codex) in memory so switching
back to a recently-used agent reuses the warm subprocess, persist model
lists to disk for instant picker fill on cold starts, pre-warm the
last-used provider on server boot, and surface a dedicated "Spinning
up …" state in the chat UI bracketed around the prepare call. Also
de-dupes concurrent prepareSession calls so a background prepare from
the agent picker and a foreground one from sendMessage join the same
in-flight promise instead of racing duplicate newSession round-trips.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dbb4f53d63

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +848 to +852
let cache = provider_cache.lock().await;
cache.get(&provider_name).map(Arc::clone)
};
match cached {
Some(p) => p,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop reusing ACP provider instances across sessions

This cache hit path returns the same Arc<dyn Provider> for every session that selects the same provider name, which is unsafe for ACP providers because AcpProvider owns a single ACP session created at connect time and stream() always uses that stored session id. In practice, opening a second chat on the same ACP provider can route prompts/cancels through the first chat’s ACP session, causing cross-session context/tool-state leakage; cache entries need to be scoped by session (or cache only startup artifacts, not the full provider instance).

Useful? React with 👍 / 👎.

Comment on lines +76 to +80
prepared.set(key, entry);
prepared.set(sessionId, entry);
gooseToLocal.set(gooseSessionId, sessionId);

await acpApi.setProvider(gooseSessionId, providerId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist prepared sessions only after setProvider succeeds

The new ordering marks the session as prepared before acpApi.setProvider(...) completes, so if setProvider fails (e.g., backend still spinning up or transient ACP error), the failed session remains cached as prepared with the requested provider id. Subsequent prepareSession calls then skip the provider update path and return this stale entry, which can leave later sends bound to the wrong/uninitialized backend session instead of retrying provider setup.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant