Skip to content

Commit 340176e

Browse files
authored
Merge pull request #3 from itkonen/fix/restore-pending-ask-user
Restore pending ask_user questions when opening a remote chat
2 parents e19e025 + 3af2185 commit 340176e

4 files changed

Lines changed: 371 additions & 8 deletions

File tree

‎src/bridge/chat-restore.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import type { ChatContentReceivedParams } from '@webview/protocol';
1414
import type {
15+
AskQuestionData,
1516
RemoteChat,
1617
StoredMessage,
1718
StoredReasonContent,
@@ -92,6 +93,58 @@ export function chatToRestoreEvents(chat: RemoteChat): RestoreEvent[] {
9293
return events;
9394
}
9495

96+
/**
97+
* Restore actionable ask_user calls from the current snapshot, not history.
98+
* Dispatch these through chat/askQuestion after the chat's content is restored.
99+
* Older servers and calls still awaiting approval may have no requestId.
100+
*/
101+
export function chatToPendingQuestions(chat: RemoteChat): AskQuestionData[] {
102+
if (!chat?.id) return [];
103+
104+
return (chat.pendingToolCalls ?? []).flatMap((call) => {
105+
if (call.name !== 'ask_user' || !call.requestId?.trim()) return [];
106+
const args = call.arguments;
107+
if (!args || typeof args.question !== 'string' || !args.question.trim()) return [];
108+
109+
// Match ask_user's normalization: options may be strings, objects, or
110+
// a JSON-encoded array. Invalid options are ignored, not rendered as buttons.
111+
let rawOptions = args.options;
112+
if (typeof rawOptions === 'string') {
113+
try {
114+
rawOptions = JSON.parse(rawOptions);
115+
} catch {
116+
rawOptions = [];
117+
}
118+
}
119+
const options: AskQuestionData['options'] = [];
120+
if (Array.isArray(rawOptions)) {
121+
for (const option of rawOptions) {
122+
const value = typeof option === 'string' ? { label: option } : option;
123+
if (!isRecord(value) || typeof value.label !== 'string' || !value.label.trim()) continue;
124+
options.push({
125+
label: value.label,
126+
...(typeof value.description === 'string' && value.description.trim()
127+
? { description: value.description }
128+
: {}),
129+
});
130+
}
131+
}
132+
133+
return [{
134+
chatId: chat.id,
135+
toolCallId: call.id,
136+
requestId: call.requestId,
137+
question: args.question,
138+
options,
139+
allowFreeform: args.allowFreeform === undefined ? true : Boolean(args.allowFreeform),
140+
}];
141+
});
142+
}
143+
144+
function isRecord(value: unknown): value is Record<string, unknown> {
145+
return value !== null && typeof value === 'object' && !Array.isArray(value);
146+
}
147+
95148
// ---------------------------------------------------------------------------
96149
// Converters (one per stored message role)
97150
// ---------------------------------------------------------------------------

‎src/bridge/transport.ts‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
*/
1919

2020
import { EcaRemoteApi } from './api';
21-
import { chatToRestoreEvents } from './chat-restore';
21+
import { chatToPendingQuestions, chatToRestoreEvents } from './chat-restore';
2222
import { messageCache } from './message-cache';
2323
import { handleOutbound, type OutboundContext } from './outbound-handler';
2424
import { SSEClient, type SSEEvent } from './sse';
@@ -91,6 +91,9 @@ export class WebBridge {
9191
*/
9292
private loadedChatIds = new Set<string>();
9393

94+
/** Questions received during a lazy history load must wait for chat creation. */
95+
private loadingChatIds = new Set<string>();
96+
9497
/**
9598
* True after `disconnect()` has been called. Checked after every async
9699
* boundary in `connect()` so that orphaned bridges (e.g. from React
@@ -109,8 +112,8 @@ export class WebBridge {
109112
private restoring = false;
110113

111114
/**
112-
* SSE events queued while `restoring` is true. These are replayed
113-
* in order after restore completes via `flushRestoreQueue()`.
115+
* SSE events queued while restoring or lazily loading a chat. These are replayed
116+
* in order after loading completes via `flushRestoreQueue()`.
114117
*/
115118
private restoreQueue: SSEEvent[] = [];
116119

@@ -683,6 +686,9 @@ export class WebBridge {
683686
// ---------------------------------------------------------------------------
684687

685688
private handleSessionConnected(event: SSEEvent): void {
689+
// History cached before this connection cannot tell us which questions are
690+
// still pending. Invalidate all chats for this host, including lazy loads.
691+
messageCache.invalidateAll(this.host);
686692
try {
687693
const data: SSESessionConnectedPayload = JSON.parse(event.data);
688694
this.sessionState = {
@@ -714,6 +720,7 @@ export class WebBridge {
714720
'chat:cleared',
715721
'chat:deleted',
716722
'chat:opened',
723+
'chat:ask-question',
717724
]);
718725

719726
private handleSSEEvent(event: SSEEvent): void {
@@ -828,7 +835,12 @@ export class WebBridge {
828835
}
829836

830837
case 'chat:ask-question':
831-
this.dispatch('chat/askQuestion', data);
838+
messageCache.invalidate(this.host, data.chatId);
839+
if (this.loadingChatIds.has(data.chatId)) {
840+
this.restoreQueue.push(event);
841+
} else {
842+
this.dispatch('chat/askQuestion', data);
843+
}
832844
break;
833845

834846
case 'jobs:updated':
@@ -968,6 +980,7 @@ export class WebBridge {
968980
if (!candidate?.id) continue;
969981
this.currentChatId = candidate.id;
970982
const result = await this.loadChatMessages(candidate.id);
983+
if (this.disposed) return;
971984
if (result === true) {
972985
// Clear stale preferred if we had to fall back to a different chat.
973986
if (preferred && candidate.id !== preferred.id) {
@@ -1020,7 +1033,7 @@ export class WebBridge {
10201033
* to the webview. Skips if the chat was already loaded.
10211034
*
10221035
* Performance: checks the in-memory message cache first to avoid a REST
1023-
* round-trip (e.g. on tab switch or reconnection). Falls back to REST
1036+
* round-trip within a connection. Falls back to REST
10241037
* on cache miss, and populates the cache on success.
10251038
*
10261039
* Called automatically on initial connect (for the most recent chat)
@@ -1031,8 +1044,10 @@ export class WebBridge {
10311044
* `'error'` if loading failed due to a transient issue (timeout, network, 500).
10321045
*/
10331046
async loadChatMessages(chatId: string): Promise<boolean | 'not_found' | 'error'> {
1047+
if (this.disposed) return 'error';
10341048
if (this.loadedChatIds.has(chatId)) return true;
10351049

1050+
this.loadingChatIds.add(chatId);
10361051
try {
10371052
console.log(`[Bridge] Loading messages for chat ${chatId}`);
10381053

@@ -1045,6 +1060,7 @@ export class WebBridge {
10451060
} else {
10461061
// Cache miss — fetch from server with retries
10471062
const result = await this.fetchChatWithRetry(chatId);
1063+
if (this.disposed) return 'error';
10481064
if (result === WebBridge.CHAT_NOT_FOUND) return 'not_found';
10491065
if (result === WebBridge.CHAT_FETCH_ERROR) return 'error';
10501066
chat = result;
@@ -1081,11 +1097,20 @@ export class WebBridge {
10811097
// would cause N separate Immer drafts and N React renders).
10821098
this.dispatch('chat/batchContentReceived', events);
10831099

1100+
// The question reducer ignores unknown chats, so restore interactive
1101+
// state only after the content batch has created its destination.
1102+
for (const question of chatToPendingQuestions(chat)) {
1103+
this.dispatch('chat/askQuestion', question);
1104+
}
1105+
10841106
console.log(`[Bridge] Restored ${chat?.messages?.length ?? 0} message(s) for chat ${chatId}`);
10851107
return true;
10861108
} catch (err) {
10871109
console.error(`[Bridge] Failed to load messages for chat ${chatId}:`, err);
10881110
return 'error';
1111+
} finally {
1112+
this.loadingChatIds.delete(chatId);
1113+
this.flushRestoreQueue();
10891114
}
10901115
}
10911116

@@ -1132,11 +1157,12 @@ export class WebBridge {
11321157
}
11331158

11341159
/**
1135-
* Replay SSE events that were queued while `restoring` was true.
1136-
* Called in the `finally` block of `dispatchInitialState()` and
1137-
* `syncAfterReconnect()` after `restoring` is set back to false.
1160+
* Replay SSE events that were queued while restoring or lazily loading a chat.
1161+
* Called in the `finally` blocks of `dispatchInitialState()`,
1162+
* `syncAfterReconnect()`, and `loadChatMessages()`.
11381163
*/
11391164
private flushRestoreQueue(): void {
1165+
if (this.disposed || this.restoring) return;
11401166
if (this.restoreQueue.length === 0) return;
11411167

11421168
const queued = this.restoreQueue;

‎src/bridge/types.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
*/
1313

1414
import type {
15+
AskQuestionData as _AskQuestionData,
1516
ChatContext as _ChatContext,
1617
ChatContentReceivedParams as _ChatContentReceivedParams,
1718
MCPServerUpdatedParams as _MCPServerUpdatedParams,
@@ -20,6 +21,7 @@ import type {
2021

2122
// Re-export webview types used across the bridge layer,
2223
// so consumers import from one place instead of reaching into @webview.
24+
export type AskQuestionData = _AskQuestionData;
2325
export type ChatContext = _ChatContext;
2426
export type ChatContentReceivedParams = _ChatContentReceivedParams;
2527
export type MCPServerUpdatedParams = _MCPServerUpdatedParams;
@@ -73,12 +75,21 @@ export interface ChatSummary {
7375
parentChatId?: string;
7476
}
7577

78+
/** A tool call awaiting approval or an ask_user response in a chat snapshot. */
79+
export interface PendingToolCall {
80+
id: string;
81+
name: string;
82+
requestId?: string;
83+
arguments?: Record<string, unknown>;
84+
}
85+
7686
/** GET /api/v1/chats/:id — full chat detail */
7787
export interface RemoteChat {
7888
id: string;
7989
title?: string;
8090
status?: 'idle' | 'running';
8191
messages?: StoredMessage[];
92+
pendingToolCalls?: PendingToolCall[];
8293
}
8394

8495
/** POST /api/v1/chats/:id/prompt — request body */

0 commit comments

Comments
 (0)