refactor(webchat): extract shared chat state helpers

This commit is contained in:
Peter Steinberger
2026-02-22 21:35:38 +01:00
parent d574056761
commit 382785c6ce
4 changed files with 118 additions and 61 deletions

View File

@@ -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

View File

@@ -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() {

View File

@@ -217,6 +217,42 @@ export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
}
}
function handleTerminalChatEvent(
host: GatewayHost,
payload: ChatEventPayload | undefined,
state: ReturnType<typeof handleChatEvent>,
) {
if (state !== "final" && state !== "error" && state !== "aborted") {
return;
}
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void flushChatQueueForEvent(host as unknown as Parameters<typeof flushChatQueueForEvent>[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<typeof setLastActiveSessionKey>[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<typeof setLastActiveSessionKey>[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<typeof resetToolStream>[0]);
void flushChatQueueForEvent(host as unknown as Parameters<typeof flushChatQueueForEvent>[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;
}

View File

@@ -58,33 +58,53 @@ function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string }
return { mimeType: match[1], content: match[2] };
}
function normalizeAbortedAssistantMessage(message: unknown): Record<string, unknown> | null {
type AssistantMessageNormalizationOptions = {
roleRequirement: "required" | "optional";
roleCaseSensitive?: boolean;
requireContentArray?: boolean;
allowTextField?: boolean;
};
function normalizeAssistantMessage(
message: unknown,
options: AssistantMessageNormalizationOptions,
): Record<string, unknown> | null {
if (!message || typeof message !== "object") {
return null;
}
const candidate = message as Record<string, unknown>;
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<string, unknown> | null {
return normalizeAssistantMessage(message, {
roleRequirement: "required",
roleCaseSensitive: true,
requireContentArray: true,
});
}
function normalizeFinalAssistantMessage(message: unknown): Record<string, unknown> | null {
if (!message || typeof message !== "object") {
return null;
}
const candidate = message as Record<string, unknown>;
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(