Skip to content

Commit 03c4098

Browse files
authored
feat(version): upgrade openclaw version 4.11 (#845)
1 parent 5482acd commit 03c4098

File tree

9 files changed

+590
-754
lines changed

9 files changed

+590
-754
lines changed

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "clawx",
3-
"version": "0.3.8",
3+
"version": "0.3.9-beta.1",
44
"pnpm": {
55
"onlyBuiltDependencies": [
66
"@discordjs/opus",
@@ -94,7 +94,7 @@
9494
"@radix-ui/react-tooltip": "^1.2.8",
9595
"@playwright/test": "^1.56.1",
9696
"@soimy/dingtalk": "^3.5.3",
97-
"@tencent-weixin/openclaw-weixin": "^2.1.7",
97+
"@tencent-weixin/openclaw-weixin": "^2.1.8",
9898
"@testing-library/jest-dom": "^6.9.1",
9999
"@testing-library/react": "^16.3.2",
100100
"@types/node": "^25.3.0",
@@ -119,7 +119,7 @@
119119
"i18next": "^25.8.11",
120120
"jsdom": "^28.1.0",
121121
"lucide-react": "^0.563.0",
122-
"openclaw": "2026.4.9",
122+
"openclaw": "2026.4.11",
123123
"png2icons": "^2.0.1",
124124
"postcss": "^8.5.6",
125125
"react": "^19.2.4",
@@ -143,4 +143,4 @@
143143
"zx": "^8.8.5"
144144
},
145145
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
146-
}
146+
}

pnpm-lock.yaml

Lines changed: 443 additions & 681 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/stores/chat.ts

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,26 @@ function buildChatEventDedupeKey(eventState: string, event: Record<string, unkno
109109
return null;
110110
}
111111

