upgrade openclaw to 5.19 (#1063)
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
---
|
||||
id: fix-chat-stuck-processing-tool-results
|
||||
title: Clear stale chat run state when Gateway reports the session is idle
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Prevent the renderer from staying stuck on “Processing tool results…” when a terminal Gateway lifecycle event was missed but sessions.list reports the current session has no active run.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-chat-stuck-processing-tool-results.md
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat/types.ts
|
||||
- tests/unit/chat-session-actions.test.ts
|
||||
expectedUserBehavior:
|
||||
- If Gateway reports the current session has status done or hasActiveRun=false after the user's send timestamp, the chat composer and tool-processing indicator return to idle.
|
||||
- A fresh in-flight send is not prematurely cleared by stale sessions.list metadata from before the user message.
|
||||
- Renderer continues to use the existing gateway:rpc Main-process boundary.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/chat-session-actions.test.ts
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls outside the existing api-client invocation path.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- sessions.list idle metadata reconciles stale sending/activeRunId/pendingFinal state for the current session.
|
||||
- sessions.list metadata older than the current user send does not clear active state.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
14
package.json
14
package.json
@@ -92,12 +92,12 @@
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.7",
|
||||
"@larksuite/openclaw-lark": "2026.5.12",
|
||||
"@larksuite/openclaw-lark": "2026.5.13",
|
||||
"@larksuiteoapi/node-sdk": "^1.61.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@openclaw/discord": "2026.5.12",
|
||||
"@openclaw/qqbot": "2026.5.12",
|
||||
"@openclaw/whatsapp": "2026.5.12",
|
||||
"@openclaw/discord": "2026.5.19",
|
||||
"@openclaw/qqbot": "2026.5.19",
|
||||
"@openclaw/whatsapp": "2026.5.19",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -112,7 +112,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"@soimy/dingtalk": "^3.6.2",
|
||||
"@soimy/dingtalk": "^3.6.3",
|
||||
"@tencent-connect/qqbot-connector": "^1.1.0",
|
||||
"@tencent-weixin/openclaw-weixin": "^2.4.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -125,7 +125,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
||||
"@typescript-eslint/parser": "^8.56.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@wecom/wecom-openclaw-plugin": "^2026.5.7",
|
||||
"@wecom/wecom-openclaw-plugin": "^2026.5.14",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"acpx": "0.5.3",
|
||||
"autoprefixer": "^10.4.24",
|
||||
@@ -151,7 +151,7 @@
|
||||
"monaco-editor": "^0.55.1",
|
||||
"mpg123-decoder": "^1.0.3",
|
||||
"ms": "^2.1.3",
|
||||
"openclaw": "2026.5.12",
|
||||
"openclaw": "2026.5.19",
|
||||
"opusscript": "^0.1.1",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"playwright-core": "1.59.1",
|
||||
|
||||
755
pnpm-lock.yaml
generated
755
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1404,6 +1404,56 @@ function parseSessionUpdatedAtMs(value: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseSessionStatus(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
function sessionIndicatesIdle(session: ChatSession | undefined): boolean {
|
||||
if (!session) return false;
|
||||
if (session.hasActiveRun === false) return true;
|
||||
return session.status === 'done'
|
||||
|| session.status === 'completed'
|
||||
|| session.status === 'finished'
|
||||
|| session.status === 'failed'
|
||||
|| session.status === 'error'
|
||||
|| session.status === 'aborted'
|
||||
|| session.status === 'cancelled';
|
||||
}
|
||||
|
||||
function reconcileCurrentSessionIdleFromBackend(
|
||||
set: (partial: Partial<ChatState> | ((state: ChatState) => Partial<ChatState>)) => void,
|
||||
get: () => ChatState,
|
||||
sessions: ChatSession[],
|
||||
): void {
|
||||
const state = get();
|
||||
if (!state.sending && state.activeRunId == null && !state.pendingFinal) return;
|
||||
|
||||
const current = sessions.find((session) => session.key === state.currentSessionKey);
|
||||
if (!sessionIndicatesIdle(current)) return;
|
||||
|
||||
// Avoid clearing a brand-new send from stale sessions.list metadata. The
|
||||
// backend's session row must have been updated at or after the user message
|
||||
// that armed the renderer run state.
|
||||
if (
|
||||
state.lastUserMessageAt != null
|
||||
&& typeof current?.updatedAt === 'number'
|
||||
&& current.updatedAt < toMs(state.lastUserMessageAt)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingToolImages: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCronFallbackMessages(sessionKey: string, limit = 200): Promise<RawMessage[]> {
|
||||
if (!isCronSessionKey(sessionKey)) return [];
|
||||
try {
|
||||
@@ -2093,6 +2143,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
|
||||
model: s.model ? String(s.model) : undefined,
|
||||
updatedAt: parseSessionUpdatedAtMs(s.updatedAt),
|
||||
status: parseSessionStatus(s.status),
|
||||
hasActiveRun: typeof s.hasActiveRun === 'boolean' ? s.hasActiveRun : undefined,
|
||||
})).filter((s: ChatSession) => s.key);
|
||||
|
||||
const canonicalBySuffix = new Map<string, string>();
|
||||
@@ -2175,6 +2227,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
},
|
||||
}));
|
||||
}
|
||||
reconcileCurrentSessionIdleFromBackend(set, get, sessionsWithCurrent);
|
||||
applySessionBackendLabels(set, sessionsWithCurrent);
|
||||
|
||||
// Background: fetch first user message for every non-main session to populate labels upfront.
|
||||
|
||||
@@ -57,6 +57,52 @@ function parseSessionUpdatedAtMs(value: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseSessionStatus(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
function sessionIndicatesIdle(session: ChatSession | undefined): boolean {
|
||||
if (!session) return false;
|
||||
if (session.hasActiveRun === false) return true;
|
||||
return session.status === 'done'
|
||||
|| session.status === 'completed'
|
||||
|| session.status === 'finished'
|
||||
|| session.status === 'failed'
|
||||
|| session.status === 'error'
|
||||
|| session.status === 'aborted'
|
||||
|| session.status === 'cancelled';
|
||||
}
|
||||
|
||||
function reconcileCurrentSessionIdleFromBackend(set: ChatSet, get: ChatGet, sessions: ChatSession[]): void {
|
||||
const state = get();
|
||||
if (!state.sending && state.activeRunId == null && !state.pendingFinal) return;
|
||||
|
||||
const current = sessions.find((session) => session.key === state.currentSessionKey);
|
||||
if (!sessionIndicatesIdle(current)) return;
|
||||
|
||||
// Avoid clearing a brand-new send from stale sessions.list metadata. The
|
||||
// backend's session row must have been updated at or after the user message
|
||||
// that armed the renderer run state.
|
||||
if (
|
||||
state.lastUserMessageAt != null
|
||||
&& typeof current?.updatedAt === 'number'
|
||||
&& current.updatedAt < toMs(state.lastUserMessageAt)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingToolImages: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function createSessionActions(
|
||||
set: ChatSet,
|
||||
get: ChatGet,
|
||||
@@ -85,6 +131,8 @@ export function createSessionActions(
|
||||
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
|
||||
model: s.model ? String(s.model) : undefined,
|
||||
updatedAt: parseSessionUpdatedAtMs(s.updatedAt),
|
||||
status: parseSessionStatus(s.status),
|
||||
hasActiveRun: typeof s.hasActiveRun === 'boolean' ? s.hasActiveRun : undefined,
|
||||
})).filter((s: ChatSession) => s.key);
|
||||
|
||||
const canonicalBySuffix = new Map<string, string>();
|
||||
@@ -147,6 +195,7 @@ export function createSessionActions(
|
||||
...discoveredActivity,
|
||||
},
|
||||
}));
|
||||
reconcileCurrentSessionIdleFromBackend(set, get, sessionsWithCurrent);
|
||||
applySessionBackendLabels(set, sessionsWithCurrent);
|
||||
|
||||
const gatewayRuntimeKey = getSessionLabelHydrationRuntimeKey(undefined);
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface ChatSession {
|
||||
thinkingLevel?: string;
|
||||
model?: string;
|
||||
updatedAt?: number;
|
||||
status?: string;
|
||||
hasActiveRun?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolStatus {
|
||||
|
||||
@@ -8,10 +8,11 @@ vi.mock('@/lib/api-client', () => ({
|
||||
|
||||
type ChatLikeState = {
|
||||
currentSessionKey: string;
|
||||
sessions: Array<{ key: string; displayName?: string; updatedAt?: number }>;
|
||||
sessions: Array<{ key: string; displayName?: string; updatedAt?: number; status?: string; hasActiveRun?: boolean }>;
|
||||
messages: Array<{ role: string; timestamp?: number; content?: unknown }>;
|
||||
sessionLabels: Record<string, string>;
|
||||
sessionLastActivity: Record<string, number>;
|
||||
sending: boolean;
|
||||
streamingText: string;
|
||||
streamingMessage: unknown | null;
|
||||
streamingTools: unknown[];
|
||||
@@ -30,6 +31,7 @@ function makeHarness(initial?: Partial<ChatLikeState>) {
|
||||
messages: [],
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
sending: false,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
@@ -173,5 +175,79 @@ describe('chat session actions', () => {
|
||||
expect(h.read().sessionLastActivity['agent:main:cron:job-1']).toBe(1773281731621);
|
||||
expect(h.read().sessions.find((session) => session.key === 'agent:main:cron:job-1')?.updatedAt).toBe(1773281731621);
|
||||
});
|
||||
|
||||
it('clears stale current-run state when sessions.list reports the current session is idle', async () => {
|
||||
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||
const h = makeHarness({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
sessions: [{ key: 'agent:main:main' }],
|
||||
sending: true,
|
||||
activeRunId: 'run-stale',
|
||||
pendingFinal: true,
|
||||
lastUserMessageAt: 1779693769991,
|
||||
streamingText: 'partial',
|
||||
streamingMessage: { role: 'assistant', content: 'partial' },
|
||||
streamingTools: [{ name: 'browser', status: 'completed' }],
|
||||
pendingToolImages: [{ fileName: 'x.png' }],
|
||||
});
|
||||
const actions = createSessionActions(h.set as never, h.get as never);
|
||||
|
||||
invokeIpcMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [{
|
||||
key: 'agent:main:main',
|
||||
displayName: 'Main',
|
||||
updatedAt: 1779694521057,
|
||||
status: 'done',
|
||||
hasActiveRun: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
await actions.loadSessions();
|
||||
|
||||
const next = h.read();
|
||||
expect(next.sending).toBe(false);
|
||||
expect(next.activeRunId).toBeNull();
|
||||
expect(next.pendingFinal).toBe(false);
|
||||
expect(next.lastUserMessageAt).toBeNull();
|
||||
expect(next.streamingText).toBe('');
|
||||
expect(next.streamingMessage).toBeNull();
|
||||
expect(next.streamingTools).toEqual([]);
|
||||
expect(next.pendingToolImages).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not clear current-run state from stale sessions.list metadata older than the send', async () => {
|
||||
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||
const h = makeHarness({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
sending: true,
|
||||
activeRunId: 'run-current',
|
||||
pendingFinal: true,
|
||||
lastUserMessageAt: 2000,
|
||||
});
|
||||
const actions = createSessionActions(h.set as never, h.get as never);
|
||||
|
||||
invokeIpcMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [{
|
||||
key: 'agent:main:main',
|
||||
updatedAt: 1000,
|
||||
status: 'done',
|
||||
hasActiveRun: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
await actions.loadSessions();
|
||||
|
||||
const next = h.read();
|
||||
expect(next.sending).toBe(true);
|
||||
expect(next.activeRunId).toBe('run-current');
|
||||
expect(next.pendingFinal).toBe(true);
|
||||
expect(next.lastUserMessageAt).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user