Skip to content

Commit c191dc9

Browse files
committed
Control UI: preserve seq-gap reconnect state
1 parent cf84a03 commit c191dc9

2 files changed

Lines changed: 95 additions & 3 deletions

File tree

ui/src/ui/app-gateway.node.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined));
99
type GatewayClientMock = {
1010
start: ReturnType<typeof vi.fn>;
1111
stop: ReturnType<typeof vi.fn>;
12+
request: ReturnType<typeof vi.fn>;
1213
options: { clientVersion?: string };
1314
emitHello: (hello?: GatewayHelloOk) => void;
1415
emitClose: (info: {
@@ -39,6 +40,7 @@ vi.mock("./gateway.ts", async (importOriginal) => {
3940
class GatewayBrowserClient {
4041
readonly start = vi.fn();
4142
readonly stop = vi.fn();
43+
readonly request = vi.fn(async () => ({}));
4244

4345
constructor(
4446
private opts: {
@@ -56,6 +58,7 @@ vi.mock("./gateway.ts", async (importOriginal) => {
5658
gatewayClientInstances.push({
5759
start: this.start,
5860
stop: this.stop,
61+
request: this.request,
5962
options: { clientVersion: this.opts.clientVersion },
6063
emitHello: (hello) => {
6164
this.opts.onHello?.(
@@ -205,6 +208,68 @@ describe("connectGateway", () => {
205208
expect(host.lastError).toBeNull();
206209
});
207210

211+
it("preserves approval prompts, clears stale run indicators, and resumes queued work after seq-gap reconnect", () => {
212+
const host = createHost();
213+
const chatHost = host as typeof host & {
214+
chatRunId: string | null;
215+
chatQueue: Array<{
216+
id: string;
217+
text: string;
218+
createdAt: number;
219+
pendingRunId?: string;
220+
}>;
221+
};
222+
chatHost.chatRunId = "run-1";
223+
chatHost.chatQueue = [
224+
{
225+
id: "pending",
226+
text: "/steer tighten the plan",
227+
createdAt: 1,
228+
pendingRunId: "run-1",
229+
},
230+
{
231+
id: "queued",
232+
text: "follow up",
233+
createdAt: 2,
234+
},
235+
];
236+
host.execApprovalQueue = [
237+
{
238+
id: "approval-1",
239+
kind: "exec",
240+
request: { command: "rm -rf /tmp/demo" },
241+
createdAtMs: Date.now(),
242+
expiresAtMs: Date.now() + 60_000,
243+
},
244+
];
245+
246+
connectGateway(host);
247+
const client = gatewayClientInstances[0];
248+
expect(client).toBeDefined();
249+
250+
client.emitGap(20, 24);
251+
252+
expect(gatewayClientInstances).toHaveLength(2);
253+
expect(host.execApprovalQueue).toHaveLength(1);
254+
expect(host.execApprovalQueue[0]?.id).toBe("approval-1");
255+
expect(chatHost.chatQueue).toHaveLength(1);
256+
expect(chatHost.chatQueue[0]?.text).toBe("follow up");
257+
258+
const reconnectClient = gatewayClientInstances[1];
259+
expect(reconnectClient).toBeDefined();
260+
261+
reconnectClient.emitHello();
262+
263+
expect(reconnectClient.request).toHaveBeenCalledWith("chat.send", {
264+
sessionKey: "main",
265+
message: "follow up",
266+
deliver: false,
267+
idempotencyKey: expect.any(String),
268+
attachments: undefined,
269+
});
270+
expect(chatHost.chatQueue).toHaveLength(0);
271+
});
272+
208273
it("ignores stale client onEvent callbacks after reconnect", () => {
209274
const host = createHost();
210275

ui/src/ui/app-gateway.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
parseExecApprovalRequested,
3030
parseExecApprovalResolved,
3131
parsePluginApprovalRequested,
32+
pruneExecApprovalQueue,
3233
removeExecApproval,
3334
} from "./controllers/exec-approval.ts";
3435
import { loadHealthState } from "./controllers/health.ts";
@@ -98,6 +99,11 @@ type SessionDefaultsSnapshot = {
9899

99100
type GatewayHostWithShutdownMessage = GatewayHost & {
100101
pendingShutdownMessage?: string | null;
102+
resumeChatQueueAfterReconnect?: boolean;
103+
};
104+
105+
type ConnectGatewayOptions = {
106+
reason?: "initial" | "seq-gap";
101107
};
102108

103109
export function resolveControlUiClientVersion(params: {
@@ -179,14 +185,27 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps
179185
}
180186
}
181187

182-
export function connectGateway(host: GatewayHost) {
188+
export function connectGateway(host: GatewayHost, options?: ConnectGatewayOptions) {
183189
const shutdownHost = host as GatewayHostWithShutdownMessage;
190+
const reconnectReason = options?.reason ?? "initial";
184191
shutdownHost.pendingShutdownMessage = null;
192+
shutdownHost.resumeChatQueueAfterReconnect = false;
185193
host.lastError = null;
186194
host.lastErrorCode = null;
187195
host.hello = null;
188196
host.connected = false;
189-
host.execApprovalQueue = [];
197+
if (reconnectReason === "seq-gap") {
198+
// A seq gap means the socket stayed on the same gateway; preserve prompts
199+
// that only arrived as ephemeral events and clear stale run-scoped indicators.
200+
host.execApprovalQueue = pruneExecApprovalQueue(host.execApprovalQueue);
201+
clearPendingQueueItemsForRun(
202+
host as unknown as Parameters<typeof clearPendingQueueItemsForRun>[0],
203+
host.chatRunId ?? undefined,
204+
);
205+
shutdownHost.resumeChatQueueAfterReconnect = true;
206+
} else {
207+
host.execApprovalQueue = [];
208+
}
190209
host.execApprovalError = null;
191210

192211
const previousClient = host.client;
@@ -218,6 +237,14 @@ export function connectGateway(host: GatewayHost) {
218237
(host as unknown as { chatStream: string | null }).chatStream = null;
219238
(host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null;
220239
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
240+
if (shutdownHost.resumeChatQueueAfterReconnect) {
241+
// The interrupted run will never emit its terminal event now that the
242+
// old client is gone, so resume any deferred commands after hello.
243+
shutdownHost.resumeChatQueueAfterReconnect = false;
244+
void flushChatQueueForEvent(
245+
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
246+
);
247+
}
221248
void subscribeSessions(host as unknown as OpenClawApp);
222249
void loadAssistantIdentity(host as unknown as OpenClawApp);
223250
void loadAgents(host as unknown as OpenClawApp);
@@ -266,7 +293,7 @@ export function connectGateway(host: GatewayHost) {
266293
}
267294
host.lastError = `event gap detected (expected seq ${expected}, got ${received}); reconnecting`;
268295
host.lastErrorCode = null;
269-
connectGateway(host);
296+
connectGateway(host, { reason: "seq-gap" });
270297
},
271298
});
272299
host.client = client;

0 commit comments

Comments
 (0)