112+
function getFinalMessageIdDedupeKey(eventState: string, event: Record<string, unknown>): string | null {
113+
if (eventState !== 'final') return null;
114+
const msg = (event.message && typeof event.message === 'object')
115+
? event.message as Record<string, unknown>
116+
: null;
117+
if (msg?.id != null) return `final-msgid|${String(msg.id)}`;
118+
return null;
119+
}
120+
112121
function isDuplicateChatEvent(eventState: string, event: Record<string, unknown>): boolean {
113122
const key = buildChatEventDedupeKey(eventState, event);
114-
if (!key) return false;
123+
const msgKey = getFinalMessageIdDedupeKey(eventState, event);
124+
if (!key && !msgKey) return false;
115125
const now = Date.now();
116126
pruneChatEventDedupe(now);
117-
if (_chatEventDedupe.has(key)) {
127+
if ((key && _chatEventDedupe.has(key)) || (msgKey && _chatEventDedupe.has(msgKey))) {
118128
return true;
119129
}
120-
_chatEventDedupe.set(key, now);
130+
if (key) _chatEventDedupe.set(key, now);
131+
if (msgKey) _chatEventDedupe.set(msgKey, now);
121132
return false;
122133
}
123134

@@ -1118,38 +1129,50 @@ export const useChatStore = create<ChatState>((set, get) => ({
11181129
}
11191130

11201131
// Background: fetch first user message for every non-main session to populate labels upfront.
1121-
// Uses a small limit so it's cheap; runs in parallel and doesn't block anything.
1132+
// Retries on "gateway startup" errors since the gateway may still be initializing.
11221133
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
11231134
if (sessionsToLabel.length > 0) {
1124-
void Promise.all(
1125-
sessionsToLabel.map(async (session) => {
1126-
try {
1127-
const r = await useGatewayStore.getState().rpc<Record<string, unknown>>(
1128-
'chat.history',
1129-
{ sessionKey: session.key, limit: 1000 },
1130-
);
1131-
const msgs = Array.isArray(r.messages) ? r.messages as RawMessage[] : [];
1132-
const firstUser = msgs.find((m) => m.role === 'user');
1133-
const lastMsg = msgs[msgs.length - 1];
1134-
set((s) => {
1135-
const next: Partial<typeof s> = {};
1136-
if (firstUser) {
1137-
const labelText = getMessageText(firstUser.content).trim();
1138-
if (labelText) {
1139-
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
1140-
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
1135+
const LABEL_RETRY_DELAYS = [2_000, 5_000, 10_000];
1136+
void (async () => {
1137+
let pending = sessionsToLabel;
1138+
for (let attempt = 0; attempt <= LABEL_RETRY_DELAYS.length; attempt += 1) {
1139+
const failed: typeof pending = [];
1140+
await Promise.all(
1141+
pending.map(async (session) => {
1142+
try {
1143+
const r = await useGatewayStore.getState().rpc<Record<string, unknown>>(
1144+
'chat.history',
1145+
{ sessionKey: session.key, limit: 1000 },
1146+
);
1147+
const msgs = Array.isArray(r.messages) ? r.messages as RawMessage[] : [];
1148+
const firstUser = msgs.find((m) => m.role === 'user');
1149+
const lastMsg = msgs[msgs.length - 1];
1150+
set((s) => {
1151+
const next: Partial<typeof s> = {};
1152+
if (firstUser) {
1153+
const labelText = getMessageText(firstUser.content).trim();
1154+
if (labelText) {
1155+
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
1156+
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
1157+
}
1158+
}
1159+
if (lastMsg?.timestamp) {
1160+
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
1161+
}
1162+
return next;
1163+
});
1164+
} catch (err) {
1165+
if (classifyHistoryStartupRetryError(err) === 'gateway_startup') {
1166+
failed.push(session);
11411167
}
11421168
}
1143-
if (lastMsg?.timestamp) {
1144-
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
1145-
}
1146-
return next;
1147-
});
1148-
} catch {
1149-
// ignore per-session errors
1150-
}
1151-
}),
1152-
);
1169+
}),
1170+
);
1171+
if (failed.length === 0 || attempt >= LABEL_RETRY_DELAYS.length) break;
1172+
await sleep(LABEL_RETRY_DELAYS[attempt]!);
1173+
pending = failed;
1174+
}
1175+
})();
11531176
}
11541177
}
11551178
} catch (err) {

src/stores/chat/history-actions.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,10 +253,12 @@ export function createHistoryActions(
253253
return;
254254
}
255255

256-
if (isCurrentSession() && isInitialForegroundLoad && classifyHistoryStartupRetryError(lastError)) {
256+
const errorKind = classifyHistoryStartupRetryError(lastError);
257+
if (isCurrentSession() && isInitialForegroundLoad && errorKind) {
257258
console.warn('[chat.history] startup retry exhausted', {
258259
sessionKey: currentSessionKey,
259260
gatewayState: useGatewayStore.getState().status.state,
261+
errorKind,
260262
error: String(lastError),
261263
});
262264
}
@@ -267,6 +269,11 @@ export function createHistoryActions(
267269
if (applied && isInitialForegroundLoad) {
268270
foregroundHistoryLoadSeen.add(currentSessionKey);
269271
}
272+
} else if (errorKind === 'gateway_startup') {
273+
// Suppress error UI for gateway startup -- the history will load
274+
// once the gateway finishes initializing (via sidebar refresh or
275+
// the next session switch).
276+
set({ loading: false });
270277
} else {
271278
applyLoadFailure(
272279
result?.error

src/stores/chat/history-startup-retry.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { GatewayStatus } from '@/types/gateway';
22

33
export const CHAT_HISTORY_RPC_TIMEOUT_MS = 35_000;
4-
export const CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS = [600] as const;
5-
export const CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS = 15_000;
4+
export const CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS = [800, 2_000, 4_000, 8_000] as const;
5+
export const CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS = 30_000;
66
export const CHAT_HISTORY_STARTUP_RUNNING_WINDOW_MS =
77
CHAT_HISTORY_RPC_TIMEOUT_MS + CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS;
88
export const CHAT_HISTORY_DEFAULT_LOADING_SAFETY_TIMEOUT_MS = 15_000;
@@ -11,11 +11,20 @@ export const CHAT_HISTORY_LOADING_SAFETY_TIMEOUT_MS =
1111
+ CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.reduce((sum, delay) => sum + delay, 0)
1212
+ 2_000;
1313

14-
export type HistoryRetryErrorKind = 'timeout' | 'gateway_unavailable';
14+
export type HistoryRetryErrorKind = 'timeout' | 'gateway_unavailable' | 'gateway_startup';
1515

1616
export function classifyHistoryStartupRetryError(error: unknown): HistoryRetryErrorKind | null {
1717
const message = String(error).toLowerCase();
1818

19+
if (
20+
message.includes('unavailable during gateway startup')
21+
|| message.includes('unavailable during startup')
22+
|| message.includes('not yet ready')
23+
|| message.includes('service not initialized')
24+
) {
25+
return 'gateway_startup';
26+
}
27+
1928
if (
2029
message.includes('rpc timeout: chat.history')
2130
|| message.includes('gateway rpc timeout: chat.history')
@@ -47,6 +56,11 @@ export function shouldRetryStartupHistoryLoad(
4756
): boolean {
4857
if (!gatewayStatus || !errorKind) return false;
4958

59+
// The gateway explicitly told us it's still initializing -- always retry
60+
if (errorKind === 'gateway_startup') {
61+
return true;
62+
}
63+
5064
if (gatewayStatus.state === 'starting') {
5165
return true;
5266
}

src/stores/chat/session-actions.ts

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { invokeIpc } from '@/lib/api-client';
22
import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
3+
import { classifyHistoryStartupRetryError, sleep } from './history-startup-retry';
34
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
45
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
56

@@ -111,38 +112,54 @@ export function createSessionActions(
111112
}
112113

113114
// Background: fetch first user message for every non-main session to populate labels upfront.
114-
// Uses a small limit so it's cheap; runs in parallel and doesn't block anything.
115+
// Retries on "gateway startup" errors since the gateway may still be initializing.
115116
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
116117
if (sessionsToLabel.length > 0) {
117-
void Promise.all(
118-
sessionsToLabel.map(async (session) => {
119-
try {
120-
const r = await invokeIpc(
121-
'gateway:rpc',
122-
'chat.history',
123-
{ sessionKey: session.key, limit: 1000 },
124-
) as { success: boolean; result?: Record<string, unknown> };
125-
if (!r.success || !r.result) return;
126-
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
127-
const firstUser = msgs.find((m) => m.role === 'user');
128-
const lastMsg = msgs[msgs.length - 1];
129-
set((s) => {
130-
const next: Partial<typeof s> = {};
131-
if (firstUser) {
132-
const labelText = getMessageText(firstUser.content).trim();
133-
if (labelText) {
134-
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
135-
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
118+
const LABEL_RETRY_DELAYS = [2_000, 5_000, 10_000];
119+
void (async () => {
120+
let pending = sessionsToLabel;
121+
for (let attempt = 0; attempt <= LABEL_RETRY_DELAYS.length; attempt += 1) {
122+
const failed: typeof pending = [];
123+
await Promise.all(
124+
pending.map(async (session) => {
125+
try {
126+
const r = await invokeIpc(
127+
'gateway:rpc',
128+
'chat.history',
129+
{ sessionKey: session.key, limit: 1000 },
130+
) as { success: boolean; result?: Record<string, unknown>; error?: string };
131+
if (!r.success) {
132+
if (classifyHistoryStartupRetryError(r.error) === 'gateway_startup') {
133+
failed.push(session);
134+
}
135+
return;
136136
}
137-
}
138-
if (lastMsg?.timestamp) {
139-
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
140-
}
141-
return next;
142-
});
143-
} catch { /* ignore per-session errors */ }
144-
}),
145-
);
137+
if (!r.result) return;
138+
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
139+
const firstUser = msgs.find((m) => m.role === 'user');
140+
const lastMsg = msgs[msgs.length - 1];
141+
set((s) => {
142+
const next: Partial<typeof s> = {};
143+
if (firstUser) {
144+
const labelText = getMessageText(firstUser.content).trim();
145+
if (labelText) {
146+
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
147+
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
148+
}
149+
}
150+
if (lastMsg?.timestamp) {
151+
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
152+
}
153+
return next;
154+
});
155+
} catch { /* ignore per-session errors */ }
156+
}),
157+
);
158+
if (failed.length === 0 || attempt >= LABEL_RETRY_DELAYS.length) break;
159+
await sleep(LABEL_RETRY_DELAYS[attempt]!);
160+
pending = failed;
161+
}
162+
})();
146163
}
147164
}
148165
} catch (err) {

src/stores/gateway.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,28 @@ function buildGatewayEventDedupeKey(event: Record<string, unknown>): string | nu
6767
return null;
6868
}
6969

70+
function getMessageIdDedupeKey(event: Record<string, unknown>): string | null {
71+
const state = event.state != null ? String(event.state) : '';
72+
if (state !== 'final') return null;
73+
const message = event.message;
74+
if (message && typeof message === 'object') {
75+
const msgId = (message as Record<string, unknown>).id;
76+
if (msgId != null) return `final-msgid|${String(msgId)}`;
77+
}
78+
return null;
79+
}
80+
7081
function shouldProcessGatewayEvent(event: Record<string, unknown>): boolean {
7182
const key = buildGatewayEventDedupeKey(event);
72-
if (!key) return true;
83+
const msgKey = getMessageIdDedupeKey(event);
84+
if (!key && !msgKey) return true;
7385
const now = Date.now();
7486
pruneGatewayEventDedupe(now);
75-
if (gatewayEventDedupe.has(key)) {
87+
if ((key && gatewayEventDedupe.has(key)) || (msgKey && gatewayEventDedupe.has(msgKey))) {
7688
return false;
7789
}
78-
gatewayEventDedupe.set(key, now);
90+
if (key) gatewayEventDedupe.set(key, now);
91+
if (msgKey) gatewayEventDedupe.set(msgKey, now);
7992
return true;
8093
}
8194

tests/unit/chat-history-actions.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ describe('chat history actions', () => {
272272
await vi.runAllTimersAsync();
273273
await loadPromise;
274274

275-
expect(invokeIpcMock).toHaveBeenCalledTimes(2);
275+
expect(invokeIpcMock).toHaveBeenCalledTimes(5);
276276
expect(h.read().messages).toEqual([]);
277277
expect(h.read().error).toBe('RPC timeout: chat.history');
278278
expect(warnSpy).toHaveBeenCalledWith(

tests/unit/chat-store-history-retry.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ describe('useChatStore startup history retry', () => {
9090
{ sessionKey: 'agent:main:main', limit: 200 },
9191
undefined,
9292
);
93-
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 72_600);
93+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 191_800);
9494
setTimeoutSpy.mockRestore();
9595
});
9696

0 commit comments

Comments
 (0)