diff --git a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts index 90bcf0e7a28e..30683c3a6b4a 100644 --- a/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts +++ b/ui/desktop/src/acp/__tests__/chatSessionStore.test.ts @@ -170,6 +170,20 @@ function activeRunNotification(sessionId: string, activeRunId: string | null): S }; } +function queuedSteerNotification(sessionId: string, messageId: string): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'session_info_update', + _meta: { + goose: { + queuedSteer: { messageId, runId: 'run-1' }, + }, + }, + } as SessionNotification['update'], + }; +} + describe('acpChatSessionStore', () => { const sessionIds = new Set(); const sessionId = (id: string): string => { @@ -417,6 +431,36 @@ describe('acpChatSessionStore', () => { expect(firstMessage?.content[0]).toMatchObject({ type: 'text', text: 'hello' }); }); + it('keeps steer message confirmed when queuedSteer arrives before addPendingLocalSteerMessage', () => { + const currentSessionId = sessionId('session-1'); + const localSteerMessage = { + ...message('steer-1', 'hello'), + metadata: { userVisible: true, agentVisible: true, steer: true }, + }; + + acpChatSessionActions.startPromptAttempt(currentSessionId, 'attempt-1'); + + // queuedSteer notification arrives before addPendingLocalSteerMessage (race condition) + acpChatSessionActions.applyAcpSessionNotification( + queuedSteerNotification(currentSessionId, 'steer-1') + ); + + // Now the UI adds the pending message after the RPC response returns + acpChatSessionActions.addPendingLocalSteerMessage(currentSessionId, localSteerMessage); + + // Message should be present and NOT in pending (already confirmed via queuedSteer) + const snapshot = acpChatSessionStore.getSnapshot(currentSessionId); + expect(snapshot?.messages).toHaveLength(1); + + // Cancellation should keep it because it's confirmed, not pending + const cancellationSnapshot = acpChatSessionActions.startPromptCancellation( + currentSessionId, + 'attempt-1' + ); + expect(cancellationSnapshot?.messages).toHaveLength(1); + expect(cancellationSnapshot?.messages[0].id).toBe('steer-1'); + }); + it('stores active run ids from session info notifications', () => { const currentSessionId = sessionId('session-1'); diff --git a/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts b/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts index c9037f678c05..d63b855ba523 100644 --- a/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts +++ b/ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts @@ -633,4 +633,47 @@ describe('createAcpSessionNotificationAdapter', () => { }); }); }); + + describe('session_info_update with queuedSteer', () => { + it('emits localSteerConfirmed when queuedSteer meta is present', () => { + const adapter = createAcpSessionNotificationAdapter(); + const changes = adapter.apply( + acpUpdate({ + sessionUpdate: 'session_info_update', + _meta: { + goose: { + queuedSteer: { messageId: 'steer-msg-1', runId: 'run-1' }, + }, + }, + }) + ); + + expect(changes).toEqual([{ type: 'localSteerConfirmed', messageId: 'steer-msg-1' }]); + }); + + it('emits both sessionInfo and localSteerConfirmed when both are present', () => { + const adapter = createAcpSessionNotificationAdapter(); + const changes = adapter.apply( + acpUpdate({ + sessionUpdate: 'session_info_update', + title: 'New Title', + _meta: { + goose: { + queuedSteer: { messageId: 'steer-msg-2', runId: 'run-2' }, + }, + }, + }) + ); + + expect(changes).toHaveLength(2); + expect(changes[0]).toEqual({ type: 'sessionInfo', name: 'New Title' }); + expect(changes[1]).toEqual({ type: 'localSteerConfirmed', messageId: 'steer-msg-2' }); + }); + + it('returns empty array when session_info_update has no relevant fields', () => { + const adapter = createAcpSessionNotificationAdapter(); + const changes = adapter.apply(acpUpdate({ sessionUpdate: 'session_info_update' })); + expect(changes).toEqual([]); + }); + }); }); diff --git a/ui/desktop/src/acp/adapter/shared.ts b/ui/desktop/src/acp/adapter/shared.ts index c52eb43d3c0f..20ab5d71ef8f 100644 --- a/ui/desktop/src/acp/adapter/shared.ts +++ b/ui/desktop/src/acp/adapter/shared.ts @@ -10,6 +10,7 @@ export type AcpChatStateChange = type: 'sessionInfo'; name?: string; activeRunId?: string | null; + gooseMode?: string; } | { type: 'localSteerConfirmed'; messageId: string } | { type: 'notification'; notification: NotificationEvent }; @@ -79,6 +80,13 @@ export function getGooseActiveRunId(update: { _meta?: unknown }): string | null : undefined; } +export function getGooseQueuedSteer(update: { _meta?: unknown }): string | undefined { + if (!isRecord(update._meta)) return undefined; + const goose = update._meta.goose; + if (!isRecord(goose) || !isRecord(goose.queuedSteer)) return undefined; + return typeof goose.queuedSteer.messageId === 'string' ? goose.queuedSteer.messageId : undefined; +} + export function rawInputToArguments(rawInput: unknown): Record { return isRecord(rawInput) ? rawInput : {}; } diff --git a/ui/desktop/src/acp/chatSessionStore.ts b/ui/desktop/src/acp/chatSessionStore.ts index b773f260b725..5a4cea6b0c69 100644 --- a/ui/desktop/src/acp/chatSessionStore.ts +++ b/ui/desktop/src/acp/chatSessionStore.ts @@ -38,6 +38,7 @@ interface StoreEntry extends AcpChatSessionSnapshot { } | null; pendingUserInputRequestIds: Set; pendingLocalSteerMessageIds: Set; + preConfirmedSteerMessageIds: Set; } const initialTokenState: TokenState = { @@ -165,6 +166,7 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { promptCancellationRestoreState: null, pendingUserInputRequestIds: new Set(), pendingLocalSteerMessageIds: new Set(), + preConfirmedSteerMessageIds: new Set(), adapter: createAcpSessionNotificationAdapter(), }; sessionsById.set(sessionId, entry); @@ -235,7 +237,9 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal { } entry.messages = [...entry.messages, cloneMessage(message)]; - entry.pendingLocalSteerMessageIds.add(message.id); + if (!entry.preConfirmedSteerMessageIds.delete(message.id)) { + entry.pendingLocalSteerMessageIds.add(message.id); + } entry.adapter = createAdapterForEntry(entry); return notify(sessionId, entry); }; @@ -606,7 +610,9 @@ function applyChatStateChanges(entry: StoreEntry, changes: AcpChatStateChange[]) } break; case 'localSteerConfirmed': - entry.pendingLocalSteerMessageIds.delete(change.messageId); + if (!entry.pendingLocalSteerMessageIds.delete(change.messageId)) { + entry.preConfirmedSteerMessageIds.add(change.messageId); + } break; case 'notification': entry.notifications = [...entry.notifications, change.notification]; @@ -636,6 +642,7 @@ function resetReplayState(entry: StoreEntry): void { entry.promptCancellationRestoreState = null; entry.pendingUserInputRequestIds.clear(); entry.pendingLocalSteerMessageIds.clear(); + entry.preConfirmedSteerMessageIds.clear(); entry.adapter = createAcpSessionNotificationAdapter(); } diff --git a/ui/desktop/src/acp/sessionNotificationAdapter.ts b/ui/desktop/src/acp/sessionNotificationAdapter.ts index d881d5923b3c..adebd8c5e7a3 100644 --- a/ui/desktop/src/acp/sessionNotificationAdapter.ts +++ b/ui/desktop/src/acp/sessionNotificationAdapter.ts @@ -14,6 +14,7 @@ import { type AdapterState, cloneMessage, getGooseActiveRunId, + getGooseQueuedSteer, } from './adapter/shared'; import { applyToolCall, applyToolCallUpdate } from './adapter/tools'; import type { AcpElicitationRequest } from './elicitationRequests'; @@ -79,17 +80,22 @@ function applyAcpSessionNotification( return applyToolCallUpdate(state, update); case 'session_info_update': { const activeRunId = getGooseActiveRunId(update); - if (!update.title && activeRunId === undefined) { - return []; - } + const queuedSteerMessageId = getGooseQueuedSteer(update); + const changes: AcpChatStateChange[] = []; - return [ - { + if (update.title || activeRunId !== undefined) { + changes.push({ type: 'sessionInfo', ...(update.title ? { name: update.title } : {}), ...(activeRunId !== undefined ? { activeRunId } : {}), - }, - ]; + }); + } + + if (queuedSteerMessageId) { + changes.push({ type: 'localSteerConfirmed', messageId: queuedSteerMessageId }); + } + + return changes; } case 'usage_update': return [];