From fc666cf42ad0ff9c765b477a08b665da8d9a01d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 13:51:24 +0100 Subject: [PATCH 1/3] test(qa): allow slower gateway rpc startup retries --- extensions/qa-lab/src/gateway-child.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index 0243ca58256..a41dc1ef011 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -42,6 +42,7 @@ import type { QaTransportAdapter } from "./qa-transport.js"; export type { QaCliBackendAuthMode } from "./providers/env.js"; const QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS = 5; +const QA_GATEWAY_CHILD_RPC_RETRY_HEALTH_TIMEOUT_MS = 60_000; const QA_GATEWAY_CHILD_BLOCKED_SECRET_ENV_VARS = Object.freeze([ "OPENCLAW_QA_CONVEX_SECRET_CI", "OPENCLAW_QA_CONVEX_SECRET_MAINTAINER", @@ -684,7 +685,7 @@ export async function startQaGatewayChild(params: { baseUrl, logs, child: attemptChild, - timeoutMs: 15_000, + timeoutMs: QA_GATEWAY_CHILD_RPC_RETRY_HEALTH_TIMEOUT_MS, }); } } From dce35b90fe8fa09ea51a666195b7645c260d6cbf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 13:53:50 +0100 Subject: [PATCH 2/3] test(release): wait longer for dashboard smoke --- scripts/openclaw-cross-os-release-checks.ts | 6 ++++-- test/scripts/openclaw-cross-os-release-checks.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index e095964a5cb..4ac181a51f7 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -58,6 +58,8 @@ const OMITTED_QA_EXTENSION_PREFIXES = [ "dist/extensions/qa-lab/", "dist/extensions/qa-matrix/", ]; +export const CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS = 120_000; +export const CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS = 10_000; if (isMainModule()) { try { @@ -2463,7 +2465,7 @@ function parseAgentPayloadTexts(stdout) { async function runDashboardSmoke(params) { const dashboardUrl = `http://127.0.0.1:${params.lane.gatewayPort}/`; const logStream = createWriteStream(params.logPath, { flags: "a" }); - const deadline = Date.now() + 30_000; + const deadline = Date.now() + CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS; let attempt = 0; try { while (Date.now() < deadline) { @@ -2471,7 +2473,7 @@ async function runDashboardSmoke(params) { logStream.write(`${new Date().toISOString()} attempt=${attempt} url=${dashboardUrl}\n`); try { const response = await fetch(dashboardUrl, { - signal: AbortSignal.timeout(5_000), + signal: AbortSignal.timeout(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS), }); const html = await response.text(); if ( diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index cb132ab9bef..9477eb7caa8 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -12,6 +12,8 @@ import { canConnectToLoopbackPort, buildDiscordSmokeGuildsConfig, buildRealUpdateEnv, + CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS, + CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS, isImmutableReleaseRef, looksLikeReleaseVersionRef, normalizeRequestedRef, @@ -39,6 +41,11 @@ import { } from "../../scripts/openclaw-cross-os-release-checks.ts"; describe("scripts/openclaw-cross-os-release-checks", () => { + it("keeps dashboard smoke patient enough for cold packaged gateway startup", () => { + expect(CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS).toBeGreaterThanOrEqual(120_000); + expect(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS).toBeGreaterThanOrEqual(10_000); + }); + it("accepts OK agent output from the captured log when stdout is empty", () => { const dir = mkdtempSync(join(tmpdir(), "openclaw-cross-os-agent-output-")); try { From 631552c554095b0c520376db9131dac0f5427f94 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 14:14:06 +0100 Subject: [PATCH 3/3] perf: speed up dispatch-from-config tests --- .../reply/dispatch-from-config.test.ts | 116 +++++++++++++++++- src/auto-reply/reply/dispatch-from-config.ts | 44 ++++--- 2 files changed, 143 insertions(+), 17 deletions(-) diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index ec58c5c9888..22cc2cc944a 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -150,6 +150,107 @@ const replyMediaPathMocks = vi.hoisted(() => ({ const runtimePluginMocks = vi.hoisted(() => ({ ensureRuntimePluginsLoaded: vi.fn(), })); +const conversationBindingMocks = vi.hoisted(() => { + type BindingMsgContext = { + OriginatingChannel?: string | null; + Surface?: string | null; + Provider?: string | null; + AccountId?: string | null; + MessageThreadId?: string | number | null; + ThreadParentId?: string | null; + SenderId?: string | null; + SessionKey?: string | null; + ParentSessionKey?: string | null; + OriginatingTo?: string | null; + To?: string | null; + From?: string | null; + NativeChannelId?: string | null; + }; + type BindingConfig = { + channels?: Record; + }; + + const normalizeText = (value: string | number | null | undefined) => + typeof value === "number" ? `${value}` : (value ?? "").trim(); + const normalizeChannel = (value: string | null | undefined) => normalizeText(value).toLowerCase(); + const resolveChannel = (ctx: BindingMsgContext, commandChannel?: string | null) => + normalizeChannel(ctx.OriginatingChannel ?? commandChannel ?? ctx.Surface ?? ctx.Provider); + const resolveAccountId = (ctx: BindingMsgContext, cfg: BindingConfig, channel: string) => + normalizeText(ctx.AccountId) || + normalizeText(cfg.channels?.[channel]?.defaultAccount) || + "default"; + const resolveTarget = (channel: string, value: string | null | undefined) => { + const target = normalizeText(value); + if (!target) { + return undefined; + } + const channelPrefix = `${channel}:`; + return target.toLowerCase().startsWith(channelPrefix) + ? target.slice(channelPrefix.length) + : target; + }; + const resolveThreadId = (ctx: BindingMsgContext) => + normalizeText(ctx.MessageThreadId) || undefined; + + const resolveConversationBindingContextFromMessage = vi.fn( + (params: { cfg: BindingConfig; ctx: BindingMsgContext }) => { + const channel = resolveChannel(params.ctx); + if (!channel) { + return null; + } + const threadId = resolveThreadId(params.ctx); + const baseConversationId = + resolveTarget(channel, params.ctx.OriginatingTo) ?? resolveTarget(channel, params.ctx.To); + const conversationId = threadId ?? baseConversationId; + if (!conversationId) { + return null; + } + const parentConversationId = + threadId && baseConversationId && baseConversationId !== threadId + ? baseConversationId + : resolveTarget(channel, params.ctx.ThreadParentId); + return { + channel, + accountId: resolveAccountId(params.ctx, params.cfg, channel), + conversationId, + ...(parentConversationId ? { parentConversationId } : {}), + ...(threadId ? { threadId } : {}), + }; + }, + ); + + return { + resolveConversationBindingAccountIdFromMessage: (params: { + ctx: BindingMsgContext; + cfg: BindingConfig; + commandChannel?: string | null; + }) => + resolveAccountId(params.ctx, params.cfg, resolveChannel(params.ctx, params.commandChannel)), + resolveConversationBindingChannelFromMessage: ( + ctx: BindingMsgContext, + commandChannel?: string | null, + ) => resolveChannel(ctx, commandChannel), + resolveConversationBindingContextFromAcpCommand: (params: { + cfg: BindingConfig; + ctx: BindingMsgContext; + command?: { to?: string | null; senderId?: string | null }; + sessionKey?: string | null; + parentSessionKey?: string | null; + }) => + resolveConversationBindingContextFromMessage({ + cfg: params.cfg, + ctx: { + ...params.ctx, + SenderId: params.command?.senderId ?? params.ctx.SenderId, + SessionKey: params.sessionKey ?? params.ctx.SessionKey, + ParentSessionKey: params.parentSessionKey ?? params.ctx.ParentSessionKey, + To: params.command?.to ?? params.ctx.To, + }, + }), + resolveConversationBindingContextFromMessage, + resolveConversationBindingThreadIdFromMessage: (ctx: BindingMsgContext) => resolveThreadId(ctx), + }; +}); const threadInfoMocks = vi.hoisted(() => ({ parseSessionThreadInfo: vi.fn< (sessionKey: string | undefined) => { @@ -345,6 +446,18 @@ vi.mock("./reply-media-paths.runtime.js", () => ({ vi.mock("../../agents/runtime-plugins.js", () => ({ ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, })); +vi.mock("./conversation-binding-input.js", () => ({ + resolveConversationBindingAccountIdFromMessage: + conversationBindingMocks.resolveConversationBindingAccountIdFromMessage, + resolveConversationBindingChannelFromMessage: + conversationBindingMocks.resolveConversationBindingChannelFromMessage, + resolveConversationBindingContextFromAcpCommand: + conversationBindingMocks.resolveConversationBindingContextFromAcpCommand, + resolveConversationBindingContextFromMessage: + conversationBindingMocks.resolveConversationBindingContextFromMessage, + resolveConversationBindingThreadIdFromMessage: + conversationBindingMocks.resolveConversationBindingThreadIdFromMessage, +})); vi.mock("../../tts/status-config.js", () => ({ resolveStatusTtsSnapshot: () => ({ autoMode: "always", @@ -771,7 +884,8 @@ describe("dispatchReplyFromConfig", () => { OriginatingTo: undefined, }); - const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; + const replyResolver = async () => + ({ text: "hi", mediaUrl: "https://example.test/reply.png" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index e89b7d6841f..9e48a73367d 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -432,26 +432,38 @@ export async function dispatchReplyFromConfig( }); const routeReplyTo = replyRoute.to; const deliveryChannel = shouldRouteToOriginating ? routeReplyChannel : currentSurface; - const { createReplyMediaPathNormalizer } = await loadReplyMediaPathsRuntime(); - const normalizeReplyMediaPaths = createReplyMediaPathNormalizer({ - cfg, - sessionKey: acpDispatchSessionKey, - workspaceDir, - messageProvider: deliveryChannel, - accountId: replyRoute.accountId, - groupId, - groupChannel: ctx.GroupChannel, - groupSpace: ctx.GroupSpace, - requesterSenderId: ctx.SenderId, - requesterSenderName: ctx.SenderName, - requesterSenderUsername: ctx.SenderUsername, - requesterSenderE164: ctx.SenderE164, - }); + let normalizeReplyMediaPaths: + | ReturnType< + (typeof import("./reply-media-paths.runtime.js"))["createReplyMediaPathNormalizer"] + > + | undefined; + const getNormalizeReplyMediaPaths = async () => { + if (normalizeReplyMediaPaths) { + return normalizeReplyMediaPaths; + } + const { createReplyMediaPathNormalizer } = await loadReplyMediaPathsRuntime(); + normalizeReplyMediaPaths = createReplyMediaPathNormalizer({ + cfg, + sessionKey: acpDispatchSessionKey, + workspaceDir, + messageProvider: deliveryChannel, + accountId: replyRoute.accountId, + groupId, + groupChannel: ctx.GroupChannel, + groupSpace: ctx.GroupSpace, + requesterSenderId: ctx.SenderId, + requesterSenderName: ctx.SenderName, + requesterSenderUsername: ctx.SenderUsername, + requesterSenderE164: ctx.SenderE164, + }); + return normalizeReplyMediaPaths; + }; const normalizeReplyMediaPayload = async (payload: ReplyPayload): Promise => { if (!resolveSendableOutboundReplyParts(payload).hasMedia) { return payload; } - return await normalizeReplyMediaPaths(payload); + const normalizeReplyMediaPayloadPaths = await getNormalizeReplyMediaPaths(); + return await normalizeReplyMediaPayloadPaths(payload); }; const routeReplyToOriginating = async (