Skip to content

Commit 348ffe6

Browse files
authored
fix(gateway): stop stale control ui auth retry loops
Fixes #72139. Summary: - Stop Control UI and Gateway clients from retrying AUTH_TOKEN_MISMATCH forever when no trusted cached device-token retry is queued. - Keep the bounded trusted device-token retry path intact. - Cap Control UI chat history rendering by raw tool-output size, including tool_result.content, with bounded estimation to avoid pre-render stalls. Verification: - node scripts/run-vitest.mjs ui/src/ui/gateway.node.test.ts src/gateway/client.test.ts ui/src/ui/chat/build-chat-items.test.ts - node scripts/run-vitest.mjs src/gateway/reconnect-gating.test.ts ui/src/ui/controllers/chat.test.ts ui/src/ui/views/chat.test.ts - pnpm exec oxfmt --check --threads=1 CHANGELOG.md ui/src/ui/gateway.ts ui/src/ui/gateway.node.test.ts ui/src/ui/chat/history-limits.ts ui/src/ui/chat/build-chat-items.ts ui/src/ui/chat/build-chat-items.test.ts src/gateway/client.ts src/gateway/client.test.ts - git diff --check - PR CI/check graph passed on d969255.
1 parent 095de07 commit 348ffe6

8 files changed

Lines changed: 284 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
2727
- macOS/Gateway: fail managed LaunchAgent stop and restart when the configured gateway port remains busy after cleanup instead of reporting success while a listener survives. Fixes #73132. Thanks @BunsDev.
2828
- Telegram: ship the isolated polling worker at the root dist path used by the bundled worker loader, avoiding startup failures looking for `dist/telegram-ingress-worker.runtime.js`.
2929
- Telegram: skip unmentioned group media before download when `requireMention` is active, avoiding failed media-download replies for messages that should be ignored. Fixes #81181. (#81785) Thanks @joshavant.
30+
- Control UI/Gateway: stop stale token-mismatch reconnect loops when no trusted device-token retry is available, and cap rendered chat history by raw tool-output size so dashboard auth/history work cannot keep degrading channel sockets. Fixes #72139. Thanks @BunsDev.
3031
- Security/sandbox: include Windows `USERPROFILE` in the sandbox blocked home roots so credential-bearing binds (such as `.codex`, `.openclaw`, or `.ssh` under the Windows user profile) are denied even when `HOME` points at a different shell home. (#63074) Thanks @luoyanglang.
3132
- Agents/subagents: apply `agents.defaults.subagents.model` before target agent primary models during `sessions_spawn`, so model-scoped runtimes such as `claude-cli` stay attached to default child runs. Fixes #81395. (#81783) Thanks @joshavant.
3233
- Gateway/OpenAI-compatible HTTP: parse shared JSON endpoint paths without trusting malformed Host headers, avoiding 500s before `/v1/chat/completions`, `/v1/responses`, and `/v1/embeddings` request handling.

src/gateway/client.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,29 @@ describe("GatewayClient connect auth payload", () => {
11841184
});
11851185
});
11861186

