fix(acp): confirm pending steer message on queuedSteer notification (#10532)

This commit is contained in:
Abhijay Jain
2026-07-22 00:49:38 +05:30
committed by GitHub
parent 97c98a43f0
commit c07e21d316
5 changed files with 117 additions and 9 deletions
@@ -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<string>();
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');
@@ -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([]);
});
});
});
+8
View File
@@ -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<string, unknown> {
return isRecord(rawInput) ? rawInput : {};
}
+9 -2
View File
@@ -38,6 +38,7 @@ interface StoreEntry extends AcpChatSessionSnapshot {
} | null;
pendingUserInputRequestIds: Set<string>;
pendingLocalSteerMessageIds: Set<string>;
preConfirmedSteerMessageIds: Set<string>;
}
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();
}
@@ -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 [];