From 382785c6ced9d4be08ba5a230187dee984d17f50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 21:35:38 +0100 Subject: [PATCH] refactor(webchat): extract shared chat state helpers --- src/auto-reply/reply/session.ts | 55 +++++++++++++++++---------- ui/src/ui/app-gateway.node.test.ts | 13 ++++++- ui/src/ui/app-gateway.ts | 61 ++++++++++++++++++------------ ui/src/ui/controllers/chat.ts | 50 ++++++++++++++++-------- 4 files changed, 118 insertions(+), 61 deletions(-) diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 2f34f668910..6494192c58b 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -57,6 +57,35 @@ function resolveSessionKeyChannelHint(sessionKey?: string): string | undefined { return normalizeMessageChannel(head); } +function resolveLastChannelRaw(params: { + originatingChannelRaw?: string; + persistedLastChannel?: string; + sessionKey?: string; +}): string | undefined { + const originatingChannel = normalizeMessageChannel(params.originatingChannelRaw); + const persistedChannel = normalizeMessageChannel(params.persistedLastChannel); + const sessionKeyChannelHint = resolveSessionKeyChannelHint(params.sessionKey); + let resolved = params.originatingChannelRaw || params.persistedLastChannel; + // Internal webchat/system turns should not overwrite previously known external + // delivery routes (or explicit channel hints encoded in the session key). + if (originatingChannel === INTERNAL_MESSAGE_CHANNEL) { + if ( + persistedChannel && + persistedChannel !== INTERNAL_MESSAGE_CHANNEL && + isDeliverableMessageChannel(persistedChannel) + ) { + resolved = persistedChannel; + } else if ( + sessionKeyChannelHint && + sessionKeyChannelHint !== INTERNAL_MESSAGE_CHANNEL && + isDeliverableMessageChannel(sessionKeyChannelHint) + ) { + resolved = sessionKeyChannelHint; + } + } + return resolved; +} + export type SessionInitResult = { sessionCtx: TemplateContext; sessionEntry: SessionEntry; @@ -289,27 +318,11 @@ export async function initSessionState(params: { const baseEntry = !isNewSession && freshEntry ? entry : undefined; // Track the originating channel/to for announce routing (subagent announce-back). const originatingChannelRaw = ctx.OriginatingChannel as string | undefined; - const originatingChannel = normalizeMessageChannel(originatingChannelRaw); - const persistedChannel = normalizeMessageChannel(baseEntry?.lastChannel); - const sessionKeyChannelHint = resolveSessionKeyChannelHint(sessionKey); - let lastChannelRaw = originatingChannelRaw || baseEntry?.lastChannel; - // Internal webchat/system turns should not overwrite previously known external - // delivery routes (or explicit channel hints encoded in the session key). - if (originatingChannel === INTERNAL_MESSAGE_CHANNEL) { - if ( - persistedChannel && - persistedChannel !== INTERNAL_MESSAGE_CHANNEL && - isDeliverableMessageChannel(persistedChannel) - ) { - lastChannelRaw = persistedChannel; - } else if ( - sessionKeyChannelHint && - sessionKeyChannelHint !== INTERNAL_MESSAGE_CHANNEL && - isDeliverableMessageChannel(sessionKeyChannelHint) - ) { - lastChannelRaw = sessionKeyChannelHint; - } - } + const lastChannelRaw = resolveLastChannelRaw({ + originatingChannelRaw, + persistedLastChannel: baseEntry?.lastChannel, + sessionKey, + }); const lastToRaw = ctx.OriginatingTo || ctx.To || baseEntry?.lastTo; const lastAccountIdRaw = ctx.AccountId || baseEntry?.lastAccountId; // Only fall back to persisted threadId for thread sessions. Non-thread diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index e3460495708..0b333814289 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -17,6 +17,17 @@ type GatewayClientMock = { const gatewayClientInstances: GatewayClientMock[] = []; vi.mock("./gateway.ts", () => { + function resolveGatewayErrorDetailCode( + error: { details?: unknown } | null | undefined, + ): string | null { + const details = error?.details; + if (!details || typeof details !== "object") { + return null; + } + const code = (details as { code?: unknown }).code; + return typeof code === "string" ? code : null; + } + class GatewayBrowserClient { readonly start = vi.fn(); readonly stop = vi.fn(); @@ -52,7 +63,7 @@ vi.mock("./gateway.ts", () => { } } - return { GatewayBrowserClient }; + return { GatewayBrowserClient, resolveGatewayErrorDetailCode }; }); function createHost() { diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index f8ff9ae7a88..897e7d39c1b 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -217,6 +217,42 @@ export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) { } } +function handleTerminalChatEvent( + host: GatewayHost, + payload: ChatEventPayload | undefined, + state: ReturnType, +) { + if (state !== "final" && state !== "error" && state !== "aborted") { + return; + } + resetToolStream(host as unknown as Parameters[0]); + void flushChatQueueForEvent(host as unknown as Parameters[0]); + const runId = payload?.runId; + if (!runId || !host.refreshSessionsAfterChat.has(runId)) { + return; + } + host.refreshSessionsAfterChat.delete(runId); + if (state === "final") { + void loadSessions(host as unknown as OpenClawApp, { + activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, + }); + } +} + +function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | undefined) { + if (payload?.sessionKey) { + setLastActiveSessionKey( + host as unknown as Parameters[0], + payload.sessionKey, + ); + } + const state = handleChatEvent(host as unknown as OpenClawApp, payload); + handleTerminalChatEvent(host, payload, state); + if (state === "final" && shouldReloadHistoryForFinalEvent(payload)) { + void loadChatHistory(host as unknown as OpenClawApp); + } +} + function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { host.eventLogBuffer = [ { ts: Date.now(), event: evt.event, payload: evt.payload }, @@ -238,30 +274,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { } if (evt.event === "chat") { - const payload = evt.payload as ChatEventPayload | undefined; - if (payload?.sessionKey) { - setLastActiveSessionKey( - host as unknown as Parameters[0], - payload.sessionKey, - ); - } - const state = handleChatEvent(host as unknown as OpenClawApp, payload); - if (state === "final" || state === "error" || state === "aborted") { - resetToolStream(host as unknown as Parameters[0]); - void flushChatQueueForEvent(host as unknown as Parameters[0]); - const runId = payload?.runId; - if (runId && host.refreshSessionsAfterChat.has(runId)) { - host.refreshSessionsAfterChat.delete(runId); - if (state === "final") { - void loadSessions(host as unknown as OpenClawApp, { - activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, - }); - } - } - } - if (state === "final" && shouldReloadHistoryForFinalEvent(payload)) { - void loadChatHistory(host as unknown as OpenClawApp); - } + handleChatGatewayEvent(host, evt.payload as ChatEventPayload | undefined); return; } diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index c3207950079..5305bde0f65 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -58,33 +58,53 @@ function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } return { mimeType: match[1], content: match[2] }; } -function normalizeAbortedAssistantMessage(message: unknown): Record | null { +type AssistantMessageNormalizationOptions = { + roleRequirement: "required" | "optional"; + roleCaseSensitive?: boolean; + requireContentArray?: boolean; + allowTextField?: boolean; +}; + +function normalizeAssistantMessage( + message: unknown, + options: AssistantMessageNormalizationOptions, +): Record | null { if (!message || typeof message !== "object") { return null; } const candidate = message as Record; - if (candidate.role !== "assistant") { + const roleValue = candidate.role; + if (typeof roleValue === "string") { + const role = options.roleCaseSensitive ? roleValue : roleValue.toLowerCase(); + if (role !== "assistant") { + return null; + } + } else if (options.roleRequirement === "required") { return null; } - if (!("content" in candidate) || !Array.isArray(candidate.content)) { + + if (options.requireContentArray) { + return Array.isArray(candidate.content) ? candidate : null; + } + if (!("content" in candidate) && !(options.allowTextField && "text" in candidate)) { return null; } return candidate; } +function normalizeAbortedAssistantMessage(message: unknown): Record | null { + return normalizeAssistantMessage(message, { + roleRequirement: "required", + roleCaseSensitive: true, + requireContentArray: true, + }); +} + function normalizeFinalAssistantMessage(message: unknown): Record | null { - if (!message || typeof message !== "object") { - return null; - } - const candidate = message as Record; - const role = typeof candidate.role === "string" ? candidate.role.toLowerCase() : ""; - if (role && role !== "assistant") { - return null; - } - if (!("content" in candidate) && !("text" in candidate)) { - return null; - } - return candidate; + return normalizeAssistantMessage(message, { + roleRequirement: "optional", + allowTextField: true, + }); } export async function sendChatMessage(