feat (ui): load session using acp (#9875)

This commit is contained in:
Lifei Zhou
2026-06-19 19:20:01 +10:00
committed by GitHub
parent e223de2e26
commit 9785d063c1
20 changed files with 972 additions and 589 deletions
+4
View File
@@ -41,6 +41,10 @@ pub(super) fn session_meta(session: &Session) -> serde_json::Map<String, serde_j
"userSetName".to_string(),
serde_json::Value::Bool(session.user_set_name),
);
meta.insert(
"sessionType".to_string(),
serde_json::Value::String(session.session_type.to_string()),
);
meta.insert(
"hasRecipe".to_string(),
serde_json::Value::Bool(session.recipe.is_some()),
@@ -83,6 +83,10 @@ pub async fn run_list_sessions<C: Connection>() {
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,
+1
View File
@@ -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)));
});
}
@@ -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()
);
@@ -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<ReturnType<typeof acpLoadSession>>;
}
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()
);
});
});
@@ -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<string>();
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]);
});
});
@@ -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'
@@ -11,7 +11,7 @@ vi.mock('../../acpChatFeatureFlag', () => ({
}));
vi.mock('../chatSessionStore', () => ({
acpChatSessionStore: {
acpChatSessionActions: {
applyPermissionRequest: vi.fn(),
},
}));
@@ -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> = {}): 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<ReturnType<typeof getAcpClient>>
);
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'
);
});
});
+3 -3
View File
@@ -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<void> {
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<void> {
if (USE_ACP_CHAT) {
acpChatSessionStore.applyAcpGooseSessionNotification(notification);
acpChatSessionActions.applyAcpGooseSessionNotification(notification);
}
return Promise.resolve();
}
+255
View File
@@ -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<void>;
}
export interface AcpChatSessionController {
loadSession(sessionId: string, options?: AcpLoadSessionOptions): Promise<void>;
submitMessage(
sessionId: string,
userMessage: Message,
options: AcpSubmitMessageOptions
): Promise<void>;
stop(sessionId: string): void;
updateMessage(
sessionId: string,
messageId: string,
newContent: string,
editType: 'fork' | 'edit' | undefined,
options: AcpSubmitMessageOptions
): Promise<void>;
setRecipeUserParams(
sessionId: string,
userRecipeValues: Record<string, string>,
options: AcpSnapshotOptions
): Promise<void>;
}
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<void> {
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<void> {
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<void> {
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<string, string>,
options: AcpSnapshotOptions
): Promise<void> {
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,
};
+143 -69
View File
@@ -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<string, StoreEntry>();
const listenersBySessionId = new Map<string, Set<SnapshotListener>>();
@@ -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<SnapshotListener>();
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<AcpChatSessionSnapshotState>(() => ({
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,
+5 -5
View File
@@ -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());
}
}
+2 -2
View File
@@ -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<RequestPermissionResponse>((resolve) => {
pendingRequests.set(key, { request, resolve });
acpChatSessionStore.applyPermissionRequest(request);
acpChatSessionActions.applyPermissionRequest(request);
});
}
+105 -1
View File
@@ -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<string, string> | null;
extensionResults?: ExtensionLoadResult[] | null;
workingDir?: string;
}
export interface AcpLoadSessionResult {
sessionInfo: SessionInfo;
response: LoadSessionResponse;
meta: LoadSessionMeta;
}
const inFlightSessionLoads = new Map<string, Promise<AcpLoadSessionResult>>();
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<Sessio
return response.sessions.slice(0, maxSessions).map(sessionInfoToListItem);
}
export async function acpLoadSession(sessionId: string): Promise<AcpLoadSessionResult> {
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<AcpLoadSessionResult> {
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<void> {
const client = await getAcpClient();
await client.goose.sessionDelete({ sessionId });
+9
View File
@@ -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}
/>
+10 -22
View File
@@ -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<number>(TOKEN_LIMIT_DEFAULT);
const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [sessionWorkingDir, setSessionWorkingDir] = useState<string | null>(null);
const [workingDirOverride, setWorkingDirOverride] = useState<string | null>(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({
<DirSwitcher
className=""
sessionId={sessionId ?? undefined}
workingDir={sessionWorkingDir ?? getInitialWorkingDir()}
workingDir={currentWorkingDir}
onWorkingDirChange={(newDir) => {
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}
/>
</div>
</div>
@@ -98,13 +98,13 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({
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));
@@ -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<SessionListViewProps> = 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') }));
+83 -407
View File
@@ -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<void> => {
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<string, unknown>) => {
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<string, string>) => {
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<string, NotificationEvent[]>());
}, [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,