diff --git a/CHANGELOG.md b/CHANGELOG.md index e02a0555fe0..bd28d771de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev. - iMessage/remote attachments: reject unsafe remote attachment paths before spawning SCP, so sender-controlled filenames can no longer inject shell metacharacters into remote media staging. Thanks @lintsinghua. - Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08. - Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn. diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 1e939c60390..210586ad381 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -432,7 +432,7 @@ function createToolWithScreenshotter( function expectArtifactOnlyFileResult( screenshotter: DiffScreenshotter, - result: { details?: Record } | null | undefined, + result: { details?: unknown } | null | undefined, ) { expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); expect((result?.details as Record).mode).toBe("file"); diff --git a/extensions/matrix/src/test-mocks.ts b/extensions/matrix/src/test-mocks.ts index 8a104b94650..687b94459ea 100644 --- a/extensions/matrix/src/test-mocks.ts +++ b/extensions/matrix/src/test-mocks.ts @@ -1,3 +1,4 @@ +import type { Mock } from "vitest"; import { vi } from "vitest"; type MatrixBotSdkMockParams = { @@ -7,7 +8,26 @@ type MatrixBotSdkMockParams = { includeVerboseLogService?: boolean; }; -export function createMatrixBotSdkMock(params: MatrixBotSdkMockParams = {}) { +type MatrixBotSdkMock = { + ConsoleLogger: new () => { + trace: Mock<() => void>; + debug: Mock<() => void>; + info: Mock<() => void>; + warn: Mock<() => void>; + error: Mock<() => void>; + }; + MatrixClient: unknown; + LogService: { + setLogger: Mock<() => void>; + warn?: Mock<() => void>; + info?: Mock<() => void>; + debug?: Mock<() => void>; + }; + SimpleFsStorageProvider: unknown; + RustSdkCryptoStorageProvider: unknown; +}; + +export function createMatrixBotSdkMock(params: MatrixBotSdkMockParams = {}): MatrixBotSdkMock { return { ConsoleLogger: class { trace = vi.fn(); diff --git a/extensions/sglang/index.ts b/extensions/sglang/index.ts index 13e301de28b..4c9102caebc 100644 --- a/extensions/sglang/index.ts +++ b/extensions/sglang/index.ts @@ -2,9 +2,11 @@ import { buildSglangProvider, configureOpenAICompatibleSelfHostedProviderNonInteractive, emptyPluginConfigSchema, - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth, + promptAndConfigureOpenAICompatibleSelfHostedProvider, type OpenClawPluginApi, + type ProviderAuthContext, type ProviderAuthMethodNonInteractiveContext, + type ProviderAuthResult, type ProviderDiscoveryContext, } from "openclaw/plugin-sdk/core"; @@ -28,8 +30,8 @@ const sglangPlugin = { label: "SGLang", hint: "Fast self-hosted OpenAI-compatible server", kind: "custom", - run: (ctx) => - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({ + run: async (ctx: ProviderAuthContext): Promise => { + const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider({ cfg: ctx.config, prompter: ctx.prompter, providerId: PROVIDER_ID, @@ -37,7 +39,18 @@ const sglangPlugin = { defaultBaseUrl: DEFAULT_BASE_URL, defaultApiKeyEnvVar: "SGLANG_API_KEY", modelPlaceholder: "Qwen/Qwen3-8B", - }), + }); + return { + profiles: [ + { + profileId: result.profileId, + credential: result.credential, + }, + ], + configPatch: result.config, + defaultModel: result.modelRef, + }; + }, runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => configureOpenAICompatibleSelfHostedProviderNonInteractive({ ctx, diff --git a/extensions/synology-chat/src/channel.test-mocks.ts b/extensions/synology-chat/src/channel.test-mocks.ts index b27c7ad434c..10ccca5f9d0 100644 --- a/extensions/synology-chat/src/channel.test-mocks.ts +++ b/extensions/synology-chat/src/channel.test-mocks.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Mock } from "vitest"; import { vi } from "vitest"; export type RegisteredRoute = { @@ -7,11 +8,13 @@ export type RegisteredRoute = { handler: (req: IncomingMessage, res: ServerResponse) => Promise; }; -export const registerPluginHttpRouteMock = vi.fn<(params: RegisteredRoute) => () => void>(() => - vi.fn(), +export const registerPluginHttpRouteMock: Mock<(params: RegisteredRoute) => () => void> = vi.fn( + () => vi.fn(), ); -export const dispatchReplyWithBufferedBlockDispatcher = vi.fn().mockResolvedValue({ counts: {} }); +export const dispatchReplyWithBufferedBlockDispatcher: Mock< + () => Promise<{ counts: Record }> +> = vi.fn().mockResolvedValue({ counts: {} }); async function readRequestBodyWithLimitForTest(req: IncomingMessage): Promise { return await new Promise((resolve, reject) => { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index b5ae12fa06d..20d012c9dda 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -85,12 +85,12 @@ type TelegramSendOptions = NonNullable[2]>; function buildTelegramSendOptions(params: { cfg: OpenClawConfig; - mediaUrl?: string; - mediaLocalRoots?: readonly string[]; - accountId?: string; - replyToId?: string; - threadId?: string; - silent?: boolean; + mediaUrl?: string | null; + mediaLocalRoots?: readonly string[] | null; + accountId?: string | null; + replyToId?: string | null; + threadId?: string | number | null; + silent?: boolean | null; }): TelegramSendOptions { return { verbose: false, @@ -108,13 +108,13 @@ async function sendTelegramOutbound(params: { cfg: OpenClawConfig; to: string; text: string; - mediaUrl?: string; - mediaLocalRoots?: readonly string[]; - accountId?: string; + mediaUrl?: string | null; + mediaLocalRoots?: readonly string[] | null; + accountId?: string | null; deps?: { sendTelegram?: TelegramSendFn }; - replyToId?: string; - threadId?: string; - silent?: boolean; + replyToId?: string | null; + threadId?: string | number | null; + silent?: boolean | null; }) { const send = params.deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index b84679e1f39..eb37c8d7f74 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -154,8 +154,17 @@ function applyTlonSetupConfig(params: { } type ResolvedTlonAccount = ReturnType; +type ConfiguredTlonAccount = ResolvedTlonAccount & { + ship: string; + url: string; + code: string; +}; -function resolveOutboundContext(params: { cfg: OpenClawConfig; accountId?: string; to: string }) { +function resolveOutboundContext(params: { + cfg: OpenClawConfig; + accountId?: string | null; + to: string; +}) { const account = resolveTlonAccount(params.cfg, params.accountId ?? undefined); if (!account.configured || !account.ship || !account.url || !account.code) { throw new Error("Tlon account not configured"); @@ -166,15 +175,15 @@ function resolveOutboundContext(params: { cfg: OpenClawConfig; accountId?: strin throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); } - return { account, parsed }; + return { account: account as ConfiguredTlonAccount, parsed }; } -function resolveReplyId(replyToId?: string, threadId?: string) { +function resolveReplyId(replyToId?: string | null, threadId?: string | number | null) { return (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined; } async function withHttpPokeAccountApi( - account: ResolvedTlonAccount & { ship: string; url: string; code: string }, + account: ConfiguredTlonAccount, run: (api: Awaited>) => Promise, ) { const api = await createHttpPokeApi({ @@ -241,7 +250,7 @@ const tlonOutbound: ChannelOutboundAdapter = { shipUrl: account.url, shipName: account.ship.replace(/^~/, ""), verbose: false, - getCode: async () => account.code!, + getCode: async () => account.code, }); const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined; diff --git a/extensions/vllm/index.ts b/extensions/vllm/index.ts index 3a30f8b9f76..fd0a5e18914 100644 --- a/extensions/vllm/index.ts +++ b/extensions/vllm/index.ts @@ -2,9 +2,11 @@ import { buildVllmProvider, configureOpenAICompatibleSelfHostedProviderNonInteractive, emptyPluginConfigSchema, - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth, + promptAndConfigureOpenAICompatibleSelfHostedProvider, type OpenClawPluginApi, + type ProviderAuthContext, type ProviderAuthMethodNonInteractiveContext, + type ProviderAuthResult, type ProviderDiscoveryContext, } from "openclaw/plugin-sdk/core"; @@ -28,8 +30,8 @@ const vllmPlugin = { label: "vLLM", hint: "Local/self-hosted OpenAI-compatible server", kind: "custom", - run: (ctx) => - promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({ + run: async (ctx: ProviderAuthContext): Promise => { + const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider({ cfg: ctx.config, prompter: ctx.prompter, providerId: PROVIDER_ID, @@ -37,7 +39,18 @@ const vllmPlugin = { defaultBaseUrl: DEFAULT_BASE_URL, defaultApiKeyEnvVar: "VLLM_API_KEY", modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct", - }), + }); + return { + profiles: [ + { + profileId: result.profileId, + credential: result.credential, + }, + ], + configPatch: result.config, + defaultModel: result.modelRef, + }; + }, runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => configureOpenAICompatibleSelfHostedProviderNonInteractive({ ctx, diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index e61b5142ef1..7393fb03c9b 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -230,7 +230,9 @@ const voiceCallPlugin = { const respondToCallMessageAction = async (params: { requestParams: GatewayRequestHandlerOptions["params"]; respond: GatewayRequestHandlerOptions["respond"]; - action: (request: Awaited>) => Promise<{ + action: ( + request: Exclude>, { error: string }>, + ) => Promise<{ success: boolean; error?: string; transcript?: string; diff --git a/src/auto-reply/reply/test-helpers.ts b/src/auto-reply/reply/test-helpers.ts index d92bf481f42..fe1913e723d 100644 --- a/src/auto-reply/reply/test-helpers.ts +++ b/src/auto-reply/reply/test-helpers.ts @@ -27,6 +27,8 @@ export function createMockFollowupRun( enqueuedAt: Date.now(), originatingTo: "channel:C1", run: { + agentId: "agent", + agentDir: "/tmp/agent", sessionId: "session", sessionKey: "main", messageProvider: "whatsapp", @@ -34,7 +36,10 @@ export function createMockFollowupRun( sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", config: {}, - skillsSnapshot: {}, + skillsSnapshot: { + prompt: "", + skills: [], + }, provider: "anthropic", model: "claude", thinkLevel: "low", diff --git a/src/browser/routes/agent.existing-session.test.ts b/src/browser/routes/agent.existing-session.test.ts index 070a0ed41d7..89b234cdc5c 100644 --- a/src/browser/routes/agent.existing-session.test.ts +++ b/src/browser/routes/agent.existing-session.test.ts @@ -26,7 +26,9 @@ const routeState = vi.hoisted(() => ({ })); const chromeMcpMocks = vi.hoisted(() => ({ - evaluateChromeMcpScript: vi.fn(async () => true), + evaluateChromeMcpScript: vi.fn( + async (_params: { profileName: string; targetId: string; fn: string }) => true, + ), navigateChromeMcpPage: vi.fn(async ({ url }: { url: string }) => ({ url })), takeChromeMcpScreenshot: vi.fn(async () => Buffer.from("png")), takeChromeMcpSnapshot: vi.fn(async () => ({ diff --git a/src/discord/monitor/message-handler.module-test-helpers.ts b/src/discord/monitor/message-handler.module-test-helpers.ts index 046813e397f..fce7580e912 100644 --- a/src/discord/monitor/message-handler.module-test-helpers.ts +++ b/src/discord/monitor/message-handler.module-test-helpers.ts @@ -1,7 +1,8 @@ import { vi } from "vitest"; +import type { MockFn } from "../../test-utils/vitest-mock-fn.js"; -export const preflightDiscordMessageMock = vi.fn(); -export const processDiscordMessageMock = vi.fn(); +export const preflightDiscordMessageMock: MockFn = vi.fn(); +export const processDiscordMessageMock: MockFn = vi.fn(); vi.mock("./message-handler.preflight.js", () => ({ preflightDiscordMessage: preflightDiscordMessageMock, diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index c8ea860b72e..bcef1be3ed3 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -3,6 +3,8 @@ import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "../../../src/gateway/events.js"; import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js"; import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts"; +const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined)); + type GatewayClientMock = { start: ReturnType; stop: ReturnType; @@ -70,6 +72,14 @@ vi.mock("./gateway.ts", () => { return { GatewayBrowserClient, resolveGatewayErrorDetailCode }; }); +vi.mock("./controllers/chat.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadChatHistory: loadChatHistoryMock, + }; +}); + function createHost() { return { settings: { @@ -106,7 +116,15 @@ function createHost() { assistantAgentId: null, serverVersion: null, sessionKey: "main", + chatMessages: [], + chatToolMessages: [], + chatStreamSegments: [], + chatStream: null, + chatStreamStartedAt: null, chatRunId: null, + toolStreamById: new Map(), + toolStreamOrder: [], + toolStreamSyncTimer: null, refreshSessionsAfterChat: new Set(), execApprovalQueue: [], execApprovalError: null, @@ -117,6 +135,7 @@ function createHost() { describe("connectGateway", () => { beforeEach(() => { gatewayClientInstances.length = 0; + loadChatHistoryMock.mockClear(); }); it("ignores stale client onGap callbacks after reconnect", () => { @@ -294,6 +313,73 @@ describe("connectGateway", () => { expect(host.lastError).toContain("gateway token mismatch"); expect(host.lastErrorCode).toBe("AUTH_TOKEN_MISMATCH"); }); + + it("does not reload chat history for each live tool result event", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitEvent({ + event: "agent", + payload: { + runId: "engine-run-1", + seq: 1, + stream: "tool", + ts: 1, + sessionKey: "main", + data: { + toolCallId: "tool-1", + name: "fetch", + phase: "result", + result: { text: "ok" }, + }, + }, + }); + + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + }); + + it("reloads chat history once after the final chat event when tool output was used", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitEvent({ + event: "agent", + payload: { + runId: "engine-run-1", + seq: 1, + stream: "tool", + ts: 1, + sessionKey: "main", + data: { + toolCallId: "tool-1", + name: "fetch", + phase: "result", + result: { text: "ok" }, + }, + }, + }); + + client.emitEvent({ + event: "chat", + payload: { + runId: "engine-run-1", + sessionKey: "main", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "Done" }], + }, + }, + }); + + expect(loadChatHistoryMock).toHaveBeenCalledTimes(1); + }); }); describe("resolveControlUiClientVersion", () => { diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index ee761fe85e0..0cf39df0bc4 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -339,17 +339,6 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { host as unknown as Parameters[0], evt.payload as AgentEventPayload | undefined, ); - // Reload history after each tool result so the persisted text + tool - // output replaces any truncated streaming fragments. - const agentPayload = evt.payload as AgentEventPayload | undefined; - const toolData = agentPayload?.data; - if ( - agentPayload?.stream === "tool" && - typeof toolData?.phase === "string" && - toolData.phase === "result" - ) { - void loadChatHistory(host as unknown as OpenClawApp); - } return; }