mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
refactor(webchat): extract shared chat state helpers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user