diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs index 1107f8333..d5dc8729b 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -491,6 +491,18 @@ pub struct GetSessionInfoResponse { pub session: SessionInfo, } +/// Truncate a session conversation from the given message timestamp onward. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/session/conversation/truncate", + response = EmptyResponse +)] +#[serde(rename_all = "camelCase")] +pub struct TruncateSessionConversationRequest { + pub session_id: String, + pub truncate_from: i64, +} + /// Update the project association for a session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/project/update", response = EmptyResponse)] diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index a713ed632..2c4ed6ecd 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -200,6 +200,11 @@ "requestType": "GetSessionInfoRequest_unstable", "responseType": "GetSessionInfoResponse_unstable" }, + { + "method": "_goose/unstable/session/conversation/truncate", + "requestType": "TruncateSessionConversationRequest_unstable", + "responseType": "EmptyResponse" + }, { "method": "_goose/unstable/session/project/update", "requestType": "UpdateSessionProjectRequest_unstable", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 9a9bdcd32..4fa7028d9 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -2768,6 +2768,24 @@ "type": "string", "description": "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)" }, + "TruncateSessionConversationRequest_unstable": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "truncateFrom": { + "type": "integer" + } + }, + "required": [ + "sessionId", + "truncateFrom" + ], + "description": "Truncate a session conversation from the given message timestamp onward.", + "x-side": "agent", + "x-method": "_goose/unstable/session/conversation/truncate" + }, "UpdateSessionProjectRequest_unstable": { "type": "object", "properties": { @@ -4043,6 +4061,15 @@ "description": "Params for _goose/unstable/session/info", "title": "GetSessionInfoRequest_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/TruncateSessionConversationRequest_unstable" + } + ], + "description": "Params for _goose/unstable/session/conversation/truncate", + "title": "TruncateSessionConversationRequest_unstable" + }, { "allOf": [ { diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index 4a069b909..ed45793b9 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -329,6 +329,14 @@ impl GooseAcpAgent { self.on_get_session_info(req).await } + #[custom_method(TruncateSessionConversationRequest)] + async fn dispatch_truncate_session_conversation( + &self, + req: TruncateSessionConversationRequest, + ) -> Result { + self.on_truncate_session_conversation(req).await + } + #[custom_method(UpdateSessionProjectRequest)] async fn dispatch_update_session_project( &self, diff --git a/crates/goose/src/acp/server/manage_sessions.rs b/crates/goose/src/acp/server/manage_sessions.rs index 6ba9309a6..15fd717bf 100644 --- a/crates/goose/src/acp/server/manage_sessions.rs +++ b/crates/goose/src/acp/server/manage_sessions.rs @@ -142,6 +142,24 @@ impl GooseAcpAgent { }) } + pub(super) async fn on_truncate_session_conversation( + &self, + req: TruncateSessionConversationRequest, + ) -> Result { + let session_id = req.session_id.trim(); + if session_id.is_empty() { + return Err( + agent_client_protocol::Error::invalid_params().data("sessionId cannot be empty") + ); + } + + self.session_manager + .truncate_conversation(session_id, req.truncate_from) + .await + .internal_err()?; + Ok(EmptyResponse {}) + } + pub(super) async fn on_update_session_project( &self, req: UpdateSessionProjectRequest, diff --git a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts new file mode 100644 index 000000000..48694cc38 --- /dev/null +++ b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts @@ -0,0 +1,247 @@ +import type { CreateElicitationRequest, RequestPermissionRequest } from '@agentclientprotocol/sdk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Message, Session } from '../../api'; +import { ChatState } from '../../types/chatState'; +import { createAcpChatSessionStore, type AcpChatSessionStore } from '../chatSessionStore'; + +function message(id: string, text: string): Message { + return { + id, + role: 'user', + created: 123, + content: [{ type: 'text', text }], + metadata: { userVisible: true, agentVisible: true }, + }; +} + +function session(id: string, conversation: Message[] = []): Session { + return { + id, + name: `Session ${id}`, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + working_dir: '/tmp', + message_count: conversation.length, + extension_data: {}, + source: 'test', + conversation, + input_tokens: 1, + output_tokens: 2, + total_tokens: 3, + accumulated_input_tokens: 4, + accumulated_output_tokens: 5, + accumulated_total_tokens: 9, + } as Session; +} + +function permissionRequest(sessionId: string, toolCallId = 'tool-1'): RequestPermissionRequest { + return { + sessionId, + options: [{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }], + toolCall: { + toolCallId, + title: 'Edit file', + rawInput: { path: 'README.md' }, + content: [ + { + type: 'content', + content: { type: 'text', text: 'Allow editing README.md?' }, + }, + ], + _meta: { + goose: { + toolCall: { + toolName: 'edit_file', + }, + }, + }, + }, + }; +} + +function elicitationRequest(sessionId: string): { + id: string; + sessionId: string; + request: CreateElicitationRequest & { + mode: 'form'; + sessionId: string; + }; +} { + return { + id: 'acp_elicitation_1', + sessionId, + request: { + mode: 'form', + sessionId, + message: 'Choose a project', + requestedSchema: { + type: 'object', + properties: { + project: { + type: 'string', + }, + }, + }, + }, + }; +} + +describe('acpChatSessionStore', () => { + let store: AcpChatSessionStore; + + beforeEach(() => { + store = createAcpChatSessionStore(); + }); + + it('stores loaded session messages and token state', () => { + const initialMessage = message('message-1', 'Hello'); + + const snapshot = store.setLoadedSession('session-1', session('session-1', [initialMessage])); + + expect(snapshot.session?.id).toBe('session-1'); + expect(snapshot.messages).toEqual([initialMessage]); + expect(snapshot.tokenState).toMatchObject({ + inputTokens: 1, + outputTokens: 2, + totalTokens: 3, + accumulatedInputTokens: 4, + accumulatedOutputTokens: 5, + accumulatedTotalTokens: 9, + }); + 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')]); + + expect(store.getSnapshot('session-1')?.messages[0].id).toBe('message-1'); + expect(store.getSnapshot('session-2')?.messages[0].id).toBe('message-2'); + }); + + it('deletes session snapshots', () => { + store.setMessages('session-1', [message('message-1', 'One')]); + + store.deleteSnapshot('session-1'); + + expect(store.getSnapshot('session-1')).toBeUndefined(); + }); + + 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(); + }); + + it('ignores stale prompt attempts and leaves the current attempt active', () => { + store.startPromptAttempt('session-1', 'attempt-a'); + store.startPromptAttempt('session-1', 'attempt-b'); + + expect(store.finishPromptAttemptIfCurrent('session-1', 'attempt-a', 'late error')).toBe(false); + + expect(store.getSnapshot('session-1')).toMatchObject({ + activePromptAttemptId: 'attempt-b', + chatState: ChatState.Streaming, + sessionLoadError: undefined, + }); + + expect(store.finishPromptAttemptIfCurrent('session-1', 'attempt-b')).toBe(true); + expect(store.getSnapshot('session-1')).toMatchObject({ + activePromptAttemptId: null, + chatState: ChatState.Idle, + }); + }); + + it('keeps loaded sessions streaming when a prompt attempt is active', () => { + store.startPromptAttempt('session-1', 'attempt-1'); + + const snapshot = store.setLoadedSession('session-1', session('session-1')); + + expect(snapshot.activePromptAttemptId).toBe('attempt-1'); + expect(snapshot.chatState).toBe(ChatState.Streaming); + }); + + it('applies permission requests as waiting action-required messages', () => { + const snapshot = store.applyPermissionRequest(permissionRequest('session-1', 'tool-1')); + + expect(snapshot.chatState).toBe(ChatState.WaitingForUserInput); + expect(snapshot.messages).toHaveLength(1); + expect(snapshot.messages[0].role).toBe('assistant'); + expect(snapshot.messages[0].content[0]).toMatchObject({ + type: 'actionRequired', + data: { + actionType: 'toolConfirmation', + id: 'tool-1', + }, + }); + }); + + it('applies elicitation requests as waiting action-required messages', () => { + const snapshot = store.applyElicitationRequest(elicitationRequest('session-1')); + + expect(snapshot.chatState).toBe(ChatState.WaitingForUserInput); + expect(snapshot.messages).toHaveLength(1); + expect(snapshot.messages[0].role).toBe('assistant'); + expect(snapshot.messages[0].content[0]).toMatchObject({ + type: 'actionRequired', + data: { + actionType: 'elicitation', + id: 'acp_elicitation_1', + message: 'Choose a project', + }, + }); + }); + + it('stores submitted elicitation status', () => { + store.applyElicitationRequest(elicitationRequest('session-1')); + + const snapshot = store.setElicitationStatus('session-1', 'acp_elicitation_1', 'submitted'); + + expect(snapshot?.messages[0].content[0]).toMatchObject({ + type: 'actionRequired', + data: { + actionType: 'elicitation', + id: 'acp_elicitation_1', + isSubmitted: true, + isCancelled: false, + }, + }); + }); + + it('stores cancelled elicitation status', () => { + store.applyElicitationRequest(elicitationRequest('session-1')); + + const snapshot = store.setElicitationStatus('session-1', 'acp_elicitation_1', 'cancelled'); + + expect(snapshot?.messages[0].content[0]).toMatchObject({ + type: 'actionRequired', + data: { + actionType: 'elicitation', + id: 'acp_elicitation_1', + isSubmitted: false, + isCancelled: true, + }, + }); + }); +}); diff --git a/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts b/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts new file mode 100644 index 000000000..cadd94f03 --- /dev/null +++ b/ui/desktop/src/acp/__tests__/elicitationRequests.test.ts @@ -0,0 +1,156 @@ +import type { CreateElicitationRequest, CreateElicitationResponse } from '@agentclientprotocol/sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ACP_ELICITATION_TIMEOUT_SECONDS, + cancelAcpElicitationRequestsForSession, + requestAcpElicitation, + resolveAcpElicitationRequest, +} from '../elicitationRequests'; +import { acpChatSessionStore } from '../chatSessionStore'; + +vi.mock('../../acpChatFeatureFlag', () => ({ + USE_ACP_CHAT: true, +})); + +vi.mock('../chatSessionStore', () => ({ + acpChatSessionStore: { + applyElicitationRequest: vi.fn(), + setElicitationStatus: vi.fn(), + }, +})); + +const TEST_SESSION_IDS = ['session-1', 'session-2']; + +function formRequest(sessionId: string): CreateElicitationRequest { + return { + mode: 'form', + sessionId, + message: 'Choose a project', + requestedSchema: { + type: 'object', + properties: { + project: { + type: 'string', + }, + }, + required: ['project'], + }, + }; +} + +async function expectStillPending(promise: Promise): Promise { + let settled = false; + promise.then( + () => { + settled = true; + }, + () => { + settled = true; + } + ); + + await Promise.resolve(); + + expect(settled).toBe(false); +} + +describe('ACP elicitation requests', () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const sessionId of TEST_SESSION_IDS) { + cancelAcpElicitationRequestsForSession(sessionId); + } + }); + + afterEach(() => { + for (const sessionId of TEST_SESSION_IDS) { + cancelAcpElicitationRequestsForSession(sessionId); + } + }); + + it('keeps form requests pending until explicit resolve', async () => { + const response = requestAcpElicitation(formRequest('session-1')); + + await expectStillPending(response); + + const appliedRequest = vi.mocked(acpChatSessionStore.applyElicitationRequest).mock.calls[0][0]; + + expect(appliedRequest.id).toMatch(/^acp_elicitation_/); + expect(appliedRequest.sessionId).toBe('session-1'); + expect(appliedRequest.request.message).toBe('Choose a project'); + + expect( + resolveAcpElicitationRequest('session-1', appliedRequest.id, { + project: 'goose', + }) + ).toBe(true); + expect(acpChatSessionStore.setElicitationStatus).toHaveBeenCalledWith( + 'session-1', + appliedRequest.id, + 'submitted' + ); + + await expect(response).resolves.toEqual({ + action: 'accept', + content: { + project: 'goose', + }, + }); + }); + + it('cancels unsupported requests', async () => { + await expect( + requestAcpElicitation({ + mode: 'url', + requestId: 'request-1', + elicitationId: 'elicitation-1', + message: 'Open this page', + url: 'https://example.com', + }) + ).resolves.toEqual({ action: 'cancel' }); + }); + + it('cancels only pending requests for the requested session', async () => { + const sessionOneResponse = requestAcpElicitation(formRequest('session-1')); + const sessionTwoResponse = requestAcpElicitation(formRequest('session-2')); + + const applyElicitationRequest = vi.mocked(acpChatSessionStore.applyElicitationRequest); + const sessionOneRequest = applyElicitationRequest.mock.calls[0][0]; + const sessionTwoRequest = applyElicitationRequest.mock.calls[1][0]; + + cancelAcpElicitationRequestsForSession('session-1'); + + expect(acpChatSessionStore.setElicitationStatus).toHaveBeenCalledWith( + 'session-1', + sessionOneRequest.id, + 'cancelled' + ); + await expect(sessionOneResponse).resolves.toEqual({ action: 'cancel' }); + await expectStillPending(sessionTwoResponse); + + expect(resolveAcpElicitationRequest('session-2', sessionTwoRequest.id, {})).toBe(true); + await expect(sessionTwoResponse).resolves.toEqual({ + action: 'accept', + content: {}, + }); + expect(resolveAcpElicitationRequest('session-1', sessionOneRequest.id, {})).toBe(false); + }); + + it('cancels pending requests when they expire', async () => { + vi.useFakeTimers(); + try { + const response = requestAcpElicitation(formRequest('session-1')); + const appliedRequest = vi.mocked(acpChatSessionStore.applyElicitationRequest).mock + .calls[0][0]; + + await expectStillPending(response); + + await vi.advanceTimersByTimeAsync(ACP_ELICITATION_TIMEOUT_SECONDS * 1000); + + await expect(response).resolves.toEqual({ action: 'cancel' }); + expect(resolveAcpElicitationRequest('session-1', appliedRequest.id, {})).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/ui/desktop/src/acp/__tests__/permissionRequests.test.ts b/ui/desktop/src/acp/__tests__/permissionRequests.test.ts new file mode 100644 index 000000000..b2a4856db --- /dev/null +++ b/ui/desktop/src/acp/__tests__/permissionRequests.test.ts @@ -0,0 +1,126 @@ +import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + cancelAcpPermissionRequestsForSession, + requestAcpPermission, + resolveAcpPermissionRequest, +} from '../permissionRequests'; + +vi.mock('../../acpChatFeatureFlag', () => ({ + USE_ACP_CHAT: true, +})); + +vi.mock('../chatSessionStore', () => ({ + acpChatSessionStore: { + applyPermissionRequest: vi.fn(), + }, +})); + +function permissionRequest(sessionId: string, toolCallId: string): RequestPermissionRequest { + return { + sessionId, + options: [ + { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Deny once', kind: 'reject_once' }, + ], + toolCall: { + toolCallId, + title: 'Read file', + rawInput: { path: 'README.md' }, + content: [ + { + type: 'content', + content: { type: 'text', text: 'Allow reading README.md?' }, + }, + ], + }, + }; +} + +const TEST_SESSION_IDS = ['session-1', 'session-2']; + +async function expectStillPending(promise: Promise): Promise { + let settled = false; + promise.then( + () => { + settled = true; + }, + () => { + settled = true; + } + ); + + await Promise.resolve(); + + expect(settled).toBe(false); +} + +describe('ACP permission requests', () => { + beforeEach(() => { + for (const sessionId of TEST_SESSION_IDS) { + cancelAcpPermissionRequestsForSession(sessionId); + } + }); + + afterEach(() => { + for (const sessionId of TEST_SESSION_IDS) { + cancelAcpPermissionRequestsForSession(sessionId); + } + }); + + it('keeps permission requests pending until explicit resolve', async () => { + const response = requestAcpPermission(permissionRequest('session-1', 'tool-1')); + + await expectStillPending(response); + + expect(resolveAcpPermissionRequest('session-1', 'tool-1', 'allow_once')).toBe(true); + await expect(response).resolves.toEqual({ + outcome: { + outcome: 'selected', + optionId: 'allow-once', + }, + }); + }); + + it('cancels only pending requests for the requested session', async () => { + const sessionOneResponse = requestAcpPermission(permissionRequest('session-1', 'tool-1')); + const sessionTwoResponse = requestAcpPermission(permissionRequest('session-2', 'tool-2')); + + cancelAcpPermissionRequestsForSession('session-1'); + + await expect(sessionOneResponse).resolves.toEqual({ + outcome: { + outcome: 'cancelled', + }, + }); + await expectStillPending(sessionTwoResponse); + + expect(resolveAcpPermissionRequest('session-2', 'tool-2', 'deny_once')).toBe(true); + await expect(sessionTwoResponse).resolves.toEqual({ + outcome: { + outcome: 'selected', + optionId: 'reject-once', + }, + }); + }); + + it('cancels an older duplicate request for the same session and tool call', async () => { + const firstResponse = requestAcpPermission(permissionRequest('session-1', 'tool-1')); + const secondResponse = requestAcpPermission(permissionRequest('session-1', 'tool-1')); + + await expect(firstResponse).resolves.toEqual({ + outcome: { + outcome: 'cancelled', + }, + }); + await expectStillPending(secondResponse); + + expect(resolveAcpPermissionRequest('session-1', 'tool-1', 'allow_once')).toBe(true); + await expect(secondResponse).resolves.toEqual({ + outcome: { + outcome: 'selected', + optionId: 'allow-once', + }, + }); + }); +}); diff --git a/ui/desktop/src/acp/acpConnection.ts b/ui/desktop/src/acp/acpConnection.ts index e687f7047..1622ee772 100644 --- a/ui/desktop/src/acp/acpConnection.ts +++ b/ui/desktop/src/acp/acpConnection.ts @@ -5,8 +5,12 @@ import { } from '@aaif/goose-sdk'; import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'; import packageJson from '../../package.json'; -import { routeAcpGooseSessionNotification, routeAcpSessionNotification } from './chatNotifications'; +import { + handleAcpGooseSessionNotification, + handleAcpSessionNotification, +} from './chatNotifications'; import { createWebSocketStream } from './createWebSocketStream'; +import { requestAcpElicitation } from './elicitationRequests'; import { requestAcpPermission } from './permissionRequests'; let clientPromise: Promise | null = null; @@ -15,8 +19,9 @@ let resolvedClient: GooseClient | null = null; function createClientCallbacks(): () => GooseClientCallbacks { return () => ({ requestPermission: requestAcpPermission, - sessionUpdate: routeAcpSessionNotification, - unstable_sessionUpdate: routeAcpGooseSessionNotification, + unstable_createElicitation: requestAcpElicitation, + sessionUpdate: handleAcpSessionNotification, + unstable_sessionUpdate: handleAcpGooseSessionNotification, }); } @@ -44,6 +49,7 @@ async function initializeConnection(): Promise { await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { + elicitation: { form: {} }, _meta: { goose: { mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, diff --git a/ui/desktop/src/acp/adapter/elicitations.ts b/ui/desktop/src/acp/adapter/elicitations.ts new file mode 100644 index 000000000..7c3cfa140 --- /dev/null +++ b/ui/desktop/src/acp/adapter/elicitations.ts @@ -0,0 +1,89 @@ +import type { Message } from '../../api'; +import type { AcpElicitationRequest } from '../elicitationRequests'; +import { + type AcpChatStateChange, + type AdapterState, + DEFAULT_VISIBLE_MESSAGE_METADATA, + messagesChange, +} from './shared'; + +export type ElicitationStatus = 'submitted' | 'cancelled'; + +export function applyElicitationRequest( + state: AdapterState, + request: AcpElicitationRequest +): AcpChatStateChange[] { + if (hasExistingElicitation(state, request.id)) { + return messagesChange(state); + } + + state.messages.push({ + id: request.id, + role: 'assistant', + created: Math.floor(Date.now() / 1000), + content: [ + { + type: 'actionRequired', + data: { + actionType: 'elicitation', + id: request.id, + message: request.request.message, + requested_schema: request.request.requestedSchema, + }, + }, + ], + metadata: { ...DEFAULT_VISIBLE_MESSAGE_METADATA }, + }); + + return messagesChange(state); +} + +export function applyElicitationStatus( + state: AdapterState, + elicitationId: string, + status: ElicitationStatus +): AcpChatStateChange[] { + const statusData = { + isSubmitted: status === 'submitted', + isCancelled: status === 'cancelled', + }; + let changed = false; + + state.messages = state.messages.map((message) => { + let messageChanged = false; + const content = message.content.map((content) => { + if ( + content.type !== 'actionRequired' || + content.data.actionType !== 'elicitation' || + content.data.id !== elicitationId + ) { + return content; + } + + messageChanged = true; + changed = true; + return { + ...content, + data: { + ...content.data, + ...statusData, + }, + }; + }); + + return messageChanged ? { ...message, content } : message; + }); + + return changed ? messagesChange(state) : []; +} + +function hasExistingElicitation(state: AdapterState, elicitationId: string): boolean { + return state.messages.some((message: Message) => + message.content.some( + (content) => + content.type === 'actionRequired' && + content.data.actionType === 'elicitation' && + content.data.id === elicitationId + ) + ); +} diff --git a/ui/desktop/src/acp/chatNotifications.ts b/ui/desktop/src/acp/chatNotifications.ts index c79caa5c2..8f2958f46 100644 --- a/ui/desktop/src/acp/chatNotifications.ts +++ b/ui/desktop/src/acp/chatNotifications.ts @@ -1,21 +1,20 @@ import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; import type { SessionNotification } from '@agentclientprotocol/sdk'; -import { createSessionScopedNotificationRouter } from './sessionScopedNotificationRouter'; +import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; +import { acpChatSessionStore } from './chatSessionStore'; -const acpSessionRouter = createSessionScopedNotificationRouter(); -const gooseSessionRouter = - createSessionScopedNotificationRouter(); +export function handleAcpSessionNotification(notification: SessionNotification): Promise { + if (USE_ACP_CHAT) { + acpChatSessionStore.applyAcpSessionNotification(notification); + } + return Promise.resolve(); +} -export const subscribeToAcpSession = acpSessionRouter.subscribe; -export const routeAcpSessionNotification = async ( - notification: SessionNotification -): Promise => { - await acpSessionRouter.route(notification); -}; - -export const subscribeToAcpGooseSession = gooseSessionRouter.subscribe; -export const routeAcpGooseSessionNotification = async ( +export function handleAcpGooseSessionNotification( notification: GooseSessionNotification_unstable -): Promise => { - await gooseSessionRouter.route(notification); -}; +): Promise { + if (USE_ACP_CHAT) { + acpChatSessionStore.applyAcpGooseSessionNotification(notification); + } + return Promise.resolve(); +} diff --git a/ui/desktop/src/acp/chatSessionStore.ts b/ui/desktop/src/acp/chatSessionStore.ts new file mode 100644 index 000000000..fd8cae4bd --- /dev/null +++ b/ui/desktop/src/acp/chatSessionStore.ts @@ -0,0 +1,353 @@ +import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; +import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk'; +import type { Message, Session, TokenState } from '../api'; +import { ChatState } from '../types/chatState'; +import { + createAcpSessionNotificationAdapter, + type AcpChatStateChange, + type AcpSessionNotificationAdapter, +} from './sessionNotificationAdapter'; +import type { ElicitationStatus } from './adapter/elicitations'; +import { cloneMessage } from './adapter/shared'; +import type { AcpElicitationRequest } from './elicitationRequests'; + +export interface AcpChatSessionSnapshot { + session: Session | undefined; + messages: Message[]; + tokenState: TokenState; + chatState: ChatState; + sessionLoadError: string | undefined; + activePromptAttemptId: string | null; +} + +type SnapshotListener = (snapshot: AcpChatSessionSnapshot) => void; + +interface StoreEntry extends AcpChatSessionSnapshot { + adapter: AcpSessionNotificationAdapter; +} + +const initialTokenState: TokenState = { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, +}; + +export interface AcpChatSessionStore { + getSnapshot(sessionId: string): AcpChatSessionSnapshot | undefined; + subscribe(sessionId: string, listener: (snapshot: AcpChatSessionSnapshot) => void): () => void; + 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 + ): AcpChatSessionSnapshot; + applyPermissionRequest(request: RequestPermissionRequest): AcpChatSessionSnapshot; + applyElicitationRequest(request: AcpElicitationRequest): AcpChatSessionSnapshot; + setElicitationStatus( + sessionId: string, + elicitationId: string, + status: ElicitationStatus + ): AcpChatSessionSnapshot | undefined; +} + +export function createAcpChatSessionStore(): AcpChatSessionStore { + const sessionsById = new Map(); + const listenersBySessionId = new Map>(); + + const getSnapshot: AcpChatSessionStore['getSnapshot'] = (sessionId) => { + const entry = sessionsById.get(sessionId); + return entry ? snapshotFromEntry(entry) : undefined; + }; + + const subscribe: AcpChatSessionStore['subscribe'] = (sessionId, listener) => { + const listeners = listenersBySessionId.get(sessionId) ?? new Set(); + listeners.add(listener); + listenersBySessionId.set(sessionId, listeners); + + let subscribed = true; + return () => { + if (!subscribed) { + return; + } + + subscribed = false; + const currentListeners = listenersBySessionId.get(sessionId); + if (!currentListeners) { + return; + } + + currentListeners.delete(listener); + if (currentListeners.size === 0) { + listenersBySessionId.delete(sessionId); + } + }; + }; + + const deleteSnapshot: AcpChatSessionStore['deleteSnapshot'] = (sessionId) => { + sessionsById.delete(sessionId); + }; + + const getOrCreateEntry = (sessionId: string): StoreEntry => { + const existing = sessionsById.get(sessionId); + if (existing) { + return existing; + } + + const entry: StoreEntry = { + session: undefined, + messages: [], + tokenState: { ...initialTokenState }, + chatState: ChatState.Idle, + sessionLoadError: undefined, + activePromptAttemptId: null, + adapter: createAcpSessionNotificationAdapter(), + }; + sessionsById.set(sessionId, entry); + return entry; + }; + + const notify = (sessionId: string, entry: StoreEntry): AcpChatSessionSnapshot => { + const snapshot = snapshotFromEntry(entry); + const listeners = listenersBySessionId.get(sessionId); + if (listeners) { + for (const listener of listeners) { + listener(snapshot); + } + } + return snapshot; + }; + + const setLoadedSession: AcpChatSessionStore['setLoadedSession'] = ( + sessionId, + session, + tokenState = tokenStateFromSession(session) + ) => { + 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); + 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 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 entry = getOrCreateEntry(sessionId); + entry.chatState = chatState; + return notify(sessionId, entry); + }; + + const setSessionLoadError: AcpChatSessionStore['setSessionLoadError'] = ( + sessionId, + sessionLoadError + ) => { + const entry = getOrCreateEntry(sessionId); + entry.sessionLoadError = sessionLoadError; + return notify(sessionId, entry); + }; + + const startPromptAttempt: AcpChatSessionStore['startPromptAttempt'] = ( + sessionId, + promptAttemptId + ) => { + const entry = getOrCreateEntry(sessionId); + entry.activePromptAttemptId = promptAttemptId; + entry.chatState = ChatState.Streaming; + entry.sessionLoadError = undefined; + return notify(sessionId, entry); + }; + + const finishPromptAttemptIfCurrent: AcpChatSessionStore['finishPromptAttemptIfCurrent'] = ( + sessionId, + promptAttemptId, + error + ) => { + const entry = sessionsById.get(sessionId); + if (!entry || entry.activePromptAttemptId !== promptAttemptId) { + return false; + } + + entry.activePromptAttemptId = null; + entry.chatState = ChatState.Idle; + entry.sessionLoadError = error; + notify(sessionId, entry); + return true; + }; + + const clearActivePromptAttempt: AcpChatSessionStore['clearActivePromptAttempt'] = (sessionId) => { + const entry = sessionsById.get(sessionId); + if (!entry) { + return undefined; + } + + entry.activePromptAttemptId = null; + entry.chatState = ChatState.Idle; + return notify(sessionId, entry); + }; + + const isCurrentPromptAttempt: AcpChatSessionStore['isCurrentPromptAttempt'] = ( + sessionId, + promptAttemptId + ) => sessionsById.get(sessionId)?.activePromptAttemptId === promptAttemptId; + + const applyAcpSessionNotification: AcpChatSessionStore['applyAcpSessionNotification'] = ( + notification + ) => { + const entry = getOrCreateEntry(notification.sessionId); + const changes = entry.adapter.apply(notification); + applyChatStateChanges(entry, changes); + return notify(notification.sessionId, entry); + }; + + const applyAcpGooseSessionNotification: AcpChatSessionStore['applyAcpGooseSessionNotification'] = + (notification) => { + const entry = getOrCreateEntry(notification.sessionId); + const changes = entry.adapter.applyGoose(notification); + applyChatStateChanges(entry, changes); + return notify(notification.sessionId, entry); + }; + + const applyPermissionRequest: AcpChatSessionStore['applyPermissionRequest'] = (request) => { + const entry = getOrCreateEntry(request.sessionId); + const changes = entry.adapter.applyPermissionRequest(request); + applyChatStateChanges(entry, changes); + entry.chatState = ChatState.WaitingForUserInput; + return notify(request.sessionId, entry); + }; + + const applyElicitationRequest: AcpChatSessionStore['applyElicitationRequest'] = (request) => { + const entry = getOrCreateEntry(request.sessionId); + const changes = entry.adapter.applyElicitationRequest(request); + applyChatStateChanges(entry, changes); + entry.chatState = ChatState.WaitingForUserInput; + return notify(request.sessionId, entry); + }; + + const setElicitationStatus: AcpChatSessionStore['setElicitationStatus'] = ( + sessionId, + elicitationId, + status + ) => { + const entry = sessionsById.get(sessionId); + if (!entry) { + return undefined; + } + + const changes = entry.adapter.applyElicitationStatus(elicitationId, status); + if (changes.length === 0) { + return snapshotFromEntry(entry); + } + + applyChatStateChanges(entry, changes); + return notify(sessionId, entry); + }; + + return { + getSnapshot, + subscribe, + deleteSnapshot, + setLoadedSession, + setSessionMetadata, + setMessages, + setTokenState, + setChatState, + setSessionLoadError, + startPromptAttempt, + finishPromptAttemptIfCurrent, + clearActivePromptAttempt, + isCurrentPromptAttempt, + applyAcpSessionNotification, + applyAcpGooseSessionNotification, + applyPermissionRequest, + applyElicitationRequest, + setElicitationStatus, + }; +} + +export const acpChatSessionStore = createAcpChatSessionStore(); + +export function tokenStateFromSession(session: Session | undefined): TokenState { + 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 } + : {}), + }; +} + +function applyChatStateChanges(entry: StoreEntry, changes: AcpChatStateChange[]): void { + for (const change of changes) { + switch (change.type) { + case 'messages': + entry.messages = cloneMessages(change.messages); + break; + case 'tokenState': + entry.tokenState = { ...entry.tokenState, ...change.tokenState }; + break; + case 'sessionInfo': + if (change.name && entry.session) { + entry.session = { ...entry.session, name: change.name }; + } + break; + } + } +} + +function snapshotFromEntry(entry: StoreEntry): AcpChatSessionSnapshot { + return { + session: entry.session, + messages: cloneMessages(entry.messages), + tokenState: { ...entry.tokenState }, + chatState: entry.chatState, + sessionLoadError: entry.sessionLoadError, + activePromptAttemptId: entry.activePromptAttemptId, + }; +} + +function cloneMessages(messages: Message[]): Message[] { + return messages.map(cloneMessage); +} diff --git a/ui/desktop/src/acp/elicitationRequests.ts b/ui/desktop/src/acp/elicitationRequests.ts new file mode 100644 index 000000000..a7ff6832d --- /dev/null +++ b/ui/desktop/src/acp/elicitationRequests.ts @@ -0,0 +1,110 @@ +import type { + CreateElicitationRequest, + CreateElicitationResponse, + ElicitationContentValue, + ElicitationSchema, +} from '@agentclientprotocol/sdk'; +import { v7 as uuidv7 } from 'uuid'; +import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; +import { acpChatSessionStore } from './chatSessionStore'; + +type SessionScopedFormElicitationRequest = CreateElicitationRequest & { + mode: 'form'; + sessionId: string; + requestedSchema: ElicitationSchema; +}; + +export interface AcpElicitationRequest { + id: string; + sessionId: string; + request: SessionScopedFormElicitationRequest; +} + +interface PendingElicitationRequest { + request: AcpElicitationRequest; + resolve: (response: CreateElicitationResponse) => void; + timeoutId: ReturnType; +} + +const pendingRequests = new Map(); +export const ACP_ELICITATION_TIMEOUT_SECONDS = 300; + +export async function requestAcpElicitation( + request: CreateElicitationRequest +): Promise { + if (!USE_ACP_CHAT || !isSessionScopedFormElicitation(request)) { + return cancelledElicitationResponse(); + } + + const elicitationRequest: AcpElicitationRequest = { + id: `acp_elicitation_${uuidv7()}`, + sessionId: request.sessionId, + request, + }; + const key = elicitationRequestKey(elicitationRequest.sessionId, elicitationRequest.id); + + return new Promise((resolve) => { + const timeoutId = setTimeout(() => { + const pending = pendingRequests.get(key); + if (!pending) { + return; + } + + pendingRequests.delete(key); + pending.resolve(cancelledElicitationResponse()); + }, ACP_ELICITATION_TIMEOUT_SECONDS * 1000); + + pendingRequests.set(key, { request: elicitationRequest, resolve, timeoutId }); + acpChatSessionStore.applyElicitationRequest(elicitationRequest); + }); +} + +export function resolveAcpElicitationRequest( + sessionId: string, + elicitationId: string, + userData: Record +): boolean { + const key = elicitationRequestKey(sessionId, elicitationId); + const pending = pendingRequests.get(key); + if (!pending) { + return false; + } + + pendingRequests.delete(key); + clearTimeout(pending.timeoutId); + acpChatSessionStore.setElicitationStatus(sessionId, elicitationId, 'submitted'); + pending.resolve(acceptedElicitationResponse(userData)); + return true; +} + +export function cancelAcpElicitationRequestsForSession(sessionId: string): void { + for (const [key, pending] of pendingRequests) { + if (pending.request.sessionId === sessionId) { + pendingRequests.delete(key); + clearTimeout(pending.timeoutId); + acpChatSessionStore.setElicitationStatus(sessionId, pending.request.id, 'cancelled'); + pending.resolve(cancelledElicitationResponse()); + } + } +} + +function isSessionScopedFormElicitation( + request: CreateElicitationRequest +): request is SessionScopedFormElicitationRequest { + return request.mode === 'form' && 'sessionId' in request && typeof request.sessionId === 'string'; +} + +function acceptedElicitationResponse(userData: Record): CreateElicitationResponse { + return { + action: 'accept', + content: userData as Record, + }; +} + +function cancelledElicitationResponse(): CreateElicitationResponse { + return { action: 'cancel' }; +} + +function elicitationRequestKey(sessionId: string, elicitationId: string): string { + return `${sessionId}\u0000${elicitationId}`; +} diff --git a/ui/desktop/src/acp/permissionRequests.ts b/ui/desktop/src/acp/permissionRequests.ts index eddd0745a..28b852e2f 100644 --- a/ui/desktop/src/acp/permissionRequests.ts +++ b/ui/desktop/src/acp/permissionRequests.ts @@ -1,17 +1,15 @@ import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk'; import type { Permission } from '../api'; -import { createSessionScopedNotificationRouter } from './sessionScopedNotificationRouter'; +import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; +import { acpChatSessionStore } from './chatSessionStore'; interface PendingPermissionRequest { request: RequestPermissionRequest; resolve: (response: RequestPermissionResponse) => void; } -const permissionRequestRouter = createSessionScopedNotificationRouter(); const pendingRequests = new Map(); -export const subscribeToAcpPermissionRequests = permissionRequestRouter.subscribe; - export async function requestAcpPermission( request: RequestPermissionRequest ): Promise { @@ -21,28 +19,13 @@ export async function requestAcpPermission( previous.resolve(cancelledPermissionResponse()); } + if (!USE_ACP_CHAT) { + return cancelledPermissionResponse(); + } + return new Promise((resolve) => { pendingRequests.set(key, { request, resolve }); - - permissionRequestRouter - .route(request) - .then((routed) => { - if (!routed) { - const pending = pendingRequests.get(key); - if (pending?.resolve === resolve) { - pendingRequests.delete(key); - resolve(cancelledPermissionResponse()); - } - } - }) - .catch((error) => { - console.warn('Failed to route ACP permission request:', error); - const pending = pendingRequests.get(key); - if (pending?.resolve === resolve) { - pendingRequests.delete(key); - resolve(cancelledPermissionResponse()); - } - }); + acpChatSessionStore.applyPermissionRequest(request); }); } diff --git a/ui/desktop/src/acp/sessionNotificationAdapter.ts b/ui/desktop/src/acp/sessionNotificationAdapter.ts index ccb0ad0ca..3548a5084 100644 --- a/ui/desktop/src/acp/sessionNotificationAdapter.ts +++ b/ui/desktop/src/acp/sessionNotificationAdapter.ts @@ -1,11 +1,17 @@ import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; import type { RequestPermissionRequest, SessionNotification } from '@agentclientprotocol/sdk'; import type { Message } from '../api'; +import { + applyElicitationRequest as applyElicitationRequestToState, + applyElicitationStatus as applyElicitationStatusToState, + type ElicitationStatus, +} from './adapter/elicitations'; import { applyGooseSessionNotification } from './adapter/gooseSessionNotifications'; import { applyContentChunk, applyThoughtChunk } from './adapter/messages'; import { applyPermissionRequest as applyPermissionRequestToState } from './adapter/permissions'; import { type AcpChatStateChange, type AdapterState, cloneMessage } from './adapter/shared'; import { applyToolCall, applyToolCallUpdate } from './adapter/tools'; +import type { AcpElicitationRequest } from './elicitationRequests'; export type { AcpChatStateChange } from './adapter/shared'; @@ -13,6 +19,8 @@ export interface AcpSessionNotificationAdapter { apply(notification: SessionNotification): AcpChatStateChange[]; applyGoose(notification: GooseSessionNotification_unstable): AcpChatStateChange[]; applyPermissionRequest(request: RequestPermissionRequest): AcpChatStateChange[]; + applyElicitationRequest(request: AcpElicitationRequest): AcpChatStateChange[]; + applyElicitationStatus(elicitationId: string, status: ElicitationStatus): AcpChatStateChange[]; getMessages(): Message[]; } @@ -33,6 +41,12 @@ export function createAcpSessionNotificationAdapter( applyPermissionRequest(request) { return applyPermissionRequestToState(state, request); }, + applyElicitationRequest(request) { + return applyElicitationRequestToState(state, request); + }, + applyElicitationStatus(elicitationId, status) { + return applyElicitationStatusToState(state, elicitationId, status); + }, getMessages() { return state.messages.map(cloneMessage); }, diff --git a/ui/desktop/src/acp/sessionScopedNotificationRouter.ts b/ui/desktop/src/acp/sessionScopedNotificationRouter.ts deleted file mode 100644 index e8973a8e3..000000000 --- a/ui/desktop/src/acp/sessionScopedNotificationRouter.ts +++ /dev/null @@ -1,59 +0,0 @@ -type SessionScopedNotificationListener = ( - notification: TNotification -) => Promise | void; - -interface SessionScopedNotification { - sessionId: string; -} - -export function createSessionScopedNotificationRouter< - TNotification extends SessionScopedNotification, ->() { - const listenersBySessionId = new Map< - string, - Set> - >(); - - const subscribe = ( - sessionId: string, - listener: SessionScopedNotificationListener - ): (() => void) => { - const listeners = listenersBySessionId.get(sessionId) ?? new Set(); - listeners.add(listener); - listenersBySessionId.set(sessionId, listeners); - - let subscribed = true; - - return () => { - if (!subscribed) { - return; - } - - subscribed = false; - const currentListeners = listenersBySessionId.get(sessionId); - if (!currentListeners) { - return; - } - - currentListeners.delete(listener); - if (currentListeners.size === 0) { - listenersBySessionId.delete(sessionId); - } - }; - }; - - const route = async (notification: TNotification): Promise => { - const listeners = listenersBySessionId.get(notification.sessionId); - if (!listeners) { - return false; - } - - await Promise.all([...listeners].map((listener) => listener(notification))); - return true; - }; - - return { - route, - subscribe, - }; -} diff --git a/ui/desktop/src/acp/sessions.ts b/ui/desktop/src/acp/sessions.ts index d48ce1b59..2a945a113 100644 --- a/ui/desktop/src/acp/sessions.ts +++ b/ui/desktop/src/acp/sessions.ts @@ -1,4 +1,8 @@ -import type { ForkSessionRequest, ListSessionsRequest, SessionInfo } from '@agentclientprotocol/sdk'; +import type { + ForkSessionRequest, + ListSessionsRequest, + SessionInfo, +} from '@agentclientprotocol/sdk'; import { getAcpClient } from './acpConnection'; import { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext'; @@ -99,10 +103,27 @@ export async function acpRenameSession(sessionId: string, title: string): Promis await client.goose.sessionRename_unstable({ sessionId, title }); } -export async function acpForkSession(sessionId: string, cwd: string): Promise { +export async function acpTruncateSessionConversation( + sessionId: string, + truncateFrom: number +): Promise { const client = await getAcpClient(); + await client.goose.sessionConversationTruncate_unstable({ sessionId, truncateFrom }); +} + +export async function acpForkSession( + sessionId: string, + conversationBefore?: number +): Promise { + const client = await getAcpClient(); + const sessionInfo = await client.goose.sessionInfo_unstable({ sessionId }); + const { cwd } = sessionInfo.session; const request: ForkSessionRequest = { sessionId, cwd }; - await client.unstable_forkSession(request); + if (conversationBefore !== undefined) { + request._meta = { conversationBefore }; + } + const response = await client.unstable_forkSession(request); + return String(response.sessionId); } export async function acpExportSession(sessionId: string): Promise { diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 4f0a836f9..fc1cb8210 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -113,6 +113,7 @@ export default function BaseChat({ setRecipeUserParams, tokenState, notifications: toolCallNotifications, + pauseQueueOnStop, onMessageUpdate, } = useChatSession({ sessionId, @@ -510,6 +511,7 @@ export default function BaseChat({ chatState={chatState} setChatState={setChatState} onStop={stopStreaming} + pauseQueueOnStop={pauseQueueOnStop} commandHistory={commandHistory} initialValue={initialPrompt} setView={setView} diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 2e5eb0e71..c2e1a2864 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -30,11 +30,7 @@ import { DiagnosticsModal } from './ui/Diagnostics'; import { getSession, Message } from '../api'; import { getInitialWorkingDir } from '../utils/workingDir'; import { getPredefinedModelsFromEnv } from './settings/models/predefinedModelsUtils'; -import { - trackFileAttached, - trackVoiceDictation, - trackDiagnosticsOpened, -} from '../utils/analytics'; +import { trackFileAttached, trackVoiceDictation, trackDiagnosticsOpened } from '../utils/analytics'; import { getNavigationShortcutText } from '../utils/keyboardShortcuts'; import { UserInput, ImageData } from '../types/message'; import { compressImageDataUrl } from '../utils/conversionUtils'; @@ -51,9 +47,7 @@ const turndown = new TurndownService({ turndown.addRule('complexLinks', { filter: (node) => { return ( - node.nodeName === 'A' && - !!node.getAttribute('href') && - /\n/.test(node.textContent || '') + node.nodeName === 'A' && !!node.getAttribute('href') && /\n/.test(node.textContent || '') ); }, replacement: (content, node) => { @@ -178,6 +172,7 @@ interface ChatInputProps { chatState: ChatState; setChatState?: (state: ChatState) => void; onStop?: () => void; + pauseQueueOnStop?: boolean; commandHistory?: string[]; initialValue?: string; droppedFiles?: DroppedFile[]; @@ -209,6 +204,7 @@ export default function ChatInput({ chatState = ChatState.Idle, setChatState, onStop, + pauseQueueOnStop = false, commandHistory = [], initialValue = '', droppedFiles = [], @@ -1378,6 +1374,13 @@ export default function ChatInput({ if (onStop) onStop(); }; + const handleStop = () => { + if (pauseQueueOnStop && queuedMessages.length > 0) { + pauseRemainingQueue(); + } + if (onStop) onStop(); + }; + const handleResumeQueue = () => { queuePausedRef.current = false; setLastInterruption(null); @@ -1588,10 +1591,7 @@ export default function ChatInput({ extensions, diagnostics, attach, mic, send. When the bar is narrow (e.g. on a small window), the secondary controls drop out so the model selector + send button always stay visible. */} -
+
{/* Left: model selector */}
@@ -1746,7 +1746,7 @@ export default function ChatInput({ {isLoading && !hasSubmittableContent ? (
); diff --git a/ui/desktop/src/components/ElicitationRequest.test.tsx b/ui/desktop/src/components/ElicitationRequest.test.tsx new file mode 100644 index 000000000..3479e54e1 --- /dev/null +++ b/ui/desktop/src/components/ElicitationRequest.test.tsx @@ -0,0 +1,82 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import type { ActionRequired } from '../api'; +import { IntlTestWrapper } from '../i18n/test-utils'; +import ElicitationRequest from './ElicitationRequest'; + +const actionRequiredContent = { + type: 'actionRequired', + data: { + actionType: 'elicitation', + id: 'elicitation-1', + message: 'Need more information', + requested_schema: { + type: 'object', + properties: {}, + }, + }, +} as ActionRequired & { type: 'actionRequired' }; + +type SubmitElicitationResponse = ( + elicitationId: string, + userData: Record +) => Promise; + +function renderElicitationRequest(onSubmit: SubmitElicitationResponse) { + return render( + , + { wrapper: IntlTestWrapper } + ); +} + +describe('ElicitationRequest', () => { + it('shows submitted state when the response is accepted', async () => { + const onSubmit = vi.fn().mockResolvedValue(true); + + renderElicitationRequest(onSubmit); + + await userEvent.click(screen.getByRole('button', { name: 'Accept' })); + + expect(onSubmit).toHaveBeenCalledWith('elicitation-1', {}); + expect(await screen.findByText('Information submitted')).toBeInTheDocument(); + }); + + it('shows submitted state while the response is pending', async () => { + let resolveSubmission: (value: boolean) => void = () => {}; + const submission = new Promise((resolve) => { + resolveSubmission = resolve; + }); + const onSubmit = vi.fn().mockReturnValue(submission); + + renderElicitationRequest(onSubmit); + + await userEvent.click(screen.getByRole('button', { name: 'Accept' })); + + expect(screen.getByText('Information submitted')).toBeInTheDocument(); + + await act(async () => { + resolveSubmission(true); + await submission; + }); + }); + + it('keeps the request actionable when no ACP request is pending', async () => { + const onSubmit = vi.fn().mockResolvedValue(false); + + renderElicitationRequest(onSubmit); + + await userEvent.click(screen.getByRole('button', { name: 'Accept' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'This request is no longer active. The extension will need to ask again.' + ); + expect(screen.queryByText('Information submitted')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Accept' })).toBeEnabled(); + }); +}); diff --git a/ui/desktop/src/components/ElicitationRequest.tsx b/ui/desktop/src/components/ElicitationRequest.tsx index 4eefffd95..9a6e48817 100644 --- a/ui/desktop/src/components/ElicitationRequest.tsx +++ b/ui/desktop/src/components/ElicitationRequest.tsx @@ -34,6 +34,10 @@ const i18n = defineMessages({ id: 'elicitationRequest.waitingForResponse', defaultMessage: 'Waiting for your response ({timeRemaining} remaining)', }, + submitError: { + id: 'elicitationRequest.submitError', + defaultMessage: 'This request is no longer active. The extension will need to ask again.', + }, }); const ELICITATION_TIMEOUT_SECONDS = 300; @@ -42,7 +46,7 @@ interface ElicitationRequestProps { isCancelledMessage: boolean; isClicked: boolean; actionRequiredContent: ActionRequired & { type: 'actionRequired' }; - onSubmit: (elicitationId: string, userData: Record) => void; + onSubmit: (elicitationId: string, userData: Record) => Promise; } function formatTime(seconds: number): string { @@ -59,9 +63,18 @@ export default function ElicitationRequest({ }: ElicitationRequestProps) { const intl = useIntl(); const [submitted, setSubmitted] = useState(isClicked); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(); const [timeRemaining, setTimeRemaining] = useState(ELICITATION_TIMEOUT_SECONDS); const startTimeRef = useRef(Date.now()); + useEffect(() => { + if (isClicked) { + setSubmitted(true); + setSubmitError(undefined); + } + }, [isClicked]); + useEffect(() => { if (submitted || isCancelledMessage || isClicked) return; @@ -87,14 +100,31 @@ export default function ElicitationRequest({ const schema = (requested_schema ?? {}) as JsonSchema; const hasSchemaFields = Boolean(schema.properties && Object.keys(schema.properties).length > 0); - const handleSubmit = (formData: Record) => { + const submitResponse = async (formData: Record) => { setSubmitted(true); - onSubmit(elicitationId, formData); + setIsSubmitting(true); + setSubmitError(undefined); + try { + const didSubmit = await onSubmit(elicitationId, formData); + if (!didSubmit) { + setSubmitted(false); + setSubmitError(intl.formatMessage(i18n.submitError)); + } + } catch (error) { + console.error('Error submitting elicitation response:', error); + setSubmitted(false); + setSubmitError(intl.formatMessage(i18n.submitError)); + } finally { + setIsSubmitting(false); + } + }; + + const handleSubmit = (formData: Record) => { + void submitResponse(formData); }; const handleAccept = () => { - setSubmitted(true); - onSubmit(elicitationId, {}); + void submitResponse({}); }; if (isCancelledMessage) { @@ -165,14 +195,20 @@ export default function ElicitationRequest({ schema={schema} onSubmit={handleSubmit} submitLabel={intl.formatMessage(i18n.submit)} + disabled={isSubmitting} /> ) : (
-
)} + {submitError && ( +
+ {submitError} +
+ )}
diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index ee0a433aa..c0d512016 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -34,7 +34,7 @@ interface GooseMessageProps { submitElicitationResponse?: ( elicitationId: string, userData: Record - ) => Promise; + ) => Promise; } export default function GooseMessage({ @@ -75,6 +75,13 @@ export default function GooseMessage({ ); const hasToolConfirmation = toolConfirmationContent !== undefined; const hasElicitation = elicitationContent !== undefined; + const elicitationData = + elicitationContent?.data.actionType === 'elicitation' + ? (elicitationContent.data as typeof elicitationContent.data & { + isSubmitted?: boolean; + isCancelled?: boolean; + }) + : undefined; const toolConfirmationShownInline = useMemo(() => { if (!toolConfirmationContent) return false; @@ -203,8 +210,8 @@ export default function GooseMessage({ {hasElicitation && submitElicitationResponse && ( diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index af596a606..9cb402873 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -65,7 +65,7 @@ interface ProgressiveMessageListProps { submitElicitationResponse?: ( elicitationId: string, userData: Record - ) => Promise; + ) => Promise; } export default function ProgressiveMessageList({ diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index c9c00c716..ac2623b61 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -45,6 +45,9 @@ import { acpRenameSession, type SessionListItem, } from '../../acp/sessions'; +import { acpChatSessionStore } from '../../acp/chatSessionStore'; +import { cancelAcpPermissionRequestsForSession } from '../../acp/permissionRequests'; +import { cancelAcpElicitationRequestsForSession } from '../../acp/elicitationRequests'; import { getSearchShortcutText } from '../../utils/keyboardShortcuts'; import { clearSessionCache } from '../../hooks/useChatStream'; @@ -482,7 +485,7 @@ const SessionListView: React.FC = React.memo( const handleDuplicateSession = useCallback( async (session: SessionListItem) => { try { - await acpForkSession(session.id, session.workingDir); + await acpForkSession(session.id); toast.success(intl.formatMessage(i18n.duplicateSuccess, { name: session.name })); window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); await loadSessions(); @@ -509,6 +512,9 @@ const SessionListView: React.FC = React.memo( new CustomEvent(AppEvents.SESSION_DELETED, { detail: { sessionId: sessionToDeleteId } }) ); clearSessionCache(sessionToDeleteId); + cancelAcpPermissionRequestsForSession(sessionToDeleteId); + cancelAcpElicitationRequestsForSession(sessionToDeleteId); + acpChatSessionStore.deleteSnapshot(sessionToDeleteId); } catch (error) { console.error('Error deleting session:', error); toast.error(intl.formatMessage(i18n.deleteFailed, { name: sessionName, error: errorMessage(error, 'Unknown error') })); diff --git a/ui/desktop/src/hooks/useAcpChatSession.ts b/ui/desktop/src/hooks/useAcpChatSession.ts index 4cbc33436..8db3035eb 100644 --- a/ui/desktop/src/hooks/useAcpChatSession.ts +++ b/ui/desktop/src/hooks/useAcpChatSession.ts @@ -9,41 +9,25 @@ import { Message, resumeAgent, Session, - sessionCancel, - sessionReply, TokenState, updateFromSession, updateSessionUserRecipeValues, listApps, } from '../api'; -import { - createUserMessage, - createElicitationResponseMessage, - getCompactingMessage, - getThinkingMessage, - NotificationEvent, - UserInput, -} from '../types/message'; +import { createUserMessage, NotificationEvent, UserInput } from '../types/message'; import { errorMessage } from '../utils/conversionUtils'; import { showExtensionLoadResults } from '../utils/extensionErrorUtils'; -import { maybeHandlePlatformEvent } from '../utils/platform_events'; -import { useSessionEvents, type SessionEvent } from './useSessionEvents'; import type { UseChatSessionParams, UseChatSessionResult } from './useChatSessionTypes'; -import { subscribeToAcpGooseSession, subscribeToAcpSession } from '../acp/chatNotifications'; +import { cancelAcpPermissionRequestsForSession } from '../acp/permissionRequests'; import { - cancelAcpPermissionRequestsForSession, - subscribeToAcpPermissionRequests, -} from '../acp/permissionRequests'; + cancelAcpElicitationRequestsForSession, + resolveAcpElicitationRequest, +} from '../acp/elicitationRequests'; import { parseAcpCreditsExhaustedError, type AcpCreditsExhaustedError } from '../acp/errors'; import { acpCancelPrompt, acpPromptSession } from '../acp/prompt'; -import { - createAcpSessionNotificationAdapter, - type AcpChatStateChange, - type AcpSessionNotificationAdapter, -} from '../acp/sessionNotificationAdapter'; - -const resultsCache = new Map(); +import { acpForkSession, acpTruncateSessionConversation } from '../acp/sessions'; +import { acpChatSessionStore, type AcpChatSessionSnapshot } from '../acp/chatSessionStore'; interface StreamState { messages: Message[]; @@ -51,7 +35,6 @@ interface StreamState { chatState: ChatState; sessionLoadError: string | undefined; tokenState: TokenState; - notifications: NotificationEvent[]; } type StreamAction = @@ -60,17 +43,7 @@ type StreamAction = | { type: 'SET_CHAT_STATE'; payload: ChatState } | { type: 'SET_SESSION_LOAD_ERROR'; payload: string | undefined } | { type: 'SET_TOKEN_STATE'; payload: TokenState } - | { type: 'ADD_NOTIFICATION'; payload: NotificationEvent } - | { type: 'CLEAR_NOTIFICATIONS' } - | { type: 'APPLY_ACP_CHAT_STATE_CHANGE'; payload: AcpChatStateChange } - | { - type: 'SESSION_LOADED'; - payload: { - session: Session; - messages: Message[]; - tokenState: TokenState; - }; - } + | { type: 'SYNC_FROM_ACP_STORE'; payload: AcpChatSessionSnapshot } | { type: 'RESET_FOR_NEW_SESSION' } | { type: 'START_STREAMING' } | { type: 'STREAM_ERROR'; payload: string } @@ -91,7 +64,6 @@ const initialState: StreamState = { chatState: ChatState.Idle, sessionLoadError: undefined, tokenState: initialTokenState, - notifications: [], }; function streamReducer(state: StreamState, action: StreamAction): StreamState { @@ -111,38 +83,14 @@ function streamReducer(state: StreamState, action: StreamAction): StreamState { case 'SET_TOKEN_STATE': return { ...state, tokenState: action.payload }; - case 'ADD_NOTIFICATION': - return { ...state, notifications: [...state.notifications, action.payload] }; - - case 'CLEAR_NOTIFICATIONS': - return { ...state, notifications: [] }; - - case 'APPLY_ACP_CHAT_STATE_CHANGE': { - const update = action.payload; - switch (update.type) { - case 'messages': - return { ...state, messages: update.messages }; - case 'tokenState': - return { ...state, tokenState: { ...state.tokenState, ...update.tokenState } }; - case 'sessionInfo': - return update.name - ? { - ...state, - session: state.session ? { ...state.session, name: update.name } : undefined, - } - : state; - } - return state; - } - - case 'SESSION_LOADED': + case 'SYNC_FROM_ACP_STORE': return { ...state, session: action.payload.session, messages: action.payload.messages, tokenState: action.payload.tokenState, - chatState: ChatState.Idle, - sessionLoadError: undefined, + chatState: action.payload.chatState, + sessionLoadError: action.payload.sessionLoadError, }; case 'RESET_FOR_NEW_SESSION': @@ -158,7 +106,6 @@ function streamReducer(state: StreamState, action: StreamAction): StreamState { return { ...state, chatState: ChatState.Streaming, - notifications: [], }; case 'STREAM_ERROR': @@ -180,54 +127,6 @@ function streamReducer(state: StreamState, action: StreamAction): StreamState { } } -function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] { - const lastMsg = currentMessages[currentMessages.length - 1]; - - if (lastMsg?.id && lastMsg.id === incomingMsg.id) { - const lastContent = lastMsg.content[lastMsg.content.length - 1]; - const newContent = incomingMsg.content[incomingMsg.content.length - 1]; - - if (incomingMsg.metadata?.inference) { - lastMsg.metadata = { - ...lastMsg.metadata, - inference: incomingMsg.metadata.inference, - }; - } - - if ( - lastContent?.type === 'text' && - newContent?.type === 'text' && - incomingMsg.content.length === 1 - ) { - lastContent.text += newContent.text; - } else if ( - lastContent?.type === 'thinking' && - newContent?.type === 'thinking' && - incomingMsg.content.length === 1 && - 'thinking' in lastContent && - 'thinking' in newContent - ) { - // For thinking blocks: if the new block has a signature, it's the complete - // block from content_block_stop — replace entirely. Otherwise append the delta. - if ('signature' in newContent && newContent.signature) { - lastContent.thinking = newContent.thinking; - lastContent.signature = newContent.signature; - } else { - lastContent.thinking += newContent.thinking; - } - } else { - lastMsg.content.push(...incomingMsg.content); - } - return [...currentMessages]; - } else { - return [...currentMessages, incomingMsg]; - } -} - -function prefersReducedMotion(): boolean { - return window.matchMedia('(prefers-reduced-motion: reduce)').matches; -} - function createAcpCreditsExhaustedMessage(error: AcpCreditsExhaustedError): Message { return { id: uuidv7(), @@ -245,180 +144,6 @@ function createAcpCreditsExhaustedMessage(error: AcpCreditsExhaustedError): Mess }; } -const REDUCED_MOTION_BATCH_INTERVAL = 1000; - -/** - * Creates an event processor that handles individual SSE events for a request. - * Returns an unsubscribe function and a handler to process events. - */ -function createEventProcessor( - initialMessages: Message[], - dispatch: React.Dispatch, - onFinish: (error?: string) => void, - sessionId: string, - onReloadNeeded?: () => void -) { - let currentMessages = initialMessages; - const reduceMotion = prefersReducedMotion(); - let latestTokenState: TokenState | null = null; - let latestChatState: ChatState = ChatState.Streaming; - let lastBatchUpdate = Date.now(); - let hasPendingUpdate = false; - let pendingInference: Message['metadata']['inference'] | undefined; - - const flushBatchedUpdates = () => { - if (reduceMotion && hasPendingUpdate) { - if (latestTokenState) { - dispatch({ type: 'SET_TOKEN_STATE', payload: latestTokenState }); - } - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); - dispatch({ type: 'SET_CHAT_STATE', payload: latestChatState }); - hasPendingUpdate = false; - lastBatchUpdate = Date.now(); - } - }; - - const maybeUpdateUI = (tokenState: TokenState, chatState: ChatState, forceImmediate = false) => { - if (!reduceMotion) { - dispatch({ type: 'SET_TOKEN_STATE', payload: tokenState }); - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); - dispatch({ type: 'SET_CHAT_STATE', payload: chatState }); - } else if (forceImmediate) { - dispatch({ type: 'SET_TOKEN_STATE', payload: tokenState }); - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); - dispatch({ type: 'SET_CHAT_STATE', payload: chatState }); - hasPendingUpdate = false; - lastBatchUpdate = Date.now(); - } else { - latestTokenState = tokenState; - latestChatState = chatState; - hasPendingUpdate = true; - const now = Date.now(); - if (now - lastBatchUpdate >= REDUCED_MOTION_BATCH_INTERVAL) { - flushBatchedUpdates(); - } - } - }; - - const flushPendingInference = () => { - if (!pendingInference) { - return; - } - - for (let i = currentMessages.length - 1; i >= 0; i--) { - const message = currentMessages[i]; - if (message.role === 'assistant' && message.metadata.userVisible) { - currentMessages = [ - ...currentMessages.slice(0, i), - { - ...message, - metadata: { - ...message.metadata, - inference: message.metadata.inference ?? pendingInference, - }, - }, - ...currentMessages.slice(i + 1), - ]; - break; - } - } - pendingInference = undefined; - }; - - // Returns true if the event is terminal (Finish or Error) - const processEvent = (event: SessionEvent): boolean => { - switch (event.type) { - case 'Message': { - let msg = (event as Record).message as Message; - const tokenState = (event as Record).token_state as TokenState; - - if (msg.content.length === 0 && msg.metadata?.inference) { - pendingInference = msg.metadata.inference; - return false; - } - - if (pendingInference && msg.role === 'assistant' && msg.metadata.userVisible) { - msg = { - ...msg, - metadata: { - ...msg.metadata, - inference: msg.metadata.inference ?? pendingInference, - }, - }; - pendingInference = undefined; - } - - currentMessages = pushMessage(currentMessages, msg); - - const hasToolConfirmation = msg.content.some( - (content) => - content.type === 'actionRequired' && content.data.actionType === 'toolConfirmation' - ); - - const hasElicitation = msg.content.some( - (content) => - content.type === 'actionRequired' && content.data.actionType === 'elicitation' - ); - - if (hasToolConfirmation || hasElicitation) { - maybeUpdateUI(tokenState, ChatState.WaitingForUserInput, true); - } else if (getCompactingMessage(msg)) { - maybeUpdateUI(tokenState, ChatState.Compacting); - } else if (getThinkingMessage(msg)) { - maybeUpdateUI(tokenState, ChatState.Thinking); - } else { - maybeUpdateUI(tokenState, ChatState.Streaming); - } - return false; - } - case 'Error': { - flushPendingInference(); - flushBatchedUpdates(); - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); - const errorMsg = String((event as Record).error ?? ''); - if (errorMsg.includes('too far behind') && onReloadNeeded) { - // Server indicated we missed events — end streaming without setting - // an error (which would show a blocking error screen), then reload - // the full conversation so the UI reflects the actual state. - onFinish(); - onReloadNeeded(); - } else { - onFinish('Stream error: ' + errorMsg); - } - return true; - } - case 'Finish': { - flushPendingInference(); - flushBatchedUpdates(); - dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); - onFinish(); - return true; - } - case 'UpdateConversation': { - const conversation = (event as Record).conversation as Message[]; - currentMessages = conversation; - if (!reduceMotion) { - dispatch({ type: 'SET_MESSAGES', payload: conversation }); - } else { - hasPendingUpdate = true; - } - return false; - } - case 'Notification': { - dispatch({ type: 'ADD_NOTIFICATION', payload: event as unknown as NotificationEvent }); - maybeHandlePlatformEvent((event as Record).message, sessionId); - return false; - } - case 'Ping': - return false; - default: - return false; - } - }; - - return processEvent; -} - const i18n = defineMessages({ notificationTitle: { id: 'chat.notification.taskComplete.title', @@ -438,63 +163,26 @@ export function useAcpChatSession({ const intl = useIntl(); const [state, dispatch] = useReducer(streamReducer, initialState); - // Long-lived SSE connection for this session - const { addListener, setActiveRequestsHandler } = useSessionEvents(sessionId); - - // Track the active request for cancellation (includes the session that started it) - const activeRequestIdRef = useRef(null); - const activeRequestSessionIdRef = useRef(null); - const activeAbortRef = useRef(null); - const activeUnsubscribeRef = useRef<(() => void) | null>(null); - // When ActiveRequests fires before resumeAgent populates messages (cold mount), - // defer the reattach until the session is loaded so the event processor has - // the full conversation history. Events are buffered in the meantime. - const pendingReattachRequestIdRef = useRef(null); - const pendingReattachBufferRef = useRef([]); const namePollingRef = useRef | null>(null); // Ref to access latest state in callbacks (avoids stale closures) const stateRef = useRef(state); stateRef.current = state; - const doReattachRef = useRef<((requestId: string, messages: Message[]) => void) | null>(null); - const acpAdapterRef = useRef( - createAcpSessionNotificationAdapter() - ); - - const dispatchAcpChatStateChanges = useCallback((chatStateChanges: AcpChatStateChange[]) => { - for (const chatStateChange of chatStateChanges) { - dispatch({ type: 'APPLY_ACP_CHAT_STATE_CHANGE', payload: chatStateChange }); - } - }, []); - - useEffect(() => { - const messages = state.session?.id === sessionId ? state.messages : []; - acpAdapterRef.current = createAcpSessionNotificationAdapter(messages); - }, [sessionId, state.messages, state.session?.id]); useEffect(() => { if (!sessionId) { return; } - const unsubscribeAcp = subscribeToAcpSession(sessionId, (notification) => { - dispatchAcpChatStateChanges(acpAdapterRef.current.apply(notification)); - }); - const unsubscribeGoose = subscribeToAcpGooseSession(sessionId, (notification) => { - dispatchAcpChatStateChanges(acpAdapterRef.current.applyGoose(notification)); - }); - const unsubscribePermissionRequests = subscribeToAcpPermissionRequests(sessionId, (request) => { - dispatchAcpChatStateChanges(acpAdapterRef.current.applyPermissionRequest(request)); - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.WaitingForUserInput }); - }); + const snapshot = acpChatSessionStore.getSnapshot(sessionId); + if (snapshot) { + dispatch({ type: 'SYNC_FROM_ACP_STORE', payload: snapshot }); + } - return () => { - unsubscribeAcp(); - unsubscribeGoose(); - unsubscribePermissionRequests(); - cancelAcpPermissionRequestsForSession(sessionId); - }; - }, [dispatchAcpChatStateChanges, sessionId]); + return acpChatSessionStore.subscribe(sessionId, (nextSnapshot) => { + dispatch({ type: 'SYNC_FROM_ACP_STORE', payload: nextSnapshot }); + }); + }, [sessionId]); useEffect(() => { return () => { @@ -505,23 +193,15 @@ export function useAcpChatSession({ }; }, [sessionId]); - useEffect(() => { - if (state.session) { - resultsCache.set(sessionId, { session: state.session, messages: state.messages }); - } - }, [sessionId, state.session, state.messages]); - const onFinish = useCallback( async (error?: string): Promise => { - // Note: SSE listener/ref cleanup is handled by the terminal-event - // handler in each listener closure (which guards on requestId) so - // that overlapping requests don't clobber each other's state. - if (namePollingRef.current) { clearTimeout(namePollingRef.current); namePollingRef.current = null; } + acpChatSessionStore.setSessionLoadError(sessionId, error); + acpChatSessionStore.setChatState(sessionId, ChatState.Idle); dispatch({ type: 'STREAM_FINISH', payload: error }); if (!error) { @@ -558,12 +238,11 @@ export function useAcpChatSession({ throwOnError: true, }); if (response.data?.name) { - dispatch({ - type: 'SET_SESSION', - payload: currentState.session - ? { ...currentState.session, name: response.data.name } - : undefined, - }); + const updatedSession = currentState.session + ? { ...currentState.session, name: response.data.name } + : undefined; + acpChatSessionStore.setSessionMetadata(sessionId, updatedSession); + dispatch({ type: 'SET_SESSION', payload: updatedSession }); window.dispatchEvent( new CustomEvent(AppEvents.SESSION_RENAMED, { detail: { sessionId, newName: response.data.name }, @@ -581,236 +260,47 @@ export function useAcpChatSession({ [intl, onStreamFinish, sessionId] ); - // Reload the full conversation from the server, e.g. after the SSE - // stream indicates the client fell too far behind the replay buffer. - const reloadConversation = useCallback(() => { - getSession({ - path: { session_id: sessionId }, - throwOnError: true, - }) - .then((response) => { - const session = response.data as Session; - if (session?.conversation) { - dispatch({ type: 'SET_MESSAGES', payload: session.conversation }); - } - }) - .catch((e) => { - console.warn('Failed to reload conversation after buffer overflow:', e); - }); - }, [sessionId]); - - // Perform the actual reattach: wire up an event processor and listener - // for a request that is already in-flight on the server. - const doReattach = useCallback( - (requestId: string, messages: Message[]) => { - activeRequestIdRef.current = requestId; - activeRequestSessionIdRef.current = sessionId; - pendingReattachRequestIdRef.current = null; - - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Streaming }); - dispatch({ type: 'SET_SESSION_LOAD_ERROR', payload: undefined }); - - const processEvent = createEventProcessor( - messages, - dispatch, - onFinish, - sessionId, - reloadConversation - ); - - // Replay any events that were buffered during cold-mount wait - const buffered = pendingReattachBufferRef.current; - pendingReattachBufferRef.current = []; - let finished = false; - for (const event of buffered) { - if (processEvent(event)) { - finished = true; - break; - } - } - - if (finished) { - // The reply already completed while we were waiting for session load. - // Clean up — the buffering listener will be replaced below but the - // old one captured into activeUnsubscribeRef should be removed. - if (activeUnsubscribeRef.current) { - activeUnsubscribeRef.current(); - activeUnsubscribeRef.current = null; - } - activeRequestIdRef.current = null; - activeRequestSessionIdRef.current = null; - return; - } - - // Replace the buffering listener with a real processing listener - if (activeUnsubscribeRef.current) { - activeUnsubscribeRef.current(); - } - const unsubscribe = addListener(requestId, (event) => { - const isTerminal = processEvent(event); - if (isTerminal) { - unsubscribe(); - if (activeRequestIdRef.current === requestId) { - activeUnsubscribeRef.current = null; - activeRequestIdRef.current = null; - activeRequestSessionIdRef.current = null; - } - } - }); - activeUnsubscribeRef.current = unsubscribe; - }, - [sessionId, addListener, onFinish, reloadConversation] - ); - doReattachRef.current = doReattach; - - // Reattach to in-flight replies discovered via the SSE ActiveRequests event. - // This handles the case where the chat view remounts while a reply is still - // running on the server — the new hook instance picks up the existing request - // and starts processing its events. - useEffect(() => { - setActiveRequestsHandler((requestIds: string[]) => { - // Only reattach if we don't already have an active request - if (activeRequestIdRef.current) return; - if (requestIds.length === 0) return; - - // Reattach to the first (most recent) active request. - // Multiple concurrent requests per session aren't supported in the UI. - const requestId = requestIds[0]; - const currentMessages = stateRef.current.messages; - - if (currentMessages.length === 0) { - // Cold mount: resumeAgent hasn't populated messages yet. - // Defer event processing until session load completes so the - // processor starts with the full conversation history. - // Register a buffering listener NOW so replayed events aren't - // lost while we wait. - pendingReattachRequestIdRef.current = requestId; - pendingReattachBufferRef.current = []; - activeRequestIdRef.current = requestId; - activeRequestSessionIdRef.current = sessionId; - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Streaming }); - dispatch({ type: 'SET_SESSION_LOAD_ERROR', payload: undefined }); - - const unsubscribe = addListener(requestId, (event) => { - pendingReattachBufferRef.current.push(event); - }); - activeUnsubscribeRef.current = unsubscribe; - return; - } - - doReattach(requestId, currentMessages); - }); - - return () => { - setActiveRequestsHandler(null); - }; - }, [sessionId, addListener, onFinish, reloadConversation, setActiveRequestsHandler, doReattach]); - - /** - * Submit a message via the new POST+SSE pattern. - * 1. Generate request_id - * 2. Register SSE listener BEFORE POST (no race condition) - * 3. POST to /sessions/{id}/reply - * 4. Events arrive on the long-lived SSE connection - */ - const submitToSession = useCallback( - async ( - targetSessionId: string, - userMessage: Message, - currentMessages: Message[], - overrideConversation?: Message[] - ) => { - const requestId = uuidv7(); - const abortController = new AbortController(); - activeRequestIdRef.current = requestId; - activeRequestSessionIdRef.current = targetSessionId; - activeAbortRef.current = abortController; - - // Create event processor and register listener BEFORE the POST - const processEvent = createEventProcessor( - currentMessages, - dispatch, - onFinish, - targetSessionId, - reloadConversation - ); - - const unsubscribe = addListener(requestId, (event) => { - const isTerminal = processEvent(event); - if (isTerminal) { - unsubscribe(); - // Only clear global refs if this request is still the active one. - // A newer request may have already replaced them. - if (activeRequestIdRef.current === requestId) { - activeUnsubscribeRef.current = null; - activeRequestIdRef.current = null; - activeRequestSessionIdRef.current = null; - activeAbortRef.current = null; - } - } - }); - activeUnsubscribeRef.current = unsubscribe; - - try { - await sessionReply({ - path: { id: targetSessionId }, - body: { - request_id: requestId, - user_message: userMessage, - override_conversation: overrideConversation, - }, - signal: abortController.signal, - throwOnError: true, - }); - } catch (error) { - // Abort is expected when stopStreaming races with the POST - if (abortController.signal.aborted) return; - // POST failed — clean up listener and report error. - // Only clear global refs if this request is still the active one; - // a newer request may have already replaced them. - unsubscribe(); - if (activeRequestIdRef.current === requestId) { - activeUnsubscribeRef.current = null; - activeRequestIdRef.current = null; - activeRequestSessionIdRef.current = null; - activeAbortRef.current = null; - } - const msg = errorMessage(error); - if (msg.includes('already has an active request')) { - dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); - } else { - onFinish('Submit error: ' + msg); - } - } - }, - [addListener, onFinish, reloadConversation] - ); - const submitToAcpSession = useCallback( async (targetSessionId: string, userMessage: Message) => { - activeRequestSessionIdRef.current = targetSessionId; + const promptAttemptId = uuidv7(); + acpChatSessionStore.startPromptAttempt(targetSessionId, promptAttemptId); try { await acpPromptSession(targetSessionId, userMessage); - onFinish(); + 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: [ - ...stateRef.current.messages, - createAcpCreditsExhaustedMessage(creditsExhaustedError), - ], + payload: messages, }); - onFinish(); + if (acpChatSessionStore.finishPromptAttemptIfCurrent(targetSessionId, promptAttemptId)) { + onFinish(); + } return; } - onFinish('Submit error: ' + errorMessage(error)); - } finally { - if (activeRequestSessionIdRef.current === targetSessionId) { - activeRequestSessionIdRef.current = null; + const submitError = 'Submit error: ' + errorMessage(error); + if ( + acpChatSessionStore.finishPromptAttemptIfCurrent( + targetSessionId, + promptAttemptId, + submitError + ) + ) { + onFinish(submitError); } } }, @@ -821,23 +311,9 @@ export function useAcpChatSession({ useEffect(() => { if (!sessionId) return; - const cached = resultsCache.get(sessionId); - if (cached) { - dispatch({ - type: 'SESSION_LOADED', - payload: { - session: cached.session, - messages: cached.messages, - tokenState: { - inputTokens: cached.session?.input_tokens ?? 0, - outputTokens: cached.session?.output_tokens ?? 0, - totalTokens: cached.session?.total_tokens ?? 0, - accumulatedInputTokens: cached.session?.accumulated_input_tokens ?? 0, - accumulatedOutputTokens: cached.session?.accumulated_output_tokens ?? 0, - accumulatedTotalTokens: cached.session?.accumulated_total_tokens ?? 0, - }, - }, - }); + 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 } }) ); @@ -872,62 +348,9 @@ export function useAcpChatSession({ new CustomEvent(AppEvents.SESSION_EXTENSIONS_LOADED, { detail: { sessionId } }) ); - const pendingRequestId = pendingReattachRequestIdRef.current; - const reattachedToActiveRequest = activeRequestIdRef.current !== null; - - if (pendingRequestId) { - // Cold-mount reattach: ActiveRequests arrived before resumeAgent - // returned. Load session state first, then complete the reattach - // with the full conversation so the event processor has context. - dispatch({ - type: 'SESSION_LOADED', - payload: { - session: loadedSession!, - messages: loadedSession?.conversation || [], - tokenState: { - inputTokens: loadedSession?.input_tokens ?? 0, - outputTokens: loadedSession?.output_tokens ?? 0, - totalTokens: loadedSession?.total_tokens ?? 0, - accumulatedInputTokens: loadedSession?.accumulated_input_tokens ?? 0, - accumulatedOutputTokens: loadedSession?.accumulated_output_tokens ?? 0, - accumulatedTotalTokens: loadedSession?.accumulated_total_tokens ?? 0, - }, - }, - }); - // Now complete the deferred reattach with the loaded messages - doReattachRef.current?.(pendingRequestId, loadedSession?.conversation || []); - } else if (reattachedToActiveRequest) { - // ActiveRequests already wired up an event processor with existing - // messages — only load session metadata, don't overwrite messages - // with the stale DB snapshot. - dispatch({ type: 'SET_SESSION', payload: loadedSession }); - dispatch({ - type: 'SET_TOKEN_STATE', - payload: { - inputTokens: loadedSession?.input_tokens ?? 0, - outputTokens: loadedSession?.output_tokens ?? 0, - totalTokens: loadedSession?.total_tokens ?? 0, - accumulatedInputTokens: loadedSession?.accumulated_input_tokens ?? 0, - accumulatedOutputTokens: loadedSession?.accumulated_output_tokens ?? 0, - accumulatedTotalTokens: loadedSession?.accumulated_total_tokens ?? 0, - }, - }); - } else { - dispatch({ - type: 'SESSION_LOADED', - payload: { - session: loadedSession!, - messages: loadedSession?.conversation || [], - tokenState: { - inputTokens: loadedSession?.input_tokens ?? 0, - outputTokens: loadedSession?.output_tokens ?? 0, - totalTokens: loadedSession?.total_tokens ?? 0, - accumulatedInputTokens: loadedSession?.accumulated_input_tokens ?? 0, - accumulatedOutputTokens: loadedSession?.accumulated_output_tokens ?? 0, - accumulatedTotalTokens: loadedSession?.accumulated_total_tokens ?? 0, - }, - }, - }); + if (loadedSession) { + const snapshot = acpChatSessionStore.setLoadedSession(sessionId, loadedSession); + dispatch({ type: 'SYNC_FROM_ACP_STORE', payload: snapshot }); } listApps({ @@ -941,7 +364,10 @@ export function useAcpChatSession({ } catch (error) { if (cancelled) return; - dispatch({ type: 'STREAM_ERROR', payload: errorMessage(error) }); + const loadError = errorMessage(error); + acpChatSessionStore.setSessionLoadError(sessionId, loadError); + acpChatSessionStore.setChatState(sessionId, ChatState.Idle); + dispatch({ type: 'STREAM_ERROR', payload: loadError }); } })(); @@ -989,12 +415,11 @@ export function useAcpChatSession({ const newName = response.data?.name; if (newName && newName !== currentName) { - dispatch({ - type: 'SET_SESSION', - payload: currentState.session - ? { ...currentState.session, name: newName } - : undefined, - }); + const updatedSession = currentState.session + ? { ...currentState.session, name: newName } + : undefined; + acpChatSessionStore.setSessionMetadata(sessionId, updatedSession); + dispatch({ type: 'SET_SESSION', payload: updatedSession }); window.dispatchEvent( new CustomEvent(AppEvents.SESSION_RENAMED, { detail: { sessionId, newName }, @@ -1027,9 +452,11 @@ export function useAcpChatSession({ : [...currentState.messages]; if (hasNewMessage) { + acpChatSessionStore.setMessages(sessionId, currentMessages); dispatch({ type: 'SET_MESSAGES', payload: currentMessages }); } + acpChatSessionStore.setChatState(sessionId, ChatState.Streaming); dispatch({ type: 'START_STREAMING' }); await submitToAcpSession(sessionId, newMessage); @@ -1042,29 +469,17 @@ export function useAcpChatSession({ const currentState = stateRef.current; if (!currentState.session || currentState.chatState === ChatState.LoadingConversation) { - return; + return false; } - // An elicitation response unblocks an in-flight tool call on the original - // request's SSE stream — don't start a new stream or flip chat state. - const responseMessage = createElicitationResponseMessage(elicitationId, userData); - const nextMessages = [...currentState.messages, responseMessage]; - dispatch({ type: 'SET_MESSAGES', payload: nextMessages }); - - try { - await sessionReply({ - path: { id: sessionId }, - body: { - request_id: uuidv7(), - user_message: responseMessage, - }, - throwOnError: true, - }); - } catch (error) { - onFinish('Submit error: ' + errorMessage(error)); + if (!resolveAcpElicitationRequest(sessionId, elicitationId, userData)) { + console.error('No pending ACP elicitation request found', { sessionId, elicitationId }); + return false; } + + return true; }, - [sessionId, onFinish] + [sessionId] ); const setRecipeUserParams = useCallback( @@ -1081,14 +496,17 @@ export function useAcpChatSession({ }, throwOnError: true, }); - dispatch({ - type: 'SET_SESSION', - payload: { - ...currentState.session, - user_recipe_values, - }, - }); + 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", @@ -1110,74 +528,41 @@ export function useAcpChatSession({ }, [state.session]); const stopStreaming = useCallback(() => { - const requestId = activeRequestIdRef.current; - const requestSessionId = activeRequestSessionIdRef.current; + const storedPromptAttemptId = acpChatSessionStore.getSnapshot(sessionId)?.activePromptAttemptId; + const hasStoredAcpPrompt = + storedPromptAttemptId !== null && storedPromptAttemptId !== undefined; - // Abort the in-flight POST so the reply never starts if cancel wins the race - if (activeAbortRef.current) { - activeAbortRef.current.abort(); - activeAbortRef.current = null; - } - - if (requestId && requestSessionId) { - // Cancel against the session that originally started the request, - // not the current sessionId (which may have changed if user navigated). - sessionCancel({ - path: { id: requestSessionId }, - body: { request_id: requestId }, - }).catch((e) => { - console.warn('Failed to cancel request:', e); - }); - } else if (requestSessionId) { - cancelAcpPermissionRequestsForSession(requestSessionId); - acpCancelPrompt(requestSessionId).catch((e) => { + if (hasStoredAcpPrompt) { + acpChatSessionStore.clearActivePromptAttempt(sessionId); + cancelAcpPermissionRequestsForSession(sessionId); + cancelAcpElicitationRequestsForSession(sessionId); + acpCancelPrompt(sessionId).catch((e) => { console.warn('Failed to cancel ACP prompt:', e); }); } - // Clean up listener - if (activeUnsubscribeRef.current) { - activeUnsubscribeRef.current(); - activeUnsubscribeRef.current = null; - } - activeRequestIdRef.current = null; - activeRequestSessionIdRef.current = null; - + acpChatSessionStore.setChatState(sessionId, ChatState.Idle); dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Idle }); - }, []); + }, [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 { forkSession } = await import('../api'); const message = currentState.messages.find((m) => m.id === messageId); if (!message) { throw new Error(`Message with id ${messageId} not found in current messages`); } - const response = await forkSession({ - path: { - session_id: sessionId, - }, - body: { - timestamp: message.created, - truncate: true, - copy: editType === 'fork', - }, - throwOnError: true, - }); - - const targetSessionId = response.data?.sessionId; - if (!targetSessionId) { - throw new Error('No session ID returned from fork'); - } - 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: { @@ -1189,32 +574,29 @@ export function useAcpChatSession({ window.dispatchEvent(event); window.electron.logInfo(`Dispatched session-forked event for session ${targetSessionId}`); } else { - const { getSession } = await import('../api'); - const sessionResponse = await getSession({ - path: { session_id: targetSessionId }, - throwOnError: true, - }); + await acpTruncateSessionConversation(sessionId, message.created); - if (sessionResponse.data?.conversation) { - const truncatedMessages = [...sessionResponse.data.conversation]; - const updatedUserMessage = createUserMessage(newContent); + 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); - } + for (const content of message.content) { + if (content.type === 'image') { + updatedUserMessage.content.push(content); } - - const messagesForUI = [...truncatedMessages, updatedUserMessage]; - dispatch({ type: 'SET_MESSAGES', payload: messagesForUI }); - dispatch({ type: 'START_STREAMING' }); - - await submitToSession(targetSessionId, updatedUserMessage, messagesForUI); - } else { - await handleSubmit({ msg: newContent, images: [] }); } + + 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); } } 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); @@ -1225,27 +607,22 @@ export function useAcpChatSession({ }); } }, - [sessionId, handleSubmit, submitToSession] + [sessionId, submitToAcpSession] ); - const setChatState = useCallback((newState: ChatState) => { - dispatch({ type: 'SET_CHAT_STATE', payload: newState }); - }, []); + const setChatState = useCallback( + (newState: ChatState) => { + acpChatSessionStore.setChatState(sessionId, newState); + dispatch({ type: 'SET_CHAT_STATE', payload: newState }); + }, + [sessionId] + ); - const cached = resultsCache.get(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) => { - const key = notification.request_id; - if (!map.has(key)) { - map.set(key, []); - } - map.get(key)!.push(notification); - return map; - }, new Map()); - }, [state.notifications]); + const notificationsMap = useMemo(() => new Map(), []); return { sessionLoadError: state.sessionLoadError, @@ -1259,6 +636,7 @@ export function useAcpChatSession({ setRecipeUserParams, tokenState: state.tokenState, notifications: notificationsMap, + pauseQueueOnStop: true, onMessageUpdate, }; } diff --git a/ui/desktop/src/hooks/useChatSessionTypes.ts b/ui/desktop/src/hooks/useChatSessionTypes.ts index 85ee82599..14f2e065f 100644 --- a/ui/desktop/src/hooks/useChatSessionTypes.ts +++ b/ui/desktop/src/hooks/useChatSessionTypes.ts @@ -17,12 +17,13 @@ export interface UseChatSessionResult { submitElicitationResponse: ( elicitationId: string, userData: Record - ) => Promise; + ) => Promise; setRecipeUserParams: (values: Record) => Promise; stopStreaming: () => void; sessionLoadError?: string; tokenState: TokenState; notifications: Map; + pauseQueueOnStop: boolean; onMessageUpdate: ( messageId: string, newContent: string, diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 69cb0a778..339be423b 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -929,7 +929,7 @@ export function useChatStream({ const currentState = stateRef.current; if (!currentState.session || currentState.chatState === ChatState.LoadingConversation) { - return; + return true; } // An elicitation response unblocks an in-flight tool call on the original @@ -947,8 +947,10 @@ export function useChatStream({ }, throwOnError: true, }); + return true; } catch (error) { onFinish('Submit error: ' + errorMessage(error)); + return true; } }, [sessionId, onFinish] @@ -1141,6 +1143,7 @@ export function useChatStream({ setRecipeUserParams, tokenState: state.tokenState, notifications: notificationsMap, + pauseQueueOnStop: false, onMessageUpdate, }; } diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index 1581b255b..1bf2f93ab 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -992,6 +992,9 @@ "elicitationRequest.submit": { "defaultMessage": "Submit" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "Information submitted" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index 5c1c32e5a..5a8aa3b8f 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -992,6 +992,9 @@ "elicitationRequest.submit": { "defaultMessage": "Enviar" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "Información enviada" }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index 185d43e75..1834d8395 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -992,6 +992,9 @@ "elicitationRequest.submit": { "defaultMessage": "सबमिट करें" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "जानकारी प्रस्तुत की गई" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index 9e1d7c653..761711457 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -992,6 +992,9 @@ "elicitationRequest.submit": { "defaultMessage": "送信" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "情報を送信しました" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index 827aa3922..ce9603f08 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -992,6 +992,9 @@ "elicitationRequest.submit": { "defaultMessage": "Отправить" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "Информация отправлена" }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index 40950b1c4..99d54d7f5 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -992,6 +992,9 @@ "elicitationRequest.submit": { "defaultMessage": "Gönder" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "Bilgiler gönderildi" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index 5fcb3ef0f..004398a7f 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -992,6 +992,9 @@ "elicitationRequest.submit": { "defaultMessage": "提交" }, + "elicitationRequest.submitError": { + "defaultMessage": "This request is no longer active. The extension will need to ask again." + }, "elicitationRequest.submitted": { "defaultMessage": "信息已提交" }, diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index bb5e4f44f..c9a117b20 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -101,6 +101,7 @@ import type { SetSessionSystemPromptRequest_unstable, SteerSessionRequest_unstable, SteerSessionResponse_unstable, + TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, @@ -575,6 +576,15 @@ export class GooseExtClient { ) as GetSessionInfoResponse_unstable; } + async sessionConversationTruncate_unstable( + params: TruncateSessionConversationRequest_unstable, + ): Promise { + await this.conn.extMethod( + "_goose/unstable/session/conversation/truncate", + params, + ); + } + async sessionProjectUpdate_unstable( params: UpdateSessionProjectRequest_unstable, ): Promise { diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index bd7833ec2..555182187 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, ResourceLink, Role, SessionId, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, TextContent, TextResourceContents, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; +export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, ResourceLink, Role, SessionId, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, TextContent, TextResourceContents, TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -203,6 +203,11 @@ export const GOOSE_EXT_METHODS = [ requestType: "GetSessionInfoRequest_unstable", responseType: "GetSessionInfoResponse_unstable", }, + { + method: "_goose/unstable/session/conversation/truncate", + requestType: "TruncateSessionConversationRequest_unstable", + responseType: "EmptyResponse", + }, { method: "_goose/unstable/session/project/update", requestType: "UpdateSessionProjectRequest_unstable", diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index c2ceb32e8..7a97352d3 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -1197,6 +1197,14 @@ export type SessionInfo = { */ export type SessionId = string; +/** + * Truncate a session conversation from the given message timestamp onward. + */ +export type TruncateSessionConversationRequest_unstable = { + sessionId: string; + truncateFrom: number; +}; + /** * Update the project association for a session. */ @@ -1594,7 +1602,7 @@ export type StatusMessageUpdate = { export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | GetSessionInfoRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | { + params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | { [key: string]: unknown; } | null; }; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 614d7a47c..54b87f001 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -1173,6 +1173,14 @@ export const zGetSessionInfoResponse_unstable = z.object({ session: zSessionInfo }); +/** + * Truncate a session conversation from the given message timestamp onward. + */ +export const zTruncateSessionConversationRequest_unstable = z.object({ + sessionId: z.string(), + truncateFrom: z.number().int() +}); + /** * Update the project association for a session. */ @@ -1605,6 +1613,7 @@ export const zExtRequest = z.object({ zExportSessionRequest_unstable, zImportSessionRequest_unstable, zGetSessionInfoRequest_unstable, + zTruncateSessionConversationRequest_unstable, zUpdateSessionProjectRequest_unstable, zRenameSessionRequest_unstable, zArchiveSessionRequest_unstable,