From 9785d063c169cbdf7d0419ae3ab567ecf83c799b Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Fri, 19 Jun 2026 19:20:01 +1000 Subject: [PATCH] feat (ui): load session using acp (#9875) --- crates/goose/src/acp/response_builder.rs | 4 + crates/goose/tests/acp_common_tests/mod.rs | 4 + crates/goose/tests/acp_server_test.rs | 1 + .../acp/__tests__/chatNotifications.test.ts | 11 +- .../__tests__/chatSessionController.test.ts | 99 ++++ .../acp/__tests__/chatSessionStore.test.ts | 217 +++++--- .../acp/__tests__/elicitationRequests.test.ts | 21 +- .../acp/__tests__/permissionRequests.test.ts | 2 +- ui/desktop/src/acp/__tests__/sessions.test.ts | 72 +++ ui/desktop/src/acp/chatNotifications.ts | 6 +- ui/desktop/src/acp/chatSessionController.ts | 255 +++++++++ ui/desktop/src/acp/chatSessionStore.ts | 212 +++++--- ui/desktop/src/acp/elicitationRequests.ts | 10 +- ui/desktop/src/acp/permissionRequests.ts | 4 +- ui/desktop/src/acp/sessions.ts | 106 +++- ui/desktop/src/components/BaseChat.tsx | 9 + ui/desktop/src/components/ChatInput.tsx | 32 +- .../components/bottom_menu/DirSwitcher.tsx | 2 +- .../components/sessions/SessionListView.tsx | 4 +- ui/desktop/src/hooks/useAcpChatSession.ts | 490 +++--------------- 20 files changed, 972 insertions(+), 589 deletions(-) create mode 100644 ui/desktop/src/acp/__tests__/chatSessionController.test.ts create mode 100644 ui/desktop/src/acp/__tests__/sessions.test.ts create mode 100644 ui/desktop/src/acp/chatSessionController.ts diff --git a/crates/goose/src/acp/response_builder.rs b/crates/goose/src/acp/response_builder.rs index ffdfe61dd..ea2596881 100644 --- a/crates/goose/src/acp/response_builder.rs +++ b/crates/goose/src/acp/response_builder.rs @@ -41,6 +41,10 @@ pub(super) fn session_meta(session: &Session) -> serde_json::Map() { serde_json::Value::Number(2.into()), ); expected_meta.insert("userSetName".to_string(), serde_json::Value::Bool(false)); + expected_meta.insert( + "sessionType".to_string(), + serde_json::Value::String("acp".to_string()), + ); expected_meta.insert("hasRecipe".to_string(), serde_json::Value::Bool(false)); assert_eq!( response, diff --git a/crates/goose/tests/acp_server_test.rs b/crates/goose/tests/acp_server_test.rs index 5f3b084cf..7a4c883b4 100644 --- a/crates/goose/tests/acp_server_test.rs +++ b/crates/goose/tests/acp_server_test.rs @@ -455,6 +455,7 @@ fn test_get_session_info() { assert!(meta.get("createdAt").and_then(|v| v.as_str()).is_some()); assert_eq!(meta.get("messageCount"), Some(&serde_json::json!(1))); assert_eq!(meta.get("userSetName"), Some(&serde_json::json!(false))); + assert_eq!(meta.get("sessionType"), Some(&serde_json::json!("acp"))); assert_eq!(meta.get("hasRecipe"), Some(&serde_json::json!(false))); }); } diff --git a/ui/desktop/src/acp/__tests__/chatNotifications.test.ts b/ui/desktop/src/acp/__tests__/chatNotifications.test.ts index 3575ffa6c..440209c93 100644 --- a/ui/desktop/src/acp/__tests__/chatNotifications.test.ts +++ b/ui/desktop/src/acp/__tests__/chatNotifications.test.ts @@ -5,7 +5,7 @@ import { AppEvents } from '../../constants/events'; import { ChatState } from '../../types/chatState'; import { handleAcpSessionNotification } from '../chatNotifications'; import type { AcpChatSessionSnapshot } from '../chatSessionStore'; -import { acpChatSessionStore } from '../chatSessionStore'; +import { acpChatSessionActions, acpChatSessionStore } from '../chatSessionStore'; vi.mock('../../acpChatFeatureFlag', () => ({ USE_ACP_CHAT: true, @@ -14,7 +14,10 @@ vi.mock('../../acpChatFeatureFlag', () => ({ vi.mock('../chatSessionStore', () => ({ acpChatSessionStore: { getSnapshot: vi.fn(), + }, + acpChatSessionActions: { applyAcpSessionNotification: vi.fn(), + applyAcpGooseSessionNotification: vi.fn(), }, })); @@ -89,7 +92,7 @@ describe('handleAcpSessionNotification', () => { it('dispatches SESSION_RENAMED when a session info notification changes the name', async () => { const dispatchEvent = vi.spyOn(window, 'dispatchEvent'); vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValueOnce(snapshotWithName('Old name')); - vi.mocked(acpChatSessionStore.applyAcpSessionNotification).mockReturnValueOnce( + vi.mocked(acpChatSessionActions.applyAcpSessionNotification).mockReturnValueOnce( snapshotWithName('New name') ); @@ -106,7 +109,7 @@ describe('handleAcpSessionNotification', () => { it('does not dispatch SESSION_RENAMED when the name is unchanged', async () => { const dispatchEvent = vi.spyOn(window, 'dispatchEvent'); vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValueOnce(snapshotWithName('Same name')); - vi.mocked(acpChatSessionStore.applyAcpSessionNotification).mockReturnValueOnce( + vi.mocked(acpChatSessionActions.applyAcpSessionNotification).mockReturnValueOnce( snapshotWithName('Same name') ); @@ -118,7 +121,7 @@ describe('handleAcpSessionNotification', () => { it('dispatches SESSION_RENAMED from the notification title when the session is not loaded', async () => { const dispatchEvent = vi.spyOn(window, 'dispatchEvent'); vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValueOnce(snapshotWithoutSession()); - vi.mocked(acpChatSessionStore.applyAcpSessionNotification).mockReturnValueOnce( + vi.mocked(acpChatSessionActions.applyAcpSessionNotification).mockReturnValueOnce( snapshotWithoutSession() ); diff --git a/ui/desktop/src/acp/__tests__/chatSessionController.test.ts b/ui/desktop/src/acp/__tests__/chatSessionController.test.ts new file mode 100644 index 000000000..cb598fa2e --- /dev/null +++ b/ui/desktop/src/acp/__tests__/chatSessionController.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Session } from '../../api'; +import { acpChatSessionController } from '../chatSessionController'; +import { acpChatSessionActions, acpChatSessionStore } from '../chatSessionStore'; +import { acpLoadSession, isAcpSessionLoadInFlight, sessionInfoToSession } from '../sessions'; + +vi.mock('../../utils/extensionErrorUtils', () => ({ + showExtensionLoadResults: vi.fn(), +})); + +vi.mock('../chatSessionStore', () => ({ + acpChatSessionStore: { + getSnapshot: vi.fn(), + }, + acpChatSessionActions: { + startSessionLoad: vi.fn(), + finishSessionLoad: vi.fn(), + failSessionLoad: vi.fn(), + startPromptAttempt: vi.fn(), + finishPromptAttemptIfCurrent: vi.fn(), + isCurrentPromptAttempt: vi.fn(), + setMessages: vi.fn(), + clearActivePromptAttempt: vi.fn(), + setChatState: vi.fn(), + setSessionMetadata: vi.fn(), + setSessionLoadError: vi.fn(), + }, +})); + +vi.mock('../sessions', () => ({ + acpLoadSession: vi.fn(), + isAcpSessionLoadInFlight: vi.fn(), + sessionInfoToSession: vi.fn(), + acpForkSession: vi.fn(), + acpTruncateSessionConversation: vi.fn(), +})); + +const SESSION_ID = 'session-1'; + +function loadedSession(): Session { + return { + id: SESSION_ID, + name: 'Loaded session', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + working_dir: '/tmp', + message_count: 0, + extension_data: {}, + source: 'test', + } as Session; +} + +function mockLoadResult() { + return { + sessionInfo: { + sessionId: SESSION_ID, + cwd: '/tmp', + title: 'Loaded session', + updatedAt: '2026-01-01T00:00:00Z', + }, + response: {}, + meta: {}, + } as Awaited>; +} + +describe('acpChatSessionController.loadSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(acpChatSessionStore.getSnapshot).mockReturnValue(undefined); + vi.mocked(acpLoadSession).mockResolvedValue(mockLoadResult()); + vi.mocked(sessionInfoToSession).mockReturnValue(loadedSession()); + }); + + it('starts a fresh session load before ACP replays notifications', async () => { + vi.mocked(isAcpSessionLoadInFlight).mockReturnValue(false); + + await acpChatSessionController.loadSession(SESSION_ID); + + expect(acpChatSessionActions.startSessionLoad).toHaveBeenCalledWith(SESSION_ID); + expect(acpLoadSession).toHaveBeenCalledWith(SESSION_ID); + expect(acpChatSessionActions.finishSessionLoad).toHaveBeenCalledWith( + SESSION_ID, + loadedSession() + ); + }); + + it('does not reset replay state when joining an in-flight session load', async () => { + vi.mocked(isAcpSessionLoadInFlight).mockReturnValue(true); + + await acpChatSessionController.loadSession(SESSION_ID); + + expect(acpChatSessionActions.startSessionLoad).not.toHaveBeenCalled(); + expect(acpLoadSession).toHaveBeenCalledWith(SESSION_ID); + expect(acpChatSessionActions.finishSessionLoad).toHaveBeenCalledWith( + SESSION_ID, + loadedSession() + ); + }); +}); diff --git a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts index ef0f92342..3fc3dc630 100644 --- a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts +++ b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts @@ -3,10 +3,15 @@ import type { RequestPermissionRequest, SessionNotification, } from '@agentclientprotocol/sdk'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; import type { Message, Session } from '../../api'; import { ChatState } from '../../types/chatState'; -import { createAcpChatSessionStore, type AcpChatSessionStore } from '../chatSessionStore'; +import { + acpChatSessionActions, + acpChatSessionStore, + useAcpChatSessionSnapshot, +} from '../chatSessionStore'; function message(id: string, text: string): Message { return { @@ -110,104 +115,133 @@ function toolProgressNotification(sessionId: string): SessionNotification { }; } -describe('acpChatSessionStore', () => { - let store: AcpChatSessionStore; +function agentMessageChunkNotification( + sessionId: string, + messageId: string, + text: string +): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + messageId, + content: { + type: 'text', + text, + }, + }, + }; +} - beforeEach(() => { - store = createAcpChatSessionStore(); +describe('acpChatSessionStore', () => { + const sessionIds = new Set(); + const sessionId = (id: string): string => { + sessionIds.add(id); + return id; + }; + + afterEach(() => { + for (const id of sessionIds) { + acpChatSessionActions.deleteSnapshot(id); + } + sessionIds.clear(); }); - it('stores loaded session messages and token state', () => { + it('finishes session load with session metadata', () => { + const currentSessionId = sessionId('session-1'); const initialMessage = message('message-1', 'Hello'); - const snapshot = store.setLoadedSession('session-1', session('session-1', [initialMessage])); + acpChatSessionActions.setMessages(currentSessionId, [initialMessage]); - expect(snapshot.session?.id).toBe('session-1'); + const snapshot = acpChatSessionActions.finishSessionLoad( + currentSessionId, + session(currentSessionId) + ); + + expect(snapshot.session?.id).toBe(currentSessionId); expect(snapshot.messages).toEqual([initialMessage]); expect(snapshot.tokenState).toMatchObject({ - inputTokens: 1, - outputTokens: 2, - totalTokens: 3, - accumulatedInputTokens: 4, - accumulatedOutputTokens: 5, - accumulatedTotalTokens: 9, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, }); expect(snapshot.chatState).toBe(ChatState.Idle); expect(snapshot.sessionLoadError).toBeUndefined(); }); it('keeps multiple session snapshots isolated', () => { - store.setMessages('session-1', [message('message-1', 'One')]); - store.setMessages('session-2', [message('message-2', 'Two')]); + const firstSessionId = sessionId('session-1'); + const secondSessionId = sessionId('session-2'); - expect(store.getSnapshot('session-1')?.messages[0].id).toBe('message-1'); - expect(store.getSnapshot('session-2')?.messages[0].id).toBe('message-2'); + acpChatSessionActions.setMessages(firstSessionId, [message('message-1', 'One')]); + acpChatSessionActions.setMessages(secondSessionId, [message('message-2', 'Two')]); + + expect(acpChatSessionStore.getSnapshot(firstSessionId)?.messages[0].id).toBe('message-1'); + expect(acpChatSessionStore.getSnapshot(secondSessionId)?.messages[0].id).toBe('message-2'); }); it('deletes session snapshots', () => { - store.setMessages('session-1', [message('message-1', 'One')]); + const currentSessionId = sessionId('session-1'); - store.deleteSnapshot('session-1'); + acpChatSessionActions.setMessages(currentSessionId, [message('message-1', 'One')]); - expect(store.getSnapshot('session-1')).toBeUndefined(); - }); + acpChatSessionActions.deleteSnapshot(currentSessionId); - it('notifies only listeners for the updated session', () => { - const sessionOneListener = vi.fn(); - const sessionTwoListener = vi.fn(); - - store.subscribe('session-1', sessionOneListener); - store.subscribe('session-2', sessionTwoListener); - - store.setChatState('session-1', ChatState.Streaming); - - expect(sessionOneListener).toHaveBeenCalledTimes(1); - expect(sessionOneListener).toHaveBeenCalledWith( - expect.objectContaining({ chatState: ChatState.Streaming }) - ); - expect(sessionTwoListener).not.toHaveBeenCalled(); - }); - - it('stops notifying after unsubscribe', () => { - const listener = vi.fn(); - const unsubscribe = store.subscribe('session-1', listener); - - unsubscribe(); - store.setChatState('session-1', ChatState.Streaming); - - expect(listener).not.toHaveBeenCalled(); + expect(acpChatSessionStore.getSnapshot(currentSessionId)).toBeUndefined(); }); it('ignores stale prompt attempts and leaves the current attempt active', () => { - store.startPromptAttempt('session-1', 'attempt-a'); - store.startPromptAttempt('session-1', 'attempt-b'); + const currentSessionId = sessionId('session-1'); - expect(store.finishPromptAttemptIfCurrent('session-1', 'attempt-a', 'late error')).toBe(false); + acpChatSessionActions.startPromptAttempt(currentSessionId, 'attempt-a'); + acpChatSessionActions.startPromptAttempt(currentSessionId, 'attempt-b'); - expect(store.getSnapshot('session-1')).toMatchObject({ + expect( + acpChatSessionActions.finishPromptAttemptIfCurrent( + currentSessionId, + 'attempt-a', + 'late error' + ) + ).toBe(false); + + expect(acpChatSessionStore.getSnapshot(currentSessionId)).toMatchObject({ activePromptAttemptId: 'attempt-b', chatState: ChatState.Streaming, sessionLoadError: undefined, }); - expect(store.finishPromptAttemptIfCurrent('session-1', 'attempt-b')).toBe(true); - expect(store.getSnapshot('session-1')).toMatchObject({ + expect(acpChatSessionActions.finishPromptAttemptIfCurrent(currentSessionId, 'attempt-b')).toBe( + true + ); + expect(acpChatSessionStore.getSnapshot(currentSessionId)).toMatchObject({ activePromptAttemptId: null, chatState: ChatState.Idle, }); }); it('keeps loaded sessions streaming when a prompt attempt is active', () => { - store.startPromptAttempt('session-1', 'attempt-1'); + const currentSessionId = sessionId('session-1'); - const snapshot = store.setLoadedSession('session-1', session('session-1')); + acpChatSessionActions.startPromptAttempt(currentSessionId, 'attempt-1'); + + const snapshot = acpChatSessionActions.finishSessionLoad( + currentSessionId, + session(currentSessionId) + ); expect(snapshot.activePromptAttemptId).toBe('attempt-1'); expect(snapshot.chatState).toBe(ChatState.Streaming); }); it('stores ACP tool notifications and clears them for a new prompt attempt', () => { - const snapshot = store.applyAcpSessionNotification(toolProgressNotification('session-1')); + const currentSessionId = sessionId('session-1'); + + const snapshot = acpChatSessionActions.applyAcpSessionNotification( + toolProgressNotification(currentSessionId) + ); expect(snapshot.notifications).toHaveLength(1); expect(snapshot.notifications[0]).toMatchObject({ @@ -222,13 +256,33 @@ describe('acpChatSessionStore', () => { }, }); - const nextSnapshot = store.startPromptAttempt('session-1', 'attempt-1'); + const nextSnapshot = acpChatSessionActions.startPromptAttempt(currentSessionId, 'attempt-1'); expect(nextSnapshot.notifications).toEqual([]); }); + it('resets replayed messages before starting an unloaded session load', () => { + const currentSessionId = sessionId('session-1'); + const replayedChunk = agentMessageChunkNotification(currentSessionId, 'message-1', 'Hello'); + + acpChatSessionActions.startSessionLoad(currentSessionId); + acpChatSessionActions.applyAcpSessionNotification(replayedChunk); + + const loadingSnapshot = acpChatSessionActions.startSessionLoad(currentSessionId); + expect(loadingSnapshot.messages).toEqual([]); + + const replayedSnapshot = acpChatSessionActions.applyAcpSessionNotification(replayedChunk); + + expect(replayedSnapshot.messages).toHaveLength(1); + expect(replayedSnapshot.messages[0].content).toEqual([{ type: 'text', text: 'Hello' }]); + }); + it('applies permission requests as waiting action-required messages', () => { - const snapshot = store.applyPermissionRequest(permissionRequest('session-1', 'tool-1')); + const currentSessionId = sessionId('session-1'); + + const snapshot = acpChatSessionActions.applyPermissionRequest( + permissionRequest(currentSessionId, 'tool-1') + ); expect(snapshot.chatState).toBe(ChatState.WaitingForUserInput); expect(snapshot.messages).toHaveLength(1); @@ -243,7 +297,11 @@ describe('acpChatSessionStore', () => { }); it('applies elicitation requests as waiting action-required messages', () => { - const snapshot = store.applyElicitationRequest(elicitationRequest('session-1')); + const currentSessionId = sessionId('session-1'); + + const snapshot = acpChatSessionActions.applyElicitationRequest( + elicitationRequest(currentSessionId) + ); expect(snapshot.chatState).toBe(ChatState.WaitingForUserInput); expect(snapshot.messages).toHaveLength(1); @@ -259,9 +317,15 @@ describe('acpChatSessionStore', () => { }); it('stores submitted elicitation status', () => { - store.applyElicitationRequest(elicitationRequest('session-1')); + const currentSessionId = sessionId('session-1'); - const snapshot = store.setElicitationStatus('session-1', 'acp_elicitation_1', 'submitted'); + acpChatSessionActions.applyElicitationRequest(elicitationRequest(currentSessionId)); + + const snapshot = acpChatSessionActions.setElicitationStatus( + currentSessionId, + 'acp_elicitation_1', + 'submitted' + ); expect(snapshot?.messages[0].content[0]).toMatchObject({ type: 'actionRequired', @@ -275,9 +339,15 @@ describe('acpChatSessionStore', () => { }); it('stores cancelled elicitation status', () => { - store.applyElicitationRequest(elicitationRequest('session-1')); + const currentSessionId = sessionId('session-1'); - const snapshot = store.setElicitationStatus('session-1', 'acp_elicitation_1', 'cancelled'); + acpChatSessionActions.applyElicitationRequest(elicitationRequest(currentSessionId)); + + const snapshot = acpChatSessionActions.setElicitationStatus( + currentSessionId, + 'acp_elicitation_1', + 'cancelled' + ); expect(snapshot?.messages[0].content[0]).toMatchObject({ type: 'actionRequired', @@ -290,3 +360,24 @@ describe('acpChatSessionStore', () => { }); }); }); + +describe('useAcpChatSessionSnapshot', () => { + const sessionId = 'hook-session-1'; + + afterEach(() => { + acpChatSessionActions.deleteSnapshot(sessionId); + }); + + it('subscribes to session store snapshots', () => { + const { result } = renderHook(() => useAcpChatSessionSnapshot(sessionId)); + + expect(result.current).toBeUndefined(); + + const nextMessage = message('message-1', 'Hello from hook'); + act(() => { + acpChatSessionActions.setMessages(sessionId, [nextMessage]); + }); + + expect(result.current?.messages).toEqual([nextMessage]); + }); +}); diff --git a/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts b/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts index 64e178779..c9b691b91 100644 --- a/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts +++ b/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts @@ -6,14 +6,14 @@ import { requestAcpElicitation, resolveAcpElicitationRequest, } from '../elicitationRequests'; -import { acpChatSessionStore } from '../chatSessionStore'; +import { acpChatSessionActions } from '../chatSessionStore'; vi.mock('../../acpChatFeatureFlag', () => ({ USE_ACP_CHAT: true, })); vi.mock('../chatSessionStore', () => ({ - acpChatSessionStore: { + acpChatSessionActions: { applyElicitationRequest: vi.fn(), setElicitationStatus: vi.fn(), }, @@ -73,7 +73,9 @@ describe('ACP elicitation requests', () => { await expectStillPending(response); - const appliedRequest = vi.mocked(acpChatSessionStore.applyElicitationRequest).mock.calls[0][0]; + const appliedRequest = vi.mocked( + acpChatSessionActions.applyElicitationRequest + ).mock.calls[0][0]; expect(appliedRequest.id).toMatch(/^acp_elicitation_/); expect(appliedRequest.sessionId).toBe('session-1'); @@ -84,7 +86,7 @@ describe('ACP elicitation requests', () => { project: 'goose', }) ).toBe(true); - expect(acpChatSessionStore.setElicitationStatus).toHaveBeenCalledWith( + expect(acpChatSessionActions.setElicitationStatus).toHaveBeenCalledWith( 'session-1', appliedRequest.id, 'submitted' @@ -114,13 +116,13 @@ describe('ACP elicitation requests', () => { const sessionOneResponse = requestAcpElicitation(formRequest('session-1')); const sessionTwoResponse = requestAcpElicitation(formRequest('session-2')); - const applyElicitationRequest = vi.mocked(acpChatSessionStore.applyElicitationRequest); + const applyElicitationRequest = vi.mocked(acpChatSessionActions.applyElicitationRequest); const sessionOneRequest = applyElicitationRequest.mock.calls[0][0]; const sessionTwoRequest = applyElicitationRequest.mock.calls[1][0]; cancelAcpElicitationRequestsForSession('session-1'); - expect(acpChatSessionStore.setElicitationStatus).toHaveBeenCalledWith( + expect(acpChatSessionActions.setElicitationStatus).toHaveBeenCalledWith( 'session-1', sessionOneRequest.id, 'cancelled' @@ -140,14 +142,15 @@ describe('ACP elicitation requests', () => { vi.useFakeTimers(); try { const response = requestAcpElicitation(formRequest('session-1')); - const appliedRequest = vi.mocked(acpChatSessionStore.applyElicitationRequest).mock - .calls[0][0]; + const appliedRequest = vi.mocked( + acpChatSessionActions.applyElicitationRequest + ).mock.calls[0][0]; await expectStillPending(response); await vi.advanceTimersByTimeAsync(ACP_ELICITATION_TIMEOUT_SECONDS * 1000); - expect(acpChatSessionStore.setElicitationStatus).toHaveBeenCalledWith( + expect(acpChatSessionActions.setElicitationStatus).toHaveBeenCalledWith( 'session-1', appliedRequest.id, 'cancelled' diff --git a/ui/desktop/src/acp/__tests__/permissionRequests.test.ts b/ui/desktop/src/acp/__tests__/permissionRequests.test.ts index b2a4856db..d2aeb5d5d 100644 --- a/ui/desktop/src/acp/__tests__/permissionRequests.test.ts +++ b/ui/desktop/src/acp/__tests__/permissionRequests.test.ts @@ -11,7 +11,7 @@ vi.mock('../../acpChatFeatureFlag', () => ({ })); vi.mock('../chatSessionStore', () => ({ - acpChatSessionStore: { + acpChatSessionActions: { applyPermissionRequest: vi.fn(), }, })); diff --git a/ui/desktop/src/acp/__tests__/sessions.test.ts b/ui/desktop/src/acp/__tests__/sessions.test.ts new file mode 100644 index 000000000..f53457254 --- /dev/null +++ b/ui/desktop/src/acp/__tests__/sessions.test.ts @@ -0,0 +1,72 @@ +import type { SessionInfo } from '@agentclientprotocol/sdk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAcpClient } from '../acpConnection'; +import { acpLoadSession, sessionInfoToSession } from '../sessions'; + +vi.mock('../acpConnection', () => ({ + getAcpClient: vi.fn(), +})); + +function sessionInfo(overrides: Partial = {}): SessionInfo { + return { + sessionId: 'session-1', + cwd: '/tmp', + title: 'Scheduled session', + updatedAt: '2026-01-01T00:00:00Z', + _meta: { + createdAt: '2026-01-01T00:00:00Z', + messageCount: 0, + sessionType: 'scheduled', + }, + ...overrides, + } as unknown as SessionInfo; +} + +describe('ACP sessions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('preserves session type from ACP session info metadata', () => { + const session = sessionInfoToSession(sessionInfo()); + + expect(session.session_type).toBe('scheduled'); + }); + + it('returns session info refreshed after loading the ACP session', async () => { + const loadedSessionInfo = sessionInfo({ + _meta: { + createdAt: '2026-01-01T00:00:00Z', + messageCount: 0, + providerId: 'anthropic', + modelId: 'claude-sonnet-4-5', + }, + }); + const client = { + goose: { + sessionInfo_unstable: vi + .fn() + .mockResolvedValueOnce({ session: sessionInfo() }) + .mockResolvedValueOnce({ session: loadedSessionInfo }), + }, + loadSession: vi.fn().mockResolvedValue({}), + }; + vi.mocked(getAcpClient).mockResolvedValue( + client as unknown as Awaited> + ); + + const result = await acpLoadSession('session-1'); + + expect(client.loadSession).toHaveBeenCalledWith({ + sessionId: 'session-1', + cwd: '/tmp', + mcpServers: [], + }); + expect(client.goose.sessionInfo_unstable).toHaveBeenCalledTimes(2); + expect(result.sessionInfo).toBe(loadedSessionInfo); + expect(sessionInfoToSession(result.sessionInfo).provider_name).toBe('anthropic'); + expect(sessionInfoToSession(result.sessionInfo).model_config?.model_name).toBe( + 'claude-sonnet-4-5' + ); + }); +}); diff --git a/ui/desktop/src/acp/chatNotifications.ts b/ui/desktop/src/acp/chatNotifications.ts index b70e8c02d..ca1c31b8e 100644 --- a/ui/desktop/src/acp/chatNotifications.ts +++ b/ui/desktop/src/acp/chatNotifications.ts @@ -2,7 +2,7 @@ import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; import type { SessionNotification } from '@agentclientprotocol/sdk'; import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; import { AppEvents } from '../constants/events'; -import { acpChatSessionStore } from './chatSessionStore'; +import { acpChatSessionActions, acpChatSessionStore } from './chatSessionStore'; export function handleAcpSessionNotification(notification: SessionNotification): Promise { if (USE_ACP_CHAT) { @@ -13,7 +13,7 @@ export function handleAcpSessionNotification(notification: SessionNotification): notification.update.sessionUpdate === 'session_info_update' ? notification.update.title : undefined; - acpChatSessionStore.applyAcpSessionNotification(notification); + acpChatSessionActions.applyAcpSessionNotification(notification); if (updatedName && updatedName !== sessionNameBeforeNotification) { window.dispatchEvent( @@ -30,7 +30,7 @@ export function handleAcpGooseSessionNotification( notification: GooseSessionNotification_unstable ): Promise { if (USE_ACP_CHAT) { - acpChatSessionStore.applyAcpGooseSessionNotification(notification); + acpChatSessionActions.applyAcpGooseSessionNotification(notification); } return Promise.resolve(); } diff --git a/ui/desktop/src/acp/chatSessionController.ts b/ui/desktop/src/acp/chatSessionController.ts new file mode 100644 index 000000000..2bf0c182a --- /dev/null +++ b/ui/desktop/src/acp/chatSessionController.ts @@ -0,0 +1,255 @@ +import { v7 as uuidv7 } from 'uuid'; +import { updateSessionUserRecipeValues, type Message } from '../api'; +import { AppEvents } from '../constants/events'; +import { ChatState } from '../types/chatState'; +import { errorMessage } from '../utils/conversionUtils'; +import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; +import { createUserMessage } from '../types/message'; +import { + acpChatSessionActions, + acpChatSessionStore, + type AcpChatSessionSnapshot, +} from './chatSessionStore'; +import { cancelAcpElicitationRequestsForSession } from './elicitationRequests'; +import { parseAcpCreditsExhaustedError, type AcpCreditsExhaustedError } from './errors'; +import { cancelAcpPermissionRequestsForSession } from './permissionRequests'; +import { acpCancelPrompt, acpPromptSession } from './prompt'; +import { + acpForkSession, + acpLoadSession, + acpTruncateSessionConversation, + isAcpSessionLoadInFlight, + sessionInfoToSession, +} from './sessions'; + +export interface AcpLoadSessionOptions { + onSessionLoaded?: () => void; +} + +export interface AcpSnapshotOptions { + getCurrentSnapshot(): AcpChatSessionSnapshot | undefined; +} + +export interface AcpSubmitMessageOptions extends AcpSnapshotOptions { + onFinish(error?: string): void | Promise; +} + +export interface AcpChatSessionController { + loadSession(sessionId: string, options?: AcpLoadSessionOptions): Promise; + submitMessage( + sessionId: string, + userMessage: Message, + options: AcpSubmitMessageOptions + ): Promise; + stop(sessionId: string): void; + updateMessage( + sessionId: string, + messageId: string, + newContent: string, + editType: 'fork' | 'edit' | undefined, + options: AcpSubmitMessageOptions + ): Promise; + setRecipeUserParams( + sessionId: string, + userRecipeValues: Record, + options: AcpSnapshotOptions + ): Promise; +} + +function createAcpCreditsExhaustedMessage(error: AcpCreditsExhaustedError): Message { + return { + id: uuidv7(), + role: 'assistant', + created: Math.floor(Date.now() / 1000), + content: [ + { + type: 'systemNotification', + notificationType: 'creditsExhausted', + msg: error.message, + ...(error.url ? { data: { top_up_url: error.url } } : {}), + }, + ], + metadata: { userVisible: true, agentVisible: false }, + }; +} + +async function loadSession(sessionId: string, options: AcpLoadSessionOptions = {}): Promise { + const cached = acpChatSessionStore.getSnapshot(sessionId); + if (cached?.session) { + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_EXTENSIONS_LOADED, { detail: { sessionId } }) + ); + options.onSessionLoaded?.(); + return; + } + + if (!isAcpSessionLoadInFlight(sessionId)) { + acpChatSessionActions.startSessionLoad(sessionId); + } + + try { + const { sessionInfo, meta } = await acpLoadSession(sessionId); + + showExtensionLoadResults(meta.extensionResults); + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_EXTENSIONS_LOADED, { detail: { sessionId } }) + ); + acpChatSessionActions.finishSessionLoad(sessionId, sessionInfoToSession(sessionInfo, meta)); + options.onSessionLoaded?.(); + } catch (error) { + acpChatSessionActions.failSessionLoad(sessionId, errorMessage(error)); + } +} + +async function submitMessage( + sessionId: string, + userMessage: Message, + options: AcpSubmitMessageOptions +): Promise { + const promptAttemptId = uuidv7(); + acpChatSessionActions.startPromptAttempt(sessionId, promptAttemptId); + + try { + await acpPromptSession(sessionId, userMessage); + if (acpChatSessionActions.finishPromptAttemptIfCurrent(sessionId, promptAttemptId)) { + void options.onFinish(); + } + } catch (error) { + const creditsExhaustedError = parseAcpCreditsExhaustedError(error); + if (creditsExhaustedError) { + if (!acpChatSessionActions.isCurrentPromptAttempt(sessionId, promptAttemptId)) { + return; + } + + const messages = [ + ...(options.getCurrentSnapshot()?.messages ?? []), + createAcpCreditsExhaustedMessage(creditsExhaustedError), + ]; + acpChatSessionActions.setMessages(sessionId, messages); + if (acpChatSessionActions.finishPromptAttemptIfCurrent(sessionId, promptAttemptId)) { + void options.onFinish(); + } + return; + } + + const submitError = 'Submit error: ' + errorMessage(error); + if ( + acpChatSessionActions.finishPromptAttemptIfCurrent(sessionId, promptAttemptId, submitError) + ) { + void options.onFinish(submitError); + } + } +} + +function stop(sessionId: string): void { + const storedPromptAttemptId = acpChatSessionStore.getSnapshot(sessionId)?.activePromptAttemptId; + const hasStoredAcpPrompt = storedPromptAttemptId !== null && storedPromptAttemptId !== undefined; + + if (hasStoredAcpPrompt) { + acpChatSessionActions.clearActivePromptAttempt(sessionId); + cancelAcpPermissionRequestsForSession(sessionId); + cancelAcpElicitationRequestsForSession(sessionId); + acpCancelPrompt(sessionId).catch((error) => { + console.warn('Failed to cancel ACP prompt:', error); + }); + return; + } + + acpChatSessionActions.setChatState(sessionId, ChatState.Idle); +} + +async function updateMessage( + sessionId: string, + messageId: string, + newContent: string, + editType: 'fork' | 'edit' | undefined, + options: AcpSubmitMessageOptions +): Promise { + const resolvedEditType = editType ?? 'fork'; + const currentSnapshot = options.getCurrentSnapshot(); + + acpChatSessionActions.setChatState(sessionId, ChatState.Thinking); + + try { + const currentMessages = currentSnapshot?.messages ?? []; + const message = currentMessages.find((m) => m.id === messageId); + + if (!message) { + throw new Error(`Message with id ${messageId} not found in current messages`); + } + + if (resolvedEditType === 'fork') { + const targetSessionId = await acpForkSession(sessionId, message.created); + + acpChatSessionActions.setChatState(sessionId, ChatState.Idle); + const event = new CustomEvent(AppEvents.SESSION_FORKED, { + detail: { + newSessionId: targetSessionId, + shouldStartAgent: true, + editedMessage: newContent, + }, + }); + window.dispatchEvent(event); + window.electron.logInfo(`Dispatched session-forked event for session ${targetSessionId}`); + return; + } + + await acpTruncateSessionConversation(sessionId, message.created); + + const truncatedMessages = currentMessages.filter((m) => m.created < message.created); + const updatedUserMessage = createUserMessage(newContent); + + for (const content of message.content) { + if (content.type === 'image') { + updatedUserMessage.content.push(content); + } + } + + const messagesForUI = [...truncatedMessages, updatedUserMessage]; + acpChatSessionActions.setMessages(sessionId, messagesForUI); + + await submitMessage(sessionId, updatedUserMessage, options); + } catch (error) { + acpChatSessionActions.setChatState(sessionId, ChatState.Idle); + throw error; + } +} + +async function setRecipeUserParams( + sessionId: string, + userRecipeValues: Record, + options: AcpSnapshotOptions +): Promise { + const currentSession = + options.getCurrentSnapshot()?.session ?? acpChatSessionStore.getSnapshot(sessionId)?.session; + + if (currentSession) { + await updateSessionUserRecipeValues({ + path: { + session_id: sessionId, + }, + body: { + userRecipeValues, + }, + throwOnError: true, + }); + const updatedSession = { + ...currentSession, + user_recipe_values: userRecipeValues, + }; + acpChatSessionActions.setSessionMetadata(sessionId, updatedSession); + } else { + acpChatSessionActions.setSessionLoadError( + sessionId, + "can't call setRecipeParams without a session" + ); + } +} + +export const acpChatSessionController: AcpChatSessionController = { + loadSession, + submitMessage, + stop, + updateMessage, + setRecipeUserParams, +}; diff --git a/ui/desktop/src/acp/chatSessionStore.ts b/ui/desktop/src/acp/chatSessionStore.ts index 93ef805d2..7dfc62608 100644 --- a/ui/desktop/src/acp/chatSessionStore.ts +++ b/ui/desktop/src/acp/chatSessionStore.ts @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk'; import type { Message, Session, TokenState } from '../api'; @@ -39,25 +40,11 @@ const initialTokenState: TokenState = { export interface AcpChatSessionStore { getSnapshot(sessionId: string): AcpChatSessionSnapshot | undefined; - subscribe(sessionId: string, listener: (snapshot: AcpChatSessionSnapshot) => void): () => void; +} + +export interface AcpChatSessionActions { deleteSnapshot(sessionId: string): void; - setLoadedSession( - sessionId: string, - session: Session, - tokenState?: TokenState - ): AcpChatSessionSnapshot; - setSessionMetadata(sessionId: string, session: Session | undefined): AcpChatSessionSnapshot; - setMessages(sessionId: string, messages: Message[]): AcpChatSessionSnapshot; - setTokenState(sessionId: string, tokenState: TokenState): AcpChatSessionSnapshot; - setChatState(sessionId: string, chatState: ChatState): AcpChatSessionSnapshot; - setSessionLoadError( - sessionId: string, - sessionLoadError: string | undefined - ): AcpChatSessionSnapshot; - startPromptAttempt(sessionId: string, promptAttemptId: string): AcpChatSessionSnapshot; - finishPromptAttemptIfCurrent(sessionId: string, promptAttemptId: string, error?: string): boolean; - clearActivePromptAttempt(sessionId: string): AcpChatSessionSnapshot | undefined; - isCurrentPromptAttempt(sessionId: string, promptAttemptId: string): boolean; + applyAcpSessionNotification(notification: SessionNotification): AcpChatSessionSnapshot; applyAcpGooseSessionNotification( notification: GooseSessionNotification_unstable @@ -69,9 +56,30 @@ export interface AcpChatSessionStore { elicitationId: string, status: ElicitationStatus ): AcpChatSessionSnapshot | undefined; + + setSessionMetadata(sessionId: string, session: Session | undefined): AcpChatSessionSnapshot; + startSessionLoad(sessionId: string): AcpChatSessionSnapshot; + finishSessionLoad(sessionId: string, session: Session): AcpChatSessionSnapshot; + failSessionLoad(sessionId: string, sessionLoadError: string): AcpChatSessionSnapshot; + setSessionLoadError( + sessionId: string, + sessionLoadError: string | undefined + ): AcpChatSessionSnapshot; + + setMessages(sessionId: string, messages: Message[]): AcpChatSessionSnapshot; + setChatState(sessionId: string, chatState: ChatState): AcpChatSessionSnapshot; + + startPromptAttempt(sessionId: string, promptAttemptId: string): AcpChatSessionSnapshot; + finishPromptAttemptIfCurrent(sessionId: string, promptAttemptId: string, error?: string): boolean; + clearActivePromptAttempt(sessionId: string): AcpChatSessionSnapshot | undefined; + isCurrentPromptAttempt(sessionId: string, promptAttemptId: string): boolean; } -export function createAcpChatSessionStore(): AcpChatSessionStore { +interface AcpChatSessionStoreInternal extends AcpChatSessionStore, AcpChatSessionActions { + subscribe(sessionId: string, listener: (snapshot: AcpChatSessionSnapshot) => void): () => void; +} + +function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { const sessionsById = new Map(); const listenersBySessionId = new Map>(); @@ -80,7 +88,7 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { return entry ? snapshotFromEntry(entry) : undefined; }; - const subscribe: AcpChatSessionStore['subscribe'] = (sessionId, listener) => { + const subscribe: AcpChatSessionStoreInternal['subscribe'] = (sessionId, listener) => { const listeners = listenersBySessionId.get(sessionId) ?? new Set(); listeners.add(listener); listenersBySessionId.set(sessionId, listeners); @@ -104,7 +112,7 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { }; }; - const deleteSnapshot: AcpChatSessionStore['deleteSnapshot'] = (sessionId) => { + const deleteSnapshot: AcpChatSessionActions['deleteSnapshot'] = (sessionId) => { sessionsById.delete(sessionId); }; @@ -139,47 +147,52 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { return snapshot; }; - const setLoadedSession: AcpChatSessionStore['setLoadedSession'] = ( + const setSessionMetadata: AcpChatSessionActions['setSessionMetadata'] = (sessionId, session) => { + const entry = getOrCreateEntry(sessionId); + entry.session = session; + return notify(sessionId, entry); + }; + + const startSessionLoad: AcpChatSessionActions['startSessionLoad'] = (sessionId) => { + const entry = getOrCreateEntry(sessionId); + resetReplayState(entry); + entry.sessionLoadError = undefined; + entry.chatState = ChatState.LoadingConversation; + return notify(sessionId, entry); + }; + + const finishSessionLoad: AcpChatSessionActions['finishSessionLoad'] = (sessionId, session) => { + const entry = getOrCreateEntry(sessionId); + entry.session = session; + entry.sessionLoadError = undefined; + entry.chatState = entry.activePromptAttemptId ? ChatState.Streaming : ChatState.Idle; + return notify(sessionId, entry); + }; + + const failSessionLoad: AcpChatSessionActions['failSessionLoad'] = ( sessionId, - session, - tokenState = tokenStateFromSession(session) + sessionLoadError ) => { const entry = getOrCreateEntry(sessionId); - entry.session = session; - entry.messages = cloneMessages(session.conversation ?? []); - entry.tokenState = { ...tokenState }; - entry.chatState = entry.activePromptAttemptId ? ChatState.Streaming : ChatState.Idle; - entry.sessionLoadError = undefined; - entry.adapter = createAcpSessionNotificationAdapter(entry.messages); + entry.sessionLoadError = sessionLoadError; + entry.chatState = ChatState.Idle; return notify(sessionId, entry); }; - const setSessionMetadata: AcpChatSessionStore['setSessionMetadata'] = (sessionId, session) => { - const entry = getOrCreateEntry(sessionId); - entry.session = session; - return notify(sessionId, entry); - }; - - const setMessages: AcpChatSessionStore['setMessages'] = (sessionId, messages) => { + const setMessages: AcpChatSessionActions['setMessages'] = (sessionId, messages) => { const entry = getOrCreateEntry(sessionId); entry.messages = cloneMessages(messages); entry.adapter = createAcpSessionNotificationAdapter(entry.messages); return notify(sessionId, entry); }; - const setTokenState: AcpChatSessionStore['setTokenState'] = (sessionId, tokenState) => { - const entry = getOrCreateEntry(sessionId); - entry.tokenState = { ...tokenState }; - return notify(sessionId, entry); - }; - - const setChatState: AcpChatSessionStore['setChatState'] = (sessionId, chatState) => { + const setChatState: AcpChatSessionActions['setChatState'] = (sessionId, chatState) => { const entry = getOrCreateEntry(sessionId); entry.chatState = chatState; return notify(sessionId, entry); }; - const setSessionLoadError: AcpChatSessionStore['setSessionLoadError'] = ( + const setSessionLoadError: AcpChatSessionActions['setSessionLoadError'] = ( sessionId, sessionLoadError ) => { @@ -188,7 +201,7 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { return notify(sessionId, entry); }; - const startPromptAttempt: AcpChatSessionStore['startPromptAttempt'] = ( + const startPromptAttempt: AcpChatSessionActions['startPromptAttempt'] = ( sessionId, promptAttemptId ) => { @@ -200,7 +213,7 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { return notify(sessionId, entry); }; - const finishPromptAttemptIfCurrent: AcpChatSessionStore['finishPromptAttemptIfCurrent'] = ( + const finishPromptAttemptIfCurrent: AcpChatSessionActions['finishPromptAttemptIfCurrent'] = ( sessionId, promptAttemptId, error @@ -217,7 +230,9 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { return true; }; - const clearActivePromptAttempt: AcpChatSessionStore['clearActivePromptAttempt'] = (sessionId) => { + const clearActivePromptAttempt: AcpChatSessionActions['clearActivePromptAttempt'] = ( + sessionId + ) => { const entry = sessionsById.get(sessionId); if (!entry) { return undefined; @@ -228,12 +243,12 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { return notify(sessionId, entry); }; - const isCurrentPromptAttempt: AcpChatSessionStore['isCurrentPromptAttempt'] = ( + const isCurrentPromptAttempt: AcpChatSessionActions['isCurrentPromptAttempt'] = ( sessionId, promptAttemptId ) => sessionsById.get(sessionId)?.activePromptAttemptId === promptAttemptId; - const applyAcpSessionNotification: AcpChatSessionStore['applyAcpSessionNotification'] = ( + const applyAcpSessionNotification: AcpChatSessionActions['applyAcpSessionNotification'] = ( notification ) => { const entry = getOrCreateEntry(notification.sessionId); @@ -242,7 +257,7 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { return notify(notification.sessionId, entry); }; - const applyAcpGooseSessionNotification: AcpChatSessionStore['applyAcpGooseSessionNotification'] = + const applyAcpGooseSessionNotification: AcpChatSessionActions['applyAcpGooseSessionNotification'] = (notification) => { const entry = getOrCreateEntry(notification.sessionId); const changes = entry.adapter.applyGoose(notification); @@ -250,7 +265,7 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { return notify(notification.sessionId, entry); }; - const applyPermissionRequest: AcpChatSessionStore['applyPermissionRequest'] = (request) => { + const applyPermissionRequest: AcpChatSessionActions['applyPermissionRequest'] = (request) => { const entry = getOrCreateEntry(request.sessionId); const changes = entry.adapter.applyPermissionRequest(request); applyChatStateChanges(entry, changes); @@ -258,7 +273,7 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { return notify(request.sessionId, entry); }; - const applyElicitationRequest: AcpChatSessionStore['applyElicitationRequest'] = (request) => { + const applyElicitationRequest: AcpChatSessionActions['applyElicitationRequest'] = (request) => { const entry = getOrCreateEntry(request.sessionId); const changes = entry.adapter.applyElicitationRequest(request); applyChatStateChanges(entry, changes); @@ -266,7 +281,7 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { return notify(request.sessionId, entry); }; - const setElicitationStatus: AcpChatSessionStore['setElicitationStatus'] = ( + const setElicitationStatus: AcpChatSessionActions['setElicitationStatus'] = ( sessionId, elicitationId, status @@ -289,12 +304,13 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { getSnapshot, subscribe, deleteSnapshot, - setLoadedSession, setSessionMetadata, - setMessages, - setTokenState, - setChatState, + startSessionLoad, + finishSessionLoad, + failSessionLoad, setSessionLoadError, + setMessages, + setChatState, startPromptAttempt, finishPromptAttemptIfCurrent, clearActivePromptAttempt, @@ -307,19 +323,70 @@ export function createAcpChatSessionStore(): AcpChatSessionStore { }; } -export const acpChatSessionStore = createAcpChatSessionStore(); +const acpChatSessionStoreInternal = createAcpChatSessionStoreInternal(); -export function tokenStateFromSession(session: Session | undefined): TokenState { +export const acpChatSessionStore: AcpChatSessionStore = storeFromInternal( + acpChatSessionStoreInternal +); + +export const acpChatSessionActions: AcpChatSessionActions = actionsFromStore( + acpChatSessionStoreInternal +); + +interface AcpChatSessionSnapshotState { + sessionId: string; + snapshot: AcpChatSessionSnapshot | undefined; +} + +export function useAcpChatSessionSnapshot(sessionId: string): AcpChatSessionSnapshot | undefined { + const [snapshotState, setSnapshotState] = useState(() => ({ + sessionId, + snapshot: acpChatSessionStoreInternal.getSnapshot(sessionId), + })); + + useEffect(() => { + setSnapshotState({ + sessionId, + snapshot: acpChatSessionStoreInternal.getSnapshot(sessionId), + }); + + return acpChatSessionStoreInternal.subscribe(sessionId, (snapshot) => { + setSnapshotState({ sessionId, snapshot }); + }); + }, [sessionId]); + + if (snapshotState.sessionId !== sessionId) { + return acpChatSessionStoreInternal.getSnapshot(sessionId); + } + + return snapshotState.snapshot; +} + +function storeFromInternal(store: AcpChatSessionStoreInternal): AcpChatSessionStore { return { - inputTokens: session?.input_tokens ?? 0, - outputTokens: session?.output_tokens ?? 0, - totalTokens: session?.total_tokens ?? 0, - accumulatedInputTokens: session?.accumulated_input_tokens ?? 0, - accumulatedOutputTokens: session?.accumulated_output_tokens ?? 0, - accumulatedTotalTokens: session?.accumulated_total_tokens ?? 0, - ...(session?.accumulated_cost !== undefined - ? { accumulatedCost: session.accumulated_cost } - : {}), + getSnapshot: store.getSnapshot, + }; +} + +function actionsFromStore(store: AcpChatSessionStoreInternal): AcpChatSessionActions { + return { + deleteSnapshot: store.deleteSnapshot, + applyAcpSessionNotification: store.applyAcpSessionNotification, + applyAcpGooseSessionNotification: store.applyAcpGooseSessionNotification, + applyPermissionRequest: store.applyPermissionRequest, + applyElicitationRequest: store.applyElicitationRequest, + setElicitationStatus: store.setElicitationStatus, + setSessionMetadata: store.setSessionMetadata, + startSessionLoad: store.startSessionLoad, + finishSessionLoad: store.finishSessionLoad, + failSessionLoad: store.failSessionLoad, + setSessionLoadError: store.setSessionLoadError, + setMessages: store.setMessages, + setChatState: store.setChatState, + startPromptAttempt: store.startPromptAttempt, + finishPromptAttemptIfCurrent: store.finishPromptAttemptIfCurrent, + clearActivePromptAttempt: store.clearActivePromptAttempt, + isCurrentPromptAttempt: store.isCurrentPromptAttempt, }; } @@ -344,6 +411,13 @@ function applyChatStateChanges(entry: StoreEntry, changes: AcpChatStateChange[]) } } +function resetReplayState(entry: StoreEntry): void { + entry.messages = []; + entry.tokenState = { ...initialTokenState }; + entry.notifications = []; + entry.adapter = createAcpSessionNotificationAdapter(); +} + function snapshotFromEntry(entry: StoreEntry): AcpChatSessionSnapshot { return { session: entry.session, diff --git a/ui/desktop/src/acp/elicitationRequests.ts b/ui/desktop/src/acp/elicitationRequests.ts index 775f6fc61..19caf7f63 100644 --- a/ui/desktop/src/acp/elicitationRequests.ts +++ b/ui/desktop/src/acp/elicitationRequests.ts @@ -6,7 +6,7 @@ import type { } from '@agentclientprotocol/sdk'; import { v7 as uuidv7 } from 'uuid'; import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; -import { acpChatSessionStore } from './chatSessionStore'; +import { acpChatSessionActions } from './chatSessionStore'; type SessionScopedFormElicitationRequest = CreateElicitationRequest & { mode: 'form'; @@ -51,7 +51,7 @@ export async function requestAcpElicitation( } pendingRequests.delete(key); - acpChatSessionStore.setElicitationStatus( + acpChatSessionActions.setElicitationStatus( elicitationRequest.sessionId, elicitationRequest.id, 'cancelled' @@ -60,7 +60,7 @@ export async function requestAcpElicitation( }, ACP_ELICITATION_TIMEOUT_SECONDS * 1000); pendingRequests.set(key, { request: elicitationRequest, resolve, timeoutId }); - acpChatSessionStore.applyElicitationRequest(elicitationRequest); + acpChatSessionActions.applyElicitationRequest(elicitationRequest); }); } @@ -77,7 +77,7 @@ export function resolveAcpElicitationRequest( pendingRequests.delete(key); clearTimeout(pending.timeoutId); - acpChatSessionStore.setElicitationStatus(sessionId, elicitationId, 'submitted'); + acpChatSessionActions.setElicitationStatus(sessionId, elicitationId, 'submitted'); pending.resolve(acceptedElicitationResponse(userData)); return true; } @@ -87,7 +87,7 @@ export function cancelAcpElicitationRequestsForSession(sessionId: string): void if (pending.request.sessionId === sessionId) { pendingRequests.delete(key); clearTimeout(pending.timeoutId); - acpChatSessionStore.setElicitationStatus(sessionId, pending.request.id, 'cancelled'); + acpChatSessionActions.setElicitationStatus(sessionId, pending.request.id, 'cancelled'); pending.resolve(cancelledElicitationResponse()); } } diff --git a/ui/desktop/src/acp/permissionRequests.ts b/ui/desktop/src/acp/permissionRequests.ts index 28b852e2f..847ad141f 100644 --- a/ui/desktop/src/acp/permissionRequests.ts +++ b/ui/desktop/src/acp/permissionRequests.ts @@ -1,7 +1,7 @@ import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk'; import type { Permission } from '../api'; import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; -import { acpChatSessionStore } from './chatSessionStore'; +import { acpChatSessionActions } from './chatSessionStore'; interface PendingPermissionRequest { request: RequestPermissionRequest; @@ -25,7 +25,7 @@ export async function requestAcpPermission( return new Promise((resolve) => { pendingRequests.set(key, { request, resolve }); - acpChatSessionStore.applyPermissionRequest(request); + acpChatSessionActions.applyPermissionRequest(request); }); } diff --git a/ui/desktop/src/acp/sessions.ts b/ui/desktop/src/acp/sessions.ts index 2a945a113..3e748db1c 100644 --- a/ui/desktop/src/acp/sessions.ts +++ b/ui/desktop/src/acp/sessions.ts @@ -1,10 +1,12 @@ import type { ForkSessionRequest, ListSessionsRequest, + LoadSessionResponse, SessionInfo, } from '@agentclientprotocol/sdk'; import { getAcpClient } from './acpConnection'; import { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext'; +import type { ExtensionLoadResult, Recipe, Session } from '../api'; interface GooseSessionInfoMeta { messageCount?: number; @@ -13,8 +15,10 @@ interface GooseSessionInfoMeta { projectId?: string; providerId?: string; modelId?: string; + sessionType?: Session['session_type']; userSetName?: boolean; hasRecipe?: boolean; + lastMessageSnippet?: string; } export interface SessionListItem { @@ -37,8 +41,68 @@ export interface SessionListPage { nextCursor: string | null; } +export interface LoadSessionMeta { + recipe?: Recipe | null; + userRecipeValues?: Record | null; + extensionResults?: ExtensionLoadResult[] | null; + workingDir?: string; +} + +export interface AcpLoadSessionResult { + sessionInfo: SessionInfo; + response: LoadSessionResponse; + meta: LoadSessionMeta; +} + +const inFlightSessionLoads = new Map>(); + +export function parseLoadMeta(response: LoadSessionResponse): LoadSessionMeta { + const meta = (response._meta ?? {}) as LoadSessionMeta; + return { + recipe: meta.recipe, + userRecipeValues: meta.userRecipeValues, + extensionResults: meta.extensionResults, + workingDir: typeof meta.workingDir === 'string' ? meta.workingDir : undefined, + }; +} + +function sessionInfoMeta(s: SessionInfo): GooseSessionInfoMeta { + return (s._meta ?? {}) as GooseSessionInfoMeta; +} + +export function sessionInfoToSession(s: SessionInfo, loadMeta: LoadSessionMeta = {}): Session { + const meta = sessionInfoMeta(s); + const createdAt = meta.createdAt ?? s.updatedAt ?? ''; + const updatedAt = s.updatedAt ?? createdAt; + const modelConfig: Session['model_config'] = meta.modelId + ? { + model_name: meta.modelId, + toolshim: false, + } + : null; + + return { + id: String(s.sessionId), + name: s.title ?? DEFAULT_CHAT_TITLE, + working_dir: loadMeta.workingDir ?? s.cwd, + created_at: createdAt, + updated_at: updatedAt, + message_count: meta.messageCount ?? 0, + extension_data: {}, + archived_at: meta.archivedAt, + project_id: meta.projectId, + provider_name: meta.providerId, + model_config: modelConfig, + session_type: meta.sessionType, + recipe: loadMeta.recipe, + user_recipe_values: loadMeta.userRecipeValues, + user_set_name: meta.userSetName, + last_message_snippet: meta.lastMessageSnippet, + }; +} + function sessionInfoToListItem(s: SessionInfo): SessionListItem { - const meta = (s._meta ?? {}) as GooseSessionInfoMeta; + const meta = sessionInfoMeta(s); return { id: String(s.sessionId), name: s.title ?? DEFAULT_CHAT_TITLE, @@ -93,6 +157,46 @@ export async function acpListRecentSessions(maxSessions: number): Promise { + const pendingLoad = inFlightSessionLoads.get(sessionId); + if (pendingLoad) { + return pendingLoad; + } + + const loadPromise = loadAcpSession(sessionId); + inFlightSessionLoads.set(sessionId, loadPromise); + try { + return await loadPromise; + } finally { + if (inFlightSessionLoads.get(sessionId) === loadPromise) { + inFlightSessionLoads.delete(sessionId); + } + } +} + +export function isAcpSessionLoadInFlight(sessionId: string): boolean { + return inFlightSessionLoads.has(sessionId); +} + +async function loadAcpSession(sessionId: string): Promise { + const client = await getAcpClient(); + const initialSessionInfoResponse = await client.goose.sessionInfo_unstable({ sessionId }); + const initialSessionInfo = initialSessionInfoResponse.session; + const response = await client.loadSession({ + sessionId, + cwd: initialSessionInfo.cwd, + mcpServers: [], + }); + // Loading can populate missing provider/model metadata. + const sessionInfoResponse = await client.goose.sessionInfo_unstable({ sessionId }); + + return { + sessionInfo: sessionInfoResponse.session, + response, + meta: parseLoadMeta(response), + }; +} + export async function acpDeleteSession(sessionId: string): Promise { const client = await getAcpClient(); await client.goose.sessionDelete({ sessionId }); diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 33c645749..b89cbba24 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -121,6 +121,13 @@ export default function BaseChat({ onStreamFinish, }); + const handleWorkingDirChange = useCallback( + (newDir: string) => { + updateSession((currentSession) => ({ ...currentSession, working_dir: newDir })); + }, + [updateSession] + ); + const recipe = session?.recipe; const resolvedInitialMessage = useMemo((): UserInput | undefined => { @@ -535,6 +542,8 @@ export default function BaseChat({ sessionModel={sessionModel} sessionProvider={sessionProvider} sessionLoaded={sessionLoaded} + workingDir={session?.working_dir} + onWorkingDirChange={handleWorkingDirChange} latestInference={latestInference} {...customChatInputProps} /> diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index c2e1a2864..f1ca7d292 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -27,7 +27,7 @@ import { Recipe } from '../recipe'; import { MessageQueue, QueuedMessage } from './MessageQueue'; import { detectInterruption } from '../utils/interruptionDetector'; import { DiagnosticsModal } from './ui/Diagnostics'; -import { getSession, Message } from '../api'; +import { Message } from '../api'; import { getInitialWorkingDir } from '../utils/workingDir'; import { getPredefinedModelsFromEnv } from './settings/models/predefinedModelsUtils'; import { trackFileAttached, trackVoiceDictation, trackDiagnosticsOpened } from '../utils/analytics'; @@ -195,6 +195,7 @@ interface ChatInputProps { sessionModel?: string | null; sessionProvider?: string | null; sessionLoaded?: boolean; + workingDir?: string | null; latestInference?: Message['metadata']['inference'] | null; } @@ -227,6 +228,7 @@ export default function ChatInput({ sessionModel, sessionProvider, sessionLoaded, + workingDir, latestInference, }: ChatInputProps) { const [_value, setValue] = useState(initialValue); @@ -299,7 +301,8 @@ export default function ChatInput({ const [tokenLimit, setTokenLimit] = useState(TOKEN_LIMIT_DEFAULT); const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); - const [sessionWorkingDir, setSessionWorkingDir] = useState(null); + const [workingDirOverride, setWorkingDirOverride] = useState(null); + const currentWorkingDir = workingDirOverride ?? workingDir ?? getInitialWorkingDir(); // Hide non-essential bottom-bar controls when the chat input is narrow. // Only the model selector, mic, and send button remain visible. @@ -317,23 +320,8 @@ export default function ChatInput({ }, []); useEffect(() => { - if (!sessionId) { - return; - } - - const fetchSessionWorkingDir = async () => { - try { - const response = await getSession({ path: { session_id: sessionId } }); - if (response.data?.working_dir) { - setSessionWorkingDir(response.data.working_dir); - } - } catch (error) { - console.error('[ChatInput] Failed to fetch session working dir:', error); - } - }; - - fetchSessionWorkingDir(); - }, [sessionId]); + setWorkingDirOverride(null); + }, [sessionId, workingDir]); // Save queue state (paused/interrupted) to storage useEffect(() => { @@ -1613,9 +1601,9 @@ export default function ChatInput({ { - setSessionWorkingDir(newDir); + setWorkingDirOverride(newDir); if (onWorkingDirChange) { onWorkingDirChange(newDir); } @@ -1802,7 +1790,7 @@ export default function ChatInput({ onSelectedIndexChange={(index) => setMentionPopover((prev) => ({ ...prev, selectedIndex: index })) } - workingDir={sessionWorkingDir ?? getInitialWorkingDir()} + workingDir={currentWorkingDir} /> diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx index 10b24c886..09b67ed78 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -98,13 +98,13 @@ export const DirSwitcher: React.FC = ({ setRecentDirs((previous) => [newDir, ...previous.filter((dir) => dir !== newDir)].slice(0, 10)); if (sessionId) { - onWorkingDirChange?.(newDir); onRestartStart?.(); try { await updateWorkingDir({ body: { session_id: sessionId, working_dir: newDir }, }); + onWorkingDirChange?.(newDir); } catch (error) { console.error('[DirSwitcher] Failed to update working directory:', error); toast.error(intl.formatMessage(i18n.failedToUpdateWorkingDir)); diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index ac2623b61..bf505c0f4 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -45,7 +45,7 @@ import { acpRenameSession, type SessionListItem, } from '../../acp/sessions'; -import { acpChatSessionStore } from '../../acp/chatSessionStore'; +import { acpChatSessionActions } from '../../acp/chatSessionStore'; import { cancelAcpPermissionRequestsForSession } from '../../acp/permissionRequests'; import { cancelAcpElicitationRequestsForSession } from '../../acp/elicitationRequests'; import { getSearchShortcutText } from '../../utils/keyboardShortcuts'; @@ -514,7 +514,7 @@ const SessionListView: React.FC = React.memo( clearSessionCache(sessionToDeleteId); cancelAcpPermissionRequestsForSession(sessionToDeleteId); cancelAcpElicitationRequestsForSession(sessionToDeleteId); - acpChatSessionStore.deleteSnapshot(sessionToDeleteId); + acpChatSessionActions.deleteSnapshot(sessionToDeleteId); } catch (error) { console.error('Error deleting session:', error); toast.error(intl.formatMessage(i18n.deleteFailed, { name: sessionName, error: errorMessage(error, 'Unknown error') })); diff --git a/ui/desktop/src/hooks/useAcpChatSession.ts b/ui/desktop/src/hooks/useAcpChatSession.ts index eef729bba..e0f8337b1 100644 --- a/ui/desktop/src/hooks/useAcpChatSession.ts +++ b/ui/desktop/src/hooks/useAcpChatSession.ts @@ -1,53 +1,20 @@ -import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { defineMessages, useIntl } from '../i18n'; -import { v7 as uuidv7 } from 'uuid'; import { AppEvents } from '../constants/events'; import { ChatState } from '../types/chatState'; -import { - Message, - resumeAgent, - Session, - TokenState, - updateFromSession, - updateSessionUserRecipeValues, - listApps, -} from '../api'; +import { Message, Session, TokenState, updateFromSession } from '../api'; import { createUserMessage, NotificationEvent, UserInput } from '../types/message'; import { errorMessage } from '../utils/conversionUtils'; -import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; import type { UseChatSessionParams, UseChatSessionResult } from './useChatSessionTypes'; -import { cancelAcpPermissionRequestsForSession } from '../acp/permissionRequests'; +import { resolveAcpElicitationRequest } from '../acp/elicitationRequests'; +import { acpChatSessionController } from '../acp/chatSessionController'; import { - cancelAcpElicitationRequestsForSession, - resolveAcpElicitationRequest, -} from '../acp/elicitationRequests'; -import { parseAcpCreditsExhaustedError, type AcpCreditsExhaustedError } from '../acp/errors'; -import { acpCancelPrompt, acpPromptSession } from '../acp/prompt'; -import { acpForkSession, acpTruncateSessionConversation } from '../acp/sessions'; -import { acpChatSessionStore, type AcpChatSessionSnapshot } from '../acp/chatSessionStore'; - -interface StreamState { - messages: Message[]; - session: Session | undefined; - chatState: ChatState; - sessionLoadError: string | undefined; - tokenState: TokenState; - notifications: NotificationEvent[]; -} - -type StreamAction = - | { type: 'SET_MESSAGES'; payload: Message[] } - | { type: 'SET_SESSION'; payload: Session | undefined } - | { type: 'SET_CHAT_STATE'; payload: ChatState } - | { type: 'SET_SESSION_LOAD_ERROR'; payload: string | undefined } - | { type: 'SET_TOKEN_STATE'; payload: TokenState } - | { type: 'SYNC_FROM_ACP_STORE'; payload: AcpChatSessionSnapshot } - | { type: 'RESET_FOR_NEW_SESSION' } - | { type: 'START_STREAMING' } - | { type: 'STREAM_ERROR'; payload: string } - | { type: 'STREAM_FINISH'; payload?: string }; + acpChatSessionActions, + acpChatSessionStore, + useAcpChatSessionSnapshot, +} from '../acp/chatSessionStore'; const initialTokenState: TokenState = { inputTokens: 0, @@ -58,100 +25,10 @@ const initialTokenState: TokenState = { accumulatedTotalTokens: 0, }; -const initialState: StreamState = { - messages: [], - session: undefined, - chatState: ChatState.Idle, - sessionLoadError: undefined, - tokenState: initialTokenState, - notifications: [], -}; - -function streamReducer(state: StreamState, action: StreamAction): StreamState { - switch (action.type) { - case 'SET_MESSAGES': - return { ...state, messages: action.payload }; - - case 'SET_SESSION': - return { ...state, session: action.payload }; - - case 'SET_CHAT_STATE': - return { ...state, chatState: action.payload }; - - case 'SET_SESSION_LOAD_ERROR': - return { ...state, sessionLoadError: action.payload }; - - case 'SET_TOKEN_STATE': - return { ...state, tokenState: action.payload }; - - case 'SYNC_FROM_ACP_STORE': - return { - ...state, - session: action.payload.session, - messages: action.payload.messages, - tokenState: action.payload.tokenState, - notifications: action.payload.notifications, - chatState: action.payload.chatState, - sessionLoadError: action.payload.sessionLoadError, - }; - - case 'RESET_FOR_NEW_SESSION': - return { - ...state, - messages: [], - session: undefined, - sessionLoadError: undefined, - notifications: [], - chatState: ChatState.LoadingConversation, - }; - - case 'START_STREAMING': - return { - ...state, - chatState: ChatState.Streaming, - notifications: [], - }; - - case 'STREAM_ERROR': - return { - ...state, - sessionLoadError: action.payload, - chatState: ChatState.Idle, - }; - - case 'STREAM_FINISH': - return { - ...state, - sessionLoadError: action.payload, - chatState: ChatState.Idle, - }; - - default: - return state; - } -} - function isClearCommand(message: string): boolean { return message.trim() === '/clear'; } -function createAcpCreditsExhaustedMessage(error: AcpCreditsExhaustedError): Message { - return { - id: uuidv7(), - role: 'assistant', - created: Math.floor(Date.now() / 1000), - content: [ - { - type: 'systemNotification', - notificationType: 'creditsExhausted', - msg: error.message, - ...(error.url ? { data: { top_up_url: error.url } } : {}), - }, - ], - metadata: { userVisible: true, agentVisible: false }, - }; -} - const i18n = defineMessages({ notificationTitle: { id: 'chat.notification.taskComplete.title', @@ -169,38 +46,35 @@ export function useAcpChatSession({ onSessionLoaded, }: UseChatSessionParams): UseChatSessionResult { const intl = useIntl(); - const [state, dispatch] = useReducer(streamReducer, initialState); + const acpSnapshot = useAcpChatSessionSnapshot(sessionId); + const messages = acpSnapshot?.messages ?? []; + const session = acpSnapshot?.session; + const chatState = acpSnapshot?.chatState ?? ChatState.LoadingConversation; + const sessionLoadError = acpSnapshot?.sessionLoadError; + const tokenState = acpSnapshot?.tokenState ?? initialTokenState; - // Ref to access latest state in callbacks (avoids stale closures) - const stateRef = useRef(state); - stateRef.current = state; + const snapshotRef = useRef(acpSnapshot); + snapshotRef.current = acpSnapshot; - useEffect(() => { - if (!sessionId) { - return; - } - - const snapshot = acpChatSessionStore.getSnapshot(sessionId); - if (snapshot) { - dispatch({ type: 'SYNC_FROM_ACP_STORE', payload: snapshot }); - } - - return acpChatSessionStore.subscribe(sessionId, (nextSnapshot) => { - dispatch({ type: 'SYNC_FROM_ACP_STORE', payload: nextSnapshot }); - }); - }, [sessionId]); + const getCurrentSnapshot = useCallback( + () => snapshotRef.current ?? acpChatSessionStore.getSnapshot(sessionId), + [sessionId] + ); useEffect(() => { const handleSessionRenamed = (event: Event) => { - const { sessionId: renamedSessionId, newName, userInitiated } = ( - event as CustomEvent<{ sessionId: string; newName: string; userInitiated?: boolean }> - ).detail; + const { + sessionId: renamedSessionId, + newName, + userInitiated, + } = (event as CustomEvent<{ sessionId: string; newName: string; userInitiated?: boolean }>) + .detail; if (renamedSessionId !== sessionId) { return; } - const currentSession = stateRef.current.session; + const currentSession = getCurrentSnapshot()?.session; if (!currentSession || (currentSession.name === newName && !userInitiated)) { return; } @@ -210,20 +84,15 @@ export function useAcpChatSession({ name: newName, ...(userInitiated && { user_set_name: true }), }; - acpChatSessionStore.setSessionMetadata(sessionId, updatedSession); - dispatch({ type: 'SET_SESSION', payload: updatedSession }); + acpChatSessionActions.setSessionMetadata(sessionId, updatedSession); }; window.addEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed); return () => window.removeEventListener(AppEvents.SESSION_RENAMED, handleSessionRenamed); - }, [sessionId]); + }, [getCurrentSnapshot, sessionId]); const onFinish = useCallback( async (error?: string): Promise => { - acpChatSessionStore.setSessionLoadError(sessionId, error); - acpChatSessionStore.setChatState(sessionId, ChatState.Idle); - dispatch({ type: 'STREAM_FINISH', payload: error }); - if (!error) { try { const [notificationsEnabled, anyWindowFocused] = await Promise.all([ @@ -241,148 +110,48 @@ export function useAcpChatSession({ } } - const isNewSession = sessionId && sessionId.match(/^\d{8}_\d{6}$/); - if (isNewSession) { - window.dispatchEvent(new CustomEvent(AppEvents.MESSAGE_STREAM_FINISHED)); - } - onStreamFinish(); }, - [intl, onStreamFinish, sessionId] + [intl, onStreamFinish] ); const submitToAcpSession = useCallback( async (targetSessionId: string, userMessage: Message) => { - const promptAttemptId = uuidv7(); - acpChatSessionStore.startPromptAttempt(targetSessionId, promptAttemptId); - - try { - await acpPromptSession(targetSessionId, userMessage); - if (acpChatSessionStore.finishPromptAttemptIfCurrent(targetSessionId, promptAttemptId)) { - onFinish(); - } - } catch (error) { - const creditsExhaustedError = parseAcpCreditsExhaustedError(error); - if (creditsExhaustedError) { - if (!acpChatSessionStore.isCurrentPromptAttempt(targetSessionId, promptAttemptId)) { - return; - } - - const messages = [ - ...stateRef.current.messages, - createAcpCreditsExhaustedMessage(creditsExhaustedError), - ]; - acpChatSessionStore.setMessages(targetSessionId, messages); - dispatch({ - type: 'SET_MESSAGES', - payload: messages, - }); - if (acpChatSessionStore.finishPromptAttemptIfCurrent(targetSessionId, promptAttemptId)) { - onFinish(); - } - return; - } - - const submitError = 'Submit error: ' + errorMessage(error); - if ( - acpChatSessionStore.finishPromptAttemptIfCurrent( - targetSessionId, - promptAttemptId, - submitError - ) - ) { - onFinish(submitError); - } - } + await acpChatSessionController.submitMessage(targetSessionId, userMessage, { + getCurrentSnapshot: () => + targetSessionId === sessionId + ? getCurrentSnapshot() + : acpChatSessionStore.getSnapshot(targetSessionId), + onFinish, + }); }, - [onFinish] + [getCurrentSnapshot, onFinish, sessionId] ); // Load session on mount or sessionId change useEffect(() => { if (!sessionId) return; - const cached = acpChatSessionStore.getSnapshot(sessionId); - if (cached?.session) { - dispatch({ type: 'SYNC_FROM_ACP_STORE', payload: cached }); - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_EXTENSIONS_LOADED, { detail: { sessionId } }) - ); - onSessionLoaded?.(); - return; - } - - dispatch({ type: 'RESET_FOR_NEW_SESSION' }); - - let cancelled = false; - - (async () => { - try { - const response = await resumeAgent({ - body: { - session_id: sessionId, - load_model_and_extensions: true, - }, - throwOnError: true, - }); - - if (cancelled) { - return; - } - - const resumeData = response.data; - const loadedSession = resumeData?.session; - const extensionResults = resumeData?.extension_results; - - showExtensionLoadResults(extensionResults); - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_EXTENSIONS_LOADED, { detail: { sessionId } }) - ); - - if (loadedSession) { - const snapshot = acpChatSessionStore.setLoadedSession(sessionId, loadedSession); - dispatch({ type: 'SYNC_FROM_ACP_STORE', payload: snapshot }); - } - - listApps({ - throwOnError: true, - query: { session_id: sessionId }, - }).catch((err) => { - console.warn('Failed to populate apps cache:', err); - }); - - onSessionLoaded?.(); - } catch (error) { - if (cancelled) return; - - const loadError = errorMessage(error); - acpChatSessionStore.setSessionLoadError(sessionId, loadError); - acpChatSessionStore.setChatState(sessionId, ChatState.Idle); - dispatch({ type: 'STREAM_ERROR', payload: loadError }); - } - })(); - - return () => { - cancelled = true; - }; + void acpChatSessionController.loadSession(sessionId, { onSessionLoaded }); }, [sessionId, onSessionLoaded]); const handleSubmit = useCallback( async (input: UserInput) => { const { msg: userMessage, images } = input; - const currentState = stateRef.current; + const currentSnapshot = getCurrentSnapshot(); if ( - !currentState.session || - currentState.chatState === ChatState.LoadingConversation || - currentState.chatState === ChatState.Streaming || - currentState.chatState === ChatState.Thinking || - currentState.chatState === ChatState.Compacting + !currentSnapshot?.session || + currentSnapshot.chatState === ChatState.LoadingConversation || + currentSnapshot.chatState === ChatState.Streaming || + currentSnapshot.chatState === ChatState.Thinking || + currentSnapshot.chatState === ChatState.Compacting ) { return; } - const hasExistingMessages = currentState.messages.length > 0; + const currentMessages = currentSnapshot.messages; + const hasExistingMessages = currentMessages.length > 0; const hasNewMessage = userMessage.trim().length > 0 || images.length > 0; const clearsConversation = hasNewMessage && isClearCommand(userMessage); @@ -397,31 +166,30 @@ export function useAcpChatSession({ const newMessage = hasNewMessage ? createUserMessage(userMessage, images) - : currentState.messages[currentState.messages.length - 1]; - const currentMessages = clearsConversation + : currentMessages[currentMessages.length - 1]; + const messagesForStore = clearsConversation ? [] : hasNewMessage - ? [...currentState.messages, newMessage] - : [...currentState.messages]; + ? [...currentMessages, newMessage] + : [...currentMessages]; if (clearsConversation || hasNewMessage) { - acpChatSessionStore.setMessages(sessionId, currentMessages); - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); + acpChatSessionActions.setMessages(sessionId, messagesForStore); } - acpChatSessionStore.setChatState(sessionId, ChatState.Streaming); - dispatch({ type: 'START_STREAMING' }); - await submitToAcpSession(sessionId, newMessage); }, - [sessionId, submitToAcpSession] + [getCurrentSnapshot, sessionId, submitToAcpSession] ); const submitElicitationResponse = useCallback( async (elicitationId: string, userData: Record) => { - const currentState = stateRef.current; + const currentSnapshot = getCurrentSnapshot(); - if (!currentState.session || currentState.chatState === ChatState.LoadingConversation) { + if ( + !currentSnapshot?.session || + currentSnapshot.chatState === ChatState.LoadingConversation + ) { return false; } @@ -432,125 +200,41 @@ export function useAcpChatSession({ return true; }, - [sessionId] + [getCurrentSnapshot, sessionId] ); const setRecipeUserParams = useCallback( async (user_recipe_values: Record) => { - const currentState = stateRef.current; - - if (currentState.session) { - await updateSessionUserRecipeValues({ - path: { - session_id: sessionId, - }, - body: { - userRecipeValues: user_recipe_values, - }, - throwOnError: true, - }); - const updatedSession = { - ...currentState.session, - user_recipe_values, - }; - acpChatSessionStore.setSessionMetadata(sessionId, updatedSession); - dispatch({ type: 'SET_SESSION', payload: updatedSession }); - } else { - acpChatSessionStore.setSessionLoadError( - sessionId, - "can't call setRecipeParams without a session" - ); - dispatch({ - type: 'SET_SESSION_LOAD_ERROR', - payload: "can't call setRecipeParams without a session", - }); - } + await acpChatSessionController.setRecipeUserParams(sessionId, user_recipe_values, { + getCurrentSnapshot, + }); }, - [sessionId] + [getCurrentSnapshot, sessionId] ); useEffect(() => { - if (state.session) { + if (session) { updateFromSession({ body: { - session_id: state.session.id, + session_id: session.id, }, throwOnError: true, }); } - }, [state.session]); + }, [session]); const stopStreaming = useCallback(() => { - const storedPromptAttemptId = acpChatSessionStore.getSnapshot(sessionId)?.activePromptAttemptId; - const hasStoredAcpPrompt = - storedPromptAttemptId !== null && storedPromptAttemptId !== undefined; - - if (hasStoredAcpPrompt) { - acpChatSessionStore.clearActivePromptAttempt(sessionId); - cancelAcpPermissionRequestsForSession(sessionId); - cancelAcpElicitationRequestsForSession(sessionId); - acpCancelPrompt(sessionId).catch((e) => { - console.warn('Failed to cancel ACP prompt:', e); - }); - } - - acpChatSessionStore.setChatState(sessionId, ChatState.Idle); - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); + acpChatSessionController.stop(sessionId); }, [sessionId]); const onMessageUpdate = useCallback( async (messageId: string, newContent: string, editType: 'fork' | 'edit' = 'fork') => { - const currentState = stateRef.current; - - acpChatSessionStore.setChatState(sessionId, ChatState.Thinking); - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Thinking }); - try { - const message = currentState.messages.find((m) => m.id === messageId); - - if (!message) { - throw new Error(`Message with id ${messageId} not found in current messages`); - } - - if (editType === 'fork') { - const targetSessionId = await acpForkSession(sessionId, message.created); - - acpChatSessionStore.setChatState(sessionId, ChatState.Idle); - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); - const event = new CustomEvent(AppEvents.SESSION_FORKED, { - detail: { - newSessionId: targetSessionId, - shouldStartAgent: true, - editedMessage: newContent, - }, - }); - window.dispatchEvent(event); - window.electron.logInfo(`Dispatched session-forked event for session ${targetSessionId}`); - } else { - await acpTruncateSessionConversation(sessionId, message.created); - - const truncatedMessages = currentState.messages.filter( - (m) => m.created < message.created - ); - const updatedUserMessage = createUserMessage(newContent); - - for (const content of message.content) { - if (content.type === 'image') { - updatedUserMessage.content.push(content); - } - } - - const messagesForUI = [...truncatedMessages, updatedUserMessage]; - acpChatSessionStore.setMessages(sessionId, messagesForUI); - acpChatSessionStore.setChatState(sessionId, ChatState.Streaming); - dispatch({ type: 'SET_MESSAGES', payload: messagesForUI }); - dispatch({ type: 'START_STREAMING' }); - - await submitToAcpSession(sessionId, updatedUserMessage); - } + await acpChatSessionController.updateMessage(sessionId, messageId, newContent, editType, { + getCurrentSnapshot, + onFinish, + }); } catch (error) { - acpChatSessionStore.setChatState(sessionId, ChatState.Idle); - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); const errorMsg = errorMessage(error); console.error('Failed to edit message:', error); const { toastError } = await import('../toasts'); @@ -560,36 +244,28 @@ export function useAcpChatSession({ }); } }, - [sessionId, submitToAcpSession] + [getCurrentSnapshot, onFinish, sessionId] ); const setChatState = useCallback( (newState: ChatState) => { - acpChatSessionStore.setChatState(sessionId, newState); - dispatch({ type: 'SET_CHAT_STATE', payload: newState }); + acpChatSessionActions.setChatState(sessionId, newState); }, [sessionId] ); const updateSession = useCallback( (updater: (session: Session) => Session) => { - const cached = acpChatSessionStore.getSnapshot(sessionId); - const currentSession = stateRef.current.session ?? cached?.session; + const currentSession = getCurrentSnapshot()?.session; if (!currentSession) return; const nextSession = updater(currentSession); - acpChatSessionStore.setSessionMetadata(sessionId, nextSession); - dispatch({ type: 'SET_SESSION', payload: nextSession }); + acpChatSessionActions.setSessionMetadata(sessionId, nextSession); }, - [sessionId] + [getCurrentSnapshot, sessionId] ); - - const cached = acpChatSessionStore.getSnapshot(sessionId); - const maybe_cached_messages = state.session ? state.messages : cached?.messages || []; - const maybe_cached_session = state.session ?? cached?.session; - const notificationsMap = useMemo(() => { - return state.notifications.reduce((map, notification) => { + return (acpSnapshot?.notifications ?? []).reduce((map, notification) => { const key = notification.request_id; if (!map.has(key)) { map.set(key, []); @@ -597,20 +273,20 @@ export function useAcpChatSession({ map.get(key)!.push(notification); return map; }, new Map()); - }, [state.notifications]); + }, [acpSnapshot?.notifications]); return { - sessionLoadError: state.sessionLoadError, - messages: maybe_cached_messages, - session: maybe_cached_session, - chatState: state.chatState, + sessionLoadError, + messages, + session, + chatState, setChatState, updateSession, handleSubmit, submitElicitationResponse, stopStreaming, setRecipeUserParams, - tokenState: state.tokenState, + tokenState, notifications: notificationsMap, pauseQueueOnStop: true, onMessageUpdate,