Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions ui/desktop/src/acp/__tests__/chatSessionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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');

Expand Down
43 changes: 43 additions & 0 deletions ui/desktop/src/acp/__tests__/sessionNotificationAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 8 additions & 0 deletions ui/desktop/src/acp/adapter/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type AcpChatStateChange =
type: 'sessionInfo';
name?: string;
activeRunId?: string | null;
gooseMode?: string;
}
| { type: 'localSteerConfirmed'; messageId: string }
| { type: 'notification'; notification: NotificationEvent };
Expand Down Expand Up @@ -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 : {};
}
Expand Down
11 changes: 9 additions & 2 deletions ui/desktop/src/acp/chatSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface StoreEntry extends AcpChatSessionSnapshot {
} | null;
pendingUserInputRequestIds: Set<string>;
pendingLocalSteerMessageIds: Set<string>;
preConfirmedSteerMessageIds: Set<string>;
}

const initialTokenState: TokenState = {
Expand Down Expand Up @@ -165,6 +166,7 @@ function createAcpChatSessionStoreInternal(): AcpChatSessionStoreInternal {
promptCancellationRestoreState: null,
pendingUserInputRequestIds: new Set(),
pendingLocalSteerMessageIds: new Set(),
preConfirmedSteerMessageIds: new Set(),
adapter: createAcpSessionNotificationAdapter(),
};
sessionsById.set(sessionId, entry);
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -636,6 +642,7 @@ function resetReplayState(entry: StoreEntry): void {
entry.promptCancellationRestoreState = null;
entry.pendingUserInputRequestIds.clear();
entry.pendingLocalSteerMessageIds.clear();
entry.preConfirmedSteerMessageIds.clear();
entry.adapter = createAcpSessionNotificationAdapter();
}

Expand Down
20 changes: 13 additions & 7 deletions ui/desktop/src/acp/sessionNotificationAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type AdapterState,
cloneMessage,
getGooseActiveRunId,
getGooseQueuedSteer,
} from './adapter/shared';
import { applyToolCall, applyToolCallUpdate } from './adapter/tools';
import type { AcpElicitationRequest } from './elicitationRequests';
Expand Down Expand Up @@ -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 });
Comment thread
Abhijay007 marked this conversation as resolved.
}

return changes;
}
case 'usage_update':
return [];
Expand Down