feat (ui): load session using acp (#9875)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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') }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user