mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 04:55:44 +00:00
fix(ui): stop dashboard chat history reload storm (#45541)
* UI: stop dashboard chat history reload storm * Changelog: add PR number for chat reload fix * fix: resolve branch typecheck regressions
This commit is contained in:
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ function createToolWithScreenshotter(
|
|||||||
|
|
||||||
function expectArtifactOnlyFileResult(
|
function expectArtifactOnlyFileResult(
|
||||||
screenshotter: DiffScreenshotter,
|
screenshotter: DiffScreenshotter,
|
||||||
result: { details?: Record<string, unknown> } | null | undefined,
|
result: { details?: unknown } | null | undefined,
|
||||||
) {
|
) {
|
||||||
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
|
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
|
||||||
expect((result?.details as Record<string, unknown>).mode).toBe("file");
|
expect((result?.details as Record<string, unknown>).mode).toBe("file");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Mock } from "vitest";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
type MatrixBotSdkMockParams = {
|
type MatrixBotSdkMockParams = {
|
||||||
@@ -7,7 +8,26 @@ type MatrixBotSdkMockParams = {
|
|||||||
includeVerboseLogService?: boolean;
|
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 {
|
return {
|
||||||
ConsoleLogger: class {
|
ConsoleLogger: class {
|
||||||
trace = vi.fn();
|
trace = vi.fn();
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import {
|
|||||||
buildSglangProvider,
|
buildSglangProvider,
|
||||||
configureOpenAICompatibleSelfHostedProviderNonInteractive,
|
configureOpenAICompatibleSelfHostedProviderNonInteractive,
|
||||||
emptyPluginConfigSchema,
|
emptyPluginConfigSchema,
|
||||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
|
promptAndConfigureOpenAICompatibleSelfHostedProvider,
|
||||||
type OpenClawPluginApi,
|
type OpenClawPluginApi,
|
||||||
|
type ProviderAuthContext,
|
||||||
type ProviderAuthMethodNonInteractiveContext,
|
type ProviderAuthMethodNonInteractiveContext,
|
||||||
|
type ProviderAuthResult,
|
||||||
type ProviderDiscoveryContext,
|
type ProviderDiscoveryContext,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/core";
|
||||||
|
|
||||||
@@ -28,8 +30,8 @@ const sglangPlugin = {
|
|||||||
label: "SGLang",
|
label: "SGLang",
|
||||||
hint: "Fast self-hosted OpenAI-compatible server",
|
hint: "Fast self-hosted OpenAI-compatible server",
|
||||||
kind: "custom",
|
kind: "custom",
|
||||||
run: (ctx) =>
|
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
|
||||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider({
|
||||||
cfg: ctx.config,
|
cfg: ctx.config,
|
||||||
prompter: ctx.prompter,
|
prompter: ctx.prompter,
|
||||||
providerId: PROVIDER_ID,
|
providerId: PROVIDER_ID,
|
||||||
@@ -37,7 +39,18 @@ const sglangPlugin = {
|
|||||||
defaultBaseUrl: DEFAULT_BASE_URL,
|
defaultBaseUrl: DEFAULT_BASE_URL,
|
||||||
defaultApiKeyEnvVar: "SGLANG_API_KEY",
|
defaultApiKeyEnvVar: "SGLANG_API_KEY",
|
||||||
modelPlaceholder: "Qwen/Qwen3-8B",
|
modelPlaceholder: "Qwen/Qwen3-8B",
|
||||||
}),
|
});
|
||||||
|
return {
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
profileId: result.profileId,
|
||||||
|
credential: result.credential,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
configPatch: result.config,
|
||||||
|
defaultModel: result.modelRef,
|
||||||
|
};
|
||||||
|
},
|
||||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
||||||
configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import type { Mock } from "vitest";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
export type RegisteredRoute = {
|
export type RegisteredRoute = {
|
||||||
@@ -7,11 +8,13 @@ export type RegisteredRoute = {
|
|||||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const registerPluginHttpRouteMock = vi.fn<(params: RegisteredRoute) => () => void>(() =>
|
export const registerPluginHttpRouteMock: Mock<(params: RegisteredRoute) => () => void> = vi.fn(
|
||||||
vi.fn(),
|
() => vi.fn(),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const dispatchReplyWithBufferedBlockDispatcher = vi.fn().mockResolvedValue({ counts: {} });
|
export const dispatchReplyWithBufferedBlockDispatcher: Mock<
|
||||||
|
() => Promise<{ counts: Record<string, number> }>
|
||||||
|
> = vi.fn().mockResolvedValue({ counts: {} });
|
||||||
|
|
||||||
async function readRequestBodyWithLimitForTest(req: IncomingMessage): Promise<string> {
|
async function readRequestBodyWithLimitForTest(req: IncomingMessage): Promise<string> {
|
||||||
return await new Promise<string>((resolve, reject) => {
|
return await new Promise<string>((resolve, reject) => {
|
||||||
|
|||||||
@@ -85,12 +85,12 @@ type TelegramSendOptions = NonNullable<Parameters<TelegramSendFn>[2]>;
|
|||||||
|
|
||||||
function buildTelegramSendOptions(params: {
|
function buildTelegramSendOptions(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string | null;
|
||||||
mediaLocalRoots?: readonly string[];
|
mediaLocalRoots?: readonly string[] | null;
|
||||||
accountId?: string;
|
accountId?: string | null;
|
||||||
replyToId?: string;
|
replyToId?: string | null;
|
||||||
threadId?: string;
|
threadId?: string | number | null;
|
||||||
silent?: boolean;
|
silent?: boolean | null;
|
||||||
}): TelegramSendOptions {
|
}): TelegramSendOptions {
|
||||||
return {
|
return {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
@@ -108,13 +108,13 @@ async function sendTelegramOutbound(params: {
|
|||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
to: string;
|
to: string;
|
||||||
text: string;
|
text: string;
|
||||||
mediaUrl?: string;
|
mediaUrl?: string | null;
|
||||||
mediaLocalRoots?: readonly string[];
|
mediaLocalRoots?: readonly string[] | null;
|
||||||
accountId?: string;
|
accountId?: string | null;
|
||||||
deps?: { sendTelegram?: TelegramSendFn };
|
deps?: { sendTelegram?: TelegramSendFn };
|
||||||
replyToId?: string;
|
replyToId?: string | null;
|
||||||
threadId?: string;
|
threadId?: string | number | null;
|
||||||
silent?: boolean;
|
silent?: boolean | null;
|
||||||
}) {
|
}) {
|
||||||
const send =
|
const send =
|
||||||
params.deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
params.deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram;
|
||||||
|
|||||||
@@ -154,8 +154,17 @@ function applyTlonSetupConfig(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ResolvedTlonAccount = ReturnType<typeof resolveTlonAccount>;
|
type ResolvedTlonAccount = ReturnType<typeof resolveTlonAccount>;
|
||||||
|
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);
|
const account = resolveTlonAccount(params.cfg, params.accountId ?? undefined);
|
||||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
if (!account.configured || !account.ship || !account.url || !account.code) {
|
||||||
throw new Error("Tlon account not configured");
|
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()}`);
|
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;
|
return (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withHttpPokeAccountApi<T>(
|
async function withHttpPokeAccountApi<T>(
|
||||||
account: ResolvedTlonAccount & { ship: string; url: string; code: string },
|
account: ConfiguredTlonAccount,
|
||||||
run: (api: Awaited<ReturnType<typeof createHttpPokeApi>>) => Promise<T>,
|
run: (api: Awaited<ReturnType<typeof createHttpPokeApi>>) => Promise<T>,
|
||||||
) {
|
) {
|
||||||
const api = await createHttpPokeApi({
|
const api = await createHttpPokeApi({
|
||||||
@@ -241,7 +250,7 @@ const tlonOutbound: ChannelOutboundAdapter = {
|
|||||||
shipUrl: account.url,
|
shipUrl: account.url,
|
||||||
shipName: account.ship.replace(/^~/, ""),
|
shipName: account.ship.replace(/^~/, ""),
|
||||||
verbose: false,
|
verbose: false,
|
||||||
getCode: async () => account.code!,
|
getCode: async () => account.code,
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined;
|
const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined;
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import {
|
|||||||
buildVllmProvider,
|
buildVllmProvider,
|
||||||
configureOpenAICompatibleSelfHostedProviderNonInteractive,
|
configureOpenAICompatibleSelfHostedProviderNonInteractive,
|
||||||
emptyPluginConfigSchema,
|
emptyPluginConfigSchema,
|
||||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
|
promptAndConfigureOpenAICompatibleSelfHostedProvider,
|
||||||
type OpenClawPluginApi,
|
type OpenClawPluginApi,
|
||||||
|
type ProviderAuthContext,
|
||||||
type ProviderAuthMethodNonInteractiveContext,
|
type ProviderAuthMethodNonInteractiveContext,
|
||||||
|
type ProviderAuthResult,
|
||||||
type ProviderDiscoveryContext,
|
type ProviderDiscoveryContext,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/core";
|
||||||
|
|
||||||
@@ -28,8 +30,8 @@ const vllmPlugin = {
|
|||||||
label: "vLLM",
|
label: "vLLM",
|
||||||
hint: "Local/self-hosted OpenAI-compatible server",
|
hint: "Local/self-hosted OpenAI-compatible server",
|
||||||
kind: "custom",
|
kind: "custom",
|
||||||
run: (ctx) =>
|
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
|
||||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
const result = await promptAndConfigureOpenAICompatibleSelfHostedProvider({
|
||||||
cfg: ctx.config,
|
cfg: ctx.config,
|
||||||
prompter: ctx.prompter,
|
prompter: ctx.prompter,
|
||||||
providerId: PROVIDER_ID,
|
providerId: PROVIDER_ID,
|
||||||
@@ -37,7 +39,18 @@ const vllmPlugin = {
|
|||||||
defaultBaseUrl: DEFAULT_BASE_URL,
|
defaultBaseUrl: DEFAULT_BASE_URL,
|
||||||
defaultApiKeyEnvVar: "VLLM_API_KEY",
|
defaultApiKeyEnvVar: "VLLM_API_KEY",
|
||||||
modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct",
|
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) =>
|
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
||||||
configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -230,7 +230,9 @@ const voiceCallPlugin = {
|
|||||||
const respondToCallMessageAction = async (params: {
|
const respondToCallMessageAction = async (params: {
|
||||||
requestParams: GatewayRequestHandlerOptions["params"];
|
requestParams: GatewayRequestHandlerOptions["params"];
|
||||||
respond: GatewayRequestHandlerOptions["respond"];
|
respond: GatewayRequestHandlerOptions["respond"];
|
||||||
action: (request: Awaited<ReturnType<typeof resolveCallMessageRequest>>) => Promise<{
|
action: (
|
||||||
|
request: Exclude<Awaited<ReturnType<typeof resolveCallMessageRequest>>, { error: string }>,
|
||||||
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
transcript?: string;
|
transcript?: string;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export function createMockFollowupRun(
|
|||||||
enqueuedAt: Date.now(),
|
enqueuedAt: Date.now(),
|
||||||
originatingTo: "channel:C1",
|
originatingTo: "channel:C1",
|
||||||
run: {
|
run: {
|
||||||
|
agentId: "agent",
|
||||||
|
agentDir: "/tmp/agent",
|
||||||
sessionId: "session",
|
sessionId: "session",
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
messageProvider: "whatsapp",
|
messageProvider: "whatsapp",
|
||||||
@@ -34,7 +36,10 @@ export function createMockFollowupRun(
|
|||||||
sessionFile: "/tmp/session.jsonl",
|
sessionFile: "/tmp/session.jsonl",
|
||||||
workspaceDir: "/tmp",
|
workspaceDir: "/tmp",
|
||||||
config: {},
|
config: {},
|
||||||
skillsSnapshot: {},
|
skillsSnapshot: {
|
||||||
|
prompt: "",
|
||||||
|
skills: [],
|
||||||
|
},
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
model: "claude",
|
model: "claude",
|
||||||
thinkLevel: "low",
|
thinkLevel: "low",
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ const routeState = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const chromeMcpMocks = 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 })),
|
navigateChromeMcpPage: vi.fn(async ({ url }: { url: string }) => ({ url })),
|
||||||
takeChromeMcpScreenshot: vi.fn(async () => Buffer.from("png")),
|
takeChromeMcpScreenshot: vi.fn(async () => Buffer.from("png")),
|
||||||
takeChromeMcpSnapshot: vi.fn(async () => ({
|
takeChromeMcpSnapshot: vi.fn(async () => ({
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
import type { MockFn } from "../../test-utils/vitest-mock-fn.js";
|
||||||
|
|
||||||
export const preflightDiscordMessageMock = vi.fn();
|
export const preflightDiscordMessageMock: MockFn = vi.fn();
|
||||||
export const processDiscordMessageMock = vi.fn();
|
export const processDiscordMessageMock: MockFn = vi.fn();
|
||||||
|
|
||||||
vi.mock("./message-handler.preflight.js", () => ({
|
vi.mock("./message-handler.preflight.js", () => ({
|
||||||
preflightDiscordMessage: preflightDiscordMessageMock,
|
preflightDiscordMessage: preflightDiscordMessageMock,
|
||||||
|
|||||||
@@ -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 { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js";
|
||||||
import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts";
|
import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts";
|
||||||
|
|
||||||
|
const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||||
|
|
||||||
type GatewayClientMock = {
|
type GatewayClientMock = {
|
||||||
start: ReturnType<typeof vi.fn>;
|
start: ReturnType<typeof vi.fn>;
|
||||||
stop: ReturnType<typeof vi.fn>;
|
stop: ReturnType<typeof vi.fn>;
|
||||||
@@ -70,6 +72,14 @@ vi.mock("./gateway.ts", () => {
|
|||||||
return { GatewayBrowserClient, resolveGatewayErrorDetailCode };
|
return { GatewayBrowserClient, resolveGatewayErrorDetailCode };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("./controllers/chat.ts", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./controllers/chat.ts")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadChatHistory: loadChatHistoryMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function createHost() {
|
function createHost() {
|
||||||
return {
|
return {
|
||||||
settings: {
|
settings: {
|
||||||
@@ -106,7 +116,15 @@ function createHost() {
|
|||||||
assistantAgentId: null,
|
assistantAgentId: null,
|
||||||
serverVersion: null,
|
serverVersion: null,
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
|
chatMessages: [],
|
||||||
|
chatToolMessages: [],
|
||||||
|
chatStreamSegments: [],
|
||||||
|
chatStream: null,
|
||||||
|
chatStreamStartedAt: null,
|
||||||
chatRunId: null,
|
chatRunId: null,
|
||||||
|
toolStreamById: new Map(),
|
||||||
|
toolStreamOrder: [],
|
||||||
|
toolStreamSyncTimer: null,
|
||||||
refreshSessionsAfterChat: new Set<string>(),
|
refreshSessionsAfterChat: new Set<string>(),
|
||||||
execApprovalQueue: [],
|
execApprovalQueue: [],
|
||||||
execApprovalError: null,
|
execApprovalError: null,
|
||||||
@@ -117,6 +135,7 @@ function createHost() {
|
|||||||
describe("connectGateway", () => {
|
describe("connectGateway", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
gatewayClientInstances.length = 0;
|
gatewayClientInstances.length = 0;
|
||||||
|
loadChatHistoryMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores stale client onGap callbacks after reconnect", () => {
|
it("ignores stale client onGap callbacks after reconnect", () => {
|
||||||
@@ -294,6 +313,73 @@ describe("connectGateway", () => {
|
|||||||
expect(host.lastError).toContain("gateway token mismatch");
|
expect(host.lastError).toContain("gateway token mismatch");
|
||||||
expect(host.lastErrorCode).toBe("AUTH_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", () => {
|
describe("resolveControlUiClientVersion", () => {
|
||||||
|
|||||||
@@ -339,17 +339,6 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
|||||||
host as unknown as Parameters<typeof handleAgentEvent>[0],
|
host as unknown as Parameters<typeof handleAgentEvent>[0],
|
||||||
evt.payload as AgentEventPayload | undefined,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user