From 765213561e6e7c39ee6149d35cf3ca84ce61a5cd Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Mon, 20 Apr 2026 14:46:14 +1000 Subject: [PATCH 01/81] used default fallback provider and model if default are set (#8650) --- ui/desktop/src/App.test.tsx | 4 ++-- .../components/onboarding/OnboardingGuard.tsx | 22 +++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx index d594441edae2..f45e44588cab 100644 --- a/ui/desktop/src/App.test.tsx +++ b/ui/desktop/src/App.test.tsx @@ -87,6 +87,8 @@ vi.mock('./components/ModelAndProviderContext', () => ({ provider: null, model: null, getCurrentModelAndProvider: vi.fn(), + getFallbackModelAndProvider: vi.fn().mockResolvedValue({ provider: '', model: '' }), + refreshCurrentModelAndProvider: vi.fn().mockResolvedValue(undefined), setCurrentModelAndProvider: vi.fn(), }), })); @@ -205,8 +207,6 @@ describe('App Component - Brand New State', () => { window.location.hash = ''; window.location.search = ''; window.location.pathname = '/'; - window.sessionStorage?.clear?.(); - window.localStorage?.clear?.(); }); afterEach(() => { diff --git a/ui/desktop/src/components/onboarding/OnboardingGuard.tsx b/ui/desktop/src/components/onboarding/OnboardingGuard.tsx index cb7098180a60..59e543679a36 100644 --- a/ui/desktop/src/components/onboarding/OnboardingGuard.tsx +++ b/ui/desktop/src/components/onboarding/OnboardingGuard.tsx @@ -48,7 +48,7 @@ export default function OnboardingGuard({ children }: OnboardingGuardProps) { const intl = useIntl(); const navigate = useNavigate(); const { read, upsert, getProviders } = useConfig(); - const { refreshCurrentModelAndProvider } = useModelAndProvider(); + const { getFallbackModelAndProvider, refreshCurrentModelAndProvider } = useModelAndProvider(); const [isCheckingProvider, setIsCheckingProvider] = useState(true); const [hasProvider, setHasProvider] = useState(false); @@ -67,7 +67,25 @@ export default function OnboardingGuard({ children }: OnboardingGuardProps) { for (let attempt = 0; attempt <= retries; attempt++) { try { const provider = (await read('GOOSE_PROVIDER', false, { throwOnError: true })) as string | null; - setHasProvider(!!provider?.trim()); + if (provider?.trim()) { + setHasProvider(true); + setIsCheckingProvider(false); + return; + } + + const fallback = await getFallbackModelAndProvider(); + if (fallback.provider?.trim() && fallback.model?.trim()) { + const configuredProvider = (await read('GOOSE_PROVIDER', false)) as string | null; + const configuredModel = (await read('GOOSE_MODEL', false)) as string | null; + if (configuredProvider?.trim() && configuredModel?.trim()) { + await refreshCurrentModelAndProvider(); + setHasProvider(true); + setIsCheckingProvider(false); + return; + } + } + + setHasProvider(false); setIsCheckingProvider(false); return; } catch (error) { From a7d78ee59e6a0eded1f45426c7ad2f9e71ce5710 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 19 Apr 2026 18:53:44 -1000 Subject: [PATCH 02/81] feat: goose2 context window usage in chat input (#8613) Signed-off-by: Taylor Ho --- crates/goose-acp/src/server.rs | 161 ++++++++++++- ui/goose2/justfile | 39 ++- ui/goose2/scripts/check-file-sizes.mjs | 12 +- .../__tests__/useChat.compaction.test.ts | 228 ++++++++++++++++++ .../chat/hooks/__tests__/useChat.test.ts | 20 +- ui/goose2/src/features/chat/hooks/useChat.ts | 107 +++++++- ui/goose2/src/features/chat/ui/ChatInput.tsx | 9 + .../src/features/chat/ui/ChatInputToolbar.tsx | 110 ++++++++- ui/goose2/src/features/chat/ui/ChatView.tsx | 10 +- .../src/features/chat/ui/ContextRing.tsx | 23 +- .../src/features/chat/ui/LoadingGoose.tsx | 4 +- .../chat/ui/__tests__/ChatInput.test.tsx | 43 ++++ .../chat/ui/__tests__/LoadingGoose.test.tsx | 9 +- .../src/shared/api/__tests__/acp.test.ts | 57 +++++ ui/goose2/src/shared/api/acp.ts | 17 +- ui/goose2/src/shared/api/acpApi.ts | 14 +- .../shared/api/acpNotificationHandler.test.ts | 76 ++++++ .../src/shared/api/acpNotificationHandler.ts | 70 +++++- ui/goose2/src/shared/api/acpSessionTracker.ts | 85 ++++++- .../src/shared/i18n/locales/en/chat.json | 6 + .../src/shared/i18n/locales/es/chat.json | 6 + ui/goose2/src/shared/ui/button.tsx | 2 +- 22 files changed, 1035 insertions(+), 73 deletions(-) create mode 100644 ui/goose2/src/features/chat/hooks/__tests__/useChat.compaction.test.ts create mode 100644 ui/goose2/src/shared/api/__tests__/acp.test.ts create mode 100644 ui/goose2/src/shared/api/acpNotificationHandler.test.ts diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index eeeae74df0de..d1a8212c7507 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -40,7 +40,7 @@ use sacp::schema::{ SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate, - ToolCallUpdateFields, ToolKind, + ToolCallUpdateFields, ToolKind, Usage, UsageUpdate, }; use sacp::util::MatchDispatchFrom; use sacp::{ @@ -603,6 +603,22 @@ fn build_config_options( ] } +fn to_nonnegative_u64(value: Option) -> Option { + value.and_then(|v| u64::try_from(v).ok()) +} + +fn build_prompt_usage(session: &Session) -> Option { + let total = to_nonnegative_u64(session.total_tokens)?; + let input = to_nonnegative_u64(session.input_tokens).unwrap_or(0); + let output = to_nonnegative_u64(session.output_tokens).unwrap_or(0); + Some(Usage::new(total, input, output)) +} + +fn build_usage_update(session: &Session, context_limit: usize) -> UsageUpdate { + let used = session.total_tokens.unwrap_or(0).max(0) as u64; + UsageUpdate::new(used, context_limit as u64) +} + impl GooseAcpAgent { pub fn permission_manager(&self) -> Arc { Arc::clone(&self.permission_manager) @@ -1383,27 +1399,38 @@ impl GooseAcpAgent { // Resolve provider + model from config so we can include the current // model in the response without waiting for the full agent setup. let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await; + let initial_usage_update = resolved + .as_ref() + .ok() + .map(|(_, mc)| build_usage_update(&goose_session, mc.context_limit())); let (model_state, config_options) = build_eager_config(&resolved, &mode_state, &goose_session).await; + let session_id = SessionId::new(thread_id.clone()); self.spawn_agent_setup( cx, agent_tx, AgentSetupRequest { - session_id: SessionId::new(thread_id.clone()), + session_id: session_id.clone(), goose_session, mcp_servers: args.mcp_servers, - resolved_provider: resolved.ok(), + resolved_provider: resolved.as_ref().ok().cloned(), }, ); - let mut response = NewSessionResponse::new(SessionId::new(thread_id)).modes(mode_state); + let mut response = NewSessionResponse::new(session_id.clone()).modes(mode_state); if let Some(ms) = model_state { response = response.models(ms); } if let Some(co) = config_options { response = response.config_options(co); } + if let Some(usage_update) = initial_usage_update { + cx.send_notification(SessionNotification::new( + session_id, + SessionUpdate::UsageUpdate(usage_update), + ))?; + } debug!( target: "perf", sid = %sid, @@ -1748,6 +1775,16 @@ impl GooseAcpAgent { let mode_state = build_mode_state(loaded_mode)?; let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await; + let initial_usage_update = resolved + .as_ref() + .ok() + .map(|(_, mc)| build_usage_update(&goose_session, mc.context_limit())) + .or_else(|| { + goose_session + .model_config + .as_ref() + .map(|mc| build_usage_update(&goose_session, mc.context_limit())) + }); let (model_state, config_options) = build_eager_config(&resolved, &mode_state, &goose_session).await; @@ -1769,6 +1806,12 @@ impl GooseAcpAgent { if let Some(co) = config_options { response = response.config_options(co); } + if let Some(usage_update) = initial_usage_update { + cx.send_notification(SessionNotification::new( + args.session_id.clone(), + SessionUpdate::UsageUpdate(usage_update), + ))?; + } debug!( target: "perf", sid = %sid, @@ -1882,10 +1925,30 @@ impl GooseAcpAgent { } } - let mut sessions = self.sessions.lock().await; - if let Some(session) = sessions.get_mut(&thread_id) { - session.cancel_token = None; + { + let mut sessions = self.sessions.lock().await; + if let Some(session) = sessions.get_mut(&thread_id) { + session.cancel_token = None; + } } + + let session = self + .session_manager + .get_session(&internal_session_id, false) + .await + .map_err(|e| { + sacp::Error::internal_error().data(format!("Failed to load session: {}", e)) + })?; + let provider = agent.provider().await.map_err(|e| { + sacp::Error::internal_error().data(format!("Failed to get provider: {}", e)) + })?; + let usage_update = + build_usage_update(&session, provider.get_model_config().context_limit()); + cx.send_notification(SessionNotification::new( + args.session_id.clone(), + SessionUpdate::UsageUpdate(usage_update), + ))?; + debug!( target: "perf", sid = %sid, @@ -1894,11 +1957,17 @@ impl GooseAcpAgent { cancelled = was_cancelled, "perf: prompt done" ); - Ok(PromptResponse::new(if was_cancelled { + let stop_reason = if was_cancelled { StopReason::Cancelled } else { StopReason::EndTurn - })) + }; + + let mut response = PromptResponse::new(stop_reason); + if let Some(usage) = build_prompt_usage(&session) { + response = response.usage(usage); + } + Ok(response) } async fn on_cancel(&self, args: CancelNotification) -> Result<(), sacp::Error> { @@ -3486,6 +3555,80 @@ print(\"hello, world\") .map(|locs| locs.into_iter().map(|loc| (loc.path, loc.line)).collect()) } + fn make_session_with_usage( + total_tokens: Option, + input_tokens: Option, + output_tokens: Option, + accumulated_total_tokens: Option, + accumulated_input_tokens: Option, + accumulated_output_tokens: Option, + ) -> Session { + Session { + id: "session-1".to_string(), + working_dir: PathBuf::from("/tmp"), + name: "ACP Session".to_string(), + user_set_name: false, + session_type: SessionType::Acp, + created_at: Default::default(), + updated_at: Default::default(), + extension_data: goose::session::ExtensionData::default(), + total_tokens, + input_tokens, + output_tokens, + accumulated_total_tokens, + accumulated_input_tokens, + accumulated_output_tokens, + schedule_id: None, + recipe: None, + user_recipe_values: None, + conversation: None, + message_count: 0, + provider_name: None, + model_config: None, + goose_mode: GooseMode::default(), + thread_id: None, + } + } + + #[test] + fn test_build_prompt_usage_uses_current_turn_tokens() { + let session = make_session_with_usage( + Some(120), + Some(80), + Some(40), + Some(360), + Some(210), + Some(150), + ); + let usage = build_prompt_usage(&session).expect("usage should be present"); + assert_eq!(usage.total_tokens, 120); + assert_eq!(usage.input_tokens, 80); + assert_eq!(usage.output_tokens, 40); + } + + #[test] + fn test_build_prompt_usage_falls_back_to_current_tokens() { + let session = make_session_with_usage(Some(120), Some(80), Some(40), None, None, None); + let usage = build_prompt_usage(&session).expect("usage should be present"); + assert_eq!(usage.total_tokens, 120); + assert_eq!(usage.input_tokens, 80); + assert_eq!(usage.output_tokens, 40); + } + + #[test] + fn test_build_prompt_usage_requires_total_tokens() { + let session = make_session_with_usage(None, Some(80), Some(40), None, None, None); + assert!(build_prompt_usage(&session).is_none()); + } + + #[test] + fn test_build_usage_update_clamps_negative_used_to_zero() { + let session = make_session_with_usage(Some(-7), Some(0), Some(0), None, None, None); + let usage = build_usage_update(&session, 258_000); + assert_eq!(usage.used, 0); + assert_eq!(usage.size, 258_000); + } + #[test_case( GooseMode::Auto => Ok(SessionModeState::new( diff --git a/ui/goose2/justfile b/ui/goose2/justfile index f02b33a8d34a..11c2a6ca8d15 100644 --- a/ui/goose2/justfile +++ b/ui/goose2/justfile @@ -88,10 +88,24 @@ dev: # Override with e.g. RUST_LOG=info just dev to disable. export RUST_LOG="${RUST_LOG:-perf=debug,info}" PROJECT_DIR=$(pwd) - GOOSE_BIN="${PROJECT_DIR}/../../target/debug/goose" - export GOOSE_BIN + REPO_ROOT=$(cd ../.. && pwd) + LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose" + LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose" + if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then + export GOOSE_BIN="${LOCAL_GOOSE_DEBUG}" + elif [[ -x "${LOCAL_GOOSE_RELEASE}" ]]; then + export GOOSE_BIN="${LOCAL_GOOSE_RELEASE}" + else + unset GOOSE_BIN + fi EXTRA_CONFIG_ARGS=(--config "{\"build\":{\"devUrl\":\"http://localhost:${VITE_PORT}\",\"beforeDevCommand\":{\"script\":\"cd ${PROJECT_DIR} && exec pnpm exec vite --port ${VITE_PORT} --strictPort\",\"cwd\":\".\",\"wait\":false}}}") + if [[ -n "${GOOSE_BIN:-}" ]]; then + echo "Using local goose binary: ${GOOSE_BIN}" + else + echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH" + fi + # In worktrees, generate a labeled icon so you can tell instances apart if git rev-parse --is-inside-work-tree &>/dev/null; then GIT_DIR=$(git rev-parse --git-dir) @@ -117,14 +131,29 @@ dev-debug: #!/usr/bin/env bash set -euo pipefail + VITE_PORT={{ vite_port }} + export VITE_PORT # Enable perf logs in the child `goose serve` process by default. # Override with e.g. RUST_LOG=info just dev-debug to disable. export RUST_LOG="${RUST_LOG:-perf=debug,info}" - PROJECT_DIR=$(pwd) - GOOSE_BIN="${PROJECT_DIR}/../../target/debug/goose" - export GOOSE_BIN + REPO_ROOT=$(cd ../.. && pwd) + LOCAL_GOOSE_DEBUG="${REPO_ROOT}/target/debug/goose" + LOCAL_GOOSE_RELEASE="${REPO_ROOT}/target/release/goose" + if [[ -x "${LOCAL_GOOSE_DEBUG}" ]]; then + export GOOSE_BIN="${LOCAL_GOOSE_DEBUG}" + elif [[ -x "${LOCAL_GOOSE_RELEASE}" ]]; then + export GOOSE_BIN="${LOCAL_GOOSE_RELEASE}" + else + unset GOOSE_BIN + fi EXTRA_CONFIG_ARGS=(--config "{\"build\":{\"devUrl\":\"http://localhost:${VITE_PORT}\",\"beforeDevCommand\":{\"script\":\"exec ./node_modules/.bin/vite --port ${VITE_PORT} --strictPort\",\"cwd\":\"..\",\"wait\":false}}}") + if [[ -n "${GOOSE_BIN:-}" ]]; then + echo "Using local goose binary: ${GOOSE_BIN}" + else + echo "No local goose binary found under ${REPO_ROOT}/target; falling back to PATH" + fi + # In worktrees, generate a labeled icon so you can tell instances apart if git rev-parse --is-inside-work-tree &>/dev/null; then GIT_DIR=$(git rev-parse --git-dir) diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index 5d2066a196d0..07e1d124f273 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -13,7 +13,17 @@ const EXCEPTIONS = { "src/features/chat/ui/ChatView.tsx": { limit: 570, justification: - "ACP prewarm guards, project-aware working dir selection, working context sync, and chat bootstrapping still live together here. Includes gated [perf:chatview] logging via perfLog (dev-only by default).", + "ACP prewarm guards, project-aware working dir selection, working context sync, chat bootstrapping, context-ring compaction wiring, and gated [perf:chatview] logging via perfLog (dev-only by default).", + }, + "src/features/chat/hooks/useChat.ts": { + limit: 510, + justification: + "Session preparation, provider/model handoff, persona-aware sends, cancellation, and compaction replay still live in one chat lifecycle hook.", + }, + "src/shared/api/acpNotificationHandler.ts": { + limit: 550, + justification: + "ACP replay/live update handling, pending session buffering, model/config propagation, and streaming perf tracking still share one notification entrypoint.", }, "src/features/chat/ui/__tests__/ContextPanel.test.tsx": { limit: 550, diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChat.compaction.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChat.compaction.test.ts new file mode 100644 index 000000000000..09b1f6fd2fe3 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChat.compaction.test.ts @@ -0,0 +1,228 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Message } from "@/shared/types/messages"; +import { useChatStore } from "../../stores/chatStore"; +import { clearReplayBuffer, ensureReplayBuffer } from "../replayBuffer"; + +const mockAcpSendMessage = vi.fn(); +const mockAcpLoadSession = vi.fn(); +const mockGetGooseSessionId = vi.fn(); + +vi.mock("@/shared/api/acp", () => ({ + acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args), + acpCancelSession: vi.fn(), + acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args), + acpPrepareSession: vi.fn(), + acpSetModel: vi.fn(), +})); + +vi.mock("@/shared/api/acpSessionTracker", () => ({ + getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args), +})); + +import { useChat } from "../useChat"; + +function createDeferredPromise() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function createTextMessage( + id: string, + role: Message["role"], + text: string, +): Message { + return { + id, + role, + created: 0, + content: [{ type: "text", text }], + metadata: { + userVisible: true, + agentVisible: role !== "system", + }, + }; +} + +describe("useChat compaction", () => { + beforeEach(() => { + mockAcpSendMessage.mockReset(); + mockAcpLoadSession.mockReset(); + mockGetGooseSessionId.mockReset(); + clearReplayBuffer("session-1"); + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + activeSessionId: null, + isConnected: true, + loadingSessionIds: new Set(), + }); + mockAcpSendMessage.mockResolvedValue(undefined); + mockAcpLoadSession.mockResolvedValue(undefined); + mockGetGooseSessionId.mockReturnValue(null); + }); + + it("reloads compacted history after sending the compact command", async () => { + mockGetGooseSessionId.mockReturnValue("goose-session-1"); + mockAcpLoadSession.mockImplementation(async (sessionId: string) => { + const buffer = ensureReplayBuffer(sessionId); + buffer.push(createTextMessage("user-1", "user", "Before compact")); + buffer.push(createTextMessage("compact-1", "user", "/compact/compact")); + buffer.push( + createTextMessage("assistant-1", "assistant", "After compact"), + ); + }); + + useChatStore + .getState() + .setMessages("session-1", [ + createTextMessage("stale-1", "assistant", "Stale"), + ]); + + const { result } = renderHook(() => useChat("session-1")); + + await act(async () => { + await result.current.compactConversation(); + }); + + expect(mockAcpSendMessage).toHaveBeenCalledWith( + "session-1", + "/compact", + undefined, + ); + expect(mockAcpLoadSession).toHaveBeenCalledWith( + "session-1", + "goose-session-1", + undefined, + ); + + const messages = useChatStore.getState().messagesBySession["session-1"]; + const runtime = useChatStore.getState().getSessionRuntime("session-1"); + + expect(messages).toEqual([ + createTextMessage("user-1", "user", "Before compact"), + createTextMessage("assistant-1", "assistant", "After compact"), + ]); + expect(runtime.chatState).toBe("idle"); + expect(runtime.error).toBeNull(); + expect(useChatStore.getState().loadingSessionIds.has("session-1")).toBe( + false, + ); + }); + + it("blocks new sends while compaction is in flight", async () => { + mockGetGooseSessionId.mockReturnValue("goose-session-1"); + const compactDeferred = createDeferredPromise(); + mockAcpSendMessage.mockImplementation( + (_sessionId: string, prompt: string) => + prompt === "/compact" ? compactDeferred.promise : Promise.resolve(), + ); + + const { result } = renderHook(() => useChat("session-1")); + + let compactPromise!: Promise; + await act(async () => { + compactPromise = result.current.compactConversation(); + await Promise.resolve(); + }); + + expect( + useChatStore.getState().getSessionRuntime("session-1").chatState, + ).toBe("compacting"); + + await act(async () => { + await result.current.sendMessage("Hello during compact"); + }); + + expect(mockAcpSendMessage).toHaveBeenCalledTimes(1); + expect(mockAcpSendMessage).toHaveBeenCalledWith( + "session-1", + "/compact", + undefined, + ); + expect( + useChatStore.getState().messagesBySession["session-1"], + ).toBeUndefined(); + expect( + useChatStore.getState().getSessionRuntime("session-1").chatState, + ).toBe("compacting"); + + compactDeferred.resolve(); + await act(async () => { + await compactPromise; + }); + + expect( + useChatStore.getState().getSessionRuntime("session-1").chatState, + ).toBe("idle"); + }); + + it("ignores a second compact request while the first one is still in flight", async () => { + mockGetGooseSessionId.mockReturnValue("goose-session-1"); + const compactDeferred = createDeferredPromise(); + mockAcpSendMessage.mockImplementation( + (_sessionId: string, prompt: string) => + prompt === "/compact" ? compactDeferred.promise : Promise.resolve(), + ); + + const { result } = renderHook(() => useChat("session-1")); + + let firstCompact!: Promise; + let secondCompact!: Promise; + await act(async () => { + firstCompact = result.current.compactConversation(); + secondCompact = result.current.compactConversation(); + await Promise.resolve(); + }); + + expect(mockAcpSendMessage).toHaveBeenCalledTimes(1); + expect(mockAcpSendMessage).toHaveBeenCalledWith( + "session-1", + "/compact", + undefined, + ); + expect(mockAcpLoadSession).not.toHaveBeenCalled(); + expect( + useChatStore.getState().getSessionRuntime("session-1").chatState, + ).toBe("compacting"); + + compactDeferred.resolve(); + await act(async () => { + await Promise.all([firstCompact, secondCompact]); + }); + + expect(mockAcpLoadSession).toHaveBeenCalledTimes(1); + expect( + useChatStore.getState().getSessionRuntime("session-1").chatState, + ).toBe("idle"); + }); + + it("surfaces an error when compacting before the session is prepared", async () => { + const { result } = renderHook(() => useChat("session-1")); + + await act(async () => { + await result.current.compactConversation(); + }); + + expect(mockAcpSendMessage).not.toHaveBeenCalled(); + expect(mockAcpLoadSession).not.toHaveBeenCalled(); + + const messages = useChatStore.getState().messagesBySession["session-1"]; + const runtime = useChatStore.getState().getSessionRuntime("session-1"); + + expect(messages).toHaveLength(1); + expect(messages[0].content).toEqual([ + { + type: "systemNotification", + notificationType: "error", + text: "Session not prepared. Send a message before compacting.", + }, + ]); + expect(runtime.error).toBe( + "Session not prepared. Send a message before compacting.", + ); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts index 4c9509a5afcf..3dc918e7d60e 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts @@ -4,19 +4,27 @@ import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useChatStore } from "../../stores/chatStore"; import { useChatSessionStore } from "../../stores/chatSessionStore"; import type { Message } from "@/shared/types/messages"; +import { clearReplayBuffer } from "../replayBuffer"; const mockAcpSendMessage = vi.fn(); const mockAcpCancelSession = vi.fn(); +const mockAcpLoadSession = vi.fn(); const mockAcpPrepareSession = vi.fn(); const mockAcpSetModel = vi.fn(); +const mockGetGooseSessionId = vi.fn(); vi.mock("@/shared/api/acp", () => ({ acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args), acpCancelSession: (...args: unknown[]) => mockAcpCancelSession(...args), + acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args), acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args), acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args), })); +vi.mock("@/shared/api/acpSessionTracker", () => ({ + getGooseSessionId: (...args: unknown[]) => mockGetGooseSessionId(...args), +})); + import { useChat } from "../useChat"; function addStreamingAssistantMessage( @@ -53,7 +61,14 @@ function createDeferredPromise() { describe("useChat", () => { beforeEach(() => { - vi.clearAllMocks(); + mockAcpSendMessage.mockReset(); + mockAcpCancelSession.mockReset(); + mockAcpLoadSession.mockReset(); + mockAcpPrepareSession.mockReset(); + mockAcpSetModel.mockReset(); + mockGetGooseSessionId.mockReset(); + clearReplayBuffer("session-1"); + clearReplayBuffer("session-2"); useChatStore.setState({ messagesBySession: {}, sessionStateById: {}, @@ -96,9 +111,12 @@ describe("useChat", () => { personaEditorOpen: false, editingPersona: null, }); + mockAcpSendMessage.mockResolvedValue(undefined); mockAcpCancelSession.mockResolvedValue(true); + mockAcpLoadSession.mockResolvedValue(undefined); mockAcpPrepareSession.mockResolvedValue(undefined); mockAcpSetModel.mockResolvedValue(undefined); + mockGetGooseSessionId.mockReturnValue(null); }); it("cancels the active override persona instead of the hook default persona", async () => { diff --git a/ui/goose2/src/features/chat/hooks/useChat.ts b/ui/goose2/src/features/chat/hooks/useChat.ts index 3f16395a6228..51af77f0dc12 100644 --- a/ui/goose2/src/features/chat/hooks/useChat.ts +++ b/ui/goose2/src/features/chat/hooks/useChat.ts @@ -1,18 +1,23 @@ import { useCallback, useRef } from "react"; import { useChatStore } from "../stores/chatStore"; import { useChatSessionStore } from "../stores/chatSessionStore"; +import { clearReplayBuffer, getAndDeleteReplayBuffer } from "./replayBuffer"; import { type ChatAttachmentDraft, + type Message, createSystemNotificationMessage, createUserMessage, + getTextContent, } from "@/shared/types/messages"; import type { ChatState, TokenState } from "@/shared/types/chat"; import { acpSendMessage, acpCancelSession, + acpLoadSession, acpPrepareSession, acpSetModel, } from "@/shared/api/acp"; +import { getGooseSessionId } from "@/shared/api/acpSessionTracker"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { getSessionTitleFromDraft, @@ -26,6 +31,26 @@ import { buildMessageAttachments, } from "../lib/attachments"; +// TODO: Remove this fallback once goose2 has first-class /-commands. +const MANUAL_COMPACT_TRIGGER = "/compact"; + +function isManualCompactCommandMessage(message: Message): boolean { + if (message.role !== "user") { + return false; + } + + const normalizedText = getTextContent(message).replace(/\s+/g, ""); + if (!normalizedText) { + return false; + } + + return normalizedText.replaceAll(MANUAL_COMPACT_TRIGGER, "").length === 0; +} + +function removeManualCompactCommandMessages(messages: Message[]): Message[] { + return messages.filter((message) => !isManualCompactCommandMessage(message)); +} + function getErrorMessage(error: unknown): string { // Tauri command rejections typically arrive as plain strings, so handle // that shape first before falling back to standard Error objects. @@ -134,10 +159,14 @@ export function useChat( const tSendStart = performance.now(); const images = buildAcpImages(attachments); const hasAttachments = (attachments?.length ?? 0) > 0; + const currentChatState = useChatStore + .getState() + .getSessionRuntime(sessionId).chatState; if ( (!text.trim() && !hasAttachments) || - chatState === "streaming" || - chatState === "thinking" + currentChatState === "streaming" || + currentChatState === "thinking" || + currentChatState === "compacting" ) return; perfLog( @@ -318,7 +347,6 @@ export function useChat( }, [ sessionId, - chatState, store, providerOverride, systemPromptOverride, @@ -387,6 +415,78 @@ export function useChat( store.setPendingAssistantProvider(sessionId, null); }, [sessionId, store]); + const compactConversation = useCallback(async () => { + const currentChatState = useChatStore + .getState() + .getSessionRuntime(sessionId).chatState; + if (currentChatState !== "idle") { + return; + } + + const effectivePersonaInfo = resolvePersonaInfo(); + const gooseSessionId = getGooseSessionId( + sessionId, + effectivePersonaInfo?.id, + ); + + if (!gooseSessionId) { + const errorMessage = + "Session not prepared. Send a message before compacting."; + store.addMessage( + sessionId, + createSystemNotificationMessage(errorMessage, "error"), + ); + store.setError(sessionId, errorMessage); + return; + } + + store.setActiveSession(sessionId); + store.setChatState(sessionId, "compacting"); + store.setStreamingMessageId(sessionId, null); + store.setError(sessionId, null); + store.setSessionLoading(sessionId, true); + clearReplayBuffer(sessionId); + + try { + const sendOptions = effectivePersonaInfo?.id + ? { personaId: effectivePersonaInfo.id } + : undefined; + await acpSendMessage(sessionId, MANUAL_COMPACT_TRIGGER, sendOptions); + + // Command responses are streamed via prompt notifications, but the ACP + // layer does not currently forward history replacement events. Drop those + // transient chunks and refresh the session from replay instead. + clearReplayBuffer(sessionId); + const workingDir = await getWorkingDir?.(); + await acpLoadSession(sessionId, gooseSessionId, workingDir); + + store.setSessionLoading(sessionId, false); + + const buffer = getAndDeleteReplayBuffer(sessionId); + if (buffer) { + store.setMessages( + sessionId, + removeManualCompactCommandMessages(buffer), + ); + } + } catch (err) { + clearReplayBuffer(sessionId); + store.setSessionLoading(sessionId, false); + + const errorMessage = getErrorMessage(err); + store.addMessage( + sessionId, + createSystemNotificationMessage(errorMessage, "error"), + ); + store.setError(sessionId, errorMessage); + } finally { + store.setChatState(sessionId, "idle"); + store.setStreamingMessageId(sessionId, null); + store.setPendingAssistantProvider(sessionId, null); + store.setSessionLoading(sessionId, false); + } + }, [getWorkingDir, resolvePersonaInfo, sessionId, store]); + const stopStreaming = stopGeneration; return { @@ -400,6 +500,7 @@ export function useChat( stopStreaming, retryLastMessage, clearChat, + compactConversation, isStreaming, }; } diff --git a/ui/goose2/src/features/chat/ui/ChatInput.tsx b/ui/goose2/src/features/chat/ui/ChatInput.tsx index fdf2bea7d051..9b40f2b768f3 100644 --- a/ui/goose2/src/features/chat/ui/ChatInput.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInput.tsx @@ -64,6 +64,9 @@ interface ChatInputProps { }) => void; contextTokens?: number; contextLimit?: number; + onCompactContext?: () => void | Promise; + canCompactContext?: boolean; + isCompactingContext?: boolean; } export function ChatInput({ @@ -94,6 +97,9 @@ export function ChatInput({ onCreateProject, contextTokens = 0, contextLimit = 0, + onCompactContext, + canCompactContext = false, + isCompactingContext = false, }: ChatInputProps) { const { t } = useTranslation("chat"); const [text, setTextRaw] = useState(initialValue); @@ -429,6 +435,9 @@ export function ChatInput({ onCreateProject={onCreateProject} contextTokens={contextTokens} contextLimit={contextLimit} + onCompactContext={onCompactContext} + canCompactContext={canCompactContext} + isCompactingContext={isCompactingContext} canSend={canSend} isStreaming={isStreaming} hasQueuedMessage={hasQueuedMessage} diff --git a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx index 85cffd37900c..3e25b8f084ce 100644 --- a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Mic, ArrowUp, @@ -24,6 +24,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Progress } from "@/shared/ui/progress"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip"; import { AgentModelPicker } from "./AgentModelPicker"; import type { ModelOption } from "../types"; @@ -77,6 +79,9 @@ interface ChatInputToolbarProps { contextTokens: number; contextLimit: number; // Actions + canCompactContext?: boolean; + isCompactingContext?: boolean; + onCompactContext?: () => void | Promise; canSend: boolean; isStreaming: boolean; hasQueuedMessage: boolean; @@ -108,6 +113,9 @@ export function ChatInputToolbar({ onCreateProject, contextTokens, contextLimit, + canCompactContext = false, + isCompactingContext = false, + onCompactContext, canSend, isStreaming, hasQueuedMessage, @@ -121,6 +129,7 @@ export function ChatInputToolbar({ const { t } = useTranslation("chat"); const { formatNumber } = useLocaleFormatting(); const { readyAgentIds } = useAgentProviderStatus(); + const [isContextPopoverOpen, setIsContextPopoverOpen] = useState(false); const agentProviders = useMemo(() => { const seen = new Set(); @@ -156,6 +165,21 @@ export function ChatInputToolbar({ const projectTitle = selectedProject?.workingDirs.length ? selectedProject.workingDirs.join(", ") : undefined; + const contextProgress = + contextLimit > 0 ? Math.min(contextTokens / contextLimit, 1) : 0; + const contextPercentDigits = + contextProgress > 0 && contextProgress < 0.1 ? 1 : 0; + const usedPercentLabel = formatNumber(contextProgress, { + style: "percent", + minimumFractionDigits: contextPercentDigits, + maximumFractionDigits: contextPercentDigits, + }); + const formatCompactTokenCount = (value: number) => + formatNumber(value, { + notation: "compact", + compactDisplay: "short", + maximumFractionDigits: value < 10_000 ? 1 : 0, + }); const handleProjectValueChange = (value: string) => { if (value === CREATE_PROJECT_VALUE) { @@ -166,6 +190,15 @@ export function ChatInputToolbar({ onProjectChange?.(value === NO_PROJECT_VALUE ? null : value); }; + const handleCompactContext = () => { + if (!canCompactContext || isCompactingContext || !onCompactContext) { + return; + } + + setIsContextPopoverOpen(false); + void onCompactContext(); + }; + return (
{/* Left side: pickers */} @@ -247,19 +280,70 @@ export function ChatInputToolbar({ )} {contextLimit > 0 && ( - + + + + +
+ {t("toolbar.contextWindow")} +
+
+ +
+
+ {t("toolbar.contextTokensBreakdown", { + tokens: formatCompactTokenCount(contextTokens), + limit: formatCompactTokenCount(contextLimit), + })} +
+
{usedPercentLabel}
+
+ +
+
+ )} diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx index f71313a37cea..ce0c6184f792 100644 --- a/ui/goose2/src/features/chat/ui/ChatView.tsx +++ b/ui/goose2/src/features/chat/ui/ChatView.tsx @@ -53,7 +53,6 @@ export function ChatView({ const { t } = useTranslation("chat"); const activeSessionId = sessionId; const mountStart = useRef(performance.now()); - // biome-ignore lint/correctness/useExhaustiveDependencies: log once on mount per session useEffect(() => { const ms = (performance.now() - mountStart.current).toFixed(1); perfLog(`[perf:chatview] ${sessionId.slice(0, 8)} mounted in ${ms}ms`); @@ -363,6 +362,7 @@ export function ChatView({ chatState, tokenState, sendMessage, + compactConversation, stopStreaming, streamingMessageId, } = useChat( @@ -453,6 +453,7 @@ export function ChatView({ onInitialMessageConsumed, ]); const isStreaming = chatState === "streaming"; + const isCompacting = chatState === "compacting"; const showIndicator = chatState === "thinking" || chatState === "streaming" || @@ -544,6 +545,13 @@ export function ChatView({ } contextTokens={tokenState.accumulatedTotal} contextLimit={tokenState.contextLimit} + onCompactContext={compactConversation} + canCompactContext={ + chatState === "idle" && + tokenState.accumulatedTotal > 0 && + !projectMetadataPending + } + isCompactingContext={isCompacting} />
diff --git a/ui/goose2/src/features/chat/ui/ContextRing.tsx b/ui/goose2/src/features/chat/ui/ContextRing.tsx index cacb436d7135..9f49d28482e0 100644 --- a/ui/goose2/src/features/chat/ui/ContextRing.tsx +++ b/ui/goose2/src/features/chat/ui/ContextRing.tsx @@ -22,14 +22,6 @@ export function ContextRing({ const offset = circumference - progress * circumference; const percent = formatNumber(Math.round(progress * 100)); - // Color based on usage - const strokeColor = - progress > 0.9 - ? "var(--text-danger)" - : progress > 0.7 - ? "var(--text-warning)" - : "var(--text-muted)"; - return ( - {/* Background track */} - {/* Progress arc */} 0 ? "round" : "butt"} strokeDasharray={circumference} strokeDashoffset={offset} transform={`rotate(-90 ${size / 2} ${size / 2})`} - className="transition-all duration-300" + className="transition-all duration-300 ease-out" /> ); diff --git a/ui/goose2/src/features/chat/ui/LoadingGoose.tsx b/ui/goose2/src/features/chat/ui/LoadingGoose.tsx index e7a2e3f3b6c6..bd5c885985b8 100644 --- a/ui/goose2/src/features/chat/ui/LoadingGoose.tsx +++ b/ui/goose2/src/features/chat/ui/LoadingGoose.tsx @@ -21,12 +21,12 @@ const LOADING_SHIMMER_REPEAT_DELAY_S = 0.9; const MESSAGE_KEY_BY_STATE: Record< Exclude, - "thinking" | "responding" + "thinking" | "responding" | "compacting" > = { thinking: "thinking", streaming: "responding", waiting: "responding", - compacting: "responding", + compacting: "compacting", }; export function LoadingGoose({ chatState = "idle" }: LoadingGooseProps) { diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx index 68d141de0835..0561892dd011 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -223,6 +223,49 @@ describe("ChatInput", () => { expect(screen.getByText("goose2")).toBeInTheDocument(); }); + it("opens a context usage popover when token tracking is available", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: /context usage/i })); + + expect(screen.getByText("Context window")).toBeInTheDocument(); + expect(screen.getByText("1.5K / 8.2K tokens used")).toBeInTheDocument(); + expect(screen.getByText("19%")).toBeInTheDocument(); + }); + + it("runs compaction from the context usage popover", async () => { + const user = userEvent.setup(); + const onCompactContext = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /context usage/i })); + await user.click(screen.getByRole("button", { name: "Compact" })); + + expect(onCompactContext).toHaveBeenCalledOnce(); + }); + + it("hides the context usage control when the context limit is unavailable", () => { + render( + , + ); + + expect( + screen.queryByRole("button", { name: /context usage/i }), + ).not.toBeInTheDocument(); + }); + it("shows stop button when streaming", () => { render(); expect( diff --git a/ui/goose2/src/features/chat/ui/__tests__/LoadingGoose.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/LoadingGoose.test.tsx index 939ee3f824df..9c34927c3581 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/LoadingGoose.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/LoadingGoose.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { LoadingGoose } from "../LoadingGoose"; import chat from "@/shared/i18n/locales/en/chat.json"; -const { thinking, responding } = chat.loading; +const { thinking, responding, compacting } = chat.loading; describe("LoadingGoose", () => { it("renders thinking copy for the thinking state", () => { @@ -23,10 +23,13 @@ describe("LoadingGoose", () => { expect( screen.getByRole("status", { name: responding }), ).toBeInTheDocument(); + }); + + it("renders compacting copy for the compacting state", () => { + render(); - rerender(); expect( - screen.getByRole("status", { name: responding }), + screen.getByRole("status", { name: compacting }), ).toBeInTheDocument(); }); diff --git a/ui/goose2/src/shared/api/__tests__/acp.test.ts b/ui/goose2/src/shared/api/__tests__/acp.test.ts new file mode 100644 index 000000000000..880a9fb4fd74 --- /dev/null +++ b/ui/goose2/src/shared/api/__tests__/acp.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockLoadSession = vi.fn(); + +vi.mock("../acpApi", () => ({ + listProviders: vi.fn(), + prompt: vi.fn(), + setModel: vi.fn(), + listSessions: vi.fn(), + loadSession: (...args: unknown[]) => mockLoadSession(...args), + exportSession: vi.fn(), + importSession: vi.fn(), + forkSession: vi.fn(), + cancelSession: vi.fn(), +})); + +vi.mock("../acpNotificationHandler", () => ({ + setActiveMessageId: vi.fn(), + clearActiveMessageId: vi.fn(), +})); + +vi.mock("../sessionSearch", () => ({ + searchSessionsViaExports: vi.fn(), +})); + +describe("acpLoadSession", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it("restores the prior session mapping when replay loading fails", async () => { + mockLoadSession.mockRejectedValueOnce(new Error("load failed")); + + const sessionTracker = await import("../acpSessionTracker"); + const { acpLoadSession } = await import("../acp"); + + sessionTracker.registerSession( + "local-session", + "goose-session-1", + "goose", + "/tmp/original", + ); + + await expect( + acpLoadSession("local-session", "goose-session-2", "/tmp/replay"), + ).rejects.toThrow("load failed"); + + expect(sessionTracker.getGooseSessionId("local-session")).toBe( + "goose-session-1", + ); + expect(sessionTracker.getLocalSessionId("goose-session-1")).toBe( + "local-session", + ); + expect(sessionTracker.getLocalSessionId("goose-session-2")).toBeNull(); + }); +}); diff --git a/ui/goose2/src/shared/api/acp.ts b/ui/goose2/src/shared/api/acp.ts index 85fa8dd54925..79b1b7c0b93e 100644 --- a/ui/goose2/src/shared/api/acp.ts +++ b/ui/goose2/src/shared/api/acp.ts @@ -146,17 +146,22 @@ export async function acpLoadSession( const effectiveWorkingDir = workingDir ?? "~/.goose/artifacts"; const sid = sessionId.slice(0, 8); const t0 = performance.now(); - perfLog(`[perf:load] ${sid} acpLoadSession → client.loadSession`); - await directAcp.loadSession(gooseSessionId, effectiveWorkingDir); - perfLog( - `[perf:load] ${sid} client.loadSession resolved in ${(performance.now() - t0).toFixed(1)}ms`, - ); - sessionTracker.registerSession( + const rollbackSessionRegistration = sessionTracker.registerSession( sessionId, gooseSessionId, "goose", effectiveWorkingDir, ); + try { + perfLog(`[perf:load] ${sid} acpLoadSession → client.loadSession`); + await directAcp.loadSession(gooseSessionId, effectiveWorkingDir); + perfLog( + `[perf:load] ${sid} client.loadSession resolved in ${(performance.now() - t0).toFixed(1)}ms`, + ); + } catch (error) { + rollbackSessionRegistration(); + throw error; + } } /** Export a session as JSON via the goose binary. */ diff --git a/ui/goose2/src/shared/api/acpApi.ts b/ui/goose2/src/shared/api/acpApi.ts index 015e7be18fd5..f5bf706007b3 100644 --- a/ui/goose2/src/shared/api/acpApi.ts +++ b/ui/goose2/src/shared/api/acpApi.ts @@ -132,11 +132,23 @@ export async function cancelSession(sessionId: string): Promise { export async function newSession( workingDir: string, + providerId?: string, ): Promise { const tClient = performance.now(); const client = await getClient(); + const request: Parameters[0] & { + meta?: Record; + } = { + cwd: workingDir, + mcpServers: [], + }; + + if (providerId) { + request.meta = { provider: providerId }; + } + const tCall = performance.now(); - const response = await client.newSession({ cwd: workingDir, mcpServers: [] }); + const response = await client.newSession(request); const sid = response.sessionId.slice(0, 8); perfLog( `[perf:api] ${sid} newSession getClient=${(tCall - tClient).toFixed(1)}ms wire=${(performance.now() - tCall).toFixed(1)}ms`, diff --git a/ui/goose2/src/shared/api/acpNotificationHandler.test.ts b/ui/goose2/src/shared/api/acpNotificationHandler.test.ts new file mode 100644 index 000000000000..1a3df9e7e118 --- /dev/null +++ b/ui/goose2/src/shared/api/acpNotificationHandler.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { SessionNotification } from "@agentclientprotocol/sdk"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import { + clearReplayBuffer, + getAndDeleteReplayBuffer, +} from "@/features/chat/hooks/replayBuffer"; +import { registerSession } from "./acpSessionTracker"; +import { handleSessionNotification } from "./acpNotificationHandler"; + +describe("acpNotificationHandler", () => { + beforeEach(() => { + clearReplayBuffer("draft-session-1"); + clearReplayBuffer("draft-session-2"); + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + queuedMessageBySession: {}, + draftsBySession: {}, + activeSessionId: null, + isConnected: false, + loadingSessionIds: new Set(), + scrollTargetMessageBySession: {}, + }); + }); + + it("buffers usage updates until the local session mapping is registered", async () => { + const notification = { + sessionId: "goose-session-1", + update: { + sessionUpdate: "usage_update", + used: 512, + size: 8192, + }, + } as SessionNotification; + + await handleSessionNotification(notification); + + expect( + useChatStore.getState().sessionStateById["draft-session-1"], + ).toBeUndefined(); + expect( + useChatStore.getState().sessionStateById["goose-session-1"], + ).toBeUndefined(); + + registerSession("draft-session-1", "goose-session-1", "goose", "/tmp"); + + const runtime = useChatStore + .getState() + .getSessionRuntime("draft-session-1"); + expect(runtime.tokenState.accumulatedTotal).toBe(512); + expect(runtime.tokenState.contextLimit).toBe(8192); + }); + + it("does not buffer non-usage updates before the local session mapping exists", async () => { + const notification = { + sessionId: "goose-session-2", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "message-1", + content: { + type: "text", + text: "hello from replay", + }, + }, + } as SessionNotification; + + await handleSessionNotification(notification); + registerSession("draft-session-2", "goose-session-2", "goose", "/tmp"); + + expect(getAndDeleteReplayBuffer("draft-session-2")).toBeUndefined(); + expect( + useChatStore.getState().messagesBySession["draft-session-2"], + ).toBeUndefined(); + }); +}); diff --git a/ui/goose2/src/shared/api/acpNotificationHandler.ts b/ui/goose2/src/shared/api/acpNotificationHandler.ts index b0d8d8a578ce..23b5bf313335 100644 --- a/ui/goose2/src/shared/api/acpNotificationHandler.ts +++ b/ui/goose2/src/shared/api/acpNotificationHandler.ts @@ -14,11 +14,55 @@ import type { ToolResponseContent, } from "@/shared/types/messages"; import type { AcpNotificationHandler } from "./acpConnection"; -import { getLocalSessionId } from "./acpSessionTracker"; +import { + getLocalSessionId, + subscribeToSessionRegistration, +} from "./acpSessionTracker"; import { perfLog } from "@/shared/lib/perfLog"; // Pre-set message ID for the next live stream per goose session const presetMessageIds = new Map(); +const pendingUsageUpdates = new Map(); + +function shouldBufferPendingUpdate(update: SessionUpdate): boolean { + return update.sessionUpdate === "usage_update"; +} + +function queuePendingUsageUpdate( + gooseSessionId: string, + update: SessionUpdate, +): void { + const pending = pendingUsageUpdates.get(gooseSessionId); + if (pending) { + pending.push(update); + return; + } + pendingUsageUpdates.set(gooseSessionId, [update]); +} + +function flushPendingUsageUpdates( + localSessionId: string, + gooseSessionId: string, +): void { + const pending = pendingUsageUpdates.get(gooseSessionId); + if (!pending?.length) { + return; + } + + pendingUsageUpdates.delete(gooseSessionId); + + for (const update of pending) { + if (useChatStore.getState().loadingSessionIds.has(localSessionId)) { + handleReplay(localSessionId, update); + } else { + handleLive(localSessionId, gooseSessionId, update); + } + } +} + +subscribeToSessionRegistration((localSessionId, gooseSessionId) => { + flushPendingUsageUpdates(localSessionId, gooseSessionId); +}); // Per-session perf counters for replay/live streaming. interface ReplayPerf { @@ -67,22 +111,32 @@ export async function handleSessionNotification( notification: SessionNotification, ): Promise { const gooseSessionId = notification.sessionId; - const sessionId = getLocalSessionId(gooseSessionId) ?? gooseSessionId; const { update } = notification; - const isReplay = useChatStore.getState().loadingSessionIds.has(sessionId); + const localSessionId = getLocalSessionId(gooseSessionId); + + if (!localSessionId) { + if (shouldBufferPendingUpdate(update)) { + queuePendingUsageUpdate(gooseSessionId, update); + } + return; + } + + const isReplay = useChatStore + .getState() + .loadingSessionIds.has(localSessionId); if (isReplay) { - const sid = sessionId.slice(0, 8); - let perf = replayPerf.get(sessionId); + const sid = localSessionId.slice(0, 8); + let perf = replayPerf.get(localSessionId); const now = performance.now(); if (!perf) { perf = { firstAt: now, lastAt: now, count: 0 }; - replayPerf.set(sessionId, perf); + replayPerf.set(localSessionId, perf); perfLog(`[perf:replay] ${sid} first notification received`); } perf.lastAt = now; perf.count += 1; - handleReplay(sessionId, update); + handleReplay(localSessionId, update); } else { const perf = livePerf.get(gooseSessionId); if (perf && update.sessionUpdate === "agent_message_chunk") { @@ -95,7 +149,7 @@ export async function handleSessionNotification( ); } } - handleLive(sessionId, gooseSessionId, update); + handleLive(localSessionId, gooseSessionId, update); } } diff --git a/ui/goose2/src/shared/api/acpSessionTracker.ts b/ui/goose2/src/shared/api/acpSessionTracker.ts index 835bcc1900c9..f521a6293a36 100644 --- a/ui/goose2/src/shared/api/acpSessionTracker.ts +++ b/ui/goose2/src/shared/api/acpSessionTracker.ts @@ -7,8 +7,26 @@ interface PreparedSession { workingDir: string; } +type SessionRegistrationListener = ( + localSessionId: string, + gooseSessionId: string, +) => void; + const prepared = new Map(); const gooseToLocal = new Map(); +const registrationListeners = new Set(); + +function restoreGooseRegistration( + gooseSessionId: string, + localSessionId: string | undefined, +): void { + if (localSessionId === undefined) { + gooseToLocal.delete(gooseSessionId); + return; + } + + gooseToLocal.set(gooseSessionId, localSessionId); +} function makeKey(sessionId: string, personaId?: string): string { if (personaId && personaId.length > 0) { @@ -17,6 +35,22 @@ function makeKey(sessionId: string, personaId?: string): string { return sessionId; } +function notifySessionRegistered( + localSessionId: string, + gooseSessionId: string, +): void { + for (const listener of registrationListeners) { + listener(localSessionId, gooseSessionId); + } +} + +export function subscribeToSessionRegistration( + listener: SessionRegistrationListener, +): () => void { + registrationListeners.add(listener); + return () => registrationListeners.delete(listener); +} + export async function prepareSession( sessionId: string, providerId: string, @@ -67,7 +101,7 @@ export async function prepareSession( if (!gooseSessionId) { const tNew = performance.now(); - const response = await acpApi.newSession(workingDir); + const response = await acpApi.newSession(workingDir, providerId); gooseSessionId = response.sessionId; perfLog( `[perf:prepare] ${sid} tracker newSession done in ${(performance.now() - tNew).toFixed(1)}ms (goose_sid=${gooseSessionId.slice(0, 8)})`, @@ -84,6 +118,7 @@ export async function prepareSession( prepared.set(key, { gooseSessionId, providerId, workingDir }); prepared.set(sessionId, { gooseSessionId, providerId, workingDir }); gooseToLocal.set(gooseSessionId, sessionId); + notifySessionRegistered(sessionId, gooseSessionId); return gooseSessionId; } @@ -109,8 +144,54 @@ export function registerSession( gooseSessionId: string, providerId: string, workingDir: string, -): void { +): () => void { + const previousEntry = prepared.get(sessionId); + const previousGooseSessionLocal = gooseToLocal.get(gooseSessionId); + const previousSessionGooseLocal = previousEntry + ? gooseToLocal.get(previousEntry.gooseSessionId) + : undefined; const entry = { gooseSessionId, providerId, workingDir }; + + if ( + previousEntry && + previousEntry.gooseSessionId !== gooseSessionId && + gooseToLocal.get(previousEntry.gooseSessionId) === sessionId + ) { + gooseToLocal.delete(previousEntry.gooseSessionId); + } + prepared.set(sessionId, entry); gooseToLocal.set(gooseSessionId, sessionId); + notifySessionRegistered(sessionId, gooseSessionId); + + return () => { + prepared.delete(sessionId); + if (previousEntry) { + prepared.set(sessionId, previousEntry); + } + + restoreGooseRegistration(gooseSessionId, previousGooseSessionLocal); + if (previousEntry && previousEntry.gooseSessionId !== gooseSessionId) { + restoreGooseRegistration( + previousEntry.gooseSessionId, + previousSessionGooseLocal, + ); + } + }; +} + +export function unregisterSession( + sessionId: string, + gooseSessionId?: string, +): void { + const entry = prepared.get(sessionId); + prepared.delete(sessionId); + + const resolvedGooseSessionId = gooseSessionId ?? entry?.gooseSessionId; + if ( + resolvedGooseSessionId && + gooseToLocal.get(resolvedGooseSessionId) === sessionId + ) { + gooseToLocal.delete(resolvedGooseSessionId); + } } diff --git a/ui/goose2/src/shared/i18n/locales/en/chat.json b/ui/goose2/src/shared/i18n/locales/en/chat.json index 4c9fcb5e3887..efe6776e3d87 100644 --- a/ui/goose2/src/shared/i18n/locales/en/chat.json +++ b/ui/goose2/src/shared/i18n/locales/en/chat.json @@ -111,6 +111,7 @@ "placeholder": "Message {{agent}}, @ to mention personas" }, "loading": { + "compacting": "Compacting conversation...", "thinking": "Thinking...", "responding": "Responding..." }, @@ -151,9 +152,14 @@ "attachFolder": "Folder", "chooseAgentModel": "Choose agent and model", "chooseProject": "Choose a project", + "compactNow": "Compact", + "compacting": "Compacting...", "chooseProvider": "Choose a provider", + "contextTokensBreakdown": "{{tokens}} / {{limit}} tokens used", "contextUsage": "Context usage", + "contextUsageBreakdown": "{{used}} used ({{left}} left)", "contextUsageTitle": "{{tokens}} / {{limit}} tokens", + "contextWindow": "Context window", "createProject": "Create project", "generalChatWithoutProject": "General chat without project context", "loading": "Loading...", diff --git a/ui/goose2/src/shared/i18n/locales/es/chat.json b/ui/goose2/src/shared/i18n/locales/es/chat.json index e73f7a1fc7a8..3a5760189e23 100644 --- a/ui/goose2/src/shared/i18n/locales/es/chat.json +++ b/ui/goose2/src/shared/i18n/locales/es/chat.json @@ -111,6 +111,7 @@ "placeholder": "Enviar mensaje a {{agent}}, usa @ para mencionar personas" }, "loading": { + "compacting": "Compactando conversación...", "thinking": "Pensando...", "responding": "Respondiendo..." }, @@ -151,9 +152,14 @@ "attachFolder": "Carpeta", "chooseAgentModel": "Elegir agente y modelo", "chooseProject": "Elegir un proyecto", + "compactNow": "Compactar", + "compacting": "Compactando...", "chooseProvider": "Elegir un proveedor", + "contextTokensBreakdown": "{{tokens}} / {{limit}} tokens usados", "contextUsage": "Uso del contexto", + "contextUsageBreakdown": "{{used}} en uso ({{left}} libres)", "contextUsageTitle": "{{tokens}} / {{limit}} tokens", + "contextWindow": "Ventana de contexto", "createProject": "Crear proyecto", "generalChatWithoutProject": "Chat general sin contexto de proyecto", "loading": "Cargando...", diff --git a/ui/goose2/src/shared/ui/button.tsx b/ui/goose2/src/shared/ui/button.tsx index 0f8dfe971ec5..b1e161d7a4cb 100644 --- a/ui/goose2/src/shared/ui/button.tsx +++ b/ui/goose2/src/shared/ui/button.tsx @@ -18,7 +18,7 @@ const buttonVariants = cva( "outline-flat": "border border-input bg-background shadow-none hover:bg-accent hover:text-accent-foreground", secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", "ghost-light": "font-normal hover:bg-accent hover:text-accent-foreground", From 5b43b5f2db7c0ae7c6e9529b2a692a2c445223e1 Mon Sep 17 00:00:00 2001 From: Kyle E DeFreitas Date: Mon, 20 Apr 2026 04:59:29 -0400 Subject: [PATCH 03/81] fix(gym): isolate scenario Cargo projects from parent workspace (#8640) Signed-off-by: Kyle De Freitas --- evals/open-model-gym/suite/scenarios/file-editing.yaml | 2 ++ evals/open-model-gym/suite/scenarios/multi-turn-edit.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/evals/open-model-gym/suite/scenarios/file-editing.yaml b/evals/open-model-gym/suite/scenarios/file-editing.yaml index d3ba73202fc2..5eb1d818ebef 100644 --- a/evals/open-model-gym/suite/scenarios/file-editing.yaml +++ b/evals/open-model-gym/suite/scenarios/file-editing.yaml @@ -15,6 +15,8 @@ setup: version = "0.1.0" edition = "2021" + [workspace] + src/main.rs: | mod models; mod utils; diff --git a/evals/open-model-gym/suite/scenarios/multi-turn-edit.yaml b/evals/open-model-gym/suite/scenarios/multi-turn-edit.yaml index 5e3bc0475158..03798a85ca6b 100644 --- a/evals/open-model-gym/suite/scenarios/multi-turn-edit.yaml +++ b/evals/open-model-gym/suite/scenarios/multi-turn-edit.yaml @@ -12,6 +12,8 @@ setup: version = "0.1.0" edition = "2021" + [workspace] + src/main.rs: | mod models; From 40918697a272c07febbd3890c8320eb293a5ebbd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:50:54 +0200 Subject: [PATCH 04/81] chore(deps): bump thin-vec from 0.2.14 to 0.2.16 (#8561) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 879339f3b093..093e9658825e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10360,9 +10360,9 @@ dependencies = [ [[package]] name = "thin-vec" -version = "0.2.14" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d" +checksum = "259cdf8ed4e4aca6f1e9d011e10bd53f524a2d0637d7b28450f6c64ac298c4c6" [[package]] name = "thiserror" From 77542db43286e698e873fae7b7d50c8107a99cf1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:51:26 +0000 Subject: [PATCH 05/81] chore(deps): bump hono from 4.12.12 to 4.12.14 in /evals/open-model-gym/mcp-harness (#8579) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- evals/open-model-gym/mcp-harness/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/evals/open-model-gym/mcp-harness/package-lock.json b/evals/open-model-gym/mcp-harness/package-lock.json index 367849ea4433..ce355ab5f29c 100644 --- a/evals/open-model-gym/mcp-harness/package-lock.json +++ b/evals/open-model-gym/mcp-harness/package-lock.json @@ -582,9 +582,9 @@ } }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", "engines": { "node": ">=16.9.0" From 030de5de915ea57e5cc281ffc9aa58cfd24896e6 Mon Sep 17 00:00:00 2001 From: jh-block Date: Mon, 20 Apr 2026 13:13:10 +0200 Subject: [PATCH 06/81] Add dependabot config for pnpm workspace, cargo, and actions (#8660) Signed-off-by: jh-block --- .github/dependabot.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000000..25f111e03732 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,33 @@ +version: 2 +updates: + # pnpm workspace for the UI (desktop, acp, text, sdk, goose-binary/*, goose2). + # Point at the workspace ROOT where pnpm-lock.yaml lives so Dependabot updates + # both the child package.json AND ui/pnpm-lock.yaml in one PR. + - package-ecosystem: "npm" + directory: "/ui" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + groups: + ui-minor-and-patch: + update-types: + - "minor" + - "patch" + + # Cargo workspace at the repo root. + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + groups: + cargo-minor-and-patch: + update-types: + - "minor" + - "patch" + + # GitHub Actions used by workflows in .github/workflows. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" From e953a49bbfa466aa87a30e78e6a44d79c176025f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:32:51 +0000 Subject: [PATCH 07/81] chore(deps): bump EmbarkStudios/cargo-deny-action from 2.0.15 to 2.0.17 (#8665) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cargo-deny.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index c2215b088720..e9081364f97f 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -24,6 +24,6 @@ jobs: steps: - uses: actions/checkout@v4 # https://github.com/EmbarkStudios/cargo-deny-action v2.0.15 - - uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 + - uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb with: command: check advisories From f2350f8d6814db2c4d4d4ba3772fb94ed5a87152 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Mon, 20 Apr 2026 13:36:20 +0200 Subject: [PATCH 08/81] Reset ChatGPT Codex auth during OAuth setup (#8569) Signed-off-by: Vincenzo Palazzo Co-authored-by: goose Co-authored-by: Claude Opus 4.6 (1M context) --- crates/goose/src/providers/chatgpt_codex.rs | 30 ++++++++++++++++++--- crates/goose/src/providers/init.rs | 4 +++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs index 6f4ab1ca3f9a..05c6255350b2 100644 --- a/crates/goose/src/providers/chatgpt_codex.rs +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -806,6 +806,10 @@ impl ChatGptCodexAuthProvider { } } + fn clear_cached_tokens(&self) { + self.cache.clear(); + } + async fn get_valid_token(&self) -> Result { if let Some(mut token_data) = self.cache.load() { if token_data.expires_at > Utc::now() + chrono::Duration::seconds(60) { @@ -865,6 +869,11 @@ pub struct ChatGptCodexProvider { } impl ChatGptCodexProvider { + pub async fn cleanup() -> Result<()> { + TokenCache::new().clear(); + Ok(()) + } + pub async fn from_env(model: ModelConfig) -> Result { let auth_provider = Arc::new(ChatGptCodexAuthProvider::new( ChatGptCodexAuthState::instance(), @@ -997,10 +1006,25 @@ impl Provider for ChatGptCodexProvider { } async fn configure_oauth(&self) -> Result<(), ProviderError> { - self.auth_provider - .get_valid_token() + let previous_token = self.auth_provider.cache.load(); + self.auth_provider.clear_cached_tokens(); + + let result = perform_oauth_flow(self.auth_provider.state.as_ref()) .await - .map_err(|e| ProviderError::Authentication(format!("OAuth flow failed: {}", e)))?; + .and_then(|token_data| self.auth_provider.cache.save(&token_data)); + + if let Err(e) = result { + if let Some(previous_token) = previous_token.as_ref() { + if self.auth_provider.cache.load().is_none() { + let _ = self.auth_provider.cache.save(previous_token); + } + } + return Err(ProviderError::Authentication(format!( + "OAuth flow failed: {}", + e + ))); + } + Ok(()) } diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index c0c228bbcfea..6e864251d690 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -100,6 +100,10 @@ async fn init_registry() -> RwLock { "kimi_code", Arc::new(|| Box::pin(KimiCodeProvider::cleanup())), ); + registry.set_cleanup( + "chatgpt_codex", + Arc::new(|| Box::pin(ChatGptCodexProvider::cleanup())), + ); if let Err(e) = load_custom_providers_into_registry(&mut registry) { tracing::warn!("Failed to load custom providers: {}", e); From 52055403118242ca7732f07d4829acc751e2af44 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Dhodda <123675218+aravind4219@users.noreply.github.com> Date: Mon, 20 Apr 2026 06:45:31 -0500 Subject: [PATCH 09/81] fix: append /chat/completions for prefixed v1 base URLs (#8521) --- crates/goose/src/providers/openai.rs | 70 +++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index aad6ddd071d6..1ab2b1c52f61 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -187,12 +187,7 @@ impl OpenAiProvider { let base_path = if let Some(ref explicit_path) = config.base_path { explicit_path.trim_start_matches('/').to_string() } else { - let url_path = url.path().trim_start_matches('/').to_string(); - if url_path.is_empty() || url_path == "v1" || url_path == "v1/" { - "v1/chat/completions".to_string() - } else { - url_path - } + Self::derive_base_path(url.path()) }; let timeout_secs = config.timeout_seconds.unwrap_or(600); @@ -241,6 +236,19 @@ impl OpenAiProvider { }) } + // Derive a base path from the raw URL path + fn derive_base_path(url_path: &str) -> String { + let stripped = url_path.trim_start_matches('/'); + let normalized = stripped.trim_end_matches('/'); + if normalized.is_empty() { + "v1/chat/completions".to_string() + } else if normalized == "v1" || normalized.ends_with("/v1") { + format!("{}/chat/completions", normalized) + } else { + stripped.to_string() + } + } + fn normalize_base_path(base_path: &str) -> String { if let Some(path) = base_path.strip_prefix('/') { format!("/{}", path.trim_end_matches('/')) @@ -849,4 +857,54 @@ mod tests { let models_path = OpenAiProvider::map_base_path("/custom/path", "models", "v1/models"); assert_eq!(models_path, "/v1/models"); } + + #[test] + fn derive_base_path_empty_path_gives_default_endpoint() { + assert_eq!(OpenAiProvider::derive_base_path("/"), "v1/chat/completions"); + } + + #[test] + fn derive_base_path_bare_v1_gives_chat_completions() { + assert_eq!( + OpenAiProvider::derive_base_path("/v1"), + "v1/chat/completions" + ); + } + + #[test] + fn derive_base_path_v1_with_trailing_slash() { + assert_eq!( + OpenAiProvider::derive_base_path("/v1/"), + "v1/chat/completions" + ); + } + + #[test] + fn derive_base_path_prefixed_v1_appends_chat_completions() { + assert_eq!( + OpenAiProvider::derive_base_path("/zen/go/v1"), + "zen/go/v1/chat/completions" + ); + } + + #[test] + fn derive_base_path_prefixed_v1_with_trailing_slash() { + assert_eq!( + OpenAiProvider::derive_base_path("/zen/go/v1/"), + "zen/go/v1/chat/completions" + ); + } + + #[test] + fn derive_base_path_full_chat_completions_url_unchanged() { + assert_eq!( + OpenAiProvider::derive_base_path("/openai/v1/chat/completions"), + "openai/v1/chat/completions" + ); + } + + #[test] + fn derive_base_path_non_v1_prefix_unchanged() { + assert_eq!(OpenAiProvider::derive_base_path("/anthropic"), "anthropic"); + } } From a789bc16fbe919c1e70bc6027d2a66859e4b8034 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Mon, 20 Apr 2026 21:47:36 +1000 Subject: [PATCH 10/81] docs: add blog post about Mesh LLM provider option (#8655) Signed-off-by: Michael Neale Co-authored-by: Angie Jones --- .../blog/2026-04-20-mesh-llm/index.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 documentation/blog/2026-04-20-mesh-llm/index.md diff --git a/documentation/blog/2026-04-20-mesh-llm/index.md b/documentation/blog/2026-04-20-mesh-llm/index.md new file mode 100644 index 000000000000..14f2c8bec7ab --- /dev/null +++ b/documentation/blog/2026-04-20-mesh-llm/index.md @@ -0,0 +1,29 @@ +--- +title: "Mesh LLM in goose: routing across models" +description: "Mesh LLM is now available as a provider setting in goose." +authors: + - mic +--- + +Quick note: [Mesh LLM](https://github.com/Mesh-LLM/mesh-llm/) is now in goose as an option for accessing and sharing (open) LLMs with friends and family. + +It uses the same llama.cpp infra as local mode to run models, with a twist. + + + +## What is Mesh LLM? + +Mesh LLM is an associated project we're trying out that lets people connect their compute capacity (which may just be a laptop) peer-to-peer, so they can access models they may not otherwise be able to self-host. + +There is a demo public "mesh" which at any point has some capacity in it, but you can also make your own private networks and pool compute together. The mesh will try to work out the best places to run models (downloading them as needed) and can even split the compute in various ways. + +This is a pretty early-stage project, so we'd love any feedback on it. + +Check out [the project docs](https://docs.anarchai.org/) and the [live public mesh](https://meshllm.cloud/dashboard). + + + + + + + From 0b3a70c7661a61a307a1af595243b0ba7222f47d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:54:53 +0000 Subject: [PATCH 11/81] chore(deps): bump ncipollo/release-action from 1.20.0 to 1.21.0 (#8664) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/canary.yml | 2 +- .github/workflows/goose-release-notes.yml | 2 +- .github/workflows/release.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 181f9466dcdc..65c1741528ce 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -143,7 +143,7 @@ jobs: # Create/update the canary release - name: Release canary - uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: tag: canary name: Canary diff --git a/.github/workflows/goose-release-notes.yml b/.github/workflows/goose-release-notes.yml index 4a2d70b001fe..68a2eae8b8dd 100644 --- a/.github/workflows/goose-release-notes.yml +++ b/.github/workflows/goose-release-notes.yml @@ -170,7 +170,7 @@ jobs: steps: - name: Update release body - uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: tag: ${{ needs.generate-release-notes.outputs.release_tag }} token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c1a2671c833..60cea64628a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,7 +117,7 @@ jobs: # Create/update the versioned release - name: Release versioned - uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: token: ${{ secrets.GITHUB_TOKEN }} artifacts: | @@ -135,7 +135,7 @@ jobs: # Create/update the stable release - name: Release stable - uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: tag: stable name: Stable From 17b1548e106e949637719282f8c55ebddf83eca4 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Mon, 20 Apr 2026 10:36:31 -0400 Subject: [PATCH 12/81] Add a goose2 release workflow (#8629) --- .github/workflows/bundle-goose2-manual.yml | 26 + .github/workflows/bundle-goose2.yml | 699 +++++++++++++++++++++ 2 files changed, 725 insertions(+) create mode 100644 .github/workflows/bundle-goose2-manual.yml create mode 100644 .github/workflows/bundle-goose2.yml diff --git a/.github/workflows/bundle-goose2-manual.yml b/.github/workflows/bundle-goose2-manual.yml new file mode 100644 index 000000000000..8655287054ed --- /dev/null +++ b/.github/workflows/bundle-goose2-manual.yml @@ -0,0 +1,26 @@ +name: "Manual Goose 2 Bundle (Unsigned)" + +on: + workflow_dispatch: + inputs: + branch: + description: "Branch name to bundle app from" + required: true + type: string + cli-run-id: + description: "Run ID of a build-cli workflow to pull the goose binary from (optional, builds from source if empty)" + required: false + type: string + default: "" + +jobs: + bundle-goose2: + uses: ./.github/workflows/bundle-goose2.yml + permissions: + id-token: write + contents: read + actions: read + with: + signing: false + ref: ${{ inputs.branch }} + cli-run-id: ${{ inputs.cli-run-id }} diff --git a/.github/workflows/bundle-goose2.yml b/.github/workflows/bundle-goose2.yml new file mode 100644 index 000000000000..1784e7bde62f --- /dev/null +++ b/.github/workflows/bundle-goose2.yml @@ -0,0 +1,699 @@ +# Reusable workflow that bundles the Goose 2 (Tauri) desktop app. +# Produces .app / .dmg on macOS and .deb / .AppImage on Linux. +# +# The justfile recipe is: `just bundle` → `pnpm tauri build` +# +# Called from release.yml, canary.yml, or manually via bundle-goose2-manual.yml. +# +# The goose CLI binary can either be built from source (default) or pulled +# from a prior build-cli.yml run by passing `cli-run-id`. This avoids +# redundant ~20min Rust builds during release pipelines. +name: "Bundle Goose 2 Desktop" + +on: + workflow_call: + inputs: + version: + description: "Version to set for the build (leave empty to use Cargo.toml default)" + required: false + default: "" + type: string + signing: + description: "Whether to perform Apple signing and notarization (macOS only)" + required: false + default: false + type: boolean + quick_test: + description: "Whether to perform the quick launch test (macOS only)" + required: false + default: true + type: boolean + ref: + description: "Git ref to checkout (branch, tag, or SHA). Defaults to the triggering ref." + required: false + default: "" + type: string + environment: + description: "GitHub Environment containing signing secrets. Leave empty to skip." + required: false + default: "" + type: string + cli-run-id: + description: > + Run ID of a prior build-cli.yml workflow run to download the goose + binary from. When empty, the goose CLI is built from source. + required: false + default: "" + type: string + +jobs: + # ─────────────────────────────────────────────── + # macOS ARM (Apple Silicon) + # ─────────────────────────────────────────────── + bundle-macos-arm: + name: "macOS ARM64" + runs-on: macos-latest + environment: ${{ inputs.environment || '' }} + timeout-minutes: 60 + env: + MACOSX_DEPLOYMENT_TARGET: "12.0" + permissions: + id-token: write + contents: read + actions: read + outputs: + artifact-url: ${{ steps.upload.outputs.artifact-url }} + steps: + - name: Debug workflow info + env: + INPUT_REF: ${{ inputs.ref }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_SIGNING: ${{ inputs.signing }} + INPUT_CLI_RUN_ID: ${{ inputs.cli-run-id }} + run: | + echo "=== Goose 2 Bundle (macOS ARM64) ===" + echo "Ref: ${INPUT_REF:-}" + echo "Version: ${INPUT_VERSION:-}" + echo "Signing: ${INPUT_SIGNING}" + echo "CLI run ID: ${INPUT_CLI_RUN_ID:-}" + df -h + + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ inputs.ref != '' && inputs.ref || '' }} + + # ── Version stamps ── + - name: Update versions + if: inputs.version != '' + env: + VERSION: ${{ inputs.version }} + run: | + # Root Cargo.toml (workspace version) + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml + rm -f Cargo.toml.bak + + # Tauri Cargo.toml + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" ui/goose2/src-tauri/Cargo.toml + rm -f ui/goose2/src-tauri/Cargo.toml.bak + + # package.json + source ./bin/activate-hermit + cd ui/goose2 + npm pkg set "version=${VERSION}" + + # ── Goose CLI: download from prior run OR build from source ── + - name: Download goose CLI from build-cli run + if: inputs.cli-run-id != '' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: goose-aarch64-apple-darwin + run-id: ${{ inputs.cli-run-id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: cli-artifact + + - name: Extract downloaded goose CLI + if: inputs.cli-run-id != '' + run: | + TRIPLE="aarch64-apple-darwin" + mkdir -p target/release + # The artifact contains goose-.tar.bz2 with goose inside + tar -xjf "cli-artifact/goose-${TRIPLE}.tar.bz2" -C target/release/ + chmod +x target/release/goose + cp target/release/goose "target/release/goose-${TRIPLE}" + ls -lh "target/release/goose-${TRIPLE}" + + - name: Cache Rust dependencies + if: inputs.cli-run-id == '' + uses: Swatinem/rust-cache@v2 + with: + key: goose2-macos-arm64 + + - name: Build goose CLI (release) + if: inputs.cli-run-id == '' + run: | + source ./bin/activate-hermit + cargo build --release -p goose-cli --bin goose + + - name: Prepare goose binary with target triple + if: inputs.cli-run-id == '' + run: | + TRIPLE=$(rustc --print host-tuple) + cp target/release/goose "target/release/goose-${TRIPLE}" + ls -lh "target/release/goose-${TRIPLE}" + + # ── Frontend: pnpm install + SDK build ── + - name: Cache pnpm dependencies + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + with: + path: | + ui/goose2/node_modules + ui/sdk/node_modules + .hermit/node/cache + key: goose2-pnpm-macos-arm64-${{ runner.os }}-${{ hashFiles('ui/pnpm-lock.yaml') }} + restore-keys: | + goose2-pnpm-macos-arm64-${{ runner.os }}- + + - name: Install pnpm dependencies + run: | + source ./bin/activate-hermit + cd ui + pnpm install --frozen-lockfile + + - name: Build SDK + run: | + source ./bin/activate-hermit + cd ui/sdk + pnpm build + + # ── Apple signing ── + - name: Import Apple signing certificate + if: inputs.signing + uses: ./.github/actions/apple-codesign + with: + certificate-base64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + # ── Tauri bundle ── + - name: Check disk space before bundle + run: df -h + + - name: Bundle Goose 2 (pnpm tauri build) + env: + APPLE_ID: ${{ inputs.signing && secrets.APPLE_ID || '' }} + APPLE_ID_PASSWORD: ${{ inputs.signing && secrets.APPLE_ID_PASSWORD || '' }} + APPLE_TEAM_ID: ${{ inputs.signing && secrets.APPLE_TEAM_ID || '' }} + working-directory: ui/goose2 + run: | + source ../../bin/activate-hermit + pnpm tauri build + + - name: Clean up signing keychain + if: always() + run: | + if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi + + # ── Upload ── + - name: List bundle output + run: | + BUNDLE_DIR="ui/goose2/src-tauri/target/release/bundle" + echo "=== Bundle contents ===" + find "$BUNDLE_DIR" -type f 2>/dev/null || echo "(no bundle output found)" + + - name: Upload Goose 2 macOS ARM64 artifact + id: upload + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-darwin-arm64 + path: | + ui/goose2/src-tauri/target/release/bundle/dmg/*.dmg + ui/goose2/src-tauri/target/release/bundle/macos/*.app + if-no-files-found: error + + # ── Smoke test ── + - name: Quick launch test + if: inputs.quick_test + run: | + APP_PATH=$(find ui/goose2/src-tauri/target/release/bundle/macos -maxdepth 1 -name "*.app" | head -1) + if [ -z "$APP_PATH" ]; then + echo "No .app found, skipping launch test" + exit 0 + fi + + xattr -cr "$APP_PATH" + echo "Opening $APP_PATH..." + open -g "$APP_PATH" + sleep 5 + + if pgrep -f "Goose.app/Contents/MacOS" > /dev/null; then + echo "✅ App is running" + else + echo "❌ App did not stay open" + exit 1 + fi + + pkill -f "Goose.app/Contents/MacOS" || true + + # ─────────────────────────────────────────────── + # macOS Intel (x86_64) + # ─────────────────────────────────────────────── + bundle-macos-intel: + name: "macOS x86_64" + runs-on: macos-latest + environment: ${{ inputs.environment || '' }} + timeout-minutes: 60 + env: + MACOSX_DEPLOYMENT_TARGET: "12.0" + permissions: + id-token: write + contents: read + actions: read + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ inputs.ref != '' && inputs.ref || '' }} + + - name: Update versions + if: inputs.version != '' + env: + VERSION: ${{ inputs.version }} + run: | + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml + rm -f Cargo.toml.bak + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" ui/goose2/src-tauri/Cargo.toml + rm -f ui/goose2/src-tauri/Cargo.toml.bak + source ./bin/activate-hermit + cd ui/goose2 + npm pkg set "version=${VERSION}" + + # ── Goose CLI: download from prior run OR build from source ── + - name: Download goose CLI from build-cli run + if: inputs.cli-run-id != '' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: goose-x86_64-apple-darwin + run-id: ${{ inputs.cli-run-id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: cli-artifact + + - name: Extract downloaded goose CLI + if: inputs.cli-run-id != '' + run: | + TARGET="x86_64-apple-darwin" + mkdir -p target/release + tar -xjf "cli-artifact/goose-${TARGET}.tar.bz2" -C target/release/ + chmod +x target/release/goose + cp target/release/goose "target/release/goose-${TARGET}" + ls -lh "target/release/goose-${TARGET}" + + - name: Cache Rust dependencies + if: inputs.cli-run-id == '' + uses: Swatinem/rust-cache@v2 + with: + key: goose2-macos-x86_64 + + - name: Install Intel target for both toolchains + if: inputs.cli-run-id == '' + run: | + source ./bin/activate-hermit + rustup target add x86_64-apple-darwin + cd ui/goose2/src-tauri + rustup target add x86_64-apple-darwin + + - name: Build goose CLI for Intel (x86_64-apple-darwin) + if: inputs.cli-run-id == '' + run: | + source ./bin/activate-hermit + cargo build --release -p goose-cli --bin goose --target x86_64-apple-darwin + + - name: Prepare goose binary with target triple + if: inputs.cli-run-id == '' + run: | + TARGET="x86_64-apple-darwin" + mkdir -p target/release + cp "target/${TARGET}/release/goose" "target/release/goose-${TARGET}" + ls -lh "target/release/goose-${TARGET}" + + # ── Intel target still needed for Tauri's own Rust build ── + - name: Install Intel target for Tauri toolchain + run: | + source ./bin/activate-hermit + rustup target add x86_64-apple-darwin + cd ui/goose2/src-tauri + rustup target add x86_64-apple-darwin + + # ── Frontend ── + - name: Cache pnpm dependencies + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + with: + path: | + ui/goose2/node_modules + ui/sdk/node_modules + .hermit/node/cache + key: goose2-pnpm-macos-intel-${{ runner.os }}-${{ hashFiles('ui/pnpm-lock.yaml') }} + restore-keys: | + goose2-pnpm-macos-intel-${{ runner.os }}- + + - name: Install pnpm dependencies + run: | + source ./bin/activate-hermit + cd ui + pnpm install --frozen-lockfile + + - name: Build SDK + run: | + source ./bin/activate-hermit + cd ui/sdk + pnpm build + + # ── Apple signing ── + - name: Import Apple signing certificate + if: inputs.signing + uses: ./.github/actions/apple-codesign + with: + certificate-base64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + # ── Tauri bundle (cross-compile for Intel) ── + - name: Bundle Goose 2 for Intel + env: + APPLE_ID: ${{ inputs.signing && secrets.APPLE_ID || '' }} + APPLE_ID_PASSWORD: ${{ inputs.signing && secrets.APPLE_ID_PASSWORD || '' }} + APPLE_TEAM_ID: ${{ inputs.signing && secrets.APPLE_TEAM_ID || '' }} + working-directory: ui/goose2 + run: | + source ../../bin/activate-hermit + pnpm tauri build --target x86_64-apple-darwin + + - name: Clean up signing keychain + if: always() + run: | + if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi + + # ── Upload ── + - name: List bundle output + run: | + BUNDLE_DIR="ui/goose2/src-tauri/target/x86_64-apple-darwin/release/bundle" + echo "=== Bundle contents ===" + find "$BUNDLE_DIR" -type f 2>/dev/null || echo "(no bundle output found)" + + - name: Upload Goose 2 macOS Intel artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-darwin-x64 + path: | + ui/goose2/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg + ui/goose2/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/*.app + if-no-files-found: error + + - name: Quick launch test + if: inputs.quick_test + run: | + APP_PATH=$(find ui/goose2/src-tauri/target/x86_64-apple-darwin/release/bundle/macos -maxdepth 1 -name "*.app" 2>/dev/null | head -1) + if [ -z "$APP_PATH" ]; then + echo "No .app found, skipping launch test" + exit 0 + fi + + xattr -cr "$APP_PATH" + echo "Opening $APP_PATH..." + open -g "$APP_PATH" + sleep 5 + + if pgrep -f "Goose.app/Contents/MacOS" > /dev/null; then + echo "✅ App is running" + else + echo "❌ App did not stay open" + exit 1 + fi + + pkill -f "Goose.app/Contents/MacOS" || true + + # ─────────────────────────────────────────────── + # Linux x86_64 + # ─────────────────────────────────────────────── + bundle-linux: + name: "Linux x86_64" + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + actions: read + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ inputs.ref != '' && inputs.ref || '' }} + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + protobuf-compiler + + - name: Update versions + if: inputs.version != '' + env: + VERSION: ${{ inputs.version }} + run: | + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml + rm -f Cargo.toml.bak + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" ui/goose2/src-tauri/Cargo.toml + rm -f ui/goose2/src-tauri/Cargo.toml.bak + source ./bin/activate-hermit + cd ui/goose2 + npm pkg set "version=${VERSION}" + + # ── Goose CLI: download from prior run OR build from source ── + - name: Download goose CLI from build-cli run + if: inputs.cli-run-id != '' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: goose-x86_64-unknown-linux-gnu + run-id: ${{ inputs.cli-run-id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: cli-artifact + + - name: Extract downloaded goose CLI + if: inputs.cli-run-id != '' + run: | + TRIPLE="x86_64-unknown-linux-gnu" + mkdir -p target/release + tar -xjf "cli-artifact/goose-${TRIPLE}.tar.bz2" -C target/release/ + chmod +x target/release/goose + cp target/release/goose "target/release/goose-${TRIPLE}" + ls -lh "target/release/goose-${TRIPLE}" + + - name: Cache Rust dependencies + if: inputs.cli-run-id == '' + uses: Swatinem/rust-cache@v2 + with: + key: goose2-linux-x86_64 + + - name: Build goose CLI (release) + if: inputs.cli-run-id == '' + run: | + source ./bin/activate-hermit + cargo build --release -p goose-cli --bin goose + + - name: Prepare goose binary with target triple + if: inputs.cli-run-id == '' + run: | + TRIPLE=$(rustc --print host-tuple) + cp target/release/goose "target/release/goose-${TRIPLE}" + ls -lh "target/release/goose-${TRIPLE}" + + # ── Frontend ── + - name: Cache pnpm dependencies + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + with: + path: | + ui/goose2/node_modules + ui/sdk/node_modules + .hermit/node/cache + key: goose2-pnpm-linux-${{ runner.os }}-${{ hashFiles('ui/pnpm-lock.yaml') }} + restore-keys: | + goose2-pnpm-linux-${{ runner.os }}- + + - name: Install pnpm dependencies + run: | + source ./bin/activate-hermit + cd ui + pnpm install --frozen-lockfile + + - name: Build SDK + run: | + source ./bin/activate-hermit + cd ui/sdk + pnpm build + + # ── Tauri bundle ── + - name: Bundle Goose 2 (pnpm tauri build) + working-directory: ui/goose2 + run: | + source ../../bin/activate-hermit + pnpm tauri build + + # ── Upload ── + - name: List bundle output + run: | + BUNDLE_DIR="ui/goose2/src-tauri/target/release/bundle" + echo "=== Bundle contents ===" + find "$BUNDLE_DIR" -type f 2>/dev/null | head -30 + echo "" + echo "=== File sizes ===" + find "$BUNDLE_DIR" -type f -exec ls -lh {} \; 2>/dev/null | head -20 + + - name: Upload .deb package + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-linux-x64-deb + path: ui/goose2/src-tauri/target/release/bundle/deb/*.deb + if-no-files-found: warn + + - name: Upload AppImage + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-linux-x64-appimage + path: ui/goose2/src-tauri/target/release/bundle/appimage/*.AppImage + if-no-files-found: warn + + - name: Upload RPM package + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-linux-x64-rpm + path: ui/goose2/src-tauri/target/release/bundle/rpm/*.rpm + if-no-files-found: warn + + # ─────────────────────────────────────────────── + # Windows x86_64 + # ─────────────────────────────────────────────── + bundle-windows: + name: "Windows x86_64" + runs-on: windows-latest + timeout-minutes: 60 + permissions: + contents: read + actions: read + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ inputs.ref != '' && inputs.ref || '' }} + + # Hermit doesn't work on Windows — install node/pnpm directly + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: 24 + + - name: Install pnpm + run: npm install -g pnpm@10.33.0 + + - name: Update versions + if: inputs.version != '' + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml + rm -f Cargo.toml.bak + sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" ui/goose2/src-tauri/Cargo.toml + rm -f ui/goose2/src-tauri/Cargo.toml.bak + cd ui/goose2 + npm pkg set "version=${VERSION}" + + # ── Goose CLI: download from prior run OR build from source ── + - name: Download goose CLI from build-cli run + if: inputs.cli-run-id != '' + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: goose-x86_64-pc-windows-msvc + run-id: ${{ inputs.cli-run-id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + path: cli-artifact + + - name: Extract downloaded goose CLI + if: inputs.cli-run-id != '' + shell: bash + run: | + TARGET="x86_64-pc-windows-msvc" + mkdir -p target/release + # The zip contains goose-package/goose.exe + cd cli-artifact + 7z x "goose-${TARGET}.zip" + cd .. + cp cli-artifact/goose-package/goose.exe target/release/ + # Tauri externalBin appends target triple + .exe on Windows + cp target/release/goose.exe "target/release/goose-${TARGET}.exe" + ls -lh "target/release/goose-${TARGET}.exe" + + - name: Cache Rust dependencies + if: inputs.cli-run-id == '' + uses: Swatinem/rust-cache@v2 + with: + key: goose2-windows-x86_64 + + - name: Setup Rust + if: inputs.cli-run-id == '' + shell: bash + run: | + rustup show + rustup target add x86_64-pc-windows-msvc + + - name: Build goose CLI (release) + if: inputs.cli-run-id == '' + shell: bash + run: | + cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose + + - name: Prepare goose binary with target triple + if: inputs.cli-run-id == '' + shell: bash + run: | + TARGET="x86_64-pc-windows-msvc" + cp "target/${TARGET}/release/goose.exe" "target/release/goose-${TARGET}.exe" + ls -lh "target/release/goose-${TARGET}.exe" + + # ── Frontend ── + - name: Cache pnpm dependencies + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 + with: + path: | + ui/goose2/node_modules + ui/sdk/node_modules + key: goose2-pnpm-windows-${{ runner.os }}-${{ hashFiles('ui/pnpm-lock.yaml') }} + restore-keys: | + goose2-pnpm-windows-${{ runner.os }}- + + - name: Install pnpm dependencies + shell: bash + run: | + cd ui + pnpm install --frozen-lockfile + + - name: Build SDK + shell: bash + run: | + cd ui/sdk + pnpm build + + # ── Tauri bundle ── + - name: Bundle Goose 2 (pnpm tauri build) + shell: bash + working-directory: ui/goose2 + run: | + pnpm tauri build --target x86_64-pc-windows-msvc + + # ── Upload ── + - name: List bundle output + shell: bash + run: | + BUNDLE_DIR="ui/goose2/src-tauri/target/x86_64-pc-windows-msvc/release/bundle" + echo "=== Bundle contents ===" + find "$BUNDLE_DIR" -type f 2>/dev/null || echo "(no bundle output found)" + + - name: Upload NSIS installer + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-windows-x64-nsis + path: ui/goose2/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe + if-no-files-found: warn + + - name: Upload MSI installer + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: Goose2-windows-x64-msi + path: ui/goose2/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi + if-no-files-found: warn From e74438bc8b640836fa2356229abee27b440d561b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Mon, 20 Apr 2026 23:41:16 +0900 Subject: [PATCH 13/81] refactor(providers): extract shared OAuth device-flow helper (#8619) Signed-off-by: DaeHee Lee --- crates/goose/src/providers/githubcopilot.rs | 117 +--- crates/goose/src/providers/kimicode.rs | 322 ++-------- crates/goose/src/providers/mod.rs | 1 + .../goose/src/providers/oauth_device_flow.rs | 559 ++++++++++++++++++ 4 files changed, 620 insertions(+), 379 deletions(-) create mode 100644 crates/goose/src/providers/oauth_device_flow.rs diff --git a/crates/goose/src/providers/githubcopilot.rs b/crates/goose/src/providers/githubcopilot.rs index 2ce16cf2cbb6..34211075f66a 100644 --- a/crates/goose/src/providers/githubcopilot.rs +++ b/crates/goose/src/providers/githubcopilot.rs @@ -1,5 +1,6 @@ use crate::config::paths::Paths; use crate::providers::api_client::{ApiClient, AuthMethod}; +use crate::providers::oauth_device_flow::{run_device_flow, DeviceFlowConfig, RequestEncoding}; use crate::providers::openai_compatible::{handle_status_openai_compat, stream_openai_compat}; use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; @@ -92,13 +93,6 @@ impl GithubCopilotUrls { } } -#[derive(Debug, Deserialize)] -struct DeviceCodeInfo { - device_code: String, - user_code: String, - verification_uri: String, -} - #[derive(Debug, Serialize, Deserialize, Clone)] struct CopilotTokenEndpoints { api: String, @@ -344,105 +338,16 @@ impl GithubCopilotProvider { } async fn login(&self) -> Result { - let device_code_info = self.get_device_code().await?; - - if let Ok(mut clipboard) = arboard::Clipboard::new() { - if let Err(e) = clipboard.set_text(&device_code_info.user_code) { - tracing::warn!("Failed to copy verification code to clipboard: {}", e); - } - } - - if let Err(e) = webbrowser::open(&device_code_info.verification_uri) { - tracing::warn!("Failed to open browser: {}", e); - } - - println!( - "Please visit {} and enter code {}", - device_code_info.verification_uri, device_code_info.user_code - ); - - self.poll_for_access_token(&device_code_info.device_code) - .await - } - - async fn get_device_code(&self) -> Result { - #[derive(Serialize)] - struct DeviceCodeRequest { - client_id: String, - scope: String, - } - self.client - .post(&self.urls.device_code_url) - .headers(self.get_github_headers()) - .json(&DeviceCodeRequest { - client_id: self.client_id.clone(), - scope: "read:user".to_string(), - }) - .send() - .await - .context("failed to send request to get device code")? - .error_for_status() - .context("failed to get device code")? - .json::() - .await - .context("failed to parse device code response") - } - - async fn poll_for_access_token(&self, device_code: &str) -> Result { - #[derive(Serialize)] - struct AccessTokenRequest { - client_id: String, - device_code: String, - grant_type: String, - } - #[derive(Debug, Deserialize)] - struct AccessTokenResponse { - access_token: Option, - error: Option, - #[serde(flatten)] - _extra: HashMap, - } - - const MAX_ATTEMPTS: i32 = 36; - for attempt in 0..MAX_ATTEMPTS { - let resp = self - .client - .post(&self.urls.access_token_url) - .headers(self.get_github_headers()) - .json(&AccessTokenRequest { - client_id: self.client_id.clone(), - device_code: device_code.to_string(), - grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(), - }) - .send() - .await - .context("failed to make request while polling for access token")? - .error_for_status() - .context("error polling for access token")? - .json::() - .await - .context("failed to parse response while polling for access token")?; - if resp.access_token.is_some() { - tracing::trace!("successful authorization: {:#?}", resp,); - } - if let Some(access_token) = resp.access_token { - return Ok(access_token); - } else if resp - .error - .as_ref() - .is_some_and(|err| err == "authorization_pending") - { - tracing::debug!( - "authorization pending (attempt {}/{})", - attempt + 1, - MAX_ATTEMPTS - ); - } else { - tracing::debug!("unexpected response: {:#?}", resp); - } - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; - } - Err(anyhow!("failed to get access token")) + let cfg = DeviceFlowConfig { + device_auth_url: Some(&self.urls.device_code_url), + token_url: &self.urls.access_token_url, + client_id: &self.client_id, + scopes: Some("read:user"), + extra_headers: self.get_github_headers(), + encoding: RequestEncoding::Json, + }; + let tokens = run_device_flow(&self.client, &cfg).await?; + Ok(tokens.access_token) } fn get_github_headers(&self) -> http::HeaderMap { diff --git a/crates/goose/src/providers/kimicode.rs b/crates/goose/src/providers/kimicode.rs index 88065d888c53..2187d739d122 100644 --- a/crates/goose/src/providers/kimicode.rs +++ b/crates/goose/src/providers/kimicode.rs @@ -1,7 +1,7 @@ use crate::config::paths::Paths; use crate::config::Config; use crate::session_context::SESSION_ID_HEADER; -use anyhow::{anyhow, Context, Result}; +use anyhow::Result; use async_stream::try_stream; use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; @@ -19,6 +19,9 @@ use uuid::Uuid; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::errors::ProviderError; use super::formats::anthropic::{create_request, response_to_streaming_message}; +use super::oauth_device_flow::{ + refresh_device_flow_token, run_device_flow, DeviceFlowConfig, DeviceFlowTokens, RequestEncoding, +}; use super::openai_compatible::handle_status_openai_compat; use super::retry::ProviderRetry; use super::utils::RequestLog; @@ -49,16 +52,6 @@ const REFRESH_THRESHOLD_SECS: i64 = 300; /// Fallback access-token lifetime when the server omits `expires_in`. const DEFAULT_TOKEN_LIFETIME_SECS: i64 = 3600; -/// Fallback device-code window when the server omits `expires_in` -/// from `device_authorization`. -const DEFAULT_DEVICE_CODE_LIFETIME_SECS: u64 = 300; - -/// Fallback poll interval when the server omits `interval`. -const DEFAULT_POLL_INTERVAL_SECS: u64 = 5; - -/// Extra seconds added to the poll interval after an RFC 8628 `slow_down`. -const SLOW_DOWN_BACKOFF_SECS: u64 = 5; - /// Marker key written to the user config when OAuth completes successfully. /// `check_provider_configured` (server) keys off this when an OAuth-flow /// provider has no required secret env var. @@ -73,6 +66,24 @@ struct KimiToken { expires_at: DateTime, } +/// Normalize helper output into the on-disk `KimiToken` shape. When the helper +/// returns `None` for `refresh_token` or `expires_at`, fall back to the prior +/// refresh token (per RFC 6749 §6) and a default lifetime. +fn tokens_to_kimi(tokens: DeviceFlowTokens, prior_refresh: Option<&str>) -> KimiToken { + let refresh_token = tokens + .refresh_token + .or_else(|| prior_refresh.map(str::to_string)) + .unwrap_or_default(); + let expires_at = tokens + .expires_at + .unwrap_or_else(|| Utc::now() + Duration::seconds(DEFAULT_TOKEN_LIFETIME_SECS)); + KimiToken { + access_token: tokens.access_token, + refresh_token, + expires_at, + } +} + #[derive(Debug)] struct TokenCache { path: std::path::PathBuf, @@ -261,201 +272,34 @@ impl KimiCodeProvider { } async fn device_flow_login(&self) -> Result { - #[derive(Serialize)] - struct DeviceAuthReq<'a> { - client_id: &'a str, - } - #[derive(Deserialize)] - struct DeviceAuthResp { - device_code: String, - user_code: String, - verification_uri_complete: Option, - verification_uri: String, - interval: Option, - expires_in: Option, - } - - let resp: DeviceAuthResp = self - .client - .post(format!("{}/api/oauth/device_authorization", self.auth_host)) - .headers(self.kimi_headers()) - .form(&DeviceAuthReq { - client_id: KIMI_CODE_CLIENT_ID, - }) - .send() - .await - .context("failed to request device authorization")? - .error_for_status() - .context("device authorization request failed")? - .json() - .await - .context("failed to parse device authorization response")?; - - let verify_url = resp - .verification_uri_complete - .as_deref() - .unwrap_or(&resp.verification_uri); - let interval = resp.interval.unwrap_or(DEFAULT_POLL_INTERVAL_SECS); - - if let Ok(mut clipboard) = arboard::Clipboard::new() { - let _ = clipboard.set_text(&resp.user_code); - } - if let Err(e) = webbrowser::open(verify_url) { - tracing::warn!("Failed to open browser: {}", e); - } - - // stderr so CLI workflows parsing stdout aren't interfered with. - eprintln!( - "Please visit {} and enter code {}", - verify_url, resp.user_code - ); - - let expires_in = resp.expires_in.unwrap_or(DEFAULT_DEVICE_CODE_LIFETIME_SECS); - self.poll_for_token(&resp.device_code, interval, expires_in) - .await - } - - async fn poll_for_token( - &self, - device_code: &str, - interval_secs: u64, - expires_in_secs: u64, - ) -> Result { - #[derive(Serialize)] - struct PollReq<'a> { - client_id: &'a str, - device_code: &'a str, - grant_type: &'static str, - } - #[derive(Deserialize, Debug)] - struct PollResp { - access_token: Option, - refresh_token: Option, - expires_in: Option, - error: Option, - } - - let deadline = - tokio::time::Instant::now() + tokio::time::Duration::from_secs(expires_in_secs); - let mut effective_interval = interval_secs; - loop { - if tokio::time::Instant::now() >= deadline { - return Err(anyhow!("timed out waiting for user authorization")); - } - tokio::time::sleep(tokio::time::Duration::from_secs(effective_interval)).await; - - let response = self - .client - .post(format!("{}/api/oauth/token", self.auth_host)) - .headers(self.kimi_headers()) - .form(&PollReq { - client_id: KIMI_CODE_CLIENT_ID, - device_code, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }) - .send() - .await - .context("failed to poll for token")?; - - // RFC 8628 returns pending/slow_down as 4xx with a JSON error payload, - // so don't `error_for_status()` before parsing — but if the body is - // unparseable AND the status is non-2xx, surface the HTTP status. - let status = response.status(); - let bytes = response - .bytes() - .await - .context("failed to read token poll response")?; - let resp: PollResp = match serde_json::from_slice(&bytes) { - Ok(p) => p, - Err(e) => { - if !status.is_success() { - return Err(anyhow!( - "token poll HTTP {}: {}", - status, - String::from_utf8_lossy(&bytes) - )); - } - return Err( - anyhow::Error::new(e).context("failed to parse token poll response") - ); - } - }; - - if let Some(access_token) = resp.access_token { - // RFC 6749: refresh_token is optional in token responses. - // Kimi currently returns one, but be defensive for servers/ - // versions that do not. - let refresh_token = resp.refresh_token.unwrap_or_default(); - let expires_in = resp.expires_in.unwrap_or(DEFAULT_TOKEN_LIFETIME_SECS); - return Ok(KimiToken { - access_token, - refresh_token, - expires_at: Utc::now() + Duration::seconds(expires_in), - }); - } - - match resp.error.as_deref() { - Some("authorization_pending") => { - tracing::debug!("authorization pending, continuing to poll"); - } - // RFC 8628: client MUST increase polling interval by 5 seconds - Some("slow_down") => { - tracing::debug!("slow_down received, increasing poll interval"); - effective_interval += SLOW_DOWN_BACKOFF_SECS; - } - Some(err) => { - return Err(anyhow!("authorization failed: {}", err)); - } - None => { - tracing::debug!("unexpected poll response: no token and no error"); - } - } - } + let device_auth_url = format!("{}/api/oauth/device_authorization", self.auth_host); + let token_url = format!("{}/api/oauth/token", self.auth_host); + let cfg = DeviceFlowConfig { + device_auth_url: Some(&device_auth_url), + token_url: &token_url, + client_id: KIMI_CODE_CLIENT_ID, + scopes: None, + extra_headers: self.kimi_headers(), + encoding: RequestEncoding::Form, + }; + let tokens = run_device_flow(&self.client, &cfg).await?; + Ok(tokens_to_kimi(tokens, None)) } async fn do_refresh_token(&self, refresh_token: &str) -> Result { - #[derive(Serialize)] - struct RefreshReq<'a> { - client_id: &'a str, - grant_type: &'static str, - refresh_token: &'a str, - } - #[derive(Deserialize)] - struct RefreshResp { - access_token: String, - refresh_token: Option, - expires_in: Option, - } - - let resp: RefreshResp = self - .client - .post(format!("{}/api/oauth/token", self.auth_host)) - .headers(self.kimi_headers()) - .form(&RefreshReq { - client_id: KIMI_CODE_CLIENT_ID, - grant_type: "refresh_token", - refresh_token, - }) - .send() - .await - .context("failed to refresh token")? - .error_for_status() - .context("token refresh failed")? - .json() - .await - .context("failed to parse token refresh response")?; - + let token_url = format!("{}/api/oauth/token", self.auth_host); + let cfg = DeviceFlowConfig { + device_auth_url: None, + token_url: &token_url, + client_id: KIMI_CODE_CLIENT_ID, + scopes: None, + extra_headers: self.kimi_headers(), + encoding: RequestEncoding::Form, + }; + let tokens = refresh_device_flow_token(&self.client, &cfg, refresh_token).await?; // RFC 6749 §6: the server MAY omit `refresh_token` from a refresh // response, in which case the client should keep reusing the prior one. - let next_refresh_token = resp - .refresh_token - .unwrap_or_else(|| refresh_token.to_string()); - let expires_in = resp.expires_in.unwrap_or(DEFAULT_TOKEN_LIFETIME_SECS); - Ok(KimiToken { - access_token: resp.access_token, - refresh_token: next_refresh_token, - expires_at: Utc::now() + Duration::seconds(expires_in), - }) + Ok(tokens_to_kimi(tokens, Some(refresh_token))) } // ── HTTP ───────────────────────────────────────────────────────────────── @@ -808,55 +652,10 @@ mod tests { assert_eq!(usable.refresh_token, "new_refresh"); } - #[tokio::test] - async fn poll_for_token_handles_authorization_pending_then_success() { - let server = MockServer::start().await; - - // First call: authorization_pending (returned as 400 per RFC 8628). - Mock::given(method("POST")) - .and(path("/api/oauth/token")) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "error": "authorization_pending", - }))) - .up_to_n_times(1) - .mount(&server) - .await; - - // Subsequent call: token issued. - Mock::given(method("POST")) - .and(path("/api/oauth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "access_token": "the_token", - "refresh_token": "the_refresh", - "expires_in": 1800, - }))) - .mount(&server) - .await; - - let provider = test_provider(&server.uri(), "abc"); - let token = provider.poll_for_token("device-abc", 0, 30).await.unwrap(); - assert_eq!(token.access_token, "the_token"); - assert_eq!(token.refresh_token, "the_refresh"); - } - - #[tokio::test] - async fn poll_for_token_accepts_response_without_refresh_token() { - // RFC 6749: refresh_token is optional in token responses. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/oauth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "access_token": "access_only", - "expires_in": 1800, - }))) - .mount(&server) - .await; - - let provider = test_provider(&server.uri(), "abc"); - let token = provider.poll_for_token("device-abc", 0, 5).await.unwrap(); - assert_eq!(token.access_token, "access_only"); - assert_eq!(token.refresh_token, ""); - } + // NOTE: RFC 8628 polling behavior (authorization_pending, slow_down, missing + // refresh_token, HTTP errors during polling) is covered by + // `providers::oauth_device_flow` tests. Tests here focus on Kimi-specific + // integration — token cache, refresh-fallback when server omits refresh_token. #[tokio::test] async fn use_or_refresh_preserves_refresh_token_when_server_omits_it() { @@ -885,29 +684,6 @@ mod tests { assert_eq!(usable.refresh_token, "original_refresh"); } - #[tokio::test] - async fn poll_for_token_surfaces_http_error_on_unparseable_body() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/oauth/token")) - .respond_with(ResponseTemplate::new(502).set_body_string("Bad Gateway")) - .mount(&server) - .await; - - let provider = test_provider(&server.uri(), "abc"); - let err = provider - .poll_for_token("device-abc", 0, 5) - .await - .unwrap_err(); - let msg = format!("{:#}", err); - assert!(msg.contains("502"), "expected status in error: {}", msg); - assert!( - msg.contains("Bad Gateway"), - "expected body in error: {}", - msg - ); - } - // ── fetch_supported_models ──────────────────────────────────────────────── async fn seed_fresh_token(provider: &KimiCodeProvider) { diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 616e7c49ff7d..2712a12ff028 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -34,6 +34,7 @@ pub mod litellm; pub mod local_inference; pub mod nanogpt; pub mod oauth; +pub mod oauth_device_flow; pub mod ollama; pub mod openai; pub mod openai_compatible; diff --git a/crates/goose/src/providers/oauth_device_flow.rs b/crates/goose/src/providers/oauth_device_flow.rs new file mode 100644 index 000000000000..d257d67b2df8 --- /dev/null +++ b/crates/goose/src/providers/oauth_device_flow.rs @@ -0,0 +1,559 @@ +//! Shared OAuth 2.0 Device Authorization Grant (RFC 8628) helper. +//! +//! Used by providers that authenticate via device-code flow (kimicode, +//! githubcopilot). Handles the authorization request, user-interaction UI, +//! polling loop with RFC 8628 `authorization_pending` / `slow_down` semantics, +//! and optional `refresh_token` grant (RFC 6749 §6). + +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use reqwest::header::HeaderMap; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +/// Fallback poll interval when the server omits `interval` (RFC 8628 §3.2). +const DEFAULT_POLL_INTERVAL_SECS: u64 = 5; + +/// Fallback device-code window when the server omits `expires_in` (RFC 8628 §3.2). +const DEFAULT_DEVICE_CODE_LIFETIME_SECS: u64 = 300; + +/// Extra seconds added to the poll interval after an RFC 8628 `slow_down`. +const SLOW_DOWN_BACKOFF_SECS: u64 = 5; + +/// How a provider expects the device-authorization and token request bodies to +/// be encoded. RFC 8628 §3.1 specifies `application/x-www-form-urlencoded`, but +/// GitHub accepts JSON when `Accept: application/json` is set. +#[derive(Debug, Clone, Copy)] +pub enum RequestEncoding { + Form, + Json, +} + +/// Connection details for a provider's device flow. +#[derive(Debug, Clone)] +pub struct DeviceFlowConfig<'a> { + /// `device_authorization_endpoint` (RFC 8628 §3.1). + /// `None` when only the refresh grant is needed. + pub device_auth_url: Option<&'a str>, + /// `token_endpoint` used for both device-code polling and refresh grants. + pub token_url: &'a str, + /// Public OAuth client identifier. + pub client_id: &'a str, + /// Space-separated scope string, or `None` to omit the parameter. + pub scopes: Option<&'a str>, + /// Provider-specific headers (user-agent, platform markers, `Accept`, etc.). + pub extra_headers: HeaderMap, + /// Body encoding for device-auth, polling, and refresh requests. + pub encoding: RequestEncoding, +} + +/// Fields returned by `/device_authorization` (RFC 8628 §3.2). +#[derive(Debug, Clone, Deserialize)] +pub struct DeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + /// Pre-populated URI with user_code embedded, used when the provider + /// supports it (e.g. Kimi). Fall back to `verification_uri` otherwise. + pub verification_uri_complete: Option, + pub interval: Option, + pub expires_in: Option, +} + +impl DeviceCodeResponse { + /// URI the user should visit. Prefers the `_complete` form when present. + pub fn verification_url(&self) -> &str { + self.verification_uri_complete + .as_deref() + .unwrap_or(&self.verification_uri) + } +} + +/// Access + optional refresh credentials from a device-code exchange. +#[derive(Debug, Clone)] +pub struct DeviceFlowTokens { + pub access_token: String, + /// Some providers (GitHub Copilot) do not issue a refresh token. + pub refresh_token: Option, + /// Derived from `expires_in` on the token response. `None` when the server + /// omits it (RFC 6749 §5.1 permits that). + pub expires_at: Option>, +} + +// ── Public entry points ────────────────────────────────────────────────────── + +/// Request a device code from the authorization server. +pub async fn request_device_code( + client: &Client, + cfg: &DeviceFlowConfig<'_>, +) -> Result { + #[derive(Serialize)] + struct DeviceAuthReq<'a> { + client_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option<&'a str>, + } + + let body = DeviceAuthReq { + client_id: cfg.client_id, + scope: cfg.scopes, + }; + + let url = cfg + .device_auth_url + .ok_or_else(|| anyhow!("device_auth_url is required for device code request"))?; + send_request(client, cfg, url, &body) + .await + .context("failed to request device authorization")? + .error_for_status() + .context("device authorization request failed")? + .json::() + .await + .context("failed to parse device authorization response") +} + +/// Poll the token endpoint until the user authorizes (or the device code expires). +/// Implements RFC 8628 §3.5 — handles `authorization_pending` and `slow_down`. +pub async fn poll_for_tokens( + client: &Client, + cfg: &DeviceFlowConfig<'_>, + device_code: &str, + interval_secs: u64, + expires_in_secs: u64, +) -> Result { + #[derive(Serialize)] + struct PollReq<'a> { + client_id: &'a str, + device_code: &'a str, + grant_type: &'static str, + } + + let req = PollReq { + client_id: cfg.client_id, + device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }; + + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(expires_in_secs); + let mut effective_interval = interval_secs; + + loop { + if tokio::time::Instant::now() >= deadline { + return Err(anyhow!("timed out waiting for user authorization")); + } + tokio::time::sleep(tokio::time::Duration::from_secs(effective_interval)).await; + + let response = send_request(client, cfg, cfg.token_url, &req) + .await + .context("failed to poll for token")?; + + // RFC 8628 §3.5 returns pending/slow_down as 4xx with a JSON error + // payload, so don't `error_for_status()` before parsing. If the body + // is unparseable AND the status is non-2xx, surface the HTTP status. + match parse_token_response(response).await? { + TokenPollOutcome::Issued(tokens) => return Ok(tokens), + TokenPollOutcome::Pending => { + tracing::debug!("authorization pending, continuing to poll"); + } + TokenPollOutcome::SlowDown => { + tracing::debug!("slow_down received, increasing poll interval"); + effective_interval += SLOW_DOWN_BACKOFF_SECS; + } + TokenPollOutcome::Failed(err) => { + return Err(anyhow!("authorization failed: {}", err)); + } + } + } +} + +/// High-level flow: request a device code, print user-facing instructions, +/// open the browser, and poll until tokens are issued. +pub async fn run_device_flow( + client: &Client, + cfg: &DeviceFlowConfig<'_>, +) -> Result { + let device = request_device_code(client, cfg).await?; + announce_user_action(&device); + + let interval = device.interval.unwrap_or(DEFAULT_POLL_INTERVAL_SECS); + let expires_in = device + .expires_in + .unwrap_or(DEFAULT_DEVICE_CODE_LIFETIME_SECS); + + poll_for_tokens(client, cfg, &device.device_code, interval, expires_in).await +} + +/// Exchange a refresh token for a new access token (RFC 6749 §6). +pub async fn refresh_device_flow_token( + client: &Client, + cfg: &DeviceFlowConfig<'_>, + refresh_token: &str, +) -> Result { + #[derive(Serialize)] + struct RefreshReq<'a> { + client_id: &'a str, + grant_type: &'static str, + refresh_token: &'a str, + } + + let req = RefreshReq { + client_id: cfg.client_id, + grant_type: "refresh_token", + refresh_token, + }; + + let raw: TokenResponseBody = send_request(client, cfg, cfg.token_url, &req) + .await + .context("failed to refresh token")? + .error_for_status() + .context("token refresh failed")? + .json() + .await + .context("failed to parse token refresh response")?; + + let access_token = raw + .access_token + .ok_or_else(|| anyhow!("refresh response missing access_token"))?; + Ok(DeviceFlowTokens { + access_token, + refresh_token: raw.refresh_token, + expires_at: raw + .expires_in + .map(|secs| Utc::now() + Duration::seconds(secs)), + }) +} + +// ── Internals ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct TokenResponseBody { + access_token: Option, + refresh_token: Option, + expires_in: Option, + error: Option, +} + +enum TokenPollOutcome { + Issued(DeviceFlowTokens), + Pending, + SlowDown, + Failed(String), +} + +async fn parse_token_response(response: reqwest::Response) -> Result { + let status = response.status(); + let bytes = response + .bytes() + .await + .context("failed to read token poll response")?; + + let body: TokenResponseBody = match serde_json::from_slice(&bytes) { + Ok(p) => p, + Err(e) => { + if !status.is_success() { + return Err(anyhow!( + "token poll HTTP {}: {}", + status, + String::from_utf8_lossy(&bytes) + )); + } + return Err(anyhow::Error::new(e).context("failed to parse token poll response")); + } + }; + + if let Some(access_token) = body.access_token { + return Ok(TokenPollOutcome::Issued(DeviceFlowTokens { + access_token, + refresh_token: body.refresh_token, + expires_at: body + .expires_in + .map(|secs| Utc::now() + Duration::seconds(secs)), + })); + } + + Ok(match body.error.as_deref() { + Some("authorization_pending") => TokenPollOutcome::Pending, + Some("slow_down") => TokenPollOutcome::SlowDown, + Some(err) => TokenPollOutcome::Failed(err.to_string()), + None => TokenPollOutcome::Failed( + "unexpected token response: no access_token and no error code".to_string(), + ), + }) +} + +async fn send_request( + client: &Client, + cfg: &DeviceFlowConfig<'_>, + url: &str, + body: &T, +) -> reqwest::Result { + let builder = client.post(url).headers(cfg.extra_headers.clone()); + let builder = match cfg.encoding { + RequestEncoding::Form => builder.form(body), + RequestEncoding::Json => builder.json(body), + }; + builder.send().await +} + +fn announce_user_action(device: &DeviceCodeResponse) { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + if let Err(e) = clipboard.set_text(&device.user_code) { + tracing::warn!("Failed to copy verification code to clipboard: {}", e); + } + } + let verify_url = device.verification_url(); + if let Err(e) = webbrowser::open(verify_url) { + tracing::warn!("Failed to open browser: {}", e); + } + // stderr keeps stdout clean for CLI workflows parsing provider output. + eprintln!( + "Please visit {} and enter code {}", + verify_url, device.user_code + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn make_cfg<'a>(device_auth_url: Option<&'a str>, token_url: &'a str) -> DeviceFlowConfig<'a> { + DeviceFlowConfig { + device_auth_url, + token_url, + client_id: "test-client", + scopes: None, + extra_headers: HeaderMap::new(), + encoding: RequestEncoding::Form, + } + } + + #[tokio::test] + async fn poll_returns_issued_tokens_when_server_responds_immediately() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "the_token", + "refresh_token": "the_refresh", + "expires_in": 1800, + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let tokens = poll_for_tokens(&client, &cfg, "device-abc", 0, 30) + .await + .unwrap(); + assert_eq!(tokens.access_token, "the_token"); + assert_eq!(tokens.refresh_token.as_deref(), Some("the_refresh")); + assert!(tokens.expires_at.is_some()); + } + + #[tokio::test] + async fn poll_handles_authorization_pending_then_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": "authorization_pending", + }))) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "issued", + "expires_in": 900, + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let tokens = poll_for_tokens(&client, &cfg, "device-abc", 0, 30) + .await + .unwrap(); + assert_eq!(tokens.access_token, "issued"); + assert!(tokens.refresh_token.is_none()); + } + + #[tokio::test] + async fn poll_handles_slow_down_then_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": "slow_down", + }))) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "issued", + "expires_in": 900, + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let tokens = poll_for_tokens(&client, &cfg, "device-abc", 0, 30) + .await + .unwrap(); + assert_eq!(tokens.access_token, "issued"); + } + + #[tokio::test] + async fn poll_times_out_when_user_never_authorizes() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": "authorization_pending", + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let err = poll_for_tokens(&client, &cfg, "device-abc", 0, 0) + .await + .unwrap_err(); + assert!(err.to_string().contains("timed out"), "got: {}", err); + } + + #[tokio::test] + async fn poll_surfaces_http_status_on_unparseable_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(502).set_body_string("Bad Gateway")) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let err = poll_for_tokens(&client, &cfg, "device-abc", 0, 5) + .await + .unwrap_err(); + let msg = format!("{:#}", err); + assert!(msg.contains("502"), "expected status in error: {}", msg); + assert!(msg.contains("Bad Gateway"), "expected body: {}", msg); + } + + #[tokio::test] + async fn poll_surfaces_server_error_message() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": "access_denied", + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let err = poll_for_tokens(&client, &cfg, "device-abc", 0, 5) + .await + .unwrap_err(); + assert!(err.to_string().contains("access_denied"), "got: {}", err); + } + + #[tokio::test] + async fn request_device_code_parses_complete_response() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/device_authorization")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "device_code": "dc", + "user_code": "UC-1", + "verification_uri": "https://example.com/activate", + "verification_uri_complete": "https://example.com/activate?user_code=UC-1", + "interval": 3, + "expires_in": 600, + }))) + .mount(&server) + .await; + + let device_url = format!("{}/device_authorization", server.uri()); + let cfg = make_cfg(Some(&device_url), ""); + + let client = Client::new(); + let resp = request_device_code(&client, &cfg).await.unwrap(); + assert_eq!(resp.device_code, "dc"); + assert_eq!(resp.user_code, "UC-1"); + assert_eq!( + resp.verification_url(), + "https://example.com/activate?user_code=UC-1" + ); + assert_eq!(resp.interval, Some(3)); + } + + #[tokio::test] + async fn refresh_token_returns_new_credentials() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "new_access", + "refresh_token": "new_refresh", + "expires_in": 3600, + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let tokens = refresh_device_flow_token(&client, &cfg, "old_refresh") + .await + .unwrap(); + assert_eq!(tokens.access_token, "new_access"); + assert_eq!(tokens.refresh_token.as_deref(), Some("new_refresh")); + } + + #[tokio::test] + async fn refresh_token_allows_server_to_omit_refresh_token() { + // RFC 6749 §6: server MAY omit refresh_token; caller should reuse prior. + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "new_access", + "expires_in": 3600, + }))) + .mount(&server) + .await; + + let token_url = format!("{}/token", server.uri()); + let cfg = make_cfg(None, &token_url); + + let client = Client::new(); + let tokens = refresh_device_flow_token(&client, &cfg, "old_refresh") + .await + .unwrap(); + assert_eq!(tokens.access_token, "new_access"); + assert!(tokens.refresh_token.is_none()); + } +} From e81465c316220ec5fde88ed595c80fe5a39bf4c1 Mon Sep 17 00:00:00 2001 From: jh-block Date: Mon, 20 Apr 2026 16:45:24 +0200 Subject: [PATCH 14/81] fix(developer): run shell tool under bash/sh regardless of login shell (#8659) Signed-off-by: jh-block --- .../platform_extensions/developer/mod.rs | 11 +- .../platform_extensions/developer/shell.rs | 118 +++++++++--------- 2 files changed, 69 insertions(+), 60 deletions(-) diff --git a/crates/goose/src/agents/platform_extensions/developer/mod.rs b/crates/goose/src/agents/platform_extensions/developer/mod.rs index b6cee198e205..8fcb90f2d01c 100644 --- a/crates/goose/src/agents/platform_extensions/developer/mod.rs +++ b/crates/goose/src/agents/platform_extensions/developer/mod.rs @@ -15,7 +15,7 @@ use rmcp::model::{ }; use schemars::{schema_for, JsonSchema}; use serde_json::Value; -use shell::{ShellOutput, ShellParams, ShellTool}; +use shell::{shell_display_name, ShellOutput, ShellParams, ShellTool}; use std::sync::Arc; use tokio_util::sync::CancellationToken; use tree::{TreeParams, TreeTool}; @@ -120,7 +120,14 @@ impl DeveloperClient { )), Tool::new( "shell".to_string(), - "Execute a shell command in the user's default shell in the current dir. Returns an object with stdout and stderr as separate fields. The output of each stream is limited to up to 2000 lines, and longer outputs will be saved to a temporary file.".to_string(), + format!( + "Execute a shell command in the current dir. Commands run under `{shell}` \ + (set GOOSE_SHELL to override) - write command strings in that shell's \ + syntax. Returns an object with stdout and stderr as separate fields. The \ + output of each stream is limited to up to 2000 lines, and longer outputs \ + will be saved to a temporary file.", + shell = shell_display_name(), + ), Self::schema::(), ) .with_output_schema::() diff --git a/crates/goose/src/agents/platform_extensions/developer/shell.rs b/crates/goose/src/agents/platform_extensions/developer/shell.rs index 9a74f8047ae8..49ac6f111259 100644 --- a/crates/goose/src/agents/platform_extensions/developer/shell.rs +++ b/crates/goose/src/agents/platform_extensions/developer/shell.rs @@ -47,50 +47,67 @@ fn flatpak_spawn_process() -> std::process::Command { command } -/// Resolve the preferred Unix shell, respecting GOOSE_SHELL. +/// Resolve the preferred Unix shell for command execution, respecting GOOSE_SHELL. /// -/// Returns `(shell_path, is_user_configured)` — the boolean is true when -/// `GOOSE_SHELL` was explicitly set, which matters in Flatpak mode: an -/// explicit path is passed through as-is (the user likely intends a host -/// path), whereas auto-detected defaults are reduced to their basename so -/// the host's PATH resolves the correct binary. +/// Auto-detected shells are returned as basenames (e.g. `"bash"`) so that +/// `Command::new` resolves them on `PATH` at spawn time — this also keeps +/// Flatpak happy, where absolute paths from inside the sandbox don't match +/// the host filesystem. `GOOSE_SHELL` is passed through as-is. /// -/// In Flatpak mode without an explicit GOOSE_SHELL, we skip sandbox -/// filesystem checks (e.g. `/bin/bash` existing inside the sandbox tells -/// us nothing about the host) and default to `"bash"` directly. +#[cfg(windows)] +fn windows_shell() -> String { + std::env::var("GOOSE_SHELL").unwrap_or_else(|_| "cmd".to_string()) +} + +/// Short, human-readable name of a shell path (the file stem), used both to +/// pick the right argument style on Windows and to tell the LLM which +/// dialect to write in the tool description. +#[cfg(windows)] +fn shell_basename(shell: &str) -> String { + Path::new(shell) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("cmd") + .to_lowercase() +} + #[cfg(not(windows))] -fn unix_shell() -> (String, bool) { - match std::env::var("GOOSE_SHELL") { - Ok(shell) => (shell, true), - Err(_) => { - let shell = if is_flatpak() { - // Don't inspect sandbox paths — they don't reflect the host. - // Default to "bash" (ubiquitous on Flatpak-capable Linux hosts). - "bash".to_string() - } else if PathBuf::from("/bin/bash").is_file() { - "/bin/bash".to_string() - } else { - std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string()) - }; - (shell, false) - } - } +fn shell_basename(shell: &str) -> String { + std::path::Path::new(shell) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(shell) + .to_string() +} + +/// Basename of the shell the `shell` tool will invoke, for use in the tool +/// description so the LLM knows which dialect to write. +#[cfg(windows)] +pub fn shell_display_name() -> String { + shell_basename(&windows_shell()) } -/// Return the shell reference to pass to `flatpak-spawn --host`. -/// -/// If the user explicitly configured GOOSE_SHELL, honour the full path -/// (it likely refers to a host binary, e.g. a Nix-profile shell). -/// Otherwise strip to basename so the host's default PATH resolves it. #[cfg(not(windows))] -fn flatpak_shell_arg(shell: &str, is_user_configured: bool) -> &str { - if is_user_configured { - shell +pub fn shell_display_name() -> String { + shell_basename(&unix_shell()) +} + +/// The shell tool runs commands with `-c "..."`, and LLMs routinely emit +/// POSIX-style patterns such as heredocs (`cat < file`), `$VAR` +/// expansion, and `2>&1` redirection. Non-POSIX shells (fish, csh, tcsh, +/// nu, ...) reject or mis-interpret these, so we don't auto-select based +/// on `$SHELL`: we check whether `bash` is on PATH and otherwise fall back +/// to `sh`. Users who really want their login shell can opt in via +/// `GOOSE_SHELL`. +#[cfg(not(windows))] +fn unix_shell() -> String { + if let Ok(shell) = std::env::var("GOOSE_SHELL") { + return shell; + } + if which::which("bash").is_ok() { + "bash".to_string() } else { - std::path::Path::new(shell) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("bash") + "sh".to_string() } } @@ -149,17 +166,11 @@ pub struct ShellOutput { /// source the user's profile and recover the full PATH. #[cfg(not(windows))] fn resolve_login_shell_path() -> Option { - let (shell, is_user_configured) = unix_shell(); + let shell = unix_shell(); let mut child = if is_flatpak() { flatpak_spawn_process() - .args([ - flatpak_shell_arg(&shell, is_user_configured), - "-l", - "-i", - "-c", - "echo $PATH", - ]) + .args([&shell, "-l", "-i", "-c", "echo $PATH"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -540,12 +551,8 @@ fn build_shell_command( ) -> tokio::process::Command { #[cfg(windows)] let mut command = { - let shell = std::env::var("GOOSE_SHELL").unwrap_or_else(|_| "cmd".to_string()); - let shell_stem = Path::new(&shell) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("cmd") - .to_lowercase(); + let shell = windows_shell(); + let shell_stem = shell_basename(&shell); let mut command = tokio::process::Command::new(&shell); match shell_stem.as_str() { "pwsh" | "powershell" => { @@ -570,7 +577,7 @@ fn build_shell_command( #[cfg(not(windows))] let mut command = { - let (shell, is_user_configured) = unix_shell(); + let shell = unix_shell(); if is_flatpak() { let mut command = flatpak_spawn_command(); @@ -580,12 +587,7 @@ fn build_shell_command( if let Some(path) = login_path { command.arg(format!("--env=PATH={}", path)); } - // If GOOSE_SHELL was explicitly set, honour the full path (likely a host - // binary). Otherwise use basename so the host's PATH resolves it. - command - .arg(flatpak_shell_arg(&shell, is_user_configured)) - .arg("-c") - .arg(command_line); + command.arg(&shell).arg("-c").arg(command_line); command } else { let mut command = tokio::process::Command::new(shell); From 2efa625da91a25b126c0ef4547e00ace8139ddc7 Mon Sep 17 00:00:00 2001 From: jh-block Date: Mon, 20 Apr 2026 18:32:28 +0200 Subject: [PATCH 15/81] consistently use actions-rust-lang/setup-rust-toolchain (#8671) --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/docs-update-cli-ref.yml | 2 +- .github/workflows/goose2-ci.yml | 7 +++++-- .github/workflows/pr-smoke-test.yml | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1db093a0c056..03115d5624fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 - name: Run cargo fmt run: cargo fmt --check @@ -55,7 +55,7 @@ jobs: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 - name: Install Dependencies run: | @@ -108,7 +108,7 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 - uses: Swatinem/rust-cache@v2 @@ -130,7 +130,7 @@ jobs: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 - name: Install Dependencies run: | diff --git a/.github/workflows/docs-update-cli-ref.yml b/.github/workflows/docs-update-cli-ref.yml index 37600bfe3158..8253f9888633 100644 --- a/.github/workflows/docs-update-cli-ref.yml +++ b/.github/workflows/docs-update-cli-ref.yml @@ -63,7 +63,7 @@ jobs: sudo apt-get install -y jq ripgrep - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1 + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 with: toolchain: stable diff --git a/.github/workflows/goose2-ci.yml b/.github/workflows/goose2-ci.yml index 12f70754ce0a..57b4f12aa16c 100644 --- a/.github/workflows/goose2-ci.yml +++ b/.github/workflows/goose2-ci.yml @@ -91,7 +91,9 @@ jobs: patchelf - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + with: + rust-src-dir: ui/goose2 - name: Cache Rust uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 @@ -158,8 +160,9 @@ jobs: patchelf - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 with: + rust-src-dir: ui/goose2 components: rustfmt, clippy - name: Cache Rust diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index cdc0414ace61..17c8047e8064 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -55,7 +55,7 @@ jobs: with: ref: ${{ github.event.inputs.branch || github.ref }} - - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 - name: Install Dependencies run: | From 5642e0b81ee87a1411f7e6ff095e3b048987392d Mon Sep 17 00:00:00 2001 From: tulsi Date: Mon, 20 Apr 2026 12:26:50 -0700 Subject: [PATCH 16/81] feat(goose2): voice dictation via direct-ACP pattern (#8609) Signed-off-by: tulsi Co-authored-by: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + crates/goose-acp/Cargo.toml | 2 + crates/goose-acp/acp-meta.json | 40 ++ crates/goose-acp/acp-schema.json | 427 +++++++++++++++ crates/goose-acp/src/server.rs | 427 +++++++++++++++ crates/goose-cli/Cargo.toml | 2 +- crates/goose-sdk/src/custom_requests.rs | 149 ++++++ ui/goose2/scripts/check-file-sizes.mjs | 10 + ui/goose2/src-tauri/Info.plist | 8 + .../plugins/app-test-driver/src/lib.rs | 4 +- .../src-tauri/src/services/provider_defs.rs | 11 + .../__tests__/useDictationRecorder.test.ts | 72 +++ .../hooks/__tests__/useVoiceDictation.test.ts | 99 ++++ .../useVoiceInputPreferences.test.ts | 106 ++++ .../chat/hooks/useDictationRecorder.ts | 415 +++++++++++++++ .../features/chat/hooks/useVoiceDictation.ts | 181 +++++++ .../chat/hooks/useVoiceInputPreferences.ts | 211 ++++++++ .../features/chat/lib/dictationVad.test.ts | 51 ++ .../src/features/chat/lib/dictationVad.ts | 147 ++++++ .../src/features/chat/lib/voiceInput.test.ts | 97 ++++ ui/goose2/src/features/chat/lib/voiceInput.ts | 199 +++++++ ui/goose2/src/features/chat/ui/ChatInput.tsx | 52 +- .../src/features/chat/ui/ChatInputToolbar.tsx | 33 +- .../chat/ui/__tests__/ChatInput.test.tsx | 68 +++ .../settings/ui/LocalWhisperModels.tsx | 325 ++++++++++++ .../features/settings/ui/SettingsModal.tsx | 4 + .../settings/ui/VoiceInputSettings.tsx | 489 ++++++++++++++++++ .../ui/__tests__/LocalWhisperModels.test.tsx | 106 ++++ .../shared/api/__tests__/dictation.test.ts | 140 +++++ ui/goose2/src/shared/api/dictation.ts | 106 ++++ .../src/shared/i18n/locales/en/chat.json | 6 +- .../src/shared/i18n/locales/en/settings.json | 47 +- .../src/shared/i18n/locales/es/chat.json | 6 +- .../src/shared/i18n/locales/es/settings.json | 47 +- ui/goose2/src/shared/types/dictation.ts | 47 ++ .../shared/ui/ai-elements/mic-selector.tsx | 56 +- ui/sdk/src/generated/client.gen.ts | 83 +++ ui/sdk/src/generated/index.ts | 42 +- ui/sdk/src/generated/types.gen.ts | 141 ++++- ui/sdk/src/generated/zod.gen.ts | 156 +++++- 40 files changed, 4588 insertions(+), 25 deletions(-) create mode 100644 ui/goose2/src-tauri/Info.plist create mode 100644 ui/goose2/src/features/chat/hooks/__tests__/useDictationRecorder.test.ts create mode 100644 ui/goose2/src/features/chat/hooks/__tests__/useVoiceDictation.test.ts create mode 100644 ui/goose2/src/features/chat/hooks/__tests__/useVoiceInputPreferences.test.ts create mode 100644 ui/goose2/src/features/chat/hooks/useDictationRecorder.ts create mode 100644 ui/goose2/src/features/chat/hooks/useVoiceDictation.ts create mode 100644 ui/goose2/src/features/chat/hooks/useVoiceInputPreferences.ts create mode 100644 ui/goose2/src/features/chat/lib/dictationVad.test.ts create mode 100644 ui/goose2/src/features/chat/lib/dictationVad.ts create mode 100644 ui/goose2/src/features/chat/lib/voiceInput.test.ts create mode 100644 ui/goose2/src/features/chat/lib/voiceInput.ts create mode 100644 ui/goose2/src/features/settings/ui/LocalWhisperModels.tsx create mode 100644 ui/goose2/src/features/settings/ui/VoiceInputSettings.tsx create mode 100644 ui/goose2/src/features/settings/ui/__tests__/LocalWhisperModels.test.tsx create mode 100644 ui/goose2/src/shared/api/__tests__/dictation.test.ts create mode 100644 ui/goose2/src/shared/api/dictation.ts create mode 100644 ui/goose2/src/shared/types/dictation.ts diff --git a/Cargo.lock b/Cargo.lock index 093e9658825e..e38f0b0f0aa3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4454,6 +4454,7 @@ dependencies = [ "async-stream", "async-trait", "axum", + "base64 0.22.1", "fs-err", "futures", "goose", diff --git a/crates/goose-acp/Cargo.toml b/crates/goose-acp/Cargo.toml index 8bc2b1e7eed5..a7200146b8a8 100644 --- a/crates/goose-acp/Cargo.toml +++ b/crates/goose-acp/Cargo.toml @@ -14,6 +14,7 @@ path = "src/bin/generate_acp_schema.rs" [features] default = ["code-mode", "rustls-tls"] code-mode = ["goose/code-mode"] +local-inference = ["goose/local-inference"] rustls-tls = ["goose/rustls-tls", "goose-mcp/rustls-tls"] native-tls = ["goose/native-tls", "goose-mcp/native-tls"] @@ -48,6 +49,7 @@ uuid = { workspace = true, features = ["v7"] } schemars = { workspace = true, features = ["derive"] } goose-acp-macros = { path = "../goose-acp-macros" } goose-sdk = { path = "../goose-sdk" } +base64 = { workspace = true } [dev-dependencies] async-trait = { workspace = true } diff --git a/crates/goose-acp/acp-meta.json b/crates/goose-acp/acp-meta.json index 944d227b663f..75f28ef60a98 100644 --- a/crates/goose-acp/acp-meta.json +++ b/crates/goose-acp/acp-meta.json @@ -104,6 +104,46 @@ "method": "_goose/session/unarchive", "requestType": "UnarchiveSessionRequest", "responseType": "EmptyResponse" + }, + { + "method": "_goose/dictation/transcribe", + "requestType": "DictationTranscribeRequest", + "responseType": "DictationTranscribeResponse" + }, + { + "method": "_goose/dictation/config", + "requestType": "DictationConfigRequest", + "responseType": "DictationConfigResponse" + }, + { + "method": "_goose/dictation/models/list", + "requestType": "DictationModelsListRequest", + "responseType": "DictationModelsListResponse" + }, + { + "method": "_goose/dictation/models/download", + "requestType": "DictationModelDownloadRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/dictation/models/download/progress", + "requestType": "DictationModelDownloadProgressRequest", + "responseType": "DictationModelDownloadProgressResponse" + }, + { + "method": "_goose/dictation/models/cancel", + "requestType": "DictationModelCancelRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/dictation/models/delete", + "requestType": "DictationModelDeleteRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/dictation/model/select", + "requestType": "DictationModelSelectRequest", + "responseType": "EmptyResponse" } ] } diff --git a/crates/goose-acp/acp-schema.json b/crates/goose-acp/acp-schema.json index 0f0db1759a37..821de4145e74 100644 --- a/crates/goose-acp/acp-schema.json +++ b/crates/goose-acp/acp-schema.json @@ -607,6 +607,329 @@ "x-side": "agent", "x-method": "_goose/session/unarchive" }, + "DictationTranscribeRequest": { + "type": "object", + "properties": { + "audio": { + "type": "string", + "description": "Base64-encoded audio data" + }, + "mimeType": { + "type": "string", + "description": "MIME type (e.g. \"audio/wav\", \"audio/webm\")" + }, + "provider": { + "type": "string", + "description": "Provider to use: \"openai\", \"groq\", \"elevenlabs\", or \"local\"" + } + }, + "required": [ + "audio", + "mimeType", + "provider" + ], + "description": "Transcribe audio via a dictation provider.", + "x-side": "agent", + "x-method": "_goose/dictation/transcribe" + }, + "DictationTranscribeResponse": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "description": "Transcription result.", + "x-side": "agent", + "x-method": "_goose/dictation/transcribe" + }, + "DictationConfigRequest": { + "type": "object", + "description": "Get the configuration status of all dictation providers.", + "x-side": "agent", + "x-method": "_goose/dictation/config" + }, + "DictationConfigResponse": { + "type": "object", + "properties": { + "providers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/DictationProviderStatusEntry" + } + } + }, + "required": [ + "providers" + ], + "description": "Dictation config response — map of provider name to status.", + "x-side": "agent", + "x-method": "_goose/dictation/config" + }, + "DictationProviderStatusEntry": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + }, + "host": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "usesProviderConfig": { + "type": "boolean" + }, + "settingsPath": { + "type": [ + "string", + "null" + ] + }, + "configKey": { + "type": [ + "string", + "null" + ] + }, + "modelConfigKey": { + "type": [ + "string", + "null" + ] + }, + "defaultModel": { + "type": [ + "string", + "null" + ] + }, + "selectedModel": { + "type": [ + "string", + "null" + ] + }, + "availableModels": { + "type": "array", + "items": { + "$ref": "#/$defs/DictationModelOption" + }, + "default": [] + } + }, + "required": [ + "configured", + "description", + "usesProviderConfig" + ], + "description": "Per-provider configuration status." + }, + "DictationModelOption": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "label", + "description" + ] + }, + "DictationModelsListRequest": { + "type": "object", + "description": "List available local Whisper models with their download status.", + "x-side": "agent", + "x-method": "_goose/dictation/models/list" + }, + "DictationModelsListResponse": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { + "$ref": "#/$defs/DictationLocalModelStatus" + } + } + }, + "required": [ + "models" + ], + "x-side": "agent", + "x-method": "_goose/dictation/models/list" + }, + "DictationLocalModelStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "sizeMb": { + "type": "integer", + "minimum": 0 + }, + "downloaded": { + "type": "boolean" + }, + "downloadInProgress": { + "type": "boolean" + } + }, + "required": [ + "id", + "label", + "description", + "sizeMb", + "downloaded", + "downloadInProgress" + ] + }, + "DictationModelDownloadRequest": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "description": "Kick off a background download of a local Whisper model.", + "x-side": "agent", + "x-method": "_goose/dictation/models/download" + }, + "DictationModelDownloadProgressRequest": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "description": "Poll the progress of an in-flight download.", + "x-side": "agent", + "x-method": "_goose/dictation/models/download/progress" + }, + "DictationModelDownloadProgressResponse": { + "type": "object", + "properties": { + "progress": { + "anyOf": [ + { + "$ref": "#/$defs/DictationDownloadProgress" + }, + { + "type": "null" + } + ], + "description": "None when no download is active for this model id." + } + }, + "x-side": "agent", + "x-method": "_goose/dictation/models/download/progress" + }, + "DictationDownloadProgress": { + "type": "object", + "properties": { + "bytesDownloaded": { + "type": "integer", + "minimum": 0 + }, + "totalBytes": { + "type": "integer", + "minimum": 0 + }, + "progressPercent": { + "type": "number", + "format": "float" + }, + "status": { + "type": "string", + "description": "serde lowercase of DownloadStatus: \"downloading\" | \"completed\" | \"failed\" | \"cancelled\"" + }, + "error": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "bytesDownloaded", + "totalBytes", + "progressPercent", + "status" + ] + }, + "DictationModelCancelRequest": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "description": "Cancel an in-flight download.", + "x-side": "agent", + "x-method": "_goose/dictation/models/cancel" + }, + "DictationModelDeleteRequest": { + "type": "object", + "properties": { + "modelId": { + "type": "string" + } + }, + "required": [ + "modelId" + ], + "description": "Delete a downloaded local Whisper model from disk.", + "x-side": "agent", + "x-method": "_goose/dictation/models/delete" + }, + "DictationModelSelectRequest": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "modelId": { + "type": "string" + } + }, + "required": [ + "provider", + "modelId" + ], + "description": "Persist the user's model selection for a given provider.", + "x-side": "agent", + "x-method": "_goose/dictation/model/select" + }, "ExtRequest": { "properties": { "id": { @@ -807,6 +1130,78 @@ ], "description": "Params for _goose/session/unarchive", "title": "UnarchiveSessionRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationTranscribeRequest" + } + ], + "description": "Params for _goose/dictation/transcribe", + "title": "DictationTranscribeRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationConfigRequest" + } + ], + "description": "Params for _goose/dictation/config", + "title": "DictationConfigRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelsListRequest" + } + ], + "description": "Params for _goose/dictation/models/list", + "title": "DictationModelsListRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelDownloadRequest" + } + ], + "description": "Params for _goose/dictation/models/download", + "title": "DictationModelDownloadRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelDownloadProgressRequest" + } + ], + "description": "Params for _goose/dictation/models/download/progress", + "title": "DictationModelDownloadProgressRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelCancelRequest" + } + ], + "description": "Params for _goose/dictation/models/cancel", + "title": "DictationModelCancelRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelDeleteRequest" + } + ], + "description": "Params for _goose/dictation/models/delete", + "title": "DictationModelDeleteRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelSelectRequest" + } + ], + "description": "Params for _goose/dictation/model/select", + "title": "DictationModelSelectRequest" } ] }, @@ -933,6 +1328,38 @@ } ], "title": "ImportSessionResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationTranscribeResponse" + } + ], + "title": "DictationTranscribeResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationConfigResponse" + } + ], + "title": "DictationConfigResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelsListResponse" + } + ], + "title": "DictationModelsListResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DictationModelDownloadProgressResponse" + } + ], + "title": "DictationModelDownloadProgressResponse" } ] }, diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index d1a8212c7507..ca7c0f7ff883 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -16,6 +16,13 @@ use goose::config::paths::Paths; use goose::config::permission::PermissionManager; use goose::config::{Config, GooseMode}; use goose::conversation::message::{ActionRequiredData, Message, MessageContent}; +#[cfg(feature = "local-inference")] +use goose::dictation::providers::transcribe_local; +use goose::dictation::providers::{ + all_providers, is_configured, transcribe_with_provider, DictationProvider, +}; +#[cfg(feature = "local-inference")] +use goose::dictation::whisper; use goose::mcp_utils::ToolResult; use goose::permission::permission_confirmation::PrincipalType; use goose::permission::{Permission, PermissionConfirmation}; @@ -68,6 +75,12 @@ pub type AcpProviderFactory = Arc< const DEFAULT_PROVIDER_ID: &str = "goose"; const DEFAULT_PROVIDER_LABEL: &str = "Goose (Default)"; +const OPENAI_TRANSCRIPTION_MODEL_CONFIG_KEY: &str = "OPENAI_TRANSCRIPTION_MODEL"; +const GROQ_TRANSCRIPTION_MODEL_CONFIG_KEY: &str = "GROQ_TRANSCRIPTION_MODEL"; +const ELEVENLABS_TRANSCRIPTION_MODEL_CONFIG_KEY: &str = "ELEVENLABS_TRANSCRIPTION_MODEL"; +const OPENAI_TRANSCRIPTION_MODEL: &str = "whisper-1"; +const GROQ_TRANSCRIPTION_MODEL: &str = "whisper-large-v3-turbo"; +const ELEVENLABS_TRANSCRIPTION_MODEL: &str = "scribe_v1"; /// In-memory state for an active ACP session. /// @@ -2904,6 +2917,420 @@ impl GooseAcpAgent { .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; Ok(EmptyResponse {}) } + + #[custom_method(DictationTranscribeRequest)] + async fn on_dictation_transcribe( + &self, + req: DictationTranscribeRequest, + ) -> Result { + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; + let config = goose::config::Config::global(); + + #[cfg(not(feature = "local-inference"))] + if req.provider == "local" { + return Err(sacp::Error::invalid_params() + .data("Local inference is not available in this build")); + } + + let provider: DictationProvider = serde_json::from_value(serde_json::Value::String( + req.provider.clone(), + )) + .map_err(|_| { + sacp::Error::invalid_params().data(format!("Unknown provider: {}", req.provider)) + })?; + + let audio_bytes = BASE64 + .decode(&req.audio) + .map_err(|_| sacp::Error::invalid_params().data("Invalid base64 audio data"))?; + + if audio_bytes.len() > 50 * 1024 * 1024 { + return Err(sacp::Error::invalid_params().data("Audio too large (max 50MB)")); + } + + let extension = match req.mime_type.as_str() { + "audio/webm" | "audio/webm;codecs=opus" => "webm", + "audio/mp4" => "mp4", + "audio/mpeg" | "audio/mpga" => "mp3", + "audio/m4a" => "m4a", + "audio/wav" | "audio/x-wav" => "wav", + other => { + return Err( + sacp::Error::invalid_params().data(format!("Unsupported format: {other}")) + ) + } + }; + + let text = match provider { + DictationProvider::OpenAI => { + let model = dictation_selected_model(config, DictationProvider::OpenAI) + .unwrap_or_else(|| OPENAI_TRANSCRIPTION_MODEL.to_string()); + transcribe_with_provider( + DictationProvider::OpenAI, + "model".to_string(), + model, + audio_bytes, + extension, + &req.mime_type, + ) + .await + } + DictationProvider::Groq => { + let model = dictation_selected_model(config, DictationProvider::Groq) + .unwrap_or_else(|| GROQ_TRANSCRIPTION_MODEL.to_string()); + transcribe_with_provider( + DictationProvider::Groq, + "model".to_string(), + model, + audio_bytes, + extension, + &req.mime_type, + ) + .await + } + DictationProvider::ElevenLabs => { + let model = dictation_selected_model(config, DictationProvider::ElevenLabs) + .unwrap_or_else(|| ELEVENLABS_TRANSCRIPTION_MODEL.to_string()); + transcribe_with_provider( + DictationProvider::ElevenLabs, + "model_id".to_string(), + model, + audio_bytes, + extension, + &req.mime_type, + ) + .await + } + #[cfg(feature = "local-inference")] + DictationProvider::Local => transcribe_local(audio_bytes).await, + } + .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + + Ok(DictationTranscribeResponse { text }) + } + + #[custom_method(DictationConfigRequest)] + async fn on_dictation_config( + &self, + _req: DictationConfigRequest, + ) -> Result { + let config = goose::config::Config::global(); + let mut providers = std::collections::HashMap::new(); + + for def in all_providers() { + let provider = def.provider; + let host = if let Some(host_key) = def.host_key { + config + .get(host_key, false) + .ok() + .and_then(|v| v.as_str().map(|s| s.to_string())) + } else { + None + }; + + let provider_key = serde_json::to_value(provider) + .ok() + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| format!("{:?}", provider).to_lowercase()); + providers.insert( + provider_key, + DictationProviderStatusEntry { + configured: is_configured(provider), + host, + description: def.description.to_string(), + uses_provider_config: def.uses_provider_config, + settings_path: def.settings_path.map(|s| s.to_string()), + config_key: if !def.uses_provider_config { + Some(def.config_key.to_string()) + } else { + None + }, + model_config_key: dictation_model_config_key(provider), + default_model: dictation_default_model(provider), + selected_model: dictation_selected_model(config, provider), + available_models: dictation_available_models(provider), + }, + ); + } + + Ok(DictationConfigResponse { providers }) + } + + #[custom_method(DictationModelsListRequest)] + async fn on_dictation_models_list( + &self, + _req: DictationModelsListRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + use goose::download_manager::{get_download_manager, DownloadStatus}; + + let manager = get_download_manager(); + let models = whisper::available_models() + .iter() + .map(|model| DictationLocalModelStatus { + id: model.id.to_string(), + label: model.id.to_string(), + description: model.description.to_string(), + size_mb: model.size_mb, + downloaded: model.is_downloaded(), + download_in_progress: manager + .get_progress(model.id) + .map(|progress| progress.status == DownloadStatus::Downloading) + .unwrap_or(false), + }) + .collect(); + + Ok(DictationModelsListResponse { models }) + } + + #[cfg(not(feature = "local-inference"))] + Ok(DictationModelsListResponse::default()) + } + + #[custom_method(DictationModelDownloadRequest)] + async fn on_dictation_model_download( + &self, + _req: DictationModelDownloadRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + use goose::download_manager::get_download_manager; + + let model = whisper::get_model(&_req.model_id) + .ok_or_else(|| sacp::Error::invalid_params().data("Unknown model id"))?; + let manager = get_download_manager(); + let model_id_for_config = model.id.to_string(); + + manager + .download_model( + model.id.to_string(), + model.url.to_string(), + model.local_path(), + Some(Box::new(move || { + let config = goose::config::Config::global(); + // Only auto-select this model if the user has no model + // currently selected. This prevents silently switching + // the active model mid-session when a user downloads an + // additional model while one is already in use. + let already_selected = config + .get(whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY, false) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .filter(|model_id| { + // Treat a deleted model file as no active selection + // so a fresh download can auto-select cleanly. + whisper::get_model(model_id) + .is_some_and(|model| model.is_downloaded()) + }); + if already_selected.is_none() { + if let Err(e) = config.set_param( + whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY, + model_id_for_config.clone(), + ) { + error!("Failed to save LOCAL_WHISPER_MODEL after download: {}", e); + } + } + })), + ) + .await + .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + + Ok(EmptyResponse {}) + } + + #[cfg(not(feature = "local-inference"))] + Err(sacp::Error::invalid_params().data("Local inference not enabled")) + } + + #[custom_method(DictationModelDownloadProgressRequest)] + async fn on_dictation_model_download_progress( + &self, + _req: DictationModelDownloadProgressRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + use goose::download_manager::get_download_manager; + + let manager = get_download_manager(); + let progress = + manager + .get_progress(&_req.model_id) + .map(|progress| DictationDownloadProgress { + bytes_downloaded: progress.bytes_downloaded, + total_bytes: progress.total_bytes, + progress_percent: progress.progress_percent, + status: serde_json::to_value(&progress.status) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "unknown".to_string()), + error: progress.error, + }); + + Ok(DictationModelDownloadProgressResponse { progress }) + } + + #[cfg(not(feature = "local-inference"))] + Ok(DictationModelDownloadProgressResponse { progress: None }) + } + + #[custom_method(DictationModelCancelRequest)] + async fn on_dictation_model_cancel( + &self, + _req: DictationModelCancelRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + use goose::download_manager::get_download_manager; + + let manager = get_download_manager(); + manager + .cancel_download(&_req.model_id) + .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + + Ok(EmptyResponse {}) + } + + #[cfg(not(feature = "local-inference"))] + Err(sacp::Error::invalid_params().data("Local inference not enabled")) + } + + #[custom_method(DictationModelDeleteRequest)] + async fn on_dictation_model_delete( + &self, + _req: DictationModelDeleteRequest, + ) -> Result { + #[cfg(feature = "local-inference")] + { + let model = whisper::get_model(&_req.model_id) + .ok_or_else(|| sacp::Error::invalid_params().data("Unknown model id"))?; + let path = model.local_path(); + + if !path.exists() { + return Err(sacp::Error::invalid_params().data("Model not downloaded")); + } + + std::fs::remove_file(path) + .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + + Ok(EmptyResponse {}) + } + + #[cfg(not(feature = "local-inference"))] + Err(sacp::Error::invalid_params().data("Local inference not enabled")) + } + + #[custom_method(DictationModelSelectRequest)] + async fn on_dictation_model_select( + &self, + req: DictationModelSelectRequest, + ) -> Result { + #[cfg(not(feature = "local-inference"))] + if req.provider == "local" { + return Err(sacp::Error::invalid_params().data("Local inference not enabled")); + } + + let provider: DictationProvider = serde_json::from_value(serde_json::Value::String( + req.provider.clone(), + )) + .map_err(|_| { + sacp::Error::invalid_params().data(format!("Unknown provider: {}", req.provider)) + })?; + + let key = match provider { + DictationProvider::OpenAI => OPENAI_TRANSCRIPTION_MODEL_CONFIG_KEY, + DictationProvider::Groq => GROQ_TRANSCRIPTION_MODEL_CONFIG_KEY, + DictationProvider::ElevenLabs => ELEVENLABS_TRANSCRIPTION_MODEL_CONFIG_KEY, + #[cfg(feature = "local-inference")] + DictationProvider::Local => { + let model = whisper::get_model(&req.model_id) + .ok_or_else(|| sacp::Error::invalid_params().data("Unknown model id"))?; + if !model.is_downloaded() { + return Err( + sacp::Error::invalid_params().data("Local Whisper model is not downloaded") + ); + } + whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY + } + }; + + goose::config::Config::global() + .set_param(key, req.model_id) + .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + + Ok(EmptyResponse {}) + } +} + +fn dictation_model_config_key(provider: DictationProvider) -> Option { + match provider { + DictationProvider::OpenAI => Some(OPENAI_TRANSCRIPTION_MODEL_CONFIG_KEY.to_string()), + DictationProvider::Groq => Some(GROQ_TRANSCRIPTION_MODEL_CONFIG_KEY.to_string()), + DictationProvider::ElevenLabs => { + Some(ELEVENLABS_TRANSCRIPTION_MODEL_CONFIG_KEY.to_string()) + } + #[cfg(feature = "local-inference")] + DictationProvider::Local => Some(whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY.to_string()), + } +} + +fn dictation_default_model(provider: DictationProvider) -> Option { + match provider { + DictationProvider::OpenAI => Some(OPENAI_TRANSCRIPTION_MODEL.to_string()), + DictationProvider::Groq => Some(GROQ_TRANSCRIPTION_MODEL.to_string()), + DictationProvider::ElevenLabs => Some(ELEVENLABS_TRANSCRIPTION_MODEL.to_string()), + #[cfg(feature = "local-inference")] + DictationProvider::Local => Some(whisper::recommend_model().to_string()), + } +} + +fn dictation_selected_model(config: &Config, provider: DictationProvider) -> Option { + #[cfg(feature = "local-inference")] + if provider == DictationProvider::Local { + return config + .get(whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY, false) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .filter(|model_id| whisper::get_model(model_id).is_some()) + .or_else(|| dictation_default_model(provider)); + } + + dictation_model_config_key(provider) + .and_then(|key| { + config + .get(&key, false) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + }) + .or_else(|| dictation_default_model(provider)) +} + +fn dictation_available_models(provider: DictationProvider) -> Vec { + match provider { + DictationProvider::OpenAI => vec![DictationModelOption { + id: OPENAI_TRANSCRIPTION_MODEL.to_string(), + label: "Whisper-1".to_string(), + description: "OpenAI's hosted Whisper transcription model.".to_string(), + }], + DictationProvider::Groq => vec![DictationModelOption { + id: GROQ_TRANSCRIPTION_MODEL.to_string(), + label: "Whisper Large V3 Turbo".to_string(), + description: "Groq's fast hosted Whisper transcription model.".to_string(), + }], + DictationProvider::ElevenLabs => vec![DictationModelOption { + id: ELEVENLABS_TRANSCRIPTION_MODEL.to_string(), + label: "Scribe v1".to_string(), + description: "ElevenLabs' hosted speech-to-text model.".to_string(), + }], + #[cfg(feature = "local-inference")] + DictationProvider::Local => whisper::available_models() + .iter() + .map(|model| DictationModelOption { + id: model.id.to_string(), + label: model.id.to_string(), + description: model.description.to_string(), + }) + .collect(), + } } pub struct GooseAcpHandler { diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 6c20a644912a..369cd59606cb 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -71,7 +71,7 @@ winapi = { workspace = true } [features] default = ["code-mode", "local-inference", "aws-providers", "telemetry", "otel", "rustls-tls"] code-mode = ["goose/code-mode", "goose-acp/code-mode"] -local-inference = ["goose/local-inference"] +local-inference = ["goose/local-inference", "goose-acp/local-inference"] aws-providers = ["goose/aws-providers"] cuda = ["goose/cuda", "local-inference"] telemetry = ["goose/telemetry"] diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index bbc375be09f3..609299712c47 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -1,6 +1,7 @@ use sacp::{JsonRpcRequest, JsonRpcResponse}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// Schema descriptor for a single custom method, produced by the /// `#[custom_methods]` macro's generated `custom_method_schemas()` function. @@ -309,6 +310,154 @@ pub struct ProviderConfigKey { pub primary: bool, } +/// Transcribe audio via a dictation provider. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/transcribe", response = DictationTranscribeResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationTranscribeRequest { + /// Base64-encoded audio data + pub audio: String, + /// MIME type (e.g. "audio/wav", "audio/webm") + pub mime_type: String, + /// Provider to use: "openai", "groq", "elevenlabs", or "local" + pub provider: String, +} + +/// Transcription result. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct DictationTranscribeResponse { + pub text: String, +} + +/// Get the configuration status of all dictation providers. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/config", response = DictationConfigResponse)] +pub struct DictationConfigRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DictationModelOption { + pub id: String, + pub label: String, + pub description: String, +} + +/// Per-provider configuration status. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DictationProviderStatusEntry { + pub configured: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + pub description: String, + pub uses_provider_config: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub settings_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub config_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_config_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_model: Option, + #[serde(default)] + pub available_models: Vec, +} + +/// Dictation config response — map of provider name to status. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct DictationConfigResponse { + pub providers: HashMap, +} + /// Empty success response for operations that return no data. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] pub struct EmptyResponse {} + +/// List available local Whisper models with their download status. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/dictation/models/list", + response = DictationModelsListResponse +)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelsListRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelsListResponse { + pub models: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DictationLocalModelStatus { + pub id: String, + pub label: String, + pub description: String, + pub size_mb: u32, + pub downloaded: bool, + pub download_in_progress: bool, +} + +/// Kick off a background download of a local Whisper model. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/models/download", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelDownloadRequest { + pub model_id: String, +} + +/// Poll the progress of an in-flight download. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/dictation/models/download/progress", + response = DictationModelDownloadProgressResponse +)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelDownloadProgressRequest { + pub model_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelDownloadProgressResponse { + /// None when no download is active for this model id. + pub progress: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DictationDownloadProgress { + pub bytes_downloaded: u64, + pub total_bytes: u64, + pub progress_percent: f32, + /// serde lowercase of DownloadStatus: "downloading" | "completed" | "failed" | "cancelled" + pub status: String, + pub error: Option, +} + +/// Cancel an in-flight download. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/models/cancel", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelCancelRequest { + pub model_id: String, +} + +/// Delete a downloaded local Whisper model from disk. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/models/delete", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelDeleteRequest { + pub model_id: String, +} + +/// Persist the user's model selection for a given provider. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/dictation/model/select", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DictationModelSelectRequest { + pub provider: String, + pub model_id: String, +} diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index 07e1d124f273..c5c47459595b 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -50,6 +50,16 @@ const EXCEPTIONS = { justification: "ACP-backed session overlay persistence, draft migration, and sidebar-facing session merge logic live together for now.", }, + "src/features/chat/ui/ChatInput.tsx": { + limit: 510, + justification: + "Voice dictation send/stop guards, attachment handling, and mention/picker coordination still share one chat composer component.", + }, + "src/features/chat/ui/__tests__/ChatInput.test.tsx": { + limit: 510, + justification: + "Composer regression coverage spans personas, queueing, attachments, and voice-input edge cases in one interaction-heavy suite.", + }, "src-tauri/src/commands/projects.rs": { limit: 520, justification: diff --git a/ui/goose2/src-tauri/Info.plist b/ui/goose2/src-tauri/Info.plist new file mode 100644 index 000000000000..8588d2d741c4 --- /dev/null +++ b/ui/goose2/src-tauri/Info.plist @@ -0,0 +1,8 @@ + + + + + NSMicrophoneUsageDescription + Goose uses your microphone to capture voice input for dictation. + + diff --git a/ui/goose2/src-tauri/plugins/app-test-driver/src/lib.rs b/ui/goose2/src-tauri/plugins/app-test-driver/src/lib.rs index 256b2c29e1f6..0d7c09998b63 100644 --- a/ui/goose2/src-tauri/plugins/app-test-driver/src/lib.rs +++ b/ui/goose2/src-tauri/plugins/app-test-driver/src/lib.rs @@ -2,7 +2,9 @@ use serde::{Deserialize, Serialize}; use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::sync::Mutex; -use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; +use tauri::{AppHandle, Manager, Runtime}; +#[cfg(target_os = "macos")] +use tauri::WebviewWindow; #[derive(Deserialize, Debug)] struct TestCommand { diff --git a/ui/goose2/src-tauri/src/services/provider_defs.rs b/ui/goose2/src-tauri/src/services/provider_defs.rs index 0a2a326eaf00..5eea0c0a5a64 100644 --- a/ui/goose2/src-tauri/src/services/provider_defs.rs +++ b/ui/goose2/src-tauri/src/services/provider_defs.rs @@ -125,6 +125,17 @@ pub(crate) static PROVIDER_CONFIG_DEFS: &[ProviderConfigDef] = &[ keys: &[], oauth_cache_path: None, }, + // Dictation providers (voice input) + ProviderConfigDef { + id: "dictation_groq", + keys: &[key("GROQ_API_KEY", true, true)], + oauth_cache_path: None, + }, + ProviderConfigDef { + id: "dictation_elevenlabs", + keys: &[key("ELEVENLABS_API_KEY", true, true)], + oauth_cache_path: None, + }, ]; pub(crate) fn find_config_key(key_name: &str) -> Option<&'static ConfigKey> { diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useDictationRecorder.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useDictationRecorder.test.ts new file mode 100644 index 000000000000..43b851d3ffc9 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useDictationRecorder.test.ts @@ -0,0 +1,72 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockTranscribeDictation = vi.fn(); + +vi.mock("@/shared/api/dictation", () => ({ + transcribeDictation: (...args: unknown[]) => mockTranscribeDictation(...args), +})); + +import { useDictationRecorder } from "../useDictationRecorder"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +describe("useDictationRecorder", () => { + beforeEach(() => { + mockTranscribeDictation.mockReset(); + + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + getUserMedia: vi.fn(), + }, + }); + }); + + it("lets a second toggle cancel a pending startup", async () => { + const pendingStream = deferred(); + const stopTrack = vi.fn(); + const stream = { + getTracks: () => [{ stop: stopTrack }], + } as unknown as MediaStream; + + vi.mocked(navigator.mediaDevices.getUserMedia).mockReturnValue( + pendingStream.promise, + ); + + const { result } = renderHook(() => + useDictationRecorder({ + onError: vi.fn(), + onTranscription: vi.fn(), + preferredMicrophoneId: null, + provider: "openai", + providerConfigured: true, + }), + ); + + act(() => { + result.current.toggleRecording(); + }); + + expect(result.current.isStarting()).toBe(true); + + act(() => { + result.current.toggleRecording(); + }); + + await act(async () => { + pendingStream.resolve(stream); + await pendingStream.promise; + }); + + await waitFor(() => expect(result.current.isStarting()).toBe(false)); + expect(result.current.isRecording).toBe(false); + expect(stopTrack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useVoiceDictation.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useVoiceDictation.test.ts new file mode 100644 index 000000000000..a030d44b0f3e --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useVoiceDictation.test.ts @@ -0,0 +1,99 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGetDictationConfig = vi.fn(); +const mockUseDictationRecorder = vi.fn(); +const mockUseVoiceInputPreferences = vi.fn(); + +vi.mock("@/shared/api/dictation", () => ({ + getDictationConfig: () => mockGetDictationConfig(), +})); + +vi.mock("../useDictationRecorder", () => ({ + useDictationRecorder: (options: unknown) => mockUseDictationRecorder(options), +})); + +vi.mock("../useVoiceInputPreferences", () => ({ + useVoiceInputPreferences: () => mockUseVoiceInputPreferences(), +})); + +import { useVoiceDictation } from "../useVoiceDictation"; + +describe("useVoiceDictation", () => { + beforeEach(() => { + mockGetDictationConfig.mockReset(); + mockUseDictationRecorder.mockReset(); + mockUseVoiceInputPreferences.mockReset(); + + mockUseDictationRecorder.mockReturnValue({ + isEnabled: false, + isRecording: false, + isStarting: () => false, + isTranscribing: false, + startRecording: vi.fn(), + stopRecording: vi.fn(), + toggleRecording: vi.fn(), + }); + }); + + it("defers default provider fallback until preferences hydrate", async () => { + const voicePrefs = { + autoSubmitPhrases: [], + clearSelectedProvider: vi.fn(), + hasStoredProviderPreference: false, + isHydrated: false, + preferredMicrophoneId: null, + rawAutoSubmitPhrases: "submit", + selectedProvider: null, + setPreferredMicrophoneId: vi.fn(), + setRawAutoSubmitPhrases: vi.fn(), + setSelectedProvider: vi.fn(), + }; + + mockUseVoiceInputPreferences.mockImplementation(() => voicePrefs); + mockGetDictationConfig.mockResolvedValue({ + openai: { + availableModels: [], + configured: true, + description: "OpenAI", + usesProviderConfig: true, + }, + }); + + const { rerender } = renderHook(() => + useVoiceDictation({ + attachments: [], + clearAttachments: vi.fn(), + onSend: vi.fn(), + resetTextarea: vi.fn(), + selectedPersonaId: null, + setText: vi.fn(), + text: "", + }), + ); + + await waitFor(() => + expect(mockGetDictationConfig).toHaveBeenCalledTimes(1), + ); + await waitFor(() => + expect(mockUseDictationRecorder).toHaveBeenLastCalledWith( + expect.objectContaining({ + provider: null, + providerConfigured: false, + }), + ), + ); + + voicePrefs.isHydrated = true; + rerender(); + + await waitFor(() => + expect(mockUseDictationRecorder).toHaveBeenLastCalledWith( + expect.objectContaining({ + provider: "openai", + providerConfigured: true, + }), + ), + ); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useVoiceInputPreferences.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useVoiceInputPreferences.test.ts new file mode 100644 index 000000000000..8878ac1195aa --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useVoiceInputPreferences.test.ts @@ -0,0 +1,106 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGetClient = vi.fn(); + +vi.mock("@/shared/api/acpConnection", () => ({ + getClient: () => mockGetClient(), +})); + +import { useVoiceInputPreferences } from "../useVoiceInputPreferences"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +describe("useVoiceInputPreferences", () => { + beforeEach(() => { + mockGetClient.mockReset(); + }); + + it("does not hydrate until provider config can be read successfully", async () => { + let shouldFailProviderRead = true; + + mockGetClient.mockResolvedValue({ + goose: { + GooseConfigRead: vi.fn().mockImplementation(({ key }) => { + if (key === "VOICE_DICTATION_PROVIDER") { + if (shouldFailProviderRead) { + return Promise.reject(new Error("temporary acp failure")); + } + return Promise.resolve({ value: "groq" }); + } + return Promise.resolve({ value: null }); + }), + GooseConfigUpsert: vi.fn().mockResolvedValue({}), + GooseConfigRemove: vi.fn().mockResolvedValue({}), + }, + }); + + const { result } = renderHook(() => useVoiceInputPreferences()); + + await act(async () => {}); + + expect(result.current.isHydrated).toBe(false); + expect(result.current.selectedProvider).toBeNull(); + + shouldFailProviderRead = false; + + await act(async () => { + window.dispatchEvent(new Event("goose:voice-input-preferences")); + }); + + await waitFor(() => expect(result.current.isHydrated).toBe(true)); + expect(result.current.selectedProvider).toBe("groq"); + expect(result.current.hasStoredProviderPreference).toBe(true); + }); + + it("broadcasts preference changes only after config persistence settles", async () => { + const upsert = vi.fn(); + const providerRead = deferred<{ value?: unknown }>(); + const pendingWrite = deferred(); + + mockGetClient.mockResolvedValue({ + goose: { + GooseConfigRead: vi + .fn() + .mockResolvedValueOnce({ value: null }) + .mockResolvedValueOnce({ value: null }) + .mockResolvedValueOnce({ value: null }) + .mockImplementation(() => providerRead.promise), + GooseConfigUpsert: upsert.mockImplementation( + () => pendingWrite.promise, + ), + GooseConfigRemove: vi.fn().mockResolvedValue({}), + }, + }); + + const eventListener = vi.fn(); + window.addEventListener("goose:voice-input-preferences", eventListener); + + const { result } = renderHook(() => useVoiceInputPreferences()); + + await waitFor(() => expect(result.current.isHydrated).toBe(true)); + + act(() => { + result.current.setSelectedProvider("openai"); + }); + + expect(eventListener).not.toHaveBeenCalled(); + expect(result.current.selectedProvider).toBe("openai"); + + await act(async () => { + pendingWrite.resolve(); + await pendingWrite.promise; + }); + + await waitFor(() => expect(eventListener).toHaveBeenCalledTimes(1)); + + providerRead.resolve({ value: "openai" }); + window.removeEventListener("goose:voice-input-preferences", eventListener); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/useDictationRecorder.ts b/ui/goose2/src/features/chat/hooks/useDictationRecorder.ts new file mode 100644 index 000000000000..33462376b583 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/useDictationRecorder.ts @@ -0,0 +1,415 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { transcribeDictation } from "@/shared/api/dictation"; +import type { DictationProvider } from "@/shared/types/dictation"; +import { + advanceVadState, + createInitialVadState, + getFrameRms, +} from "../lib/dictationVad"; + +interface UseDictationRecorderOptions { + provider: DictationProvider | null; + providerConfigured: boolean; + preferredMicrophoneId: string | null; + onError: (message: string) => void; + onTranscription: (text: string) => void; +} + +const SAMPLE_RATE = 16000; + +function encodeWav(samples: Float32Array, sampleRate: number): ArrayBuffer { + const buffer = new ArrayBuffer(44 + samples.length * 2); + const view = new DataView(buffer); + const write = (offset: number, value: string) => { + for (let index = 0; index < value.length; index += 1) { + view.setUint8(offset + index, value.charCodeAt(index)); + } + }; + + write(0, "RIFF"); + view.setUint32(4, 36 + samples.length * 2, true); + write(8, "WAVE"); + write(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + write(36, "data"); + view.setUint32(40, samples.length * 2, true); + + let offset = 44; + for (let index = 0; index < samples.length; index += 1) { + const sample = Math.max(-1, Math.min(1, samples[index] ?? 0)); + view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true); + offset += 2; + } + + return buffer; +} + +function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(String(reader.result).split(",")[1] ?? ""); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} + +function toErrorMessage(error: unknown) { + if (error instanceof Error && error.message) { + return error.message; + } + + return "Voice input failed"; +} + +export function useDictationRecorder({ + provider, + providerConfigured, + preferredMicrophoneId, + onError, + onTranscription, +}: UseDictationRecorderOptions) { + const [isRecording, setIsRecording] = useState(false); + const [isTranscribing, setIsTranscribing] = useState(false); + const audioContextRef = useRef(null); + const processorRef = useRef(null); + const sourceRef = useRef(null); + const streamRef = useRef(null); + const samplesRef = useRef([]); + const vadStateRef = useRef(createInitialVadState()); + const pendingTranscriptionsRef = useRef(0); + const generationRef = useRef(0); + // Per-generation sequence numbers so out-of-order transcription responses + // can be reassembled into the order the chunks were captured. Without this, + // a later chunk whose API call resolves faster can be appended before an + // earlier, slower one — scrambling long dictation sessions with variable + // API latency. Empty transcriptions still occupy a slot so they don't block + // subsequent chunks. + const chunkSeqRef = useRef(0); + const nextExpectedSeqRef = useRef(0); + const pendingResultsRef = useRef>(new Map()); + // Guards against overlapping startRecording calls while getUserMedia is + // pending (user double-clicks the mic before the first startup resolves). + const startingRef = useRef(false); + // Signals to an in-flight startRecording that the user has asked to stop. + // When true, the startup path tears down any just-acquired stream instead + // of flipping isRecording to true — otherwise the OS mic indicator would + // stay on after the user tried to stop/send. + const cancelStartRef = useRef(false); + const providerRef = useRef(provider); + providerRef.current = provider; + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + const onTranscriptionRef = useRef(onTranscription); + onTranscriptionRef.current = onTranscription; + + const isEnabled = Boolean(provider && providerConfigured); + + const cleanupAudioGraph = useCallback(() => { + processorRef.current?.disconnect(); + processorRef.current = null; + sourceRef.current?.disconnect(); + sourceRef.current = null; + void audioContextRef.current?.close(); + audioContextRef.current = null; + streamRef.current?.getTracks().forEach((track) => { + track.stop(); + }); + streamRef.current = null; + }, []); + + const transcribeChunk = useCallback(async (samples: Float32Array) => { + const activeProvider = providerRef.current; + if (!activeProvider) { + return; + } + + const gen = generationRef.current; + const mySeq = chunkSeqRef.current; + chunkSeqRef.current += 1; + pendingTranscriptionsRef.current += 1; + setIsTranscribing(true); + + try { + const wavBlob = new Blob([encodeWav(samples, SAMPLE_RATE)], { + type: "audio/wav", + }); + const audio = await blobToBase64(wavBlob); + const response = await transcribeDictation({ + audio, + mimeType: "audio/wav", + provider: activeProvider, + }); + + if (gen !== generationRef.current) { + return; + } + + // Buffer by sequence number, then drain any contiguous prefix so + // emissions to onTranscription stay in capture order even when API + // responses resolve out of order. + pendingResultsRef.current.set(mySeq, response.text); + while (pendingResultsRef.current.has(nextExpectedSeqRef.current)) { + const text = pendingResultsRef.current.get(nextExpectedSeqRef.current); + pendingResultsRef.current.delete(nextExpectedSeqRef.current); + nextExpectedSeqRef.current += 1; + if (text?.trim()) { + onTranscriptionRef.current(text); + } + } + } catch (error) { + onErrorRef.current(toErrorMessage(error)); + // Unblock the queue so a failure doesn't stall every subsequent chunk. + if (gen === generationRef.current) { + pendingResultsRef.current.set(mySeq, ""); + while (pendingResultsRef.current.has(nextExpectedSeqRef.current)) { + const text = pendingResultsRef.current.get( + nextExpectedSeqRef.current, + ); + pendingResultsRef.current.delete(nextExpectedSeqRef.current); + nextExpectedSeqRef.current += 1; + if (text?.trim()) { + onTranscriptionRef.current(text); + } + } + } + } finally { + pendingTranscriptionsRef.current -= 1; + if (pendingTranscriptionsRef.current === 0) { + setIsTranscribing(false); + } + } + }, []); + + const flushPendingSamples = useCallback(() => { + const chunks = samplesRef.current; + if (chunks.length === 0) { + return; + } + + const totalSamples = chunks.reduce( + (count, chunk) => count + chunk.length, + 0, + ); + const merged = new Float32Array(totalSamples); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + + samplesRef.current = []; + void transcribeChunk(merged); + }, [transcribeChunk]); + + const stopRecording = useCallback( + (options?: { flushPending?: boolean }) => { + const flushPending = options?.flushPending ?? true; + + // Signal any in-flight startRecording to abort. If getUserMedia is + // still pending or the audio graph hasn't been wired up yet, the + // startup path will see this flag and clean up the just-acquired + // stream instead of flipping isRecording to true. + cancelStartRef.current = true; + + if (flushPending && samplesRef.current.length > 0) { + flushPendingSamples(); + } else if (!flushPending) { + samplesRef.current = []; + generationRef.current += 1; + // Reset chunk-ordering state so the new generation starts at seq 0. + // In-flight chunks from the old generation bail at the gen check in + // transcribeChunk without touching the pending map. + chunkSeqRef.current = 0; + nextExpectedSeqRef.current = 0; + pendingResultsRef.current.clear(); + } + + vadStateRef.current = createInitialVadState(); + cleanupAudioGraph(); + setIsRecording(false); + }, + [cleanupAudioGraph, flushPendingSamples], + ); + + const handleFrame = useCallback( + (samples: Float32Array) => { + const { decision, nextState } = advanceVadState( + vadStateRef.current, + getFrameRms(samples), + ); + vadStateRef.current = nextState; + + if (decision === "ignore") { + return; + } + + if (decision === "discard") { + samplesRef.current = []; + return; + } + + samplesRef.current.push(new Float32Array(samples)); + + if (decision === "append_and_flush") { + flushPendingSamples(); + } + }, + [flushPendingSamples], + ); + + const startRecording = useCallback(async () => { + if (!isEnabled || !provider) { + onError("Voice input is not configured"); + return; + } + + // Bail if a startup is already in-flight or we're already recording. + // Without this guard, a rapid second click (before getUserMedia resolves) + // would kick off a parallel recorder setup and leak a MediaStream — the + // OS mic indicator would stay on after the user thought they'd stopped. + if (startingRef.current || isRecording) { + return; + } + + startingRef.current = true; + cancelStartRef.current = false; + + try { + const audioConstraints: MediaTrackConstraints = { + autoGainControl: true, + echoCancellation: true, + noiseSuppression: true, + }; + + if (preferredMicrophoneId) { + audioConstraints.deviceId = { exact: preferredMicrophoneId }; + } + + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: audioConstraints, + }); + } catch (error) { + if ( + preferredMicrophoneId && + error instanceof DOMException && + (error.name === "NotFoundError" || + error.name === "OverconstrainedError") + ) { + delete audioConstraints.deviceId; + stream = await navigator.mediaDevices.getUserMedia({ + audio: audioConstraints, + }); + } else { + throw error; + } + } + + // If stopRecording was called while getUserMedia was pending (e.g., + // user clicked Send before the mic finished setting up), tear down + // the freshly-acquired stream immediately and bail. Otherwise the + // MediaStream tracks stay hot and the OS mic indicator lingers. + if (cancelStartRef.current) { + stream.getTracks().forEach((track) => { + track.stop(); + }); + return; + } + + streamRef.current = stream; + samplesRef.current = []; + vadStateRef.current = createInitialVadState(); + + const context = new AudioContext({ sampleRate: SAMPLE_RATE }); + audioContextRef.current = context; + await context.resume(); + + // Check again after the async context.resume() — stopRecording may + // have fired while we were awaiting. + if (cancelStartRef.current) { + cleanupAudioGraph(); + return; + } + + const source = context.createMediaStreamSource(stream); + const processor = context.createScriptProcessor(1024, 1, 1); + const silence = context.createGain(); + silence.gain.value = 0; + + processor.onaudioprocess = (event) => { + const channel = event.inputBuffer.getChannelData(0); + handleFrame(new Float32Array(channel)); + }; + + source.connect(processor); + processor.connect(silence); + silence.connect(context.destination); + + sourceRef.current = source; + processorRef.current = processor; + setIsRecording(true); + } catch (error) { + stopRecording({ flushPending: false }); + onError(toErrorMessage(error)); + } finally { + startingRef.current = false; + } + }, [ + cleanupAudioGraph, + handleFrame, + isEnabled, + isRecording, + onError, + preferredMicrophoneId, + provider, + stopRecording, + ]); + + const toggleRecording = useCallback(() => { + if (startingRef.current) { + stopRecording({ flushPending: false }); + return; + } + if (isRecording) { + stopRecording(); + } else { + void startRecording(); + } + }, [isRecording, startRecording, stopRecording]); + + useEffect( + () => () => { + stopRecording({ flushPending: false }); + }, + [stopRecording], + ); + + useEffect(() => { + if (!provider && isRecording) { + stopRecording({ flushPending: false }); + } + }, [isRecording, provider, stopRecording]); + + // Imperative check for consumers (e.g. handleSend) who need to know at + // click time whether a startup is pending. Uses a function rather than a + // state value because startingRef is a ref (no render on change) and we + // only need the answer when the consumer is deciding what to do *now*. + const isStarting = useCallback(() => startingRef.current, []); + + return { + isEnabled, + isRecording, + isTranscribing, + isStarting, + startRecording, + stopRecording, + toggleRecording, + }; +} diff --git a/ui/goose2/src/features/chat/hooks/useVoiceDictation.ts b/ui/goose2/src/features/chat/hooks/useVoiceDictation.ts new file mode 100644 index 000000000000..cfd2d67e81a4 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/useVoiceDictation.ts @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getDictationConfig } from "@/shared/api/dictation"; +import type { DictationProviderStatus } from "@/shared/types/dictation"; +import type { ChatAttachmentDraft } from "@/shared/types/messages"; +import { useDictationRecorder } from "./useDictationRecorder"; +import { useVoiceInputPreferences } from "./useVoiceInputPreferences"; +import { + appendTranscribedText, + getAutoSubmitMatch, + getDefaultDictationProvider, + VOICE_DICTATION_CONFIG_EVENT, +} from "../lib/voiceInput"; + +interface UseVoiceDictationOptions { + text: string; + setText: (value: string) => void; + attachments: ChatAttachmentDraft[]; + clearAttachments: () => void; + selectedPersonaId: string | null; + onSend: ( + text: string, + personaId?: string, + attachments?: ChatAttachmentDraft[], + ) => void; + resetTextarea: () => void; + /** + * When true, auto-submit on trigger phrase will NOT call `onSend`. + * Instead, the trigger phrase is stripped and the remaining transcription + * is left in the textarea for the user to review and send manually. + * Caller should set this to match `ChatInput`'s own send guards + * (queued-message lockout, outer `disabled` state, etc.) so voice + * auto-submit can't bypass the UI's protection against extra sends + * during an active run. + */ + isSendLocked?: boolean; +} + +export function useVoiceDictation({ + text, + setText, + attachments, + clearAttachments, + selectedPersonaId, + onSend, + resetTextarea, + isSendLocked = false, +}: UseVoiceDictationOptions) { + const voicePrefs = useVoiceInputPreferences(); + const [providerStatuses, setProviderStatuses] = useState< + Partial> + >({}); + + const fetchDictationConfig = useCallback(() => { + getDictationConfig() + .then(setProviderStatuses) + .catch(() => {}); + }, []); + + useEffect(() => { + fetchDictationConfig(); + window.addEventListener(VOICE_DICTATION_CONFIG_EVENT, fetchDictationConfig); + return () => + window.removeEventListener( + VOICE_DICTATION_CONFIG_EVENT, + fetchDictationConfig, + ); + }, [fetchDictationConfig]); + + // Treat the stored preference as valid only when it actually appears in + // `providerStatuses`. If the stored value points at a provider that's been + // feature-flagged off or removed, fall through to the default so voice + // input isn't silently disabled. The explicit "off" state + // (`hasStoredProviderPreference && selectedProvider == null`) is preserved. + const storedProviderIsPresent = + voicePrefs.selectedProvider != null && + providerStatuses[voicePrefs.selectedProvider] !== undefined; + + const activeVoiceProvider = !voicePrefs.isHydrated + ? null + : storedProviderIsPresent + ? voicePrefs.selectedProvider + : voicePrefs.hasStoredProviderPreference && + voicePrefs.selectedProvider == null + ? null + : getDefaultDictationProvider(providerStatuses); + + // If a stored preference points at a provider that's no longer in + // providerStatuses (feature-flagged off, removed), clear it so next boot + // falls through to the default cleanly instead of re-detecting the stale + // value every session. + useEffect(() => { + if ( + voicePrefs.selectedProvider != null && + Object.keys(providerStatuses).length > 0 && + providerStatuses[voicePrefs.selectedProvider] === undefined + ) { + voicePrefs.clearSelectedProvider(); + } + }, [providerStatuses, voicePrefs]); + + const providerConfigured = + activeVoiceProvider != null && + providerStatuses[activeVoiceProvider]?.configured === true; + + const stopRecordingRef = useRef< + (options?: { flushPending?: boolean }) => void + >(() => {}); + + // Mirror `text` in a ref so `handleTranscription` always sees the latest + // value, even when `useDictationRecorder` fires multiple callbacks in the + // same tick before React has applied the first setText. Without this, two + // concurrent callbacks would both read a stale `text` from closure and the + // second would overwrite the first fragment, dropping dictated words. + // + // Assign during render (not in a post-render `useEffect`) so there is no + // commit-window race: if the user types a character in the textarea and a + // transcription callback resolves before the effect runs, the callback + // would otherwise read the previous `text` and clobber the user's edit. + // Writing to `ref.current` during render is explicitly supported by React + // (see `providerRef.current = provider;` in `useDictationRecorder.ts`). + const textRef = useRef(text); + textRef.current = text; + + const handleTranscription = useCallback( + (fragment: string) => { + const latest = textRef.current; + const match = getAutoSubmitMatch(fragment, voicePrefs.autoSubmitPhrases); + if (match) { + const merged = appendTranscribedText(latest, match.textWithoutPhrase); + if (!merged.trim()) { + return; + } + stopRecordingRef.current({ flushPending: false }); + if (isSendLocked) { + // Parent UI is blocking sends (queued message, disabled, etc.). + // Strip the trigger phrase and leave the transcription in the + // textarea so the user can send it manually when the lock clears. + setText(merged); + textRef.current = merged; + return; + } + onSend( + merged.trim(), + selectedPersonaId ?? undefined, + attachments.length > 0 ? attachments : undefined, + ); + setText(""); + textRef.current = ""; + clearAttachments(); + resetTextarea(); + } else { + const merged = appendTranscribedText(latest, fragment); + setText(merged); + textRef.current = merged; + } + }, + [ + attachments, + clearAttachments, + isSendLocked, + onSend, + resetTextarea, + selectedPersonaId, + setText, + voicePrefs.autoSubmitPhrases, + ], + ); + + const handleVoiceError = useCallback((_message: string) => {}, []); + + const dictation = useDictationRecorder({ + provider: activeVoiceProvider, + providerConfigured, + preferredMicrophoneId: voicePrefs.preferredMicrophoneId, + onError: handleVoiceError, + onTranscription: handleTranscription, + }); + stopRecordingRef.current = dictation.stopRecording; + + return dictation; +} diff --git a/ui/goose2/src/features/chat/hooks/useVoiceInputPreferences.ts b/ui/goose2/src/features/chat/hooks/useVoiceInputPreferences.ts new file mode 100644 index 000000000000..e2fc66f2472c --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/useVoiceInputPreferences.ts @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getClient } from "@/shared/api/acpConnection"; +import { + DEFAULT_AUTO_SUBMIT_PHRASES_RAW, + DISABLED_DICTATION_PROVIDER_CONFIG_VALUE, + VOICE_AUTO_SUBMIT_PHRASES_CONFIG_KEY, + VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY, + VOICE_DICTATION_PROVIDER_CONFIG_KEY, + normalizeDictationProvider, + parseAutoSubmitPhrases, +} from "../lib/voiceInput"; +import type { DictationProvider } from "@/shared/types/dictation"; + +const VOICE_INPUT_PREFERENCES_EVENT = "goose:voice-input-preferences"; + +type ConfigReadResult = { ok: true; value: string | null } | { ok: false }; + +async function readConfigString(key: string): Promise { + try { + const client = await getClient(); + const response = await client.goose.GooseConfigRead({ key }); + return { + ok: true, + value: typeof response.value === "string" ? response.value : null, + }; + } catch { + return { ok: false }; + } +} + +async function writeConfigString(key: string, value: string): Promise { + try { + const client = await getClient(); + await client.goose.GooseConfigUpsert({ key, value }); + } catch { + // goose config may be unavailable + } +} + +async function removeConfigKey(key: string): Promise { + try { + const client = await getClient(); + await client.goose.GooseConfigRemove({ key }); + } catch { + // goose config may be unavailable + } +} + +export function useVoiceInputPreferences() { + const [rawAutoSubmitPhrases, setRawAutoSubmitPhrasesState] = useState( + DEFAULT_AUTO_SUBMIT_PHRASES_RAW, + ); + const [selectedProvider, setSelectedProviderState] = + useState(null); + const [hasStoredProviderPreference, setHasStoredProviderPreferenceState] = + useState(false); + const [preferredMicrophoneId, setPreferredMicrophoneIdState] = useState< + string | null + >(null); + // Flips true after the first syncFromConfig completes so consumers can + // distinguish "no stored preference" from "the ACP round-trip hasn't + // finished yet." Without this, a consumer that auto-writes a default when + // hasStoredProviderPreference is false can race ahead and overwrite the + // user's saved choice before it loads. + const [isHydrated, setIsHydrated] = useState(false); + + const syncFromConfig = useCallback(async () => { + const [phrasesResult, providerResult, micResult] = await Promise.all([ + readConfigString(VOICE_AUTO_SUBMIT_PHRASES_CONFIG_KEY), + readConfigString(VOICE_DICTATION_PROVIDER_CONFIG_KEY), + readConfigString(VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY), + ]); + + if (phrasesResult.ok) { + setRawAutoSubmitPhrasesState( + phrasesResult.value ?? DEFAULT_AUTO_SUBMIT_PHRASES_RAW, + ); + } + + if (!providerResult.ok) { + if (micResult.ok) { + setPreferredMicrophoneIdState(micResult.value); + } + return; + } + + if (providerResult.value === DISABLED_DICTATION_PROVIDER_CONFIG_VALUE) { + setSelectedProviderState(null); + setHasStoredProviderPreferenceState(true); + } else if (providerResult.value != null) { + const normalized = normalizeDictationProvider(providerResult.value); + if (normalized !== null) { + setSelectedProviderState(normalized); + setHasStoredProviderPreferenceState(true); + } else { + // Stored value isn't a recognized provider (stale from an older + // build, typo, etc.). Treat as no preference — don't pin the user + // to voice-off — and clear the config key so future boots fall + // through to the default cleanly. + setSelectedProviderState(null); + setHasStoredProviderPreferenceState(false); + void removeConfigKey(VOICE_DICTATION_PROVIDER_CONFIG_KEY); + } + } else { + setSelectedProviderState(null); + setHasStoredProviderPreferenceState(false); + } + + if (micResult.ok) { + setPreferredMicrophoneIdState(micResult.value); + } + setIsHydrated(true); + }, []); + + useEffect(() => { + void syncFromConfig(); + const handler = () => { + void syncFromConfig(); + }; + window.addEventListener( + VOICE_INPUT_PREFERENCES_EVENT, + handler as EventListener, + ); + return () => { + window.removeEventListener( + VOICE_INPUT_PREFERENCES_EVENT, + handler as EventListener, + ); + }; + }, [syncFromConfig]); + + const dispatchPreferencesEvent = useCallback(() => { + window.dispatchEvent(new Event(VOICE_INPUT_PREFERENCES_EVENT)); + }, []); + + const persistAndBroadcast = useCallback( + (operation: Promise) => { + void operation.finally(() => { + dispatchPreferencesEvent(); + }); + }, + [dispatchPreferencesEvent], + ); + + const setRawAutoSubmitPhrases = useCallback( + (value: string) => { + setRawAutoSubmitPhrasesState(value); + persistAndBroadcast( + writeConfigString(VOICE_AUTO_SUBMIT_PHRASES_CONFIG_KEY, value), + ); + }, + [persistAndBroadcast], + ); + + const setSelectedProvider = useCallback( + (value: DictationProvider | null) => { + setSelectedProviderState(value); + setHasStoredProviderPreferenceState(true); + persistAndBroadcast( + writeConfigString( + VOICE_DICTATION_PROVIDER_CONFIG_KEY, + value ?? DISABLED_DICTATION_PROVIDER_CONFIG_VALUE, + ), + ); + }, + [persistAndBroadcast], + ); + + // Remove the stored preference entirely, so the user falls through to the + // default provider on next boot. Distinct from setSelectedProvider(null), + // which pins the user to "voice off" via a sentinel value. + const clearSelectedProvider = useCallback(() => { + setSelectedProviderState(null); + setHasStoredProviderPreferenceState(false); + persistAndBroadcast(removeConfigKey(VOICE_DICTATION_PROVIDER_CONFIG_KEY)); + }, [persistAndBroadcast]); + + const setPreferredMicrophoneId = useCallback( + (value: string | null) => { + setPreferredMicrophoneIdState(value); + if (value) { + persistAndBroadcast( + writeConfigString(VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY, value), + ); + } else { + persistAndBroadcast( + removeConfigKey(VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY), + ); + } + }, + [persistAndBroadcast], + ); + + const autoSubmitPhrases = useMemo( + () => parseAutoSubmitPhrases(rawAutoSubmitPhrases), + [rawAutoSubmitPhrases], + ); + + return { + autoSubmitPhrases, + clearSelectedProvider, + hasStoredProviderPreference, + isHydrated, + preferredMicrophoneId, + rawAutoSubmitPhrases, + selectedProvider, + setPreferredMicrophoneId, + setRawAutoSubmitPhrases, + setSelectedProvider, + }; +} diff --git a/ui/goose2/src/features/chat/lib/dictationVad.test.ts b/ui/goose2/src/features/chat/lib/dictationVad.test.ts new file mode 100644 index 000000000000..89e96045c507 --- /dev/null +++ b/ui/goose2/src/features/chat/lib/dictationVad.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { advanceVadState, createInitialVadState } from "./dictationVad"; + +function runFrames(levels: number[]) { + const decisions: string[] = []; + let state = createInitialVadState(); + + for (const level of levels) { + const result = advanceVadState(state, level); + decisions.push(result.decision); + state = result.nextState; + } + + return decisions; +} + +describe("dictationVad", () => { + it("ignores silence-only audio", () => { + expect(runFrames([0, 0, 0, 0])).toEqual([ + "ignore", + "ignore", + "ignore", + "ignore", + ]); + }); + + it("discards short noise bursts that never confirm speech", () => { + expect(runFrames([0.03, 0, 0, 0])).toEqual([ + "append", + "append", + "append", + "discard", + ]); + }); + + it("flushes a chunk after speech followed by trailing silence", () => { + expect(runFrames([0.03, 0.03, 0.03, 0, 0, 0, 0, 0, 0])).toContain( + "append_and_flush", + ); + }); + + it("returns to ignoring silence after a flush, ready for another chunk", () => { + const decisions = runFrames([ + 0.03, 0.03, 0.03, 0, 0, 0, 0, 0, 0, 0.03, 0.03, 0.03, 0, 0, 0, 0, 0, 0, + ]); + + expect( + decisions.filter((decision) => decision === "append_and_flush"), + ).toHaveLength(2); + }); +}); diff --git a/ui/goose2/src/features/chat/lib/dictationVad.ts b/ui/goose2/src/features/chat/lib/dictationVad.ts new file mode 100644 index 000000000000..0b4561e8cbae --- /dev/null +++ b/ui/goose2/src/features/chat/lib/dictationVad.ts @@ -0,0 +1,147 @@ +export type VadPhase = "idle" | "primed" | "speaking" | "trailing"; + +export type VadDecision = "ignore" | "append" | "append_and_flush" | "discard"; + +export interface VadState { + phase: VadPhase; + speechFrames: number; + silenceFrames: number; + framesInChunk: number; +} + +const SPEECH_RMS_THRESHOLD = 0.018; +const SPEECH_CONFIRMATION_FRAMES = 2; +const MAX_PRIMED_SILENCE_FRAMES = 2; +const TRAILING_SILENCE_FRAMES = 6; +const MIN_SPEECH_FRAMES = 3; + +export function createInitialVadState(): VadState { + return { + phase: "idle", + speechFrames: 0, + silenceFrames: 0, + framesInChunk: 0, + }; +} + +export function getFrameRms(samples: Float32Array): number { + let sum = 0; + for (let index = 0; index < samples.length; index += 1) { + const value = samples[index] ?? 0; + sum += value * value; + } + + return Math.sqrt(sum / Math.max(samples.length, 1)); +} + +export function advanceVadState( + state: VadState, + frameRms: number, +): { decision: VadDecision; nextState: VadState } { + const isSpeech = frameRms >= SPEECH_RMS_THRESHOLD; + + if (state.phase === "idle") { + if (!isSpeech) { + return { decision: "ignore" as const, nextState: state }; + } + + return { + decision: "append" as const, + nextState: { + phase: "primed" as const, + speechFrames: 1, + silenceFrames: 0, + framesInChunk: 1, + }, + }; + } + + if (state.phase === "primed") { + if (isSpeech) { + const speechFrames = state.speechFrames + 1; + return { + decision: "append" as const, + nextState: { + phase: + speechFrames >= SPEECH_CONFIRMATION_FRAMES ? "speaking" : "primed", + speechFrames, + silenceFrames: 0, + framesInChunk: state.framesInChunk + 1, + }, + }; + } + + const silenceFrames = state.silenceFrames + 1; + if (silenceFrames > MAX_PRIMED_SILENCE_FRAMES) { + return { + decision: "discard" as const, + nextState: createInitialVadState(), + }; + } + + return { + decision: "append" as const, + nextState: { + ...state, + silenceFrames, + framesInChunk: state.framesInChunk + 1, + }, + }; + } + + if (state.phase === "speaking") { + if (isSpeech) { + return { + decision: "append" as const, + nextState: { + phase: "speaking" as const, + speechFrames: state.speechFrames + 1, + silenceFrames: 0, + framesInChunk: state.framesInChunk + 1, + }, + }; + } + + return { + decision: "append" as const, + nextState: { + phase: "trailing" as const, + speechFrames: state.speechFrames, + silenceFrames: 1, + framesInChunk: state.framesInChunk + 1, + }, + }; + } + + if (isSpeech) { + return { + decision: "append" as const, + nextState: { + phase: "speaking" as const, + speechFrames: state.speechFrames + 1, + silenceFrames: 0, + framesInChunk: state.framesInChunk + 1, + }, + }; + } + + const silenceFrames = state.silenceFrames + 1; + if (silenceFrames < TRAILING_SILENCE_FRAMES) { + return { + decision: "append" as const, + nextState: { + ...state, + silenceFrames, + framesInChunk: state.framesInChunk + 1, + }, + }; + } + + return { + decision: + state.speechFrames >= MIN_SPEECH_FRAMES + ? ("append_and_flush" as const) + : ("discard" as const), + nextState: createInitialVadState(), + }; +} diff --git a/ui/goose2/src/features/chat/lib/voiceInput.test.ts b/ui/goose2/src/features/chat/lib/voiceInput.test.ts new file mode 100644 index 000000000000..452e5bf2d189 --- /dev/null +++ b/ui/goose2/src/features/chat/lib/voiceInput.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + appendTranscribedText, + getDefaultDictationProvider, + getAutoSubmitMatch, + parseAutoSubmitPhrases, + replaceTrailingTranscribedText, +} from "./voiceInput"; + +describe("voiceInput helpers", () => { + it("parses comma-separated auto-submit phrases", () => { + expect(parseAutoSubmitPhrases(" submit, Ship It ,submit ,, ")).toEqual([ + "submit", + "ship it", + ]); + }); + + it("appends dictated text without smashing words together", () => { + expect(appendTranscribedText("hello", "world")).toBe("hello world"); + expect(appendTranscribedText("hello ", "world")).toBe("hello world"); + expect(appendTranscribedText("hello", ", world")).toBe("hello, world"); + }); + + it("replaces only the trailing dictated segment", () => { + expect( + replaceTrailingTranscribedText( + "draft dictated text", + "dictated text", + "dictated text submit", + ), + ).toBe("draft dictated text submit"); + }); + + it("matches auto-submit phrases only at the end of dictated text", () => { + expect(getAutoSubmitMatch("please submit now", ["submit"])).toBeNull(); + expect(getAutoSubmitMatch("please SUBMIT.", ["submit"])).toEqual({ + matchedPhrase: "submit", + textWithoutPhrase: "please", + }); + }); + + it("strips the full raw phrase span when internal whitespace is repeated", () => { + // The phrase "ship it" is matched against the *normalized* text, where + // "ship it" collapses to "ship it" (7 chars). But in the raw text the + // phrase occupies 9 chars — slicing by -phrase.length would leave a + // dangling "sh" on the end. The fix walks the raw text with a regex so + // the slice index reflects the actual phrase span in the raw string. + expect(getAutoSubmitMatch("hello ship it", ["ship it"])).toEqual({ + matchedPhrase: "ship it", + textWithoutPhrase: "hello", + }); + }); + + it("picks the first configured dictation provider by priority", () => { + expect( + getDefaultDictationProvider({ + openai: { + configured: false, + description: "OpenAI", + usesProviderConfig: true, + availableModels: [], + }, + groq: { + configured: true, + description: "Groq", + usesProviderConfig: false, + availableModels: [], + }, + local: { + configured: true, + description: "Local", + usesProviderConfig: false, + availableModels: [], + }, + }), + ).toBe("groq"); + }); + + it("falls back to the first available provider when none are configured", () => { + expect( + getDefaultDictationProvider({ + elevenlabs: { + configured: false, + description: "ElevenLabs", + usesProviderConfig: false, + availableModels: [], + }, + local: { + configured: false, + description: "Local", + usesProviderConfig: false, + availableModels: [], + }, + }), + ).toBe("local"); + }); +}); diff --git a/ui/goose2/src/features/chat/lib/voiceInput.ts b/ui/goose2/src/features/chat/lib/voiceInput.ts new file mode 100644 index 000000000000..b349f89165ca --- /dev/null +++ b/ui/goose2/src/features/chat/lib/voiceInput.ts @@ -0,0 +1,199 @@ +import type { + DictationProvider, + DictationProviderStatus, +} from "@/shared/types/dictation"; + +// goose config keys — stored in the user's goose config.yaml via the +// _goose/config/{read,upsert,remove} ACP methods, not localStorage. +export const VOICE_AUTO_SUBMIT_PHRASES_CONFIG_KEY = "VOICE_AUTO_SUBMIT_PHRASES"; +export const VOICE_DICTATION_PROVIDER_CONFIG_KEY = "VOICE_DICTATION_PROVIDER"; +export const VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY = + "VOICE_DICTATION_PREFERRED_MIC"; +export const VOICE_DICTATION_CONFIG_EVENT = "goose:voice-dictation-config"; +export const DISABLED_DICTATION_PROVIDER_CONFIG_VALUE = "__disabled__"; + +export const DEFAULT_AUTO_SUBMIT_PHRASES_RAW = "submit"; + +const TRAILING_PUNCTUATION_REGEX = /[\s"'`.,!?;:)\]}]+$/u; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function normalizePhrase(value: string): string { + return value + .toLowerCase() + .replace(/\s+/g, " ") + .trim() + .replace(TRAILING_PUNCTUATION_REGEX, "") + .trim(); +} + +export function parseAutoSubmitPhrases(rawValue: string | null | undefined) { + if (!rawValue) { + return []; + } + + return Array.from( + new Set( + rawValue + .split(",") + .map((value) => normalizePhrase(value)) + .filter(Boolean), + ), + ); +} + +export function normalizeDictationProvider( + value: string | null | undefined, +): DictationProvider | null { + if ( + value === "openai" || + value === "groq" || + value === "elevenlabs" || + value === "local" + ) { + return value; + } + + return null; +} + +export function getDefaultDictationProvider( + providerStatuses: Partial>, +): DictationProvider | null { + const configuredProviderPriority: DictationProvider[] = [ + "openai", + "groq", + "elevenlabs", + "local", + ]; + const fallbackProviderPriority: DictationProvider[] = [ + "local", + "openai", + "groq", + "elevenlabs", + ]; + + for (const provider of configuredProviderPriority) { + if (providerStatuses[provider]?.configured) { + return provider; + } + } + + for (const provider of fallbackProviderPriority) { + if (providerStatuses[provider]) { + return provider; + } + } + + return null; +} + +export function appendTranscribedText(baseText: string, fragment: string) { + const normalizedFragment = fragment.replace(/\s+/g, " ").trim(); + if (!normalizedFragment) { + return baseText; + } + + if (!baseText.trim()) { + return normalizedFragment; + } + + if (/[\s([{/-]$/.test(baseText) || /^[,.;!?)]/.test(normalizedFragment)) { + return `${baseText}${normalizedFragment}`; + } + + return `${baseText} ${normalizedFragment}`; +} + +export function replaceTrailingTranscribedText( + fullText: string, + previousTranscribedText: string, + nextTranscribedText: string, +) { + if (!previousTranscribedText) { + return appendTranscribedText(fullText, nextTranscribedText); + } + + if (fullText.endsWith(previousTranscribedText)) { + return appendTranscribedText( + fullText.slice(0, -previousTranscribedText.length), + nextTranscribedText, + ); + } + + const trimmedPreviousText = previousTranscribedText.trim(); + if (trimmedPreviousText && fullText.endsWith(trimmedPreviousText)) { + return appendTranscribedText( + fullText.slice(0, -trimmedPreviousText.length), + nextTranscribedText, + ); + } + + return appendTranscribedText(fullText, nextTranscribedText); +} + +export function getAutoSubmitMatch( + transcribedText: string, + autoSubmitPhrases: string[], +) { + const normalizedTranscribedText = normalizePhrase(transcribedText); + if (!normalizedTranscribedText) { + return null; + } + + const sortedPhrases = [...autoSubmitPhrases].sort( + (left, right) => right.length - left.length, + ); + + for (const phrase of sortedPhrases) { + if (!normalizedTranscribedText.endsWith(phrase)) { + continue; + } + + const phraseStartIndex = normalizedTranscribedText.length - phrase.length; + if ( + phraseStartIndex > 0 && + normalizedTranscribedText[phraseStartIndex - 1] !== " " + ) { + continue; + } + + // Map the phrase back to the *raw* transcribed text. `phrase.length` is + // the length in normalized form (whitespace collapsed to single spaces, + // lowercased, trailing punctuation stripped). Applying -phrase.length + // directly to trimmedText undercounts whenever the raw text has repeated + // whitespace or mixed case, chopping off legitimate content. Instead, + // match the phrase at the end of the raw text using a regex that allows + // flexible whitespace between words, so the slice index reflects the + // actual start of the phrase in the raw string. + const trimmedText = transcribedText.replace(TRAILING_PUNCTUATION_REGEX, ""); + const phraseWords = phrase.split(" ").filter(Boolean).map(escapeRegExp); + const phrasePattern = new RegExp( + `(^|\\s)(${phraseWords.join("\\s+")})\\s*$`, + "iu", + ); + const rawMatch = trimmedText.match(phrasePattern); + const phraseStartOffset = + rawMatch && rawMatch.index !== undefined + ? rawMatch.index + (rawMatch[1]?.length ?? 0) + : trimmedText.length - phrase.length; + const textWithoutPhrase = trimmedText.slice(0, phraseStartOffset).trimEnd(); + + return { + matchedPhrase: phrase, + textWithoutPhrase, + }; + } + + return null; +} + +export function notifyVoiceDictationConfigChanged() { + try { + window.dispatchEvent(new Event(VOICE_DICTATION_CONFIG_EVENT)); + } catch { + // no-op + } +} diff --git a/ui/goose2/src/features/chat/ui/ChatInput.tsx b/ui/goose2/src/features/chat/ui/ChatInput.tsx index 9b40f2b768f3..4a21f9a60c1d 100644 --- a/ui/goose2/src/features/chat/ui/ChatInput.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInput.tsx @@ -22,6 +22,7 @@ import { } from "../hooks/useChatInputAttachments"; import type { ModelOption } from "../types"; import { ChatInputAttachments } from "./ChatInputAttachments"; +import { useVoiceDictation } from "../hooks/useVoiceDictation"; export interface ProjectOption { id: string; @@ -121,6 +122,25 @@ export function ChatInput({ clearAttachments, } = useChatInputAttachments(); + const resetTextarea = useCallback(() => { + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + }, []); + + const hasQueuedMessage = queuedMessage !== null; + + const dictation = useVoiceDictation({ + text, + setText, + attachments, + clearAttachments, + selectedPersonaId, + onSend, + resetTextarea, + isSendLocked: hasQueuedMessage || disabled, + }); + const activePersona = useMemo( () => personas.find((persona) => persona.id === selectedPersonaId) ?? null, [personas, selectedPersonaId], @@ -133,7 +153,6 @@ export function ChatInput({ ); const stickyPersona = activePersona; - const hasQueuedMessage = queuedMessage !== null; const canSend = (text.trim().length > 0 || attachments.length > 0) && !hasQueuedMessage && @@ -182,6 +201,24 @@ export function ChatInput({ return; } + // If recording, stop without waiting for final flush and send what's + // already transcribed into the textarea. This makes Send a single click + // even while the mic is hot; any in-flight audio after the user clicked + // Send is intentionally dropped. + // + // Also handles the edge case where the user clicks Send while a + // getUserMedia startup is still pending (isRecording is still false but + // a stream is about to be acquired) — stopRecording sets the internal + // cancel flag so the pending startup tears itself down instead of + // leaving the OS mic indicator on. + if ( + dictation.isRecording || + dictation.isTranscribing || + dictation.isStarting() + ) { + dictation.stopRecording({ flushPending: false }); + } + onSend( text.trim(), selectedPersonaId ?? undefined, @@ -196,6 +233,7 @@ export function ChatInput({ attachments, canSend, clearAttachments, + dictation, onSend, selectedPersonaId, setText, @@ -408,7 +446,13 @@ export function ChatInput({ onChange={handleInput} onKeyDown={handleKeyDown} onPaste={handlePaste} - placeholder={effectivePlaceholder} + placeholder={ + dictation.isRecording + ? t("toolbar.voiceInputRecording") + : dictation.isTranscribing + ? t("toolbar.voiceInputTranscribing") + : effectivePlaceholder + } disabled={disabled} rows={1} className="mb-3 min-h-[36px] max-h-[200px] w-full resize-none bg-transparent px-1 text-[14px] leading-relaxed text-foreground placeholder:font-light placeholder:text-muted-foreground/60 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-60" @@ -447,6 +491,10 @@ export function ChatInput({ onSend={handleSend} onStop={onStop} isCompact={isCompact} + voiceEnabled={dictation.isEnabled} + voiceRecording={dictation.isRecording} + voiceTranscribing={dictation.isTranscribing} + onVoiceToggle={dictation.toggleRecording} /> diff --git a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx index 3e25b8f084ce..411c002fa1fc 100644 --- a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx @@ -90,6 +90,11 @@ interface ChatInputToolbarProps { onAttachFiles?: () => void; onAttachFolders?: () => void; disabled?: boolean; + // Voice + voiceEnabled?: boolean; + voiceRecording?: boolean; + voiceTranscribing?: boolean; + onVoiceToggle?: () => void; // Layout isCompact: boolean; } @@ -124,6 +129,10 @@ export function ChatInputToolbar({ onAttachFiles, onAttachFolders, disabled = false, + voiceEnabled = false, + voiceRecording = false, + voiceTranscribing = false, + onVoiceToggle, isCompact, }: ChatInputToolbarProps) { const { t } = useTranslation("chat"); @@ -384,14 +393,32 @@ export function ChatInputToolbar({ type="button" variant="ghost" size="icon-sm" - disabled - aria-label={t("toolbar.voiceInputSoon")} + disabled={!voiceRecording && (!voiceEnabled || disabled)} + onClick={onVoiceToggle} + aria-label={ + voiceRecording + ? t("toolbar.voiceInputRecording") + : t("toolbar.voiceInput") + } + className={cn( + voiceRecording && + "bg-destructive/10 text-destructive hover:bg-destructive/20 hover:text-destructive", + voiceTranscribing && "animate-pulse", + )} > - {t("toolbar.voiceInputSoon")} + + {!voiceEnabled + ? t("toolbar.voiceInputDisabled") + : voiceRecording + ? t("toolbar.voiceInputRecording") + : voiceTranscribing + ? t("toolbar.voiceInputTranscribing") + : t("toolbar.voiceInput")} + diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx index 0561892dd011..6b60e12d7264 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -3,8 +3,22 @@ import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useState } from "react"; import { ChatInput } from "../ChatInput"; +import { ChatInputToolbar } from "../ChatInputToolbar"; import type { Persona } from "@/shared/types/agents"; +const mockVoiceDictation = { + isEnabled: true, + isRecording: false, + isTranscribing: false, + isStarting: vi.fn(() => false), + stopRecording: vi.fn(), + toggleRecording: vi.fn(), +}; + +vi.mock("../hooks/useVoiceDictation", () => ({ + useVoiceDictation: () => mockVoiceDictation, +})); + vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({ useAgentProviderStatus: () => ({ readyAgentIds: new Set(["goose", "claude-acp", "codex-acp"]), @@ -63,6 +77,13 @@ describe("ChatInput", () => { beforeEach(() => { mockListFilesForMentions.mockClear(); mockListFilesForMentions.mockResolvedValue([]); + mockVoiceDictation.isEnabled = true; + mockVoiceDictation.isRecording = false; + mockVoiceDictation.isTranscribing = false; + mockVoiceDictation.isStarting.mockReset(); + mockVoiceDictation.isStarting.mockReturnValue(false); + mockVoiceDictation.stopRecording.mockReset(); + mockVoiceDictation.toggleRecording.mockReset(); }); it("renders with default placeholder", () => { @@ -418,6 +439,53 @@ describe("ChatInput", () => { expect(onSend).not.toHaveBeenCalled(); }); + it("does not stop dictation when send is blocked", async () => { + const onSend = vi.fn(); + const user = userEvent.setup(); + mockVoiceDictation.isRecording = true; + + render( + , + ); + + await user.type(screen.getByRole("textbox"), "another message"); + await user.keyboard("{Enter}"); + + expect(onSend).not.toHaveBeenCalled(); + expect(mockVoiceDictation.stopRecording).not.toHaveBeenCalled(); + }); + + it("keeps the mic toggle enabled while recording even if voice input becomes unavailable", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Listening..." })).toBeEnabled(); + }); + it("keeps the selected assistant chip after sending subsequent messages", async () => { const onSend = vi.fn(); const user = userEvent.setup(); diff --git a/ui/goose2/src/features/settings/ui/LocalWhisperModels.tsx b/ui/goose2/src/features/settings/ui/LocalWhisperModels.tsx new file mode 100644 index 000000000000..751ef8a628b7 --- /dev/null +++ b/ui/goose2/src/features/settings/ui/LocalWhisperModels.tsx @@ -0,0 +1,325 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shared/ui/button"; +import { + cancelDictationLocalModelDownload, + deleteDictationLocalModel, + downloadDictationLocalModel, + getDictationLocalModelDownloadProgress, + listDictationLocalModels, +} from "@/shared/api/dictation"; + +type LocalModel = { + id: string; + description: string; + sizeMb: number; + downloaded: boolean; + downloadInProgress: boolean; +}; + +type DownloadProgress = { + bytesDownloaded: number; + totalBytes: number; + progressPercent: number; + status: string; + error?: string | null; +}; + +const POLL_INTERVAL_MS = 750; + +interface LocalWhisperModelsProps { + selectedModelId: string; + onSelectModel: (modelId: string) => void | Promise; + onModelsChanged: () => void | Promise; +} + +export function LocalWhisperModels({ + selectedModelId, + onSelectModel, + onModelsChanged, +}: LocalWhisperModelsProps) { + const { t } = useTranslation(["settings", "common"]); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [downloadingIds, setDownloadingIds] = useState>(new Set()); + const [progresses, setProgresses] = useState>( + new Map(), + ); + const onModelsChangedRef = useRef(onModelsChanged); + onModelsChangedRef.current = onModelsChanged; + + const refresh = useCallback(async () => { + try { + const list = + (await listDictationLocalModels()) as unknown as LocalModel[]; + setModels(list); + setDownloadingIds((prev) => { + const next = new Set(prev); + for (const m of list) { + if (m.downloadInProgress) next.add(m.id); + } + return next; + }); + } catch (err) { + setError( + err instanceof Error ? err.message : t("general.voiceInput.loadError"), + ); + } + }, [t]); + + useEffect(() => { + const load = async () => { + setLoading(true); + setError(null); + await refresh(); + setLoading(false); + }; + void load(); + }, [refresh]); + + useEffect(() => { + if (downloadingIds.size === 0) return; + let cancelled = false; + + const tick = async () => { + const next = new Map(); + const stillActive = new Set(); + const finishedIds: string[] = []; + + for (const id of downloadingIds) { + try { + const progress = (await getDictationLocalModelDownloadProgress( + id, + )) as unknown as DownloadProgress | null; + if (!progress) { + finishedIds.push(id); + continue; + } + next.set(id, progress); + if (progress.status === "downloading") { + stillActive.add(id); + } else { + finishedIds.push(id); + } + } catch { + stillActive.add(id); + } + } + if (cancelled) return; + setProgresses(next); + if (finishedIds.length > 0) { + await refresh(); + await onModelsChangedRef.current(); + } + setDownloadingIds(stillActive); + }; + + const interval = window.setInterval(() => { + void tick(); + }, POLL_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [downloadingIds, refresh]); + + const startDownload = useCallback( + async (modelId: string) => { + setError(null); + try { + await downloadDictationLocalModel(modelId); + setDownloadingIds((prev) => new Set(prev).add(modelId)); + } catch (err) { + setError( + err instanceof Error + ? err.message + : t("general.voiceInput.saveError"), + ); + } + }, + [t], + ); + + const cancelDownload = useCallback( + async (modelId: string) => { + setError(null); + try { + await cancelDictationLocalModelDownload(modelId); + } catch (err) { + setError( + err instanceof Error + ? err.message + : t("general.voiceInput.saveError"), + ); + } finally { + setProgresses((prev) => { + const next = new Map(prev); + next.delete(modelId); + return next; + }); + setDownloadingIds((prev) => { + const next = new Set(prev); + next.delete(modelId); + return next; + }); + await refresh(); + } + }, + [refresh, t], + ); + + const deleteModel = useCallback( + async (modelId: string) => { + setError(null); + try { + await deleteDictationLocalModel(modelId); + await refresh(); + await onModelsChanged(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : t("general.voiceInput.deleteError"), + ); + } + }, + [onModelsChanged, refresh, t], + ); + + if (loading) { + return ( +
+

+ {t("common:labels.loading")} +

+
+ ); + } + + if (models.length === 0) { + return ( +
+

+ {t("general.voiceInput.noLocalModels")} +

+
+ ); + } + + return ( +
+
+

+ {t("general.voiceInput.localModelLabel")} +

+

+ {t("general.voiceInput.localModelDescription")} +

+
+ +
    + {models.map((model) => { + const progress = progresses.get(model.id); + const isDownloading = + downloadingIds.has(model.id) || + progress?.status === "downloading" || + model.downloadInProgress; + const isSelected = model.downloaded && model.id === selectedModelId; + return ( +
  • +
    +
    +

    + {model.id} +

    + + {model.sizeMb} MB + + {isSelected ? ( + + {t("general.voiceInput.selectedModel")} + + ) : null} +
    +

    + {model.description} +

    + {isDownloading && progress ? ( +
    +
    +
    +
    +

    + {t("general.voiceInput.downloadProgress", { + percent: Math.round(progress.progressPercent), + })} +

    +
    + ) : null} + {progress?.status === "failed" && progress.error ? ( +

    + {progress.error} +

    + ) : null} +
    + +
    + {isDownloading ? ( + + ) : model.downloaded ? ( + <> + {!isSelected ? ( + + ) : null} + + + ) : ( + + )} +
    +
  • + ); + })} +
+ + {error ?

{error}

: null} +
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx index 65ab6b6aff76..03400ccef214 100644 --- a/ui/goose2/src/features/settings/ui/SettingsModal.tsx +++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx @@ -21,6 +21,7 @@ import { SelectValue, } from "@/shared/ui/select"; import { + Mic, Palette, Settings2, FolderKanban, @@ -34,6 +35,7 @@ import { AppearanceSettings } from "./AppearanceSettings"; import { DoctorSettings } from "./DoctorSettings"; import { ProvidersSettings } from "./ProvidersSettings"; import { ExtensionsSettings } from "@/features/extensions/ui/ExtensionsSettings"; +import { VoiceInputSettings } from "./VoiceInputSettings"; import { listArchivedProjects, restoreProject, @@ -50,6 +52,7 @@ const NAV_ITEMS = [ { id: "appearance", labelKey: "nav.appearance", icon: Palette }, { id: "providers", labelKey: "nav.providers", icon: IconPlug }, { id: "extensions", labelKey: "nav.extensions", icon: IconPuzzle }, + { id: "voice", labelKey: "nav.voice", icon: Mic }, { id: "general", labelKey: "nav.general", icon: Settings2 }, { id: "projects", labelKey: "nav.projects", icon: FolderKanban }, { id: "chats", labelKey: "nav.chats", icon: MessageSquare }, @@ -241,6 +244,7 @@ export function SettingsModal({ {activeSection === "appearance" && } {activeSection === "providers" && } {activeSection === "extensions" && } + {activeSection === "voice" && } {activeSection === "doctor" && } {activeSection === "general" && (
diff --git a/ui/goose2/src/features/settings/ui/VoiceInputSettings.tsx b/ui/goose2/src/features/settings/ui/VoiceInputSettings.tsx new file mode 100644 index 000000000000..3203608c1d8d --- /dev/null +++ b/ui/goose2/src/features/settings/ui/VoiceInputSettings.tsx @@ -0,0 +1,489 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + deleteDictationProviderSecret, + getDictationConfig, + saveDictationModelSelection, + saveDictationProviderSecret, +} from "@/shared/api/dictation"; +import { + notifyVoiceDictationConfigChanged, + getDefaultDictationProvider, +} from "@/features/chat/lib/voiceInput"; +import { useVoiceInputPreferences } from "@/features/chat/hooks/useVoiceInputPreferences"; +import type { + DictationProvider, + DictationProviderStatus, +} from "@/shared/types/dictation"; +import { useAudioDevices } from "@/shared/ui/ai-elements/mic-selector"; +import { Button } from "@/shared/ui/button"; +import { LocalWhisperModels } from "./LocalWhisperModels"; +import { Input } from "@/shared/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui/select"; + +const DISABLED_PROVIDER = "__disabled__"; + +export function VoiceInputSettings() { + const { t } = useTranslation(["settings", "chat", "common"]); + const { + clearSelectedProvider, + hasStoredProviderPreference, + isHydrated: voicePrefsHydrated, + preferredMicrophoneId, + rawAutoSubmitPhrases, + selectedProvider, + setPreferredMicrophoneId, + setRawAutoSubmitPhrases, + setSelectedProvider, + } = useVoiceInputPreferences(); + const [providerStatuses, setProviderStatuses] = useState< + Record + >({} as Record); + const [apiKeyInput, setApiKeyInput] = useState(""); + const [isEditingApiKey, setIsEditingApiKey] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { + devices, + error: devicesError, + hasPermission, + loadDevices, + loading: loadingDevices, + } = useAudioDevices(); + const isMicrophoneSupported = + typeof navigator !== "undefined" && !!navigator.mediaDevices; + const permissionStatus = hasPermission ? "authorized" : "not_determined"; + const requestPermission = loadDevices; + + const refreshConfig = useCallback(async () => { + const nextConfig = await getDictationConfig(); + setProviderStatuses(nextConfig); + + // Wait for useVoiceInputPreferences to finish loading the stored value + // from goose config before deciding whether to auto-select a default. + // Otherwise the initial mount sees hasStoredProviderPreference=false + // (pre-hydration default) and clobbers the user's saved choice. + if (!voicePrefsHydrated) { + return; + } + + if (!hasStoredProviderPreference) { + const defaultProvider = getDefaultDictationProvider(nextConfig); + if (defaultProvider) { + setSelectedProvider(defaultProvider); + } + return; + } + + if (!selectedProvider) { + return; + } + + // The stored provider is no longer in the fetched config (e.g. it was + // feature-flagged off or removed). Clear the preference entirely rather + // than writing `null`, which would persist the explicit "voice off" + // sentinel and leave the user opted out across future sessions even + // after valid providers reappear. + if (!nextConfig[selectedProvider]) { + clearSelectedProvider(); + } + }, [ + clearSelectedProvider, + hasStoredProviderPreference, + selectedProvider, + setSelectedProvider, + voicePrefsHydrated, + ]); + + useEffect(() => { + const load = async () => { + setLoading(true); + setError(null); + + try { + await refreshConfig(); + } catch (caughtError) { + setError( + caughtError instanceof Error + ? caughtError.message + : t("general.voiceInput.loadError"), + ); + } finally { + setLoading(false); + } + }; + + void load(); + }, [refreshConfig, t]); + + const selectedStatus = selectedProvider + ? providerStatuses[selectedProvider] + : null; + + const providerOptions = useMemo( + () => + Object.entries(providerStatuses) as Array< + [DictationProvider, DictationProviderStatus] + >, + [providerStatuses], + ); + + const currentModelValue = + selectedStatus?.selectedModel ?? selectedStatus?.defaultModel ?? ""; + + const saveApiKey = useCallback(async () => { + if (!selectedProvider) { + return; + } + + setError(null); + try { + await saveDictationProviderSecret( + selectedProvider, + apiKeyInput, + selectedStatus?.configKey ?? undefined, + ); + setApiKeyInput(""); + setIsEditingApiKey(false); + await refreshConfig(); + notifyVoiceDictationConfigChanged(); + } catch (caughtError) { + setError( + caughtError instanceof Error + ? caughtError.message + : t("general.voiceInput.saveError"), + ); + } + }, [apiKeyInput, refreshConfig, selectedProvider, selectedStatus, t]); + + const removeApiKey = useCallback(async () => { + if (!selectedProvider) { + return; + } + + setError(null); + try { + await deleteDictationProviderSecret( + selectedProvider, + selectedStatus?.configKey ?? undefined, + ); + setApiKeyInput(""); + setIsEditingApiKey(false); + await refreshConfig(); + notifyVoiceDictationConfigChanged(); + } catch (caughtError) { + setError( + caughtError instanceof Error + ? caughtError.message + : t("general.voiceInput.deleteError"), + ); + } + }, [refreshConfig, selectedProvider, selectedStatus, t]); + + const handleModelChange = useCallback( + async (modelId: string) => { + if (!selectedProvider) { + return; + } + + setError(null); + try { + await saveDictationModelSelection(selectedProvider, modelId); + await refreshConfig(); + notifyVoiceDictationConfigChanged(); + } catch (caughtError) { + setError( + caughtError instanceof Error + ? caughtError.message + : t("general.voiceInput.saveError"), + ); + } + }, + [refreshConfig, selectedProvider, t], + ); + + const selectedMicrophoneLabel = useMemo(() => { + if (!preferredMicrophoneId) { + return t("general.voiceInput.systemMicrophone"); + } + + return ( + devices.find((device) => device.deviceId === preferredMicrophoneId) + ?.label || t("general.voiceInput.systemMicrophone") + ); + }, [devices, preferredMicrophoneId, t]); + + if (loading) { + return ( +
+

+ {t("general.voiceInput.label")} +

+

+ {t("common:labels.loading")} +

+
+ ); + } + + return ( +
+
+

+ {t("general.voiceInput.label")} +

+

+ {t("general.voiceInput.description")} +

+
+ +
+

+ {t("general.voiceInput.providerLabel")} +

+ +
+ +
+
+
+

+ {t("general.voiceInput.microphoneLabel")} +

+

+ {isMicrophoneSupported + ? t("general.voiceInput.microphoneDescription") + : t("general.voiceInput.microphoneUnavailable")} +

+
+ {isMicrophoneSupported && !hasPermission ? ( + + ) : null} +
+ + {!devicesError && + !hasPermission && + permissionStatus === "not_determined" ? ( +

+ {t("general.voiceInput.microphoneAccessPrompt")} +

+ ) : null} + + {devicesError ? ( +

{devicesError}

+ ) : null} + + {isMicrophoneSupported && hasPermission ? ( + + ) : null} +
+ + {selectedStatus ? ( + <> + {!selectedStatus.usesProviderConfig && + selectedProvider !== "local" ? ( +
+ {isEditingApiKey ? ( + <> +
+

+ {t("general.voiceInput.apiKeyLabel")} +

+

+ {t("general.voiceInput.apiKeyDescription")} +

+
+
+ setApiKeyInput(event.target.value)} + placeholder={t("general.voiceInput.apiKeyPlaceholder")} + className="max-w-sm" + /> +
+ + +
+
+ + ) : ( +
+
+

+ {t("general.voiceInput.apiKeyLabel")} +

+

+ {selectedStatus.configured + ? t("general.voiceInput.apiKeyConfigured") + : t("general.voiceInput.apiKeyDescription")} +

+
+
+ + {selectedStatus.configured ? ( + + ) : null} +
+
+ )} +
+ ) : null} + + {selectedProvider === "local" ? ( + handleModelChange(modelId)} + onModelsChanged={async () => { + await refreshConfig(); + notifyVoiceDictationConfigChanged(); + }} + /> + ) : (selectedStatus.availableModels ?? []).length > 0 ? ( +
+

+ {t("general.voiceInput.modelLabel")} +

+ +

+ {(selectedStatus.availableModels ?? []).find( + (model) => model.id === currentModelValue, + )?.description ?? ""} +

+
+ ) : null} + + ) : null} + +
+ +

+ {t("general.voiceInput.autoSubmitDescription")} +

+ setRawAutoSubmitPhrases(event.target.value)} + placeholder={t("general.voiceInput.placeholder")} + className="max-w-sm" + /> +
+ + {error ?

{error}

: null} +
+ ); +} diff --git a/ui/goose2/src/features/settings/ui/__tests__/LocalWhisperModels.test.tsx b/ui/goose2/src/features/settings/ui/__tests__/LocalWhisperModels.test.tsx new file mode 100644 index 000000000000..b79c34577351 --- /dev/null +++ b/ui/goose2/src/features/settings/ui/__tests__/LocalWhisperModels.test.tsx @@ -0,0 +1,106 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LocalWhisperModels } from "../LocalWhisperModels"; + +const mockListDictationLocalModels = vi.fn(); +const mockDownloadDictationLocalModel = vi.fn(); +const mockGetDictationLocalModelDownloadProgress = vi.fn(); +const mockCancelDictationLocalModelDownload = vi.fn(); +const mockDeleteDictationLocalModel = vi.fn(); + +vi.mock("@/shared/api/dictation", () => ({ + listDictationLocalModels: (...args: unknown[]) => + mockListDictationLocalModels(...args), + downloadDictationLocalModel: (...args: unknown[]) => + mockDownloadDictationLocalModel(...args), + getDictationLocalModelDownloadProgress: (...args: unknown[]) => + mockGetDictationLocalModelDownloadProgress(...args), + cancelDictationLocalModelDownload: (...args: unknown[]) => + mockCancelDictationLocalModelDownload(...args), + deleteDictationLocalModel: (...args: unknown[]) => + mockDeleteDictationLocalModel(...args), +})); + +describe("LocalWhisperModels", () => { + beforeEach(() => { + mockListDictationLocalModels.mockReset(); + mockDownloadDictationLocalModel.mockReset(); + mockGetDictationLocalModelDownloadProgress.mockReset(); + mockCancelDictationLocalModelDownload.mockReset(); + mockDeleteDictationLocalModel.mockReset(); + }); + + it("clears cached progress when cancelling a download", async () => { + const user = userEvent.setup(); + const onModelsChanged = vi.fn(); + + mockListDictationLocalModels + .mockResolvedValueOnce([ + { + id: "tiny", + description: "Tiny model", + sizeMb: 75, + downloaded: false, + downloadInProgress: true, + }, + ]) + .mockResolvedValueOnce([ + { + id: "tiny", + description: "Tiny model", + sizeMb: 75, + downloaded: false, + downloadInProgress: false, + }, + ]); + mockGetDictationLocalModelDownloadProgress.mockResolvedValue({ + bytesDownloaded: 100, + totalBytes: 1000, + progressPercent: 10, + status: "downloading", + error: null, + }); + mockCancelDictationLocalModelDownload.mockResolvedValue(undefined); + + render( + , + ); + + await waitFor(() => + expect( + screen.getByRole("button", { name: /cancel/i }), + ).toBeInTheDocument(), + ); + + await waitFor( + () => + expect(mockGetDictationLocalModelDownloadProgress).toHaveBeenCalledWith( + "tiny", + ), + { timeout: 2000 }, + ); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + + await waitFor(() => + expect(mockCancelDictationLocalModelDownload).toHaveBeenCalledWith( + "tiny", + ), + ); + await waitFor(() => + expect( + screen.getByRole("button", { name: /download/i }), + ).toBeInTheDocument(), + ); + + expect( + screen.queryByRole("button", { name: /cancel/i }), + ).not.toBeInTheDocument(); + expect(onModelsChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/goose2/src/shared/api/__tests__/dictation.test.ts b/ui/goose2/src/shared/api/__tests__/dictation.test.ts new file mode 100644 index 000000000000..79831ca7cc92 --- /dev/null +++ b/ui/goose2/src/shared/api/__tests__/dictation.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + cancelDictationLocalModelDownload, + deleteDictationLocalModel, + downloadDictationLocalModel, + getDictationConfig, + getDictationLocalModelDownloadProgress, + listDictationLocalModels, + saveDictationModelSelection, + transcribeDictation, +} from "../dictation"; +import { getClient } from "../acpConnection"; + +vi.mock("../acpConnection", () => ({ + getClient: vi.fn(), +})); + +describe("dictation SDK wiring", () => { + let client: any; + beforeEach(() => { + client = { + goose: { + GooseDictationConfig: vi.fn().mockResolvedValue({ + providers: { + openai: { + configured: true, + description: "OpenAI transcription", + usesProviderConfig: true, + availableModels: [], + }, + }, + }), + GooseDictationTranscribe: vi.fn().mockResolvedValue({ text: "hello" }), + }, + }; + vi.mocked(getClient).mockResolvedValue(client); + }); + + it("getDictationConfig calls GooseDictationConfig and returns providers map", async () => { + const result = await getDictationConfig(); + expect(client.goose.GooseDictationConfig).toHaveBeenCalledWith({}); + expect(result.openai.configured).toBe(true); + }); + + it("transcribeDictation forwards audio + mimeType + provider", async () => { + const result = await transcribeDictation({ + audio: "base64==", + mimeType: "audio/webm", + provider: "openai" as any, + }); + expect(client.goose.GooseDictationTranscribe).toHaveBeenCalledWith({ + audio: "base64==", + mimeType: "audio/webm", + provider: "openai", + }); + expect(result.text).toBe("hello"); + }); + + it("saveDictationModelSelection calls GooseDictationModelSelect", async () => { + client.goose.GooseDictationModelSelect = vi.fn().mockResolvedValue({}); + await saveDictationModelSelection("local" as any, "tiny"); + expect(client.goose.GooseDictationModelSelect).toHaveBeenCalledWith({ + provider: "local", + modelId: "tiny", + }); + }); + + it("listDictationLocalModels returns the models array", async () => { + client.goose.GooseDictationModelsList = vi.fn().mockResolvedValue({ + models: [ + { + id: "tiny", + description: "Tiny", + sizeMb: 75, + downloaded: true, + downloadInProgress: false, + }, + ], + }); + const result = await listDictationLocalModels(); + expect(client.goose.GooseDictationModelsList).toHaveBeenCalledWith({}); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("tiny"); + }); + + it("downloadDictationLocalModel forwards modelId", async () => { + client.goose.GooseDictationModelsDownload = vi.fn().mockResolvedValue({}); + await downloadDictationLocalModel("tiny"); + expect(client.goose.GooseDictationModelsDownload).toHaveBeenCalledWith({ + modelId: "tiny", + }); + }); + + it("getDictationLocalModelDownloadProgress returns progress or null", async () => { + client.goose.GooseDictationModelsDownloadProgress = vi + .fn() + .mockResolvedValue({ + progress: { + bytesDownloaded: 100, + totalBytes: 1000, + progressPercent: 10, + status: "downloading", + error: null, + }, + }); + const result = await getDictationLocalModelDownloadProgress("tiny"); + expect(result?.bytesDownloaded).toBe(100); + expect( + client.goose.GooseDictationModelsDownloadProgress, + ).toHaveBeenCalledWith({ + modelId: "tiny", + }); + }); + + it("getDictationLocalModelDownloadProgress returns null when no download", async () => { + client.goose.GooseDictationModelsDownloadProgress = vi + .fn() + .mockResolvedValue({ + progress: undefined, + }); + const result = await getDictationLocalModelDownloadProgress("tiny"); + expect(result).toBeNull(); + }); + + it("cancelDictationLocalModelDownload forwards modelId", async () => { + client.goose.GooseDictationModelsCancel = vi.fn().mockResolvedValue({}); + await cancelDictationLocalModelDownload("tiny"); + expect(client.goose.GooseDictationModelsCancel).toHaveBeenCalledWith({ + modelId: "tiny", + }); + }); + + it("deleteDictationLocalModel forwards modelId", async () => { + client.goose.GooseDictationModelsDelete = vi.fn().mockResolvedValue({}); + await deleteDictationLocalModel("tiny"); + expect(client.goose.GooseDictationModelsDelete).toHaveBeenCalledWith({ + modelId: "tiny", + }); + }); +}); diff --git a/ui/goose2/src/shared/api/dictation.ts b/ui/goose2/src/shared/api/dictation.ts new file mode 100644 index 000000000000..4c3b42c6e140 --- /dev/null +++ b/ui/goose2/src/shared/api/dictation.ts @@ -0,0 +1,106 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { + DictationDownloadProgress, + DictationProvider, + DictationProviderStatus, + DictationTranscribeResponse, + WhisperModelStatus, +} from "@/shared/types/dictation"; +import { getClient } from "./acpConnection"; + +export async function getDictationConfig(): Promise< + Record +> { + const client = await getClient(); + const response = await client.goose.GooseDictationConfig({}); + return response.providers as Record< + DictationProvider, + DictationProviderStatus + >; +} + +export async function transcribeDictation(request: { + audio: string; + mimeType: string; + provider: DictationProvider; +}): Promise { + const client = await getClient(); + return client.goose.GooseDictationTranscribe({ + audio: request.audio, + mimeType: request.mimeType, + provider: request.provider, + }); +} + +export async function saveDictationModelSelection( + provider: DictationProvider, + modelId: string, +): Promise { + const client = await getClient(); + await client.goose.GooseDictationModelSelect({ provider, modelId }); +} + +export async function saveDictationProviderSecret( + _provider: DictationProvider, + value: string, + configKey?: string, +): Promise { + if (!configKey) { + throw new Error("No config key for this provider"); + } + return invoke("save_provider_field", { key: configKey, value }); +} + +export async function deleteDictationProviderSecret( + provider: DictationProvider, + _configKey?: string, +): Promise { + const providerIdMap: Record = { + groq: "dictation_groq", + elevenlabs: "dictation_elevenlabs", + }; + const providerId = providerIdMap[provider]; + if (!providerId) { + throw new Error("Cannot delete secrets for this provider"); + } + return invoke("delete_provider_config", { providerId }); +} + +export async function listDictationLocalModels(): Promise< + WhisperModelStatus[] +> { + const client = await getClient(); + const response = await client.goose.GooseDictationModelsList({}); + return response.models as unknown as WhisperModelStatus[]; +} + +export async function downloadDictationLocalModel( + modelId: string, +): Promise { + const client = await getClient(); + await client.goose.GooseDictationModelsDownload({ modelId }); +} + +export async function getDictationLocalModelDownloadProgress( + modelId: string, +): Promise { + const client = await getClient(); + const response = await client.goose.GooseDictationModelsDownloadProgress({ + modelId, + }); + return (response.progress ?? null) as DictationDownloadProgress | null; +} + +export async function cancelDictationLocalModelDownload( + modelId: string, +): Promise { + const client = await getClient(); + await client.goose.GooseDictationModelsCancel({ modelId }); +} + +export async function deleteDictationLocalModel( + modelId: string, +): Promise { + const client = await getClient(); + await client.goose.GooseDictationModelsDelete({ modelId }); +} diff --git a/ui/goose2/src/shared/i18n/locales/en/chat.json b/ui/goose2/src/shared/i18n/locales/en/chat.json index efe6776e3d87..424007cc8c5c 100644 --- a/ui/goose2/src/shared/i18n/locales/en/chat.json +++ b/ui/goose2/src/shared/i18n/locales/en/chat.json @@ -169,7 +169,11 @@ "selectProject": "Select project", "sendMessage": "Send message", "stopGeneration": "Stop generation", - "voiceInputSoon": "Voice input (coming soon)" + "voiceInput": "Voice dictation", + "voiceInputDisabled": "Configure a voice provider in Settings to enable dictation", + "voiceInputRecording": "Listening...", + "voiceInputTranscribing": "Transcribing...", + "voiceInputAutoSubmitHint": "Say \"submit\" to send" }, "tools": { "fileNotFound": "File not found: {{path}}", diff --git a/ui/goose2/src/shared/i18n/locales/en/settings.json b/ui/goose2/src/shared/i18n/locales/en/settings.json index be55f4766a1d..d9733f3800b5 100644 --- a/ui/goose2/src/shared/i18n/locales/en/settings.json +++ b/ui/goose2/src/shared/i18n/locales/en/settings.json @@ -124,7 +124,49 @@ "spanish": "Spanish", "system": "System default ({{language}})" }, - "title": "General" + "title": "General", + "voiceInput": { + "label": "Voice Input", + "description": "Configure voice dictation for hands-free input.", + "providerLabel": "Transcription Provider", + "disabled": "Disabled", + "notConfiguredSuffix": "(not configured)", + "placeholder": "Select a provider", + "modelLabel": "Model", + "apiKeyLabel": "API Key", + "apiKeyDescription": "Enter your API key for this provider.", + "apiKeyPlaceholder": "sk-...", + "apiKeyConfigured": "API key configured", + "addApiKey": "Add API key", + "updateApiKey": "Update API key", + "removeApiKey": "Remove API key", + "localModelLabel": "Local Whisper Model", + "localModelDescription": "Download a Whisper model to run transcription locally. Selecting a model sets it as your active local transcription model.", + "noLocalModels": "No local Whisper models available.", + "download": "Download", + "selectModel": "Select", + "selectedModel": "Selected", + "deleteModel": "Delete", + "microphoneLabel": "Microphone", + "microphoneDescription": "Choose which microphone to use for voice input.", + "microphoneUnavailable": "Microphone access is not available in this environment.", + "microphoneAccessPrompt": "Click \"Grant access\" to allow microphone use.", + "grantMicrophone": "Grant access", + "systemMicrophone": "System default", + "unknownMicrophone": "Unknown microphone", + "autoSubmitLabel": "Auto-submit Phrases", + "autoSubmitDescription": "Comma-separated words that trigger automatic send (e.g. \"submit\").", + "providers": { + "openai": "OpenAI Whisper", + "groq": "Groq", + "elevenlabs": "ElevenLabs", + "local": "Local Whisper" + }, + "downloadProgress": "Downloading... {{percent}}%", + "loadError": "Failed to load voice settings.", + "saveError": "Failed to save.", + "deleteError": "Failed to delete." + } }, "nav": { "about": "About", @@ -134,7 +176,8 @@ "general": "General", "projects": "Projects", "extensions": "Extensions", - "providers": "Providers" + "providers": "Providers", + "voice": "Voice" }, "projects": { "description": "Manage your projects.", diff --git a/ui/goose2/src/shared/i18n/locales/es/chat.json b/ui/goose2/src/shared/i18n/locales/es/chat.json index 3a5760189e23..5bd93d8a560d 100644 --- a/ui/goose2/src/shared/i18n/locales/es/chat.json +++ b/ui/goose2/src/shared/i18n/locales/es/chat.json @@ -169,7 +169,11 @@ "selectProject": "Seleccionar proyecto", "sendMessage": "Enviar mensaje", "stopGeneration": "Detener generación", - "voiceInputSoon": "Entrada de voz (pronto)" + "voiceInput": "Dictado por voz", + "voiceInputDisabled": "Configura un proveedor de voz en Ajustes para activar el dictado", + "voiceInputRecording": "Escuchando...", + "voiceInputTranscribing": "Transcribiendo...", + "voiceInputAutoSubmitHint": "Di \"enviar\" para enviar" }, "tools": { "fileNotFound": "Archivo no encontrado: {{path}}", diff --git a/ui/goose2/src/shared/i18n/locales/es/settings.json b/ui/goose2/src/shared/i18n/locales/es/settings.json index 8b2b85236ece..16e33a960aa6 100644 --- a/ui/goose2/src/shared/i18n/locales/es/settings.json +++ b/ui/goose2/src/shared/i18n/locales/es/settings.json @@ -124,7 +124,49 @@ "spanish": "Español", "system": "Predeterminado del sistema ({{language}})" }, - "title": "General" + "title": "General", + "voiceInput": { + "label": "Entrada de voz", + "description": "Configura el dictado por voz para entrada manos libres.", + "providerLabel": "Proveedor de transcripción", + "disabled": "Desactivado", + "notConfiguredSuffix": "(no configurado)", + "placeholder": "Selecciona un proveedor", + "modelLabel": "Modelo", + "apiKeyLabel": "Clave API", + "apiKeyDescription": "Ingresa tu clave API para este proveedor.", + "apiKeyPlaceholder": "sk-...", + "apiKeyConfigured": "Clave API configurada", + "addApiKey": "Agregar clave API", + "updateApiKey": "Actualizar clave API", + "removeApiKey": "Eliminar clave API", + "localModelLabel": "Modelo Whisper local", + "localModelDescription": "Descarga un modelo Whisper para transcribir localmente. Seleccionar un modelo lo establece como tu modelo de transcripción local activo.", + "noLocalModels": "No hay modelos Whisper locales disponibles.", + "download": "Descargar", + "selectModel": "Seleccionar", + "selectedModel": "Seleccionado", + "deleteModel": "Eliminar", + "microphoneLabel": "Micrófono", + "microphoneDescription": "Elige qué micrófono usar para la entrada de voz.", + "microphoneUnavailable": "El acceso al micrófono no está disponible en este entorno.", + "microphoneAccessPrompt": "Haz clic en \"Permitir acceso\" para usar el micrófono.", + "grantMicrophone": "Permitir acceso", + "systemMicrophone": "Predeterminado del sistema", + "unknownMicrophone": "Micrófono desconocido", + "autoSubmitLabel": "Frases de envío automático", + "autoSubmitDescription": "Palabras separadas por coma que activan el envío automático (ej. \"enviar\").", + "providers": { + "openai": "OpenAI Whisper", + "groq": "Groq", + "elevenlabs": "ElevenLabs", + "local": "Whisper local" + }, + "downloadProgress": "Descargando... {{percent}}%", + "loadError": "Error al cargar ajustes de voz.", + "saveError": "Error al guardar.", + "deleteError": "Error al eliminar." + } }, "nav": { "about": "Acerca de", @@ -134,7 +176,8 @@ "general": "General", "projects": "Proyectos", "extensions": "Extensiones", - "providers": "Proveedores" + "providers": "Proveedores", + "voice": "Voz" }, "projects": { "description": "Administra tus proyectos.", diff --git a/ui/goose2/src/shared/types/dictation.ts b/ui/goose2/src/shared/types/dictation.ts new file mode 100644 index 000000000000..f27593506772 --- /dev/null +++ b/ui/goose2/src/shared/types/dictation.ts @@ -0,0 +1,47 @@ +export type DictationProvider = "openai" | "groq" | "elevenlabs" | "local"; + +export interface DictationModelOption { + id: string; + label: string; + description: string; +} + +export interface DictationProviderStatus { + configured: boolean; + host?: string | null; + description: string; + usesProviderConfig: boolean; + settingsPath?: string | null; + configKey?: string | null; + modelConfigKey?: string | null; + defaultModel?: string | null; + selectedModel?: string | null; + availableModels: DictationModelOption[]; +} + +export interface DictationTranscribeResponse { + text: string; +} + +export type MicrophonePermissionStatus = + | "not_determined" + | "authorized" + | "denied" + | "restricted" + | "unsupported"; + +export interface WhisperModelStatus { + id: string; + sizeMb: number; + description: string; + downloaded: boolean; + downloadInProgress: boolean; +} + +export interface DictationDownloadProgress { + bytesDownloaded: number; + totalBytes: number; + progressPercent: number; + status: string; + error?: string | null; +} diff --git a/ui/goose2/src/shared/ui/ai-elements/mic-selector.tsx b/ui/goose2/src/shared/ui/ai-elements/mic-selector.tsx index 9e0369e135e3..0a05fc705b59 100644 --- a/ui/goose2/src/shared/ui/ai-elements/mic-selector.tsx +++ b/ui/goose2/src/shared/ui/ai-elements/mic-selector.tsx @@ -74,10 +74,6 @@ export const useAudioDevices = () => { }, []); const loadDevicesWithPermission = useCallback(async () => { - if (loading) { - return; - } - try { setLoading(true); setError(null); @@ -108,11 +104,57 @@ export const useAudioDevices = () => { } finally { setLoading(false); } - }, [loading]); + }, []); useEffect(() => { - loadDevicesWithoutPermission(); - }, [loadDevicesWithoutPermission]); + let cancelled = false; + let status: PermissionStatus | null = null; + const onChange = () => { + if (cancelled || !status) return; + const granted = status.state === "granted"; + setHasPermission(granted); + // When permission flips to granted mid-session (e.g. the user enabled + // mic access via OS settings), re-enumerate devices so we pick up the + // real deviceIds/labels — the prior enumeration may have returned + // empty-string entries that VoiceInputSettings filters out. + if (granted) { + void loadDevicesWithPermission(); + } + }; + + const init = async () => { + let alreadyGranted = false; + try { + status = await navigator.permissions.query({ + name: "microphone" as PermissionName, + }); + if (cancelled) return; + alreadyGranted = status.state === "granted"; + setHasPermission(alreadyGranted); + status.addEventListener("change", onChange); + } catch { + // Permissions API not available for microphone; fall back silently. + } + if (cancelled) return; + // If OS-level permission is already granted, enumerate through the + // permission-ful path — otherwise enumerateDevices() may return + // entries with empty deviceId/label, which Radix Select rejects. + if (alreadyGranted) { + await loadDevicesWithPermission(); + } else { + await loadDevicesWithoutPermission(); + } + }; + + void init(); + + return () => { + cancelled = true; + if (status) { + status.removeEventListener("change", onChange); + } + }; + }, [loadDevicesWithPermission, loadDevicesWithoutPermission]); useEffect(() => { const handleDeviceChange = () => { diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index 3bd1e0d13f89..a1eeeee569d3 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -13,6 +13,18 @@ import type { CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, + DictationConfigRequest, + DictationConfigResponse, + DictationModelCancelRequest, + DictationModelDeleteRequest, + DictationModelDownloadProgressRequest, + DictationModelDownloadProgressResponse, + DictationModelDownloadRequest, + DictationModelSelectRequest, + DictationModelsListRequest, + DictationModelsListResponse, + DictationTranscribeRequest, + DictationTranscribeResponse, ExportSessionRequest, ExportSessionResponse, GetExtensionsRequest, @@ -43,6 +55,10 @@ import type { } from './types.gen.js'; import { zCheckSecretResponse, + zDictationConfigResponse, + zDictationModelDownloadProgressResponse, + zDictationModelsListResponse, + zDictationTranscribeResponse, zExportSessionResponse, zGetExtensionsResponse, zGetProviderDetailsResponse, @@ -174,4 +190,71 @@ export class GooseExtClient { async GooseSessionUnarchive(params: UnarchiveSessionRequest): Promise { await this.conn.extMethod("_goose/session/unarchive", params); } + + async GooseDictationTranscribe( + params: DictationTranscribeRequest, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/dictation/transcribe", + params, + ); + return zDictationTranscribeResponse.parse( + raw, + ) as DictationTranscribeResponse; + } + + async GooseDictationConfig( + params: DictationConfigRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/dictation/config", params); + return zDictationConfigResponse.parse(raw) as DictationConfigResponse; + } + + async GooseDictationModelsList( + params: DictationModelsListRequest, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/dictation/models/list", + params, + ); + return zDictationModelsListResponse.parse( + raw, + ) as DictationModelsListResponse; + } + + async GooseDictationModelsDownload( + params: DictationModelDownloadRequest, + ): Promise { + await this.conn.extMethod("_goose/dictation/models/download", params); + } + + async GooseDictationModelsDownloadProgress( + params: DictationModelDownloadProgressRequest, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/dictation/models/download/progress", + params, + ); + return zDictationModelDownloadProgressResponse.parse( + raw, + ) as DictationModelDownloadProgressResponse; + } + + async GooseDictationModelsCancel( + params: DictationModelCancelRequest, + ): Promise { + await this.conn.extMethod("_goose/dictation/models/cancel", params); + } + + async GooseDictationModelsDelete( + params: DictationModelDeleteRequest, + ): Promise { + await this.conn.extMethod("_goose/dictation/models/delete", params); + } + + async GooseDictationModelSelect( + params: DictationModelSelectRequest, + ): Promise { + await this.conn.extMethod("_goose/dictation/model/select", params); + } } diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index aa103a439a31..d1886b07d767 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderModelsRequest, GetProviderModelsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; +export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderModelsRequest, GetProviderModelsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -108,6 +108,46 @@ export const GOOSE_EXT_METHODS = [ requestType: "UnarchiveSessionRequest", responseType: "EmptyResponse", }, + { + method: "_goose/dictation/transcribe", + requestType: "DictationTranscribeRequest", + responseType: "DictationTranscribeResponse", + }, + { + method: "_goose/dictation/config", + requestType: "DictationConfigRequest", + responseType: "DictationConfigResponse", + }, + { + method: "_goose/dictation/models/list", + requestType: "DictationModelsListRequest", + responseType: "DictationModelsListResponse", + }, + { + method: "_goose/dictation/models/download", + requestType: "DictationModelDownloadRequest", + responseType: "EmptyResponse", + }, + { + method: "_goose/dictation/models/download/progress", + requestType: "DictationModelDownloadProgressRequest", + responseType: "DictationModelDownloadProgressResponse", + }, + { + method: "_goose/dictation/models/cancel", + requestType: "DictationModelCancelRequest", + responseType: "EmptyResponse", + }, + { + method: "_goose/dictation/models/delete", + requestType: "DictationModelDeleteRequest", + responseType: "EmptyResponse", + }, + { + method: "_goose/dictation/model/select", + requestType: "DictationModelSelectRequest", + responseType: "EmptyResponse", + }, ] as const; export type GooseExtMethod = (typeof GOOSE_EXT_METHODS)[number]; diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index e27160830133..15cf78ea75a7 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -281,17 +281,154 @@ export type UnarchiveSessionRequest = { sessionId: string; }; +/** + * Transcribe audio via a dictation provider. + */ +export type DictationTranscribeRequest = { + /** + * Base64-encoded audio data + */ + audio: string; + /** + * MIME type (e.g. "audio/wav", "audio/webm") + */ + mimeType: string; + /** + * Provider to use: "openai", "groq", "elevenlabs", or "local" + */ + provider: string; +}; + +/** + * Transcription result. + */ +export type DictationTranscribeResponse = { + text: string; +}; + +/** + * Get the configuration status of all dictation providers. + */ +export type DictationConfigRequest = { + [key: string]: unknown; +}; + +/** + * Dictation config response — map of provider name to status. + */ +export type DictationConfigResponse = { + providers: { + [key: string]: DictationProviderStatusEntry; + }; +}; + +/** + * Per-provider configuration status. + */ +export type DictationProviderStatusEntry = { + configured: boolean; + host?: string | null; + description: string; + usesProviderConfig: boolean; + settingsPath?: string | null; + configKey?: string | null; + modelConfigKey?: string | null; + defaultModel?: string | null; + selectedModel?: string | null; + availableModels?: Array; +}; + +export type DictationModelOption = { + id: string; + label: string; + description: string; +}; + +/** + * List available local Whisper models with their download status. + */ +export type DictationModelsListRequest = { + [key: string]: unknown; +}; + +export type DictationModelsListResponse = { + models: Array; +}; + +export type DictationLocalModelStatus = { + id: string; + label: string; + description: string; + sizeMb: number; + downloaded: boolean; + downloadInProgress: boolean; +}; + +/** + * Kick off a background download of a local Whisper model. + */ +export type DictationModelDownloadRequest = { + modelId: string; +}; + +/** + * Poll the progress of an in-flight download. + */ +export type DictationModelDownloadProgressRequest = { + modelId: string; +}; + +export type DictationModelDownloadProgressResponse = { + /** + * None when no download is active for this model id. + */ + progress?: DictationDownloadProgress | null; +}; + +export type DictationDownloadProgress = { + bytesDownloaded: number; + totalBytes: number; + progressPercent: number; + /** + * serde lowercase of DownloadStatus: "downloading" | "completed" | "failed" | "cancelled" + */ + status: string; + error?: string | null; +}; + +/** + * Cancel an in-flight download. + */ +export type DictationModelCancelRequest = { + modelId: string; +}; + +/** + * Delete a downloaded local Whisper model from disk. + */ +export type DictationModelDeleteRequest = { + modelId: string; +}; + +/** + * Persist the user's model selection for a given provider. + */ +export type DictationModelSelectRequest = { + provider: string; + modelId: string; +}; + export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderModelsRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | { + params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderModelsRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { [key: string]: unknown; } | null; }; export type ExtResponse = { id: string; - result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderModelsResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | unknown; + result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderModelsResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 1679d0ae1585..b48935fb2d5d 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -271,6 +271,146 @@ export const zUnarchiveSessionRequest = z.object({ sessionId: z.string() }); +/** + * Transcribe audio via a dictation provider. + */ +export const zDictationTranscribeRequest = z.object({ + audio: z.string(), + mimeType: z.string(), + provider: z.string() +}); + +/** + * Transcription result. + */ +export const zDictationTranscribeResponse = z.object({ + text: z.string() +}); + +/** + * Get the configuration status of all dictation providers. + */ +export const zDictationConfigRequest = z.record(z.unknown()); + +export const zDictationModelOption = z.object({ + id: z.string(), + label: z.string(), + description: z.string() +}); + +/** + * Per-provider configuration status. + */ +export const zDictationProviderStatusEntry = z.object({ + configured: z.boolean(), + host: z.union([ + z.string(), + z.null() + ]).optional(), + description: z.string(), + usesProviderConfig: z.boolean(), + settingsPath: z.union([ + z.string(), + z.null() + ]).optional(), + configKey: z.union([ + z.string(), + z.null() + ]).optional(), + modelConfigKey: z.union([ + z.string(), + z.null() + ]).optional(), + defaultModel: z.union([ + z.string(), + z.null() + ]).optional(), + selectedModel: z.union([ + z.string(), + z.null() + ]).optional(), + availableModels: z.array(zDictationModelOption).optional().default([]) +}); + +/** + * Dictation config response — map of provider name to status. + */ +export const zDictationConfigResponse = z.object({ + providers: z.record(zDictationProviderStatusEntry) +}); + +/** + * List available local Whisper models with their download status. + */ +export const zDictationModelsListRequest = z.record(z.unknown()); + +export const zDictationLocalModelStatus = z.object({ + id: z.string(), + label: z.string(), + description: z.string(), + sizeMb: z.number().int().gte(0), + downloaded: z.boolean(), + downloadInProgress: z.boolean() +}); + +export const zDictationModelsListResponse = z.object({ + models: z.array(zDictationLocalModelStatus) +}); + +/** + * Kick off a background download of a local Whisper model. + */ +export const zDictationModelDownloadRequest = z.object({ + modelId: z.string() +}); + +/** + * Poll the progress of an in-flight download. + */ +export const zDictationModelDownloadProgressRequest = z.object({ + modelId: z.string() +}); + +export const zDictationDownloadProgress = z.object({ + bytesDownloaded: z.number().int().gte(0), + totalBytes: z.number().int().gte(0), + progressPercent: z.number(), + status: z.string(), + error: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zDictationModelDownloadProgressResponse = z.object({ + progress: z.union([ + zDictationDownloadProgress, + z.null() + ]).optional() +}); + +/** + * Cancel an in-flight download. + */ +export const zDictationModelCancelRequest = z.object({ + modelId: z.string() +}); + +/** + * Delete a downloaded local Whisper model from disk. + */ +export const zDictationModelDeleteRequest = z.object({ + modelId: z.string() +}); + +/** + * Persist the user's model selection for a given provider. + */ +export const zDictationModelSelectRequest = z.object({ + provider: z.string(), + modelId: z.string() +}); + export const zExtRequest = z.object({ id: z.string(), method: z.string(), @@ -296,7 +436,15 @@ export const zExtRequest = z.object({ zExportSessionRequest, zImportSessionRequest, zArchiveSessionRequest, - zUnarchiveSessionRequest + zUnarchiveSessionRequest, + zDictationTranscribeRequest, + zDictationConfigRequest, + zDictationModelsListRequest, + zDictationModelDownloadRequest, + zDictationModelDownloadProgressRequest, + zDictationModelCancelRequest, + zDictationModelDeleteRequest, + zDictationModelSelectRequest ]), z.union([ z.record(z.unknown()), @@ -321,7 +469,11 @@ export const zExtResponse = z.union([ zReadConfigResponse, zCheckSecretResponse, zExportSessionResponse, - zImportSessionResponse + zImportSessionResponse, + zDictationTranscribeResponse, + zDictationConfigResponse, + zDictationModelsListResponse, + zDictationModelDownloadProgressResponse ]), z.unknown() ]).optional() From 234dc3d501dfb1dc9bee1323f70903bcf9a2a6ec Mon Sep 17 00:00:00 2001 From: Angie Jones Date: Mon, 20 Apr 2026 16:27:13 -0400 Subject: [PATCH 17/81] Add health score badge to README (#8677) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fed529b2da4f..a150908f8516 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ _your native open source AI agent — desktop app, CLI, and API — for code, wo >Discord CI +

From 49526405d6bfd81887a7611124c74e8175839d43 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Mon, 20 Apr 2026 16:41:34 -0400 Subject: [PATCH 18/81] delete the goose2 migration plan prompt (#8678) --- .../acp-plus-migration-plan/00-overview.md | 103 ---- .../01-expose-goose-serve-url.md | 87 ---- .../02-add-acp-npm-dependencies.md | 115 ----- .../03-create-ts-acp-connection.md | 397 --------------- .../04-create-ts-notification-handler.md | 315 ------------ .../05-create-ts-session-manager.md | 465 ------------------ .../06-port-session-search.md | 357 -------------- .../07-rewire-shared-api-acp.md | 237 --------- .../08-rewire-hooks.md | 251 ---------- .../09-delete-rust-acp-code.md | 341 ------------- .../10-phase-b-future-native-migration.md | 305 ------------ 11 files changed, 2973 deletions(-) delete mode 100644 ui/goose2/acp-plus-migration-plan/00-overview.md delete mode 100644 ui/goose2/acp-plus-migration-plan/01-expose-goose-serve-url.md delete mode 100644 ui/goose2/acp-plus-migration-plan/02-add-acp-npm-dependencies.md delete mode 100644 ui/goose2/acp-plus-migration-plan/03-create-ts-acp-connection.md delete mode 100644 ui/goose2/acp-plus-migration-plan/04-create-ts-notification-handler.md delete mode 100644 ui/goose2/acp-plus-migration-plan/05-create-ts-session-manager.md delete mode 100644 ui/goose2/acp-plus-migration-plan/06-port-session-search.md delete mode 100644 ui/goose2/acp-plus-migration-plan/07-rewire-shared-api-acp.md delete mode 100644 ui/goose2/acp-plus-migration-plan/08-rewire-hooks.md delete mode 100644 ui/goose2/acp-plus-migration-plan/09-delete-rust-acp-code.md delete mode 100644 ui/goose2/acp-plus-migration-plan/10-phase-b-future-native-migration.md diff --git a/ui/goose2/acp-plus-migration-plan/00-overview.md b/ui/goose2/acp-plus-migration-plan/00-overview.md deleted file mode 100644 index c75b28ab16de..000000000000 --- a/ui/goose2/acp-plus-migration-plan/00-overview.md +++ /dev/null @@ -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:/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:/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:/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 | diff --git a/ui/goose2/acp-plus-migration-plan/01-expose-goose-serve-url.md b/ui/goose2/acp-plus-migration-plan/01-expose-goose-serve-url.md deleted file mode 100644 index 06ad245bf993..000000000000 --- a/ui/goose2/acp-plus-migration-plan/01-expose-goose-serve-url.md +++ /dev/null @@ -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:/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 { - 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`. diff --git a/ui/goose2/acp-plus-migration-plan/02-add-acp-npm-dependencies.md b/ui/goose2/acp-plus-migration-plan/02-add-acp-npm-dependencies.md deleted file mode 100644 index 9158bc99a5da..000000000000 --- a/ui/goose2/acp-plus-migration-plan/02-add-acp-npm-dependencies.md +++ /dev/null @@ -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` and `WritableStream`). 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`. diff --git a/ui/goose2/acp-plus-migration-plan/03-create-ts-acp-connection.md b/ui/goose2/acp-plus-migration-plan/03-create-ts-acp-connection.md deleted file mode 100644 index 179b09a56cc2..000000000000 --- a/ui/goose2/acp-plus-migration-plan/03-create-ts-acp-connection.md +++ /dev/null @@ -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, writable: WritableStream }`. 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 { - if (incoming.length > 0 || closed) return Promise.resolve(); - return new Promise((resolve) => waiters.push(resolve)); - } - - // Wait for the WebSocket to open before allowing writes. - const openPromise = new Promise((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({ - async pull(controller) { - await waitForMessage(); - while (incoming.length > 0) { - controller.enqueue(incoming.shift()!); - } - if (closed && incoming.length === 0) { - controller.close(); - } - }, - }); - - const writable = new WritableStream({ - 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>` 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; -} - -/** - * Register the notification handler. Called once during app initialization - * after the handler is created in Step 04. - */ -export function setNotificationHandler(handler: AcpNotificationHandler): void { - notificationHandler = handler; -} - -// Singleton state -let clientPromise: Promise | null = null; -let resolvedClient: GooseClient | null = null; - -/** - * Build the Client implementation that the ACP SDK calls back into. - * - * This handles two callback types: - * - 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 => { - const optionId = args.options?.[0]?.optionId ?? "approve"; - return { - outcome: { - type: "selected", - optionId, - }, - }; - }, - - sessionUpdate: async ( - notification: SessionNotification, - ): Promise => { - 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 { - // 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 { - 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. diff --git a/ui/goose2/acp-plus-migration-plan/04-create-ts-notification-handler.md b/ui/goose2/acp-plus-migration-plan/04-create-ts-notification-handler.md deleted file mode 100644 index c28368622a99..000000000000 --- a/ui/goose2/acp-plus-migration-plan/04-create-ts-notification-handler.md +++ /dev/null @@ -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` 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(); -``` - -### 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(); -``` - -## 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 = "\n"; - const closeTag = "\n"; - const startIdx = raw.indexOf(openTag); - if (startIdx >= 0) { - const innerStart = startIdx + openTag.length; - if (raw.substring(innerStart).endsWith(closeTag)) { - return raw.substring(innerStart, raw.length - closeTag.length); - } - } - return raw; -} -``` - -### 6. Done / Finalize - -When a streaming message completes (the `prompt()` call resolves), the session manager (Step 05) calls a finalize method: - -```typescript -export function finalizeMessage(localSessionId: string, messageId: string): void { - const store = useChatStore.getState(); - store.updateMessage(localSessionId, messageId, (message) => { - const content = message.content.map((block) => - block.type === "toolRequest" && block.status === "executing" - ? { ...block, status: "completed" as const } - : block, - ); - return { - ...message, - content, - metadata: { ...message.metadata, completionStatus: "completed" }, - }; - }); - store.setStreamingMessageId(localSessionId, null); - store.setChatState(localSessionId, "idle"); -} -``` - -## 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; -``` - -## 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>` 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. diff --git a/ui/goose2/acp-plus-migration-plan/05-create-ts-session-manager.md b/ui/goose2/acp-plus-migration-plan/05-create-ts-session-manager.md deleted file mode 100644 index e0c2f8c780ce..000000000000 --- a/ui/goose2/acp-plus-migration-plan/05-create-ts-session-manager.md +++ /dev/null @@ -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(); -``` - -### 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(); -``` - -## 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 { - 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 = {}; - 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 { - 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 { - 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 { - 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 { - 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 { - const client = await getClient(); - const result = await client.goose.gooseSessionExport({ sessionId }); - return result.data; -} - -export async function importSession(json: string): Promise { - const client = await getClient(); - return await client.goose.gooseSessionImport({ data: json }); -} - -export async function forkSession(sessionId: string): Promise { - const client = await getClient(); - const response = await client.unstable_forkSession({ - sessionId, - workingDir: defaultArtifactsWorkingDir(), - }); - return { - sessionId: response.sessionId, - title: (response.meta?.title as string) ?? null, - updatedAt: null, - messageCount: (response.meta?.messageCount as number) ?? 0, - }; -} -``` - -### 7. `setModel` - -```typescript -export async function setModel(localSessionId: string, modelId: string): Promise { - const client = await getClient(); - - 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 { - 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 [ - `\n${systemPrompt}\n`, - `\n${prompt}\n`, - ].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` 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) diff --git a/ui/goose2/acp-plus-migration-plan/06-port-session-search.md b/ui/goose2/acp-plus-migration-plan/06-port-session-search.md deleted file mode 100644 index 7af063fcd8d2..000000000000 --- a/ui/goose2/acp-plus-migration-plan/06-port-session-search.md +++ /dev/null @@ -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 { - const trimmed = query.trim(); - if (!trimmed) return []; - - const seen = new Set(); - const results: SessionSearchResult[] = []; - - for (const sessionId of sessionIds) { - if (seen.has(sessionId)) continue; - seen.add(sessionId); - - try { - const exported = await exportSession(sessionId); - const result = searchExportedSession(sessionId, exported, trimmed); - if (result) results.push(result); - } catch { - // Skip sessions that fail to export - } - } - - return results; -} -``` - -### 2. `searchExportedSession` - -```typescript -function searchExportedSession( - sessionId: string, - exportedJson: string, - query: string, -): SessionSearchResult | null { - let root: unknown; - try { - root = JSON.parse(exportedJson); - } catch { - return null; - } - - const obj = root as Record; - const conversation = obj.conversation ?? obj.messages; - if (!conversation) return null; - - const messages = extractMessages(conversation); - if (messages.length === 0) return null; - - let firstMatch: { messageId: string; role: string | null; snippet: string } | null = null; - let matchCount = 0; - - for (const message of messages) { - for (const text of message.searchableTexts) { - const occurrences = countOccurrences(text, query); - if (occurrences === 0) continue; - - matchCount += occurrences; - - if (!firstMatch) { - firstMatch = { - messageId: message.id, - role: message.role, - snippet: buildSnippet(text, query), - }; - } - } - } - - if (!firstMatch) return null; - - return { - sessionId, - snippet: firstMatch.snippet, - messageId: firstMatch.messageId, - messageRole: firstMatch.role as SessionSearchResult["messageRole"], - matchCount, - }; -} -``` - -### 3. `extractMessages` - -Recursively walks the JSON structure to find message objects: - -```typescript -interface ExportedMessage { - id: string; - role: string | null; - searchableTexts: string[]; -} - -function extractMessages(value: unknown): ExportedMessage[] { - const messages: ExportedMessage[] = []; - collectMessages(value, messages); - return messages; -} - -function collectMessages(value: unknown, messages: ExportedMessage[]): void { - if (Array.isArray(value)) { - for (const item of value) { - collectMessages(item, messages); - } - return; - } - - if (typeof value !== "object" || value === null) return; - const obj = value as Record; - - if (obj.message !== undefined) { - collectMessages(obj.message, messages); - return; - } - - if (obj.messages !== undefined) { - collectMessages(obj.messages, messages); - return; - } - - if (looksLikeMessage(obj)) { - const fallbackId = `message-${messages.length}`; - const message = extractMessage(obj, fallbackId); - if (message) messages.push(message); - } -} - -function looksLikeMessage(obj: Record): boolean { - return "role" in obj && ("content" in obj || "text" in obj); -} -``` - -### 4. `extractMessage` - -```typescript -function extractMessage( - obj: Record, - fallbackId: string, -): ExportedMessage | null { - const role = normalizeRole(obj.role as string | undefined); - const searchableTexts: string[] = []; - - if (obj.content !== undefined) { - searchableTexts.push(...extractSearchableTexts(obj.content, role)); - } else if (typeof obj.text === "string") { - if (role && obj.text.trim().length > 0) { - searchableTexts.push(obj.text.trim()); - } - } - - if (searchableTexts.length === 0) return null; - - return { - id: typeof obj.id === "string" ? obj.id : fallbackId, - role, - searchableTexts, - }; -} -``` - -### 5. `extractSearchableTexts` - -```typescript -function extractSearchableTexts(value: unknown, role: string | null): string[] { - if (typeof value === "string") { - if (role && isSearchableRole(role)) { - const trimmed = value.trim(); - return trimmed.length > 0 ? [trimmed] : []; - } - return []; - } - - if (Array.isArray(value)) { - return value.flatMap((item) => extractSearchableBlockText(item, role)); - } - - if (typeof value === "object" && value !== null) { - return extractSearchableBlockText(value, role); - } - - return []; -} - -function extractSearchableBlockText(value: unknown, role: string | null): string[] { - if (typeof value !== "object" || value === null) return []; - const obj = value as Record; - - const blockType = obj.type as string | undefined; - const text = obj.text as string | undefined; - - switch (blockType) { - case "text": - case "input_text": - case "output_text": - case "systemNotification": - case "system_notification": { - const trimmed = text?.trim(); - return trimmed && trimmed.length > 0 ? [trimmed] : []; - } - case "toolRequest": - case "toolResponse": - case "thinking": - case "redactedThinking": - case "reasoning": - case "image": - return []; - default: { - if (role && isSearchableRole(role)) { - const trimmed = text?.trim(); - return trimmed && trimmed.length > 0 ? [trimmed] : []; - } - return []; - } - } -} -``` - -### 6. Helper Functions - -```typescript -function normalizeRole(role: string | undefined): string | null { - if (!role) return null; - const trimmed = role.trim().toLowerCase(); - if (trimmed === "user") return "user"; - if (trimmed === "assistant") return "assistant"; - if (trimmed === "system") return "system"; - return null; -} - -function isSearchableRole(role: string): boolean { - return role === "user" || role === "assistant" || role === "system"; -} - -function countOccurrences(text: string, query: string): number { - const haystack = text.toLowerCase(); - const needle = query.toLowerCase(); - if (needle.length === 0) return 0; - - let count = 0; - let searchStart = 0; - - while (true) { - const index = haystack.indexOf(needle, searchStart); - if (index === -1) break; - count += 1; - searchStart = index + needle.length; - } - - return count; -} - -function buildSnippet(text: string, query: string): string { - const haystack = text.toLowerCase(); - const needle = query.toLowerCase(); - const matchIndex = haystack.indexOf(needle); - const effectiveMatchIndex = matchIndex >= 0 ? matchIndex : 0; - - const start = Math.max(0, effectiveMatchIndex - SNIPPET_PREFIX_BYTES); - const end = Math.min( - text.length, - effectiveMatchIndex + query.length + SNIPPET_SUFFIX_BYTES, - ); - - const prefix = start > 0 ? "..." : ""; - const suffix = end < text.length ? "..." : ""; - const body = text.substring(start, end).trim(); - - return `${prefix}${body}${suffix}`; -} -``` - -## Tests - -### `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. diff --git a/ui/goose2/acp-plus-migration-plan/07-rewire-shared-api-acp.md b/ui/goose2/acp-plus-migration-plan/07-rewire-shared-api-acp.md deleted file mode 100644 index d3a40801906b..000000000000 --- a/ui/goose2/acp-plus-migration-plan/07-rewire-shared-api-acp.md +++ /dev/null @@ -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 { - 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 { - 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 { - return sendPrompt(sessionId, providerId, prompt, { - workingDir: options.workingDir, - systemPrompt: options.systemPrompt, - personaId: options.personaId, - personaName: options.personaName, - images: options.images, - }); -} -``` - -#### `acpPrepareSession` - -```typescript -export async function acpPrepareSession( - sessionId: string, - providerId: string, - options: AcpPrepareSessionOptions = {}, -): Promise { - const { makeCompositeKey } = await import("./acpSessionManager"); - const compositeKey = makeCompositeKey(sessionId, options.personaId); - const workingDir = options.workingDir ?? "~/.goose/artifacts"; - await prepareSession(compositeKey, sessionId, providerId, workingDir); -} -``` - -#### `acpSetModel` - -```typescript -export async function acpSetModel( - sessionId: string, - modelId: string, -): Promise { - return setModel(sessionId, modelId); -} -``` - -#### `acpListSessions` - -```typescript -export async function acpListSessions(): Promise { - return listSessions(); -} -``` - -#### `acpSearchSessions` - -```typescript -export async function acpSearchSessions( - query: string, - sessionIds: string[], -): Promise { - return searchSessionsViaExports(query, sessionIds); -} -``` - -#### `acpLoadSession` - -```typescript -export async function acpLoadSession( - sessionId: string, - gooseSessionId: string, - workingDir?: string, -): Promise { - return loadSession(sessionId, gooseSessionId, workingDir ?? "~/.goose/artifacts"); -} -``` - -#### `acpExportSession` - -```typescript -export async function acpExportSession(sessionId: string): Promise { - return exportSession(sessionId); -} -``` - -#### `acpImportSession` - -```typescript -export async function acpImportSession(json: string): Promise { - return importSession(json); -} -``` - -#### `acpDuplicateSession` - -```typescript -export async function acpDuplicateSession(sessionId: string): Promise { - return forkSession(sessionId); -} -``` - -#### `acpCancelSession` - -```typescript -export async function acpCancelSession( - sessionId: string, - personaId?: string, -): Promise { - return cancelSession(sessionId, personaId); -} -``` - -### 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. diff --git a/ui/goose2/acp-plus-migration-plan/08-rewire-hooks.md b/ui/goose2/acp-plus-migration-plan/08-rewire-hooks.md deleted file mode 100644 index 64a7800094ba..000000000000 --- a/ui/goose2/acp-plus-migration-plan/08-rewire-hooks.md +++ /dev/null @@ -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. diff --git a/ui/goose2/acp-plus-migration-plan/09-delete-rust-acp-code.md b/ui/goose2/acp-plus-migration-plan/09-delete-rust-acp-code.md deleted file mode 100644 index a25809e8d77d..000000000000 --- a/ui/goose2/acp-plus-migration-plan/09-delete-rust-acp-code.md +++ /dev/null @@ -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 { - 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. diff --git a/ui/goose2/acp-plus-migration-plan/10-phase-b-future-native-migration.md b/ui/goose2/acp-plus-migration-plan/10-phase-b-future-native-migration.md deleted file mode 100644 index d23ab6153e2c..000000000000 --- a/ui/goose2/acp-plus-migration-plan/10-phase-b-future-native-migration.md +++ /dev/null @@ -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.()`. 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.()` 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.()` 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. From 3d582943fd159183aeae9cc9d116f14d6dc587d2 Mon Sep 17 00:00:00 2001 From: jh-block Date: Mon, 20 Apr 2026 23:02:26 +0200 Subject: [PATCH 19/81] Remove unused import (#8676) From 8eda6fdabc108b82700860e5099693687f8c0734 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 20 Apr 2026 15:00:17 -0700 Subject: [PATCH 20/81] overhaul provider inventory and agent/model selection (#8652) Signed-off-by: Bradley Axen --- crates/goose-acp/acp-meta.json | 11 +- crates/goose-acp/acp-schema.json | 237 ++++- crates/goose-acp/src/server.rs | 529 +++++++--- crates/goose-sdk/src/custom_requests.rs | 128 ++- crates/goose/src/agents/prompt_manager.rs | 5 + .../goose/src/config/declarative_providers.rs | 28 +- crates/goose/src/providers/acp_tooling.rs | 18 + crates/goose/src/providers/amp_acp.rs | 25 +- crates/goose/src/providers/anthropic.rs | 28 + crates/goose/src/providers/base.rs | 50 +- crates/goose/src/providers/claude_acp.rs | 14 + crates/goose/src/providers/codex_acp.rs | 14 + crates/goose/src/providers/copilot_acp.rs | 14 + crates/goose/src/providers/databricks.rs | 4 + .../goose/src/providers/formats/databricks.rs | 6 +- crates/goose/src/providers/init.rs | 4 + crates/goose/src/providers/inventory/mod.rs | 970 ++++++++++++++++++ crates/goose/src/providers/mod.rs | 4 +- crates/goose/src/providers/ollama.rs | 17 + crates/goose/src/providers/openai.rs | 53 + crates/goose/src/providers/pi_acp.rs | 15 + .../goose/src/providers/provider_registry.rs | 43 +- crates/goose/src/session/session_manager.rs | 7 +- crates/goose/tests/agent.rs | 3 + crates/goose/tests/compaction.rs | 1 + ui/desktop/openapi.json | 5 + ui/desktop/src/api/types.gen.ts | 4 + ui/goose2/scripts/check-file-sizes.mjs | 16 +- ui/goose2/scripts/reset-inventory.sh | 33 + ui/goose2/src/app/AppShell.tsx | 296 ++++-- ui/goose2/src/app/hooks/useAppStartup.ts | 84 ++ ui/goose2/src/app/ui/AppShellContent.tsx | 42 +- .../useAgentModelPickerState.test.ts | 125 +++ .../__tests__/useChat.attachments.test.ts | 2 - .../chat/hooks/__tests__/useChat.test.ts | 62 +- .../useChatSessionController.test.ts | 181 ++++ .../chat/hooks/useAgentModelPickerState.ts | 173 ++++ ui/goose2/src/features/chat/hooks/useChat.ts | 71 +- .../chat/hooks/useChatSessionController.ts | 682 ++++++++++++ .../src/features/chat/lib/newChat.test.ts | 202 +--- ui/goose2/src/features/chat/lib/newChat.ts | 35 +- .../chat/lib/sessionMetadataOverlay.ts | 92 +- .../stores/__tests__/chatSessionStore.test.ts | 379 +++---- .../features/chat/stores/chatSessionStore.ts | 336 ++---- ui/goose2/src/features/chat/types.ts | 56 + .../src/features/chat/ui/AgentModelPicker.tsx | 552 ++++++---- ui/goose2/src/features/chat/ui/ChatInput.tsx | 72 +- .../src/features/chat/ui/ChatInputToolbar.tsx | 39 +- ui/goose2/src/features/chat/ui/ChatView.tsx | 546 ++-------- .../ui/__tests__/AgentModelPicker.test.tsx | 46 +- .../chat/ui/__tests__/ChatInput.test.tsx | 16 +- .../src/features/home/ui/HomeScreen.test.tsx | 93 +- ui/goose2/src/features/home/ui/HomeScreen.tsx | 181 ++-- .../src/features/providers/api/inventory.ts | 32 + .../providers/hooks/useProviderInventory.ts | 83 ++ .../stores/providerInventoryStore.ts | 47 + .../sessions/ui/SessionHistoryView.tsx | 14 +- ui/goose2/src/features/sidebar/ui/Sidebar.tsx | 43 +- .../sidebar/ui/__tests__/Sidebar.test.tsx | 38 +- .../shared/api/__tests__/dictation.test.ts | 10 +- ui/goose2/src/shared/api/acp.ts | 57 +- .../src/shared/api/acpNotificationHandler.ts | 1 - ui/goose2/src/shared/api/acpSessionTracker.ts | 7 +- .../src/shared/i18n/locales/en/chat.json | 7 + .../src/shared/i18n/locales/es/chat.json | 7 + ui/goose2/tests/e2e/fixtures/tauri-mock.ts | 152 ++- ui/sdk/src/generated/client.gen.ts | 33 +- ui/sdk/src/generated/index.ts | 13 +- ui/sdk/src/generated/types.gen.ts | 128 ++- ui/sdk/src/generated/zod.gen.ts | 95 +- 70 files changed, 5307 insertions(+), 2109 deletions(-) create mode 100644 crates/goose/src/providers/acp_tooling.rs create mode 100644 crates/goose/src/providers/inventory/mod.rs create mode 100755 ui/goose2/scripts/reset-inventory.sh create mode 100644 ui/goose2/src/features/chat/hooks/__tests__/useAgentModelPickerState.test.ts create mode 100644 ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.test.ts create mode 100644 ui/goose2/src/features/chat/hooks/useAgentModelPickerState.ts create mode 100644 ui/goose2/src/features/chat/hooks/useChatSessionController.ts create mode 100644 ui/goose2/src/features/providers/api/inventory.ts create mode 100644 ui/goose2/src/features/providers/hooks/useProviderInventory.ts create mode 100644 ui/goose2/src/features/providers/stores/providerInventoryStore.ts diff --git a/crates/goose-acp/acp-meta.json b/crates/goose-acp/acp-meta.json index 75f28ef60a98..413707f6a638 100644 --- a/crates/goose-acp/acp-meta.json +++ b/crates/goose-acp/acp-meta.json @@ -51,9 +51,14 @@ "responseType": "GetProviderDetailsResponse" }, { - "method": "_goose/providers/models", - "requestType": "GetProviderModelsRequest", - "responseType": "GetProviderModelsResponse" + "method": "_goose/providers/inventory", + "requestType": "GetProviderInventoryRequest", + "responseType": "GetProviderInventoryResponse" + }, + { + "method": "_goose/providers/inventory/refresh", + "requestType": "RefreshProviderInventoryRequest", + "responseType": "RefreshProviderInventoryResponse" }, { "method": "_goose/config/read", diff --git a/crates/goose-acp/acp-schema.json b/crates/goose-acp/acp-schema.json index 821de4145e74..4ae0b23633f3 100644 --- a/crates/goose-acp/acp-schema.json +++ b/crates/goose-acp/acp-schema.json @@ -362,36 +362,224 @@ "contextLimit" ] }, - "GetProviderModelsRequest": { + "GetProviderInventoryRequest": { "type": "object", "properties": { - "providerName": { - "type": "string" + "providerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only return entries for these providers. Empty means all.", + "default": [] + } + }, + "description": "Read per-provider inventory. Always returns immediately from stored state.", + "x-side": "agent", + "x-method": "_goose/providers/inventory" + }, + "GetProviderInventoryResponse": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/$defs/ProviderInventoryEntryDto" + } } }, "required": [ - "providerName" + "entries" ], - "description": "Fetch the full list of models available for a specific provider.", + "description": "Provider inventory response.", "x-side": "agent", - "x-method": "_goose/providers/models" + "x-method": "_goose/providers/inventory" }, - "GetProviderModelsResponse": { + "ProviderInventoryEntryDto": { "type": "object", "properties": { + "providerId": { + "type": "string", + "description": "Provider identifier." + }, + "providerName": { + "type": "string", + "description": "Human-readable provider name." + }, + "configured": { + "type": "boolean", + "description": "Whether Goose has enough configuration to use this provider." + }, + "supportsRefresh": { + "type": "boolean", + "description": "Whether this provider supports background inventory refresh." + }, + "refreshing": { + "type": "boolean", + "description": "Whether a refresh is currently in flight." + }, "models": { + "type": "array", + "items": { + "$ref": "#/$defs/ProviderInventoryModelDto" + }, + "description": "The list of available models." + }, + "lastUpdatedAt": { + "type": [ + "string", + "null" + ], + "description": "When this entry was last successfully refreshed (ISO 8601)." + }, + "lastRefreshAttemptAt": { + "type": [ + "string", + "null" + ], + "description": "When a refresh was most recently attempted (ISO 8601)." + }, + "lastRefreshError": { + "type": [ + "string", + "null" + ], + "description": "The last refresh failure message, if any." + }, + "stale": { + "type": "boolean", + "description": "Whether we believe this data may be outdated." + }, + "modelSelectionHint": { + "type": [ + "string", + "null" + ], + "description": "Guidance message shown when this provider manages its own model selection externally." + } + }, + "required": [ + "providerId", + "providerName", + "configured", + "supportsRefresh", + "refreshing", + "models", + "stale" + ], + "description": "Provider inventory entry." + }, + "ProviderInventoryModelDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Model identifier as the provider knows it." + }, + "name": { + "type": "string", + "description": "Human-readable display name." + }, + "family": { + "type": [ + "string", + "null" + ], + "description": "Model family for grouping in UI." + }, + "contextLimit": { + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0, + "description": "Context window size in tokens." + }, + "reasoning": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the model supports reasoning/extended thinking." + }, + "recommended": { + "type": "boolean", + "description": "Whether this model should appear in the compact recommended picker.", + "default": false + } + }, + "required": [ + "id", + "name" + ], + "description": "A single model in provider inventory." + }, + "RefreshProviderInventoryRequest": { + "type": "object", + "properties": { + "providerIds": { "type": "array", "items": { "type": "string" - } + }, + "description": "Which providers to refresh. Empty means all known providers.", + "default": [] + } + }, + "description": "Trigger a background refresh of provider inventories.", + "x-side": "agent", + "x-method": "_goose/providers/inventory/refresh" + }, + "RefreshProviderInventoryResponse": { + "type": "object", + "properties": { + "started": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Which providers will be refreshed." + }, + "skipped": { + "type": "array", + "items": { + "$ref": "#/$defs/RefreshProviderInventorySkipDto" + }, + "description": "Which providers were skipped and why.", + "default": [] } }, "required": [ - "models" + "started" ], - "description": "Provider models response.", + "description": "Refresh acknowledgement.", "x-side": "agent", - "x-method": "_goose/providers/models" + "x-method": "_goose/providers/inventory/refresh" + }, + "RefreshProviderInventorySkipDto": { + "type": "object", + "properties": { + "providerId": { + "type": "string" + }, + "reason": { + "$ref": "#/$defs/RefreshProviderInventorySkipReasonDto" + } + }, + "required": [ + "providerId", + "reason" + ] + }, + "RefreshProviderInventorySkipReasonDto": { + "type": "string", + "enum": [ + "unknown_provider", + "not_configured", + "does_not_support_refresh", + "already_refreshing" + ] }, "ReadConfigRequest": { "type": "object", @@ -1035,11 +1223,20 @@ { "allOf": [ { - "$ref": "#/$defs/GetProviderModelsRequest" + "$ref": "#/$defs/GetProviderInventoryRequest" + } + ], + "description": "Params for _goose/providers/inventory", + "title": "GetProviderInventoryRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RefreshProviderInventoryRequest" } ], - "description": "Params for _goose/providers/models", - "title": "GetProviderModelsRequest" + "description": "Params for _goose/providers/inventory/refresh", + "title": "RefreshProviderInventoryRequest" }, { "allOf": [ @@ -1292,10 +1489,18 @@ { "allOf": [ { - "$ref": "#/$defs/GetProviderModelsResponse" + "$ref": "#/$defs/GetProviderInventoryResponse" + } + ], + "title": "GetProviderInventoryResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RefreshProviderInventoryResponse" } ], - "title": "GetProviderModelsResponse" + "title": "RefreshProviderInventoryResponse" }, { "allOf": [ diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index ca7c0f7ff883..9a4dc8ca48a4 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -27,6 +27,9 @@ use goose::mcp_utils::ToolResult; use goose::permission::permission_confirmation::PrincipalType; use goose::permission::{Permission, PermissionConfirmation}; use goose::providers::base::Provider; +use goose::providers::inventory::{ + ProviderInventoryEntry, ProviderInventoryService, RefreshSkipReason, +}; use goose::session::session_manager::SessionType; use goose::session::{EnabledExtensionsState, Session, SessionManager}; use goose_acp_macros::custom_methods; @@ -125,6 +128,8 @@ struct AgentSetupRequest { /// Pre-resolved provider name + model config (from config, no network). /// When present the spawn skips re-deriving these from config. resolved_provider: Option<(String, goose::model::ModelConfig)>, + /// Pre-instantiated provider reused from synchronous session initialization. + prebuilt_provider: Option>, } pub struct GooseAcpAgent { @@ -139,6 +144,7 @@ pub struct GooseAcpAgent { permission_manager: Arc, goose_mode: GooseMode, disable_session_naming: bool, + provider_inventory: ProviderInventoryService, } /// Shorten a session/thread id for perf log correlation. @@ -415,19 +421,50 @@ fn builtin_to_extension_config(name: &str) -> ExtensionConfig { } } -async fn build_model_state(provider: &dyn Provider) -> Result { - let models = provider - .fetch_recommended_models() - .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; - let current_model = &provider.get_model_config().model_name; - Ok(SessionModelState::new( - ModelId::new(current_model.as_str()), - models - .iter() - .map(|name| ModelInfo::new(ModelId::new(&**name), &**name)) +fn inventory_entry_to_dto(entry: ProviderInventoryEntry) -> ProviderInventoryEntryDto { + let stale = ProviderInventoryService::is_stale(&entry); + ProviderInventoryEntryDto { + provider_id: entry.provider_id, + provider_name: entry.provider_name, + configured: entry.configured, + supports_refresh: entry.supports_refresh, + refreshing: entry.refreshing, + models: entry + .models + .into_iter() + .map(|m| ProviderInventoryModelDto { + id: m.id, + name: m.name, + family: m.family, + context_limit: m.context_limit, + reasoning: m.reasoning, + recommended: m.recommended, + }) .collect(), - )) + last_updated_at: entry.last_updated_at.map(|t| t.to_rfc3339()), + last_refresh_attempt_at: entry.last_refresh_attempt_at.map(|t| t.to_rfc3339()), + last_refresh_error: entry.last_refresh_error, + stale, + model_selection_hint: entry.model_selection_hint, + } +} + +fn build_model_state(current_model: &str, inventory: &ProviderInventoryEntry) -> SessionModelState { + let mut available_models = inventory + .models + .iter() + .map(|model| ModelInfo::new(ModelId::new(model.id.as_str()), model.name.as_str())) + .collect::>(); + if !available_models + .iter() + .any(|model| model.model_id.0.as_ref() == current_model) + { + available_models.insert( + 0, + ModelInfo::new(ModelId::new(current_model), current_model), + ); + } + SessionModelState::new(ModelId::new(current_model), available_models) } async fn list_provider_entries(current_provider: Option<&str>) -> Vec { @@ -546,31 +583,25 @@ fn build_mode_state(current_mode: GooseMode) -> Result, +fn should_refresh_inventory_for_session_init(entry: &ProviderInventoryEntry) -> bool { + entry.configured + && entry.supports_refresh + && (entry.last_updated_at.is_none() || ProviderInventoryService::is_stale(entry)) +} + +async fn build_eager_config_from_inventory( + provider_name: &str, + current_model: &str, + inventory: &ProviderInventoryEntry, mode_state: &SessionModeState, goose_session: &Session, -) -> (Option, Option>) { - let Ok((ref provider_name, ref mc)) = resolved else { - return (None, None); - }; - let recommended = goose::providers::canonical::recommended_models_from_registry(provider_name); - let available: Vec = recommended - .iter() - .map(|name| ModelInfo::new(ModelId::new(&**name), &**name)) - .collect(); - let ms = SessionModelState::new(ModelId::new(mc.model_name.as_str()), available); +) -> (SessionModelState, Vec) { + let ms = build_model_state(current_model, inventory); let provider_selection = session_provider_selection(goose_session); - let provider_options = build_provider_options(Some(provider_name.as_str())).await; + let provider_options = build_provider_options(Some(provider_name)).await; let config_options = build_config_options(mode_state, &ms, provider_selection, provider_options); - (Some(ms), Some(config_options)) + (ms, config_options) } fn build_config_options( @@ -651,6 +682,7 @@ impl GooseAcpAgent { session_manager.storage().clone(), )); let permission_manager = Arc::new(PermissionManager::new(config_dir.clone())); + let provider_inventory = ProviderInventoryService::new(session_manager.storage().clone()); Ok(Self { sessions: Arc::new(Mutex::new(HashMap::new())), @@ -664,6 +696,7 @@ impl GooseAcpAgent { permission_manager, goose_mode, disable_session_naming, + provider_inventory, }) } @@ -680,6 +713,125 @@ impl GooseAcpAgent { (self.provider_factory)(provider_name.to_string(), model_config, extensions).await } + async fn prepare_session_init_config( + &self, + resolved: &Result<(String, goose::model::ModelConfig), String>, + mode_state: &SessionModeState, + goose_session: &Session, + ) -> ( + Option, + Option>, + Option>, + ) { + let Ok((provider_name, model_config)) = resolved else { + return (None, None, None); + }; + + let Some(mut inventory) = self + .provider_inventory + .entry_for_provider(provider_name) + .await + .ok() + .flatten() + else { + return (None, None, None); + }; + + let mut prebuilt_provider = None; + if should_refresh_inventory_for_session_init(&inventory) { + match self.load_config() { + Ok(config) => { + let ext_state = EnabledExtensionsState::extensions_or_default( + Some(&goose_session.extension_data), + &config, + ); + match self + .create_provider(provider_name, model_config.clone(), ext_state) + .await + { + Ok(provider) => { + let provider_id = provider_name.clone(); + prebuilt_provider = Some(provider.clone()); + match self + .provider_inventory + .plan_refresh(std::slice::from_ref(&provider_id)) + .await + { + Ok(plan) if plan.started.iter().any(|id| id == &provider_id) => { + match provider.fetch_recommended_models().await { + Ok(models) => { + if let Err(error) = self + .provider_inventory + .store_refreshed_models(&provider_id, &models) + .await + { + warn!( + provider = %provider_id, + error = %error, + "failed to store refreshed provider inventory during session init" + ); + } + } + Err(error) => { + if let Err(store_error) = self + .provider_inventory + .store_refresh_error( + &provider_id, + error.to_string(), + ) + .await + { + warn!( + provider = %provider_id, + error = %store_error, + "failed to store provider inventory refresh error during session init" + ); + } + } + } + } + Ok(_) => {} + Err(error) => warn!( + provider = %provider_id, + error = %error, + "failed to plan provider inventory refresh during session init" + ), + } + + if let Ok(Some(refreshed_inventory)) = self + .provider_inventory + .entry_for_provider(provider_name) + .await + { + inventory = refreshed_inventory; + } + } + Err(error) => warn!( + provider = %provider_name, + error = %error, + "failed to initialize provider during synchronous inventory refresh" + ), + } + } + Err(error) => warn!( + provider = %provider_name, + error = %error, + "failed to load config during synchronous inventory refresh" + ), + } + } + + let (model_state, config_options) = build_eager_config_from_inventory( + provider_name, + model_config.model_name.as_str(), + &inventory, + mode_state, + goose_session, + ) + .await; + (Some(model_state), Some(config_options), prebuilt_provider) + } + fn spawn_agent_setup( &self, cx: &ConnectionTo, @@ -691,6 +843,7 @@ impl GooseAcpAgent { goose_session, mcp_servers, resolved_provider, + prebuilt_provider, } = req; let goose_mode = goose_session.goose_mode; @@ -845,9 +998,12 @@ impl GooseAcpAgent { Some(&goose_session.extension_data), &config, ); - let provider = provider_factory(provider_name.to_string(), model_config, ext_state) - .await - .map_err(|e| e.to_string())?; + let provider = match prebuilt_provider { + Some(provider) => provider, + None => provider_factory(provider_name.to_string(), model_config, ext_state) + .await + .map_err(|e| e.to_string())?, + }; agent .update_provider(provider.clone(), &goose_session.id) .await @@ -1416,9 +1572,10 @@ impl GooseAcpAgent { .as_ref() .ok() .map(|(_, mc)| build_usage_update(&goose_session, mc.context_limit())); - let (model_state, config_options) = - build_eager_config(&resolved, &mode_state, &goose_session).await; let session_id = SessionId::new(thread_id.clone()); + let (model_state, config_options, prebuilt_provider) = self + .prepare_session_init_config(&resolved, &mode_state, &goose_session) + .await; self.spawn_agent_setup( cx, @@ -1428,6 +1585,7 @@ impl GooseAcpAgent { goose_session, mcp_servers: args.mcp_servers, resolved_provider: resolved.as_ref().ok().cloned(), + prebuilt_provider, }, ); @@ -1798,8 +1956,9 @@ impl GooseAcpAgent { .as_ref() .map(|mc| build_usage_update(&goose_session, mc.context_limit())) }); - let (model_state, config_options) = - build_eager_config(&resolved, &mode_state, &goose_session).await; + let (model_state, config_options, prebuilt_provider) = self + .prepare_session_init_config(&resolved, &mode_state, &goose_session) + .await; self.spawn_agent_setup( cx, @@ -1809,6 +1968,7 @@ impl GooseAcpAgent { goose_session, mcp_servers: args.mcp_servers, resolved_provider: None, + prebuilt_provider, }, ); @@ -2116,10 +2276,21 @@ impl GooseAcpAgent { let provider = agent.provider().await.map_err(|e| { sacp::Error::internal_error().data(format!("Failed to get provider: {}", e)) })?; + let provider_name = provider.get_name().to_string(); + let current_model = provider.get_model_config().model_name.clone(); let goose_mode = agent.goose_mode().await; - let model_state = build_model_state(&*provider).await?; + let inventory = self + .provider_inventory + .entry_for_provider(&provider_name) + .await + .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + let Some(inventory) = inventory else { + return Err(sacp::Error::internal_error() + .data(format!("Unknown provider inventory: {}", provider_name))); + }; + let model_state = build_model_state(current_model.as_str(), &inventory); let mode_state = build_mode_state(goose_mode)?; - let provider_options = build_provider_options(Some(provider.get_name())).await; + let provider_options = build_provider_options(Some(&provider_name)).await; let config_options = build_config_options( &mode_state, &model_state, @@ -2399,8 +2570,9 @@ impl GooseAcpAgent { let mode_state = build_mode_state(self.goose_mode)?; let resolved = resolve_provider_and_model(&self.config_dir, &goose_session).await; - let (model_state, config_options) = - build_eager_config(&resolved, &mode_state, &goose_session).await; + let (model_state, config_options, prebuilt_provider) = self + .prepare_session_init_config(&resolved, &mode_state, &goose_session) + .await; self.spawn_agent_setup( cx, @@ -2410,6 +2582,7 @@ impl GooseAcpAgent { goose_session, mcp_servers: args.mcp_servers, resolved_provider: resolved.ok(), + prebuilt_provider, }, ); @@ -2677,58 +2850,82 @@ impl GooseAcpAgent { Ok(GetProviderDetailsResponse { providers: entries }) } - #[custom_method(GetProviderModelsRequest)] - async fn on_get_provider_models( + #[custom_method(GetProviderInventoryRequest)] + async fn on_get_provider_inventory( &self, - req: GetProviderModelsRequest, - ) -> Result { - let config = self.load_config().ok(); - let all = goose::providers::providers().await; - - let Some((metadata, _provider_type)) = - all.into_iter().find(|(m, _)| m.name == req.provider_name) - else { - return Err(sacp::Error::invalid_params() - .data(format!("Unknown provider: {}", req.provider_name))); - }; - - let is_configured = config - .as_ref() - .map(|c| { - metadata.config_keys.iter().all(|k| { - if !k.required { - return true; - } - if k.secret { - c.get_secret::(&k.name).is_ok() - } else { - c.get_param::(&k.name).is_ok() - } - }) - }) - .unwrap_or(false); - - if !is_configured { - return Err(sacp::Error::invalid_params().data(format!( - "Provider '{}' is not configured", - req.provider_name - ))); - } - - let model_config = goose::model::ModelConfig::new(&metadata.default_model) - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))? - .with_canonical_limits(&req.provider_name); - - let provider = (self.provider_factory)(req.provider_name.clone(), model_config, Vec::new()) - .await - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; - - let models = provider - .fetch_recommended_models() + req: GetProviderInventoryRequest, + ) -> Result { + let entries = self + .provider_inventory + .entries(&req.provider_ids) .await .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + Ok(GetProviderInventoryResponse { + entries: entries.into_iter().map(inventory_entry_to_dto).collect(), + }) + } - Ok(GetProviderModelsResponse { models }) + #[custom_method(RefreshProviderInventoryRequest)] + async fn on_refresh_provider_inventory( + &self, + req: RefreshProviderInventoryRequest, + ) -> Result { + let refresh_plan = self + .provider_inventory + .plan_refresh(&req.provider_ids) + .await; + let refresh_plan = + refresh_plan.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + for provider_id in &refresh_plan.started { + let provider_inventory = self.provider_inventory.clone(); + let provider_factory = Arc::clone(&self.provider_factory); + let provider_id = provider_id.clone(); + tokio::spawn(async move { + let result = async { + let metadata = goose::providers::get_from_registry(&provider_id).await?; + let model_config = + goose::model::ModelConfig::new(&metadata.metadata().default_model)? + .with_canonical_limits(&provider_id); + let provider = + provider_factory(provider_id.clone(), model_config, Vec::new()).await?; + let models = provider.fetch_recommended_models().await?; + provider_inventory + .store_refreshed_models(&provider_id, &models) + .await + } + .await; + if let Err(error) = result { + let _ = provider_inventory + .store_refresh_error(&provider_id, error.to_string()) + .await; + warn!(provider = %provider_id, error = %error, "provider inventory refresh failed"); + } + }); + } + Ok(RefreshProviderInventoryResponse { + started: refresh_plan.started, + skipped: refresh_plan + .skipped + .into_iter() + .map(|entry| RefreshProviderInventorySkipDto { + provider_id: entry.provider_id, + reason: match entry.reason { + RefreshSkipReason::UnknownProvider => { + RefreshProviderInventorySkipReasonDto::UnknownProvider + } + RefreshSkipReason::NotConfigured => { + RefreshProviderInventorySkipReasonDto::NotConfigured + } + RefreshSkipReason::DoesNotSupportRefresh => { + RefreshProviderInventorySkipReasonDto::DoesNotSupportRefresh + } + RefreshSkipReason::AlreadyRefreshing => { + RefreshProviderInventorySkipReasonDto::AlreadyRefreshing + } + }, + }) + .collect(), + }) } #[custom_method(ReadConfigRequest)] @@ -3450,11 +3647,77 @@ impl HandleDispatchFrom for GooseAcpHandler { return Ok(()); } } + // Respond immediately using the current provider inventory snapshot. let t_tail = std::time::Instant::now(); let (notification, config_options) = agent.build_config_update(&session_id).await?; cx.send_notification(notification)?; responder.respond(SetSessionConfigOptionResponse::new(config_options))?; - debug!(target: "perf", sid = %sid, ms = t_tail.elapsed().as_millis() as u64, "perf: set_config_option notification_and_respond"); + debug!(target: "perf", sid = %sid, ms = t_tail.elapsed().as_millis() as u64, "perf: set_config_option inventory_respond"); + + let maybe_refresh = if config_id == "provider" { + let provider_id = value_id.0.to_string(); + agent + .provider_inventory + .plan_refresh(std::slice::from_ref(&provider_id)) + .await + .ok() + .filter(|plan| plan.started.iter().any(|id| id == &provider_id)) + } else { + None + }; + if maybe_refresh.is_some() { + let agent_bg = agent.clone(); + let cx_bg = cx.clone(); + let session_id_bg = session_id.clone(); + let sid_bg = sid.clone(); + tokio::spawn(async move { + let t_bg = std::time::Instant::now(); + let refreshed = async { + let session_agent = + agent_bg.get_session_agent(&session_id_bg.0, None).await?; + let provider = session_agent + .provider() + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + let provider_name = provider.get_name().to_string(); + let models = provider + .fetch_recommended_models() + .await + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + agent_bg + .provider_inventory + .store_refreshed_models(&provider_name, &models) + .await?; + agent_bg + .build_config_update(&session_id_bg) + .await + .map_err(|e| anyhow::anyhow!(e.to_string())) + } + .await; + + match refreshed { + Ok((fresh_notification, _)) => { + let _ = cx_bg.send_notification(fresh_notification); + debug!(target: "perf", sid = %sid_bg, ms = t_bg.elapsed().as_millis() as u64, "perf: set_config_option background_refresh done"); + } + Err(e) => { + if let Ok(session_agent) = + agent_bg.get_session_agent(&session_id_bg.0, None).await + { + if let Ok(provider) = session_agent.provider().await { + let provider_name = provider.get_name().to_string(); + let _ = agent_bg + .provider_inventory + .store_refresh_error(&provider_name, e.to_string()) + .await; + } + } + debug!(target: "perf", sid = %sid_bg, error = %e, ms = t_bg.elapsed().as_millis() as u64, "perf: set_config_option background_refresh failed"); + } + } + }); + } + debug!(target: "perf", sid = %sid, ms = t_handler.elapsed().as_millis() as u64, config_id = %config_id, "perf: set_config_option done"); Ok(()) } @@ -3597,7 +3860,6 @@ pub async fn run(builtins: Vec) -> Result<()> { mod tests { use super::*; use goose::conversation::message::{ToolRequest, ToolResponse}; - use goose::providers::errors::ProviderError; use rmcp::model::{CallToolRequestParams, Content as RmcpContent}; use sacp::schema::{ EnvVariable, HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio, @@ -3787,61 +4049,48 @@ print(\"hello, world\") assert_eq!(outcome_to_confirmation(&input), expected); } - struct MockModelProvider { - models: Result, ProviderError>, - } - - #[async_trait::async_trait] - impl Provider for MockModelProvider { - fn get_name(&self) -> &str { - "mock" - } - - async fn stream( - &self, - _model_config: &goose::model::ModelConfig, - _session_id: &str, - _system: &str, - _messages: &[goose::conversation::message::Message], - _tools: &[rmcp::model::Tool], - ) -> Result { - unimplemented!() - } - - fn get_model_config(&self) -> goose::model::ModelConfig { - goose::model::ModelConfig::new_or_fail("unused") - } - - async fn fetch_recommended_models(&self) -> Result, ProviderError> { - self.models.clone() - } - } - #[test_case( - Ok(vec!["model-a".into(), "model-b".into()]) - => Ok(SessionModelState::new( + vec!["model-a".into(), "model-b".into()] + => SessionModelState::new( ModelId::new("unused"), - vec![ModelInfo::new(ModelId::new("model-a"), "model-a"), + vec![ModelInfo::new(ModelId::new("unused"), "unused"), + ModelInfo::new(ModelId::new("model-a"), "model-a"), ModelInfo::new(ModelId::new("model-b"), "model-b")], - )) + ) ; "returns current and available models" )] #[test_case( - Ok(vec![]) - => Ok(SessionModelState::new(ModelId::new("unused"), vec![])) + vec![] + => SessionModelState::new( + ModelId::new("unused"), + vec![ModelInfo::new(ModelId::new("unused"), "unused")], + ) ; "empty model list" )] - #[test_case( - Err(ProviderError::ExecutionError("fail".into())) - => Err(sacp::Error::internal_error().data("Execution error: fail".to_string())) - ; "fetch error propagates" - )] - #[tokio::test] - async fn test_build_model_state( - models: Result, ProviderError>, - ) -> Result { - let provider = MockModelProvider { models }; - build_model_state(&provider).await + fn test_build_model_state(models: Vec) -> SessionModelState { + let inventory = ProviderInventoryEntry { + provider_id: "mock".to_string(), + provider_name: "Mock".to_string(), + configured: true, + supports_refresh: true, + refreshing: false, + models: models + .into_iter() + .map(|id| goose::providers::inventory::InventoryModel { + name: id.clone(), + id, + family: None, + context_limit: None, + reasoning: None, + recommended: false, + }) + .collect(), + last_updated_at: None, + last_refresh_attempt_at: None, + last_refresh_error: None, + model_selection_hint: None, + }; + build_model_state("unused", &inventory) } fn json_object(pairs: Vec<(&str, serde_json::Value)>) -> rmcp::model::JsonObject { diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 609299712c47..533718211062 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -257,20 +257,6 @@ pub struct GetProviderDetailsResponse { pub providers: Vec, } -/// Fetch the full list of models available for a specific provider. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] -#[request(method = "_goose/providers/models", response = GetProviderModelsResponse)] -#[serde(rename_all = "camelCase")] -pub struct GetProviderModelsRequest { - pub provider_name: String, -} - -/// Provider models response. -#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] -pub struct GetProviderModelsResponse { - pub models: Vec, -} - #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ProviderDetailEntry { @@ -370,6 +356,120 @@ pub struct DictationConfigResponse { pub providers: HashMap, } +/// Read per-provider inventory. Always returns immediately from stored state. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/providers/inventory", + response = GetProviderInventoryResponse +)] +#[serde(rename_all = "camelCase")] +pub struct GetProviderInventoryRequest { + /// Only return entries for these providers. Empty means all. + #[serde(default)] + pub provider_ids: Vec, +} + +/// Provider inventory response. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct GetProviderInventoryResponse { + pub entries: Vec, +} + +/// Trigger a background refresh of provider inventories. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/providers/inventory/refresh", + response = RefreshProviderInventoryResponse +)] +#[serde(rename_all = "camelCase")] +pub struct RefreshProviderInventoryRequest { + /// Which providers to refresh. Empty means all known providers. + #[serde(default)] + pub provider_ids: Vec, +} + +/// Refresh acknowledgement. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct RefreshProviderInventoryResponse { + /// Which providers will be refreshed. + pub started: Vec, + /// Which providers were skipped and why. + #[serde(default)] + pub skipped: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct RefreshProviderInventorySkipDto { + pub provider_id: String, + pub reason: RefreshProviderInventorySkipReasonDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RefreshProviderInventorySkipReasonDto { + #[default] + UnknownProvider, + NotConfigured, + DoesNotSupportRefresh, + AlreadyRefreshing, +} + +/// A single model in provider inventory. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProviderInventoryModelDto { + /// Model identifier as the provider knows it. + pub id: String, + /// Human-readable display name. + pub name: String, + /// Model family for grouping in UI. + #[serde(skip_serializing_if = "Option::is_none")] + pub family: Option, + /// Context window size in tokens. + #[serde(skip_serializing_if = "Option::is_none")] + pub context_limit: Option, + /// Whether the model supports reasoning/extended thinking. + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + /// Whether this model should appear in the compact recommended picker. + #[serde(default)] + pub recommended: bool, +} + +/// Provider inventory entry. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProviderInventoryEntryDto { + /// Provider identifier. + pub provider_id: String, + /// Human-readable provider name. + pub provider_name: String, + /// Whether Goose has enough configuration to use this provider. + pub configured: bool, + /// Whether this provider supports background inventory refresh. + pub supports_refresh: bool, + /// Whether a refresh is currently in flight. + pub refreshing: bool, + /// The list of available models. + pub models: Vec, + /// When this entry was last successfully refreshed (ISO 8601). + #[serde(skip_serializing_if = "Option::is_none")] + pub last_updated_at: Option, + /// When a refresh was most recently attempted (ISO 8601). + #[serde(skip_serializing_if = "Option::is_none")] + pub last_refresh_attempt_at: Option, + /// The last refresh failure message, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub last_refresh_error: Option, + /// Whether we believe this data may be outdated. + pub stale: bool, + /// Guidance message shown when this provider manages its own model selection externally. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_selection_hint: Option, +} + /// Empty success response for operations that return no data. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] pub struct EmptyResponse {} diff --git a/crates/goose/src/agents/prompt_manager.rs b/crates/goose/src/agents/prompt_manager.rs index dae26e70b658..b6ae713cbfb9 100644 --- a/crates/goose/src/agents/prompt_manager.rs +++ b/crates/goose/src/agents/prompt_manager.rs @@ -416,6 +416,11 @@ mod tests { use std::sync::Arc; let tmp_dir = tempfile::tempdir().unwrap(); + let temp_root = tmp_dir.path().display().to_string(); + let _guard = env_lock::lock_env([ + ("HOME", Some(temp_root.as_str())), + ("GOOSE_PATH_ROOT", Some(temp_root.as_str())), + ]); let session_manager = Arc::new(SessionManager::new(tmp_dir.path().to_path_buf())); let session = session_manager .create_session( diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index e470c0a4e3bd..60dfe56599a9 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -2,6 +2,7 @@ use crate::config::paths::Paths; use crate::config::Config; use crate::providers::anthropic::AnthropicProvider; use crate::providers::base::{ModelInfo, ProviderType}; +use crate::providers::inventory::declarative_inventory_identity; use crate::providers::ollama::OllamaProvider; use crate::providers::openai::OpenAiProvider; use anyhow::Result; @@ -460,38 +461,59 @@ pub fn register_declarative_provider( match config.engine { ProviderEngine::OpenAI => { let captured = config.clone(); - registry.register_with_name::( + let identity_config = config.clone(); + registry.register_with_name::( &config, provider_type, + config.dynamic_models.unwrap_or(false), move |model| { let mut cfg = captured.clone(); resolve_config(&mut cfg)?; OpenAiProvider::from_custom_config(model, cfg) }, + move || { + let mut cfg = identity_config.clone(); + resolve_config(&mut cfg)?; + declarative_inventory_identity(&cfg) + }, ); } ProviderEngine::Ollama => { let captured = config.clone(); - registry.register_with_name::( + let identity_config = config.clone(); + registry.register_with_name::( &config, provider_type, + config.dynamic_models.unwrap_or(false), move |model| { let mut cfg = captured.clone(); resolve_config(&mut cfg)?; OllamaProvider::from_custom_config(model, cfg) }, + move || { + let mut cfg = identity_config.clone(); + resolve_config(&mut cfg)?; + declarative_inventory_identity(&cfg) + }, ); } ProviderEngine::Anthropic => { let captured = config.clone(); - registry.register_with_name::( + let identity_config = config.clone(); + registry.register_with_name::( &config, provider_type, + config.dynamic_models.unwrap_or(false), move |model| { let mut cfg = captured.clone(); resolve_config(&mut cfg)?; AnthropicProvider::from_custom_config(model, cfg) }, + move || { + let mut cfg = identity_config.clone(); + resolve_config(&mut cfg)?; + declarative_inventory_identity(&cfg) + }, ); } } diff --git a/crates/goose/src/providers/acp_tooling.rs b/crates/goose/src/providers/acp_tooling.rs new file mode 100644 index 000000000000..91c9351b6c00 --- /dev/null +++ b/crates/goose/src/providers/acp_tooling.rs @@ -0,0 +1,18 @@ +use crate::config::search_path::SearchPaths; +use crate::providers::inventory::InventoryIdentityInput; +use anyhow::Result; +use std::path::PathBuf; + +pub fn acp_adapter_installed(command: &str) -> bool { + resolve_acp_command(command).is_ok() +} + +pub fn acp_inventory_identity(provider_id: &str, command: &str) -> Result { + let resolved_command = resolve_acp_command(command)?; + Ok(InventoryIdentityInput::new(provider_id, provider_id) + .with_public("command", resolved_command.display().to_string())) +} + +fn resolve_acp_command(command: &str) -> Result { + SearchPaths::builder().with_npm().resolve(command) +} diff --git a/crates/goose/src/providers/amp_acp.rs b/crates/goose/src/providers/amp_acp.rs index 987b370b8c73..5d6339b13f58 100644 --- a/crates/goose/src/providers/amp_acp.rs +++ b/crates/goose/src/providers/amp_acp.rs @@ -9,7 +9,9 @@ use crate::acp::{ use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; use crate::model::ModelConfig; +use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{ProviderDef, ProviderMetadata}; +use crate::providers::inventory::InventoryIdentityInput; const AMP_ACP_PROVIDER_NAME: &str = "amp-acp"; const AMP_ACP_DOC_URL: &str = "https://ampcode.com"; @@ -37,6 +39,7 @@ impl ProviderDef for AmpAcpProvider { "Set in your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: amp-acp\n GOOSE_MODEL: current", "Restart goose for changes to take effect", ]) + .with_model_selection_hint("Use the Amp CLI to configure models") } fn from_env( @@ -49,10 +52,12 @@ impl ProviderDef for AmpAcpProvider { let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); let mode_mapping = HashMap::from([ - (GooseMode::Auto, "auto".to_string()), - (GooseMode::Approve, "approve".to_string()), - (GooseMode::SmartApprove, "smart-approve".to_string()), - (GooseMode::Chat, "chat".to_string()), + // "bypass" skips confirmations, closest to autonomous mode. + (GooseMode::Auto, "bypass".to_string()), + // "default" prompts before risky actions. + (GooseMode::Approve, "default".to_string()), + (GooseMode::SmartApprove, "default".to_string()), + (GooseMode::Chat, "default".to_string()), ]); let provider_config = AcpProviderConfig { @@ -71,4 +76,16 @@ impl ProviderDef for AmpAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } + + fn supports_inventory_refresh() -> bool { + false + } + + fn inventory_identity() -> Result { + acp_inventory_identity(AMP_ACP_PROVIDER_NAME, AMP_ACP_BINARY) + } + + fn inventory_configured() -> bool { + acp_adapter_installed(AMP_ACP_BINARY) + } } diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index 1529e0afc20b..01bcb0401af6 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -14,6 +14,7 @@ use super::errors::ProviderError; use super::formats::anthropic::{ create_request, response_to_streaming_message, thinking_type, ThinkingType, }; +use super::inventory::{config_secret_value, serialize_string_map, InventoryIdentityInput}; use super::openai_compatible::handle_status_openai_compat; use super::openai_compatible::map_http_error_to_provider_error; use super::retry::ProviderRetry; @@ -235,6 +236,33 @@ impl ProviderDef for AnthropicProvider { ) -> BoxFuture<'static, Result> { Box::pin(Self::from_env(model)) } + + fn supports_inventory_refresh() -> bool { + true + } + + fn inventory_identity() -> Result { + let config = crate::config::Config::global(); + let mut identity = + InventoryIdentityInput::new(ANTHROPIC_PROVIDER_NAME, ANTHROPIC_PROVIDER_NAME) + .with_public( + "host", + config + .get_param::("ANTHROPIC_HOST") + .unwrap_or_else(|_| "https://api.anthropic.com".to_string()), + ); + + if let Some(api_key) = config_secret_value(config, "ANTHROPIC_API_KEY") { + identity = identity.with_secret("api_key", api_key); + } + if let Ok(headers) = config + .get_secret::>("ANTHROPIC_CUSTOM_HEADERS") + { + identity = identity.with_secret("headers", serialize_string_map(&headers)?); + } + + Ok(identity) + } } #[async_trait] diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs index 12b3a3c5104e..6b6a0707b9ef 100644 --- a/crates/goose/src/providers/base.rs +++ b/crates/goose/src/providers/base.rs @@ -6,9 +6,10 @@ use serde::{Deserialize, Serialize}; use super::canonical::{map_to_canonical_model, CanonicalModelRegistry}; use super::errors::ProviderError; +use super::inventory::{default_inventory_identity, InventoryIdentityInput}; use super::retry::RetryConfig; use crate::config::base::ConfigValue; -use crate::config::{ExtensionConfig, GooseMode}; +use crate::config::{Config, ExtensionConfig, GooseMode}; use crate::conversation::message::{Message, MessageContent}; use crate::conversation::Conversation; use crate::model::ModelConfig; @@ -179,6 +180,9 @@ pub struct ProviderMetadata { /// step-by-step instructions for set up providers eg: api key #[serde(default)] pub setup_steps: Vec, + /// Hint shown in the model picker when this provider manages its own model selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_selection_hint: Option, } impl ProviderMetadata { @@ -212,6 +216,7 @@ impl ProviderMetadata { model_doc_link: model_doc_link.to_string(), config_keys, setup_steps: vec![], + model_selection_hint: None, } } @@ -233,6 +238,7 @@ impl ProviderMetadata { model_doc_link: model_doc_link.to_string(), config_keys, setup_steps: vec![], + model_selection_hint: None, } } @@ -246,6 +252,7 @@ impl ProviderMetadata { model_doc_link: "".to_string(), config_keys: vec![], setup_steps: vec![], + model_selection_hint: None, } } @@ -253,6 +260,11 @@ impl ProviderMetadata { self.setup_steps = steps.into_iter().map(|s| s.to_string()).collect(); self } + + pub fn with_model_selection_hint(mut self, hint: &str) -> Self { + self.model_selection_hint = Some(hint.to_string()); + self + } } /// Configuration key metadata for provider setup @@ -492,6 +504,34 @@ pub trait ProviderDef: Send + Sync { ) -> BoxFuture<'static, Result> where Self: Sized; + + fn supports_inventory_refresh() -> bool + where + Self: Sized, + { + false + } + + fn inventory_identity() -> Result + where + Self: Sized, + { + let metadata = Self::metadata(); + Ok(default_inventory_identity( + &metadata.name, + &metadata.name, + &metadata.config_keys, + Config::global(), + )) + } + + fn inventory_configured() -> bool + where + Self: Sized, + { + let metadata = Self::metadata(); + super::inventory::default_inventory_configured(&metadata.config_keys, Config::global()) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -588,7 +628,7 @@ pub trait Provider: Send + Sync { false } - /// Fetch models filtered by canonical registry and usability + /// Fetch inventory models filtered by canonical registry and usability. async fn fetch_recommended_models(&self) -> Result, ProviderError> { let all_models = self.fetch_supported_models().await?; @@ -637,15 +677,15 @@ pub trait Provider: Send + Sync { (None, None) => a.0.cmp(&b.0), }); - let recommended_models: Vec = models_with_dates + let inventory_models: Vec = models_with_dates .into_iter() .map(|(name, _)| name) .collect(); - if recommended_models.is_empty() { + if inventory_models.is_empty() { Ok(all_models) } else { - Ok(recommended_models) + Ok(inventory_models) } } diff --git a/crates/goose/src/providers/claude_acp.rs b/crates/goose/src/providers/claude_acp.rs index 9fb25be28f11..dc7cb301b2dd 100644 --- a/crates/goose/src/providers/claude_acp.rs +++ b/crates/goose/src/providers/claude_acp.rs @@ -9,7 +9,9 @@ use crate::acp::{ use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; use crate::model::ModelConfig; +use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{ProviderDef, ProviderMetadata}; +use crate::providers::inventory::InventoryIdentityInput; const CLAUDE_ACP_PROVIDER_NAME: &str = "claude-acp"; const CLAUDE_ACP_DOC_URL: &str = "https://github.com/zed-industries/claude-agent-acp"; @@ -78,4 +80,16 @@ impl ProviderDef for ClaudeAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } + + fn supports_inventory_refresh() -> bool { + true + } + + fn inventory_identity() -> Result { + acp_inventory_identity(CLAUDE_ACP_PROVIDER_NAME, CLAUDE_ACP_BINARY) + } + + fn inventory_configured() -> bool { + acp_adapter_installed(CLAUDE_ACP_BINARY) + } } diff --git a/crates/goose/src/providers/codex_acp.rs b/crates/goose/src/providers/codex_acp.rs index f28bd02917cf..21f0a328cb05 100644 --- a/crates/goose/src/providers/codex_acp.rs +++ b/crates/goose/src/providers/codex_acp.rs @@ -9,7 +9,9 @@ use crate::acp::{ use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; use crate::model::ModelConfig; +use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{ProviderDef, ProviderMetadata}; +use crate::providers::inventory::InventoryIdentityInput; const CODEX_ACP_PROVIDER_NAME: &str = "codex-acp"; const CODEX_ACP_DOC_URL: &str = "https://github.com/zed-industries/codex-acp"; @@ -98,6 +100,18 @@ impl ProviderDef for CodexAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } + + fn supports_inventory_refresh() -> bool { + true + } + + fn inventory_identity() -> Result { + acp_inventory_identity(CODEX_ACP_PROVIDER_NAME, CODEX_ACP_PROVIDER_NAME) + } + + fn inventory_configured() -> bool { + acp_adapter_installed(CODEX_ACP_PROVIDER_NAME) + } } // Codex sandbox scope determines what needs approval: operations within the diff --git a/crates/goose/src/providers/copilot_acp.rs b/crates/goose/src/providers/copilot_acp.rs index 9810547ff384..35bed3dec04a 100644 --- a/crates/goose/src/providers/copilot_acp.rs +++ b/crates/goose/src/providers/copilot_acp.rs @@ -9,7 +9,9 @@ use crate::acp::{ use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; use crate::model::ModelConfig; +use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{ProviderDef, ProviderMetadata}; +use crate::providers::inventory::InventoryIdentityInput; const COPILOT_ACP_PROVIDER_NAME: &str = "copilot-acp"; const COPILOT_ACP_DOC_URL: &str = "https://github.com/github/copilot-cli"; @@ -84,4 +86,16 @@ impl ProviderDef for CopilotAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } + + fn supports_inventory_refresh() -> bool { + true + } + + fn inventory_identity() -> Result { + acp_inventory_identity(COPILOT_ACP_PROVIDER_NAME, COPILOT_ACP_BINARY) + } + + fn inventory_configured() -> bool { + acp_adapter_installed(COPILOT_ACP_BINARY) + } } diff --git a/crates/goose/src/providers/databricks.rs b/crates/goose/src/providers/databricks.rs index dcbe3d112f75..3b1df79ea4b7 100644 --- a/crates/goose/src/providers/databricks.rs +++ b/crates/goose/src/providers/databricks.rs @@ -340,6 +340,10 @@ impl ProviderDef for DatabricksProvider { ) -> BoxFuture<'static, Result> { Box::pin(Self::from_env(model)) } + + fn supports_inventory_refresh() -> bool { + true + } } #[async_trait] diff --git a/crates/goose/src/providers/formats/databricks.rs b/crates/goose/src/providers/formats/databricks.rs index fb712cc34336..74b1f3de5843 100644 --- a/crates/goose/src/providers/formats/databricks.rs +++ b/crates/goose/src/providers/formats/databricks.rs @@ -1100,12 +1100,16 @@ mod tests { fn test_create_request_enabled_thinking_with_budget() -> anyhow::Result<()> { let _guard = env_lock::lock_env([ ("CLAUDE_THINKING_TYPE", None::<&str>), - ("CLAUDE_THINKING_ENABLED", Some("1")), + ("CLAUDE_THINKING_ENABLED", None::<&str>), ("CLAUDE_THINKING_BUDGET", Some("10000")), ]); let mut model_config = ModelConfig::new_or_fail("databricks-claude-3-7-sonnet"); model_config.max_tokens = Some(4096); + model_config = model_config.with_request_params(Some(std::collections::HashMap::from([( + "thinking_type".to_string(), + json!("enabled"), + )]))); let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?; diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index 6e864251d690..c4ee34871506 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -149,6 +149,10 @@ pub async fn get_from_registry(name: &str) -> Result { .cloned() } +pub async fn inventory_identity(name: &str) -> Result { + get_from_registry(name).await?.inventory_identity() +} + pub async fn create( name: &str, model: ModelConfig, diff --git a/crates/goose/src/providers/inventory/mod.rs b/crates/goose/src/providers/inventory/mod.rs new file mode 100644 index 000000000000..0a0eb9dc9ac4 --- /dev/null +++ b/crates/goose/src/providers/inventory/mod.rs @@ -0,0 +1,970 @@ +use super::base::{ConfigKey, ModelInfo}; +use super::canonical::{map_provider_name, map_to_canonical_model, CanonicalModelRegistry}; +use crate::config::declarative_providers::{DeclarativeProviderConfig, ProviderEngine}; +use crate::config::Config; +use crate::session::session_manager::SessionStorage; +use anyhow::Result; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::{Pool, Row, Sqlite, Transaction}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; +use tokio::sync::RwLock; + +const STALE_AFTER_HOURS: i64 = 24; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderInventoryEntry { + pub provider_id: String, + pub provider_name: String, + pub configured: bool, + pub supports_refresh: bool, + pub refreshing: bool, + pub models: Vec, + pub last_updated_at: Option>, + pub last_refresh_attempt_at: Option>, + pub last_refresh_error: Option, + pub model_selection_hint: Option, +} + +/// Families whose latest model should be surfaced in the compact picker. +/// Each entry is matched against the `family` field of enriched models. +const RECOMMENDED_FAMILIES: &[&str] = &[ + "claude-opus", + "claude-sonnet", + "gpt", + "gpt-mini", + "glm", + "gemini-pro", + "gemini-flash", + "gemma", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InventoryModel { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub family: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + /// Whether this model should appear in the compact recommended picker. + pub recommended: bool, +} + +#[derive(Debug, Clone)] +pub struct InventoryIdentity { + pub provider_id: String, + pub provider_family: String, + pub inventory_key: String, +} + +#[derive(Debug, Clone, Default)] +pub struct InventoryIdentityInput { + pub provider_id: String, + pub provider_family: String, + pub public_inputs: BTreeMap, + pub secret_inputs: BTreeMap, +} + +impl InventoryIdentityInput { + pub fn new( + provider_id: impl Into, + provider_family: impl Into, + ) -> InventoryIdentityInput { + InventoryIdentityInput { + provider_id: provider_id.into(), + provider_family: provider_family.into(), + public_inputs: BTreeMap::new(), + secret_inputs: BTreeMap::new(), + } + } + + pub fn with_public( + mut self, + key: impl Into, + value: impl Into, + ) -> InventoryIdentityInput { + self.public_inputs.insert(key.into(), value.into()); + self + } + + pub fn with_secret( + mut self, + key: impl Into, + value: impl Into, + ) -> InventoryIdentityInput { + self.secret_inputs.insert(key.into(), value.into()); + self + } + + pub fn into_identity(self) -> Result { + let InventoryIdentityInput { + provider_id, + provider_family, + public_inputs, + secret_inputs, + } = self; + let payload = serde_json::json!({ + "provider_family": provider_family, + "public_inputs": public_inputs, + "secret_inputs": secret_inputs, + }); + let digest = Sha256::digest(serde_json::to_vec(&payload)?); + Ok(InventoryIdentity { + provider_id, + provider_family, + inventory_key: format!("{digest:x}"), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefreshSkipReason { + UnknownProvider, + NotConfigured, + DoesNotSupportRefresh, + AlreadyRefreshing, +} + +#[derive(Debug, Clone)] +pub struct RefreshSkip { + pub provider_id: String, + pub reason: RefreshSkipReason, +} + +#[derive(Debug, Clone, Default)] +pub struct RefreshPlan { + pub started: Vec, + pub skipped: Vec, +} + +#[derive(Clone)] +pub struct ProviderInventoryService { + storage: Arc, + refreshing_keys: Arc>>, +} + +#[derive(Debug, Clone)] +struct InventorySnapshot { + models: Vec, + last_updated_at: Option>, + last_refresh_attempt_at: Option>, + last_refresh_error: Option, +} + +#[derive(Debug, Clone)] +struct ProviderDescriptor { + provider_id: String, + provider_name: String, + identity: InventoryIdentity, + configured: bool, + supports_refresh: bool, + static_models: Vec, + model_selection_hint: Option, +} + +impl ProviderInventoryService { + pub fn new(storage: Arc) -> ProviderInventoryService { + ProviderInventoryService { + storage, + refreshing_keys: Arc::new(RwLock::new(HashSet::new())), + } + } + + pub async fn entry_for_provider( + &self, + provider_id: &str, + ) -> Result> { + let Some(descriptor) = self.describe_provider(provider_id).await? else { + return Ok(None); + }; + let snapshot = self.read_snapshot(&descriptor.identity).await?; + let refreshing = self + .refreshing_keys + .read() + .await + .contains(&descriptor.identity.inventory_key); + let models = inventory_models_from_snapshot( + snapshot.as_ref(), + &descriptor.identity.provider_family, + &descriptor.static_models, + ); + + Ok(Some(ProviderInventoryEntry { + provider_id: descriptor.provider_id, + provider_name: descriptor.provider_name, + configured: descriptor.configured, + supports_refresh: descriptor.supports_refresh, + refreshing, + models, + last_updated_at: snapshot + .as_ref() + .and_then(|snapshot| snapshot.last_updated_at), + last_refresh_attempt_at: snapshot + .as_ref() + .and_then(|snapshot| snapshot.last_refresh_attempt_at), + last_refresh_error: snapshot.and_then(|snapshot| snapshot.last_refresh_error), + model_selection_hint: descriptor.model_selection_hint, + })) + } + + pub async fn entries(&self, provider_ids: &[String]) -> Result> { + let ids = self.resolve_provider_ids(provider_ids).await; + let mut entries = Vec::with_capacity(ids.len()); + for provider_id in ids { + if let Some(entry) = self.entry_for_provider(&provider_id).await? { + entries.push(entry); + } + } + Ok(entries) + } + + pub async fn plan_refresh(&self, provider_ids: &[String]) -> Result { + let ids = self.resolve_provider_ids(provider_ids).await; + let mut plan = RefreshPlan::default(); + + for provider_id in ids { + let Some(descriptor) = self.describe_provider(&provider_id).await? else { + plan.skipped.push(RefreshSkip { + provider_id, + reason: RefreshSkipReason::UnknownProvider, + }); + continue; + }; + + if !descriptor.supports_refresh { + plan.skipped.push(RefreshSkip { + provider_id: descriptor.provider_id, + reason: RefreshSkipReason::DoesNotSupportRefresh, + }); + continue; + } + + if !descriptor.configured { + plan.skipped.push(RefreshSkip { + provider_id: descriptor.provider_id, + reason: RefreshSkipReason::NotConfigured, + }); + continue; + } + + let mut refreshing_keys = self.refreshing_keys.write().await; + if refreshing_keys.contains(&descriptor.identity.inventory_key) { + plan.skipped.push(RefreshSkip { + provider_id: descriptor.provider_id, + reason: RefreshSkipReason::AlreadyRefreshing, + }); + continue; + } + + refreshing_keys.insert(descriptor.identity.inventory_key.clone()); + drop(refreshing_keys); + + self.mark_refresh_started(&descriptor.identity).await?; + plan.started.push(descriptor.provider_id); + } + + Ok(plan) + } + + pub async fn store_refreshed_models( + &self, + provider_id: &str, + model_ids: &[String], + ) -> Result<()> { + let descriptor = self.require_provider(provider_id).await?; + let models = + enrich_model_ids_with_canonical(&descriptor.identity.provider_family, model_ids); + let now = Utc::now(); + let pool = self.storage.pool().await?; + let mut tx = pool.begin().await?; + + sqlx::query( + r#" + INSERT INTO provider_inventory_entries ( + inventory_key, + provider_id, + provider_family, + last_updated_at, + last_refresh_attempt_at, + last_refresh_error, + updated_at + ) VALUES (?, ?, ?, ?, ?, NULL, CURRENT_TIMESTAMP) + ON CONFLICT(inventory_key) DO UPDATE SET + provider_id = excluded.provider_id, + provider_family = excluded.provider_family, + last_updated_at = excluded.last_updated_at, + last_refresh_attempt_at = excluded.last_refresh_attempt_at, + last_refresh_error = NULL, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(&descriptor.identity.inventory_key) + .bind(&descriptor.identity.provider_id) + .bind(&descriptor.identity.provider_family) + .bind(now.to_rfc3339()) + .bind(now.to_rfc3339()) + .execute(&mut *tx) + .await?; + + sqlx::query("DELETE FROM provider_inventory_models WHERE inventory_key = ?") + .bind(&descriptor.identity.inventory_key) + .execute(&mut *tx) + .await?; + + for (ordinal, model) in models.iter().enumerate() { + sqlx::query( + r#" + INSERT INTO provider_inventory_models ( + inventory_key, + ordinal, + model_id, + name, + family, + context_limit, + reasoning, + recommended + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(&descriptor.identity.inventory_key) + .bind(i64::try_from(ordinal)?) + .bind(&model.id) + .bind(&model.name) + .bind(&model.family) + .bind(model.context_limit.map(i64::try_from).transpose()?) + .bind(model.reasoning) + .bind(model.recommended) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + self.refreshing_keys + .write() + .await + .remove(&descriptor.identity.inventory_key); + Ok(()) + } + + pub async fn store_refresh_error( + &self, + provider_id: &str, + error: impl Into, + ) -> Result<()> { + let descriptor = self.require_provider(provider_id).await?; + let error = error.into(); + let existing = self.read_snapshot(&descriptor.identity).await?; + + sqlx::query( + r#" + INSERT INTO provider_inventory_entries ( + inventory_key, + provider_id, + provider_family, + last_updated_at, + last_refresh_attempt_at, + last_refresh_error, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(inventory_key) DO UPDATE SET + provider_id = excluded.provider_id, + provider_family = excluded.provider_family, + last_updated_at = excluded.last_updated_at, + last_refresh_attempt_at = excluded.last_refresh_attempt_at, + last_refresh_error = excluded.last_refresh_error, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(&descriptor.identity.inventory_key) + .bind(&descriptor.identity.provider_id) + .bind(&descriptor.identity.provider_family) + .bind(existing.and_then(|snapshot| snapshot.last_updated_at.map(|time| time.to_rfc3339()))) + .bind(Utc::now().to_rfc3339()) + .bind(error) + .execute(self.storage.pool().await?) + .await?; + + self.refreshing_keys + .write() + .await + .remove(&descriptor.identity.inventory_key); + Ok(()) + } + + pub fn is_stale(entry: &ProviderInventoryEntry) -> bool { + let Some(last_updated_at) = entry.last_updated_at else { + return false; + }; + entry.supports_refresh && Utc::now() - last_updated_at > Duration::hours(STALE_AFTER_HOURS) + } + + async fn describe_provider(&self, provider_id: &str) -> Result> { + let entry = match crate::providers::get_from_registry(provider_id).await { + Ok(entry) => entry, + Err(_) => return Ok(None), + }; + let metadata = entry.metadata().clone(); + let identity = crate::providers::inventory_identity(provider_id) + .await + .unwrap_or_else(|_| fallback_inventory_identity(provider_id)) + .into_identity()?; + + Ok(Some(ProviderDescriptor { + provider_id: metadata.name.clone(), + provider_name: metadata.display_name.clone(), + identity, + configured: entry.inventory_configured(), + supports_refresh: entry.supports_inventory_refresh(), + static_models: metadata.known_models, + model_selection_hint: metadata.model_selection_hint, + })) + } + + async fn require_provider(&self, provider_id: &str) -> Result { + self.describe_provider(provider_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Unknown provider: {}", provider_id)) + } + + async fn mark_refresh_started(&self, identity: &InventoryIdentity) -> Result<()> { + let existing = self.read_snapshot(identity).await?; + + sqlx::query( + r#" + INSERT INTO provider_inventory_entries ( + inventory_key, + provider_id, + provider_family, + last_updated_at, + last_refresh_attempt_at, + last_refresh_error, + updated_at + ) VALUES (?, ?, ?, ?, ?, NULL, CURRENT_TIMESTAMP) + ON CONFLICT(inventory_key) DO UPDATE SET + provider_id = excluded.provider_id, + provider_family = excluded.provider_family, + last_updated_at = excluded.last_updated_at, + last_refresh_attempt_at = excluded.last_refresh_attempt_at, + last_refresh_error = NULL, + updated_at = CURRENT_TIMESTAMP + "#, + ) + .bind(&identity.inventory_key) + .bind(&identity.provider_id) + .bind(&identity.provider_family) + .bind(existing.and_then(|snapshot| snapshot.last_updated_at.map(|time| time.to_rfc3339()))) + .bind(Utc::now().to_rfc3339()) + .execute(self.storage.pool().await?) + .await?; + + Ok(()) + } + + async fn read_snapshot( + &self, + identity: &InventoryIdentity, + ) -> Result> { + let pool = self.storage.pool().await?; + let entry = sqlx::query( + r#" + SELECT last_updated_at, last_refresh_attempt_at, last_refresh_error + FROM provider_inventory_entries + WHERE inventory_key = ? + "#, + ) + .bind(&identity.inventory_key) + .fetch_optional(pool) + .await?; + + let Some(entry) = entry else { + return Ok(None); + }; + + let last_updated_at = parse_optional_datetime(entry.try_get("last_updated_at")?)?; + let last_refresh_attempt_at = + parse_optional_datetime(entry.try_get("last_refresh_attempt_at")?)?; + let last_refresh_error = entry.try_get("last_refresh_error")?; + + let rows = sqlx::query( + r#" + SELECT model_id, name, family, context_limit, reasoning, recommended + FROM provider_inventory_models + WHERE inventory_key = ? + ORDER BY ordinal + "#, + ) + .bind(&identity.inventory_key) + .fetch_all(pool) + .await?; + + let models = rows + .into_iter() + .map(|row| { + Ok(InventoryModel { + id: row.try_get("model_id")?, + name: row.try_get("name")?, + family: row.try_get("family")?, + context_limit: row + .try_get::, _>("context_limit")? + .map(usize::try_from) + .transpose()?, + reasoning: row.try_get("reasoning")?, + recommended: row + .try_get::, _>("recommended")? + .unwrap_or(false), + }) + }) + .collect::, anyhow::Error>>()?; + + Ok(Some(InventorySnapshot { + models, + last_updated_at, + last_refresh_attempt_at, + last_refresh_error, + })) + } + + async fn resolve_provider_ids(&self, provider_ids: &[String]) -> Vec { + let mut ids = if provider_ids.is_empty() { + crate::providers::providers() + .await + .into_iter() + .map(|(metadata, _)| metadata.name) + .collect::>() + } else { + provider_ids.to_vec() + }; + ids.sort(); + ids.dedup(); + ids + } +} + +pub fn default_inventory_identity( + provider_id: &str, + provider_family: &str, + config_keys: &[ConfigKey], + config: &Config, +) -> InventoryIdentityInput { + let mut identity = InventoryIdentityInput::new(provider_id, provider_family); + + for key in config_keys { + if key.secret { + if let Some(value) = config_secret_value(config, &key.name) { + identity.secret_inputs.insert(key.name.clone(), value); + } + } else if let Some(value) = config_param_value(config, &key.name) { + identity.public_inputs.insert(key.name.clone(), value); + } + } + + identity +} + +pub fn default_inventory_configured(config_keys: &[ConfigKey], config: &Config) -> bool { + config_keys.iter().all(|key| { + if !key.required { + return true; + } + if key.default.is_some() { + return true; + } + if key.secret { + config.get_secret::(&key.name).is_ok() + } else { + config.get_param::(&key.name).is_ok() + } + }) +} + +pub fn declarative_inventory_identity( + config: &DeclarativeProviderConfig, +) -> Result { + let global = Config::global(); + let mut identity = InventoryIdentityInput::new( + config.name.clone(), + config + .catalog_provider_id + .clone() + .unwrap_or_else(|| match config.engine { + ProviderEngine::OpenAI => "openai".to_string(), + ProviderEngine::Anthropic => "anthropic".to_string(), + ProviderEngine::Ollama => "ollama".to_string(), + }), + ); + + identity + .public_inputs + .insert("base_url".to_string(), config.base_url.clone()); + + if let Some(base_path) = &config.base_path { + identity + .public_inputs + .insert("base_path".to_string(), base_path.clone()); + } + if let Some(catalog_provider_id) = &config.catalog_provider_id { + identity.public_inputs.insert( + "catalog_provider_id".to_string(), + catalog_provider_id.clone(), + ); + } + if let Some(dynamic_models) = config.dynamic_models { + identity + .public_inputs + .insert("dynamic_models".to_string(), dynamic_models.to_string()); + } + identity.public_inputs.insert( + "skip_canonical_filtering".to_string(), + config.skip_canonical_filtering.to_string(), + ); + if !config.models.is_empty() { + identity.public_inputs.insert( + "models".to_string(), + serde_json::to_string( + &config + .models + .iter() + .map(|model| &model.name) + .collect::>(), + )?, + ); + } + if let Some(headers) = &config.headers { + identity + .public_inputs + .insert("headers".to_string(), serialize_string_map(headers)?); + } + if config.requires_auth && !config.api_key_env.is_empty() { + if let Some(value) = config_secret_value(global, &config.api_key_env) { + identity + .secret_inputs + .insert(config.api_key_env.clone(), value); + } + } + + Ok(identity) +} + +pub fn config_param_value(config: &Config, key: &str) -> Option { + config + .get_param::(key) + .ok() + .and_then(|value| normalize_json_value(&value)) +} + +pub fn config_secret_value(config: &Config, key: &str) -> Option { + config + .get_secret::(key) + .ok() + .and_then(|value| normalize_json_value(&value)) +} + +pub fn serialize_string_map(map: &HashMap) -> Result { + let ordered = map + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + Ok(serde_json::to_string(&ordered)?) +} + +fn parse_optional_datetime(value: Option) -> Result>> { + value + .map(|value| value.parse::>()) + .transpose() + .map_err(Into::into) +} + +fn normalize_json_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Null => None, + serde_json::Value::String(value) if value.is_empty() => None, + serde_json::Value::String(value) => Some(value.clone()), + other => serde_json::to_string(other).ok(), + } +} + +fn fallback_inventory_identity(provider_id: &str) -> InventoryIdentityInput { + InventoryIdentityInput::new( + provider_id.to_string(), + map_provider_name(provider_id).to_string(), + ) +} + +fn enrich_model_ids_with_canonical( + provider_family: &str, + model_ids: &[String], +) -> Vec { + let mut models: Vec = Vec::new(); + let mut seen_names: HashSet = HashSet::new(); + + for id in model_ids { + let model = enriched_model(provider_family, id, None); + if !seen_names.insert(model.name.clone()) { + continue; + } + models.push(model); + } + + // For databricks, prefer goose- prefixed model_ids when there are duplicates. + // Re-scan: if a later model_id with "goose-" prefix maps to the same display name, + // swap it in. + if provider_family == "databricks" { + let mut name_to_idx: HashMap = HashMap::new(); + for (idx, model) in models.iter().enumerate() { + name_to_idx.insert(model.name.clone(), idx); + } + for id in model_ids { + if !id.starts_with("goose-") { + continue; + } + let candidate = enriched_model(provider_family, id, None); + if let Some(&idx) = name_to_idx.get(&candidate.name) { + if !models[idx].id.starts_with("goose-") { + models[idx].id = candidate.id; + } + } + } + } + + // Mark the latest model per recommended family. + let mut seen_recommended_families: HashSet = HashSet::new(); + for model in &mut models { + if let Some(family) = &model.family { + if RECOMMENDED_FAMILIES.contains(&family.as_str()) + && seen_recommended_families.insert(family.clone()) + { + model.recommended = true; + } + } + } + + models +} + +fn configured_models_to_inventory( + provider_family: &str, + models: &[ModelInfo], +) -> Vec { + let mut result: Vec = Vec::new(); + let mut seen_names: HashSet = HashSet::new(); + for model in models { + let enriched = enriched_model(provider_family, &model.name, Some(model.context_limit)); + if seen_names.insert(enriched.name.clone()) { + result.push(enriched); + } + } + + let mut seen_recommended_families: HashSet = HashSet::new(); + for model in &mut result { + if let Some(family) = &model.family { + if RECOMMENDED_FAMILIES.contains(&family.as_str()) + && seen_recommended_families.insert(family.clone()) + { + model.recommended = true; + } + } + } + + result +} + +fn inventory_models_from_snapshot( + snapshot: Option<&InventorySnapshot>, + provider_family: &str, + configured_models: &[ModelInfo], +) -> Vec { + match snapshot { + Some(snapshot) if !snapshot.models.is_empty() || snapshot.last_updated_at.is_some() => { + snapshot.models.clone() + } + _ => configured_models_to_inventory(provider_family, configured_models), + } +} + +fn enriched_model( + provider_family: &str, + model_id: &str, + fallback_context_limit: Option, +) -> InventoryModel { + let registry = CanonicalModelRegistry::bundled().ok(); + let canonical = registry.as_ref().and_then(|registry| { + let canonical_id = map_to_canonical_model(provider_family, model_id, registry)?; + let (provider, model) = canonical_id.split_once('/')?; + registry.get(provider, model).cloned() + }); + + InventoryModel { + id: model_id.to_string(), + name: canonical + .as_ref() + .map(|model| model.name.clone()) + .unwrap_or_else(|| model_id.to_string()), + family: canonical.as_ref().and_then(|model| model.family.clone()), + context_limit: canonical + .as_ref() + .map(|model| model.limit.context) + .or(fallback_context_limit), + reasoning: canonical.as_ref().and_then(|model| model.reasoning), + recommended: false, + } +} + +pub async fn create_tables(pool: &Pool) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS provider_inventory_entries ( + inventory_key TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + provider_family TEXT NOT NULL, + last_updated_at TEXT, + last_refresh_attempt_at TEXT, + last_refresh_error TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS provider_inventory_models ( + inventory_key TEXT NOT NULL REFERENCES provider_inventory_entries(inventory_key) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + model_id TEXT NOT NULL, + name TEXT NOT NULL, + family TEXT, + context_limit INTEGER, + reasoning BOOLEAN, + recommended BOOLEAN, + PRIMARY KEY (inventory_key, ordinal) + ) + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_provider_inventory_provider_id ON provider_inventory_entries(provider_id)", + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn create_tables_in_tx(tx: &mut Transaction<'_, Sqlite>) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS provider_inventory_entries ( + inventory_key TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + provider_family TEXT NOT NULL, + last_updated_at TEXT, + last_refresh_attempt_at TEXT, + last_refresh_error TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "#, + ) + .execute(&mut **tx) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS provider_inventory_models ( + inventory_key TEXT NOT NULL REFERENCES provider_inventory_entries(inventory_key) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + model_id TEXT NOT NULL, + name TEXT NOT NULL, + family TEXT, + context_limit INTEGER, + reasoning BOOLEAN, + recommended BOOLEAN, + PRIMARY KEY (inventory_key, ordinal) + ) + "#, + ) + .execute(&mut **tx) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_provider_inventory_provider_id ON provider_inventory_entries(provider_id)", + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inventory_identity_hash_changes_with_secret_inputs() { + let left = InventoryIdentityInput::new("openai", "openai") + .with_public("host", "https://api.openai.com") + .with_secret("api_key", "secret-a") + .into_identity() + .unwrap(); + let right = InventoryIdentityInput::new("openai", "openai") + .with_public("host", "https://api.openai.com") + .with_secret("api_key", "secret-b") + .into_identity() + .unwrap(); + + assert_ne!(left.inventory_key, right.inventory_key); + } + + #[test] + fn configured_models_use_canonical_enrichment() { + let models = + configured_models_to_inventory("anthropic", &[ModelInfo::new("claude-sonnet-4-5", 0)]); + + assert_eq!(models.len(), 1); + assert!(models[0].name.contains("Claude")); + } + + #[test] + fn inventory_uses_configured_models_before_first_successful_refresh() { + let configured_models = [ModelInfo::new("claude-sonnet-4-5", 0)]; + let snapshot = InventorySnapshot { + models: vec![], + last_updated_at: None, + last_refresh_attempt_at: Some(Utc::now()), + last_refresh_error: Some("auth failed".to_string()), + }; + + let models = + inventory_models_from_snapshot(Some(&snapshot), "anthropic", &configured_models); + + assert_eq!(models.len(), 1); + assert_eq!(models[0].id, "claude-sonnet-4-5"); + } + + #[test] + fn inventory_preserves_empty_models_after_successful_refresh() { + let configured_models = [ModelInfo::new("claude-sonnet-4-5", 0)]; + let snapshot = InventorySnapshot { + models: vec![], + last_updated_at: Some(Utc::now()), + last_refresh_attempt_at: Some(Utc::now()), + last_refresh_error: None, + }; + + let models = + inventory_models_from_snapshot(Some(&snapshot), "anthropic", &configured_models); + + assert!(models.is_empty()); + } +} diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 2712a12ff028..2cc132771b10 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -1,3 +1,4 @@ +mod acp_tooling; pub mod amp_acp; pub mod anthropic; pub mod api_client; @@ -28,6 +29,7 @@ pub mod gemini_oauth; pub mod githubcopilot; pub mod google; mod init; +pub mod inventory; pub mod kimicode; pub mod litellm; #[cfg(feature = "local-inference")] @@ -56,6 +58,6 @@ pub mod xai; pub use init::{ cleanup_provider, create, create_with_default_model, create_with_named_model, - get_from_registry, providers, refresh_custom_providers, + get_from_registry, inventory_identity, providers, refresh_custom_providers, }; pub use retry::{retry_operation, RetryConfig}; diff --git a/crates/goose/src/providers/ollama.rs b/crates/goose/src/providers/ollama.rs index 48a063638bdf..fd23767f72a5 100644 --- a/crates/goose/src/providers/ollama.rs +++ b/crates/goose/src/providers/ollama.rs @@ -1,6 +1,7 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::errors::ProviderError; +use super::inventory::InventoryIdentityInput; use super::openai_compatible::handle_status_openai_compat; use super::retry::{ProviderRetry, RetryConfig}; use super::utils::{ImageFormat, RequestLog}; @@ -256,6 +257,22 @@ impl ProviderDef for OllamaProvider { ) -> BoxFuture<'static, Result> { Box::pin(Self::from_env(model)) } + + fn supports_inventory_refresh() -> bool { + true + } + + fn inventory_identity() -> Result { + let config = crate::config::Config::global(); + Ok( + InventoryIdentityInput::new(OLLAMA_PROVIDER_NAME, OLLAMA_PROVIDER_NAME).with_public( + "host", + config + .get_param::("OLLAMA_HOST") + .unwrap_or_else(|_| OLLAMA_HOST.to_string()), + ), + ) + } } #[async_trait] diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index 1ab2b1c52f61..330dfb3b9ad6 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -7,6 +7,7 @@ use super::formats::openai_responses::{ create_responses_request, get_responses_usage, responses_api_to_message, responses_api_to_streaming_message, ResponsesApiResponse, }; +use super::inventory::{config_secret_value, InventoryIdentityInput}; use super::openai_compatible::{ handle_response_openai_compat, handle_status_openai_compat, stream_openai_compat, }; @@ -425,6 +426,58 @@ impl ProviderDef for OpenAiProvider { ) -> BoxFuture<'static, Result> { Box::pin(Self::from_env(model)) } + + fn supports_inventory_refresh() -> bool { + true + } + + fn inventory_configured() -> bool { + let config = crate::config::Config::global(); + // If the host is explicitly set to something non-default, trust the user's + // custom setup (e.g. a local server that doesn't require an API key). + if let Ok(host) = config.get_param::("OPENAI_HOST") { + if host != "https://api.openai.com" { + return true; + } + } + // Standard OpenAI endpoint requires an API key. + config + .get_secret::("OPENAI_API_KEY") + .is_ok() + } + + fn inventory_identity() -> Result { + let config = crate::config::Config::global(); + let mut identity = + InventoryIdentityInput::new(OPEN_AI_PROVIDER_NAME, OPEN_AI_PROVIDER_NAME) + .with_public( + "host", + config + .get_param::("OPENAI_HOST") + .unwrap_or_else(|_| "https://api.openai.com".to_string()), + ) + .with_public( + "base_path", + config + .get_param::("OPENAI_BASE_PATH") + .unwrap_or_else(|_| OPEN_AI_DEFAULT_BASE_PATH.to_string()), + ); + + if let Ok(organization) = config.get_param::("OPENAI_ORGANIZATION") { + identity = identity.with_public("organization", organization); + } + if let Ok(project) = config.get_param::("OPENAI_PROJECT") { + identity = identity.with_public("project", project); + } + if let Some(api_key) = config_secret_value(config, "OPENAI_API_KEY") { + identity = identity.with_secret("api_key", api_key); + } + if let Some(custom_headers) = config_secret_value(config, "OPENAI_CUSTOM_HEADERS") { + identity = identity.with_secret("custom_headers", custom_headers); + } + + Ok(identity) + } } #[async_trait] diff --git a/crates/goose/src/providers/pi_acp.rs b/crates/goose/src/providers/pi_acp.rs index 5c3eaa72cfe0..b02278f97dcf 100644 --- a/crates/goose/src/providers/pi_acp.rs +++ b/crates/goose/src/providers/pi_acp.rs @@ -9,7 +9,9 @@ use crate::acp::{ use crate::config::search_path::SearchPaths; use crate::config::{Config, GooseMode}; use crate::model::ModelConfig; +use crate::providers::acp_tooling::{acp_adapter_installed, acp_inventory_identity}; use crate::providers::base::{ProviderDef, ProviderMetadata}; +use crate::providers::inventory::InventoryIdentityInput; const PI_ACP_PROVIDER_NAME: &str = "pi-acp"; const PI_ACP_DOC_URL: &str = "https://github.com/anthropics/pi"; @@ -36,6 +38,7 @@ impl ProviderDef for PiAcpProvider { "Set in your goose config file (`~/.config/goose/config.yaml` on macOS/Linux):\n GOOSE_PROVIDER: pi-acp\n GOOSE_MODEL: current", "Restart goose for changes to take effect", ]) + .with_model_selection_hint("Use the Pi CLI to configure models") } fn from_env( @@ -70,4 +73,16 @@ impl ProviderDef for PiAcpProvider { AcpProvider::connect(metadata.name, model, goose_mode, provider_config).await }) } + + fn supports_inventory_refresh() -> bool { + false + } + + fn inventory_identity() -> Result { + acp_inventory_identity(PI_ACP_PROVIDER_NAME, PI_ACP_BINARY) + } + + fn inventory_configured() -> bool { + acp_adapter_installed(PI_ACP_BINARY) + } } diff --git a/crates/goose/src/providers/provider_registry.rs b/crates/goose/src/providers/provider_registry.rs index be2c5a23caaf..551ca1516f4d 100644 --- a/crates/goose/src/providers/provider_registry.rs +++ b/crates/goose/src/providers/provider_registry.rs @@ -1,4 +1,5 @@ use super::base::{ModelInfo, Provider, ProviderDef, ProviderMetadata, ProviderType}; +use super::inventory::InventoryIdentityInput; use crate::config::{DeclarativeProviderConfig, ExtensionConfig}; use crate::model::ModelConfig; use anyhow::Result; @@ -14,12 +15,20 @@ pub type ProviderConstructor = Arc< pub type ProviderCleanup = Arc BoxFuture<'static, Result<()>> + Send + Sync>; +pub type ProviderInventoryIdentityResolver = + Arc Result + Send + Sync>; + +pub type ProviderInventoryConfiguredResolver = Arc bool + Send + Sync>; + #[derive(Clone)] pub struct ProviderEntry { metadata: ProviderMetadata, pub(crate) constructor: ProviderConstructor, + pub(crate) inventory_identity: ProviderInventoryIdentityResolver, + pub(crate) inventory_configured: ProviderInventoryConfiguredResolver, pub(crate) cleanup: Option, provider_type: ProviderType, + supports_inventory_refresh: bool, } impl ProviderEntry { @@ -27,6 +36,22 @@ impl ProviderEntry { &self.metadata } + pub fn provider_type(&self) -> ProviderType { + self.provider_type + } + + pub fn supports_inventory_refresh(&self) -> bool { + self.supports_inventory_refresh + } + + pub fn inventory_identity(&self) -> Result { + (self.inventory_identity)() + } + + pub fn inventory_configured(&self) -> bool { + (self.inventory_configured)() + } + fn normalize_model_config(&self, mut model: ModelConfig) -> ModelConfig { model = model.with_canonical_limits(&self.metadata.name); @@ -92,24 +117,30 @@ impl ProviderRegistry { Ok(Arc::new(provider) as Arc) }) }), + inventory_identity: Arc::new(F::inventory_identity), + inventory_configured: Arc::new(F::inventory_configured), cleanup: None, provider_type: if preferred { ProviderType::Preferred } else { ProviderType::Builtin }, + supports_inventory_refresh: F::supports_inventory_refresh(), }, ); } - pub fn register_with_name( + pub fn register_with_name( &mut self, config: &DeclarativeProviderConfig, provider_type: ProviderType, + supports_inventory_refresh: bool, constructor: F, + inventory_identity: G, ) where P: ProviderDef + 'static, F: Fn(ModelConfig) -> Result + Send + Sync + 'static, + G: Fn() -> Result + Send + Sync + 'static, { let base_metadata = P::metadata(); let description = config @@ -174,7 +205,9 @@ impl ProviderRegistry { model_doc_link: base_metadata.model_doc_link, config_keys, setup_steps: vec![], + model_selection_hint: None, }; + let inventory_config_keys = custom_metadata.config_keys.clone(); self.entries.insert( config.name.clone(), @@ -187,8 +220,16 @@ impl ProviderRegistry { Ok(Arc::new(provider) as Arc) }) }), + inventory_identity: Arc::new(inventory_identity), + inventory_configured: Arc::new(move || { + super::inventory::default_inventory_configured( + &inventory_config_keys, + crate::config::Config::global(), + ) + }), cleanup: None, provider_type, + supports_inventory_refresh, }, ); } diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index bea5ec97170c..5c6559be1481 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -19,7 +19,7 @@ use std::sync::{Arc, LazyLock}; use tracing::{info, warn}; use utoipa::ToSchema; -pub const CURRENT_SCHEMA_VERSION: i32 = 10; +pub const CURRENT_SCHEMA_VERSION: i32 = 11; pub const SESSIONS_FOLDER: &str = "sessions"; pub const DB_NAME: &str = "sessions.db"; @@ -717,6 +717,8 @@ impl SessionStorage { .execute(pool) .await?; + crate::providers::inventory::create_tables(pool).await?; + Ok(()) } @@ -1060,6 +1062,9 @@ impl SessionStorage { .execute(&mut **tx) .await?; } + 11 => { + crate::providers::inventory::create_tables_in_tx(tx).await?; + } _ => { anyhow::bail!("Unknown migration version: {}", version); } diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index a267e7237307..96aac0ec71c3 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -374,6 +374,7 @@ mod tests { model_doc_link: "".to_string(), config_keys: vec![], setup_steps: vec![], + model_selection_hint: None, } } @@ -542,6 +543,7 @@ mod tests { model_doc_link: "".to_string(), config_keys: vec![], setup_steps: vec![], + model_selection_hint: None, } } @@ -867,6 +869,7 @@ mod tests { model_doc_link: "".to_string(), config_keys: vec![], setup_steps: vec![], + model_selection_hint: None, } } diff --git a/crates/goose/tests/compaction.rs b/crates/goose/tests/compaction.rs index 4c81e745e1ae..67b16b34a6a5 100644 --- a/crates/goose/tests/compaction.rs +++ b/crates/goose/tests/compaction.rs @@ -192,6 +192,7 @@ impl ProviderDef for MockCompactionProvider { model_doc_link: "".to_string(), config_keys: vec![], setup_steps: vec![], + model_selection_hint: None, } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 7260dfbeb5c4..4611c8d86100 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -6864,6 +6864,11 @@ "type": "string", "description": "Link to the docs where models can be found" }, + "model_selection_hint": { + "type": "string", + "description": "Hint shown in the model picker when this provider manages its own model selection.", + "nullable": true + }, "name": { "type": "string", "description": "The unique identifier for this provider" diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index a9e8d80bf302..fc5487e7b9e0 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -970,6 +970,10 @@ export type ProviderMetadata = { * Link to the docs where models can be found */ model_doc_link: string; + /** + * Hint shown in the model picker when this provider manages its own model selection. + */ + model_selection_hint?: string | null; /** * The unique identifier for this provider */ diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index c5c47459595b..9d2b3c4b1b54 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -36,9 +36,19 @@ const EXCEPTIONS = { "Search-as-you-type filtering and draft-aware sidebar highlight logic.", }, "src/app/AppShell.tsx": { - limit: 660, + limit: 730, justification: - "Shell still coordinates ACP session loading, replay-buffer cleanup on load failure, project reassignment, and app-level chat routing. Includes gated [perf:load]/[perf:newtab] logging via perfLog (dev-only by default).", + "Shell still coordinates ACP session loading, replay-buffer cleanup on load failure, project reassignment, home-session restoration, app-level chat routing, and restored project-draft reuse. Includes gated [perf:load]/[perf:newtab] logging via perfLog (dev-only by default).", + }, + "src/features/chat/hooks/useChatSessionController.ts": { + limit: 690, + justification: + "Controller now centralizes home-to-chat pending state transfer, workspace/project preparation, provider/model/persona handoff, Goose cross-provider model selection sequencing with rollback, and chat input orchestration pending a later decomposition pass.", + }, + "src/features/chat/ui/AgentModelPicker.tsx": { + limit: 570, + justification: + "Agent-first picker currently keeps the full trigger, recommended-model view, searchable full-model view, and ACP/goose-specific labeling logic in one component pending later extraction.", }, "src/features/chat/stores/__tests__/chatSessionStore.test.ts": { limit: 540, @@ -56,7 +66,7 @@ const EXCEPTIONS = { "Voice dictation send/stop guards, attachment handling, and mention/picker coordination still share one chat composer component.", }, "src/features/chat/ui/__tests__/ChatInput.test.tsx": { - limit: 510, + limit: 520, justification: "Composer regression coverage spans personas, queueing, attachments, and voice-input edge cases in one interaction-heavy suite.", }, diff --git a/ui/goose2/scripts/reset-inventory.sh b/ui/goose2/scripts/reset-inventory.sh new file mode 100755 index 000000000000..73125f683102 --- /dev/null +++ b/ui/goose2/scripts/reset-inventory.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Reset the provider inventory tables to empty, as if migration 12 just ran. +# This lets you test the first-use experience (cold inventory). +# +# Usage: ./scripts/reset-inventory.sh + +set -euo pipefail + +DB="${GOOSE_DB:-$HOME/.local/share/goose/sessions/sessions.db}" + +if [ ! -f "$DB" ]; then + echo "Database not found at $DB" + echo "Set GOOSE_DB to override the path." + exit 1 +fi + +echo "Database: $DB" +echo "" +echo "Before:" +echo " provider_inventory_entries: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM provider_inventory_entries;')" +echo " provider_inventory_models: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM provider_inventory_models;')" + +# ON DELETE CASCADE on provider_inventory_models means deleting entries clears both tables. +# Delete models first since CASCADE isn't reliable in all sqlite3 builds, +# then delete entries. +sqlite3 "$DB" "DELETE FROM provider_inventory_models; DELETE FROM provider_inventory_entries;" + +echo "" +echo "After:" +echo " provider_inventory_entries: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM provider_inventory_entries;')" +echo " provider_inventory_models: $(sqlite3 "$DB" 'SELECT COUNT(*) FROM provider_inventory_models;')" +echo "" +echo "Inventory tables are empty. Restart goose to test first-use flow." diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index a38dde0b17b2..3ba9c0f792c2 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Sidebar } from "@/features/sidebar/ui/Sidebar"; import { StatusBar } from "@/features/status/ui/StatusBar"; -import type { ChatAttachmentDraft } from "@/shared/types/messages"; import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog"; import { archiveProject } from "@/features/projects/api/projects"; import type { ProjectInfo } from "@/features/projects/api/projects"; @@ -9,7 +8,11 @@ import { SettingsModal } from "@/features/settings/ui/SettingsModal"; import type { SectionId } from "@/features/settings/ui/SettingsModal"; import { TopBar } from "./ui/TopBar"; import { useChatStore } from "@/features/chat/stores/chatStore"; -import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { + type ChatSession, + hasSessionStarted, + useChatSessionStore, +} from "@/features/chat/stores/chatSessionStore"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; import { findExistingDraft } from "@/features/chat/lib/newChat"; @@ -37,6 +40,33 @@ const SIDEBAR_MIN_WIDTH = 180; const SIDEBAR_MAX_WIDTH = 380; const SIDEBAR_SNAP_COLLAPSE_THRESHOLD = 100; const SIDEBAR_COLLAPSED_WIDTH = 48; +const HOME_SESSION_STORAGE_KEY = "goose:home-session-id"; + +function loadStoredHomeSessionId(): string | null { + if (typeof window === "undefined") { + return null; + } + try { + return window.localStorage.getItem(HOME_SESSION_STORAGE_KEY); + } catch { + return null; + } +} + +function persistHomeSessionId(sessionId: string | null): void { + if (typeof window === "undefined") { + return; + } + try { + if (sessionId) { + window.localStorage.setItem(HOME_SESSION_STORAGE_KEY, sessionId); + return; + } + window.localStorage.removeItem(HOME_SESSION_STORAGE_KEY); + } catch { + // localStorage may be unavailable + } +} export function AppShell({ children }: { children?: React.ReactNode }) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); @@ -52,9 +82,9 @@ export function AppShell({ children }: { children?: React.ReactNode }) { null, ); const [activeView, setActiveView] = useState("home"); - const [homeSelectedProvider, setHomeSelectedProvider] = useState< - string | undefined - >(); + const [homeSessionId, setHomeSessionId] = useState(() => + loadStoredHomeSessionId(), + ); const chatStore = useChatStore(); const sessionStore = useChatSessionStore(); @@ -64,6 +94,9 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>( null, ); + const homeSessionRequestRef = useRef | null>( + null, + ); const loadSessionMessages = useCallback(async (sessionId: string) => { const sid = sessionId.slice(0, 8); @@ -125,70 +158,146 @@ export function AppShell({ children }: { children?: React.ReactNode }) { } }, [activeSessionId, activeView]); - const isHome = activeSessionId === null && activeView === "home"; + const isHome = activeView === "home"; const activeSession = activeSessionId ? sessionStore.getSession(activeSessionId) : undefined; - const modelName = activeSession?.modelName; - const tokenCount = activeSessionId - ? chatStore.getSessionRuntime(activeSessionId).tokenState.totalTokens - : 0; - - const [pendingInitialMessage, setPendingInitialMessage] = useState< - string | undefined - >(); - const [pendingInitialAttachments, setPendingInitialAttachments] = useState< - ChatAttachmentDraft[] | undefined - >(); - const [homeSelectedPersonaId, setHomeSelectedPersonaId] = useState< - string | undefined - >(); - - const cleanupEmptyDraft = useCallback( - (sessionId: string | null) => { - if (!sessionId) return; - const state = useChatSessionStore.getState(); - const session = state.sessions.find((s) => s.id === sessionId); - if (!session?.draft) return; - const draft = useChatStore.getState().draftsBySession[sessionId] ?? ""; - if (draft.length > 0) return; // has typed text — keep it - chatStore.cleanupSession(sessionId); - state.removeDraft(sessionId); - }, - [chatStore], - ); + const modelName = + activeView === "chat" ? activeSession?.modelName : undefined; + const tokenCount = + activeView === "chat" && activeSessionId + ? chatStore.getSessionRuntime(activeSessionId).tokenState.totalTokens + : 0; + const homeSession = homeSessionId + ? sessionStore.getSession(homeSessionId) + : undefined; + + useEffect(() => { + if ( + !homeSessionId || + !sessionStore.hasHydratedSessions || + sessionStore.isLoading + ) { + return; + } + if ( + !homeSession || + homeSession.archivedAt || + hasSessionStarted( + homeSession, + chatStore.messagesBySession[homeSession.id], + ) + ) { + setHomeSessionId(null); + } + }, [ + chatStore.messagesBySession, + homeSession, + homeSession?.archivedAt, + homeSession?.messageCount, + homeSessionId, + sessionStore.hasHydratedSessions, + sessionStore.isLoading, + ]); + + useEffect(() => { + persistHomeSessionId(homeSessionId); + }, [homeSessionId]); + + const ensureHomeSession = useCallback(async () => { + if (!sessionStore.hasHydratedSessions || sessionStore.isLoading) { + return null; + } + + if (homeSessionRequestRef.current) { + return homeSessionRequestRef.current; + } + + const request = (async () => { + if ( + homeSession && + !homeSession.archivedAt && + homeSession.messageCount === 0 + ) { + const project = homeSession.projectId + ? (projectStore.projects.find( + (candidate) => candidate.id === homeSession.projectId, + ) ?? null) + : null; + const workingDir = await resolveSessionCwd(project); + await acpPrepareSession( + homeSession.id, + homeSession.providerId ?? agentStore.selectedProvider ?? "goose", + workingDir, + { + personaId: homeSession.personaId, + }, + ); + return homeSession; + } + + const workingDir = await resolveSessionCwd(null); + const session = await sessionStore.createSession({ + title: DEFAULT_CHAT_TITLE, + providerId: agentStore.selectedProvider ?? "goose", + workingDir, + }); + setHomeSessionId(session.id); + return session; + })(); + + homeSessionRequestRef.current = request; + try { + return await request; + } finally { + if (homeSessionRequestRef.current === request) { + homeSessionRequestRef.current = null; + } + } + }, [ + agentStore.selectedProvider, + homeSession, + projectStore.projects, + sessionStore.hasHydratedSessions, + sessionStore, + sessionStore.isLoading, + ]); + + useEffect(() => { + if (activeView !== "home") { + return; + } + void ensureHomeSession().catch((error) => { + console.error("Failed to ensure Home session:", error); + }); + }, [activeView, ensureHomeSession]); const createNewTab = useCallback( - (title = DEFAULT_CHAT_TITLE, project?: ProjectInfo) => { + async (title = DEFAULT_CHAT_TITLE, project?: ProjectInfo) => { const tStart = performance.now(); perfLog( `[perf:newtab] createNewTab start (project=${project?.id ?? "none"})`, ); const agentId = agentStore.activeAgentId ?? undefined; - const providerId = project?.preferredProvider ?? homeSelectedProvider; - const personaId = homeSelectedPersonaId; + const providerId = + project?.preferredProvider ?? agentStore.selectedProvider ?? "goose"; + const modelId = project?.preferredModel ?? undefined; const sessionState = useChatSessionStore.getState(); - const chatStoreState = useChatStore.getState(); + const chatState = useChatStore.getState(); const existingDraft = findExistingDraft({ sessions: sessionState.sessions, activeSessionId: sessionState.activeSessionId, - draftsBySession: chatStoreState.draftsBySession, - messagesBySession: chatStoreState.messagesBySession, + draftsBySession: chatState.draftsBySession, + messagesBySession: chatState.messagesBySession, request: { title, projectId: project?.id, - agentId, - providerId, - personaId, }, }); if (existingDraft) { - if (sessionState.activeSessionId !== existingDraft.id) { - cleanupEmptyDraft(sessionState.activeSessionId); - } - sessionState.setActiveSession(existingDraft.id); + sessionStore.setActiveSession(existingDraft.id); setActiveView("chat"); chatStore.setActiveSession(existingDraft.id); perfLog( @@ -196,46 +305,45 @@ export function AppShell({ children }: { children?: React.ReactNode }) { ); return existingDraft; } - cleanupEmptyDraft(sessionState.activeSessionId); - const session = sessionStore.createDraftSession({ + + const workingDir = await resolveSessionCwd(project); + const session = await sessionStore.createSession({ title, projectId: project?.id, agentId, providerId, - personaId, + workingDir, + modelId, + modelName: modelId, }); sessionStore.setActiveSession(session.id); setActiveView("chat"); chatStore.setActiveSession(session.id); perfLog( - `[perf:newtab] ${session.id.slice(0, 8)} created draft in ${(performance.now() - tStart).toFixed(1)}ms`, + `[perf:newtab] ${session.id.slice(0, 8)} created session in ${(performance.now() - tStart).toFixed(1)}ms`, ); return session; }, [ + agentStore.activeAgentId, + agentStore.selectedProvider, chatStore, sessionStore, - agentStore.activeAgentId, - homeSelectedPersonaId, - homeSelectedProvider, - cleanupEmptyDraft, ], ); const handleStartChatFromProject = useCallback( (project: ProjectInfo) => { - setHomeSelectedProvider(undefined); - createNewTab(DEFAULT_CHAT_TITLE, project); + void createNewTab(DEFAULT_CHAT_TITLE, project); }, [createNewTab], ); const handleNewChatInProject = useCallback( (projectId: string) => { - setHomeSelectedProvider(undefined); const project = projectStore.projects.find((p) => p.id === projectId); if (project) { - createNewTab(DEFAULT_CHAT_TITLE, project); + void createNewTab(DEFAULT_CHAT_TITLE, project); } }, [createNewTab, projectStore.projects], @@ -255,12 +363,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const clearActiveSession = useCallback( (sessionId: string) => { - cleanupEmptyDraft(sessionId); chatStore.cleanupSession(sessionId); sessionStore.setActiveSession(null); setActiveView("home"); }, - [chatStore, sessionStore, cleanupEmptyDraft], + [chatStore, sessionStore], ); const openSettings = useCallback((section: SectionId = "appearance") => { setSettingsInitialSection(section); @@ -306,7 +413,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { sessionStore.updateSession(sessionId, { projectId }); const session = useChatSessionStore.getState().getSession(sessionId); - if (!session || session.draft) { + if (!session) { return; } @@ -362,41 +469,28 @@ export function AppShell({ children }: { children?: React.ReactNode }) { [], ); - const handleHomeStartChat = useCallback( - ( - initialMessage?: string, - providerId?: string, - personaId?: string, - projectId?: string | null, - attachments?: ChatAttachmentDraft[], - ) => { - setHomeSelectedProvider(providerId); - setHomeSelectedPersonaId(personaId); - setPendingInitialMessage(initialMessage); - setPendingInitialAttachments(attachments); - const selectedProject = - projectId != null - ? projectStore.projects.find((project) => project.id === projectId) - : undefined; - - createNewTab( - initialMessage?.slice(0, 40) || DEFAULT_CHAT_TITLE, - selectedProject, - ); + const activateHomeSession = useCallback( + (sessionId: string) => { + if (homeSessionId === sessionId) { + setHomeSessionId(null); + } + sessionStore.setActiveSession(sessionId); + setActiveView("chat"); + chatStore.setActiveSession(sessionId); + useChatStore.getState().markSessionRead(sessionId); }, - [createNewTab, projectStore.projects], + [chatStore, homeSessionId, sessionStore], ); const handleSelectSession = useCallback( (id: string) => { - cleanupEmptyDraft(useChatSessionStore.getState().activeSessionId); sessionStore.setActiveSession(id); setActiveView("chat"); chatStore.setActiveSession(id); useChatStore.getState().markSessionRead(id); loadSessionMessages(id); }, - [sessionStore, chatStore, loadSessionMessages, cleanupEmptyDraft], + [sessionStore, chatStore, loadSessionMessages], ); const handleSelectSearchResult = useCallback( @@ -414,12 +508,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const handleNavigate = useCallback( (view: AppView) => { if (view !== "chat") { - cleanupEmptyDraft(useChatSessionStore.getState().activeSessionId); sessionStore.setActiveSession(null); } setActiveView(view); }, - [sessionStore, cleanupEmptyDraft], + [sessionStore], ); const toggleSidebar = () => setSidebarCollapsed((prev) => !prev); @@ -496,20 +589,13 @@ export function AppShell({ children }: { children?: React.ReactNode }) { // Cmd+N opens new conversation screen if (e.key === "n" && e.metaKey) { e.preventDefault(); - createNewTab(); + sessionStore.setActiveSession(null); + setActiveView("home"); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [clearActiveSession, createNewTab]); - - const activeSessionPersonaId = activeSession?.personaId; - const handleInitialMessageConsumed = useCallback(() => { - setPendingInitialMessage(undefined); - setPendingInitialAttachments(undefined); - setHomeSelectedProvider(undefined); - setHomeSelectedPersonaId(undefined); - }, []); + }, [clearActiveSession, sessionStore]); const editingProjectProp = useMemo( () => @@ -551,7 +637,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) { onCollapse={toggleSidebar} onNavigate={handleNavigate} onNewChatInProject={handleNewChatInProject} - onNewChat={() => createNewTab()} + onNewChat={() => { + sessionStore.setActiveSession(null); + setActiveView("home"); + }} onCreateProject={() => openCreateProjectDialog()} onEditProject={handleEditProject} onArchiveProject={handleArchiveProject} @@ -582,15 +671,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) { { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + export function useAppStartup() { useEffect(() => { (async () => { @@ -22,6 +29,7 @@ export function useAppStartup() { } const store = useAgentStore.getState(); + const inventoryStore = useProviderInventoryStore.getState(); const loadPersonas = async () => { const t0 = performance.now(); store.setPersonasLoading(true); @@ -56,6 +64,76 @@ export function useAppStartup() { } }; + const loadProviderInventory = async () => { + const t0 = performance.now(); + inventoryStore.setLoading(true); + try { + const { getProviderInventory } = await import( + "@/features/providers/api/inventory" + ); + const entries = await getProviderInventory(); + inventoryStore.setEntries(entries); + perfLog( + `[perf:startup] loadProviderInventory done in ${(performance.now() - t0).toFixed(1)}ms (n=${entries.length})`, + ); + return entries; + } catch (err) { + console.error("Failed to load provider inventory on startup:", err); + return []; + } finally { + inventoryStore.setLoading(false); + } + }; + + const refreshConfiguredProviderInventory = async ( + initialEntries?: Awaited>, + ) => { + try { + const entries = + initialEntries && initialEntries.length > 0 + ? initialEntries + : await (async () => { + const { getProviderInventory } = await import( + "@/features/providers/api/inventory" + ); + return getProviderInventory(); + })(); + const configuredProviderIds = entries + .filter((entry) => entry.configured) + .map((entry) => entry.providerId); + if (configuredProviderIds.length === 0) { + return; + } + + const { getProviderInventory, refreshProviderInventory } = + await import("@/features/providers/api/inventory"); + const refresh = await refreshProviderInventory(configuredProviderIds); + if (refresh.started.length === 0) { + return; + } + + inventoryStore.mergeEntries( + await getProviderInventory(refresh.started), + ); + + for (const delayMs of INVENTORY_POLL_DELAYS_MS) { + await sleep(delayMs); + const refreshedEntries = await getProviderInventory( + refresh.started, + ); + inventoryStore.mergeEntries(refreshedEntries); + if (refreshedEntries.every((entry) => !entry.refreshing)) { + return; + } + } + } catch (err) { + console.error( + "Failed to refresh provider inventory on startup:", + err, + ); + } + }; + const loadSessionState = async () => { const t0 = performance.now(); perfLog("[perf:startup] loadSessionState start"); @@ -68,11 +146,17 @@ export function useAppStartup() { setActiveSession(null); }; + const inventoryLoad = loadProviderInventory(); + await Promise.allSettled([ loadPersonas(), loadProviders(), + inventoryLoad, loadSessionState(), ]); + void inventoryLoad.then((entries) => + refreshConfiguredProviderInventory(entries), + ); perfLog( `[perf:startup] useAppStartup complete in ${(performance.now() - tStartup).toFixed(1)}ms`, ); diff --git a/ui/goose2/src/app/ui/AppShellContent.tsx b/ui/goose2/src/app/ui/AppShellContent.tsx index 4100082a687f..c07cfa4aa885 100644 --- a/ui/goose2/src/app/ui/AppShellContent.tsx +++ b/ui/goose2/src/app/ui/AppShellContent.tsx @@ -5,31 +5,19 @@ import { AgentsView } from "@/features/agents/ui/AgentsView"; import { ProjectsView } from "@/features/projects/ui/ProjectsView"; import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView"; import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; -import type { ChatAttachmentDraft } from "@/shared/types/messages"; import type { ProjectInfo } from "@/features/projects/api/projects"; import type { AppView } from "../AppShell"; interface AppShellContentProps { activeView: AppView; activeSession?: ChatSession; - activeSessionPersonaId?: string; - homeSelectedProvider?: string; - homeSelectedPersonaId?: string; - pendingInitialMessage?: string; - pendingInitialAttachments?: ChatAttachmentDraft[]; + homeSessionId: string | null; onArchiveChat: (sessionId: string) => Promise; onCreateProject: (options?: { initialWorkingDir?: string | null; onCreated?: (projectId: string) => void; }) => void; - onHomeStartChat: ( - initialMessage?: string, - providerId?: string, - personaId?: string, - projectId?: string | null, - attachments?: ChatAttachmentDraft[], - ) => void; - onInitialMessageConsumed: () => void; + onActivateHomeSession: (sessionId: string) => void; onRenameChat: (sessionId: string, nextTitle: string) => void; onSelectSession: (sessionId: string) => void; onSelectSearchResult: ( @@ -43,15 +31,10 @@ interface AppShellContentProps { export function AppShellContent({ activeView, activeSession, - activeSessionPersonaId, - homeSelectedProvider, - homeSelectedPersonaId, - pendingInitialMessage, - pendingInitialAttachments, + homeSessionId, onArchiveChat, onCreateProject, - onHomeStartChat, - onInitialMessageConsumed, + onActivateHomeSession, onRenameChat, onSelectSession, onSelectSearchResult, @@ -74,21 +57,24 @@ export function AppShellContent({ /> ); case "chat": - case "home": return activeSession ? ( ) : ( + ); + case "home": + return ( + ); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useAgentModelPickerState.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useAgentModelPickerState.test.ts new file mode 100644 index 000000000000..be091de5f46a --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useAgentModelPickerState.test.ts @@ -0,0 +1,125 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useAgentModelPickerState } from "../useAgentModelPickerState"; + +const mockUseProviderInventory = vi.fn(); + +vi.mock("@/features/providers/hooks/useProviderInventory", () => ({ + useProviderInventory: () => mockUseProviderInventory(), +})); + +describe("useAgentModelPickerState", () => { + it("switches to goose when the current provider is goose-backed", () => { + const onProviderSelected = vi.fn(); + + mockUseProviderInventory.mockReturnValue({ + entries: new Map([ + [ + "anthropic", + { + providerId: "anthropic", + configured: true, + refreshing: false, + models: [], + }, + ], + ]), + getEntry: (providerId: string) => + providerId === "anthropic" + ? { + providerId: "anthropic", + configured: true, + refreshing: false, + models: [], + } + : undefined, + configuredModelProviderEntries: [], + getModelsForAgent: () => [], + loading: false, + }); + + const { result } = renderHook(() => + useAgentModelPickerState({ + providers: [{ id: "anthropic", label: "Anthropic" }], + selectedProvider: "anthropic", + onProviderSelected, + }), + ); + + act(() => { + result.current.handleProviderChange("goose"); + }); + + expect(onProviderSelected).toHaveBeenCalledWith("goose"); + }); + + it("treats goose as a no-op only when goose is already selected", () => { + const onProviderSelected = vi.fn(); + + mockUseProviderInventory.mockReturnValue({ + entries: new Map(), + getEntry: () => undefined, + configuredModelProviderEntries: [], + getModelsForAgent: () => [], + loading: false, + }); + + const { result } = renderHook(() => + useAgentModelPickerState({ + providers: [], + selectedProvider: "goose", + onProviderSelected, + }), + ); + + act(() => { + result.current.handleProviderChange("goose"); + }); + + expect(onProviderSelected).not.toHaveBeenCalled(); + }); + + it("passes the selected model provider through for goose model picks", () => { + const onModelSelected = vi.fn(); + + mockUseProviderInventory.mockReturnValue({ + entries: new Map(), + getEntry: () => undefined, + configuredModelProviderEntries: [], + getModelsForAgent: () => [ + { + id: "claude-sonnet-4", + name: "claude-sonnet-4", + displayName: "Claude Sonnet 4", + providerId: "anthropic", + providerName: "Anthropic", + recommended: true, + }, + ], + loading: false, + }); + + const { result } = renderHook(() => + useAgentModelPickerState({ + providers: [{ id: "goose", label: "Goose" }], + selectedProvider: "openai", + onProviderSelected: vi.fn(), + onModelSelected, + }), + ); + + act(() => { + result.current.handleModelChange("claude-sonnet-4"); + }); + + expect(onModelSelected).toHaveBeenCalledWith({ + id: "claude-sonnet-4", + name: "claude-sonnet-4", + displayName: "Claude Sonnet 4", + provider: undefined, + providerId: "anthropic", + providerName: "Anthropic", + recommended: true, + }); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChat.attachments.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChat.attachments.test.ts index 2cf709efe792..94001897e85c 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useChat.attachments.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChat.attachments.test.ts @@ -33,8 +33,6 @@ describe("useChat attachments", () => { isLoading: false, contextPanelOpenBySession: {}, activeWorkspaceBySession: {}, - modelsBySession: {}, - modelCacheByProvider: {}, }); useAgentStore.setState({ personas: [], diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts index 3dc918e7d60e..3af65371269d 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChat.test.ts @@ -81,8 +81,6 @@ describe("useChat", () => { isLoading: false, contextPanelOpenBySession: {}, activeWorkspaceBySession: {}, - modelsBySession: {}, - modelCacheByProvider: {}, }); useAgentStore.setState({ personas: [ @@ -354,7 +352,7 @@ describe("useChat", () => { }); }); - it("prepares draft sessions before applying a selected model on first send", async () => { + it("sends messages without an extra session preparation step", async () => { useChatSessionStore.setState({ sessions: [ { @@ -366,28 +364,16 @@ describe("useChat", () => { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), messageCount: 0, - draft: true, }, ], }); - const { result } = renderHook(() => - useChat("session-1", "openai", undefined, undefined, async () => "/tmp"), - ); + const { result } = renderHook(() => useChat("session-1", "openai")); await act(async () => { await result.current.sendMessage("Hello"); }); - expect(mockAcpPrepareSession).toHaveBeenCalledWith( - "session-1", - "openai", - "/tmp", - { - personaId: undefined, - }, - ); - expect(mockAcpSetModel).toHaveBeenCalledWith("session-1", "gpt-4.1"); expect(mockAcpSendMessage).toHaveBeenCalledWith("session-1", "Hello", { systemPrompt: undefined, personaId: undefined, @@ -396,6 +382,50 @@ describe("useChat", () => { }); }); + it("fires onMessageAccepted only after the message enters the session", async () => { + const onMessageAccepted = vi.fn(); + const deferred = createDeferredPromise(); + mockAcpSendMessage.mockReturnValue(deferred.promise); + + const { result } = renderHook(() => + useChat("session-1", undefined, undefined, undefined, { + onMessageAccepted, + }), + ); + + await act(async () => { + const sendPromise = result.current.sendMessage("Hello"); + await Promise.resolve(); + + expect(onMessageAccepted).toHaveBeenCalledTimes(1); + expect( + useChatStore.getState().messagesBySession["session-1"], + ).toHaveLength(1); + + deferred.resolve(); + await sendPromise; + }); + }); + + it("awaits ensurePrepared before prompting", async () => { + const ensurePrepared = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => + useChat("session-1", undefined, undefined, undefined, { + ensurePrepared, + }), + ); + + await act(async () => { + await result.current.sendMessage("Hello"); + }); + + expect(ensurePrepared).toHaveBeenCalledTimes(1); + expect(ensurePrepared.mock.invocationCallOrder[0]).toBeLessThan( + mockAcpSendMessage.mock.invocationCallOrder[0], + ); + }); + it("appends an error message and removes the empty assistant placeholder when send fails", async () => { mockAcpSendMessage.mockRejectedValue( new Error("Working directory missing"), diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.test.ts new file mode 100644 index 000000000000..9c7c4a6dd81e --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -0,0 +1,181 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { useChatStore } from "../../stores/chatStore"; +import { useChatSessionStore } from "../../stores/chatSessionStore"; + +const mockAcpPrepareSession = vi.fn(); +const mockAcpSetModel = vi.fn(); +const mockSetSelectedProvider = vi.fn(); +const mockResolveSessionCwd = vi.fn(); + +vi.mock("@/shared/api/acp", () => ({ + acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args), + acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args), +})); + +vi.mock("../useChat", () => ({ + useChat: () => ({ + messages: [], + chatState: "idle", + tokenState: null, + sendMessage: vi.fn(), + stopStreaming: vi.fn(), + streamingMessageId: null, + }), +})); + +vi.mock("../useMessageQueue", () => ({ + useMessageQueue: () => ({ + queuedMessage: null, + enqueue: vi.fn(), + }), +})); + +vi.mock("@/features/agents/hooks/useProviderSelection", () => ({ + useProviderSelection: () => ({ + providers: [ + { id: "goose", label: "Goose" }, + { id: "openai", label: "OpenAI" }, + { id: "anthropic", label: "Anthropic" }, + ], + providersLoading: false, + selectedProvider: "openai", + setSelectedProvider: (...args: unknown[]) => + mockSetSelectedProvider(...args), + }), +})); + +vi.mock("@/features/projects/lib/sessionCwdSelection", () => ({ + resolveSessionCwd: (...args: unknown[]) => mockResolveSessionCwd(...args), +})); + +vi.mock("../useAgentModelPickerState", () => ({ + useAgentModelPickerState: ({ + onModelSelected, + }: { + onModelSelected?: (model: { + id: string; + name: string; + displayName?: string; + providerId?: string; + }) => void; + }) => ({ + selectedAgentId: "goose", + pickerAgents: [{ id: "goose", label: "Goose" }], + availableModels: [], + modelsLoading: false, + modelStatusMessage: null, + handleProviderChange: vi.fn(), + handleModelChange: (modelId: string) => { + if (modelId === "claude-sonnet-4") { + onModelSelected?.({ + id: modelId, + name: modelId, + displayName: "Claude Sonnet 4", + providerId: "anthropic", + }); + } + }, + }), +})); + +import { useChatSessionController } from "../useChatSessionController"; + +describe("useChatSessionController", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAcpPrepareSession.mockResolvedValue(undefined); + mockAcpSetModel.mockResolvedValue(undefined); + mockResolveSessionCwd.mockResolvedValue("/tmp/project"); + + useAgentStore.setState({ + personas: [], + personasLoading: false, + agents: [], + agentsLoading: false, + providers: [], + providersLoading: false, + selectedProvider: "openai", + activeAgentId: null, + isLoading: false, + personaEditorOpen: false, + editingPersona: null, + }); + + useProjectStore.setState({ + projects: [], + loading: false, + activeProjectId: null, + }); + + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + draftsBySession: {}, + queuedMessageBySession: {}, + scrollTargetMessageBySession: {}, + activeSessionId: null, + isConnected: true, + }); + + useChatSessionStore.setState({ + sessions: [ + { + id: "session-1", + title: "Chat", + providerId: "openai", + modelId: "gpt-4o", + modelName: "GPT-4o", + createdAt: "2026-04-20T00:00:00.000Z", + updatedAt: "2026-04-20T00:00:00.000Z", + messageCount: 0, + }, + ], + activeSessionId: null, + isLoading: false, + hasHydratedSessions: true, + contextPanelOpenBySession: {}, + activeWorkspaceBySession: {}, + }); + }); + + it("prepares the selected model provider before setting a goose model", async () => { + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + act(() => { + result.current.handleModelChange("claude-sonnet-4"); + }); + + await waitFor(() => { + expect(mockAcpPrepareSession).toHaveBeenCalledWith( + "session-1", + "anthropic", + "/tmp/project", + { personaId: undefined }, + ); + }); + + await waitFor(() => { + expect(mockAcpSetModel).toHaveBeenCalledWith( + "session-1", + "claude-sonnet-4", + ); + }); + + expect(mockAcpPrepareSession.mock.invocationCallOrder[0]).toBeLessThan( + mockAcpSetModel.mock.invocationCallOrder[0], + ); + expect(mockSetSelectedProvider).toHaveBeenCalledWith("anthropic"); + expect( + useChatSessionStore.getState().getSession("session-1"), + ).toMatchObject({ + providerId: "anthropic", + modelId: "claude-sonnet-4", + modelName: "Claude Sonnet 4", + }); + }); +}); diff --git a/ui/goose2/src/features/chat/hooks/useAgentModelPickerState.ts b/ui/goose2/src/features/chat/hooks/useAgentModelPickerState.ts new file mode 100644 index 000000000000..5f93140ac579 --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/useAgentModelPickerState.ts @@ -0,0 +1,173 @@ +import { useCallback, useMemo } from "react"; +import type { AcpProvider } from "@/shared/api/acp"; +import { useProviderInventory } from "@/features/providers/hooks/useProviderInventory"; +import { + getCatalogEntry, + resolveAgentProviderCatalogIdStrict, +} from "@/features/providers/providerCatalog"; +import type { ModelOption } from "../types"; + +interface UseAgentModelPickerStateOptions { + providers: AcpProvider[]; + selectedProvider?: string; + onProviderSelected: (providerId: string) => void; + onModelSelected?: (model: ModelOption) => void; +} + +const EMPTY_MODELS: ModelOption[] = []; + +export function useAgentModelPickerState({ + providers, + selectedProvider, + onProviderSelected, + onModelSelected, +}: UseAgentModelPickerStateOptions) { + const { + entries: providerInventoryEntries, + getEntry: getProviderInventoryEntry, + configuredModelProviderEntries, + getModelsForAgent, + loading: providerInventoryLoading, + } = useProviderInventory(); + + const selectedAgentId = selectedProvider + ? (resolveAgentProviderCatalogIdStrict(selectedProvider) ?? "goose") + : "goose"; + const selectedProviderInventory = getProviderInventoryEntry(selectedAgentId); + + const pickerAgents = useMemo(() => { + const visible = new Map(); + + visible.set("goose", { + id: "goose", + label: getCatalogEntry("goose")?.displayName ?? "Goose", + }); + + for (const provider of providers) { + const agentId = resolveAgentProviderCatalogIdStrict(provider.id); + if (!agentId || agentId === "goose") { + continue; + } + + const inventoryEntry = providerInventoryEntries.get(agentId); + if (!inventoryEntry?.configured && agentId !== selectedAgentId) { + continue; + } + + visible.set(agentId, { + id: agentId, + label: getCatalogEntry(agentId)?.displayName ?? provider.label, + }); + } + + if (!visible.has(selectedAgentId)) { + visible.set(selectedAgentId, { + id: selectedAgentId, + label: getCatalogEntry(selectedAgentId)?.displayName ?? selectedAgentId, + }); + } + + return [...visible.values()]; + }, [providerInventoryEntries, providers, selectedAgentId]); + + const availableModels = useMemo( + () => getModelsForAgent(selectedAgentId) ?? EMPTY_MODELS, + [getModelsForAgent, selectedAgentId], + ); + + const modelsLoading = useMemo(() => { + // Show loading only when we have no models to display yet. + // If cached models exist, show them immediately — a background refresh + // will update the list when it completes. + if (availableModels.length > 0) { + return false; + } + + if (providerInventoryLoading) { + return true; + } + + if (selectedAgentId === "goose") { + return ( + configuredModelProviderEntries.length > 0 && + configuredModelProviderEntries.some((entry) => entry.refreshing) + ); + } + + return selectedProviderInventory?.refreshing === true; + }, [ + availableModels.length, + configuredModelProviderEntries, + providerInventoryLoading, + selectedAgentId, + selectedProviderInventory?.refreshing, + ]); + + const modelStatusMessage = useMemo(() => { + if (availableModels.length > 0) { + return null; + } + + if (selectedAgentId === "goose") { + const entryWithHint = configuredModelProviderEntries.find( + (entry) => entry.modelSelectionHint || entry.lastRefreshError, + ); + return ( + entryWithHint?.modelSelectionHint ?? + entryWithHint?.lastRefreshError ?? + null + ); + } + + return ( + selectedProviderInventory?.modelSelectionHint ?? + selectedProviderInventory?.lastRefreshError ?? + null + ); + }, [ + availableModels.length, + configuredModelProviderEntries, + selectedAgentId, + selectedProviderInventory?.modelSelectionHint, + selectedProviderInventory?.lastRefreshError, + ]); + + const handleProviderChange = useCallback( + (providerId: string) => { + if (providerId === (selectedProvider ?? "goose")) { + return; + } + + onProviderSelected(providerId); + }, + [onProviderSelected, selectedProvider], + ); + + const handleModelChange = useCallback( + (modelId: string) => { + const selectedModel = availableModels.find( + (model) => model.id === modelId, + ); + onModelSelected?.({ + id: modelId, + name: selectedModel?.name ?? modelId, + displayName: selectedModel?.displayName ?? modelId, + provider: selectedModel?.provider, + providerId: selectedModel?.providerId, + providerName: selectedModel?.providerName, + recommended: selectedModel?.recommended, + }); + }, + [availableModels, onModelSelected], + ); + + return { + selectedAgentId, + pickerAgents, + availableModels, + modelsLoading, + modelStatusMessage, + handleProviderChange, + handleModelChange, + }; +} diff --git a/ui/goose2/src/features/chat/hooks/useChat.ts b/ui/goose2/src/features/chat/hooks/useChat.ts index 51af77f0dc12..2279ff664ee0 100644 --- a/ui/goose2/src/features/chat/hooks/useChat.ts +++ b/ui/goose2/src/features/chat/hooks/useChat.ts @@ -14,8 +14,6 @@ import { acpSendMessage, acpCancelSession, acpLoadSession, - acpPrepareSession, - acpSetModel, } from "@/shared/api/acp"; import { getGooseSessionId } from "@/shared/api/acpSessionTracker"; import { useAgentStore } from "@/features/agents/stores/agentStore"; @@ -109,7 +107,10 @@ export function useChat( providerOverride?: string, systemPromptOverride?: string, personaInfo?: { id: string; name: string }, - getWorkingDir?: () => Promise, + options?: { + onMessageAccepted?: (sessionId: string) => void; + ensurePrepared?: () => Promise; + }, ) { const store = useChatStore(); const abortRef = useRef(null); @@ -215,15 +216,8 @@ export function useChat( store.setChatState(sessionId, "thinking"); store.setError(sessionId, null); - // Promote draft to real backend session before first send const sessionStore = useChatSessionStore.getState(); const session = sessionStore.getSession(sessionId); - const wasDraft = !!session?.draft; - const selectedModelId = session?.modelId; - - if (wasDraft) { - sessionStore.promoteDraft(sessionId); - } // Immediately set the session/sidebar title from the user's message when // the session still has the default placeholder. This gives instant @@ -231,20 +225,18 @@ export function useChat( // A better backend-generated title will overwrite this if it arrives // via the acp:session_info event. if (session && isDefaultChatTitle(session.title)) { - sessionStore.updateSession( - sessionId, - { - title: getSessionTitleFromDraft(text, attachments), - updatedAt: new Date().toISOString(), - }, - { localOnly: wasDraft }, - ); + sessionStore.updateSession(sessionId, { + title: getSessionTitleFromDraft(text, attachments), + updatedAt: new Date().toISOString(), + }); } else { sessionStore.updateSession(sessionId, { updatedAt: new Date().toISOString(), }); } + options?.onMessageAccepted?.(sessionId); + store.clearDraft(sessionId); const abort = new AbortController(); @@ -252,26 +244,7 @@ export function useChat( streamingPersonaIdRef.current = effectivePersonaInfo?.id ?? null; try { - if (wasDraft || selectedModelId) { - const workingDir = await getWorkingDir?.(); - if (!workingDir) { - throw new Error("Missing session working directory"); - } - const tPrep = performance.now(); - await acpPrepareSession(sessionId, providerId, workingDir, { - personaId: effectivePersonaInfo?.id, - }); - perfLog( - `[perf:send] ${sid} acpPrepareSession in ${(performance.now() - tPrep).toFixed(1)}ms (wasDraft=${wasDraft})`, - ); - if (selectedModelId) { - const tModel = performance.now(); - await acpSetModel(sessionId, selectedModelId); - perfLog( - `[perf:send] ${sid} acpSetModel(${selectedModelId}) in ${(performance.now() - tModel).toFixed(1)}ms`, - ); - } - } + await options?.ensurePrepared?.(); store.setChatState(sessionId, "streaming"); // When images are present with no text, pass a single space so the ACP @@ -298,18 +271,6 @@ export function useChat( store.setChatState(sessionId, "idle"); store.setStreamingMessageId(sessionId, null); - - if (wasDraft) { - const promoted = sessionStore.getSession(sessionId); - if (promoted) { - sessionStore.updateSession(sessionId, { - title: promoted.title, - providerId: promoted.providerId, - personaId: promoted.personaId, - projectId: promoted.projectId, - }); - } - } } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { store.setChatState(sessionId, "idle"); @@ -351,7 +312,7 @@ export function useChat( providerOverride, systemPromptOverride, resolvePersonaInfo, - getWorkingDir, + options, ], ); @@ -415,6 +376,12 @@ export function useChat( store.setPendingAssistantProvider(sessionId, null); }, [sessionId, store]); + const getWorkingDir = useCallback( + () => + useChatSessionStore.getState().activeWorkspaceBySession[sessionId]?.path, + [sessionId], + ); + const compactConversation = useCallback(async () => { const currentChatState = useChatStore .getState() @@ -457,7 +424,7 @@ export function useChat( // layer does not currently forward history replacement events. Drop those // transient chunks and refresh the session from replay instead. clearReplayBuffer(sessionId); - const workingDir = await getWorkingDir?.(); + const workingDir = getWorkingDir(); await acpLoadSession(sessionId, gooseSessionId, workingDir); store.setSessionLoading(sessionId, false); diff --git a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts new file mode 100644 index 000000000000..2b321fed90cd --- /dev/null +++ b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts @@ -0,0 +1,682 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ChatAttachmentDraft } from "@/shared/types/messages"; +import { useChat } from "./useChat"; +import { useMessageQueue } from "./useMessageQueue"; +import { useChatStore } from "../stores/chatStore"; +import { useChatSessionStore } from "../stores/chatSessionStore"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { useAgentModelPickerState } from "./useAgentModelPickerState"; +import { + buildProjectSystemPrompt, + composeSystemPrompt, + getProjectArtifactRoots, + resolveProjectDefaultArtifactRoot, +} from "@/features/projects/lib/chatProjectContext"; +import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection"; +import { acpPrepareSession, acpSetModel } from "@/shared/api/acp"; + +interface UseChatSessionControllerOptions { + sessionId: string | null; + onMessageAccepted?: (sessionId: string) => void; +} + +const PENDING_HOME_SESSION_ID = "__home_pending__"; + +export function useChatSessionController({ + sessionId, + onMessageAccepted, +}: UseChatSessionControllerOptions) { + const stateSessionId = sessionId ?? PENDING_HOME_SESSION_ID; + const { + providers, + providersLoading, + selectedProvider: globalSelectedProvider, + setSelectedProvider: setGlobalSelectedProvider, + } = useProviderSelection(); + const personas = useAgentStore((s) => s.personas); + const session = useChatSessionStore((s) => + sessionId + ? s.sessions.find((candidate) => candidate.id === sessionId) + : undefined, + ); + const activeWorkspace = useChatSessionStore((s) => + sessionId ? s.activeWorkspaceBySession[sessionId] : undefined, + ); + const clearActiveWorkspace = useChatSessionStore( + (s) => s.clearActiveWorkspace, + ); + const projects = useProjectStore((s) => s.projects); + const projectsLoading = useProjectStore((s) => s.loading); + const [pendingPersonaId, setPendingPersonaId] = useState(); + const [pendingProjectId, setPendingProjectId] = useState(); + const [pendingProviderId, setPendingProviderId] = useState(); + const [pendingModelSelection, setPendingModelSelection] = useState<{ + id: string; + name: string; + providerId?: string; + } | null>(); + const pendingDraftValue = useChatStore( + (s) => s.draftsBySession[PENDING_HOME_SESSION_ID] ?? "", + ); + const pendingQueuedMessage = useChatStore( + (s) => s.queuedMessageBySession[PENDING_HOME_SESSION_ID] ?? null, + ); + const effectiveProjectId = + pendingProjectId !== undefined + ? pendingProjectId + : (session?.projectId ?? null); + const storedProject = useProjectStore((s) => + effectiveProjectId + ? s.projects.find((candidate) => candidate.id === effectiveProjectId) + : undefined, + ); + const project = storedProject ?? null; + const selectedProvider = + pendingProviderId ?? + session?.providerId ?? + project?.preferredProvider ?? + globalSelectedProvider; + const selectedPersonaId = + pendingPersonaId !== undefined + ? pendingPersonaId + : (session?.personaId ?? null); + const selectedPersona = personas.find( + (persona) => persona.id === selectedPersonaId, + ); + const projectArtifactRoots = useMemo( + () => getProjectArtifactRoots(project), + [project], + ); + const projectDefaultArtifactRoot = useMemo( + () => resolveProjectDefaultArtifactRoot(project), + [project], + ); + const projectMetadataPending = Boolean( + effectiveProjectId && !projectDefaultArtifactRoot && projectsLoading, + ); + const allowedArtifactRoots = useMemo( + () => [ + ...new Set( + projectArtifactRoots.map((path) => path.trim()).filter(Boolean), + ), + ], + [projectArtifactRoots], + ); + const availableProjects = useMemo( + () => + [...projects] + .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)) + .map((projectInfo) => ({ + id: projectInfo.id, + name: projectInfo.name, + workingDirs: projectInfo.workingDirs, + color: projectInfo.color, + })), + [projects], + ); + const projectSystemPrompt = useMemo( + () => buildProjectSystemPrompt(project), + [project], + ); + const workingContextPrompt = useMemo(() => { + if (!activeWorkspace?.branch) return undefined; + return `\nActive branch: ${activeWorkspace.branch}\nWorking directory: ${activeWorkspace.path}\n`; + }, [activeWorkspace?.branch, activeWorkspace?.path]); + const effectiveSystemPrompt = useMemo( + () => + composeSystemPrompt( + selectedPersona?.systemPrompt, + projectSystemPrompt, + workingContextPrompt, + ), + [projectSystemPrompt, selectedPersona?.systemPrompt, workingContextPrompt], + ); + + const prepareCurrentSession = useCallback( + async ( + providerId: string, + nextProject = project, + nextWorkspacePath = activeWorkspace?.path, + personaId = selectedPersonaId ?? undefined, + ) => { + if (!sessionId) { + return; + } + const workingDir = await resolveSessionCwd( + nextProject, + nextWorkspacePath, + ); + await acpPrepareSession(sessionId, providerId, workingDir, { personaId }); + }, + [activeWorkspace?.path, project, selectedPersonaId, sessionId], + ); + + const prevProjectIdRef = useRef(session?.projectId); + useEffect(() => { + if (!sessionId) { + return; + } + const previousProjectId = prevProjectIdRef.current; + prevProjectIdRef.current = session?.projectId; + if ( + previousProjectId !== undefined && + previousProjectId !== session?.projectId + ) { + clearActiveWorkspace(sessionId); + } + }, [clearActiveWorkspace, session?.projectId, sessionId]); + + const prevWorkspaceRef = useRef(activeWorkspace); + useEffect(() => { + const previousWorkspace = prevWorkspaceRef.current; + if ( + !sessionId || + !activeWorkspace || + !selectedProvider || + activeWorkspace === previousWorkspace + ) { + return; + } + prevWorkspaceRef.current = activeWorkspace; + if (previousWorkspace?.path === activeWorkspace.path) { + return; + } + void prepareCurrentSession(selectedProvider).catch((error) => { + console.error("Failed to prepare ACP session:", error); + }); + }, [activeWorkspace, prepareCurrentSession, selectedProvider, sessionId]); + + const { + selectedAgentId, + pickerAgents, + availableModels, + modelsLoading, + modelStatusMessage, + handleProviderChange, + handleModelChange, + } = useAgentModelPickerState({ + providers, + selectedProvider, + onProviderSelected: (providerId) => { + if (!sessionId) { + setGlobalSelectedProvider(providerId); + setPendingProviderId(providerId); + setPendingModelSelection(null); + return; + } + useChatSessionStore + .getState() + .switchSessionProvider(sessionId, providerId); + setGlobalSelectedProvider(providerId); + void prepareCurrentSession(providerId).catch((error) => { + console.error("Failed to update ACP session provider:", error); + }); + }, + onModelSelected: (model) => { + const modelId = model.id; + const modelName = model.displayName ?? model.name ?? model.id; + const nextProviderId = model.providerId ?? selectedProvider; + + if (!sessionId) { + if (nextProviderId && nextProviderId !== selectedProvider) { + setPendingProviderId(nextProviderId); + setGlobalSelectedProvider(nextProviderId); + } + setPendingModelSelection({ + id: modelId, + name: modelName, + providerId: nextProviderId, + }); + return; + } + if ( + !session || + (modelId === session.modelId && + (!nextProviderId || nextProviderId === session.providerId)) + ) { + return; + } + const previousProviderId = session.providerId; + const previousModelId = session.modelId; + const previousModelName = session.modelName; + const providerChanged = + Boolean(nextProviderId) && nextProviderId !== session.providerId; + + if (providerChanged && nextProviderId) { + useChatSessionStore + .getState() + .switchSessionProvider(sessionId, nextProviderId); + setGlobalSelectedProvider(nextProviderId); + } + + useChatSessionStore.getState().updateSession(sessionId, { + modelId, + modelName, + }); + + void (async () => { + try { + if (providerChanged && nextProviderId) { + await prepareCurrentSession(nextProviderId); + } + await acpSetModel(sessionId, modelId); + } catch (error) { + console.error("Failed to set model:", error); + if (providerChanged && previousProviderId) { + setGlobalSelectedProvider(previousProviderId); + } + useChatSessionStore.getState().updateSession(sessionId, { + providerId: previousProviderId, + modelId: previousModelId, + modelName: previousModelName, + }); + void (async () => { + try { + if (providerChanged && previousProviderId) { + await prepareCurrentSession(previousProviderId); + } + if (previousModelId) { + await acpSetModel(sessionId, previousModelId); + } + } catch (rollbackError) { + console.error( + "Failed to restore previous provider/model after setModel failure:", + rollbackError, + ); + } + })(); + } + })(); + }, + }); + + const handleProjectChange = useCallback( + (projectId: string | null) => { + if (!sessionId) { + setPendingProjectId(projectId); + return; + } + const nextProject = + projectId == null + ? null + : (useProjectStore + .getState() + .projects.find((candidate) => candidate.id === projectId) ?? + null); + + useChatSessionStore.getState().updateSession(sessionId, { projectId }); + if (!selectedProvider) { + return; + } + void prepareCurrentSession(selectedProvider, nextProject).catch( + (error) => { + console.error( + "Failed to update ACP session working directory:", + error, + ); + }, + ); + }, + [prepareCurrentSession, selectedProvider, sessionId], + ); + + const handlePersonaChange = useCallback( + (personaId: string | null) => { + const persona = personas.find((candidate) => candidate.id === personaId); + if (persona?.provider) { + const matchingProvider = providers.find( + (provider) => + provider.id === persona.provider || + provider.label.toLowerCase().includes(persona.provider ?? ""), + ); + if (matchingProvider) { + if (!sessionId) { + setPendingProviderId(matchingProvider.id); + setPendingModelSelection(null); + setGlobalSelectedProvider(matchingProvider.id); + } else { + handleProviderChange(matchingProvider.id); + } + } + } + const agentStore = useAgentStore.getState(); + const matchingAgent = agentStore.agents.find( + (agent) => agent.personaId === personaId, + ); + if (matchingAgent) { + agentStore.setActiveAgent(matchingAgent.id); + } + if (!sessionId) { + setPendingPersonaId(personaId); + return; + } + useChatSessionStore + .getState() + .updateSession(sessionId, { personaId: personaId ?? undefined }); + }, + [ + handleProviderChange, + personas, + providers, + sessionId, + setGlobalSelectedProvider, + ], + ); + + useEffect(() => { + if ( + selectedPersonaId !== null && + personas.length > 0 && + !personas.find((persona) => persona.id === selectedPersonaId) + ) { + if (sessionId) { + useChatSessionStore + .getState() + .updateSession(sessionId, { personaId: undefined }); + } else { + setPendingPersonaId(undefined); + } + } + }, [personas, selectedPersonaId, sessionId]); + + const personaInfo = selectedPersona + ? { id: selectedPersona.id, name: selectedPersona.displayName } + : undefined; + const { + messages, + chatState, + tokenState, + sendMessage, + stopStreaming, + streamingMessageId, + } = useChat( + stateSessionId, + selectedProvider, + effectiveSystemPrompt, + personaInfo, + { + onMessageAccepted: sessionId ? onMessageAccepted : undefined, + ensurePrepared: selectedProvider + ? () => prepareCurrentSession(selectedProvider) + : undefined, + }, + ); + const isLoadingHistory = useChatStore((s) => + sessionId + ? s.loadingSessionIds.has(sessionId) && + (s.messagesBySession[sessionId]?.length ?? 0) === 0 + : false, + ); + const deferredSend = useRef<{ + text: string; + attachments?: ChatAttachmentDraft[]; + } | null>(null); + const queue = useMessageQueue( + stateSessionId, + sessionId ? chatState : "thinking", + sendMessage, + ); + const chatStore = useChatStore(); + + const handleSend = useCallback( + (text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => { + if (!sessionId) { + if (!queue.queuedMessage) { + queue.enqueue(text, personaId, attachments); + } + return; + } + + if (personaId && personaId !== selectedPersonaId) { + const nextPersona = personas.find( + (persona) => persona.id === personaId, + ); + if (nextPersona) { + chatStore.addMessage(sessionId, { + id: crypto.randomUUID(), + role: "system", + created: Date.now(), + content: [ + { + type: "systemNotification", + notificationType: "info", + text: `Switched to ${nextPersona.displayName}`, + }, + ], + metadata: { userVisible: true, agentVisible: false }, + }); + } + handlePersonaChange(personaId); + deferredSend.current = { text, attachments }; + return; + } + + if (chatState !== "idle" && !queue.queuedMessage) { + queue.enqueue(text, personaId, attachments); + return; + } + + sendMessage(text, undefined, attachments); + }, + [ + chatState, + chatStore, + handlePersonaChange, + personas, + queue, + sessionId, + selectedPersonaId, + sendMessage, + ], + ); + + useEffect(() => { + if (deferredSend.current && selectedPersona) { + const { text, attachments } = deferredSend.current; + deferredSend.current = null; + sendMessage(text, undefined, attachments); + } + }, [selectedPersona, sendMessage]); + + const handleCreatePersona = useCallback(() => { + useAgentStore.getState().openPersonaEditor(); + }, []); + + const sessionDraftValue = useChatStore((s) => + sessionId ? (s.draftsBySession[sessionId] ?? "") : "", + ); + const draftValue = sessionId ? sessionDraftValue : pendingDraftValue; + const handleDraftChange = useCallback( + (text: string) => { + useChatStore.getState().setDraft(stateSessionId, text); + }, + [stateSessionId], + ); + const scrollTarget = useChatStore((s) => + sessionId ? (s.scrollTargetMessageBySession[sessionId] ?? null) : null, + ); + const handleScrollTargetHandled = useCallback(() => { + if (!sessionId) { + return; + } + useChatStore.getState().clearScrollTargetMessage(sessionId); + }, [sessionId]); + + useEffect(() => { + if (!sessionId) { + return; + } + + let cancelled = false; + void pendingDraftValue; + void pendingQueuedMessage; + + const syncPendingHomeState = async () => { + const chatState = useChatStore.getState(); + const pendingDraft = + chatState.draftsBySession[PENDING_HOME_SESSION_ID] ?? ""; + + if (pendingDraft && !chatState.draftsBySession[sessionId]) { + chatState.setDraft(sessionId, pendingDraft); + } + + const hasPendingProvider = pendingProviderId !== undefined; + const hasPendingPersona = pendingPersonaId !== undefined; + const hasPendingProject = pendingProjectId !== undefined; + const hasPendingModel = pendingModelSelection !== undefined; + + if ( + hasPendingProvider || + hasPendingPersona || + hasPendingProject || + hasPendingModel + ) { + const nextProviderId = pendingProviderId ?? selectedProvider; + const nextPersonaId = + pendingPersonaId !== undefined + ? (pendingPersonaId ?? undefined) + : session?.personaId; + const nextProjectId = + pendingProjectId !== undefined + ? pendingProjectId + : session?.projectId; + const nextProject = + nextProjectId == null + ? null + : (useProjectStore + .getState() + .projects.find((candidate) => candidate.id === nextProjectId) ?? + null); + + const patch: { + providerId?: string; + personaId?: string | undefined; + projectId?: string | null; + modelId?: string | undefined; + modelName?: string | undefined; + } = {}; + + if (hasPendingProvider) { + patch.providerId = nextProviderId; + patch.modelId = undefined; + patch.modelName = undefined; + } + if (hasPendingPersona) { + patch.personaId = nextPersonaId; + } + if (hasPendingProject) { + patch.projectId = nextProjectId ?? null; + } + if (hasPendingModel) { + patch.modelId = pendingModelSelection?.id; + patch.modelName = pendingModelSelection?.name; + } + + useChatSessionStore.getState().updateSession(sessionId, patch); + + try { + await prepareCurrentSession( + nextProviderId, + nextProject, + activeWorkspace?.path, + nextPersonaId, + ); + if (cancelled) { + return; + } + if (pendingModelSelection?.id) { + await acpSetModel(sessionId, pendingModelSelection.id); + if (cancelled) { + return; + } + } + } catch (error) { + console.error("Failed to sync pending Home state:", error); + return; + } + + setPendingProviderId(undefined); + setPendingPersonaId(undefined); + setPendingProjectId(undefined); + setPendingModelSelection(undefined); + } + + const latestChatState = useChatStore.getState(); + const latestPendingQueue = + latestChatState.queuedMessageBySession[PENDING_HOME_SESSION_ID] ?? null; + if ( + latestPendingQueue && + !latestChatState.queuedMessageBySession[sessionId] + ) { + latestChatState.enqueueMessage(sessionId, latestPendingQueue); + } + + useChatStore.getState().clearDraft(PENDING_HOME_SESSION_ID); + useChatStore.getState().dismissQueuedMessage(PENDING_HOME_SESSION_ID); + useChatStore.getState().cleanupSession(PENDING_HOME_SESSION_ID); + }; + + void syncPendingHomeState(); + + return () => { + cancelled = true; + }; + }, [ + activeWorkspace?.path, + pendingDraftValue, + pendingModelSelection, + pendingPersonaId, + pendingProjectId, + pendingProviderId, + pendingQueuedMessage, + prepareCurrentSession, + selectedProvider, + session?.personaId, + session?.projectId, + sessionId, + ]); + + return { + session, + project, + allowedArtifactRoots, + messages, + chatState, + tokenState, + stopStreaming, + streamingMessageId, + isLoadingHistory, + queue, + handleSend, + draftValue, + handleDraftChange, + scrollTarget, + handleScrollTargetHandled, + projectMetadataPending, + personas, + selectedPersonaId, + handlePersonaChange, + handleCreatePersona, + pickerAgents, + providersLoading, + selectedProvider: selectedAgentId, + handleProviderChange, + currentModelId: + pendingModelSelection !== undefined + ? (pendingModelSelection?.id ?? null) + : (session?.modelId ?? null), + currentModelName: + pendingModelSelection !== undefined + ? (pendingModelSelection?.name ?? null) + : session?.modelName, + availableModels, + modelsLoading, + modelStatusMessage, + handleModelChange, + selectedProjectId: effectiveProjectId, + availableProjects, + handleProjectChange, + }; +} diff --git a/ui/goose2/src/features/chat/lib/newChat.test.ts b/ui/goose2/src/features/chat/lib/newChat.test.ts index a30ce73f8a22..c0fa7bcc6bbb 100644 --- a/ui/goose2/src/features/chat/lib/newChat.test.ts +++ b/ui/goose2/src/features/chat/lib/newChat.test.ts @@ -2,170 +2,78 @@ import { describe, expect, it } from "vitest"; import { findExistingDraft } from "./newChat"; import type { ChatSession } from "../stores/chatSessionStore"; -function makeDraft(overrides: Partial = {}): ChatSession { +function makeSession( + id: string, + overrides: Partial = {}, +): ChatSession { return { - id: "session-1", + id, title: "New Chat", - createdAt: "2026-03-31T10:00:00.000Z", - updatedAt: "2026-03-31T10:00:00.000Z", + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", messageCount: 0, - draft: true, ...overrides, }; } describe("findExistingDraft", () => { - it("reuses the active empty draft session", () => { - const activeDraft = makeDraft({ id: "active-draft" }); - - const result = findExistingDraft({ - sessions: [activeDraft], - activeSessionId: activeDraft.id, - draftsBySession: {}, - messagesBySession: {}, - request: { title: "New Chat" }, - }); - - expect(result?.id).toBe(activeDraft.id); - }); - - it("does not reuse non-draft sessions", () => { - const realSession = makeDraft({ id: "real", draft: undefined }); - - const result = findExistingDraft({ - sessions: [realSession], - activeSessionId: realSession.id, - draftsBySession: {}, - messagesBySession: {}, - request: { title: "New Chat" }, - }); - - expect(result).toBeUndefined(); - }); - - it("does not reuse drafts that already have messages", () => { - const result = findExistingDraft({ - sessions: [makeDraft({ id: "used-draft", messageCount: 1 })], - activeSessionId: "used-draft", - draftsBySession: {}, - messagesBySession: {}, - request: { title: "New Chat" }, + it("reuses a matching project draft with content", () => { + const draft = makeSession("alpha-draft", { + projectId: "alpha", + providerId: "goose", }); - expect(result).toBeUndefined(); + expect( + findExistingDraft({ + sessions: [draft], + activeSessionId: null, + draftsBySession: { "alpha-draft": "alpha draft" }, + messagesBySession: {}, + request: { + title: "New Chat", + projectId: "alpha", + }, + }), + ).toEqual(draft); }); - it("does not reuse drafts with local in-memory messages", () => { - const result = findExistingDraft({ - sessions: [makeDraft({ id: "streaming-draft" })], - activeSessionId: "streaming-draft", - draftsBySession: {}, - messagesBySession: { - "streaming-draft": [ - { - id: "msg-1", - role: "user", - created: Date.now(), - content: [{ type: "text", text: "hello" }], - }, - ], - }, - request: { title: "New Chat" }, + it("does not reuse a draft from a different project", () => { + const draft = makeSession("alpha-draft", { + projectId: "alpha", + providerId: "goose", }); - expect(result).toBeUndefined(); + expect( + findExistingDraft({ + sessions: [draft], + activeSessionId: null, + draftsBySession: { "alpha-draft": "alpha draft" }, + messagesBySession: {}, + request: { + title: "New Chat", + projectId: "beta", + }, + }), + ).toBeUndefined(); }); - it("only reuses drafts for the same chat context", () => { - const projectDraft = makeDraft({ - id: "project-draft", - projectId: "project-1", - }); - - const result = findExistingDraft({ - sessions: [projectDraft], - activeSessionId: projectDraft.id, - draftsBySession: {}, - messagesBySession: {}, - request: { title: "New Chat", projectId: "project-2" }, - }); - - expect(result).toBeUndefined(); - }); - - it("does not reuse drafts when creating a titled chat", () => { - const result = findExistingDraft({ - sessions: [makeDraft()], - activeSessionId: "session-1", - draftsBySession: {}, - messagesBySession: {}, - request: { title: "What day is it?" }, - }); - - expect(result).toBeUndefined(); - }); - - it("finds a non-active draft with content", () => { - const draftWithContent = makeDraft({ id: "background-draft" }); - - const result = findExistingDraft({ - sessions: [draftWithContent], - activeSessionId: null, - draftsBySession: { "background-draft": "some typed text" }, - messagesBySession: {}, - request: { title: "New Chat" }, - }); - - expect(result?.id).toBe("background-draft"); - }); - - it("prefers active draft with content over non-active", () => { - const activeDraft = makeDraft({ id: "active-draft" }); - const backgroundDraft = makeDraft({ id: "background-draft" }); - - const result = findExistingDraft({ - sessions: [backgroundDraft, activeDraft], - activeSessionId: "active-draft", - draftsBySession: { - "active-draft": "active text", - "background-draft": "background text", - }, - messagesBySession: {}, - request: { title: "New Chat" }, - }); - - expect(result?.id).toBe("active-draft"); - }); - - it("finds an inactive empty draft when no draft has content", () => { - const inactiveDraft = makeDraft({ - id: "inactive-draft", - updatedAt: "2026-03-31T12:00:00.000Z", - }); - - const result = findExistingDraft({ - sessions: [inactiveDraft], - activeSessionId: null, - draftsBySession: {}, - messagesBySession: {}, - request: { title: "New Chat" }, - }); - - expect(result?.id).toBe("inactive-draft"); - }); - - it("prefers drafts with content over empty drafts", () => { - const emptyDraft = makeDraft({ id: "empty-draft" }); - const contentDraft = makeDraft({ id: "content-draft" }); - - const result = findExistingDraft({ - sessions: [emptyDraft, contentDraft], - activeSessionId: "empty-draft", - draftsBySession: { "content-draft": "has text" }, - messagesBySession: {}, - request: { title: "New Chat" }, + it("does not reuse an abandoned empty draft", () => { + const draft = makeSession("alpha-draft", { + projectId: "alpha", + providerId: "goose", }); - expect(result?.id).toBe("content-draft"); + expect( + findExistingDraft({ + sessions: [draft], + activeSessionId: null, + draftsBySession: {}, + messagesBySession: {}, + request: { + title: "New Chat", + projectId: "alpha", + }, + }), + ).toBeUndefined(); }); }); diff --git a/ui/goose2/src/features/chat/lib/newChat.ts b/ui/goose2/src/features/chat/lib/newChat.ts index 90d534100397..25cf47d98542 100644 --- a/ui/goose2/src/features/chat/lib/newChat.ts +++ b/ui/goose2/src/features/chat/lib/newChat.ts @@ -5,9 +5,6 @@ import { DEFAULT_CHAT_TITLE } from "./sessionTitle"; interface NewChatRequest { title: string; projectId?: string; - agentId?: string; - providerId?: string; - personaId?: string; } interface FindExistingDraftArgs { @@ -22,24 +19,14 @@ function isMatchingContext( session: ChatSession, request: Omit, ): boolean { - return ( - session.projectId === request.projectId && - session.agentId === request.agentId && - session.providerId === request.providerId && - session.personaId === request.personaId - ); + return session.projectId === request.projectId; } function isReusableDraft( session: ChatSession, - localMessages: Message[] | undefined, + _localMessages: Message[] | undefined, ): boolean { - return ( - !!session.draft && - !session.archivedAt && - session.messageCount === 0 && - (localMessages?.length ?? 0) === 0 - ); + return !session.archivedAt && session.messageCount === 0; } export function findExistingDraft({ @@ -64,18 +51,14 @@ export function findExistingDraft({ } const withContent = candidates.filter( - (s) => (draftsBySession[s.id] ?? "").length > 0, + (session) => (draftsBySession[session.id] ?? "").length > 0, ); if (withContent.length > 0) { - return withContent.find((s) => s.id === activeSessionId) ?? withContent[0]; - } - - const active = candidates.find((s) => s.id === activeSessionId); - if (active) { - return active; + return ( + withContent.find((session) => session.id === activeSessionId) ?? + withContent[0] + ); } - return candidates.sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), - )[0]; + return candidates.find((session) => session.id === activeSessionId); } diff --git a/ui/goose2/src/features/chat/lib/sessionMetadataOverlay.ts b/ui/goose2/src/features/chat/lib/sessionMetadataOverlay.ts index 1c70777b0454..befab7b96ab1 100644 --- a/ui/goose2/src/features/chat/lib/sessionMetadataOverlay.ts +++ b/ui/goose2/src/features/chat/lib/sessionMetadataOverlay.ts @@ -1,9 +1,7 @@ import type { ModelOption } from "../types"; const ACP_SESSION_METADATA_STORAGE_KEY = "goose:acp-session-metadata"; -const DRAFT_SESSION_STORAGE_KEY = "goose:chat-draft-sessions"; const LEGACY_SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions"; -const DRAFT_TEXT_STORAGE_KEY = "goose:chat-drafts"; export interface SessionMetadataOverlayRecord { sessionId: string; @@ -22,7 +20,7 @@ export interface SessionMetadataOverlayRecord { updatedAt: string; } -export interface DraftSessionRecord { +interface LegacySessionRecord { id: string; acpSessionId?: string; title: string; @@ -36,7 +34,7 @@ export interface DraftSessionRecord { updatedAt: string; archivedAt?: string; messageCount: number; - draft?: true; + draft?: boolean; userSetName?: boolean; } @@ -65,32 +63,14 @@ function persistStorageArray(storageKey: string, records: T[]): void { } } -function draftsWithText(): Set { - if (typeof window === "undefined") return new Set(); - try { - const stored = window.localStorage.getItem(DRAFT_TEXT_STORAGE_KEY); - if (!stored) return new Set(); - const parsed = JSON.parse(stored); - return new Set( - Object.entries(parsed) - .filter(([, value]) => typeof value === "string" && value.length > 0) - .map(([sessionId]) => sessionId), - ); - } catch { - return new Set(); - } -} - -function loadLegacySessions(): Array< - DraftSessionRecord & { - userSetName?: boolean; - } -> { - return parseStorageArray(LEGACY_SESSION_CACHE_STORAGE_KEY); +function loadLegacySessions(): LegacySessionRecord[] { + return parseStorageArray( + LEGACY_SESSION_CACHE_STORAGE_KEY, + ); } function recordFromLegacySession( - session: DraftSessionRecord & { userSetName?: boolean }, + session: LegacySessionRecord, ): SessionMetadataOverlayRecord { return { sessionId: session.acpSessionId ?? session.id, @@ -138,49 +118,6 @@ export function persistSessionMetadataOverlay( ); } -export function loadDraftSessionRecords(): DraftSessionRecord[] { - const records = parseStorageArray( - DRAFT_SESSION_STORAGE_KEY, - ); - const drafts = new Map(records.map((record) => [record.id, record])); - - for (const session of loadLegacySessions()) { - if (!session.draft) continue; - if (drafts.has(session.id)) continue; - drafts.set(session.id, session); - } - - return [...drafts.values()]; -} - -export function persistDraftSessionRecords( - records: DraftSessionRecord[], -): void { - const withText = draftsWithText(); - persistStorageArray( - DRAFT_SESSION_STORAGE_KEY, - records.filter((record) => withText.has(record.id)), - ); -} - -export function migrateSessionMetadataOverlayId( - previousId: string, - nextId: string, -): void { - if (!previousId || !nextId || previousId === nextId) return; - const overlays = loadSessionMetadataOverlay(); - const previous = overlays.get(previousId); - if (!previous) return; - const existing = overlays.get(nextId); - overlays.set(nextId, { - ...previous, - ...existing, - sessionId: nextId, - }); - overlays.delete(previousId); - persistSessionMetadataOverlay(overlays.values()); -} - export function upsertSessionMetadataOverlayRecord( record: SessionMetadataOverlayRecord, ): void { @@ -195,21 +132,6 @@ export function removeSessionMetadataOverlayRecord(sessionId: string): void { persistSessionMetadataOverlay(overlays.values()); } -export function persistDraftSessionRecord(record: DraftSessionRecord): void { - const drafts = loadDraftSessionRecords(); - const nextDrafts = [ - ...drafts.filter((draft) => draft.id !== record.id), - record, - ]; - persistDraftSessionRecords(nextDrafts); -} - -export function removeDraftSessionRecord(sessionId: string): void { - persistDraftSessionRecords( - loadDraftSessionRecords().filter((record) => record.id !== sessionId), - ); -} - export function modelIdsMatch( cached: ModelOption[] | undefined, next: ModelOption[], diff --git a/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts b/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts index 497869fa5d88..61f5431fb033 100644 --- a/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts +++ b/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts @@ -1,148 +1,97 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AcpSessionInfo } from "@/shared/api/acp"; -import { useChatSessionStore } from "../chatSessionStore"; +import { useChatSessionStore, type ChatSession } from "../chatSessionStore"; + +const mockAcpCreateSession = vi.fn(); +const mockAcpListSessions = vi.fn(); vi.mock("@/shared/api/acp", () => ({ - acpListSessions: vi.fn(), + acpCreateSession: (...args: unknown[]) => mockAcpCreateSession(...args), + acpListSessions: (...args: unknown[]) => mockAcpListSessions(...args), })); -import { acpListSessions } from "@/shared/api/acp"; - -const mockedAcpListSessions = vi.mocked(acpListSessions); - const LEGACY_SESSION_CACHE_KEY = "goose:chat-sessions"; const OVERLAY_CACHE_KEY = "goose:acp-session-metadata"; -const DRAFT_SESSION_CACHE_KEY = "goose:chat-draft-sessions"; function resetStore() { useChatSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, + hasHydratedSessions: false, contextPanelOpenBySession: {}, activeWorkspaceBySession: {}, - modelsBySession: {}, - modelCacheByProvider: {}, }); } +function makeSession(overrides: Partial = {}): ChatSession { + return { + id: "session-1", + acpSessionId: "session-1", + title: "Test Session", + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + messageCount: 0, + ...overrides, + }; +} + +function seedSession(overrides: Partial = {}): ChatSession { + const session = makeSession(overrides); + useChatSessionStore.getState().addSession(session); + return session; +} + describe("chatSessionStore", () => { beforeEach(() => { resetStore(); window.localStorage.removeItem(LEGACY_SESSION_CACHE_KEY); window.localStorage.removeItem(OVERLAY_CACHE_KEY); - window.localStorage.removeItem(DRAFT_SESSION_CACHE_KEY); vi.clearAllMocks(); }); afterEach(() => { window.localStorage.removeItem(LEGACY_SESSION_CACHE_KEY); window.localStorage.removeItem(OVERLAY_CACHE_KEY); - window.localStorage.removeItem(DRAFT_SESSION_CACHE_KEY); }); - describe("createDraftSession", () => { - it("creates a draft session with default title", () => { - const session = useChatSessionStore.getState().createDraftSession(); + describe("createSession", () => { + it("creates a real ACP-backed session", async () => { + mockAcpCreateSession.mockResolvedValue({ sessionId: "acp-1" }); - expect(session.title).toBe("New Chat"); - expect(session.draft).toBe(true); - expect(session.messageCount).toBe(0); - expect(useChatSessionStore.getState().sessions).toContainEqual(session); - }); - - it("creates a draft session with custom options", () => { - const session = useChatSessionStore.getState().createDraftSession({ - title: "My Custom Chat", - projectId: "proj-1", + const session = await useChatSessionStore.getState().createSession({ + title: "New Chat", providerId: "openai", personaId: "persona-1", + modelId: "gpt-4.1", + modelName: "GPT-4.1", + workingDir: "/tmp/project", }); - expect(session.title).toBe("My Custom Chat"); - expect(session.projectId).toBe("proj-1"); - expect(session.providerId).toBe("openai"); - expect(session.personaId).toBe("persona-1"); - expect(session.draft).toBe(true); - }); - }); - - describe("promoteDraft", () => { - it("removes draft flag from a draft session", () => { - const session = useChatSessionStore.getState().createDraftSession(); - expect(session.draft).toBe(true); - - useChatSessionStore.getState().promoteDraft(session.id); - - const updated = useChatSessionStore - .getState() - .sessions.find((s) => s.id === session.id); - expect(updated?.draft).toBeUndefined(); - }); - - it("does nothing for non-draft sessions", () => { - useChatSessionStore.setState({ - sessions: [ - { - id: "non-draft", - title: "Regular Session", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 5, - }, - ], - }); - - useChatSessionStore.getState().promoteDraft("non-draft"); - - const session = useChatSessionStore - .getState() - .sessions.find((s) => s.id === "non-draft"); - expect(session?.draft).toBeUndefined(); - }); - }); - - describe("removeDraft", () => { - it("removes a draft session", () => { - const session = useChatSessionStore.getState().createDraftSession(); - expect(useChatSessionStore.getState().sessions).toHaveLength(1); - - useChatSessionStore.getState().removeDraft(session.id); - - expect(useChatSessionStore.getState().sessions).toHaveLength(0); - }); - - it("clears activeSessionId if removing the active draft", () => { - const session = useChatSessionStore.getState().createDraftSession(); - useChatSessionStore.getState().setActiveSession(session.id); - - useChatSessionStore.getState().removeDraft(session.id); - - expect(useChatSessionStore.getState().activeSessionId).toBeNull(); - }); - - it("does not remove non-draft sessions", () => { - useChatSessionStore.setState({ - sessions: [ - { - id: "non-draft", - title: "Regular Session", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - messageCount: 5, - }, - ], + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "openai", + "/tmp/project", + { + personaId: "persona-1", + modelId: "gpt-4.1", + }, + ); + expect(session).toMatchObject({ + id: "acp-1", + acpSessionId: "acp-1", + title: "New Chat", + providerId: "openai", + personaId: "persona-1", + modelId: "gpt-4.1", + modelName: "GPT-4.1", }); - - useChatSessionStore.getState().removeDraft("non-draft"); - - expect(useChatSessionStore.getState().sessions).toHaveLength(1); + expect(useChatSessionStore.getState().sessions).toContainEqual(session); }); }); describe("loadSessions", () => { it("loads sessions from ACP and maps them correctly", async () => { - mockedAcpListSessions.mockResolvedValue([ + mockAcpListSessions.mockResolvedValue([ { sessionId: "acp-1", title: "ACP Session 1", @@ -161,64 +110,14 @@ describe("chatSessionStore", () => { const sessions = useChatSessionStore.getState().sessions; expect(sessions).toHaveLength(2); - expect(sessions[0].id).toBe("acp-2"); // Most recent first - expect(sessions[0].title).toBe("Untitled"); // null title becomes "Untitled" + expect(sessions[0].id).toBe("acp-2"); + expect(sessions[0].title).toBe("Untitled"); expect(sessions[0].messageCount).toBe(7); expect(sessions[1].id).toBe("acp-1"); expect(sessions[1].title).toBe("ACP Session 1"); expect(sessions[1].messageCount).toBe(4); }); - it("preserves local drafts alongside ACP sessions", async () => { - const draft = useChatSessionStore.getState().createDraftSession({ - title: "My Draft", - }); - - mockedAcpListSessions.mockResolvedValue([ - { - sessionId: "acp-1", - title: "ACP Session", - updatedAt: "2026-04-01", - messageCount: 3, - }, - ]); - - await useChatSessionStore.getState().loadSessions(); - - const sessions = useChatSessionStore.getState().sessions; - expect(sessions).toHaveLength(2); - expect(sessions.find((s) => s.id === "acp-1")).toBeDefined(); - expect(sessions.find((s) => s.id === draft.id)).toBeDefined(); - }); - - it("migrates promoted draft metadata onto the resolved ACP session id", async () => { - const draft = useChatSessionStore.getState().createDraftSession({ - title: "Project Draft", - projectId: "project-123", - providerId: "goose", - }); - - useChatSessionStore.getState().promoteDraft(draft.id); - useChatSessionStore.getState().setSessionAcpId(draft.id, "acp-1"); - - mockedAcpListSessions.mockResolvedValue([ - { - sessionId: "acp-1", - title: "ACP Session", - updatedAt: "2026-04-02", - messageCount: 3, - }, - ]); - - await useChatSessionStore.getState().loadSessions(); - - const session = useChatSessionStore.getState().sessions[0]; - expect(session.id).toBe("acp-1"); - expect(session.acpSessionId).toBe("acp-1"); - expect(session.projectId).toBe("project-123"); - expect(session.providerId).toBe("goose"); - }); - it("rehydrates cached project metadata for ACP sessions", async () => { window.localStorage.setItem( LEGACY_SESSION_CACHE_KEY, @@ -237,7 +136,7 @@ describe("chatSessionStore", () => { ]), ); - mockedAcpListSessions.mockResolvedValue([ + mockAcpListSessions.mockResolvedValue([ { sessionId: "acp-1", title: null, @@ -259,21 +158,37 @@ describe("chatSessionStore", () => { expect(session.userSetName).toBe(true); }); - it("drops stale non-draft sessions that are no longer in ACP", async () => { - useChatSessionStore.setState({ - sessions: [ + it("ignores legacy draft records while hydrating overlays", async () => { + window.localStorage.setItem( + LEGACY_SESSION_CACHE_KEY, + JSON.stringify([ { - id: "stale-session", - title: "Stale Session", + id: "cached-draft", + title: "Cached Draft", + draft: true, createdAt: "2026-04-01", updatedAt: "2026-04-01", - messageCount: 2, + messageCount: 0, }, + ]), + ); + + mockAcpListSessions.mockResolvedValue([]); + + await useChatSessionStore.getState().loadSessions(); + + expect(useChatSessionStore.getState().sessions).toEqual([]); + }); + + it("drops stale sessions that are no longer in ACP", async () => { + useChatSessionStore.setState({ + sessions: [ + makeSession({ id: "stale-session", title: "Stale Session" }), ], activeSessionId: "stale-session", }); - mockedAcpListSessions.mockResolvedValue([ + mockAcpListSessions.mockResolvedValue([ { sessionId: "acp-1", title: "ACP Session", @@ -292,7 +207,7 @@ describe("chatSessionStore", () => { it("sets isLoading during fetch", async () => { let resolvePromise: (value: AcpSessionInfo[]) => void = () => {}; - mockedAcpListSessions.mockReturnValue( + mockAcpListSessions.mockReturnValue( new Promise((resolve) => { resolvePromise = resolve; }), @@ -300,11 +215,13 @@ describe("chatSessionStore", () => { const loadPromise = useChatSessionStore.getState().loadSessions(); expect(useChatSessionStore.getState().isLoading).toBe(true); + expect(useChatSessionStore.getState().hasHydratedSessions).toBe(false); resolvePromise([]); await loadPromise; expect(useChatSessionStore.getState().isLoading).toBe(false); + expect(useChatSessionStore.getState().hasHydratedSessions).toBe(true); }); it("falls back to cached sessions on error", async () => { @@ -315,56 +232,51 @@ describe("chatSessionStore", () => { id: "cached-session", title: "Cached Session", projectId: "project-123", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", messageCount: 8, }, { id: "cached-draft", title: "Cached Draft", draft: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", messageCount: 0, }, ]), ); - mockedAcpListSessions.mockRejectedValue(new Error("Network error")); + mockAcpListSessions.mockRejectedValue(new Error("Network error")); await useChatSessionStore.getState().loadSessions(); const sessions = useChatSessionStore.getState().sessions; - expect(sessions).toHaveLength(2); - expect( - sessions.find((session) => session.id === "cached-session"), - ).toMatchObject({ + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + id: "cached-session", projectId: "project-123", }); - expect( - sessions.find((session) => session.id === "cached-draft")?.draft, - ).toBe(true); + expect(useChatSessionStore.getState().hasHydratedSessions).toBe(true); }); }); describe("updateSession", () => { it("updates session properties", () => { - const session = useChatSessionStore.getState().createDraftSession(); + const session = seedSession(); useChatSessionStore.getState().updateSession(session.id, { title: "Updated Title", projectId: "new-project", }); - const updated = useChatSessionStore - .getState() - .sessions.find((s) => s.id === session.id); + const updated = useChatSessionStore.getState().getSession(session.id); expect(updated?.title).toBe("Updated Title"); expect(updated?.projectId).toBe("new-project"); }); it("preserves updatedAt when not explicitly provided in patch", () => { - const session = useChatSessionStore.getState().createDraftSession(); + const session = seedSession(); const originalUpdatedAt = session.updatedAt; vi.useFakeTimers(); @@ -376,14 +288,12 @@ describe("chatSessionStore", () => { vi.useRealTimers(); - const updated = useChatSessionStore - .getState() - .sessions.find((s) => s.id === session.id); + const updated = useChatSessionStore.getState().getSession(session.id); expect(updated?.updatedAt).toBe(originalUpdatedAt); }); it("updates updatedAt when explicitly provided in patch", () => { - const session = useChatSessionStore.getState().createDraftSession(); + const session = seedSession(); const originalUpdatedAt = session.updatedAt; vi.useFakeTimers(); @@ -397,76 +307,43 @@ describe("chatSessionStore", () => { vi.useRealTimers(); - const updated = useChatSessionStore - .getState() - .sessions.find((s) => s.id === session.id); + const updated = useChatSessionStore.getState().getSession(session.id); expect(updated?.updatedAt).not.toBe(originalUpdatedAt); expect(updated?.updatedAt).toBe(newTimestamp); }); }); - describe("session models", () => { - it("stores models per session", () => { - const session = useChatSessionStore.getState().createDraftSession(); - - useChatSessionStore.getState().setSessionModels(session.id, [ - { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, - { id: "gpt-4o", name: "GPT-4o" }, - ]); - - expect( - useChatSessionStore.getState().getSessionModels(session.id), - ).toEqual([ - { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, - { id: "gpt-4o", name: "GPT-4o" }, - ]); - }); - - it("removes stored models when a draft session is removed", () => { - const session = useChatSessionStore.getState().createDraftSession(); - useChatSessionStore - .getState() - .setSessionModels(session.id, [ - { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, - ]); - - useChatSessionStore.getState().removeDraft(session.id); - - expect( - useChatSessionStore.getState().getSessionModels(session.id), - ).toEqual([]); - }); + describe("provider switching", () => { + it("clears the selected model when switching providers", () => { + const session = seedSession({ + providerId: "openai", + modelId: "gpt-4o", + modelName: "GPT-4o", + }); - it("removes stored models when a session is archived", async () => { - const session = useChatSessionStore.getState().createDraftSession(); useChatSessionStore .getState() - .setSessionModels(session.id, [ - { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, - ]); - - await useChatSessionStore.getState().archiveSession(session.id); + .switchSessionProvider(session.id, "anthropic"); - expect( - useChatSessionStore.getState().getSessionModels(session.id), - ).toEqual([]); + const updated = useChatSessionStore.getState().getSession(session.id); + expect(updated?.providerId).toBe("anthropic"); + expect(updated?.modelId).toBeUndefined(); + expect(updated?.modelName).toBeUndefined(); }); }); describe("archiveSession", () => { it("sets archivedAt on the session", async () => { - const session = useChatSessionStore.getState().createDraftSession(); + const session = seedSession(); await useChatSessionStore.getState().archiveSession(session.id); - const archived = useChatSessionStore - .getState() - .sessions.find((s) => s.id === session.id); + const archived = useChatSessionStore.getState().getSession(session.id); expect(archived?.archivedAt).toBeDefined(); }); it("clears activeSessionId if archiving the active session", async () => { - const session = useChatSessionStore.getState().createDraftSession(); + const session = seedSession(); useChatSessionStore.getState().setActiveSession(session.id); await useChatSessionStore.getState().archiveSession(session.id); @@ -478,13 +355,14 @@ describe("chatSessionStore", () => { describe("addSession", () => { it("prepends a new session to the list", () => { const { addSession } = useChatSessionStore.getState(); - addSession({ - id: "imported-1", - title: "Imported Session", - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - messageCount: 5, - }); + addSession( + makeSession({ + id: "imported-1", + title: "Imported Session", + messageCount: 5, + }), + ); + const sessions = useChatSessionStore.getState().sessions; expect(sessions[0].id).toBe("imported-1"); expect(sessions[0].title).toBe("Imported Session"); @@ -493,22 +371,13 @@ describe("chatSessionStore", () => { it("does not create a duplicate if session ID already exists", () => { const { addSession } = useChatSessionStore.getState(); - addSession({ - id: "dup-1", - title: "First", - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - messageCount: 1, - }); - addSession({ - id: "dup-1", - title: "Second", - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - messageCount: 2, - }); + addSession(makeSession({ id: "dup-1", title: "First", messageCount: 1 })); + addSession( + makeSession({ id: "dup-1", title: "Second", messageCount: 2 }), + ); + const sessions = useChatSessionStore.getState().sessions; - const matches = sessions.filter((s) => s.id === "dup-1"); + const matches = sessions.filter((session) => session.id === "dup-1"); expect(matches).toHaveLength(1); expect(matches[0].title).toBe("Second"); }); diff --git a/ui/goose2/src/features/chat/stores/chatSessionStore.ts b/ui/goose2/src/features/chat/stores/chatSessionStore.ts index a1c30b948880..256154eade7d 100644 --- a/ui/goose2/src/features/chat/stores/chatSessionStore.ts +++ b/ui/goose2/src/features/chat/stores/chatSessionStore.ts @@ -1,23 +1,17 @@ import { create } from "zustand"; -import { acpListSessions, type AcpSessionInfo } from "@/shared/api/acp"; +import { + acpCreateSession, + acpListSessions, + type AcpSessionInfo, +} from "@/shared/api/acp"; import type { Session } from "@/shared/types/chat"; import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle"; import { - loadDraftSessionRecords, loadSessionMetadataOverlay, - migrateSessionMetadataOverlayId, - modelIdsMatch, persistSessionMetadataOverlay, - persistDraftSessionRecord, - persistDraftSessionRecords, - removeDraftSessionRecord, upsertSessionMetadataOverlayRecord, - type DraftSessionRecord, type SessionMetadataOverlayRecord, } from "@/features/chat/lib/sessionMetadataOverlay"; -import type { ModelOption } from "../types"; - -const EMPTY_MODELS: ModelOption[] = []; export interface ChatSession { id: string; @@ -33,7 +27,6 @@ export interface ChatSession { updatedAt: string; archivedAt?: string; messageCount: number; - draft?: boolean; userSetName?: boolean; } @@ -42,14 +35,31 @@ export interface ActiveWorkspace { branch: string | null; } +export function hasSessionStarted( + session: Pick, + localMessages?: ArrayLike, +): boolean { + return session.messageCount > 0 || (localMessages?.length ?? 0) > 0; +} + +export function getVisibleSessions< + T extends Pick, +>( + sessions: T[], + messagesBySession: Record | undefined>, +): T[] { + return sessions.filter((session) => + hasSessionStarted(session, messagesBySession[session.id]), + ); +} + interface ChatSessionStoreState { sessions: ChatSession[]; activeSessionId: string | null; isLoading: boolean; + hasHydratedSessions: boolean; contextPanelOpenBySession: Record; activeWorkspaceBySession: Record; - modelsBySession: Record; - modelCacheByProvider: Record; } interface CreateSessionOpts { @@ -58,18 +68,17 @@ interface CreateSessionOpts { agentId?: string; providerId?: string; personaId?: string; + workingDir?: string; + modelId?: string; + modelName?: string; } interface UpdateSessionOptions { - localOnly?: boolean; persistOverlay?: boolean; } interface ChatSessionStoreActions { createSession: (opts?: CreateSessionOpts) => Promise; - createDraftSession: (opts?: CreateSessionOpts) => ChatSession; - promoteDraft: (id: string) => void; - removeDraft: (id: string) => void; loadSessions: () => Promise; updateSession: ( id: string, @@ -79,74 +88,20 @@ interface ChatSessionStoreActions { addSession: (session: ChatSession) => void; archiveSession: (id: string) => Promise; unarchiveSession: (id: string) => Promise; - setSessionAcpId: (id: string, acpSessionId: string) => void; setActiveSession: (sessionId: string | null) => void; setContextPanelOpen: (sessionId: string, open: boolean) => void; setActiveWorkspace: (sessionId: string, context: ActiveWorkspace) => void; clearActiveWorkspace: (sessionId: string) => void; - setSessionModels: (sessionId: string, models: ModelOption[]) => void; - switchSessionProvider: ( - sessionId: string, - providerId: string, - models: ModelOption[], - ) => void; - cacheModelsForProvider: (providerId: string, models: ModelOption[]) => void; - getCachedModels: (providerId: string) => ModelOption[]; + switchSessionProvider: (sessionId: string, providerId: string) => void; getSession: (id: string) => ChatSession | undefined; getActiveSession: () => ChatSession | null; getArchivedSessions: () => ChatSession[]; - getSessionModels: (sessionId: string) => ModelOption[]; } export type ChatSessionStore = ChatSessionStoreState & ChatSessionStoreActions; -const MODEL_CACHE_STORAGE_KEY = "goose:model-cache"; - -function loadModelCache(): Record { - if (typeof window === "undefined") return {}; - try { - const stored = window.localStorage.getItem(MODEL_CACHE_STORAGE_KEY); - if (!stored) return {}; - const parsed = JSON.parse(stored); - return typeof parsed === "object" && parsed !== null - ? (parsed as Record) - : {}; - } catch { - return {}; - } -} - -function persistModelCache(cache: Record): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(MODEL_CACHE_STORAGE_KEY, JSON.stringify(cache)); - } catch { - // localStorage may be unavailable - } -} - -function draftSessionToRecord(session: ChatSession): DraftSessionRecord { - return { - id: session.id, - acpSessionId: session.acpSessionId, - title: session.title, - projectId: session.projectId, - agentId: session.agentId, - providerId: session.providerId, - personaId: session.personaId, - modelId: session.modelId, - modelName: session.modelName, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - archivedAt: session.archivedAt, - messageCount: session.messageCount, - draft: true, - userSetName: session.userSetName, - }; -} - function overlayKeyForSession( session: Pick, ) { @@ -225,40 +180,12 @@ function mergeAcpSessionWithOverlay( }; } -function mergeDraftSessions( - currentDrafts: ChatSession[], - persistedDrafts: DraftSessionRecord[], -): ChatSession[] { - const draftsById = new Map( - persistedDrafts.map((record) => [ - record.id, - { - ...record, - draft: true, - } satisfies ChatSession, - ]), - ); - - for (const draft of currentDrafts) { - draftsById.set(draft.id, draft); - } - - return [...draftsById.values()]; -} - -function persistDraftsFromSessions(sessions: ChatSession[]): void { - persistDraftSessionRecords( - sessions.filter((session) => session.draft).map(draftSessionToRecord), - ); -} - function syncOverlaySnapshots( sessions: ChatSession[], existingOverlays = loadSessionMetadataOverlay(), ): void { const overlays = new Map(existingOverlays); for (const session of sessions) { - if (session.draft) continue; overlays.set( overlayKeyForSession(session), buildOverlayRecord( @@ -299,81 +226,55 @@ export const useChatSessionStore = create((set, get) => ({ sessions: [], activeSessionId: null, isLoading: false, + hasHydratedSessions: false, contextPanelOpenBySession: {}, activeWorkspaceBySession: {}, - modelsBySession: {}, - modelCacheByProvider: loadModelCache(), - - createSession: async (_opts) => { - throw new Error( - "createSession not yet wired to ACP — use createDraftSession", - ); - }, - createDraftSession: (opts) => { + createSession: async (opts) => { + if (!opts?.workingDir) { + throw new Error("createSession requires a working directory"); + } const now = new Date().toISOString(); + const providerId = opts.providerId ?? "goose"; + const { sessionId } = await acpCreateSession(providerId, opts.workingDir, { + personaId: opts.personaId, + modelId: opts.modelId, + }); const chatSession: ChatSession = { - id: crypto.randomUUID(), - title: opts?.title ?? DEFAULT_CHAT_TITLE, - projectId: opts?.projectId, - agentId: opts?.agentId, - providerId: opts?.providerId, - personaId: opts?.personaId, + id: sessionId, + acpSessionId: sessionId, + title: opts.title ?? DEFAULT_CHAT_TITLE, + projectId: opts.projectId, + agentId: opts.agentId, + providerId, + personaId: opts.personaId, + modelId: opts.modelId, + modelName: opts.modelName, createdAt: now, updatedAt: now, messageCount: 0, - draft: true, }; - set((state) => ({ sessions: [...state.sessions, chatSession] })); - persistDraftSessionRecord(draftSessionToRecord(chatSession)); + set((state) => ({ sessions: [chatSession, ...state.sessions] })); + const existing = loadSessionMetadataOverlay().get( + overlayKeyForSession(chatSession), + ); + upsertSessionMetadataOverlayRecord( + buildOverlayRecord(chatSession, existing), + ); return chatSession; }, - promoteDraft: (id) => { - const session = get().sessions.find((candidate) => candidate.id === id); - if (!session?.draft) return; - set((state) => ({ - sessions: state.sessions.map((candidate) => - candidate.id === id ? { ...candidate, draft: undefined } : candidate, - ), - })); - persistDraftsFromSessions(get().sessions); - }, - - removeDraft: (id) => { - const session = get().sessions.find((candidate) => candidate.id === id); - if (!session?.draft) return; - const { [id]: _ignoredPanelState, ...remainingPanelState } = - get().contextPanelOpenBySession; - const { [id]: _ignoredContext, ...remainingContextState } = - get().activeWorkspaceBySession; - const remainingModels = { ...get().modelsBySession }; - delete remainingModels[id]; - set((state) => ({ - sessions: state.sessions.filter((candidate) => candidate.id !== id), - activeSessionId: - state.activeSessionId === id ? null : state.activeSessionId, - contextPanelOpenBySession: remainingPanelState, - activeWorkspaceBySession: remainingContextState, - modelsBySession: remainingModels, - })); - removeDraftSessionRecord(id); - }, - loadSessions: async () => { set({ isLoading: true }); try { const overlays = loadSessionMetadataOverlay(); const acpSessions = await acpListSessions(); - const persistedDrafts = loadDraftSessionRecords(); - const currentDrafts = get().sessions.filter((session) => session.draft); - const drafts = mergeDraftSessions(currentDrafts, persistedDrafts); const mergedAcpSessions = sortByUpdatedAtDesc( acpSessions.map((session) => mergeAcpSessionWithOverlay(session, overlays.get(session.sessionId)), ), ); - const merged = [...mergedAcpSessions, ...drafts]; + const merged = mergedAcpSessions; const activeSessionId = get().activeSessionId; const activeSessionStillExists = activeSessionId == null || @@ -382,22 +283,16 @@ export const useChatSessionStore = create((set, get) => ({ sessions: merged, activeSessionId: activeSessionStillExists ? activeSessionId : null, }); - persistDraftsFromSessions(merged); syncOverlaySnapshots(mergedAcpSessions, overlays); } catch (error) { console.error("Failed to load sessions from ACP:", error); const overlays = loadSessionMetadataOverlay(); - const persistedDrafts = loadDraftSessionRecords(); - const currentDrafts = get().sessions.filter((session) => session.draft); - const drafts = mergeDraftSessions(currentDrafts, persistedDrafts); - const fallbackSessions = sortByUpdatedAtDesc([ - ...[...overlays.values()].map(overlayToFallbackSession), - ...drafts, - ]); + const fallbackSessions = sortByUpdatedAtDesc( + [...overlays.values()].map(overlayToFallbackSession), + ); set({ sessions: fallbackSessions }); - persistDraftsFromSessions(fallbackSessions); } finally { - set({ isLoading: false }); + set({ isLoading: false, hasHydratedSessions: true }); } }, @@ -415,23 +310,15 @@ export const useChatSessionStore = create((set, get) => ({ })); const updatedSession = get().sessions.find((session) => session.id === id); - persistDraftsFromSessions(get().sessions); - if ( - updatedSession && - !updatedSession.draft && - opts?.persistOverlay !== false - ) { + if (updatedSession && opts?.persistOverlay !== false) { const key = overlayKeyForSession(updatedSession); const existing = loadSessionMetadataOverlay().get(key); upsertSessionMetadataOverlayRecord( buildOverlayRecord(updatedSession, existing), ); } - - if (opts?.localOnly) return; - if (updatedSession?.draft) return; - // TODO: wire non-draft updates to ACP when supported + // TODO: wire session updates to ACP when supported }, addSession: (session) => { @@ -450,20 +337,15 @@ export const useChatSessionStore = create((set, get) => ({ } return { sessions: [normalizedSession, ...state.sessions] }; }); - persistDraftsFromSessions(get().sessions); - if (!normalizedSession.draft) { - const existing = loadSessionMetadataOverlay().get( - overlayKeyForSession(normalizedSession), - ); - upsertSessionMetadataOverlayRecord( - buildOverlayRecord(normalizedSession, existing), - ); - } + const existing = loadSessionMetadataOverlay().get( + overlayKeyForSession(normalizedSession), + ); + upsertSessionMetadataOverlayRecord( + buildOverlayRecord(normalizedSession, existing), + ); }, archiveSession: async (id) => { - const remainingModels = { ...get().modelsBySession }; - delete remainingModels[id]; set((state) => ({ sessions: state.sessions.map((session) => session.id === id @@ -472,11 +354,9 @@ export const useChatSessionStore = create((set, get) => ({ ), activeSessionId: state.activeSessionId === id ? null : state.activeSessionId, - modelsBySession: remainingModels, })); - persistDraftsFromSessions(get().sessions); const session = get().sessions.find((candidate) => candidate.id === id); - if (session && !session.draft) { + if (session) { const existing = loadSessionMetadataOverlay().get( overlayKeyForSession(session), ); @@ -490,9 +370,8 @@ export const useChatSessionStore = create((set, get) => ({ session.id === id ? { ...session, archivedAt: undefined } : session, ), })); - persistDraftsFromSessions(get().sessions); const session = get().sessions.find((candidate) => candidate.id === id); - if (session && !session.draft) { + if (session) { const existing = loadSessionMetadataOverlay().get( overlayKeyForSession(session), ); @@ -500,36 +379,6 @@ export const useChatSessionStore = create((set, get) => ({ } }, - setSessionAcpId: (id, acpSessionId) => { - if (!acpSessionId) return; - const session = get().sessions.find((candidate) => candidate.id === id); - if (!session || session.acpSessionId === acpSessionId) return; - - set((state) => ({ - sessions: state.sessions.map((candidate) => - candidate.id === id ? { ...candidate, acpSessionId } : candidate, - ), - })); - - if (!session.draft) { - migrateSessionMetadataOverlayId( - overlayKeyForSession(session), - acpSessionId, - ); - const updatedSession = get().sessions.find( - (candidate) => candidate.id === id, - ); - if (updatedSession) { - const existing = loadSessionMetadataOverlay().get(acpSessionId); - upsertSessionMetadataOverlayRecord( - buildOverlayRecord(updatedSession, existing), - ); - } - } else { - persistDraftsFromSessions(get().sessions); - } - }, - setActiveSession: (sessionId) => { if (get().activeSessionId === sessionId) return; set({ activeSessionId: sessionId }); @@ -560,41 +409,24 @@ export const useChatSessionStore = create((set, get) => ({ }); }, - setSessionModels: (sessionId, models) => { - set((state) => ({ - modelsBySession: { - ...state.modelsBySession, - [sessionId]: models, - }, - })); - }, - - switchSessionProvider: (sessionId, providerId, models) => { + switchSessionProvider: (sessionId, providerId) => { set((state) => ({ - modelsBySession: { - ...state.modelsBySession, - [sessionId]: models, - }, sessions: state.sessions.map((session) => session.id === sessionId ? { ...session, providerId, - modelId: models.length > 0 ? models[0].id : undefined, - modelName: - models.length > 0 - ? (models[0].displayName ?? models[0].name) - : undefined, + modelId: undefined, + modelName: undefined, updatedAt: session.updatedAt, } : session, ), })); - persistDraftsFromSessions(get().sessions); const session = get().sessions.find( (candidate) => candidate.id === sessionId, ); - if (session && !session.draft) { + if (session) { const existing = loadSessionMetadataOverlay().get( overlayKeyForSession(session), ); @@ -602,25 +434,6 @@ export const useChatSessionStore = create((set, get) => ({ } }, - cacheModelsForProvider: (providerId, models) => { - if (models.length === 0) return; - const existing = get().modelCacheByProvider[providerId]; - if (modelIdsMatch(existing, models)) { - return; - } - set((state) => { - const updated = { - ...state.modelCacheByProvider, - [providerId]: models, - }; - persistModelCache(updated); - return { modelCacheByProvider: updated }; - }); - }, - - getCachedModels: (providerId) => - get().modelCacheByProvider[providerId] ?? EMPTY_MODELS, - getSession: (id) => get().sessions.find((session) => session.id === id), getActiveSession: () => { @@ -631,7 +444,4 @@ export const useChatSessionStore = create((set, get) => ({ getArchivedSessions: () => get().sessions.filter((session) => !!session.archivedAt), - - getSessionModels: (sessionId) => - get().modelsBySession[sessionId] ?? EMPTY_MODELS, })); diff --git a/ui/goose2/src/features/chat/types.ts b/ui/goose2/src/features/chat/types.ts index e6fa552022cf..2fa09b4d21df 100644 --- a/ui/goose2/src/features/chat/types.ts +++ b/ui/goose2/src/features/chat/types.ts @@ -1,6 +1,62 @@ +import type { AcpProvider } from "@/shared/api/acp"; +import type { Persona } from "@/shared/types/agents"; +import type { ChatAttachmentDraft } from "@/shared/types/messages"; + export interface ModelOption { id: string; name: string; displayName?: string; provider?: string; + providerId?: string; + providerName?: string; + /** Whether this model should appear in the compact recommended picker. */ + recommended?: boolean; +} + +export interface ProjectOption { + id: string; + name: string; + workingDirs: string[]; + color?: string | null; +} + +export interface ChatInputProps { + onSend: ( + text: string, + personaId?: string, + attachments?: ChatAttachmentDraft[], + ) => void; + onStop?: () => void; + isStreaming?: boolean; + disabled?: boolean; + queuedMessage?: { text: string } | null; + onDismissQueue?: () => void; + initialValue?: string; + onDraftChange?: (text: string) => void; + className?: string; + personas?: Persona[]; + selectedPersonaId?: string | null; + onPersonaChange?: (personaId: string | null) => void; + onCreatePersona?: () => void; + providers?: AcpProvider[]; + providersLoading?: boolean; + selectedProvider?: string; + onProviderChange?: (providerId: string) => void; + currentModelId?: string | null; + currentModel?: string; + availableModels?: ModelOption[]; + modelsLoading?: boolean; + modelStatusMessage?: string | null; + onModelChange?: (modelId: string) => void; + selectedProjectId?: string | null; + availableProjects?: ProjectOption[]; + onProjectChange?: (projectId: string | null) => void; + onCreateProject?: (options?: { + onCreated?: (projectId: string) => void; + }) => void; + contextTokens?: number; + contextLimit?: number; + onCompactContext?: () => void | Promise; + canCompactContext?: boolean; + isCompactingContext?: boolean; } diff --git a/ui/goose2/src/features/chat/ui/AgentModelPicker.tsx b/ui/goose2/src/features/chat/ui/AgentModelPicker.tsx index 2e4368cc49a0..f0bba26cc3b3 100644 --- a/ui/goose2/src/features/chat/ui/AgentModelPicker.tsx +++ b/ui/goose2/src/features/chat/ui/AgentModelPicker.tsx @@ -1,15 +1,19 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { IconCheck, IconChevronDown, - IconChevronRight, + IconChevronLeft, + IconSearch, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import type { AcpProvider } from "@/shared/api/acp"; +import { getProviderInventory } from "@/features/providers/api/inventory"; +import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { ScrollArea } from "@/shared/ui/scroll-area"; +import { Spinner } from "@/shared/ui/spinner"; import { formatProviderLabel, getProviderIcon, @@ -23,66 +27,42 @@ interface AgentModelPickerProps { currentModelId?: string | null; currentModelName?: string | null; availableModels: ModelOption[]; + modelsLoading?: boolean; + modelStatusMessage?: string | null; onModelChange?: (modelId: string) => void; loading?: boolean; isCompact?: boolean; } -interface ModelGroup { - provider: string; - models: ModelOption[]; - hasSelectedModel: boolean; -} - -const MODEL_PROVIDER_MATCHERS: Array<[string, RegExp]> = [ - ["Anthropic", /claude|anthropic/i], - ["OpenAI", /(^|[\s-])(gpt|o1|o3|o4)([\s-]|$)|openai/i], - ["Google", /gemini|google/i], - ["Mistral", /mistral/i], - ["Meta", /llama|meta/i], - ["DeepSeek", /deepseek/i], - ["Qwen", /qwen/i], - ["Cohere", /cohere|command/i], -]; - function getModelDisplayName(model: ModelOption) { return model.displayName ?? model.name; } -function inferModelProvider(model: ModelOption) { - if (model.provider) { - return model.provider; +function getGooseModelProviderLabel(model: ModelOption) { + if (model.providerName) { + return model.providerName; } - const candidate = `${model.id} ${model.name}`; - for (const [provider, pattern] of MODEL_PROVIDER_MATCHERS) { - if (pattern.test(candidate)) { - return provider; - } + if (model.providerId) { + return formatProviderLabel(model.providerId); } - return "Other"; + return null; } -function groupModels(models: ModelOption[], currentModelId: string | null) { - const grouped = new Map(); - - for (const model of models) { - const provider = inferModelProvider(model); - const existing = grouped.get(provider) ?? []; - existing.push(model); - grouped.set(provider, existing); - } +function sortModels(models: ModelOption[], currentModelId: string | null) { + return [...models].sort((left, right) => { + if (left.id === currentModelId) return -1; + if (right.id === currentModelId) return 1; - const groups = Array.from(grouped.entries()) - .map(([provider, groupedModels]) => ({ - provider, - models: groupedModels, - hasSelectedModel: groupedModels.some((m) => m.id === currentModelId), - })) - .sort((left, right) => left.provider.localeCompare(right.provider)); + const leftProvider = getGooseModelProviderLabel(left) ?? ""; + const rightProvider = getGooseModelProviderLabel(right) ?? ""; + if (leftProvider !== rightProvider) { + return leftProvider.localeCompare(rightProvider); + } - return groups; + return getModelDisplayName(left).localeCompare(getModelDisplayName(right)); + }); } function PickerItem({ @@ -116,6 +96,219 @@ function PickerItem({ ); } +// ── Model list views ──────────────────────────────────────────────── + +type ModelView = "recommended" | "all"; + +function RecommendedModelList({ + models, + currentModelId, + selectedAgentId, + onModelSelect, + onShowAll, + t, +}: { + models: ModelOption[]; + currentModelId: string | null; + selectedAgentId: string; + onModelSelect: (id: string) => void; + onShowAll: () => void; + t: (key: string) => string; +}) { + const recommended = useMemo(() => { + const rec = models.filter((m) => m.recommended); + // If the current model isn't in the recommended list, prepend it + // so the user can always see what's selected. + if ( + currentModelId && + rec.length > 0 && + !rec.some((m) => m.id === currentModelId) + ) { + const current = models.find((m) => m.id === currentModelId); + if (current) { + return [current, ...rec]; + } + } + // Fall back to full list if no recommendations exist (e.g. ACP agents). + return rec.length > 0 ? rec : models; + }, [models, currentModelId]); + + const sorted = useMemo( + () => sortModels(recommended, currentModelId), + [recommended, currentModelId], + ); + + const hasMore = models.length > recommended.length; + + return ( +
+
+ {t("toolbar.model")} +
+ +
+ {sorted.map((model) => { + const providerLabel = getGooseModelProviderLabel(model); + return ( + onModelSelect(model.id)} + selected={model.id === currentModelId} + className="justify-between" + > +
+ {selectedAgentId === "goose" && model.providerId ? ( + + {getProviderIcon(model.providerId, "size-3.5")} + + ) : null} +
+ {getModelDisplayName(model)} +
+
+ {model.id === currentModelId ? ( + + ) : null} +
+ ); + })} +
+
+ {hasMore ? ( +
+ +
+ ) : null} +
+ ); +} + +function AllModelsList({ + models, + currentModelId, + selectedAgentId, + onModelSelect, + onBack, + t, +}: { + models: ModelOption[]; + currentModelId: string | null; + selectedAgentId: string; + onModelSelect: (id: string) => void; + onBack: () => void; + t: (key: string) => string; +}) { + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + // Auto-focus search on mount. + inputRef.current?.focus(); + }, []); + + const filtered = useMemo(() => { + if (!query.trim()) { + return sortModels(models, currentModelId); + } + const q = query.toLowerCase(); + const matches = models.filter( + (m) => + m.name.toLowerCase().includes(q) || + m.id.toLowerCase().includes(q) || + m.displayName?.toLowerCase().includes(q) || + m.providerName?.toLowerCase().includes(q) || + m.providerId?.toLowerCase().includes(q), + ); + return sortModels(matches, currentModelId); + }, [models, query, currentModelId]); + + return ( +
+
+ +
+ + setQuery(e.target.value)} + placeholder={t("toolbar.searchModels")} + className="h-7 w-full rounded-sm border bg-transparent pl-7 pr-2 text-sm outline-none placeholder:text-muted-foreground focus:ring-1 focus:ring-ring" + /> +
+
+ {filtered.length > 0 ? ( + +
+ {filtered.map((model) => { + const providerLabel = getGooseModelProviderLabel(model); + const displayName = getModelDisplayName(model); + // Show the raw model_id as secondary text when it differs from name + const showModelId = + model.id !== model.name && model.id !== displayName; + + return ( + onModelSelect(model.id)} + selected={model.id === currentModelId} + className="justify-between" + > +
+ {selectedAgentId === "goose" && model.providerId ? ( + + {getProviderIcon(model.providerId, "size-3.5")} + + ) : null} +
+
{displayName}
+ {showModelId ? ( +
+ {model.id} +
+ ) : null} +
+
+ {model.id === currentModelId ? ( + + ) : null} +
+ ); + })} +
+
+ ) : ( +
+ {t("toolbar.noSearchResults")} +
+ )} +
+ ); +} + +// ── Main component ────────────────────────────────────────────────── + export function AgentModelPicker({ agents, selectedAgentId, @@ -123,54 +316,31 @@ export function AgentModelPicker({ currentModelId = null, currentModelName = null, availableModels, + modelsLoading = false, + modelStatusMessage = null, onModelChange, loading = false, isCompact = false, }: AgentModelPickerProps) { const { t } = useTranslation("chat"); const [open, setOpen] = useState(false); - const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [modelView, setModelView] = useState("recommended"); + const mergeInventoryEntries = useProviderInventoryStore( + (s) => s.mergeEntries, + ); const selectedAgentLabel = agents.find((agent) => agent.id === selectedAgentId)?.label ?? formatProviderLabel(selectedAgentId); - const groupedModels = useMemo( - () => groupModels(availableModels, currentModelId), - [availableModels, currentModelId], - ); - const hasModelInfo = currentModelName !== null || availableModels.length > 0; - const triggerModelLabel = hasModelInfo - ? (currentModelName ?? t("toolbar.loading")) + const hasSelectedModel = currentModelName !== null || currentModelId !== null; + const triggerModelLabel = hasSelectedModel + ? (currentModelName ?? currentModelId) : null; - useEffect(() => { - if (open) { - const selected = groupedModels.find((g) => g.hasSelectedModel); - if (selected) { - setExpandedGroups(new Set([selected.provider])); - } - } - }, [open, groupedModels]); - - const toggleGroup = (provider: string) => { - setExpandedGroups((prev) => { - const next = new Set(prev); - if (next.has(provider)) { - next.delete(provider); - } else { - next.add(provider); - } - return next; - }); - }; - - const isGroupExpanded = (group: ModelGroup) => { - return expandedGroups.has(group.provider); - }; - const handleAgentSelect = (agentId: string) => { if (agentId !== selectedAgentId) { onAgentChange(agentId); + setModelView("recommended"); } }; @@ -179,6 +349,42 @@ export function AgentModelPicker({ setOpen(false); }; + // Reset to recommended view when popover closes. + useEffect(() => { + if (!open) { + setModelView("recommended"); + } + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + + let cancelled = false; + + const syncInventory = async () => { + try { + const entries = await getProviderInventory(); + if (cancelled) { + return; + } + mergeInventoryEntries(entries); + } catch (error) { + console.error("Failed to sync provider inventory from picker:", error); + } + }; + + void syncInventory(); + + return () => { + cancelled = true; + }; + }, [open, mergeInventoryEntries]); + + // When in "all" view, expand the popover to full width for the search experience. + const isAllView = modelView === "all"; + return ( @@ -251,133 +457,97 @@ export function AgentModelPicker({ } }} > -
-
-
-
- {t("toolbar.agent")} -
- -
- {agents.map((agent) => { - const isSelected = agent.id === selectedAgentId; - - return ( - handleAgentSelect(agent.id)} - selected={isSelected} - > - - {getProviderIcon(agent.id, "size-4")} - - - {agent.label} - - {isSelected ? ( - - ) : null} - - ); - })} +
+ {/* Agent column — hidden in "all models" search view */} + {!isAllView ? ( +
+
+
+ {t("toolbar.agent")}
- -
-
-
-
-
- {t("toolbar.model")} -
- {groupedModels.length > 0 ? ( -
- {groupedModels.map((group) => { - const expanded = isGroupExpanded(group); +
+ {agents.map((agent) => { + const isSelected = agent.id === selectedAgentId; return ( -
- - {expanded ? ( -
- {group.models.map((model) => { - const modelName = getModelDisplayName(model); - - return ( - handleModelSelect(model.id)} - selected={model.id === currentModelId} - className="justify-between pl-6" - > -
- {modelName} -
- {model.id === currentModelId ? ( - - ) : null} -
- ); - })} -
- ) : group.hasSelectedModel ? ( -
- {group.models - .filter((m) => m.id === currentModelId) - .map((model) => ( - handleModelSelect(model.id)} - selected - className="justify-between pl-6" - > -
- {getModelDisplayName(model)} -
- -
- ))} -
+ handleAgentSelect(agent.id)} + selected={isSelected} + > + + {getProviderIcon(agent.id, "size-4")} + + + {agent.label} + + {isSelected ? ( + ) : null} -
+ ); })}
+
+
+ ) : null} + + {/* Model column */} +
+ {modelsLoading ? ( +
+ + {t("toolbar.loadingModels")} +
+ ) : availableModels.length > 0 ? ( + modelView === "recommended" ? ( + setModelView("all")} + t={t} + /> ) : ( + setModelView("recommended")} + t={t} + /> + ) + ) : ( +
+
+ {t("toolbar.model")} +
- {currentModelName ? currentModelName : t("toolbar.loading")} + {modelStatusMessage ?? + currentModelName ?? + t("toolbar.noModelsAvailable")}
- )} -
+
+ )}
diff --git a/ui/goose2/src/features/chat/ui/ChatInput.tsx b/ui/goose2/src/features/chat/ui/ChatInput.tsx index 4a21f9a60c1d..aa10fe35cdd7 100644 --- a/ui/goose2/src/features/chat/ui/ChatInput.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInput.tsx @@ -2,8 +2,6 @@ import { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { X } from "lucide-react"; import { useTranslation } from "react-i18next"; -import type { AcpProvider } from "@/shared/api/acp"; -import type { Persona } from "@/shared/types/agents"; import { cn } from "@/shared/lib/cn"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; @@ -14,61 +12,14 @@ import { ChatInputToolbar } from "./ChatInputToolbar"; import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons"; import { TooltipProvider } from "@/shared/ui/tooltip"; import { PersonaAvatar } from "./PersonaPicker"; -import type { ChatAttachmentDraft } from "@/shared/types/messages"; import { useAttachmentDropTarget } from "../hooks/useAttachmentDropTarget"; import { normalizeDialogSelection, useChatInputAttachments, } from "../hooks/useChatInputAttachments"; -import type { ModelOption } from "../types"; import { ChatInputAttachments } from "./ChatInputAttachments"; import { useVoiceDictation } from "../hooks/useVoiceDictation"; - -export interface ProjectOption { - id: string; - name: string; - workingDirs: string[]; - color?: string | null; -} - -interface ChatInputProps { - onSend: ( - text: string, - personaId?: string, - attachments?: ChatAttachmentDraft[], - ) => void; - onStop?: () => void; - isStreaming?: boolean; - disabled?: boolean; - queuedMessage?: { text: string } | null; - onDismissQueue?: () => void; - initialValue?: string; - onDraftChange?: (text: string) => void; - className?: string; - personas?: Persona[]; - selectedPersonaId?: string | null; - onPersonaChange?: (personaId: string | null) => void; - onCreatePersona?: () => void; - providers?: AcpProvider[]; - providersLoading?: boolean; - selectedProvider?: string; - onProviderChange?: (providerId: string) => void; - currentModelId?: string | null; - currentModel?: string; - availableModels?: ModelOption[]; - onModelChange?: (modelId: string) => void; - selectedProjectId?: string | null; - availableProjects?: ProjectOption[]; - onProjectChange?: (projectId: string | null) => void; - onCreateProject?: (options?: { - onCreated?: (projectId: string) => void; - }) => void; - contextTokens?: number; - contextLimit?: number; - onCompactContext?: () => void | Promise; - canCompactContext?: boolean; - isCompactingContext?: boolean; -} +import type { ChatInputProps } from "../types"; export function ChatInput({ onSend, @@ -91,6 +42,8 @@ export function ChatInput({ currentModelId = null, currentModel, availableModels = [], + modelsLoading = false, + modelStatusMessage = null, onModelChange, selectedProjectId = null, availableProjects = [], @@ -104,6 +57,9 @@ export function ChatInput({ }: ChatInputProps) { const { t } = useTranslation("chat"); const [text, setTextRaw] = useState(initialValue); + useEffect(() => { + setTextRaw(initialValue); + }, [initialValue]); const setText = useCallback( (value: string) => { setTextRaw(value); @@ -351,8 +307,18 @@ export function ChatInput({ providers.find((provider) => provider.id === selectedProvider)?.label ?? formatProviderLabel(selectedProvider); const agentDisplayName = activePersona?.displayName ?? providerDisplayName; - const resolvedCurrentModel = - currentModel ?? availableModels[0]?.displayName ?? availableModels[0]?.name; + const resolvedCurrentModel = useMemo(() => { + if (currentModel) { + return currentModel; + } + if (!currentModelId) { + return undefined; + } + const selectedModel = availableModels.find( + (model) => model.id === currentModelId, + ); + return selectedModel?.displayName ?? selectedModel?.name ?? currentModelId; + }, [availableModels, currentModel, currentModelId]); const effectivePlaceholder = t("input.placeholder", { agent: agentDisplayName, }); @@ -472,6 +438,8 @@ export function ChatInput({ currentModelId={currentModelId} currentModel={resolvedCurrentModel} availableModels={availableModels} + modelsLoading={modelsLoading} + modelStatusMessage={modelStatusMessage} onModelChange={onModelChange} selectedProjectId={selectedProjectId} availableProjects={availableProjects} diff --git a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx index 411c002fa1fc..ea048099c5db 100644 --- a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx @@ -16,7 +16,7 @@ import { cn } from "@/shared/lib/cn"; import { ChatInputSelector } from "./ChatInputSelector"; import { ContextRing } from "./ContextRing"; import { PersonaPicker } from "./PersonaPicker"; -import type { ProjectOption } from "./ChatInput"; +import type { ProjectOption } from "../types"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -30,11 +30,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip"; import { AgentModelPicker } from "./AgentModelPicker"; import type { ModelOption } from "../types"; import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons"; -import { useAgentProviderStatus } from "@/features/providers/hooks/useAgentProviderStatus"; -import { - getCatalogEntry, - resolveAgentProviderCatalogIdStrict, -} from "@/features/providers/providerCatalog"; +import { getCatalogEntry } from "@/features/providers/providerCatalog"; const NO_PROJECT_VALUE = "__no_project__"; const CREATE_PROJECT_VALUE = "__create_project__"; @@ -67,6 +63,8 @@ interface ChatInputToolbarProps { currentModelId?: string | null; currentModel?: string; availableModels: ModelOption[]; + modelsLoading?: boolean; + modelStatusMessage?: string | null; onModelChange?: (modelId: string) => void; // Project selectedProjectId: string | null; @@ -111,6 +109,8 @@ export function ChatInputToolbar({ currentModelId, currentModel, availableModels, + modelsLoading = false, + modelStatusMessage = null, onModelChange, selectedProjectId, availableProjects, @@ -137,27 +137,22 @@ export function ChatInputToolbar({ }: ChatInputToolbarProps) { const { t } = useTranslation("chat"); const { formatNumber } = useLocaleFormatting(); - const { readyAgentIds } = useAgentProviderStatus(); const [isContextPopoverOpen, setIsContextPopoverOpen] = useState(false); const agentProviders = useMemo(() => { const seen = new Set(); - const connected: AcpProvider[] = []; - for (const p of providers) { - const catalogId = resolveAgentProviderCatalogIdStrict(p.id); - if ( - catalogId === null || - !readyAgentIds.has(catalogId) || - seen.has(catalogId) - ) + const available: AcpProvider[] = []; + for (const provider of providers) { + if (seen.has(provider.id)) { continue; - seen.add(catalogId); - connected.push({ - id: p.id, - label: getCatalogEntry(catalogId)?.displayName ?? p.label, + } + seen.add(provider.id); + available.push({ + id: provider.id, + label: getCatalogEntry(provider.id)?.displayName ?? provider.label, }); } - if (connected.length > 0) return connected; + if (available.length > 0) return available; return [ { id: selectedProvider, @@ -166,7 +161,7 @@ export function ChatInputToolbar({ formatProviderLabel(selectedProvider), }, ]; - }, [providers, readyAgentIds, selectedProvider]); + }, [providers, selectedProvider]); const selectedProject = availableProjects.find( (project) => project.id === selectedProjectId, ); @@ -220,6 +215,8 @@ export function ChatInputToolbar({ currentModelId={currentModelId} currentModelName={currentModel ?? null} availableModels={availableModels} + modelsLoading={modelsLoading} + modelStatusMessage={modelStatusMessage} onModelChange={onModelChange} loading={providersLoading} isCompact={isCompact} diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx index ce0c6184f792..1ab5df8b89eb 100644 --- a/ui/goose2/src/features/chat/ui/ChatView.tsx +++ b/ui/goose2/src/features/chat/ui/ChatView.tsx @@ -1,507 +1,97 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { useState, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { AnimatePresence } from "motion/react"; import { MessageTimeline } from "./MessageTimeline"; import { ChatInput } from "./ChatInput"; -import type { ChatAttachmentDraft } from "@/shared/types/messages"; import { LoadingGoose } from "./LoadingGoose"; import { ChatLoadingSkeleton } from "./ChatLoadingSkeleton"; -import { useChat } from "../hooks/useChat"; -import { useMessageQueue } from "../hooks/useMessageQueue"; -import { useChatStore } from "../stores/chatStore"; -import { useAgentStore } from "@/features/agents/stores/agentStore"; -import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; import { useChatSessionStore } from "../stores/chatSessionStore"; -import { useProjectStore } from "@/features/projects/stores/projectStore"; -import { acpPrepareSession, acpSetModel } from "@/shared/api/acp"; -import { - buildProjectSystemPrompt, - composeSystemPrompt, - defaultGlobalArtifactRoot, - getProjectArtifactRoots, - resolveProjectDefaultArtifactRoot, -} from "@/features/projects/lib/chatProjectContext"; -import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection"; +import { defaultGlobalArtifactRoot } from "@/features/projects/lib/chatProjectContext"; import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext"; -import type { ModelOption } from "../types"; import { ChatContextPanel } from "./ChatContextPanel"; import { perfLog } from "@/shared/lib/perfLog"; - -const EMPTY_MODELS: ModelOption[] = []; +import { useChatSessionController } from "../hooks/useChatSessionController"; interface ChatViewProps { sessionId: string; - initialProvider?: string; - initialPersonaId?: string; - initialMessage?: string; - initialAttachments?: ChatAttachmentDraft[]; - onInitialMessageConsumed?: () => void; onCreateProject?: (options?: { onCreated?: (projectId: string) => void; }) => void; } -export function ChatView({ - sessionId, - initialProvider, - initialPersonaId, - initialMessage, - initialAttachments, - onInitialMessageConsumed, - onCreateProject, -}: ChatViewProps) { +export function ChatView({ sessionId, onCreateProject }: ChatViewProps) { const { t } = useTranslation("chat"); - const activeSessionId = sessionId; const mountStart = useRef(performance.now()); - useEffect(() => { - const ms = (performance.now() - mountStart.current).toFixed(1); - perfLog(`[perf:chatview] ${sessionId.slice(0, 8)} mounted in ${ms}ms`); - }, [sessionId]); const isContextPanelOpen = useChatSessionStore( - (s) => s.contextPanelOpenBySession[activeSessionId] ?? false, + (s) => s.contextPanelOpenBySession[sessionId] ?? false, ); const setContextPanelOpen = useChatSessionStore((s) => s.setContextPanelOpen); - const activeWorkspace = useChatSessionStore( - (s) => s.activeWorkspaceBySession[activeSessionId], - ); - const clearActiveWorkspace = useChatSessionStore( - (s) => s.clearActiveWorkspace, - ); - - const { - providers, - providersLoading, - selectedProvider: globalSelectedProvider, - setSelectedProvider: setGlobalSelectedProvider, - } = useProviderSelection(); - const personas = useAgentStore((s) => s.personas); - const [selectedPersonaId, setSelectedPersonaId] = useState( - initialPersonaId ?? null, - ); - const session = useChatSessionStore((s) => - s.sessions.find((candidate) => candidate.id === activeSessionId), - ); - const availableModels = useChatSessionStore( - (s) => s.modelsBySession[activeSessionId] ?? EMPTY_MODELS, - ); - const projects = useProjectStore((s) => s.projects); - const projectsLoading = useProjectStore((s) => s.loading); - const storedProject = useProjectStore((s) => - session?.projectId - ? s.projects.find((candidate) => candidate.id === session.projectId) - : undefined, - ); const [globalArtifactRoot, setGlobalArtifactRoot] = useState( null, ); - const project = storedProject ?? null; + const controller = useChatSessionController({ sessionId }); const contextPanelLabel = isContextPanelOpen ? t("context.closePanel") : t("context.openPanel"); - const availableProjects = useMemo( - () => - [...projects] - .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)) - .map((projectInfo) => ({ - id: projectInfo.id, - name: projectInfo.name, - workingDirs: projectInfo.workingDirs, - color: projectInfo.color, - })), - [projects], - ); - const selectedProvider = - session?.providerId ?? - initialProvider ?? - project?.preferredProvider ?? - globalSelectedProvider; + const allowedArtifactRoots = [ + ...controller.allowedArtifactRoots, + ...(globalArtifactRoot ? [globalArtifactRoot] : []), + ]; - const selectedPersona = personas.find((p) => p.id === selectedPersonaId); - const projectArtifactRoots = useMemo( - () => getProjectArtifactRoots(project), - [project], - ); - const projectDefaultArtifactRoot = useMemo( - () => resolveProjectDefaultArtifactRoot(project), - [project], - ); - const projectMetadataPending = Boolean( - session?.projectId && !projectDefaultArtifactRoot && projectsLoading, - ); - const allowedArtifactRoots = useMemo(() => { - const roots = [ - ...projectArtifactRoots.map((path) => path.trim()).filter(Boolean), - ]; - if (globalArtifactRoot) { - roots.push(globalArtifactRoot); - } - return [...new Set(roots)]; - }, [globalArtifactRoot, projectArtifactRoots]); - const projectSystemPrompt = useMemo( - () => buildProjectSystemPrompt(project), - [project], - ); - const workingContextPrompt = useMemo(() => { - if (!activeWorkspace?.branch) return undefined; - return `\nActive branch: ${activeWorkspace.branch}\nWorking directory: ${activeWorkspace.path}\n`; - }, [activeWorkspace?.branch, activeWorkspace?.path]); - - const effectiveSystemPrompt = useMemo( - () => - composeSystemPrompt( - selectedPersona?.systemPrompt, - projectSystemPrompt, - workingContextPrompt, - ), - [selectedPersona?.systemPrompt, projectSystemPrompt, workingContextPrompt], - ); + useEffect(() => { + const ms = (performance.now() - mountStart.current).toFixed(1); + perfLog(`[perf:chatview] ${sessionId.slice(0, 8)} mounted in ${ms}ms`); + }, [sessionId]); useEffect(() => { let cancelled = false; defaultGlobalArtifactRoot() .then((artifactRoot) => { - if (cancelled) return; - setGlobalArtifactRoot(artifactRoot); + if (!cancelled) { + setGlobalArtifactRoot(artifactRoot); + } }) .catch(() => { - if (cancelled) return; - setGlobalArtifactRoot(null); + if (!cancelled) { + setGlobalArtifactRoot(null); + } }); return () => { cancelled = true; }; }, []); - const prevProjectIdRef = useRef(session?.projectId); - useEffect(() => { - const prevProjectId = prevProjectIdRef.current; - prevProjectIdRef.current = session?.projectId; - if (prevProjectId !== undefined && prevProjectId !== session?.projectId) { - clearActiveWorkspace(activeSessionId); - } - }, [session?.projectId, activeSessionId, clearActiveWorkspace]); - - const prevWorkspaceRef = useRef(activeWorkspace); - useEffect(() => { - const prev = prevWorkspaceRef.current; - if ( - !activeWorkspace || - !selectedProvider || - session?.draft || - activeWorkspace === prev - ) { - return; - } - prevWorkspaceRef.current = activeWorkspace; - if (prev && prev.path === activeWorkspace.path) return; - - async function prepareWorkspaceSession() { - const workingDir = await resolveSessionCwd(project, activeWorkspace.path); - if (!workingDir) { - return; - } - await acpPrepareSession(activeSessionId, selectedProvider, workingDir, { - personaId: selectedPersonaId ?? undefined, - }); - } - - void prepareWorkspaceSession().catch((error) => { - console.error("Failed to prepare ACP session:", error); - }); - }, [ - activeWorkspace, - activeSessionId, - project, - selectedProvider, - selectedPersonaId, - session?.draft, - ]); - - const handleProviderChange = useCallback( - (providerId: string) => { - if (providerId === selectedProvider) { - return; - } - const sessionStore = useChatSessionStore.getState(); - const cached = sessionStore.getCachedModels(providerId); - sessionStore.switchSessionProvider(activeSessionId, providerId, cached); - setGlobalSelectedProvider(providerId); - }, - [activeSessionId, selectedProvider, setGlobalSelectedProvider], - ); - - const handleProjectChange = useCallback( - (projectId: string | null) => { - const nextProject = - projectId == null - ? null - : (useProjectStore - .getState() - .projects.find((candidate) => candidate.id === projectId) ?? - null); - - useChatSessionStore - .getState() - .updateSession(activeSessionId, { projectId }); - - if (!session?.draft && selectedProvider) { - async function updateProjectSessionCwd() { - const workingDir = await resolveSessionCwd( - nextProject, - activeWorkspace?.path, - ); - if (!workingDir) { - return; - } - - await acpPrepareSession( - activeSessionId, - selectedProvider, - workingDir, - { - personaId: selectedPersonaId ?? undefined, - }, - ); - } - - void updateProjectSessionCwd().catch((error) => { - console.error( - "Failed to update ACP session working directory:", - error, - ); - }); - } - }, - [ - activeSessionId, - activeWorkspace?.path, - selectedPersonaId, - selectedProvider, - session?.draft, - ], - ); - const handleModelChange = useCallback( - (modelId: string) => { - if (!activeSessionId || modelId === session?.modelId) { - return; - } - const previousModelId = session?.modelId; - const previousModelName = session?.modelName; - const models = useChatSessionStore - .getState() - .getSessionModels(activeSessionId); - const selected = models.find((m) => m.id === modelId); - useChatSessionStore.getState().updateSession(activeSessionId, { - modelId, - modelName: selected?.displayName ?? selected?.name ?? modelId, - }); - if (session?.draft) { - return; - } - acpSetModel(activeSessionId, modelId).catch((error) => { - console.error("Failed to set model:", error); - useChatSessionStore.getState().updateSession(activeSessionId, { - modelId: previousModelId, - modelName: previousModelName, - }); - }); - }, - [activeSessionId, session?.draft, session?.modelId, session?.modelName], - ); - - // When persona changes, update the provider to match persona's default - const handlePersonaChange = useCallback( - (personaId: string | null) => { - setSelectedPersonaId(personaId); - const persona = personas.find((p) => p.id === personaId); - if (persona?.provider) { - const matchingProvider = providers.find( - (p) => - p.id === persona.provider || - p.label.toLowerCase().includes(persona.provider ?? ""), - ); - if (matchingProvider) { - handleProviderChange(matchingProvider.id); - } - } - const agentStore = useAgentStore.getState(); - const matchingAgent = agentStore.agents.find( - (a) => a.personaId === personaId, - ); - if (matchingAgent) { - agentStore.setActiveAgent(matchingAgent.id); - } - useChatSessionStore - .getState() - .updateSession(activeSessionId, { personaId: personaId ?? undefined }); - }, - [personas, providers, activeSessionId, handleProviderChange], - ); - - // Validate persona still exists — fall back to default if deleted - useEffect(() => { - if ( - selectedPersonaId !== null && - personas.length > 0 && - !personas.find((p) => p.id === selectedPersonaId) - ) { - // Selected persona was deleted — reset to no persona - setSelectedPersonaId(null); - } - }, [personas, selectedPersonaId]); - - const personaInfo = selectedPersona - ? { id: selectedPersona.id, name: selectedPersona.displayName } - : undefined; - const resolveCurrentSessionCwd = useCallback( - () => resolveSessionCwd(project, activeWorkspace?.path), - [project, activeWorkspace?.path], - ); - const { - messages, - chatState, - tokenState, - sendMessage, - compactConversation, - stopStreaming, - streamingMessageId, - } = useChat( - activeSessionId, - selectedProvider, - effectiveSystemPrompt, - personaInfo, - resolveCurrentSessionCwd, - ); - const isLoadingHistory = useChatStore( - (s) => - s.loadingSessionIds.has(activeSessionId) && - (s.messagesBySession[activeSessionId]?.length ?? 0) === 0, - ); - - const deferredSend = useRef<{ - text: string; - attachments?: ChatAttachmentDraft[]; - } | null>(null); - const queue = useMessageQueue(activeSessionId, chatState, sendMessage); - const chatStore = useChatStore(); - const handleSend = useCallback( - (text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => { - if (personaId && personaId !== selectedPersonaId) { - const newPersona = personas.find((p) => p.id === personaId); - if (newPersona) { - // Inject a system notification about the persona switch - chatStore.addMessage(activeSessionId, { - id: crypto.randomUUID(), - role: "system", - created: Date.now(), - content: [ - { - type: "systemNotification", - notificationType: "info", - text: `Switched to ${newPersona.displayName}`, - }, - ], - metadata: { userVisible: true, agentVisible: false }, - }); - } - handlePersonaChange(personaId); - // Defer the send until after persona state updates - deferredSend.current = { text, attachments }; - return; - } - // Queue if agent is busy and no message already queued - if (chatState !== "idle" && !queue.queuedMessage) { - queue.enqueue(text, personaId, attachments); - return; - } - - sendMessage(text, undefined, attachments); - }, - [ - sendMessage, - selectedPersonaId, - handlePersonaChange, - personas, - chatStore, - activeSessionId, - chatState, - queue, - ], - ); - - useEffect(() => { - if (deferredSend.current && selectedPersona) { - const { text, attachments } = deferredSend.current; - deferredSend.current = null; - sendMessage(text, undefined, attachments); - } - }, [sendMessage, selectedPersona]); - const initialMessageSent = useRef(false); - useEffect(() => { - if ( - (initialMessage || initialAttachments?.length) && - !initialMessageSent.current - ) { - initialMessageSent.current = true; - handleSend(initialMessage ?? "", undefined, initialAttachments); - onInitialMessageConsumed?.(); - } - }, [ - initialAttachments, - initialMessage, - handleSend, - onInitialMessageConsumed, - ]); - const isStreaming = chatState === "streaming"; - const isCompacting = chatState === "compacting"; const showIndicator = - chatState === "thinking" || - chatState === "streaming" || - chatState === "waiting" || - chatState === "compacting"; - const handleCreatePersona = useCallback(() => { - useAgentStore.getState().openPersonaEditor(); - }, []); - const draftValue = useChatStore( - (s) => s.draftsBySession[activeSessionId] ?? "", - ); - const scrollTarget = useChatStore( - (s) => s.scrollTargetMessageBySession[activeSessionId] ?? null, - ); - const handleDraftChange = useCallback( - (text: string) => { - useChatStore.getState().setDraft(activeSessionId, text); - }, - [activeSessionId], - ); - const handleScrollTargetHandled = useCallback(() => { - useChatStore.getState().clearScrollTargetMessage(activeSessionId); - }, [activeSessionId]); + controller.chatState === "thinking" || + controller.chatState === "streaming" || + controller.chatState === "waiting" || + controller.chatState === "compacting"; + return (
- {isLoadingHistory ? ( + {controller.isLoadingHistory ? ( ) : ( )} - {showIndicator && !isLoadingHistory ? ( + {showIndicator && !controller.isLoadingHistory ? ( onCreateProject?.({ onCreated: (projectId) => { - handleProjectChange(projectId); + controller.handleProjectChange(projectId); options?.onCreated?.(projectId); }, }) } - contextTokens={tokenState.accumulatedTotal} - contextLimit={tokenState.contextLimit} - onCompactContext={compactConversation} - canCompactContext={ - chatState === "idle" && - tokenState.accumulatedTotal > 0 && - !projectMetadataPending - } - isCompactingContext={isCompacting} + contextTokens={controller.tokenState.accumulatedTotal} + contextLimit={controller.tokenState.contextLimit} />
diff --git a/ui/goose2/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx index b3c7f4021790..51c4d9691b87 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx @@ -59,7 +59,6 @@ describe("AgentModelPicker", () => { screen.getByRole("button", { name: /choose agent and model/i }), ); - await user.click(screen.getByRole("button", { name: /OpenAI/i })); await user.click(screen.getByRole("button", { name: "GPT-4o" })); expect(onModelChange).toHaveBeenCalledWith("gpt-4o"); @@ -149,4 +148,49 @@ describe("AgentModelPicker", () => { expect(trigger).toHaveTextContent("Goose"); expect(trigger).not.toHaveTextContent("·"); }); + + it("shows a loading state while models are refreshing", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + + expect(screen.getByText("Loading models...")).toBeInTheDocument(); + }); + + it("shows an empty-state message when no inventory models are available", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + + expect(screen.getByText("No models available")).toBeInTheDocument(); + }); }); diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx index 6b60e12d7264..13c522e934f7 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -149,7 +149,7 @@ describe("ChatInput", () => { ).toHaveTextContent("GPT-4o"); }); - it("shows default model name in model picker", () => { + it("shows provider label when no current model is selected", () => { render( { ); expect( screen.getByRole("button", { name: /choose agent and model/i }), - ).toHaveTextContent("Claude Sonnet 4"); + ).toHaveTextContent("Goose"); }); it("shows default provider label", () => { @@ -176,6 +176,18 @@ describe("ChatInput", () => { expect(providerButton).toHaveTextContent("Goose"); }); + it("resets the textarea when initialValue changes", () => { + const { rerender } = render( + , + ); + + expect(screen.getByRole("textbox")).toHaveValue("alpha draft"); + + rerender(); + + expect(screen.getByRole("textbox")).toHaveValue(""); + }); + it("opens the agent and model picker", async () => { const user = userEvent.setup(); diff --git a/ui/goose2/src/features/home/ui/HomeScreen.test.tsx b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx index 56e454cd8795..deaf8f55224e 100644 --- a/ui/goose2/src/features/home/ui/HomeScreen.test.tsx +++ b/ui/goose2/src/features/home/ui/HomeScreen.test.tsx @@ -5,6 +5,67 @@ import { HomeScreen } from "./HomeScreen"; const setSelectedProvider = vi.fn(); const setSelectedProviderWithoutPersist = vi.fn(); +const mockController = { + handleSend: vi.fn(), + projectMetadataPending: false, + queue: { queuedMessage: null, dismiss: vi.fn() }, + stopStreaming: vi.fn(), + chatState: "idle" as const, + personas: [ + { + id: "builtin-solo", + displayName: "Solo", + systemPrompt: "You are Solo.", + provider: "openai", + description: null, + avatar: null, + createdBy: null, + source: "custom", + extensions: [], + metadata: null, + sortOrder: 0, + isDefault: false, + }, + { + id: "builtin-goose", + displayName: "Goosey", + systemPrompt: "You are Goosey.", + isBuiltin: true, + description: null, + avatar: null, + createdBy: null, + source: "custom", + extensions: [], + metadata: null, + sortOrder: 1, + isDefault: false, + createdAt: "", + updatedAt: "", + }, + ], + draftValue: "", + handleDraftChange: vi.fn(), + selectedPersonaId: null, + handlePersonaChange: vi.fn(), + handleCreatePersona: vi.fn(), + pickerAgents: [ + { id: "goose", label: "Goose" }, + { id: "claude-acp", label: "Claude Code" }, + ], + providersLoading: false, + selectedProvider: "goose", + handleProviderChange: setSelectedProvider, + currentModelId: null, + currentModelName: null, + availableModels: [], + modelsLoading: false, + modelStatusMessage: null, + handleModelChange: vi.fn(), + selectedProjectId: null, + availableProjects: [], + handleProjectChange: vi.fn(), + tokenState: { accumulatedTotal: 0, contextLimit: 0 }, +}; vi.mock("@/shared/api/acp", () => ({ discoverAcpProviders: vi.fn().mockResolvedValue([ @@ -21,6 +82,10 @@ vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({ }), })); +vi.mock("@/features/chat/hooks/useChatSessionController", () => ({ + useChatSessionController: () => mockController, +})); + vi.mock("@/features/agents/hooks/useProviderSelection", () => ({ useProviderSelection: () => ({ providers: [ @@ -34,7 +99,6 @@ vi.mock("@/features/agents/hooks/useProviderSelection", () => ({ }), })); -// HomeScreen now reads personas from the agent store, not from ACP providers vi.mock("@/features/agents/stores/agentStore", async (importOriginal) => { const actual = await importOriginal< @@ -88,6 +152,9 @@ vi.mock("@/features/agents/stores/agentStore", async (importOriginal) => { }); describe("HomeScreen", () => { + const renderHome = () => + render(); + beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 2, 29, 14, 30, 0)); // 2:30 PM @@ -98,32 +165,32 @@ describe("HomeScreen", () => { }); it("renders the clock", () => { - render(); + renderHome(); expect(screen.getByText("2:30")).toBeInTheDocument(); expect(screen.getByText("PM")).toBeInTheDocument(); }); it("shows afternoon greeting at 2:30 PM", () => { - render(); + renderHome(); expect(screen.getByText("Good afternoon")).toBeInTheDocument(); }); it("renders the chat input placeholder with default agent name when no persona selected", () => { - render(); + renderHome(); expect( screen.getByPlaceholderText("Message Goose, @ to mention personas"), ).toBeInTheDocument(); }); it("renders the assistant chooser affordance", () => { - render(); + renderHome(); expect( screen.getByRole("button", { name: /choose assistant/i }), ).toBeInTheDocument(); }); it("renders the provider and project controls on the home screen", () => { - render(); + renderHome(); expect( screen.getByRole("button", { name: /choose agent and model/i }), ).toBeInTheDocument(); @@ -132,23 +199,17 @@ describe("HomeScreen", () => { ).toBeInTheDocument(); }); - it("reverts to the stored provider when a persona override is cleared", async () => { + it("forwards persona selection through the shared session controller", async () => { vi.useRealTimers(); const user = userEvent.setup(); - render(); + renderHome(); await user.click(screen.getByRole("button", { name: /choose assistant/i })); await user.click(screen.getByRole("menuitem", { name: /solo/i })); - expect(setSelectedProviderWithoutPersist).toHaveBeenLastCalledWith( - "openai", - ); - - await user.click( - screen.getByRole("button", { name: /clear active assistant/i }), + expect(mockController.handlePersonaChange).toHaveBeenLastCalledWith( + "builtin-solo", ); - - expect(setSelectedProviderWithoutPersist).toHaveBeenLastCalledWith("goose"); }); }); diff --git a/ui/goose2/src/features/home/ui/HomeScreen.tsx b/ui/goose2/src/features/home/ui/HomeScreen.tsx index 54d3f8087281..364b2707468a 100644 --- a/ui/goose2/src/features/home/ui/HomeScreen.tsx +++ b/ui/goose2/src/features/home/ui/HomeScreen.tsx @@ -1,17 +1,8 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { - getStoredProvider, - useAgentStore, -} from "@/features/agents/stores/agentStore"; -import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; import { ChatInput } from "@/features/chat/ui/ChatInput"; -import { useChatStore } from "@/features/chat/stores/chatStore"; -import type { ChatAttachmentDraft } from "@/shared/types/messages"; -import { useProjectStore } from "@/features/projects/stores/projectStore"; import { useLocaleFormatting } from "@/shared/i18n"; - -const HOME_DRAFT_KEY = "home"; +import { useChatSessionController } from "@/features/chat/hooks/useChatSessionController"; function HomeClock() { const [time, setTime] = useState(new Date()); @@ -48,124 +39,94 @@ function getGreetingKey(hour: number): "morning" | "afternoon" | "evening" { } interface HomeScreenProps { - onStartChat?: ( - initialMessage?: string, - providerId?: string, - personaId?: string, - projectId?: string | null, - attachments?: ChatAttachmentDraft[], - ) => void; + sessionId: string | null; + onActivateSession: (sessionId: string) => void; onCreateProject?: (options?: { onCreated?: (projectId: string) => void; }) => void; } -export function HomeScreen({ onStartChat, onCreateProject }: HomeScreenProps) { - const { t } = useTranslation("home"); - const [hour] = useState(() => new Date().getHours()); - const greeting = t(`greeting.${getGreetingKey(hour)}`); - - const personas = useAgentStore((s) => s.personas); - const { - providers, - providersLoading, - selectedProvider, - setSelectedProvider, - setSelectedProviderWithoutPersist, - } = useProviderSelection(); - const projects = useProjectStore((s) => s.projects); - const [selectedPersonaId, setSelectedPersonaId] = useState( - null, - ); - const [selectedProjectId, setSelectedProjectId] = useState( - null, - ); - - const handlePersonaChange = useCallback( - (personaId: string | null) => { - setSelectedPersonaId(personaId); - const persona = personaId - ? personas.find((candidate) => candidate.id === personaId) - : null; - const nextProvider = persona?.provider ?? getStoredProvider(providers); - - setSelectedProviderWithoutPersist(nextProvider); - }, - [personas, providers, setSelectedProviderWithoutPersist], - ); - - const handleCreatePersona = useCallback(() => { - useAgentStore.getState().openPersonaEditor(); - }, []); +function HomeComposer({ + sessionId, + onActivateSession, + onCreateProject, +}: { + sessionId: string | null; + onActivateSession: (sessionId: string) => void; + onCreateProject?: HomeScreenProps["onCreateProject"]; +}) { + const controller = useChatSessionController({ + sessionId, + onMessageAccepted: onActivateSession, + }); - const homeDraft = useChatStore( - (s) => s.draftsBySession[HOME_DRAFT_KEY] ?? "", + return ( + + onCreateProject?.({ + onCreated: (projectId) => { + controller.handleProjectChange(projectId); + options?.onCreated?.(projectId); + }, + }) + } + contextTokens={controller.tokenState.accumulatedTotal} + contextLimit={controller.tokenState.contextLimit} + /> ); - const handleDraftChange = useCallback((text: string) => { - useChatStore.getState().setDraft(HOME_DRAFT_KEY, text); - }, []); - - const handleSend = useCallback( - ( - message: string, - personaId?: string, - attachments?: ChatAttachmentDraft[], - ) => { - const effectivePersonaId = personaId ?? selectedPersonaId ?? undefined; +} - useChatStore.getState().clearDraft(HOME_DRAFT_KEY); - onStartChat?.( - message, - selectedProvider, - effectivePersonaId, - selectedProjectId, - attachments, - ); - }, - [onStartChat, selectedPersonaId, selectedProjectId, selectedProvider], - ); +export function HomeScreen({ + sessionId, + onActivateSession, + onCreateProject, +}: HomeScreenProps) { + const { t } = useTranslation("home"); + const [hour] = useState(() => new Date().getHours()); + const greeting = t(`greeting.${getGreetingKey(hour)}`); return (
- {/* Clock */} - {/* Greeting */}

{greeting}

- {/* Chat input */} - ({ - id: project.id, - name: project.name, - workingDirs: project.workingDirs, - color: project.color, - }))} - onProjectChange={setSelectedProjectId} - onCreateProject={(options) => - onCreateProject?.({ - onCreated: (projectId) => { - setSelectedProjectId(projectId); - options?.onCreated?.(projectId); - }, - }) - } +
diff --git a/ui/goose2/src/features/providers/api/inventory.ts b/ui/goose2/src/features/providers/api/inventory.ts new file mode 100644 index 000000000000..ef3f52c2f6ef --- /dev/null +++ b/ui/goose2/src/features/providers/api/inventory.ts @@ -0,0 +1,32 @@ +import type { + ProviderInventoryEntryDto, + RefreshProviderInventoryResponse, +} from "@aaif/goose-sdk"; +import { getClient } from "@/shared/api/acpConnection"; +import { perfLog } from "@/shared/lib/perfLog"; + +export async function getProviderInventory( + providerIds: string[] = [], +): Promise { + const client = await getClient(); + const t0 = performance.now(); + const response = await client.goose.GooseProvidersInventory({ providerIds }); + perfLog( + `[perf:inventory] getProviderInventory done in ${(performance.now() - t0).toFixed(1)}ms (n=${response.entries.length})`, + ); + return response.entries; +} + +export async function refreshProviderInventory( + providerIds: string[] = [], +): Promise { + const client = await getClient(); + const t0 = performance.now(); + const response = await client.goose.GooseProvidersInventoryRefresh({ + providerIds, + }); + perfLog( + `[perf:inventory] refreshProviderInventory done in ${(performance.now() - t0).toFixed(1)}ms started=[${response.started.join(",")}]`, + ); + return response; +} diff --git a/ui/goose2/src/features/providers/hooks/useProviderInventory.ts b/ui/goose2/src/features/providers/hooks/useProviderInventory.ts new file mode 100644 index 000000000000..f8f001832768 --- /dev/null +++ b/ui/goose2/src/features/providers/hooks/useProviderInventory.ts @@ -0,0 +1,83 @@ +import { useCallback, useMemo } from "react"; +import { useProviderInventoryStore } from "../stores/providerInventoryStore"; +import type { ModelOption } from "@/features/chat/types"; +import type { + ProviderInventoryEntryDto, + ProviderInventoryModelDto, +} from "@aaif/goose-sdk"; +import { getModelProviders } from "../providerCatalog"; + +const MODEL_PROVIDER_IDS = new Set(getModelProviders().map((p) => p.id)); + +function inventoryModelToOption( + model: ProviderInventoryModelDto, + provider?: Pick, +): ModelOption { + return { + id: model.id, + name: model.name, + displayName: model.name !== model.id ? model.name : undefined, + provider: model.family ?? undefined, + providerId: provider?.providerId, + providerName: provider?.providerName, + recommended: model.recommended ?? false, + }; +} + +export function useProviderInventory() { + const entries = useProviderInventoryStore((s) => s.entries); + const loading = useProviderInventoryStore((s) => s.loading); + + const getEntry = useCallback( + (providerId: string) => entries.get(providerId), + [entries], + ); + + const getModelsForProvider = useCallback( + (providerId: string): ModelOption[] => { + const entry = entries.get(providerId); + if (!entry) return []; + return entry.models.map((model) => inventoryModelToOption(model, entry)); + }, + [entries], + ); + + const configuredModelProviderEntries = useMemo( + () => + [...entries.values()].filter( + (entry) => entry.configured && MODEL_PROVIDER_IDS.has(entry.providerId), + ), + [entries], + ); + + const getModelsForAgent = useCallback( + (agentId: string): ModelOption[] => { + if (agentId !== "goose") { + return getModelsForProvider(agentId); + } + + return configuredModelProviderEntries.flatMap((entry) => + entry.models.map((model) => inventoryModelToOption(model, entry)), + ); + }, + [configuredModelProviderEntries, getModelsForProvider], + ); + + const configuredProviderIds = useMemo( + () => + [...entries.values()] + .filter((e) => e.configured) + .map((e) => e.providerId), + [entries], + ); + + return { + entries, + loading, + getEntry, + configuredModelProviderEntries, + getModelsForAgent, + getModelsForProvider, + configuredProviderIds, + }; +} diff --git a/ui/goose2/src/features/providers/stores/providerInventoryStore.ts b/ui/goose2/src/features/providers/stores/providerInventoryStore.ts new file mode 100644 index 000000000000..b81c391a487b --- /dev/null +++ b/ui/goose2/src/features/providers/stores/providerInventoryStore.ts @@ -0,0 +1,47 @@ +import { create } from "zustand"; +import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk"; +import { perfLog } from "@/shared/lib/perfLog"; + +export interface ProviderInventoryState { + entries: Map; + loading: boolean; +} + +interface ProviderInventoryActions { + setEntries: (entries: ProviderInventoryEntryDto[]) => void; + mergeEntries: (entries: ProviderInventoryEntryDto[]) => void; + setLoading: (loading: boolean) => void; +} + +export type ProviderInventoryStore = ProviderInventoryState & + ProviderInventoryActions; + +export const useProviderInventoryStore = create( + (set) => ({ + entries: new Map(), + loading: false, + + setEntries: (entries) => { + const map = new Map(); + for (const entry of entries) { + map.set(entry.providerId, entry); + } + set({ entries: map }); + perfLog( + `[perf:inventory] setEntries n=${entries.length} providers=[${entries.map((e) => e.providerId).join(",")}]`, + ); + }, + + mergeEntries: (entries) => { + set((state) => { + const map = new Map(state.entries); + for (const entry of entries) { + map.set(entry.providerId, entry); + } + return { entries: map }; + }); + }, + + setLoading: (loading) => set({ loading }), + }), +); diff --git a/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx b/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx index 0b3e94047fa3..6b001b48816d 100644 --- a/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx +++ b/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx @@ -8,7 +8,11 @@ import { Button } from "@/shared/ui/button"; import { SessionCard } from "./SessionCard"; import { groupSessionsByDate } from "../lib/groupSessionsByDate"; import { useAgentStore } from "@/features/agents/stores/agentStore"; -import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { + getVisibleSessions, + useChatSessionStore, +} from "@/features/chat/stores/chatSessionStore"; +import { useChatStore } from "@/features/chat/stores/chatStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; import { acpDuplicateSession, @@ -38,10 +42,14 @@ export function SessionHistoryView({ }: SessionHistoryViewProps) { const { t, i18n } = useTranslation(["sessions", "common"]); const sessions = useChatSessionStore((s) => s.sessions); + const messagesBySession = useChatStore((s) => s.messagesBySession); const loadSessions = useChatSessionStore((s) => s.loadSessions); const activeSessions = useMemo( - () => sessions.filter((session) => !session.draft && !session.archivedAt), - [sessions], + () => + getVisibleSessions(sessions, messagesBySession).filter( + (session) => !session.archivedAt, + ), + [messagesBySession, sessions], ); const fileInputRef = useRef(null); diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx index 7f09bff3cfed..41b8bdc8f8aa 100644 --- a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx +++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx @@ -11,7 +11,10 @@ import { cn } from "@/shared/lib/cn"; import type { AppView } from "@/app/AppShell"; import type { ProjectInfo } from "@/features/projects/api/projects"; import { useChatStore } from "@/features/chat/stores/chatStore"; -import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { + getVisibleSessions, + useChatSessionStore, +} from "@/features/chat/stores/chatSessionStore"; import { isSessionRunning } from "@/features/chat/lib/sessionActivity"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; @@ -92,8 +95,12 @@ export function Sidebar({ const chatStore = useChatStore(); const { sessions } = useChatSessionStore(); - const activeSessions = sessions.filter( - (session) => !session.draft && !session.archivedAt, + const visibleSessions = getVisibleSessions( + sessions, + chatStore.messagesBySession, + ); + const activeSessions = visibleSessions.filter( + (session) => !session.archivedAt, ); useEffect(() => { @@ -137,8 +144,8 @@ export function Sidebar({ }; const byProject: Record = {}; const standalone: SessionItem[] = []; - for (const session of sessions) { - if (session.draft || session.archivedAt) continue; + for (const session of visibleSessions) { + if (session.archivedAt) continue; const runtime = chatStore.getSessionRuntime(session.id); const item: SessionItem = { id: session.id, @@ -191,7 +198,7 @@ export function Sidebar({ useEffect(() => { if (!activeSessionId) return; - const activeSession = sessions.find((s) => s.id === activeSessionId); + const activeSession = visibleSessions.find((s) => s.id === activeSessionId); const projectId = activeSession?.projectId; if (projectId) { setExpandedProjects((prev) => { @@ -199,7 +206,7 @@ export function Sidebar({ return { ...prev, [projectId]: true }; }); } - }, [activeSessionId, sessions]); + }, [activeSessionId, visibleSessions]); useEffect(() => { try { @@ -254,17 +261,13 @@ export function Sidebar({ updateActiveRect, } = useSidebarHighlight(navRef); - const activeDraft = activeSessionId - ? sessions.find((s) => s.id === activeSessionId && s.draft) - : undefined; - const activeProjectId = activeDraft?.projectId ?? null; + const activeProjectId = + activeSessionId && activeView === "chat" + ? (sessions.find((s) => s.id === activeSessionId)?.projectId ?? null) + : null; useEffect(() => { - if (activeDraft) { - if (!activeProjectId) updateActiveRect(null); - return; - } - if (activeSessionId) return; + if (activeSessionId && activeView === "chat") return; if (activeView === "home") { updateActiveRect(homeRef.current); } else if (activeView && navItemRefs.current[activeView]) { @@ -272,13 +275,7 @@ export function Sidebar({ } else { updateActiveRect(null); } - }, [ - activeSessionId, - activeDraft, - activeProjectId, - activeView, - updateActiveRect, - ]); + }, [activeSessionId, activeView, updateActiveRect]); const activeSessionRefCallback = useCallback( (el: HTMLElement | null) => { diff --git a/ui/goose2/src/features/sidebar/ui/__tests__/Sidebar.test.tsx b/ui/goose2/src/features/sidebar/ui/__tests__/Sidebar.test.tsx index a6e4daa175c0..db8eee27c94b 100644 --- a/ui/goose2/src/features/sidebar/ui/__tests__/Sidebar.test.tsx +++ b/ui/goose2/src/features/sidebar/ui/__tests__/Sidebar.test.tsx @@ -9,12 +9,12 @@ const mockSessions: Array<{ updatedAt: string; messageCount: number; projectId?: string; - draft?: boolean; archivedAt?: string; }> = []; vi.mock("@/features/chat/stores/chatStore", () => ({ useChatStore: () => ({ + messagesBySession: {}, getSessionRuntime: () => ({ chatState: "idle", hasUnread: false, @@ -23,6 +23,8 @@ vi.mock("@/features/chat/stores/chatStore", () => ({ })); vi.mock("@/features/chat/stores/chatSessionStore", () => ({ + getVisibleSessions: (sessions: typeof mockSessions) => + sessions.filter((session) => session.messageCount > 0), useChatSessionStore: () => ({ sessions: mockSessions, }), @@ -65,6 +67,40 @@ describe("Sidebar", () => { mockSessions.splice(0, mockSessions.length); }); + it("hides zero-message sessions from recents", () => { + mockSessions.splice( + 0, + mockSessions.length, + { + id: "home-session", + title: "New Chat", + updatedAt: "2026-04-09T12:00:00.000Z", + messageCount: 0, + }, + { + id: "session-1", + title: "Recovered Session", + updatedAt: "2026-04-09T12:01:00.000Z", + messageCount: 3, + }, + ); + + render( + , + ); + + expect(screen.queryByText("New Chat")).not.toBeInTheDocument(); + expect(screen.getByText("Recovered Session")).toBeInTheDocument(); + + mockSessions.splice(0, mockSessions.length); + }); + it("renders a home button in the sidebar header and navigates home", async () => { const user = userEvent.setup(); const onNavigate = vi.fn(); diff --git a/ui/goose2/src/shared/api/__tests__/dictation.test.ts b/ui/goose2/src/shared/api/__tests__/dictation.test.ts index 79831ca7cc92..ad6fb7645fac 100644 --- a/ui/goose2/src/shared/api/__tests__/dictation.test.ts +++ b/ui/goose2/src/shared/api/__tests__/dictation.test.ts @@ -16,7 +16,7 @@ vi.mock("../acpConnection", () => ({ })); describe("dictation SDK wiring", () => { - let client: any; + let client: { goose: Record> }; beforeEach(() => { client = { goose: { @@ -33,7 +33,9 @@ describe("dictation SDK wiring", () => { GooseDictationTranscribe: vi.fn().mockResolvedValue({ text: "hello" }), }, }; - vi.mocked(getClient).mockResolvedValue(client); + vi.mocked(getClient).mockResolvedValue( + client as unknown as Awaited>, + ); }); it("getDictationConfig calls GooseDictationConfig and returns providers map", async () => { @@ -46,7 +48,7 @@ describe("dictation SDK wiring", () => { const result = await transcribeDictation({ audio: "base64==", mimeType: "audio/webm", - provider: "openai" as any, + provider: "openai", }); expect(client.goose.GooseDictationTranscribe).toHaveBeenCalledWith({ audio: "base64==", @@ -58,7 +60,7 @@ describe("dictation SDK wiring", () => { it("saveDictationModelSelection calls GooseDictationModelSelect", async () => { client.goose.GooseDictationModelSelect = vi.fn().mockResolvedValue({}); - await saveDictationModelSelection("local" as any, "tiny"); + await saveDictationModelSelection("local", "tiny"); expect(client.goose.GooseDictationModelSelect).toHaveBeenCalledWith({ provider: "local", modelId: "tiny", diff --git a/ui/goose2/src/shared/api/acp.ts b/ui/goose2/src/shared/api/acp.ts index 79b1b7c0b93e..d786ae86573a 100644 --- a/ui/goose2/src/shared/api/acp.ts +++ b/ui/goose2/src/shared/api/acp.ts @@ -1,6 +1,10 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import * as directAcp from "./acpApi"; import * as sessionTracker from "./acpSessionTracker"; +import { + getCatalogEntry, + resolveAgentProviderCatalogId, +} from "@/features/providers/providerCatalog"; import { setActiveMessageId, clearActiveMessageId, @@ -25,9 +29,31 @@ export interface AcpPrepareSessionOptions { personaId?: string; } +export interface AcpCreateSessionOptions extends AcpPrepareSessionOptions { + modelId?: string | null; +} + /** Discover ACP providers installed on the system. */ export async function discoverAcpProviders(): Promise { - return directAcp.listProviders(); + const providers = await directAcp.listProviders(); + const seen = new Set(); + + return providers + .map((provider) => { + const catalogId = resolveAgentProviderCatalogId( + provider.id, + provider.label, + ); + if (!catalogId || seen.has(catalogId)) { + return null; + } + seen.add(catalogId); + return { + id: catalogId, + label: getCatalogEntry(catalogId)?.displayName ?? provider.label, + }; + }) + .filter((provider): provider is AcpProvider => provider !== null); } /** Send a message to an ACP agent. Response streams via Tauri events. */ @@ -79,13 +105,13 @@ export async function acpPrepareSession( providerId: string, workingDir: string, options: AcpPrepareSessionOptions = {}, -): Promise { +): Promise { const sid = sessionId.slice(0, 8); const t0 = performance.now(); perfLog( `[perf:prepare] ${sid} acpPrepareSession start (provider=${providerId})`, ); - await sessionTracker.prepareSession( + const gooseSessionId = await sessionTracker.prepareSession( sessionId, providerId, workingDir, @@ -94,6 +120,31 @@ export async function acpPrepareSession( perfLog( `[perf:prepare] ${sid} acpPrepareSession done in ${(performance.now() - t0).toFixed(1)}ms`, ); + return gooseSessionId; +} + +export async function acpCreateSession( + providerId: string, + workingDir: string, + options: AcpCreateSessionOptions = {}, +): Promise<{ sessionId: string }> { + const localSessionId = crypto.randomUUID(); + const gooseSessionId = await acpPrepareSession( + localSessionId, + providerId, + workingDir, + options, + ); + sessionTracker.registerSession( + gooseSessionId, + gooseSessionId, + providerId, + workingDir, + ); + if (options.modelId) { + await directAcp.setModel(gooseSessionId, options.modelId); + } + return { sessionId: gooseSessionId }; } export async function acpSetModel( diff --git a/ui/goose2/src/shared/api/acpNotificationHandler.ts b/ui/goose2/src/shared/api/acpNotificationHandler.ts index 23b5bf313335..3d623d372e1f 100644 --- a/ui/goose2/src/shared/api/acpNotificationHandler.ts +++ b/ui/goose2/src/shared/api/acpNotificationHandler.ts @@ -455,7 +455,6 @@ function handleShared(sessionId: string, update: SessionUpdate): void { currentModelId; const sessionStore = useChatSessionStore.getState(); - sessionStore.setSessionModels(sessionId, availableModels); sessionStore.updateSession( sessionId, { modelId: currentModelId, modelName: currentModelName }, diff --git a/ui/goose2/src/shared/api/acpSessionTracker.ts b/ui/goose2/src/shared/api/acpSessionTracker.ts index f521a6293a36..0465952cb4f2 100644 --- a/ui/goose2/src/shared/api/acpSessionTracker.ts +++ b/ui/goose2/src/shared/api/acpSessionTracker.ts @@ -115,8 +115,10 @@ export async function prepareSession( `[perf:prepare] ${sid} tracker setProvider(${providerId}) in ${(performance.now() - tProv).toFixed(1)}ms (goose_sid=${gooseSid})`, ); - prepared.set(key, { gooseSessionId, providerId, workingDir }); - prepared.set(sessionId, { gooseSessionId, providerId, workingDir }); + const entry = { gooseSessionId, providerId, workingDir }; + prepared.set(key, entry); + prepared.set(sessionId, entry); + prepared.set(gooseSessionId, entry); gooseToLocal.set(gooseSessionId, sessionId); notifySessionRegistered(sessionId, gooseSessionId); @@ -161,6 +163,7 @@ export function registerSession( } prepared.set(sessionId, entry); + prepared.set(gooseSessionId, entry); gooseToLocal.set(gooseSessionId, sessionId); notifySessionRegistered(sessionId, gooseSessionId); diff --git a/ui/goose2/src/shared/i18n/locales/en/chat.json b/ui/goose2/src/shared/i18n/locales/en/chat.json index 424007cc8c5c..3587786fee96 100644 --- a/ui/goose2/src/shared/i18n/locales/en/chat.json +++ b/ui/goose2/src/shared/i18n/locales/en/chat.json @@ -163,7 +163,14 @@ "createProject": "Create project", "generalChatWithoutProject": "General chat without project context", "loading": "Loading...", + "loadingModels": "Loading models...", "model": "Model", + "allModels": "All models", + "searchModels": "Search models...", + "recommended": "Recommended", + "noModelsAvailable": "No models available", + "noSearchResults": "No matching models", + "showAllModels": "Browse all models", "noProject": "No project", "selectModel": "Select model", "selectProject": "Select project", diff --git a/ui/goose2/src/shared/i18n/locales/es/chat.json b/ui/goose2/src/shared/i18n/locales/es/chat.json index 5bd93d8a560d..ca643f18b960 100644 --- a/ui/goose2/src/shared/i18n/locales/es/chat.json +++ b/ui/goose2/src/shared/i18n/locales/es/chat.json @@ -163,7 +163,14 @@ "createProject": "Crear proyecto", "generalChatWithoutProject": "Chat general sin contexto de proyecto", "loading": "Cargando...", + "loadingModels": "Cargando modelos...", "model": "Modelo", + "allModels": "Todos los modelos", + "searchModels": "Buscar modelos...", + "recommended": "Recomendados", + "noModelsAvailable": "No hay modelos disponibles", + "noSearchResults": "Sin resultados", + "showAllModels": "Ver todos los modelos", "noProject": "Sin proyecto", "selectModel": "Seleccionar modelo", "selectProject": "Seleccionar proyecto", diff --git a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts index fa1bd8cbad37..0c0182c50945 100644 --- a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts +++ b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts @@ -30,6 +30,137 @@ export function buildInitScript(options?: { const PERSONAS = ${personas}; const SKILLS = ${skills}; const PROJECTS = ${projects}; + const ACP_SESSIONS = []; + + function nowIso() { + return new Date().toISOString(); + } + + function buildSession(sessionId, providerId = "goose") { + return { + sessionId, + title: "New Chat", + updatedAt: nowIso(), + messageCount: 0, + providerId, + modelId: null, + }; + } + + function findSession(sessionId) { + return ACP_SESSIONS.find((session) => session.sessionId === sessionId) ?? null; + } + + function jsonRpcResult(id, result) { + return { jsonrpc: "2.0", id, result }; + } + + function handleAcpRequest(message) { + switch (message.method) { + case "initialize": + return jsonRpcResult(message.id, { + protocolVersion: "0.1.0", + agentCapabilities: { + loadSession: {}, + listSessions: {}, + }, + agentInfo: { + name: "mock-goose", + version: "0.0.0", + }, + authMethods: [], + }); + case "session/list": + return jsonRpcResult(message.id, { + sessions: ACP_SESSIONS.map((session) => ({ + sessionId: session.sessionId, + title: session.title, + updatedAt: session.updatedAt, + _meta: { + messageCount: session.messageCount, + }, + })), + }); + case "session/new": { + const providerId = message.params?.meta?.provider ?? "goose"; + const sessionId = "session-" + Math.random().toString(36).slice(2, 10); + ACP_SESSIONS.unshift(buildSession(sessionId, providerId)); + return jsonRpcResult(message.id, { sessionId }); + } + case "session/load": + return jsonRpcResult(message.id, {}); + case "session/set_config_option": { + const session = findSession(message.params?.sessionId); + if (session) { + if (message.params?.configId === "provider") { + session.providerId = message.params?.value ?? session.providerId; + session.modelId = null; + } + if (message.params?.configId === "model") { + session.modelId = message.params?.value ?? null; + } + session.updatedAt = nowIso(); + } + return jsonRpcResult(message.id, {}); + } + case "session/prompt": { + const session = findSession(message.params?.sessionId); + if (session) { + session.messageCount += 1; + session.updatedAt = nowIso(); + } + return jsonRpcResult(message.id, { stopReason: "end_turn" }); + } + case "_goose/providers/list": + return jsonRpcResult(message.id, { providers: [] }); + case "_goose/providers/inventory": + return jsonRpcResult(message.id, { entries: [] }); + case "_goose/providers/inventory/refresh": + return jsonRpcResult(message.id, { started: [], skipped: [] }); + case "_goose/working_dir/update": + case "goose/working_dir/update": + return jsonRpcResult(message.id, {}); + default: + return jsonRpcResult(message.id, {}); + } + } + + class MockWebSocket extends EventTarget { + constructor(url) { + super(); + this.url = url; + this.readyState = 0; + queueMicrotask(() => { + this.readyState = 1; + this.dispatchEvent(new Event("open")); + }); + } + + send(raw) { + const message = JSON.parse(raw); + const response = + message && typeof message === "object" && "id" in message + ? handleAcpRequest(message) + : null; + if (!response) { + return; + } + queueMicrotask(() => { + this.dispatchEvent( + new MessageEvent("message", { + data: JSON.stringify(response), + }), + ); + }); + } + + close() { + this.readyState = 3; + this.dispatchEvent(new CloseEvent("close")); + } + } + + window.WebSocket = MockWebSocket; window.__TAURI_INTERNALS__ = { invoke(cmd, args) { @@ -95,7 +226,16 @@ export function buildInitScript(options?: { // ---- Sessions / Misc ---- case "list_sessions": - return Promise.resolve([]); + return Promise.resolve( + ACP_SESSIONS.map((session) => ({ + sessionId: session.sessionId, + title: session.title, + updatedAt: session.updatedAt, + messageCount: session.messageCount, + })), + ); + case "get_goose_serve_url": + return Promise.resolve("ws://mock-goose"); case "create_session": return Promise.resolve({ id: "session-" + Math.random().toString(36).slice(2, 10), @@ -130,6 +270,16 @@ export function buildInitScript(options?: { return Promise.resolve("/tmp/home"); case "path_exists": return Promise.resolve(false); + case "resolve_path": { + const parts = args?.request?.parts ?? []; + const path = parts + .filter((part) => typeof part === "string" && part.length > 0) + .join("/"); + const normalizedPath = path.startsWith("~/") + ? "/tmp/home/" + path.slice(2) + : path; + return Promise.resolve({ path: normalizedPath }); + } // ---- Fallback ---- default: diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index a1eeeee569d3..dab5b1989596 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -31,8 +31,8 @@ import type { GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, - GetProviderModelsRequest, - GetProviderModelsResponse, + GetProviderInventoryRequest, + GetProviderInventoryResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, @@ -45,6 +45,8 @@ import type { ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, + RefreshProviderInventoryRequest, + RefreshProviderInventoryResponse, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, @@ -62,13 +64,14 @@ import { zExportSessionResponse, zGetExtensionsResponse, zGetProviderDetailsResponse, - zGetProviderModelsResponse, + zGetProviderInventoryResponse, zGetSessionExtensionsResponse, zGetToolsResponse, zImportSessionResponse, zListProvidersResponse, zReadConfigResponse, zReadResourceResponse, + zRefreshProviderInventoryResponse, } from './zod.gen.js'; export class GooseExtClient { @@ -132,11 +135,25 @@ export class GooseExtClient { return zGetProviderDetailsResponse.parse(raw) as GetProviderDetailsResponse; } - async GooseProvidersModels( - params: GetProviderModelsRequest, - ): Promise { - const raw = await this.conn.extMethod("_goose/providers/models", params); - return zGetProviderModelsResponse.parse(raw) as GetProviderModelsResponse; + async GooseProvidersInventory( + params: GetProviderInventoryRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/providers/inventory", params); + return zGetProviderInventoryResponse.parse( + raw, + ) as GetProviderInventoryResponse; + } + + async GooseProvidersInventoryRefresh( + params: RefreshProviderInventoryRequest, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/providers/inventory/refresh", + params, + ); + return zRefreshProviderInventoryResponse.parse( + raw, + ) as RefreshProviderInventoryResponse; } async GooseConfigRead( diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index d1886b07d767..2bb363a78fc6 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderModelsRequest, GetProviderModelsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; +export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderInventoryRequest, GetProviderInventoryResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -54,9 +54,14 @@ export const GOOSE_EXT_METHODS = [ responseType: "GetProviderDetailsResponse", }, { - method: "_goose/providers/models", - requestType: "GetProviderModelsRequest", - responseType: "GetProviderModelsResponse", + method: "_goose/providers/inventory", + requestType: "GetProviderInventoryRequest", + responseType: "GetProviderInventoryResponse", + }, + { + method: "_goose/providers/inventory/refresh", + requestType: "RefreshProviderInventoryRequest", + responseType: "RefreshProviderInventoryResponse", }, { method: "_goose/config/read", diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 15cf78ea75a7..87c164c6272c 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -165,19 +165,133 @@ export type ModelEntry = { }; /** - * Fetch the full list of models available for a specific provider. + * Read per-provider inventory. Always returns immediately from stored state. */ -export type GetProviderModelsRequest = { +export type GetProviderInventoryRequest = { + /** + * Only return entries for these providers. Empty means all. + */ + providerIds?: Array; +}; + +/** + * Provider inventory response. + */ +export type GetProviderInventoryResponse = { + entries: Array; +}; + +/** + * Provider inventory entry. + */ +export type ProviderInventoryEntryDto = { + /** + * Provider identifier. + */ + providerId: string; + /** + * Human-readable provider name. + */ providerName: string; + /** + * Whether Goose has enough configuration to use this provider. + */ + configured: boolean; + /** + * Whether this provider supports background inventory refresh. + */ + supportsRefresh: boolean; + /** + * Whether a refresh is currently in flight. + */ + refreshing: boolean; + /** + * The list of available models. + */ + models: Array; + /** + * When this entry was last successfully refreshed (ISO 8601). + */ + lastUpdatedAt?: string | null; + /** + * When a refresh was most recently attempted (ISO 8601). + */ + lastRefreshAttemptAt?: string | null; + /** + * The last refresh failure message, if any. + */ + lastRefreshError?: string | null; + /** + * Whether we believe this data may be outdated. + */ + stale: boolean; + /** + * Guidance message shown when this provider manages its own model selection externally. + */ + modelSelectionHint?: string | null; +}; + +/** + * A single model in provider inventory. + */ +export type ProviderInventoryModelDto = { + /** + * Model identifier as the provider knows it. + */ + id: string; + /** + * Human-readable display name. + */ + name: string; + /** + * Model family for grouping in UI. + */ + family?: string | null; + /** + * Context window size in tokens. + */ + contextLimit?: number | null; + /** + * Whether the model supports reasoning/extended thinking. + */ + reasoning?: boolean | null; + /** + * Whether this model should appear in the compact recommended picker. + */ + recommended?: boolean; +}; + +/** + * Trigger a background refresh of provider inventories. + */ +export type RefreshProviderInventoryRequest = { + /** + * Which providers to refresh. Empty means all known providers. + */ + providerIds?: Array; }; /** - * Provider models response. + * Refresh acknowledgement. */ -export type GetProviderModelsResponse = { - models: Array; +export type RefreshProviderInventoryResponse = { + /** + * Which providers will be refreshed. + */ + started: Array; + /** + * Which providers were skipped and why. + */ + skipped?: Array; +}; + +export type RefreshProviderInventorySkipDto = { + providerId: string; + reason: RefreshProviderInventorySkipReasonDto; }; +export type RefreshProviderInventorySkipReasonDto = 'unknown_provider' | 'not_configured' | 'does_not_support_refresh' | 'already_refreshing'; + /** * Read a single non-secret config value. */ @@ -421,14 +535,14 @@ export type DictationModelSelectRequest = { export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderModelsRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { + params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderInventoryRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { [key: string]: unknown; } | null; }; export type ExtResponse = { id: string; - result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderModelsResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; + result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderInventoryResponse | RefreshProviderInventoryResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index b48935fb2d5d..9f95a0de6d54 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -149,17 +149,94 @@ export const zGetProviderDetailsResponse = z.object({ }); /** - * Fetch the full list of models available for a specific provider. + * Read per-provider inventory. Always returns immediately from stored state. */ -export const zGetProviderModelsRequest = z.object({ - providerName: z.string() +export const zGetProviderInventoryRequest = z.object({ + providerIds: z.array(z.string()).optional().default([]) }); /** - * Provider models response. + * A single model in provider inventory. */ -export const zGetProviderModelsResponse = z.object({ - models: z.array(z.string()) +export const zProviderInventoryModelDto = z.object({ + id: z.string(), + name: z.string(), + family: z.union([ + z.string(), + z.null() + ]).optional(), + contextLimit: z.union([ + z.number().int().gte(0), + z.null() + ]).optional(), + reasoning: z.union([ + z.boolean(), + z.null() + ]).optional(), + recommended: z.boolean().optional().default(false) +}); + +/** + * Provider inventory entry. + */ +export const zProviderInventoryEntryDto = z.object({ + providerId: z.string(), + providerName: z.string(), + configured: z.boolean(), + supportsRefresh: z.boolean(), + refreshing: z.boolean(), + models: z.array(zProviderInventoryModelDto), + lastUpdatedAt: z.union([ + z.string(), + z.null() + ]).optional(), + lastRefreshAttemptAt: z.union([ + z.string(), + z.null() + ]).optional(), + lastRefreshError: z.union([ + z.string(), + z.null() + ]).optional(), + stale: z.boolean(), + modelSelectionHint: z.union([ + z.string(), + z.null() + ]).optional() +}); + +/** + * Provider inventory response. + */ +export const zGetProviderInventoryResponse = z.object({ + entries: z.array(zProviderInventoryEntryDto) +}); + +/** + * Trigger a background refresh of provider inventories. + */ +export const zRefreshProviderInventoryRequest = z.object({ + providerIds: z.array(z.string()).optional().default([]) +}); + +export const zRefreshProviderInventorySkipReasonDto = z.enum([ + 'unknown_provider', + 'not_configured', + 'does_not_support_refresh', + 'already_refreshing' +]); + +export const zRefreshProviderInventorySkipDto = z.object({ + providerId: z.string(), + reason: zRefreshProviderInventorySkipReasonDto +}); + +/** + * Refresh acknowledgement. + */ +export const zRefreshProviderInventoryResponse = z.object({ + started: z.array(z.string()), + skipped: z.array(zRefreshProviderInventorySkipDto).optional().default([]) }); /** @@ -426,7 +503,8 @@ export const zExtRequest = z.object({ zGetSessionExtensionsRequest, zListProvidersRequest, zGetProviderDetailsRequest, - zGetProviderModelsRequest, + zGetProviderInventoryRequest, + zRefreshProviderInventoryRequest, zReadConfigRequest, zUpsertConfigRequest, zRemoveConfigRequest, @@ -465,7 +543,8 @@ export const zExtResponse = z.union([ zGetSessionExtensionsResponse, zListProvidersResponse, zGetProviderDetailsResponse, - zGetProviderModelsResponse, + zGetProviderInventoryResponse, + zRefreshProviderInventoryResponse, zReadConfigResponse, zCheckSecretResponse, zExportSessionResponse, From 93299b513cebf424e3db65445e82923ee297c1fd Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:33:36 -0700 Subject: [PATCH 21/81] handle full node paths in goose2 kill recipe (#8709) Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- ui/goose2/justfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/goose2/justfile b/ui/goose2/justfile index 11c2a6ca8d15..3de2fbf90635 100644 --- a/ui/goose2/justfile +++ b/ui/goose2/justfile @@ -192,7 +192,8 @@ kill: fi PROC_NAME=$(ps -p "$PID" -o comm= 2>/dev/null) || true - if [[ "$PROC_NAME" != "node" ]]; then + PROC_BASENAME="${PROC_NAME##*/}" + if [[ "$PROC_BASENAME" != "node" ]]; then echo "Process on port $VITE_PORT is '$PROC_NAME', not 'node' — refusing to kill" exit 1 fi From b1235e7c01223e22feb66ef12ddfad3c7aa902ab Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Mon, 20 Apr 2026 23:09:21 -0400 Subject: [PATCH 22/81] Manage skills as sources over ACP (#8675) Co-authored-by: Lifei Zhou --- Cargo.lock | 1 + crates/goose-acp/acp-meta.json | 30 ++ crates/goose-acp/acp-schema.json | 387 ++++++++++++++ crates/goose-acp/src/server.rs | 79 +++ crates/goose-sdk/src/custom_requests.rs | 138 +++++ crates/goose/Cargo.toml | 1 + crates/goose/src/lib.rs | 1 + crates/goose/src/sources.rs | 526 ++++++++++++++++++++ ui/goose2/src-tauri/src/commands/acp.rs | 7 + ui/goose2/src-tauri/src/commands/mod.rs | 1 - ui/goose2/src-tauri/src/commands/skills.rs | 319 ------------ ui/goose2/src-tauri/src/lib.rs | 6 - ui/goose2/src/features/skills/api/skills.ts | 73 ++- ui/goose2/tests/e2e/fixtures/tauri-mock.ts | 77 ++- ui/sdk/src/generated/client.gen.ts | 55 ++ ui/sdk/src/generated/index.ts | 32 +- ui/sdk/src/generated/types.gen.ts | 117 ++++- ui/sdk/src/generated/zod.gen.ts | 135 +++++ 18 files changed, 1622 insertions(+), 363 deletions(-) create mode 100644 crates/goose/src/sources.rs delete mode 100644 ui/goose2/src-tauri/src/commands/skills.rs diff --git a/Cargo.lock b/Cargo.lock index e38f0b0f0aa3..c799a881e94b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4358,6 +4358,7 @@ dependencies = [ "fs-err", "fs2", "futures", + "goose-sdk", "goose-test-support", "http 1.4.0", "ignore", diff --git a/crates/goose-acp/acp-meta.json b/crates/goose-acp/acp-meta.json index 413707f6a638..8a2e7fe4ff7c 100644 --- a/crates/goose-acp/acp-meta.json +++ b/crates/goose-acp/acp-meta.json @@ -110,6 +110,36 @@ "requestType": "UnarchiveSessionRequest", "responseType": "EmptyResponse" }, + { + "method": "_goose/sources/create", + "requestType": "CreateSourceRequest", + "responseType": "CreateSourceResponse" + }, + { + "method": "_goose/sources/list", + "requestType": "ListSourcesRequest", + "responseType": "ListSourcesResponse" + }, + { + "method": "_goose/sources/update", + "requestType": "UpdateSourceRequest", + "responseType": "UpdateSourceResponse" + }, + { + "method": "_goose/sources/delete", + "requestType": "DeleteSourceRequest", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/sources/export", + "requestType": "ExportSourceRequest", + "responseType": "ExportSourceResponse" + }, + { + "method": "_goose/sources/import", + "requestType": "ImportSourcesRequest", + "responseType": "ImportSourcesResponse" + }, { "method": "_goose/dictation/transcribe", "requestType": "DictationTranscribeRequest", diff --git a/crates/goose-acp/acp-schema.json b/crates/goose-acp/acp-schema.json index 4ae0b23633f3..8fe12e001945 100644 --- a/crates/goose-acp/acp-schema.json +++ b/crates/goose-acp/acp-schema.json @@ -795,6 +795,299 @@ "x-side": "agent", "x-method": "_goose/session/unarchive" }, + "CreateSourceRequest": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "content": { + "type": "string" + }, + "global": { + "type": "boolean" + }, + "projectDir": { + "type": [ + "string", + "null" + ], + "description": "Absolute path to the project root. Required when `global` is false." + } + }, + "required": [ + "type", + "name", + "description", + "content", + "global" + ], + "description": "Create a new source (global or project-scoped).", + "x-side": "agent", + "x-method": "_goose/sources/create" + }, + "SourceType": { + "type": "string", + "enum": [ + "skill" + ], + "description": "The type of source entity." + }, + "CreateSourceResponse": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/SourceEntry" + } + }, + "required": [ + "source" + ], + "x-side": "agent", + "x-method": "_goose/sources/create" + }, + "SourceEntry": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "content": { + "type": "string" + }, + "directory": { + "type": "string", + "description": "Absolute path to the source's directory on disk." + }, + "global": { + "type": "boolean", + "description": "True when the source lives in the user's global sources directory; false\nwhen it lives inside a specific project." + } + }, + "required": [ + "type", + "name", + "description", + "content", + "directory", + "global" + ], + "description": "A source — a user-editable entity backed by an on-disk directory. Sources\nmay be either `global` (shared across all projects) or project-specific." + }, + "ListSourcesRequest": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "$ref": "#/$defs/SourceType" + }, + { + "type": "null" + } + ] + }, + "projectDir": { + "type": [ + "string", + "null" + ] + } + }, + "description": "List sources. If `type` is omitted, sources of all known types are returned.\nBoth global and project-scoped sources are included when `project_dir` is set.", + "x-side": "agent", + "x-method": "_goose/sources/list" + }, + "ListSourcesResponse": { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/SourceEntry" + } + } + }, + "required": [ + "sources" + ], + "x-side": "agent", + "x-method": "_goose/sources/list" + }, + "UpdateSourceRequest": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "content": { + "type": "string" + }, + "global": { + "type": "boolean" + }, + "projectDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type", + "name", + "description", + "content", + "global" + ], + "description": "Update an existing source's description and content.", + "x-side": "agent", + "x-method": "_goose/sources/update" + }, + "UpdateSourceResponse": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/SourceEntry" + } + }, + "required": [ + "source" + ], + "x-side": "agent", + "x-method": "_goose/sources/update" + }, + "DeleteSourceRequest": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "name": { + "type": "string" + }, + "global": { + "type": "boolean" + }, + "projectDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type", + "name", + "global" + ], + "description": "Delete a source and its on-disk directory.", + "x-side": "agent", + "x-method": "_goose/sources/delete" + }, + "ExportSourceRequest": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/SourceType" + }, + "name": { + "type": "string" + }, + "global": { + "type": "boolean" + }, + "projectDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type", + "name", + "global" + ], + "description": "Export a source as a portable JSON payload.", + "x-side": "agent", + "x-method": "_goose/sources/export" + }, + "ExportSourceResponse": { + "type": "object", + "properties": { + "json": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "required": [ + "json", + "filename" + ], + "x-side": "agent", + "x-method": "_goose/sources/export" + }, + "ImportSourcesRequest": { + "type": "object", + "properties": { + "data": { + "type": "string" + }, + "global": { + "type": "boolean" + }, + "projectDir": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data", + "global" + ], + "description": "Import a source from a JSON export payload produced by `_goose/sources/export`.\nThe imported source is written under the given scope; on name collisions a\n`-imported` suffix is appended.", + "x-side": "agent", + "x-method": "_goose/sources/import" + }, + "ImportSourcesResponse": { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/SourceEntry" + } + } + }, + "required": [ + "sources" + ], + "x-side": "agent", + "x-method": "_goose/sources/import" + }, "DictationTranscribeRequest": { "type": "object", "properties": { @@ -1328,6 +1621,60 @@ "description": "Params for _goose/session/unarchive", "title": "UnarchiveSessionRequest" }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateSourceRequest" + } + ], + "description": "Params for _goose/sources/create", + "title": "CreateSourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListSourcesRequest" + } + ], + "description": "Params for _goose/sources/list", + "title": "ListSourcesRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateSourceRequest" + } + ], + "description": "Params for _goose/sources/update", + "title": "UpdateSourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DeleteSourceRequest" + } + ], + "description": "Params for _goose/sources/delete", + "title": "DeleteSourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExportSourceRequest" + } + ], + "description": "Params for _goose/sources/export", + "title": "ExportSourceRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ImportSourcesRequest" + } + ], + "description": "Params for _goose/sources/import", + "title": "ImportSourcesRequest" + }, { "allOf": [ { @@ -1534,6 +1881,46 @@ ], "title": "ImportSessionResponse" }, + { + "allOf": [ + { + "$ref": "#/$defs/CreateSourceResponse" + } + ], + "title": "CreateSourceResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListSourcesResponse" + } + ], + "title": "ListSourcesResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateSourceResponse" + } + ], + "title": "UpdateSourceResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExportSourceResponse" + } + ], + "title": "ExportSourceResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ImportSourcesResponse" + } + ], + "title": "ImportSourcesResponse" + }, { "allOf": [ { diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 9a4dc8ca48a4..09d483a2c56a 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -3115,6 +3115,85 @@ impl GooseAcpAgent { Ok(EmptyResponse {}) } + #[custom_method(CreateSourceRequest)] + async fn on_create_source( + &self, + req: CreateSourceRequest, + ) -> Result { + let source = goose::sources::create_source( + req.source_type, + &req.name, + &req.description, + &req.content, + req.global, + req.project_dir.as_deref(), + )?; + Ok(CreateSourceResponse { source }) + } + + #[custom_method(ListSourcesRequest)] + async fn on_list_sources( + &self, + req: ListSourcesRequest, + ) -> Result { + let sources = goose::sources::list_sources(req.source_type, req.project_dir.as_deref())?; + Ok(ListSourcesResponse { sources }) + } + + #[custom_method(UpdateSourceRequest)] + async fn on_update_source( + &self, + req: UpdateSourceRequest, + ) -> Result { + let source = goose::sources::update_source( + req.source_type, + &req.name, + &req.description, + &req.content, + req.global, + req.project_dir.as_deref(), + )?; + Ok(UpdateSourceResponse { source }) + } + + #[custom_method(DeleteSourceRequest)] + async fn on_delete_source( + &self, + req: DeleteSourceRequest, + ) -> Result { + goose::sources::delete_source( + req.source_type, + &req.name, + req.global, + req.project_dir.as_deref(), + )?; + Ok(EmptyResponse {}) + } + + #[custom_method(ExportSourceRequest)] + async fn on_export_source( + &self, + req: ExportSourceRequest, + ) -> Result { + let (json, filename) = goose::sources::export_source( + req.source_type, + &req.name, + req.global, + req.project_dir.as_deref(), + )?; + Ok(ExportSourceResponse { json, filename }) + } + + #[custom_method(ImportSourcesRequest)] + async fn on_import_sources( + &self, + req: ImportSourcesRequest, + ) -> Result { + let sources = + goose::sources::import_sources(&req.data, req.global, req.project_dir.as_deref())?; + Ok(ImportSourcesResponse { sources }) + } + #[custom_method(DictationTranscribeRequest)] async fn on_dictation_transcribe( &self, diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 533718211062..2906b7aad126 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -296,6 +296,144 @@ pub struct ProviderConfigKey { pub primary: bool, } +/// The type of source entity. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum SourceType { + #[default] + Skill, +} + +/// A source — a user-editable entity backed by an on-disk directory. Sources +/// may be either `global` (shared across all projects) or project-specific. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SourceEntry { + #[serde(rename = "type")] + pub source_type: SourceType, + pub name: String, + pub description: String, + pub content: String, + /// Absolute path to the source's directory on disk. + pub directory: String, + /// True when the source lives in the user's global sources directory; false + /// when it lives inside a specific project. + pub global: bool, +} + +/// Create a new source (global or project-scoped). +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/create", response = CreateSourceResponse)] +#[serde(rename_all = "camelCase")] +pub struct CreateSourceRequest { + #[serde(rename = "type")] + pub source_type: SourceType, + pub name: String, + pub description: String, + pub content: String, + pub global: bool, + /// Absolute path to the project root. Required when `global` is false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct CreateSourceResponse { + pub source: SourceEntry, +} + +/// List sources. If `type` is omitted, sources of all known types are returned. +/// Both global and project-scoped sources are included when `project_dir` is set. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/list", response = ListSourcesResponse)] +#[serde(rename_all = "camelCase")] +pub struct ListSourcesRequest { + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub source_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ListSourcesResponse { + pub sources: Vec, +} + +/// Update an existing source's description and content. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/update", response = UpdateSourceResponse)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSourceRequest { + #[serde(rename = "type")] + pub source_type: SourceType, + pub name: String, + pub description: String, + pub content: String, + pub global: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSourceResponse { + pub source: SourceEntry, +} + +/// Delete a source and its on-disk directory. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/delete", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSourceRequest { + #[serde(rename = "type")] + pub source_type: SourceType, + pub name: String, + pub global: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} + +/// Export a source as a portable JSON payload. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/export", response = ExportSourceResponse)] +#[serde(rename_all = "camelCase")] +pub struct ExportSourceRequest { + #[serde(rename = "type")] + pub source_type: SourceType, + pub name: String, + pub global: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ExportSourceResponse { + pub json: String, + pub filename: String, +} + +/// Import a source from a JSON export payload produced by `_goose/sources/export`. +/// The imported source is written under the given scope; on name collisions a +/// `-imported` suffix is appended. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/sources/import", response = ImportSourcesResponse)] +#[serde(rename_all = "camelCase")] +pub struct ImportSourcesRequest { + pub data: String, + pub global: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_dir: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ImportSourcesResponse { + pub sources: Vec, +} + /// Transcribe audio via a dictation provider. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/dictation/transcribe", response = DictationTranscribeResponse)] diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 0960319d7623..d99551c0c772 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -113,6 +113,7 @@ strum = { workspace = true } once_cell = { workspace = true } etcetera = { workspace = true } fs-err = "3" +goose-sdk = { path = "../goose-sdk" } rand = { workspace = true } utoipa = { workspace = true, features = ["chrono"] } tokio-cron-scheduler = "0.14.0" diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index cd60b4de8ee7..def1d186d038 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -38,6 +38,7 @@ pub mod security; pub mod session; pub mod session_context; pub mod slash_commands; +pub mod sources; pub mod subprocess; pub mod token_counter; pub mod tool_inspection; diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs new file mode 100644 index 000000000000..35d0b67805a4 --- /dev/null +++ b/crates/goose/src/sources.rs @@ -0,0 +1,526 @@ +//! Filesystem-backed CRUD for [`SourceEntry`] values exchanged over ACP custom +//! methods. A source is a user-editable entity stored under a per-scope root +//! directory — `~/.agents/skills` for global sources and `/.goose/skills` +//! for project-specific sources. + +use crate::agents::platform_extensions::parse_frontmatter; +use fs_err as fs; +use goose_sdk::custom_requests::{SourceEntry, SourceType}; +use sacp::Error; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +#[derive(Deserialize)] +struct SkillFront { + #[serde(default)] + description: String, +} + +const GLOBAL_SKILLS_SUBPATH: &[&str] = &[".agents", "skills"]; +const PROJECT_SKILLS_SUBPATH: &[&str] = &[".goose", "skills"]; + +fn home_dir() -> Result { + dirs::home_dir() + .ok_or_else(|| Error::internal_error().data("Could not determine home directory")) +} + +fn skills_dir_global() -> Result { + let mut dir = home_dir()?; + for part in GLOBAL_SKILLS_SUBPATH { + dir = dir.join(part); + } + Ok(dir) +} + +fn skills_dir_project(project_dir: &str) -> Result { + if project_dir.trim().is_empty() { + return Err( + Error::invalid_params().data("projectDir must not be empty when global is false") + ); + } + let mut dir = PathBuf::from(project_dir); + for part in PROJECT_SKILLS_SUBPATH { + dir = dir.join(part); + } + Ok(dir) +} + +fn source_base_dir( + source_type: SourceType, + global: bool, + project_dir: Option<&str>, +) -> Result { + match source_type { + SourceType::Skill => { + if global { + skills_dir_global() + } else { + let pd = project_dir.ok_or_else(|| { + Error::invalid_params().data("projectDir is required when global is false") + })?; + skills_dir_project(pd) + } + } + } +} + +/// Kebab-case validation: `^[a-z0-9]+(-[a-z0-9]+)*$`. Prevents path traversal +/// via names like `../../.ssh/authorized_keys`. +fn validate_source_name(name: &str) -> Result<(), Error> { + if name.is_empty() { + return Err(Error::invalid_params().data("Source name must not be empty")); + } + let mut expect_alnum = true; + for ch in name.chars() { + if ch.is_ascii_lowercase() || ch.is_ascii_digit() { + expect_alnum = false; + } else if ch == '-' && !expect_alnum { + expect_alnum = true; + } else { + return Err(Error::invalid_params().data(format!( + "Invalid source name \"{}\". Names must be kebab-case (lowercase letters, digits, and hyphens; \ + must not start or end with a hyphen or contain consecutive hyphens).", + name + ))); + } + } + if expect_alnum { + return Err(Error::invalid_params().data(format!( + "Invalid source name \"{}\". Names must not end with a hyphen.", + name + ))); + } + Ok(()) +} + +fn build_skill_md(name: &str, description: &str, content: &str) -> String { + // YAML single-quoted strings escape a literal single quote by doubling it. + let safe_desc = description.replace('\'', "''"); + let mut md = format!("---\nname: {}\ndescription: '{}'\n---\n", name, safe_desc); + if !content.is_empty() { + md.push('\n'); + md.push_str(content); + md.push('\n'); + } + md +} + +fn parse_skill_frontmatter(raw: &str) -> (String, String) { + if !raw.trim_start().starts_with("---") { + return (String::new(), raw.to_string()); + } + match parse_frontmatter::(raw) { + Ok(Some((meta, body))) => (meta.description, body), + _ => (String::new(), raw.to_string()), + } +} + +fn source_entry( + source_type: SourceType, + name: &str, + description: &str, + content: &str, + dir: &Path, + global: bool, +) -> SourceEntry { + SourceEntry { + source_type, + name: name.to_string(), + description: description.to_string(), + content: content.to_string(), + directory: dir.to_string_lossy().to_string(), + global, + } +} + +pub fn create_source( + source_type: SourceType, + name: &str, + description: &str, + content: &str, + global: bool, + project_dir: Option<&str>, +) -> Result { + validate_source_name(name)?; + let dir = source_base_dir(source_type, global, project_dir)?.join(name); + + if dir.exists() { + return Err( + Error::invalid_params().data(format!("A source named \"{}\" already exists", name)) + ); + } + + fs::create_dir_all(&dir).map_err(|e| { + Error::internal_error().data(format!("Failed to create source directory: {e}")) + })?; + let file_path = dir.join("SKILL.md"); + let md = build_skill_md(name, description, content); + fs::write(&file_path, md) + .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; + + Ok(source_entry( + source_type, + name, + description, + content, + &dir, + global, + )) +} + +pub fn update_source( + source_type: SourceType, + name: &str, + description: &str, + content: &str, + global: bool, + project_dir: Option<&str>, +) -> Result { + validate_source_name(name)?; + let dir = source_base_dir(source_type, global, project_dir)?.join(name); + + if !dir.exists() { + return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name))); + } + + let file_path = dir.join("SKILL.md"); + let md = build_skill_md(name, description, content); + fs::write(&file_path, md) + .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; + + Ok(source_entry( + source_type, + name, + description, + content, + &dir, + global, + )) +} + +pub fn delete_source( + source_type: SourceType, + name: &str, + global: bool, + project_dir: Option<&str>, +) -> Result<(), Error> { + validate_source_name(name)?; + let dir = source_base_dir(source_type, global, project_dir)?.join(name); + + if !dir.exists() { + return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name))); + } + fs::remove_dir_all(&dir) + .map_err(|e| Error::internal_error().data(format!("Failed to delete source: {e}")))?; + Ok(()) +} + +pub fn list_sources( + source_type: Option, + project_dir: Option<&str>, +) -> Result, Error> { + let kinds: Vec = match source_type { + Some(k) => vec![k], + None => vec![SourceType::Skill], + }; + + let mut sources = Vec::new(); + for kind in kinds { + match kind { + SourceType::Skill => { + if let Some(pd) = project_dir { + if !pd.trim().is_empty() { + let dir = skills_dir_project(pd)?; + sources.extend(read_skill_dir(&dir, false)?); + } + } + let dir = skills_dir_global()?; + sources.extend(read_skill_dir(&dir, true)?); + } + } + } + sources.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(sources) +} + +fn read_skill_dir(dir: &Path, global: bool) -> Result, Error> { + if !dir.exists() { + return Ok(Vec::new()); + } + let entries = fs::read_dir(dir) + .map_err(|e| Error::internal_error().data(format!("Failed to read skills dir: {e}")))?; + + let mut out = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let skill_md = path.join("SKILL.md"); + if !skill_md.exists() { + continue; + } + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + let raw = fs::read_to_string(&skill_md).unwrap_or_default(); + let (description, content) = parse_skill_frontmatter(&raw); + out.push(source_entry( + SourceType::Skill, + &name, + &description, + &content, + &path, + global, + )); + } + Ok(out) +} + +pub fn export_source( + source_type: SourceType, + name: &str, + global: bool, + project_dir: Option<&str>, +) -> Result<(String, String), Error> { + validate_source_name(name)?; + let dir = source_base_dir(source_type, global, project_dir)?.join(name); + + if !dir.exists() { + return Err(Error::invalid_params().data(format!("Source \"{}\" not found", name))); + } + + let md = dir.join("SKILL.md"); + let raw = fs::read_to_string(&md) + .map_err(|e| Error::internal_error().data(format!("Failed to read SKILL.md: {e}")))?; + let (description, content) = parse_skill_frontmatter(&raw); + + let type_slug = match source_type { + SourceType::Skill => "skill", + }; + let export = serde_json::json!({ + "version": 1, + "type": type_slug, + "name": name, + "description": description, + "content": content, + }); + let json = serde_json::to_string_pretty(&export) + .map_err(|e| Error::internal_error().data(format!("Failed to serialize source: {e}")))?; + let filename = format!("{}.{}.json", name, type_slug); + Ok((json, filename)) +} + +pub fn import_sources( + data: &str, + global: bool, + project_dir: Option<&str>, +) -> Result, Error> { + let value: serde_json::Value = serde_json::from_str(data) + .map_err(|e| Error::invalid_params().data(format!("Invalid JSON: {e}")))?; + + let version = value + .get("version") + .and_then(|v| v.as_u64()) + .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"version\" field"))?; + if version != 1 { + return Err( + Error::invalid_params().data(format!("Unsupported source export version: {}", version)) + ); + } + + // Default to `skill` to preserve compatibility with pre-sources skill exports. + let source_type = match value + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("skill") + { + "skill" => SourceType::Skill, + other => { + return Err(Error::invalid_params().data(format!("Unsupported source type: {}", other))); + } + }; + + let name = value + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"name\" field"))? + .to_string(); + if name.is_empty() { + return Err(Error::invalid_params().data("Source name must not be empty")); + } + + let description = value + .get("description") + .and_then(|v| v.as_str()) + .ok_or_else(|| Error::invalid_params().data("Missing or invalid \"description\" field"))? + .to_string(); + if description.is_empty() { + return Err(Error::invalid_params().data("Source description must not be empty")); + } + + // Accept both the new `content` key and the legacy skills `instructions` key. + let content = value + .get("content") + .or_else(|| value.get("instructions")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + validate_source_name(&name)?; + + let base = source_base_dir(source_type, global, project_dir)?; + let mut final_name = name.clone(); + if base.join(&final_name).exists() { + final_name = format!("{}-imported", name); + let mut counter = 2u32; + while base.join(&final_name).exists() { + final_name = format!("{}-imported-{}", name, counter); + counter += 1; + } + } + + let dir = base.join(&final_name); + fs::create_dir_all(&dir).map_err(|e| { + Error::internal_error().data(format!("Failed to create source directory: {e}")) + })?; + let file_path = dir.join("SKILL.md"); + let md = build_skill_md(&final_name, &description, &content); + fs::write(&file_path, md) + .map_err(|e| Error::internal_error().data(format!("Failed to write SKILL.md: {e}")))?; + + Ok(vec![source_entry( + source_type, + &final_name, + &description, + &content, + &dir, + global, + )]) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn kebab_case_validation() { + assert!(validate_source_name("my-skill").is_ok()); + assert!(validate_source_name("abc123").is_ok()); + assert!(validate_source_name("").is_err()); + assert!(validate_source_name("-leading").is_err()); + assert!(validate_source_name("trailing-").is_err()); + assert!(validate_source_name("double--hyphen").is_err()); + assert!(validate_source_name("CAPS").is_err()); + assert!(validate_source_name("../escape").is_err()); + } + + #[test] + fn create_list_update_delete_project_skill() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + + let created = create_source( + SourceType::Skill, + "my-skill", + "does the thing", + "step one\nstep two", + false, + Some(project), + ) + .unwrap(); + assert_eq!(created.name, "my-skill"); + assert!(!created.global); + assert!(PathBuf::from(&created.directory).join("SKILL.md").exists()); + + let listed = list_sources(Some(SourceType::Skill), Some(project)).unwrap(); + assert!(listed.iter().any(|s| s.name == "my-skill" && !s.global)); + + let updated = update_source( + SourceType::Skill, + "my-skill", + "now does a different thing", + "step three", + false, + Some(project), + ) + .unwrap(); + assert_eq!(updated.description, "now does a different thing"); + + delete_source(SourceType::Skill, "my-skill", false, Some(project)).unwrap(); + assert!(!PathBuf::from(&created.directory).exists()); + } + + #[test] + fn create_rejects_duplicate_name() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + + create_source(SourceType::Skill, "dup", "d", "c", false, Some(project)).unwrap(); + let err = + create_source(SourceType::Skill, "dup", "d", "c", false, Some(project)).unwrap_err(); + assert!(format!("{:?}", err).contains("already exists")); + } + + #[test] + fn project_scope_requires_project_dir() { + let err = create_source(SourceType::Skill, "x", "d", "c", false, None).unwrap_err(); + assert!(format!("{:?}", err).contains("projectDir")); + } + + #[test] + fn export_then_import_roundtrip() { + let tmp = TempDir::new().unwrap(); + let project_a = tmp.path().join("a"); + let project_b = tmp.path().join("b"); + std::fs::create_dir_all(&project_a).unwrap(); + std::fs::create_dir_all(&project_b).unwrap(); + + create_source( + SourceType::Skill, + "portable", + "describes itself", + "body goes here", + false, + Some(project_a.to_str().unwrap()), + ) + .unwrap(); + + let (json, filename) = export_source( + SourceType::Skill, + "portable", + false, + Some(project_a.to_str().unwrap()), + ) + .unwrap(); + assert_eq!(filename, "portable.skill.json"); + + let imported = import_sources(&json, false, Some(project_b.to_str().unwrap())).unwrap(); + assert_eq!(imported.len(), 1); + assert_eq!(imported[0].name, "portable"); + assert_eq!(imported[0].description, "describes itself"); + assert_eq!(imported[0].content, "body goes here"); + } + + #[test] + fn import_collision_appends_suffix() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().to_str().unwrap(); + + create_source(SourceType::Skill, "busy", "d", "c", false, Some(project)).unwrap(); + + let payload = serde_json::json!({ + "version": 1, + "type": "skill", + "name": "busy", + "description": "d", + "content": "c", + }) + .to_string(); + let imported = import_sources(&payload, false, Some(project)).unwrap(); + assert_eq!(imported[0].name, "busy-imported"); + } +} diff --git a/ui/goose2/src-tauri/src/commands/acp.rs b/ui/goose2/src-tauri/src/commands/acp.rs index 9d97d0c6d69a..2906da85b6bf 100644 --- a/ui/goose2/src-tauri/src/commands/acp.rs +++ b/ui/goose2/src-tauri/src/commands/acp.rs @@ -1,7 +1,14 @@ +use std::env; + use crate::services::acp::GooseServeProcess; #[tauri::command] pub async fn get_goose_serve_url(app_handle: tauri::AppHandle) -> Result { + if let Ok(url) = env::var("GOOSE_SERVE_URL") { + if !url.is_empty() { + return Ok(url); + } + } let process = GooseServeProcess::get(app_handle).await?; Ok(process.ws_url()) } diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs index 7401328c9684..8acd3c56ab61 100644 --- a/ui/goose2/src-tauri/src/commands/mod.rs +++ b/ui/goose2/src-tauri/src/commands/mod.rs @@ -9,5 +9,4 @@ pub mod git_changes; pub mod model_setup; pub mod path_resolver; pub mod projects; -pub mod skills; pub mod system; diff --git a/ui/goose2/src-tauri/src/commands/skills.rs b/ui/goose2/src-tauri/src/commands/skills.rs deleted file mode 100644 index 4d975da45328..000000000000 --- a/ui/goose2/src-tauri/src/commands/skills.rs +++ /dev/null @@ -1,319 +0,0 @@ -use std::fs; -use std::path::PathBuf; - -fn skills_dir() -> Result { - let home = dirs::home_dir().ok_or("Could not determine home directory")?; - Ok(home.join(".agents").join("skills")) -} - -/// Validates that a skill name is kebab-case only: `^[a-z0-9]+(-[a-z0-9]+)*$`. -/// This prevents path traversal attacks (e.g. `../../.ssh/authorized_keys`). -fn validate_skill_name(name: &str) -> Result<(), String> { - if name.is_empty() { - return Err("Skill name must not be empty".to_string()); - } - let mut expect_alnum = true; // true = next char must be [a-z0-9], false = can also be '-' - for ch in name.chars() { - if ch.is_ascii_lowercase() || ch.is_ascii_digit() { - expect_alnum = false; - } else if ch == '-' && !expect_alnum { - expect_alnum = true; // char after '-' must be [a-z0-9] - } else { - return Err(format!( - "Invalid skill name \"{}\". Names must be kebab-case (lowercase letters, digits, and hyphens; \ - must not start or end with a hyphen or contain consecutive hyphens).", - name - )); - } - } - if expect_alnum { - // name ended with '-' - return Err(format!( - "Invalid skill name \"{}\". Names must not end with a hyphen.", - name - )); - } - Ok(()) -} - -fn build_skill_md(name: &str, description: &str, instructions: &str) -> String { - // Escape embedded single quotes by doubling them, then wrap in single quotes - // to prevent YAML injection in the description field. - let safe_desc = description.replace('\'', "''"); - let mut md = format!("---\nname: {}\ndescription: '{}'\n---\n", name, safe_desc); - if !instructions.is_empty() { - md.push('\n'); - md.push_str(instructions); - md.push('\n'); - } - md -} - -#[tauri::command] -pub fn create_skill(name: String, description: String, instructions: String) -> Result<(), String> { - validate_skill_name(&name)?; - let dir = skills_dir()?.join(&name); - - if dir.exists() { - return Err(format!("A skill named \"{}\" already exists", name)); - } - - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create skill directory: {}", e))?; - - let skill_path = dir.join("SKILL.md"); - let content = build_skill_md(&name, &description, &instructions); - - fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?; - - Ok(()) -} - -#[tauri::command] -pub fn list_skills() -> Result, String> { - let dir = skills_dir()?; - - if !dir.exists() { - return Ok(vec![]); - } - - let mut skills = Vec::new(); - let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read skills dir: {}", e))?; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let skill_md = path.join("SKILL.md"); - if !skill_md.exists() { - continue; - } - - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_string(); - - let raw = fs::read_to_string(&skill_md).unwrap_or_default(); - let (description, instructions) = parse_frontmatter(&raw); - - skills.push(SkillInfo { - name, - description, - instructions, - path: skill_md.to_string_lossy().to_string(), - }); - } - - skills.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(skills) -} - -#[tauri::command] -pub fn delete_skill(name: String) -> Result<(), String> { - validate_skill_name(&name)?; - let dir = skills_dir()?.join(&name); - if !dir.exists() { - return Err(format!("Skill \"{}\" not found", name)); - } - fs::remove_dir_all(&dir).map_err(|e| format!("Failed to delete skill: {}", e))?; - Ok(()) -} - -fn parse_frontmatter(raw: &str) -> (String, String) { - let trimmed = raw.trim(); - if !trimmed.starts_with("---") { - return (String::new(), raw.to_string()); - } - - if let Some(end) = trimmed[3..].find("\n---") { - let front = &trimmed[3..3 + end].trim(); - let body = trimmed[3 + end + 4..].trim().to_string(); - - let mut description = String::new(); - for line in front.lines() { - let line = line.trim(); - if let Some(rest) = line.strip_prefix("description:") { - let val = rest.trim(); - // Strip surrounding quotes (single or double) - let unquoted = val - .trim_start_matches(['\'', '"']) - .trim_end_matches(['\'', '"']); - description = if val.starts_with('\'') { - // Un-escape doubled single quotes - unquoted.replace("''", "'") - } else { - // Legacy double-quote format - unquoted.replace("\\\"", "\"") - } - .to_string(); - } - } - - (description, body) - } else { - (String::new(), raw.to_string()) - } -} - -#[derive(serde::Serialize, Clone)] -pub struct SkillInfo { - pub name: String, - pub description: String, - pub instructions: String, - pub path: String, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SkillExportV1 { - version: u32, - name: String, - description: String, - #[serde(skip_serializing_if = "String::is_empty")] - instructions: String, -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ExportSkillResult { - json: String, - filename: String, -} - -#[tauri::command] -pub fn update_skill( - name: String, - description: String, - instructions: String, -) -> Result { - validate_skill_name(&name)?; - let dir = skills_dir()?.join(&name); - - if !dir.exists() { - return Err(format!("Skill \"{}\" not found", name)); - } - - let skill_path = dir.join("SKILL.md"); - let content = build_skill_md(&name, &description, &instructions); - - fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?; - - Ok(SkillInfo { - name: name.clone(), - description, - instructions, - path: skill_path.to_string_lossy().to_string(), - }) -} - -#[tauri::command] -pub fn export_skill(name: String) -> Result { - validate_skill_name(&name)?; - let dir = skills_dir()?.join(&name); - - if !dir.exists() { - return Err(format!("Skill \"{}\" not found", name)); - } - - let skill_md = dir.join("SKILL.md"); - let raw = - fs::read_to_string(&skill_md).map_err(|e| format!("Failed to read SKILL.md: {}", e))?; - let (description, instructions) = parse_frontmatter(&raw); - - let export = SkillExportV1 { - version: 1, - name: name.clone(), - description, - instructions, - }; - - let json = serde_json::to_string_pretty(&export) - .map_err(|e| format!("Failed to serialize skill: {}", e))?; - - let filename = format!("{}.skill.json", name); - - Ok(ExportSkillResult { json, filename }) -} - -#[tauri::command] -pub fn import_skills(file_bytes: Vec, file_name: String) -> Result, String> { - // Validate file extension - if !file_name.ends_with(".skill.json") && !file_name.ends_with(".json") { - return Err("File must have a .skill.json or .json extension".to_string()); - } - - // Parse bytes as UTF-8 - let text = - String::from_utf8(file_bytes).map_err(|e| format!("File is not valid UTF-8: {}", e))?; - - // Parse as JSON - let value: serde_json::Value = - serde_json::from_str(&text).map_err(|e| format!("Invalid JSON: {}", e))?; - - // Validate version - let version = value - .get("version") - .and_then(|v| v.as_u64()) - .ok_or("Missing or invalid \"version\" field")?; - if version != 1 { - return Err(format!("Unsupported skill export version: {}", version)); - } - - // Extract fields - let name = value - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing or invalid \"name\" field")? - .to_string(); - if name.is_empty() { - return Err("Skill name must not be empty".to_string()); - } - - let description = value - .get("description") - .and_then(|v| v.as_str()) - .ok_or("Missing or invalid \"description\" field")? - .to_string(); - if description.is_empty() { - return Err("Skill description must not be empty".to_string()); - } - - let instructions = value - .get("instructions") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - // Validate the name - validate_skill_name(&name)?; - - // Determine final name, avoiding collisions - let base_dir = skills_dir()?; - let mut final_name = name.clone(); - if base_dir.join(&final_name).exists() { - final_name = format!("{}-imported", name); - // If that also exists, append a number - let mut counter = 2u32; - while base_dir.join(&final_name).exists() { - final_name = format!("{}-imported-{}", name, counter); - counter += 1; - } - } - - // Create the skill on disk - let dir = base_dir.join(&final_name); - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create skill directory: {}", e))?; - - let skill_path = dir.join("SKILL.md"); - let content = build_skill_md(&final_name, &description, &instructions); - fs::write(&skill_path, content).map_err(|e| format!("Failed to write SKILL.md: {}", e))?; - - Ok(vec![SkillInfo { - name: final_name, - description, - instructions, - path: skill_path.to_string_lossy().to_string(), - }]) -} diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index ff900e017038..7ec19d7b26f9 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -44,12 +44,6 @@ pub fn run() { commands::agents::save_persona_avatar_bytes, commands::agents::get_avatars_dir, commands::acp::get_goose_serve_url, - commands::skills::create_skill, - commands::skills::list_skills, - commands::skills::delete_skill, - commands::skills::update_skill, - commands::skills::export_skill, - commands::skills::import_skills, commands::projects::list_projects, commands::projects::create_project, commands::projects::update_project, diff --git a/ui/goose2/src/features/skills/api/skills.ts b/ui/goose2/src/features/skills/api/skills.ts index 63eb23ba439a..5f32147c1ad3 100644 --- a/ui/goose2/src/features/skills/api/skills.ts +++ b/ui/goose2/src/features/skills/api/skills.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { getClient } from "@/shared/api/acpConnection"; export interface SkillInfo { name: string; @@ -7,20 +7,54 @@ export interface SkillInfo { path: string; } +// Shape returned by _goose/sources/*. Narrowed to skill-type sources here. +interface SourceEntry { + type: "skill"; + name: string; + description: string; + content: string; + directory: string; + global: boolean; +} + +function toSkillInfo(source: SourceEntry): SkillInfo { + return { + name: source.name, + description: source.description, + instructions: source.content, + path: source.directory, + }; +} + export async function createSkill( name: string, description: string, instructions: string, ): Promise { - return invoke("create_skill", { name, description, instructions }); + const client = await getClient(); + await client.extMethod("_goose/sources/create", { + type: "skill", + name, + description, + content: instructions, + global: true, + }); } export async function listSkills(): Promise { - return invoke("list_skills"); + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/list", { type: "skill" }); + const sources = (raw.sources ?? []) as SourceEntry[]; + return sources.map(toSkillInfo); } export async function deleteSkill(name: string): Promise { - return invoke("delete_skill", { name }); + const client = await getClient(); + await client.extMethod("_goose/sources/delete", { + type: "skill", + name, + global: true, + }); } export async function updateSkill( @@ -28,21 +62,42 @@ export async function updateSkill( description: string, instructions: string, ): Promise { - return invoke("update_skill", { name, description, instructions }); + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/update", { + type: "skill", + name, + description, + content: instructions, + global: true, + }); + return toSkillInfo(raw.source as SourceEntry); } export async function exportSkill( name: string, ): Promise<{ json: string; filename: string }> { - return invoke("export_skill", { name }); + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/export", { + type: "skill", + name, + global: true, + }); + return { json: raw.json as string, filename: raw.filename as string }; } export async function importSkills( fileBytes: number[], fileName: string, ): Promise { - return invoke("import_skills", { - fileBytes: Array.from(fileBytes), - fileName, + if (!fileName.endsWith(".skill.json") && !fileName.endsWith(".json")) { + throw new Error("File must have a .skill.json or .json extension"); + } + const data = new TextDecoder().decode(new Uint8Array(fileBytes)); + const client = await getClient(); + const raw = await client.extMethod("_goose/sources/import", { + data, + global: true, }); + const sources = (raw.sources ?? []) as SourceEntry[]; + return sources.map(toSkillInfo); } diff --git a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts index 0c0182c50945..e778820853a9 100644 --- a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts +++ b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts @@ -2,6 +2,10 @@ * Playwright custom fixture that injects a Tauri IPC mock into the page * before every navigation. This allows E2E tests to run against the frontend * without the real Tauri backend. + * + * Also installs a `window.WebSocket` stub for the ACP connection so features + * like skills (which use `client.extMethod("_goose/sources/...")`) can run + * without a live goose-acp server. */ import { test as base, expect, type Page } from "@playwright/test"; @@ -11,7 +15,7 @@ import { MOCK_PERSONAS, MOCK_PROJECTS, MOCK_SKILLS } from "./mock-data"; * Build the init script that will be injected into the page via * `page.addInitScript()`. The script sets up `window.__TAURI_INTERNALS__` * with an `invoke` handler that returns mock data for every Tauri command - * the app is known to call. + * the app is known to call, plus a WebSocket mock for ACP traffic. * * Callers can override the default personas and skills arrays to test * empty-state or custom scenarios. @@ -30,8 +34,18 @@ export function buildInitScript(options?: { const PERSONAS = ${personas}; const SKILLS = ${skills}; const PROJECTS = ${projects}; + const FAKE_ACP_URL = "ws://127.0.0.1:0/mock-acp"; const ACP_SESSIONS = []; + const skillToSourceEntry = (s) => ({ + type: "skill", + name: s.name, + description: s.description, + content: s.instructions ?? s.content ?? "", + directory: (s.path ?? ("/mock/.agents/skills/" + s.name + "/SKILL.md")).replace(/\\/SKILL\\.md$/, ""), + global: true, + }); + function nowIso() { return new Date().toISOString(); } @@ -120,6 +134,39 @@ export function buildInitScript(options?: { case "_goose/working_dir/update": case "goose/working_dir/update": return jsonRpcResult(message.id, {}); + case "_goose/sources/list": + return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) }); + case "_goose/sources/create": + return jsonRpcResult(message.id, { + source: { + name: message.params?.name ?? "new-skill", + type: "skill", + description: message.params?.description ?? "", + content: message.params?.content ?? "", + directory: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"), + global: message.params?.global ?? true, + }, + }); + case "_goose/sources/update": + return jsonRpcResult(message.id, { + source: { + name: message.params?.name ?? "updated-skill", + type: "skill", + description: message.params?.description ?? "", + content: message.params?.content ?? "", + directory: "/mock/.agents/skills/" + (message.params?.name ?? "updated-skill"), + global: message.params?.global ?? true, + }, + }); + case "_goose/sources/delete": + return jsonRpcResult(message.id, {}); + case "_goose/sources/export": + return jsonRpcResult(message.id, { + json: "{}", + filename: (message.params?.name ?? "skill") + ".skill.json", + }); + case "_goose/sources/import": + return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) }); default: return jsonRpcResult(message.id, {}); } @@ -165,6 +212,10 @@ export function buildInitScript(options?: { window.__TAURI_INTERNALS__ = { invoke(cmd, args) { switch (cmd) { + // ---- ACP transport ---- + case "get_goose_serve_url": + return Promise.resolve(FAKE_ACP_URL); + // ---- Personas ---- case "list_personas": return Promise.resolve(PERSONAS); @@ -202,28 +253,6 @@ export function buildInitScript(options?: { case "import_personas": return Promise.resolve(PERSONAS); - // ---- Skills ---- - case "list_skills": - return Promise.resolve(SKILLS); - case "create_skill": - return Promise.resolve(null); - case "update_skill": - return Promise.resolve({ - name: args?.name ?? "updated-skill", - description: args?.description ?? "", - instructions: args?.instructions ?? "", - path: "", - }); - case "delete_skill": - return Promise.resolve(null); - case "export_skill": - return Promise.resolve({ - json: "{}", - filename: "skill.json", - }); - case "import_skills": - return Promise.resolve(SKILLS); - // ---- Sessions / Misc ---- case "list_sessions": return Promise.resolve( @@ -234,8 +263,6 @@ export function buildInitScript(options?: { messageCount: session.messageCount, })), ); - case "get_goose_serve_url": - return Promise.resolve("ws://mock-goose"); case "create_session": return Promise.resolve({ id: "session-" + Math.random().toString(36).slice(2, 10), diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index dab5b1989596..dc49801e336e 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -12,7 +12,10 @@ import type { ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, + CreateSourceRequest, + CreateSourceResponse, DeleteSessionRequest, + DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationModelCancelRequest, @@ -27,6 +30,8 @@ import type { DictationTranscribeResponse, ExportSessionRequest, ExportSessionResponse, + ExportSourceRequest, + ExportSourceResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, @@ -39,8 +44,12 @@ import type { GetToolsResponse, ImportSessionRequest, ImportSessionResponse, + ImportSourcesRequest, + ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, + ListSourcesRequest, + ListSourcesResponse, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, @@ -51,27 +60,34 @@ import type { RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, + UpdateSourceRequest, + UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest, } from './types.gen.js'; import { zCheckSecretResponse, + zCreateSourceResponse, zDictationConfigResponse, zDictationModelDownloadProgressResponse, zDictationModelsListResponse, zDictationTranscribeResponse, zExportSessionResponse, + zExportSourceResponse, zGetExtensionsResponse, zGetProviderDetailsResponse, zGetProviderInventoryResponse, zGetSessionExtensionsResponse, zGetToolsResponse, zImportSessionResponse, + zImportSourcesResponse, zListProvidersResponse, + zListSourcesResponse, zReadConfigResponse, zReadResourceResponse, zRefreshProviderInventoryResponse, + zUpdateSourceResponse, } from './zod.gen.js'; export class GooseExtClient { @@ -208,6 +224,45 @@ export class GooseExtClient { await this.conn.extMethod("_goose/session/unarchive", params); } + async GooseSourcesCreate( + params: CreateSourceRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/sources/create", params); + return zCreateSourceResponse.parse(raw) as CreateSourceResponse; + } + + async GooseSourcesList( + params: ListSourcesRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/sources/list", params); + return zListSourcesResponse.parse(raw) as ListSourcesResponse; + } + + async GooseSourcesUpdate( + params: UpdateSourceRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/sources/update", params); + return zUpdateSourceResponse.parse(raw) as UpdateSourceResponse; + } + + async GooseSourcesDelete(params: DeleteSourceRequest): Promise { + await this.conn.extMethod("_goose/sources/delete", params); + } + + async GooseSourcesExport( + params: ExportSourceRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/sources/export", params); + return zExportSourceResponse.parse(raw) as ExportSourceResponse; + } + + async GooseSourcesImport( + params: ImportSourcesRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/sources/import", params); + return zImportSourcesResponse.parse(raw) as ImportSourcesResponse; + } + async GooseDictationTranscribe( params: DictationTranscribeRequest, ): Promise { diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 2bb363a78fc6..201d8f1c41c0 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderInventoryRequest, GetProviderInventoryResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; +export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, CreateSourceRequest, CreateSourceResponse, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExportSourceRequest, ExportSourceResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderInventoryRequest, GetProviderInventoryResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, SourceEntry, SourceType, UnarchiveSessionRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -113,6 +113,36 @@ export const GOOSE_EXT_METHODS = [ requestType: "UnarchiveSessionRequest", responseType: "EmptyResponse", }, + { + method: "_goose/sources/create", + requestType: "CreateSourceRequest", + responseType: "CreateSourceResponse", + }, + { + method: "_goose/sources/list", + requestType: "ListSourcesRequest", + responseType: "ListSourcesResponse", + }, + { + method: "_goose/sources/update", + requestType: "UpdateSourceRequest", + responseType: "UpdateSourceResponse", + }, + { + method: "_goose/sources/delete", + requestType: "DeleteSourceRequest", + responseType: "EmptyResponse", + }, + { + method: "_goose/sources/export", + requestType: "ExportSourceRequest", + responseType: "ExportSourceResponse", + }, + { + method: "_goose/sources/import", + requestType: "ImportSourcesRequest", + responseType: "ImportSourcesResponse", + }, { method: "_goose/dictation/transcribe", requestType: "DictationTranscribeRequest", diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 87c164c6272c..74fec01a2775 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -395,6 +395,119 @@ export type UnarchiveSessionRequest = { sessionId: string; }; +/** + * Create a new source (global or project-scoped). + */ +export type CreateSourceRequest = { + type: SourceType; + name: string; + description: string; + content: string; + global: boolean; + /** + * Absolute path to the project root. Required when `global` is false. + */ + projectDir?: string | null; +}; + +/** + * The type of source entity. + */ +export type SourceType = 'skill'; + +export type CreateSourceResponse = { + source: SourceEntry; +}; + +/** + * A source — a user-editable entity backed by an on-disk directory. Sources + * may be either `global` (shared across all projects) or project-specific. + */ +export type SourceEntry = { + type: SourceType; + name: string; + description: string; + content: string; + /** + * Absolute path to the source's directory on disk. + */ + directory: string; + /** + * True when the source lives in the user's global sources directory; false + * when it lives inside a specific project. + */ + global: boolean; +}; + +/** + * List sources. If `type` is omitted, sources of all known types are returned. + * Both global and project-scoped sources are included when `project_dir` is set. + */ +export type ListSourcesRequest = { + type?: SourceType | null; + projectDir?: string | null; +}; + +export type ListSourcesResponse = { + sources: Array; +}; + +/** + * Update an existing source's description and content. + */ +export type UpdateSourceRequest = { + type: SourceType; + name: string; + description: string; + content: string; + global: boolean; + projectDir?: string | null; +}; + +export type UpdateSourceResponse = { + source: SourceEntry; +}; + +/** + * Delete a source and its on-disk directory. + */ +export type DeleteSourceRequest = { + type: SourceType; + name: string; + global: boolean; + projectDir?: string | null; +}; + +/** + * Export a source as a portable JSON payload. + */ +export type ExportSourceRequest = { + type: SourceType; + name: string; + global: boolean; + projectDir?: string | null; +}; + +export type ExportSourceResponse = { + json: string; + filename: string; +}; + +/** + * Import a source from a JSON export payload produced by `_goose/sources/export`. + * The imported source is written under the given scope; on name collisions a + * `-imported` suffix is appended. + */ +export type ImportSourcesRequest = { + data: string; + global: boolean; + projectDir?: string | null; +}; + +export type ImportSourcesResponse = { + sources: Array; +}; + /** * Transcribe audio via a dictation provider. */ @@ -535,14 +648,14 @@ export type DictationModelSelectRequest = { export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderInventoryRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { + params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderInventoryRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { [key: string]: unknown; } | null; }; export type ExtResponse = { id: string; - result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderInventoryResponse | RefreshProviderInventoryResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; + result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderInventoryResponse | RefreshProviderInventoryResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | CreateSourceResponse | ListSourcesResponse | UpdateSourceResponse | ExportSourceResponse | ImportSourcesResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 9f95a0de6d54..27e4ab08dd09 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -348,6 +348,130 @@ export const zUnarchiveSessionRequest = z.object({ sessionId: z.string() }); +/** + * The type of source entity. + */ +export const zSourceType = z.enum(['skill']); + +/** + * Create a new source (global or project-scoped). + */ +export const zCreateSourceRequest = z.object({ + type: zSourceType, + name: z.string(), + description: z.string(), + content: z.string(), + global: z.boolean(), + projectDir: z.union([ + z.string(), + z.null() + ]).optional() +}); + +/** + * A source — a user-editable entity backed by an on-disk directory. Sources + * may be either `global` (shared across all projects) or project-specific. + */ +export const zSourceEntry = z.object({ + type: zSourceType, + name: z.string(), + description: z.string(), + content: z.string(), + directory: z.string(), + global: z.boolean() +}); + +export const zCreateSourceResponse = z.object({ + source: zSourceEntry +}); + +/** + * List sources. If `type` is omitted, sources of all known types are returned. + * Both global and project-scoped sources are included when `project_dir` is set. + */ +export const zListSourcesRequest = z.object({ + type: z.union([ + zSourceType, + z.null() + ]).optional(), + projectDir: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zListSourcesResponse = z.object({ + sources: z.array(zSourceEntry) +}); + +/** + * Update an existing source's description and content. + */ +export const zUpdateSourceRequest = z.object({ + type: zSourceType, + name: z.string(), + description: z.string(), + content: z.string(), + global: z.boolean(), + projectDir: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zUpdateSourceResponse = z.object({ + source: zSourceEntry +}); + +/** + * Delete a source and its on-disk directory. + */ +export const zDeleteSourceRequest = z.object({ + type: zSourceType, + name: z.string(), + global: z.boolean(), + projectDir: z.union([ + z.string(), + z.null() + ]).optional() +}); + +/** + * Export a source as a portable JSON payload. + */ +export const zExportSourceRequest = z.object({ + type: zSourceType, + name: z.string(), + global: z.boolean(), + projectDir: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zExportSourceResponse = z.object({ + json: z.string(), + filename: z.string() +}); + +/** + * Import a source from a JSON export payload produced by `_goose/sources/export`. + * The imported source is written under the given scope; on name collisions a + * `-imported` suffix is appended. + */ +export const zImportSourcesRequest = z.object({ + data: z.string(), + global: z.boolean(), + projectDir: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zImportSourcesResponse = z.object({ + sources: z.array(zSourceEntry) +}); + /** * Transcribe audio via a dictation provider. */ @@ -515,6 +639,12 @@ export const zExtRequest = z.object({ zImportSessionRequest, zArchiveSessionRequest, zUnarchiveSessionRequest, + zCreateSourceRequest, + zListSourcesRequest, + zUpdateSourceRequest, + zDeleteSourceRequest, + zExportSourceRequest, + zImportSourcesRequest, zDictationTranscribeRequest, zDictationConfigRequest, zDictationModelsListRequest, @@ -549,6 +679,11 @@ export const zExtResponse = z.union([ zCheckSecretResponse, zExportSessionResponse, zImportSessionResponse, + zCreateSourceResponse, + zListSourcesResponse, + zUpdateSourceResponse, + zExportSourceResponse, + zImportSourcesResponse, zDictationTranscribeResponse, zDictationConfigResponse, zDictationModelsListResponse, From 436e126628809cae3e90cbc0c0128d1c5ae559d8 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 21 Apr 2026 16:51:59 +1200 Subject: [PATCH 23/81] fix: run setup before dev and dev-debug in goose2 justfile (#8718) Co-authored-by: Claude Opus 4.6 (1M context) --- ui/goose2/justfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/goose2/justfile b/ui/goose2/justfile index 3de2fbf90635..47e47c4f58b7 100644 --- a/ui/goose2/justfile +++ b/ui/goose2/justfile @@ -78,7 +78,7 @@ test-e2e-all: # ── Run ────────────────────────────────────────────────────── # Start the desktop app in dev mode -dev: +dev: setup #!/usr/bin/env bash set -euo pipefail @@ -127,7 +127,7 @@ dev: pnpm tauri dev --features app-test-driver --config src-tauri/tauri.dev.conf.json "${EXTRA_CONFIG_ARGS[@]}" # Start the desktop app with dev config -dev-debug: +dev-debug: setup #!/usr/bin/env bash set -euo pipefail From 70e12d943064be427f8b54e174a143c87f04e085 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 21 Apr 2026 18:05:36 +1200 Subject: [PATCH 24/81] fix: links in chat could not be opened (#8544) Signed-off-by: Matt Toohey Co-authored-by: Claude Opus 4.6 (1M context) --- .../__tests__/ArtifactPolicyContext.test.tsx | 29 +++-- .../__tests__/useArtifactLinkHandler.test.tsx | 4 +- .../chat/hooks/useArtifactLinkHandler.ts | 11 +- .../features/chat/lib/artifactPathPolicy.ts | 1 - .../chat/lib/artifactPathPolicyCore.ts | 12 +- .../chat/ui/__tests__/ContextPanel.test.tsx | 4 +- .../chat/ui/__tests__/FilesList.test.tsx | 22 ++-- .../chat/ui/__tests__/MessageBubble.test.tsx | 10 +- .../src/shared/i18n/locales/en/common.json | 7 ++ .../src/shared/i18n/locales/es/common.json | 7 ++ ui/goose2/src/shared/lib/isExternalHref.ts | 10 ++ .../ui/ai-elements/link-safety-modal.tsx | 99 ++++++++++++++++ .../src/shared/ui/ai-elements/message.tsx | 109 ++++++++++++++++-- ui/goose2/src/test/setup.ts | 5 + 14 files changed, 267 insertions(+), 63 deletions(-) create mode 100644 ui/goose2/src/shared/lib/isExternalHref.ts create mode 100644 ui/goose2/src/shared/ui/ai-elements/link-safety-modal.tsx diff --git a/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx b/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx index 93ed16b1c61c..0ea636e86ab9 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx +++ b/ui/goose2/src/features/chat/hooks/__tests__/ArtifactPolicyContext.test.tsx @@ -6,17 +6,14 @@ import { useArtifactPolicyContext, } from "../ArtifactPolicyContext"; +import { openPath } from "@tauri-apps/plugin-opener"; + const mockPathExists = vi.fn<(path: string) => Promise>(); -const mockOpenPath = vi.fn<(path: string) => Promise>(); vi.mock("@/shared/api/system", () => ({ pathExists: (path: string) => mockPathExists(path), })); -vi.mock("@tauri-apps/plugin-opener", () => ({ - openPath: (path: string) => mockOpenPath(path), -})); - function Probe({ readArgs, writeArgs, @@ -117,7 +114,7 @@ function FallbackProbe({ describe("ArtifactPolicyContext", () => { it("computes one primary host per message and resolves tool cards by args identity", () => { mockPathExists.mockReset(); - mockOpenPath.mockReset(); + vi.mocked(openPath).mockReset(); const readArgs = { path: "/Users/test/project-a/notes.md" }; const writeArgs = { paths: [ @@ -189,7 +186,7 @@ describe("ArtifactPolicyContext", () => { it("does not treat read-only tool paths as session artifacts", () => { mockPathExists.mockReset(); - mockOpenPath.mockReset(); + vi.mocked(openPath).mockReset(); const readArgs = { path: "/Users/test/project-a/notes.md" }; const messages: Message[] = [ { @@ -230,7 +227,7 @@ describe("ArtifactPolicyContext", () => { it("falls back to the home artifacts root when a project artifacts path is missing", async () => { mockPathExists.mockReset(); - mockOpenPath.mockReset(); + vi.mocked(openPath).mockReset(); mockPathExists.mockImplementation( async (path: string) => path === "/Users/test/.goose/artifacts/report.md", ); @@ -256,7 +253,7 @@ describe("ArtifactPolicyContext", () => { screen.getByRole("button", { name: "Open path" }).click(); await waitFor(() => { - expect(mockOpenPath).toHaveBeenCalledWith( + expect(vi.mocked(openPath)).toHaveBeenCalledWith( "/Users/test/.goose/artifacts/report.md", ); }); @@ -264,7 +261,7 @@ describe("ArtifactPolicyContext", () => { it("falls back from a working-dir artifacts path to the project root when the file lives there", async () => { mockPathExists.mockReset(); - mockOpenPath.mockReset(); + vi.mocked(openPath).mockReset(); mockPathExists.mockImplementation( async (path: string) => path === "/Users/test/project-a/README_ENHANCED.md", @@ -292,7 +289,7 @@ describe("ArtifactPolicyContext", () => { screen.getByRole("button", { name: "Open path" }).click(); await waitFor(() => { - expect(mockOpenPath).toHaveBeenCalledWith( + expect(vi.mocked(openPath)).toHaveBeenCalledWith( "/Users/test/project-a/README_ENHANCED.md", ); }); @@ -300,7 +297,7 @@ describe("ArtifactPolicyContext", () => { it("does not strip /artifacts/ from a parent directory in the path", async () => { mockPathExists.mockReset(); - mockOpenPath.mockReset(); + vi.mocked(openPath).mockReset(); // The file lives at the nested artifacts path — the parent `/artifacts/` should NOT be stripped mockPathExists.mockImplementation( async (path: string) => @@ -329,7 +326,7 @@ describe("ArtifactPolicyContext", () => { screen.getByRole("button", { name: "Open path" }).click(); await waitFor(() => { - expect(mockOpenPath).toHaveBeenCalledWith( + expect(vi.mocked(openPath)).toHaveBeenCalledWith( "/Users/test/artifacts/project/artifacts/README_ENHANCED.md", ); }); @@ -337,7 +334,7 @@ describe("ArtifactPolicyContext", () => { it("falls back correctly when /artifacts/ appears in a parent dir", async () => { mockPathExists.mockReset(); - mockOpenPath.mockReset(); + vi.mocked(openPath).mockReset(); // File is NOT at the artifacts path, but IS at the root-stripped path mockPathExists.mockImplementation( async (path: string) => @@ -366,7 +363,7 @@ describe("ArtifactPolicyContext", () => { screen.getByRole("button", { name: "Open path" }).click(); await waitFor(() => { - expect(mockOpenPath).toHaveBeenCalledWith( + expect(vi.mocked(openPath)).toHaveBeenCalledWith( "/Users/test/artifacts/project/README.md", ); }); @@ -374,7 +371,7 @@ describe("ArtifactPolicyContext", () => { it("uses assistant text after a tool call to populate file actions and the Files tab", () => { mockPathExists.mockReset(); - mockOpenPath.mockReset(); + vi.mocked(openPath).mockReset(); const writeArgs = {}; const messages: Message[] = [ { diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useArtifactLinkHandler.test.tsx b/ui/goose2/src/features/chat/hooks/__tests__/useArtifactLinkHandler.test.tsx index 1d958cc5a827..610c2cd0b3d9 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useArtifactLinkHandler.test.tsx +++ b/ui/goose2/src/features/chat/hooks/__tests__/useArtifactLinkHandler.test.tsx @@ -110,14 +110,12 @@ describe("useArtifactLinkHandler", () => { ); }); - it("ignores external URLs (does not call resolveMarkdownHref)", async () => { + it("does not intercept external URLs (defers to MarkdownLink's LinkSafetyModal)", async () => { const user = userEvent.setup(); - mockResolveMarkdownHref.mockReturnValue(null); render(); await user.click(screen.getByText("External")); - // isExternalHref returns true, so resolveMarkdownHref is never called expect(mockResolveMarkdownHref).not.toHaveBeenCalled(); expect(mockOpenResolvedPath).not.toHaveBeenCalled(); }); diff --git a/ui/goose2/src/features/chat/hooks/useArtifactLinkHandler.ts b/ui/goose2/src/features/chat/hooks/useArtifactLinkHandler.ts index c6600ab6f81a..52aaa54e71fe 100644 --- a/ui/goose2/src/features/chat/hooks/useArtifactLinkHandler.ts +++ b/ui/goose2/src/features/chat/hooks/useArtifactLinkHandler.ts @@ -1,10 +1,15 @@ import { useState, useCallback } from "react"; -import { isExternalHref } from "@/features/chat/lib/artifactPathPolicy"; +import { isExternalHref } from "@/shared/lib/isExternalHref"; import { useArtifactPolicyContext } from "@/features/chat/hooks/ArtifactPolicyContext"; /** * Delegated click handler that intercepts local link clicks within a * container and routes them through the artifact policy layer. + * + * External links are intentionally not handled here — MarkdownLink + * renders them as elements with preventDefault that open a + * LinkSafetyModal for confirmation. The isExternalHref early return + * below ensures there is no conflict. */ export function useArtifactLinkHandler() { const { resolveMarkdownHref, openResolvedPath } = useArtifactPolicyContext(); @@ -15,7 +20,9 @@ export function useArtifactLinkHandler() { const anchor = (event.target as HTMLElement).closest("a"); if (!anchor) return; const href = anchor.getAttribute("href"); - if (!href || isExternalHref(href)) return; + if (!href) return; + + if (isExternalHref(href)) return; event.preventDefault(); const resolved = resolveMarkdownHref(href); diff --git a/ui/goose2/src/features/chat/lib/artifactPathPolicy.ts b/ui/goose2/src/features/chat/lib/artifactPathPolicy.ts index 2c3c1252f61e..5cd22db2e5a1 100644 --- a/ui/goose2/src/features/chat/lib/artifactPathPolicy.ts +++ b/ui/goose2/src/features/chat/lib/artifactPathPolicy.ts @@ -17,7 +17,6 @@ export { evaluatePathScope, extractToolCallCandidates, inferHomeDirFromRoots, - isExternalHref, isWriteOrientedTool, normalizePath, resolveMarkdownLocalHref, diff --git a/ui/goose2/src/features/chat/lib/artifactPathPolicyCore.ts b/ui/goose2/src/features/chat/lib/artifactPathPolicyCore.ts index e499842d921a..e227e0e20b05 100644 --- a/ui/goose2/src/features/chat/lib/artifactPathPolicyCore.ts +++ b/ui/goose2/src/features/chat/lib/artifactPathPolicyCore.ts @@ -2,6 +2,7 @@ import { collectCommandArgPathCandidates, extractToolNamePathCandidates, } from "@/features/chat/lib/artifactPathCommandExtraction"; +import { isExternalHref } from "@/shared/lib/isExternalHref"; export type ArtifactCandidateSource = | "arg_key" @@ -145,17 +146,6 @@ function hasKnownScheme(value: string): boolean { return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value); } -export function isExternalHref(href?: string): boolean { - if (!href) return false; - const lower = href.trim().toLowerCase(); - return ( - lower.startsWith("http://") || - lower.startsWith("https://") || - lower.startsWith("mailto:") || - lower.startsWith("tel:") - ); -} - function isLikelyAbsoluteFilesystemPath(candidate: string): boolean { if (/^[a-zA-Z]:[\\/]/.test(candidate)) return true; if (!candidate.startsWith("/")) return false; diff --git a/ui/goose2/src/features/chat/ui/__tests__/ContextPanel.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ContextPanel.test.tsx index 64c587318b3f..89747e53be68 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ContextPanel.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ContextPanel.test.tsx @@ -28,8 +28,8 @@ vi.mock("@/shared/hooks/useChangedFiles", () => ({ }), })); -vi.mock("@tauri-apps/plugin-opener", () => ({ - openPath: vi.fn(), +vi.mock("@tauri-apps/api/event", () => ({ + listen: () => Promise.resolve(() => {}), })); vi.mock("@/shared/api/system", () => ({ diff --git a/ui/goose2/src/features/chat/ui/__tests__/FilesList.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/FilesList.test.tsx index b5040028a427..a0c6ba77e044 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/FilesList.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/FilesList.test.tsx @@ -3,21 +3,19 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { FilesList } from "../FilesList"; -const { mockListDirectoryEntries, mockOpenPath, mockRevealInFileManager } = - vi.hoisted(() => ({ +import { openPath } from "@tauri-apps/plugin-opener"; + +const { mockListDirectoryEntries, mockRevealInFileManager } = vi.hoisted( + () => ({ mockListDirectoryEntries: vi.fn(), - mockOpenPath: vi.fn(), mockRevealInFileManager: vi.fn(), - })); + }), +); vi.mock("@/shared/api/system", () => ({ listDirectoryEntries: mockListDirectoryEntries, })); -vi.mock("@tauri-apps/plugin-opener", () => ({ - openPath: mockOpenPath, -})); - vi.mock("@/shared/lib/fileManager", () => ({ revealInFileManager: mockRevealInFileManager, })); @@ -32,7 +30,7 @@ const makeEntry = (overrides: Record = {}) => ({ describe("FilesList", () => { beforeEach(() => { vi.clearAllMocks(); - mockOpenPath.mockResolvedValue(undefined); + vi.mocked(openPath).mockResolvedValue(undefined); mockRevealInFileManager.mockResolvedValue(undefined); mockListDirectoryEntries.mockResolvedValue([]); }); @@ -102,7 +100,7 @@ describe("FilesList", () => { "/Users/test/project/src", ); }); - expect(mockOpenPath).not.toHaveBeenCalled(); + expect(vi.mocked(openPath)).not.toHaveBeenCalled(); expect(screen.getByText("App.tsx")).toBeInTheDocument(); }); @@ -119,7 +117,9 @@ describe("FilesList", () => { await user.click(await screen.findByText("README.md")); - expect(mockOpenPath).toHaveBeenCalledWith("/Users/test/project/README.md"); + expect(vi.mocked(openPath)).toHaveBeenCalledWith( + "/Users/test/project/README.md", + ); }); it("supports context menu actions for folders and files", async () => { diff --git a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx index 55531e8ed5f8..51bcf5d511f3 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx @@ -4,11 +4,7 @@ import userEvent from "@testing-library/user-event"; import { MessageBubble } from "../MessageBubble"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import type { Message } from "@/shared/types/messages"; - -const mockOpenPath = vi.fn(); -vi.mock("@tauri-apps/plugin-opener", () => ({ - openPath: (path: string) => mockOpenPath(path), -})); +import { openPath } from "@tauri-apps/plugin-opener"; // ── helpers ─────────────────────────────────────────────────────────── @@ -40,7 +36,7 @@ function assistantMessage( describe("MessageBubble", () => { beforeEach(() => { useAgentStore.setState({ personas: [] }); - mockOpenPath.mockClear(); + vi.mocked(openPath).mockClear(); }); it("renders user message with correct alignment", () => { @@ -133,7 +129,7 @@ describe("MessageBubble", () => { await user.click( screen.getByRole("button", { name: /open attachment report\.pdf/i }), ); - expect(mockOpenPath).toHaveBeenCalledWith("/Users/test/report.pdf"); + expect(vi.mocked(openPath)).toHaveBeenCalledWith("/Users/test/report.pdf"); expect( screen.getByRole("button", { name: /open attachment screenshots/i }), ).toBeInTheDocument(); diff --git a/ui/goose2/src/shared/i18n/locales/en/common.json b/ui/goose2/src/shared/i18n/locales/en/common.json index 96aff012683b..3791fb77ac4f 100644 --- a/ui/goose2/src/shared/i18n/locales/en/common.json +++ b/ui/goose2/src/shared/i18n/locales/en/common.json @@ -45,6 +45,13 @@ "title": "Environment Variables", "toggleVisibility": "Toggle value visibility" }, + "linkSafety": { + "copied": "Copied!", + "copyLink": "Copy link", + "description": "You're about to visit an external website.", + "openLink": "Open link", + "title": "Open external link?" + }, "messageBranch": { "next": "Next branch", "page": "{{current}} of {{total}}", diff --git a/ui/goose2/src/shared/i18n/locales/es/common.json b/ui/goose2/src/shared/i18n/locales/es/common.json index 37c2aea48d60..074e770b93dd 100644 --- a/ui/goose2/src/shared/i18n/locales/es/common.json +++ b/ui/goose2/src/shared/i18n/locales/es/common.json @@ -40,6 +40,13 @@ "codeBlock": { "copyLabel": "Copiar código" }, + "linkSafety": { + "copied": "¡Copiado!", + "copyLink": "Copiar enlace", + "description": "Estás a punto de visitar un sitio web externo.", + "openLink": "Abrir enlace", + "title": "¿Abrir enlace externo?" + }, "environmentVariables": { "copyLabel": "Copiar variable de entorno", "title": "Variables de entorno", diff --git a/ui/goose2/src/shared/lib/isExternalHref.ts b/ui/goose2/src/shared/lib/isExternalHref.ts new file mode 100644 index 000000000000..6d7c4b4d4653 --- /dev/null +++ b/ui/goose2/src/shared/lib/isExternalHref.ts @@ -0,0 +1,10 @@ +export function isExternalHref(href?: string): boolean { + if (!href) return false; + const lower = href.trim().toLowerCase(); + return ( + lower.startsWith("http://") || + lower.startsWith("https://") || + lower.startsWith("mailto:") || + lower.startsWith("tel:") + ); +} diff --git a/ui/goose2/src/shared/ui/ai-elements/link-safety-modal.tsx b/ui/goose2/src/shared/ui/ai-elements/link-safety-modal.tsx new file mode 100644 index 000000000000..0f943ee84f07 --- /dev/null +++ b/ui/goose2/src/shared/ui/ai-elements/link-safety-modal.tsx @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +interface LinkSafetyModalProps { + isOpen: boolean; + onClose: () => void; + url: string; +} + +export function LinkSafetyModal({ + isOpen, + onClose, + url, +}: LinkSafetyModalProps) { + const { t } = useTranslation("common"); + const [isCopied, setIsCopied] = useState(false); + const timeoutRef = useRef(0); + + useEffect(() => { + if (isOpen) setIsCopied(false); + }, [isOpen]); + + useEffect( + () => () => { + window.clearTimeout(timeoutRef.current); + }, + [], + ); + + const handleOpen = useCallback(async () => { + try { + await openUrl(url); + } catch (e: unknown) { + console.error("[linkSafety] openUrl failed:", e); + } + onClose(); + }, [url, onClose]); + + const handleCopy = useCallback(() => { + if (isCopied) return; + navigator.clipboard + .writeText(url) + .then(() => { + setIsCopied(true); + timeoutRef.current = window.setTimeout(() => setIsCopied(false), 2000); + }) + .catch((e: unknown) => + console.error("[linkSafety] clipboard write failed:", e), + ); + }, [url, isCopied]); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) onClose(); + }, + [onClose], + ); + + return ( + + + + {t("components.linkSafety.title")} + + {t("components.linkSafety.description")} + + +
+ {url} +
+ + + + +
+
+ ); +} diff --git a/ui/goose2/src/shared/ui/ai-elements/message.tsx b/ui/goose2/src/shared/ui/ai-elements/message.tsx index e75b98067bc5..429a732d1e34 100644 --- a/ui/goose2/src/shared/ui/ai-elements/message.tsx +++ b/ui/goose2/src/shared/ui/ai-elements/message.tsx @@ -6,6 +6,8 @@ import { TooltipProvider, TooltipTrigger, } from "@/shared/ui/tooltip"; +import { isExternalHref } from "@/shared/lib/isExternalHref"; +import { LinkSafetyModal } from "@/shared/ui/ai-elements/link-safety-modal"; import { cn } from "@/shared/lib/cn"; import { cjk } from "@streamdown/cjk"; import { code } from "@streamdown/code"; @@ -325,17 +327,104 @@ export type MessageResponseProps = ComponentProps; const streamdownPlugins = { cjk, code, math, mermaid }; +type OpenLinkSafetyModal = (url: string) => void; + +const LinkSafetyContext = createContext(null); + +/** + * Custom link component that splits behavior by link type: + * - External links →
with preventDefault that opens a LinkSafetyModal via context + * - Internal links → plain so useArtifactLinkHandler can intercept via closest("a") + * + * Both render as elements. useArtifactLinkHandler has an early return for external + * hrefs, so there is no conflict with its delegated click handler. + * + * This replaces Streamdown's built-in linkSafety which renders
@@ -313,9 +284,12 @@ export function AgentsView() { openPersonaEditor(persona, "edit")} + onDelete={handleDeletePersona} /> {/* Delete confirmation dialog */} @@ -343,13 +317,6 @@ export function AgentsView() { - - {/* Export notification toast */} - {notification && ( -
- {notification} -
- )}
); } diff --git a/ui/goose2/src/features/agents/ui/PersonaCard.tsx b/ui/goose2/src/features/agents/ui/PersonaCard.tsx index 5128b8c2b6b6..83e2c9cb5faf 100644 --- a/ui/goose2/src/features/agents/ui/PersonaCard.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaCard.tsx @@ -13,6 +13,7 @@ import { } from "@/shared/ui/dropdown-menu"; import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; import type { Persona } from "@/shared/types/agents"; +import { getPersonaSource } from "@/features/agents/lib/personaPresentation"; interface PersonaCardProps { persona: Persona; @@ -38,18 +39,30 @@ export function PersonaCard({ const initials = persona.displayName.charAt(0).toUpperCase(); const avatarSrc = useAvatarSrc(persona.avatar); + const personaSource = getPersonaSource(persona); + const canEditPersona = personaSource === "custom"; + const canDeletePersona = personaSource !== "builtin"; + const providerModelLabel = [persona.provider, persona.model] + .filter(Boolean) + .join(" / "); + + const handleCardKeyDown = (event: React.KeyboardEvent) => { + if (event.target !== event.currentTarget || menuOpen) { + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect?.(persona); + } + }; return ( -
!menuOpen && onSelect?.(persona)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onSelect?.(persona); - } - }} - // biome-ignore lint/a11y/noNoninteractiveTabindex: card needs keyboard focus but contains nested interactive buttons + onKeyDown={handleCardKeyDown} tabIndex={0} className={cn( "group relative flex flex-col items-center gap-3 rounded-xl border p-5 cursor-pointer", @@ -68,6 +81,7 @@ export function PersonaCard({ size="icon-xs" aria-label={t("card.options")} onClick={(e) => e.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} className={cn( "size-6 rounded-md text-muted-foreground hover:text-foreground", menuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100", @@ -77,10 +91,12 @@ export function PersonaCard({ - onEdit?.(persona)}> - - {t("common:actions.edit")} - + {canEditPersona && ( + onEdit?.(persona)}> + + {t("common:actions.edit")} + + )} onDuplicate?.(persona)}> {t("common:actions.duplicate")} @@ -89,7 +105,7 @@ export function PersonaCard({ {t("common:actions.export")} - {!persona.isBuiltin && !persona.isFromDisk && ( + {canDeletePersona && ( onDelete?.(persona)} @@ -116,11 +132,16 @@ export function PersonaCard({ {/* Built-in badge */} - {persona.isBuiltin && ( + {personaSource === "builtin" && ( {t("common:labels.builtIn")} )} + {personaSource === "file" && ( + + {t("card.fileBacked")} + + )} {/* System prompt preview */}

@@ -128,15 +149,13 @@ export function PersonaCard({

{/* Provider/model badge */} - {(persona.provider || persona.model) && ( - - {persona.provider && {persona.provider}} - {persona.provider && persona.model && ( - - )} - {persona.model && {persona.model}} + {providerModelLabel && ( + + + {providerModelLabel} + )} -
+
); } diff --git a/ui/goose2/src/features/agents/ui/PersonaDetails.tsx b/ui/goose2/src/features/agents/ui/PersonaDetails.tsx new file mode 100644 index 000000000000..6022c35f544b --- /dev/null +++ b/ui/goose2/src/features/agents/ui/PersonaDetails.tsx @@ -0,0 +1,108 @@ +import { useTranslation } from "react-i18next"; +import { + Avatar as AvatarRoot, + AvatarFallback, + AvatarImage, +} from "@/shared/ui/avatar"; +import { Badge } from "@/shared/ui/badge"; +import { MessageResponse } from "@/shared/ui/ai-elements/message"; +import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; +import type { Avatar } from "@/shared/types/agents"; +import type { PersonaSource } from "@/features/agents/lib/personaPresentation"; + +interface PersonaDetailsProps { + avatar: Avatar | null; + displayName: string; + modelLabel: string; + personaSource: PersonaSource; + providerLabel: string; + systemPrompt: string; +} + +export function PersonaDetails({ + avatar, + displayName, + modelLabel, + personaSource, + providerLabel, + systemPrompt, +}: PersonaDetailsProps) { + const { t } = useTranslation(["agents", "common"]); + const avatarSrc = useAvatarSrc(avatar); + const initials = displayName.charAt(0).toUpperCase() || "?"; + + return ( +
+
+
+
+ + + + {initials} + + +
+
+

+ {t("editor.displayName")} +

+

+ {displayName} +

+
+
+ {personaSource === "builtin" ? ( + + {t("common:labels.builtIn")} + + ) : null} + {personaSource === "file" ? ( + {t("card.fileBacked")} + ) : null} +
+
+
+
+ +
+
+

+ {t("editor.provider")} +

+

+ {providerLabel} +

+
+
+

+ {t("editor.model")} +

+

{modelLabel}

+
+
+ +
+
+

+ {t("editor.systemPrompt")} +

+ + {t("common:labels.characterCount", { + count: systemPrompt.length, + })} + +
+
+ + {systemPrompt} + +
+
+
+
+ ); +} diff --git a/ui/goose2/src/features/agents/ui/PersonaEditor.tsx b/ui/goose2/src/features/agents/ui/PersonaEditor.tsx index 8c460d3a220a..7655f6afe507 100644 --- a/ui/goose2/src/features/agents/ui/PersonaEditor.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaEditor.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { Copy } from "lucide-react"; +import { Copy, Pencil, Trash2 } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { Avatar as AvatarRoot, @@ -11,6 +11,7 @@ import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Label } from "@/shared/ui/label"; import { Textarea } from "@/shared/ui/textarea"; +import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; import { Dialog, DialogContent, @@ -25,46 +26,60 @@ import { SelectTrigger, SelectValue, } from "@/shared/ui/select"; +import type { Persona, ProviderType, Avatar } from "@/shared/types/agents"; import type { - Persona, - ProviderType, - Avatar, CreatePersonaRequest, UpdatePersonaRequest, } from "@/shared/types/agents"; -import { discoverAcpProviders, type AcpProvider } from "@/shared/api/acp"; +import { discoverAcpProviders } from "@/shared/api/acp"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useProviderInventory } from "@/features/providers/hooks/useProviderInventory"; +import { getProviderInventory } from "@/features/providers/api/inventory"; +import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; +import { + getPersonaSource, + isPersonaReadOnly, +} from "@/features/agents/lib/personaPresentation"; import { AvatarDropZone } from "./AvatarDropZone"; +import { PersonaDetails } from "./PersonaDetails"; interface PersonaEditorProps { persona?: Persona; isOpen: boolean; + mode?: "create" | "edit" | "details"; onClose: () => void; onSave: (data: CreatePersonaRequest | UpdatePersonaRequest) => void; onDuplicate?: (persona: Persona) => void; + onEdit?: (persona: Persona) => void; + onDelete?: (persona: Persona) => void; isPending?: boolean; } export function PersonaEditor({ persona, isOpen, + mode = "create", onClose, onSave, onDuplicate, + onEdit, + onDelete, isPending = false, }: PersonaEditorProps) { const { t } = useTranslation(["agents", "common"]); - const isEditing = !!persona; - const isReadOnly = persona?.isBuiltin ?? false; - - const [acpProviders, setAcpProviders] = useState([]); - - useEffect(() => { - if (isOpen) { - discoverAcpProviders() - .then(setAcpProviders) - .catch(() => setAcpProviders([])); - } - }, [isOpen]); + const isEditing = mode === "edit"; + const detailsMode = mode === "details"; + const readOnlyBySource = persona ? isPersonaReadOnly(persona) : false; + const isReadOnly = detailsMode || readOnlyBySource; + const personaSource = persona ? getPersonaSource(persona) : "custom"; + const canEditPersona = personaSource === "custom"; + const canDeletePersona = personaSource !== "builtin"; + const acpProviders = useAgentStore((s) => s.providers); + const setProviders = useAgentStore((s) => s.setProviders); + const mergeInventoryEntries = useProviderInventoryStore( + (s) => s.mergeEntries, + ); + const { getEntry, getModelsForProvider } = useProviderInventory(); const [displayName, setDisplayName] = useState(""); const [avatar, setAvatar] = useState(null); @@ -72,6 +87,36 @@ export function PersonaEditor({ const [provider, setProvider] = useState(""); const [model, setModel] = useState(""); + useEffect(() => { + if (!isOpen) { + return; + } + + let cancelled = false; + + const syncProviderOptions = async () => { + try { + const providers = await discoverAcpProviders(); + if (!cancelled) { + setProviders(providers); + } + } catch {} + + try { + const entries = await getProviderInventory(); + if (!cancelled) { + mergeInventoryEntries(entries); + } + } catch {} + }; + + void syncProviderOptions(); + + return () => { + cancelled = true; + }; + }, [isOpen, mergeInventoryEntries, setProviders]); + useEffect(() => { if (isOpen && persona) { setDisplayName(persona.displayName); @@ -90,6 +135,29 @@ export function PersonaEditor({ const isValid = displayName.trim().length > 0 && systemPrompt.trim().length > 0; + const avatarSrc = useAvatarSrc(avatar); + + const availableModels = provider ? getModelsForProvider(provider) : []; + const providerInventory = provider ? getEntry(provider) : undefined; + const modelStatusMessage = + providerInventory?.modelSelectionHint ?? + providerInventory?.lastRefreshError; + const hasSavedModelOutsideInventory = + Boolean(model) && !availableModels.some((entry) => entry.id === model); + const modelSelectValue = hasSavedModelOutsideInventory + ? `__saved__:${model}` + : model || "__none__"; + + const readOnlyDescription = readOnlyBySource + ? personaSource === "builtin" + ? t("editor.readOnlyBuiltIn") + : t("editor.readOnlyFile") + : null; + const providerLabel = provider + ? (acpProviders.find((providerOption) => providerOption.id === provider) + ?.label ?? provider) + : t("common:labels.none"); + const modelLabel = model || t("common:labels.none"); const handleSubmit = useCallback( (e: React.FormEvent) => { @@ -127,147 +195,259 @@ export function PersonaEditor({ - {isReadOnly + {detailsMode ? persona?.displayName : isEditing ? t("editor.editTitle") : t("editor.newTitle")} + {readOnlyDescription ? ( +

+ {readOnlyDescription} +

+ ) : null}
-
- {/* Avatar drop zone */} -
- {isReadOnly ? ( - - + ) : ( + +
+ {isReadOnly ? ( + + + + {initials} + + + ) : ( + - - {initials} - - - ) : ( - - )} -
- - {/* Display Name */} -
- - setDisplayName(e.target.value)} - readOnly={isReadOnly} - required - placeholder={t("editor.displayNamePlaceholder")} - className={cn(isReadOnly && "opacity-70 cursor-not-allowed")} - /> -
+ )} +
- {/* System Prompt */} -
-
+
- - {t("common:labels.characterCount", { - count: systemPrompt.length, - })} - + setDisplayName(e.target.value)} + readOnly={isReadOnly} + required + placeholder={t("editor.displayNamePlaceholder")} + className={cn(isReadOnly && "opacity-70 cursor-not-allowed")} + />
-