1187+
it("does not auto-reconnect on token mismatch when no device-token retry is available", async () => {
1188+
loadDeviceAuthTokenMock.mockReturnValue(null);
1189+
const onReconnectPaused = vi.fn();
1190+
const client = new GatewayClient({
1191+
url: "ws://127.0.0.1:18789",
1192+
token: "shared-token",
1193+
onReconnectPaused,
1194+
});
1195+
1196+
const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client });
1197+
await expectNoReconnectAfterConnectFailure({
1198+
client,
1199+
firstWs: ws1,
1200+
connectId: firstConnect.id,
1201+
failureDetails: { code: "AUTH_TOKEN_MISMATCH", canRetryWithDeviceToken: true },
1202+
});
1203+
expect(onReconnectPaused).toHaveBeenCalledWith({
1204+
code: 1008,
1205+
reason: "connect failed",
1206+
detailCode: "AUTH_TOKEN_MISMATCH",
1207+
});
1208+
});
1209+
11871210
it("keeps reconnecting on PAIRING_REQUIRED when retry hints keep reconnect active", async () => {
11881211
vi.useFakeTimers();
11891212
const onReconnectPaused = vi.fn();

src/gateway/client.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -725,18 +725,10 @@ export class GatewayClient {
725725
) {
726726
return true;
727727
}
728-
if (detailCode !== ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) {
729-
return false;
730-
}
731-
if (this.pendingDeviceTokenRetry) {
732-
return false;
733-
}
734-
// If the endpoint is not trusted for retry, mismatch is terminal until operator action.
735-
if (!this.isTrustedDeviceRetryEndpoint()) {
736-
return true;
728+
if (detailCode === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) {
729+
return !this.pendingDeviceTokenRetry;
737730
}
738-
// Pause mismatch reconnect loops once the one-shot device-token retry is consumed.
739-
return this.deviceTokenRetryBudgetUsed;
731+
return false;
740732
}
741733

742734
private shouldRetryWithStoredDeviceToken(params: {

ui/src/ui/chat/build-chat-items.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,53 @@ describe("buildChatItems", () => {
236236
expect(messageRecord(groups[groups.length - 1]).content).toBe("message 104");
237237
});
238238

239+
it("budgets rendered history by tool-result content size", () => {
240+
const largeOutput = "x".repeat(100_000);
241+
const items = buildChatItems(
242+
createProps({
243+
messages: Array.from({ length: 6 }, (_, index) => ({
244+
role: "assistant",
245+
content: [
246+
{
247+
type: "tool_result",
248+
tool_use_id: `tool-${index}`,
249+
content: largeOutput,
250+
},
251+
],
252+
timestamp: index,
253+
})),
254+
}),
255+
);
256+
257+
const groups = items.filter((item) => item.kind === "group");
258+
const noticeGroup = requireGroup(items[0]);
259+
expect(messageRecord(noticeGroup).content).toBe("Showing last 2 messages (4 hidden).");
260+
expect(groups).toHaveLength(2);
261+
expect(groups[1].messages).toHaveLength(2);
262+
expect(messageRecord(groups[1], 0).timestamp).toBe(4);
263+
expect(messageRecord(groups[1], 1).timestamp).toBe(5);
264+
});
265+
266+
it("does not crash when history contains malformed entries", () => {
267+
const items = buildChatItems(
268+
createProps({
269+
messages: [
270+
null,
271+
undefined,
272+
{
273+
role: "assistant",
274+
content: "still visible",
275+
timestamp: 1,
276+
},
277+
],
278+
}),
279+
);
280+
281+
const groups = items.filter((item) => item.kind === "group");
282+
expect(groups).toHaveLength(1);
283+
expect(messageRecord(groups[0]).content).toBe("still visible");
284+
});
285+
239286
it("does not collapse duplicate text messages separated by another message", () => {
240287
const groups = messageGroups({
241288
messages: [

ui/src/ui/chat/build-chat-items.ts

Lines changed: 173 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import type { ChatItem, MessageGroup, ToolCard } from "../types/chat-types.ts";
1+
import type { ChatItem, MessageGroup, NormalizedMessage, ToolCard } from "../types/chat-types.ts";
22
import {
33
isAssistantHeartbeatAckForDisplay,
44
stripHeartbeatTokenForDisplay,
55
} from "./heartbeat-display.ts";
6-
import { CHAT_HISTORY_RENDER_LIMIT } from "./history-limits.ts";
6+
import { CHAT_HISTORY_RENDER_CHAR_BUDGET, CHAT_HISTORY_RENDER_LIMIT } from "./history-limits.ts";
77
import { extractTextCached } from "./message-extract.ts";
88
import { normalizeMessage, stripMessageDisplayMetadataText } from "./message-normalizer.ts";
99
import { normalizeRoleForGrouping } from "./role-normalizer.ts";
@@ -66,12 +66,32 @@ function appendCanvasBlockToAssistantMessage(
6666
};
6767
}
6868

69+
function asRecord(value: unknown): Record<string, unknown> | null {
70+
return value && typeof value === "object" && !Array.isArray(value)
71+
? (value as Record<string, unknown>)
72+
: null;
73+
}
74+
75+
function safeNormalizeMessage(message: unknown): NormalizedMessage | null {
76+
if (!asRecord(message)) {
77+
return null;
78+
}
79+
try {
80+
return normalizeMessage(message);
81+
} catch {
82+
return null;
83+
}
84+
}
85+
6986
function extractChatMessagePreview(toolMessage: unknown): {
7087
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
7188
text: string | null;
7289
timestamp: number | null;
7390
} | null {
74-
const normalized = normalizeMessage(toolMessage);
91+
const normalized = safeNormalizeMessage(toolMessage);
92+
if (!normalized) {
93+
return null;
94+
}
7595
const cards = extractToolCards(toolMessage, "preview");
7696
for (let index = cards.length - 1; index >= 0; index--) {
7797
const card = cards[index];
@@ -114,7 +134,7 @@ function findNearestAssistantMessageIndex(
114134
}
115135
return {
116136
index,
117-
timestamp: normalizeMessage(item.message).timestamp ?? null,
137+
timestamp: safeNormalizeMessage(item.message)?.timestamp ?? null,
118138
};
119139
})
120140
.filter(Boolean) as Array<{ index: number; timestamp: number | null }>;
@@ -203,7 +223,10 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
203223
}
204224

205225
function collapseDuplicateDisplaySignature(message: unknown): string | null {
206-
const normalized = normalizeMessage(message);
226+
const normalized = safeNormalizeMessage(message);
227+
if (!normalized) {
228+
return null;
229+
}
207230
const role = normalizeRoleForGrouping(normalized.role).toLowerCase();
208231
if (!role || role === "tool") {
209232
return null;
@@ -250,7 +273,10 @@ function collapseSequentialDuplicateMessages(items: ChatItem[]): ChatItem[] {
250273
}
251274

252275
function hasRenderableNormalizedMessage(message: unknown): boolean {
253-
const normalized = normalizeMessage(message);
276+
const normalized = safeNormalizeMessage(message);
277+
if (!normalized) {
278+
return false;
279+
}
254280
return normalized.content.length > 0 || Boolean(normalized.replyTarget);
255281
}
256282

@@ -260,7 +286,7 @@ function sanitizeStreamText(text: string): string {
260286
}
261287

262288
function rawMessageTimestamp(message: unknown): number | null {
263-
const timestamp = (message as { timestamp?: unknown }).timestamp;
289+
const timestamp = asRecord(message)?.timestamp;
264290
return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : null;
265291
}
266292

@@ -301,6 +327,129 @@ function sortChatItemsByVisibleTime(items: ChatItem[]): ChatItem[] {
301327
.map(({ item }) => item);
302328
}
303329

330+
type RawContentEstimateState = {
331+
visited: WeakSet<object>;
332+
nodes: number;
333+
};
334+
335+
const RAW_CONTENT_ESTIMATE_MAX_DEPTH = 8;
336+
const RAW_CONTENT_ESTIMATE_MAX_NODES = 400;
337+
338+
function addCapped(total: number, amount: number, limit: number): number {
339+
return Math.min(limit, total + Math.max(0, amount));
340+
}
341+
342+
function estimateRawContentChars(
343+
value: unknown,
344+
limit: number,
345+
state: RawContentEstimateState,
346+
depth = 0,
347+
): number {
348+
if (limit <= 0) {
349+
return 0;
350+
}
351+
if (typeof value === "string") {
352+
return Math.min(value.length, limit);
353+
}
354+
if (!value || typeof value !== "object") {
355+
return 0;
356+
}
357+
if (depth >= RAW_CONTENT_ESTIMATE_MAX_DEPTH || state.nodes >= RAW_CONTENT_ESTIMATE_MAX_NODES) {
358+
return 0;
359+
}
360+
if (state.visited.has(value)) {
361+
return 0;
362+
}
363+
state.visited.add(value);
364+
state.nodes += 1;
365+
366+
if (Array.isArray(value)) {
367+
let chars = 0;
368+
for (const item of value) {
369+
chars = addCapped(
370+
chars,
371+
estimateRawContentChars(item, limit - chars, state, depth + 1),
372+
limit,
373+
);
374+
if (chars >= limit) {
375+
break;
376+
}
377+
}
378+
return chars;
379+
}
380+
381+
const record = value as Record<string, unknown>;
382+
let chars = 0;
383+
for (const key of ["text", "content", "args", "arguments", "input"] as const) {
384+
chars = addCapped(
385+
chars,
386+
estimateRawContentChars(record[key], limit - chars, state, depth + 1),
387+
limit,
388+
);
389+
if (chars >= limit) {
390+
break;
391+
}
392+
}
393+
return chars;
394+
}
395+
396+
function estimateMessageRenderChars(message: unknown, limit: number): number {
397+
const record = asRecord(message);
398+
if (!record) {
399+
return 1;
400+
}
401+
const state: RawContentEstimateState = { visited: new WeakSet<object>(), nodes: 0 };
402+
let chars = 0;
403+
for (const key of ["content", "text", "args", "arguments", "input"] as const) {
404+
chars = addCapped(chars, estimateRawContentChars(record[key], limit - chars, state), limit);
405+
if (chars >= limit) {
406+
break;
407+
}
408+
}
409+
return Math.max(chars, 1);
410+
}
411+
412+
function isHiddenToolMessage(message: unknown, showToolCalls: boolean): boolean {
413+
if (showToolCalls) {
414+
return false;
415+
}
416+
return safeNormalizeMessage(message)?.role.toLowerCase() === "toolresult";
417+
}
418+
419+
function countVisibleHistoryMessages(messages: unknown[], showToolCalls: boolean): number {
420+
let count = 0;
421+
for (const message of messages) {
422+
if (!isHiddenToolMessage(message, showToolCalls)) {
423+
count += 1;
424+
}
425+
}
426+
return count;
427+
}
428+
429+
function resolveHistoryStartIndex(messages: unknown[], showToolCalls: boolean): number {
430+
let visibleCount = 0;
431+
let renderChars = 0;
432+
let startIndex = messages.length;
433+
for (let index = messages.length - 1; index >= 0; index -= 1) {
434+
const message = messages[index];
435+
if (isHiddenToolMessage(message, showToolCalls)) {
436+
continue;
437+
}
438+
if (visibleCount >= CHAT_HISTORY_RENDER_LIMIT) {
439+
break;
440+
}
441+
const remainingBudget = Math.max(1, CHAT_HISTORY_RENDER_CHAR_BUDGET - renderChars + 1);
442+
const messageChars = estimateMessageRenderChars(message, remainingBudget);
443+
if (visibleCount > 0 && renderChars + messageChars > CHAT_HISTORY_RENDER_CHAR_BUDGET) {
444+
break;
445+
}
446+
renderChars += messageChars;
447+
visibleCount += 1;
448+
startIndex = index;
449+
}
450+
return startIndex;
451+
}
452+
304453
export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGroup> {
305454
let items: ChatItem[] = [];
306455
const history = (Array.isArray(props.messages) ? props.messages : []).filter(
@@ -314,22 +463,33 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
314463
text: string | null;
315464
timestamp: number | null;
316465
}>;
317-
const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT);
318-
if (historyStart > 0) {
466+
const historyStart = resolveHistoryStartIndex(history, props.showToolCalls);
467+
const hiddenHistoryCount = countVisibleHistoryMessages(
468+
history.slice(0, historyStart),
469+
props.showToolCalls,
470+
);
471+
const visibleHistoryCount = countVisibleHistoryMessages(
472+
history.slice(historyStart),
473+
props.showToolCalls,
474+
);
475+
if (hiddenHistoryCount > 0) {
319476
items.push({
320477
kind: "message",
321478
key: "chat:history:notice",
322479
message: {
323480
role: "system",
324-
content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`,
481+
content: `Showing last ${visibleHistoryCount} messages (${hiddenHistoryCount} hidden).`,
325482
timestamp: Date.now(),
326483
},
327484
});
328485
}
329486
for (let i = historyStart; i < history.length; i++) {
330487
const msg = history[i];
331-
const normalized = normalizeMessage(msg);
332-
const raw = msg as Record<string, unknown>;
488+
const normalized = safeNormalizeMessage(msg);
489+
if (!normalized) {
490+
continue;
491+
}
492+
const raw = asRecord(msg) ?? {};
333493
const marker = raw.__openclaw as Record<string, unknown> | undefined;
334494
if (marker && marker.kind === "compaction") {
335495
items.push({
@@ -433,7 +593,7 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
433593
}
434594

435595
function messageKey(message: unknown, index: number): string {
436-
const m = message as Record<string, unknown>;
596+
const m = asRecord(message) ?? {};
437597
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
438598
if (toolCallId) {
439599
const role = typeof m.role === "string" ? m.role : "unknown";

ui/src/ui/chat/history-limits.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export const CHAT_HISTORY_RENDER_LIMIT = 100;
2+
export const CHAT_HISTORY_RENDER_CHAR_BUDGET = 240_000;

0 commit comments

Comments
 (0)