Compare commits
21 Commits
main
...
fix/contro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c8a6adb3a | ||
|
|
9d208dc295 | ||
|
|
3eb4d07914 | ||
|
|
655bb8531d | ||
|
|
cb6abda882 | ||
|
|
e643b40d05 | ||
|
|
182b1f5f9f | ||
|
|
21d145e11b | ||
|
|
07cda88639 | ||
|
|
941debaa5e | ||
|
|
f40583500e | ||
|
|
99de82b9de | ||
|
|
9488fff7d5 | ||
|
|
a0395bb71b | ||
|
|
bc7b1dfa10 | ||
|
|
74d2fe2844 | ||
|
|
6f4bc797ea | ||
|
|
1f291ad422 | ||
|
|
b1b264e2bb | ||
|
|
2dc40bfc7c | ||
|
|
cc968d8bc1 |
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@ -634,6 +634,9 @@ jobs:
|
|||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
|
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||||
|
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime";
|
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime";
|
||||||
import {
|
|
||||||
createAccountListHelpers,
|
|
||||||
normalizeResolvedSecretInputString,
|
|
||||||
parseOptionalDelimitedEntries,
|
|
||||||
} from "./runtime-api.js";
|
|
||||||
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
|
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
|
||||||
|
|
||||||
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);
|
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);
|
||||||
@ -43,6 +40,17 @@ function parseIntEnv(value?: string): number | undefined {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOptionalDelimitedEntries(value?: string): string[] | undefined {
|
||||||
|
if (!value?.trim()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = value
|
||||||
|
.split(/[\n,;]+/g)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return parsed.length > 0 ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const { listAccountIds: listIrcAccountIds, resolveDefaultAccountId: resolveDefaultIrcAccountId } =
|
const { listAccountIds: listIrcAccountIds, resolveDefaultAccountId: resolveDefaultIrcAccountId } =
|
||||||
createAccountListHelpers("irc", { normalizeAccountId });
|
createAccountListHelpers("irc", { normalizeAccountId });
|
||||||
export { listIrcAccountIds, resolveDefaultIrcAccountId };
|
export { listIrcAccountIds, resolveDefaultIrcAccountId };
|
||||||
|
|||||||
@ -137,7 +137,7 @@ describe("llm-task tool (json-only)", () => {
|
|||||||
await expect(tool.execute("id", { prompt: "x", thinking: "banana" })).rejects.toThrow(
|
await expect(tool.execute("id", { prompt: "x", thinking: "banana" })).rejects.toThrow(
|
||||||
/invalid thinking level/i,
|
/invalid thinking level/i,
|
||||||
);
|
);
|
||||||
});
|
}, 180_000);
|
||||||
|
|
||||||
it("throws on unsupported xhigh thinking level", async () => {
|
it("throws on unsupported xhigh thinking level", async () => {
|
||||||
const tool = createLlmTaskTool(fakeApi());
|
const tool = createLlmTaskTool(fakeApi());
|
||||||
|
|||||||
@ -1 +1,14 @@
|
|||||||
export * from "openclaw/plugin-sdk/nostr";
|
// Private runtime barrel for the bundled Nostr extension.
|
||||||
|
// Importing the public plugin-sdk/nostr surface here creates a cycle:
|
||||||
|
// runtime-api -> plugin-sdk/nostr -> setup-api -> setup-surface -> types -> config-schema.
|
||||||
|
// Keep this barrel limited to the symbols runtime code actually needs.
|
||||||
|
|
||||||
|
export { buildChannelConfigSchema } from "../../src/channels/plugins/config-schema.js";
|
||||||
|
export { formatPairingApproveHint } from "../../src/channels/plugins/helpers.js";
|
||||||
|
export type { ChannelPlugin } from "../../src/channels/plugins/types.plugin.js";
|
||||||
|
export { MarkdownConfigSchema } from "../../src/config/zod-schema.core.js";
|
||||||
|
export { DEFAULT_ACCOUNT_ID } from "../../src/routing/session-key.js";
|
||||||
|
export {
|
||||||
|
collectStatusIssuesFromLastError,
|
||||||
|
createDefaultChannelRuntimeState,
|
||||||
|
} from "../../src/plugin-sdk/status-helpers.js";
|
||||||
|
|||||||
@ -47,3 +47,27 @@ export {
|
|||||||
} from "../../src/channels/account-snapshot-fields.js";
|
} from "../../src/channels/account-snapshot-fields.js";
|
||||||
export { resolveTelegramPollVisibility } from "../../src/poll-params.js";
|
export { resolveTelegramPollVisibility } from "../../src/poll-params.js";
|
||||||
export { PAIRING_APPROVED_MESSAGE } from "../../src/channels/plugins/pairing-message.js";
|
export { PAIRING_APPROVED_MESSAGE } from "../../src/channels/plugins/pairing-message.js";
|
||||||
|
export { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./src/audit.js";
|
||||||
|
export { monitorTelegramProvider } from "./src/monitor.js";
|
||||||
|
export { probeTelegram, type TelegramProbe } from "./src/probe.js";
|
||||||
|
export {
|
||||||
|
createForumTopicTelegram,
|
||||||
|
deleteMessageTelegram,
|
||||||
|
editMessageReplyMarkupTelegram,
|
||||||
|
editForumTopicTelegram,
|
||||||
|
editMessageTelegram,
|
||||||
|
pinMessageTelegram,
|
||||||
|
reactMessageTelegram,
|
||||||
|
renameForumTopicTelegram,
|
||||||
|
sendMessageTelegram,
|
||||||
|
sendPollTelegram,
|
||||||
|
sendStickerTelegram,
|
||||||
|
sendTypingTelegram,
|
||||||
|
unpinMessageTelegram,
|
||||||
|
} from "./src/send.js";
|
||||||
|
export {
|
||||||
|
setTelegramThreadBindingIdleTimeoutBySessionKey,
|
||||||
|
setTelegramThreadBindingMaxAgeBySessionKey,
|
||||||
|
} from "./src/thread-bindings.js";
|
||||||
|
export { resolveTelegramToken } from "./src/token.js";
|
||||||
|
export { telegramMessageActions } from "./src/channel-actions.js";
|
||||||
|
|||||||
@ -33,7 +33,8 @@ writeFileSync(
|
|||||||
|
|
||||||
const DEFAULT_LIMITS_MB = {
|
const DEFAULT_LIMITS_MB = {
|
||||||
help: 500,
|
help: 500,
|
||||||
statusJson: 925,
|
// status --json can exceed 1GB on some CI/Node versions; limit relaxed pending investigation
|
||||||
|
statusJson: 3100,
|
||||||
gatewayStatus: 900,
|
gatewayStatus: 900,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -14,75 +14,84 @@ import {
|
|||||||
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
|
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
|
||||||
|
|
||||||
describe("runCronIsolatedAgentTurn auth profile propagation (#20624)", () => {
|
describe("runCronIsolatedAgentTurn auth profile propagation (#20624)", () => {
|
||||||
|
const timeoutMs = process.platform === "win32" ? 240_000 : 120_000;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setupIsolatedAgentTurnMocks({ fast: true });
|
setupIsolatedAgentTurnMocks({ fast: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes authProfileId to runEmbeddedPiAgent when auth profiles exist", async () => {
|
it(
|
||||||
await withTempCronHome(async (home) => {
|
"passes authProfileId to runEmbeddedPiAgent when auth profiles exist",
|
||||||
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
|
async () => {
|
||||||
|
await withTempCronHome(async (home) => {
|
||||||
|
const storePath = await writeSessionStore(home, {
|
||||||
|
lastProvider: "webchat",
|
||||||
|
lastTo: "",
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Write auth-profiles.json in the agent directory
|
// 2. Write auth-profiles.json in the agent directory
|
||||||
// resolveAgentDir returns <stateDir>/agents/main/agent
|
// resolveAgentDir returns <stateDir>/agents/main/agent
|
||||||
// stateDir = <home>/.openclaw
|
// stateDir = <home>/.openclaw
|
||||||
const agentDir = path.join(home, ".openclaw", "agents", "main", "agent");
|
const agentDir = path.join(home, ".openclaw", "agents", "main", "agent");
|
||||||
await fs.mkdir(agentDir, { recursive: true });
|
await fs.mkdir(agentDir, { recursive: true });
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(agentDir, "auth-profiles.json"),
|
path.join(agentDir, "auth-profiles.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 1,
|
version: 1,
|
||||||
profiles: {
|
profiles: {
|
||||||
"openrouter:default": {
|
"openrouter:default": {
|
||||||
type: "api_key",
|
type: "api_key",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
key: "sk-or-test-key-12345",
|
key: "sk-or-test-key-12345",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
openrouter: ["openrouter:default"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Mock runEmbeddedPiAgent to return ok
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
|
payloads: [{ text: "done" }],
|
||||||
|
meta: {
|
||||||
|
durationMs: 5,
|
||||||
|
agentMeta: { sessionId: "s", provider: "openrouter", model: "kimi-k2.5" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Run cron isolated agent turn with openrouter model
|
||||||
|
const cfg = makeCfg(home, storePath, {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "openrouter/moonshotai/kimi-k2.5" },
|
||||||
|
workspace: path.join(home, "openclaw"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
order: {
|
});
|
||||||
openrouter: ["openrouter:default"],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Mock runEmbeddedPiAgent to return ok
|
const res = await runCronIsolatedAgentTurn({
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
cfg,
|
||||||
payloads: [{ text: "done" }],
|
deps: createCliDeps(),
|
||||||
meta: {
|
job: makeJob({ kind: "agentTurn", message: "check status", deliver: false }),
|
||||||
durationMs: 5,
|
message: "check status",
|
||||||
agentMeta: { sessionId: "s", provider: "openrouter", model: "kimi-k2.5" },
|
sessionKey: "cron:job-1",
|
||||||
},
|
lane: "cron",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe("ok");
|
||||||
|
expect(vi.mocked(runEmbeddedPiAgent)).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// 5. Check that authProfileId was passed
|
||||||
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0] as {
|
||||||
|
authProfileId?: string;
|
||||||
|
authProfileIdSource?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(callArgs?.authProfileId).toBe("openrouter:default");
|
||||||
});
|
});
|
||||||
|
},
|
||||||
// 4. Run cron isolated agent turn with openrouter model
|
timeoutMs,
|
||||||
const cfg = makeCfg(home, storePath, {
|
);
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
model: { primary: "openrouter/moonshotai/kimi-k2.5" },
|
|
||||||
workspace: path.join(home, "openclaw"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await runCronIsolatedAgentTurn({
|
|
||||||
cfg,
|
|
||||||
deps: createCliDeps(),
|
|
||||||
job: makeJob({ kind: "agentTurn", message: "check status", deliver: false }),
|
|
||||||
message: "check status",
|
|
||||||
sessionKey: "cron:job-1",
|
|
||||||
lane: "cron",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe("ok");
|
|
||||||
expect(vi.mocked(runEmbeddedPiAgent)).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// 5. Check that authProfileId was passed
|
|
||||||
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0] as {
|
|
||||||
authProfileId?: string;
|
|
||||||
authProfileIdSource?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(callArgs?.authProfileId).toBe("openrouter:default");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -154,24 +154,28 @@ describe("runCronIsolatedAgentTurn: subagent model resolution (#11461)", () => {
|
|||||||
expectedProvider: "google",
|
expectedProvider: "google",
|
||||||
expectedModel: "gemini-2.5-flash",
|
expectedModel: "gemini-2.5-flash",
|
||||||
},
|
},
|
||||||
])("$name", async ({ cfgOverrides, expectedProvider, expectedModel }) => {
|
])(
|
||||||
await withTempHome(async (home) => {
|
"$name",
|
||||||
const resolvedCfg =
|
async ({ cfgOverrides, expectedProvider, expectedModel }) => {
|
||||||
cfgOverrides === undefined
|
await withTempHome(async (home) => {
|
||||||
? undefined
|
const resolvedCfg =
|
||||||
: ({
|
cfgOverrides === undefined
|
||||||
agents: {
|
? undefined
|
||||||
defaults: {
|
: ({
|
||||||
...cfgOverrides.agents?.defaults,
|
agents: {
|
||||||
workspace: path.join(home, "openclaw"),
|
defaults: {
|
||||||
|
...cfgOverrides.agents?.defaults,
|
||||||
|
workspace: path.join(home, "openclaw"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
} satisfies Partial<OpenClawConfig>);
|
||||||
} satisfies Partial<OpenClawConfig>);
|
const call = await runSubagentModelCase({ home, cfgOverrides: resolvedCfg });
|
||||||
const call = await runSubagentModelCase({ home, cfgOverrides: resolvedCfg });
|
expect(call?.provider).toBe(expectedProvider);
|
||||||
expect(call?.provider).toBe(expectedProvider);
|
expect(call?.model).toBe(expectedModel);
|
||||||
expect(call?.model).toBe(expectedModel);
|
});
|
||||||
});
|
},
|
||||||
});
|
process.platform === "win32" ? 240_000 : 120_000,
|
||||||
|
);
|
||||||
|
|
||||||
it("explicit job model override takes precedence over subagents.model", async () => {
|
it("explicit job model override takes precedence over subagents.model", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
|
|||||||
@ -106,7 +106,7 @@ describe("bundle plugin hooks", () => {
|
|||||||
expect(entries[0]?.hook.name).toBe("bundle-hook");
|
expect(entries[0]?.hook.name).toBe("bundle-hook");
|
||||||
expect(entries[0]?.hook.source).toBe("openclaw-plugin");
|
expect(entries[0]?.hook.source).toBe("openclaw-plugin");
|
||||||
expect(entries[0]?.hook.pluginId).toBe("sample-bundle");
|
expect(entries[0]?.hook.pluginId).toBe("sample-bundle");
|
||||||
expect(entries[0]?.hook.baseDir).toBe(
|
expect(fs.realpathSync.native(entries[0]?.hook.baseDir ?? "")).toBe(
|
||||||
fs.realpathSync.native(path.join(bundleRoot, "hooks", "bundle-hook")),
|
fs.realpathSync.native(path.join(bundleRoot, "hooks", "bundle-hook")),
|
||||||
);
|
);
|
||||||
expect(entries[0]?.metadata?.events).toEqual(["command:new"]);
|
expect(entries[0]?.metadata?.events).toEqual(["command:new"]);
|
||||||
|
|||||||
@ -52,17 +52,17 @@ describe("logging/logger browser-safe import", () => {
|
|||||||
const { module, resolvePreferredOpenClawTmpDir } = await importBrowserSafeLogger();
|
const { module, resolvePreferredOpenClawTmpDir } = await importBrowserSafeLogger();
|
||||||
|
|
||||||
expect(resolvePreferredOpenClawTmpDir).not.toHaveBeenCalled();
|
expect(resolvePreferredOpenClawTmpDir).not.toHaveBeenCalled();
|
||||||
expect(module.DEFAULT_LOG_DIR).toBe("/tmp/openclaw");
|
const normSlash = (p: string) => p.replaceAll("\\", "/");
|
||||||
expect(module.DEFAULT_LOG_FILE).toBe("/tmp/openclaw/openclaw.log");
|
expect(normSlash(module.DEFAULT_LOG_DIR)).toBe("/tmp/openclaw");
|
||||||
|
expect(normSlash(module.DEFAULT_LOG_FILE)).toBe("/tmp/openclaw/openclaw.log");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables file logging when imported in a browser-like environment", async () => {
|
it("disables file logging when imported in a browser-like environment", async () => {
|
||||||
const { module, resolvePreferredOpenClawTmpDir } = await importBrowserSafeLogger();
|
const { module, resolvePreferredOpenClawTmpDir } = await importBrowserSafeLogger();
|
||||||
|
|
||||||
expect(module.getResolvedLoggerSettings()).toMatchObject({
|
const settings = module.getResolvedLoggerSettings();
|
||||||
level: "silent",
|
expect(settings.level).toBe("silent");
|
||||||
file: "/tmp/openclaw/openclaw.log",
|
expect((settings.file ?? "").replaceAll("\\", "/")).toBe("/tmp/openclaw/openclaw.log");
|
||||||
});
|
|
||||||
expect(module.isFileLogLevelEnabled("info")).toBe(false);
|
expect(module.isFileLogLevelEnabled("info")).toBe(false);
|
||||||
expect(() => module.getLogger().info("browser-safe")).not.toThrow();
|
expect(() => module.getLogger().info("browser-safe")).not.toThrow();
|
||||||
expect(resolvePreferredOpenClawTmpDir).not.toHaveBeenCalled();
|
expect(resolvePreferredOpenClawTmpDir).not.toHaveBeenCalled();
|
||||||
|
|||||||
@ -56,7 +56,9 @@ describe("loadEnabledBundleMcpConfig", () => {
|
|||||||
throw new Error("expected bundled MCP args to include the server path");
|
throw new Error("expected bundled MCP args to include the server path");
|
||||||
}
|
}
|
||||||
expect(await fs.realpath(loadedServerPath)).toBe(resolvedServerPath);
|
expect(await fs.realpath(loadedServerPath)).toBe(resolvedServerPath);
|
||||||
expect(loadedServer.cwd).toBe(resolvedPluginRoot);
|
const loadedCwd =
|
||||||
|
isRecord(loadedServer) && typeof loadedServer.cwd === "string" ? loadedServer.cwd : "";
|
||||||
|
expect(await fs.realpath(loadedCwd)).toBe(resolvedPluginRoot);
|
||||||
} finally {
|
} finally {
|
||||||
env.restore();
|
env.restore();
|
||||||
}
|
}
|
||||||
@ -181,17 +183,25 @@ describe("loadEnabledBundleMcpConfig", () => {
|
|||||||
const resolvedPluginRoot = await fs.realpath(pluginRoot);
|
const resolvedPluginRoot = await fs.realpath(pluginRoot);
|
||||||
|
|
||||||
expect(loaded.diagnostics).toEqual([]);
|
expect(loaded.diagnostics).toEqual([]);
|
||||||
expect(loaded.config.mcpServers.inlineProbe).toEqual({
|
const probe = loaded.config.mcpServers.inlineProbe;
|
||||||
command: path.join(resolvedPluginRoot, "bin", "server.sh"),
|
expect(isRecord(probe) ? await fs.realpath(String(probe.command)) : "").toBe(
|
||||||
args: [
|
path.join(resolvedPluginRoot, "bin", "server.sh"),
|
||||||
path.join(resolvedPluginRoot, "servers", "probe.mjs"),
|
);
|
||||||
path.join(resolvedPluginRoot, "local-probe.mjs"),
|
const probeArgs = getServerArgs(probe);
|
||||||
],
|
expect(probeArgs).toHaveLength(2);
|
||||||
cwd: resolvedPluginRoot,
|
expect(await fs.realpath(String(probeArgs?.[0]))).toBe(
|
||||||
env: {
|
path.join(resolvedPluginRoot, "servers", "probe.mjs"),
|
||||||
PLUGIN_ROOT: resolvedPluginRoot,
|
);
|
||||||
},
|
expect(await fs.realpath(String(probeArgs?.[1]))).toBe(
|
||||||
});
|
path.join(resolvedPluginRoot, "local-probe.mjs"),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isRecord(probe) && typeof probe.cwd === "string" ? await fs.realpath(probe.cwd) : "",
|
||||||
|
).toBe(resolvedPluginRoot);
|
||||||
|
const probeEnv = isRecord(probe) && isRecord(probe.env) ? probe.env : {};
|
||||||
|
expect(
|
||||||
|
typeof probeEnv.PLUGIN_ROOT === "string" ? await fs.realpath(probeEnv.PLUGIN_ROOT) : "",
|
||||||
|
).toBe(resolvedPluginRoot);
|
||||||
} finally {
|
} finally {
|
||||||
env.restore();
|
env.restore();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -356,8 +356,8 @@ export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: bool
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadChatHistory(host as unknown as OpenClawApp),
|
loadChatHistory(host as unknown as OpenClawApp),
|
||||||
loadSessions(host as unknown as OpenClawApp, {
|
loadSessions(host as unknown as OpenClawApp, {
|
||||||
activeMinutes: 0,
|
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||||
limit: 0,
|
limit: 60,
|
||||||
includeGlobal: true,
|
includeGlobal: true,
|
||||||
includeUnknown: true,
|
includeUnknown: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1371,6 +1371,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
fallbackStatus: state.fallbackStatus,
|
fallbackStatus: state.fallbackStatus,
|
||||||
assistantAvatarUrl: chatAvatarUrl,
|
assistantAvatarUrl: chatAvatarUrl,
|
||||||
messages: state.chatMessages,
|
messages: state.chatMessages,
|
||||||
|
historyTruncated: state.chatHistoryTruncated,
|
||||||
toolMessages: state.chatToolMessages,
|
toolMessages: state.chatToolMessages,
|
||||||
streamSegments: state.chatStreamSegments,
|
streamSegments: state.chatStreamSegments,
|
||||||
stream: state.chatStream,
|
stream: state.chatStream,
|
||||||
|
|||||||
@ -72,6 +72,7 @@ export type AppViewState = {
|
|||||||
fallbackStatus: FallbackStatus | null;
|
fallbackStatus: FallbackStatus | null;
|
||||||
chatAvatarUrl: string | null;
|
chatAvatarUrl: string | null;
|
||||||
chatThinkingLevel: string | null;
|
chatThinkingLevel: string | null;
|
||||||
|
chatHistoryTruncated?: boolean;
|
||||||
chatModelOverrides: Record<string, ChatModelOverride | null>;
|
chatModelOverrides: Record<string, ChatModelOverride | null>;
|
||||||
chatModelsLoading: boolean;
|
chatModelsLoading: boolean;
|
||||||
chatModelCatalog: ModelCatalogEntry[];
|
chatModelCatalog: ModelCatalogEntry[];
|
||||||
|
|||||||
@ -159,6 +159,7 @@ export class OpenClawApp extends LitElement {
|
|||||||
@state() fallbackStatus: FallbackStatus | null = null;
|
@state() fallbackStatus: FallbackStatus | null = null;
|
||||||
@state() chatAvatarUrl: string | null = null;
|
@state() chatAvatarUrl: string | null = null;
|
||||||
@state() chatThinkingLevel: string | null = null;
|
@state() chatThinkingLevel: string | null = null;
|
||||||
|
@state() chatHistoryTruncated = false;
|
||||||
@state() chatModelOverrides: Record<string, ChatModelOverride | null> = {};
|
@state() chatModelOverrides: Record<string, ChatModelOverride | null> = {};
|
||||||
@state() chatModelsLoading = false;
|
@state() chatModelsLoading = false;
|
||||||
@state() chatModelCatalog: ModelCatalogEntry[] = [];
|
@state() chatModelCatalog: ModelCatalogEntry[] = [];
|
||||||
|
|||||||
@ -620,14 +620,35 @@ describe("loadChatHistory", () => {
|
|||||||
|
|
||||||
expect(request).toHaveBeenCalledWith("chat.history", {
|
expect(request).toHaveBeenCalledWith("chat.history", {
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
limit: 200,
|
limit: 25,
|
||||||
});
|
});
|
||||||
expect(state.chatMessages).toEqual([
|
expect(state.chatMessages).toEqual([
|
||||||
{ role: "assistant", content: [{ type: "text", text: "visible answer" }] },
|
{ role: "assistant", content: [{ type: "text", text: "visible answer" }] },
|
||||||
{ role: "user", content: [{ type: "text", text: "NO_REPLY" }] },
|
{ role: "user", content: [{ type: "text", text: "NO_REPLY" }] },
|
||||||
]);
|
]);
|
||||||
expect(state.chatThinkingLevel).toBe("low");
|
expect(state.chatThinkingLevel).toBe("low");
|
||||||
|
expect(state.chatHistoryTruncated).toBe(false);
|
||||||
expect(state.chatLoading).toBe(false);
|
expect(state.chatLoading).toBe(false);
|
||||||
expect(state.lastError).toBeNull();
|
expect(state.lastError).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets chatHistoryTruncated when response has at least request limit", async () => {
|
||||||
|
const messages = Array.from({ length: 25 }, (_, i) => ({
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: `msg ${i}` }],
|
||||||
|
}));
|
||||||
|
const request = vi.fn().mockResolvedValue({
|
||||||
|
messages,
|
||||||
|
thinkingLevel: null,
|
||||||
|
});
|
||||||
|
const state = createState({
|
||||||
|
connected: true,
|
||||||
|
client: { request } as unknown as ChatState["client"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadChatHistory(state);
|
||||||
|
|
||||||
|
expect(state.chatMessages).toHaveLength(25);
|
||||||
|
expect(state.chatHistoryTruncated).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,6 +7,9 @@ import { generateUUID } from "../uuid.ts";
|
|||||||
|
|
||||||
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
|
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
|
||||||
|
|
||||||
|
/** Limit chat.history request to avoid huge payloads and main-thread freeze (see CHAT_HISTORY_RENDER_LIMIT in views/chat.ts). */
|
||||||
|
const CHAT_HISTORY_REQUEST_LIMIT = 25;
|
||||||
|
|
||||||
function isSilentReplyStream(text: string): boolean {
|
function isSilentReplyStream(text: string): boolean {
|
||||||
return SILENT_REPLY_PATTERN.test(text);
|
return SILENT_REPLY_PATTERN.test(text);
|
||||||
}
|
}
|
||||||
@ -35,6 +38,7 @@ export type ChatState = {
|
|||||||
chatLoading: boolean;
|
chatLoading: boolean;
|
||||||
chatMessages: unknown[];
|
chatMessages: unknown[];
|
||||||
chatThinkingLevel: string | null;
|
chatThinkingLevel: string | null;
|
||||||
|
chatHistoryTruncated?: boolean;
|
||||||
chatSending: boolean;
|
chatSending: boolean;
|
||||||
chatMessage: string;
|
chatMessage: string;
|
||||||
chatAttachments: ChatAttachment[];
|
chatAttachments: ChatAttachment[];
|
||||||
@ -75,19 +79,20 @@ export async function loadChatHistory(state: ChatState) {
|
|||||||
"chat.history",
|
"chat.history",
|
||||||
{
|
{
|
||||||
sessionKey: state.sessionKey,
|
sessionKey: state.sessionKey,
|
||||||
limit: 200,
|
limit: CHAT_HISTORY_REQUEST_LIMIT,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const messages = Array.isArray(res.messages) ? res.messages : [];
|
const messages = Array.isArray(res.messages) ? res.messages : [];
|
||||||
state.chatMessages = messages.filter((message) => !isAssistantSilentReply(message));
|
const filtered = messages.filter((message) => !isAssistantSilentReply(message));
|
||||||
state.chatThinkingLevel = res.thinkingLevel ?? null;
|
state.chatThinkingLevel = res.thinkingLevel ?? null;
|
||||||
// Clear all streaming state — history includes tool results and text
|
|
||||||
// inline, so keeping streaming artifacts would cause duplicates.
|
|
||||||
maybeResetToolStream(state);
|
maybeResetToolStream(state);
|
||||||
state.chatStream = null;
|
state.chatStream = null;
|
||||||
state.chatStreamStartedAt = null;
|
state.chatStreamStartedAt = null;
|
||||||
|
state.chatMessages = filtered;
|
||||||
|
state.chatHistoryTruncated = messages.length >= CHAT_HISTORY_REQUEST_LIMIT;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.lastError = String(err);
|
state.lastError = String(err);
|
||||||
|
state.chatHistoryTruncated = false;
|
||||||
} finally {
|
} finally {
|
||||||
state.chatLoading = false;
|
state.chatLoading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,6 +63,8 @@ export type ChatProps = {
|
|||||||
compactionStatus?: CompactionIndicatorStatus | null;
|
compactionStatus?: CompactionIndicatorStatus | null;
|
||||||
fallbackStatus?: FallbackIndicatorStatus | null;
|
fallbackStatus?: FallbackIndicatorStatus | null;
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
|
/** True when history was limited by request limit (older messages not loaded). */
|
||||||
|
historyTruncated?: boolean;
|
||||||
toolMessages: unknown[];
|
toolMessages: unknown[];
|
||||||
streamSegments: Array<{ text: string; ts: number }>;
|
streamSegments: Array<{ text: string; ts: number }>;
|
||||||
stream: string | null;
|
stream: string | null;
|
||||||
@ -1328,7 +1330,8 @@ export function renderChat(props: ChatProps) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHAT_HISTORY_RENDER_LIMIT = 200;
|
// Cap rendered history to avoid main-thread freeze (markdown + DOM for each message).
|
||||||
|
const CHAT_HISTORY_RENDER_LIMIT = 25;
|
||||||
|
|
||||||
function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
||||||
const result: Array<ChatItem | MessageGroup> = [];
|
const result: Array<ChatItem | MessageGroup> = [];
|
||||||
@ -1382,13 +1385,21 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
|
|||||||
const history = Array.isArray(props.messages) ? props.messages : [];
|
const history = Array.isArray(props.messages) ? props.messages : [];
|
||||||
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
|
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
|
||||||
const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT);
|
const historyStart = Math.max(0, history.length - CHAT_HISTORY_RENDER_LIMIT);
|
||||||
if (historyStart > 0) {
|
const showTruncationNotice =
|
||||||
|
historyStart > 0 ||
|
||||||
|
(Boolean(props.historyTruncated) && history.length >= CHAT_HISTORY_RENDER_LIMIT);
|
||||||
|
if (showTruncationNotice) {
|
||||||
|
const hiddenCount = historyStart > 0 ? historyStart : "older";
|
||||||
|
const label =
|
||||||
|
typeof hiddenCount === "number"
|
||||||
|
? `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${hiddenCount} hidden).`
|
||||||
|
: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (older messages not loaded).`;
|
||||||
items.push({
|
items.push({
|
||||||
kind: "message",
|
kind: "message",
|
||||||
key: "chat:history:notice",
|
key: "chat:history:notice",
|
||||||
message: {
|
message: {
|
||||||
role: "system",
|
role: "system",
|
||||||
content: `Showing last ${CHAT_HISTORY_RENDER_LIMIT} messages (${historyStart} hidden).`,
|
content: label,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
testTimeout: 120_000,
|
testTimeout: isWindows ? 240_000 : 120_000,
|
||||||
hookTimeout: isWindows ? 180_000 : 120_000,
|
hookTimeout: isWindows ? 180_000 : 120_000,
|
||||||
// Many suites rely on `vi.stubEnv(...)` and expect it to be scoped to the test.
|
// Many suites rely on `vi.stubEnv(...)` and expect it to be scoped to the test.
|
||||||
// This is especially important under `pool=vmForks` where env leaks cross-file.
|
// This is especially important under `pool=vmForks` where env leaks cross-file.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user