mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ci): align release validation fixtures (#117494)
* fix(release): align beta 6 e2e fixtures * fix(doctor): preserve missing state startup signal * test(release): align beta e2e contracts * test(agents): use canonical empty auth store * test(agents): isolate prompt cooldown scheduling * test(release): align main validation fixtures * test(release): accept Code Mode plugin tool evidence * fix(test): repair release validation checks * test(cron): assert structured session targets
This commit is contained in:
@@ -886,6 +886,8 @@ jobs:
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
|
||||
env:
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
OPENCLAW_VITEST_MAX_WORKERS: "2"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
|
||||
@@ -55,7 +55,6 @@ describe("browser client fetch attachOnly diagnostics", () => {
|
||||
hung: {
|
||||
cdpUrl: `http://127.0.0.1:${port}`,
|
||||
attachOnly: true,
|
||||
color: "#00AA00",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -26,11 +26,16 @@ describe("qa scenario catalog channel contracts", () => {
|
||||
const config = readQaScenarioExecutionConfig("native-command-session-target") as
|
||||
| {
|
||||
requiredProviderMode?: string;
|
||||
sessionKey?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(scenario.execution.channel).toBe("telegram");
|
||||
expect(config?.requiredProviderMode).toBe("mock-openai");
|
||||
expect(config?.sessionKey).toBe("agent:main:telegram:direct:qa-native-operator");
|
||||
expect(JSON.stringify(requireFlowScenario(scenario).execution.flow)).toContain(
|
||||
"session.key === config.sessionKey && session.hasActiveRun === true",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps channel-owned scenarios independent from the driver implementation", () => {
|
||||
@@ -123,7 +128,20 @@ describe("qa scenario catalog channel contracts", () => {
|
||||
expect(scenario.coverage?.primary).toEqual(["channels.streaming-final-reply"]);
|
||||
expect(scenario.coverage?.secondary).toEqual([`${agentRuntime}.streaming-replies-delivery`]);
|
||||
expect(scenario.gatewayConfigPatch).toMatchObject({
|
||||
channels: { telegram: { streaming: { mode: "partial" } } },
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: { "*": { requireMention: false } },
|
||||
streaming: { mode: "partial" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("disables Telegram mention gating for deterministic group delivery proofs", () => {
|
||||
const scenario = readQaScenarioById("telegram-assistant-transcript-role-boundary");
|
||||
|
||||
expect(scenario.gatewayConfigPatch).toMatchObject({
|
||||
channels: { telegram: { groups: { "*": { requireMention: false } } } },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+129
-197
@@ -5,8 +5,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { escapeRegExp, formatEnvelopeTimestamp } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { setLoggerOverride } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
|
||||
import { getChildLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { getActiveWebListener } from "./active-listener.js";
|
||||
import { WhatsAppAuthUnstableError, resolveWebCredsPath } from "./auth-store.js";
|
||||
@@ -19,12 +18,16 @@ import {
|
||||
getLastWebAutoReplySessionSocket,
|
||||
installWebAutoReplyTestHomeHooks,
|
||||
installWebAutoReplyUnitTestHooks,
|
||||
makeSessionStore,
|
||||
resetLoadConfigMock,
|
||||
sendWebDirectInboundMessage,
|
||||
setLoadConfigMock,
|
||||
startWebAutoReplyMonitor,
|
||||
} from "./auto-reply.test-harness.js";
|
||||
import {
|
||||
createWhatsAppReplyTransportContext,
|
||||
deliverWebReply,
|
||||
} from "./auto-reply/deliver-reply.js";
|
||||
import { buildInboundLine } from "./auto-reply/monitor/message-line.js";
|
||||
import {
|
||||
createTestLegacyFlatWebInboundMessage,
|
||||
createTestWebInboundMessage,
|
||||
@@ -91,17 +94,23 @@ async function startWatchdogScenario(params: {
|
||||
{ timeout: 250, interval: 2 },
|
||||
);
|
||||
|
||||
const spies = createWebInboundDeliverySpies();
|
||||
await sendWebDirectInboundMessage({
|
||||
onMessage: scripted.getOnMessage()!,
|
||||
body: "hi",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "m1",
|
||||
spies,
|
||||
});
|
||||
await requireOnMessage(scripted.getOnMessage())(
|
||||
createTestWebInboundMessage({
|
||||
event: { id: "m1" },
|
||||
payload: { body: "ignored" },
|
||||
admission: {
|
||||
conversation: { kind: "direct", id: "+1" },
|
||||
ingress: {
|
||||
admission: "drop",
|
||||
decision: "block",
|
||||
decisiveGateId: "sender",
|
||||
reasonCode: "no_policy_match",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return { scripted, sleep, spies, ...started };
|
||||
return { scripted, sleep, ...started };
|
||||
}
|
||||
|
||||
function expectErrorContaining(errorFn: unknown, text: string): void {
|
||||
@@ -231,14 +240,23 @@ describe("web auto-reply connection", () => {
|
||||
).appendReplyWindow,
|
||||
).toBeUndefined();
|
||||
|
||||
await sendWebDirectInboundMessage({
|
||||
onMessage: requireOnMessage(scripted.getOnMessage()),
|
||||
body: "hi before reconnect",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "active-before-reconnect",
|
||||
spies: createWebInboundDeliverySpies(),
|
||||
});
|
||||
// The controller records inbound activity before admission; this test only needs that
|
||||
// lifecycle fact and must not start unrelated delivery/session work.
|
||||
await requireOnMessage(scripted.getOnMessage())(
|
||||
createTestWebInboundMessage({
|
||||
event: { id: "active-before-reconnect" },
|
||||
payload: { body: "ignored" },
|
||||
admission: {
|
||||
conversation: { kind: "direct", id: "+1" },
|
||||
ingress: {
|
||||
admission: "drop",
|
||||
decision: "block",
|
||||
decisiveGateId: "sender",
|
||||
reasonCode: "no_policy_match",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const reconnectStartedAt = Date.now();
|
||||
scripted.resolveClose(0, { status: 408, isLoggedOut: false, error: new Error("timeout") });
|
||||
@@ -995,16 +1013,16 @@ describe("web auto-reply connection", () => {
|
||||
it("normalizes legacy flat listener messages and rejects partial nested input", async () => {
|
||||
const capture = createWebListenerFactoryCapture();
|
||||
const { sendMedia, sendComposing, reply } = createWebInboundDeliverySpies();
|
||||
const resolver = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await monitorWebChannel(false, capture.listenerFactory as never, false, async () => ({
|
||||
text: "ok",
|
||||
}));
|
||||
await monitorWebChannel(false, capture.listenerFactory as never, false, resolver);
|
||||
const onMessage = requireOnMessage(capture.getOnMessage());
|
||||
const msg = createTestLegacyFlatWebInboundMessage({
|
||||
from: "+1",
|
||||
conversationId: "+1",
|
||||
chatId: "+1",
|
||||
to: "+2",
|
||||
accessControlPassed: false,
|
||||
reply,
|
||||
});
|
||||
|
||||
@@ -1027,7 +1045,8 @@ describe("web auto-reply connection", () => {
|
||||
).toBe(false);
|
||||
await onMessage(msg);
|
||||
|
||||
expect(reply).toHaveBeenCalledWith("ok", undefined);
|
||||
expect(resolver).not.toHaveBeenCalled();
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
onMessage({
|
||||
event: { id: "canonical-no-admission" },
|
||||
@@ -1046,8 +1065,7 @@ describe("web auto-reply connection", () => {
|
||||
}),
|
||||
).rejects.toThrow(/missing admission facts/);
|
||||
|
||||
expect(reply).toHaveBeenCalledWith("ok", undefined);
|
||||
expect(reply).toHaveBeenCalledTimes(1);
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
onMessage({
|
||||
...msg,
|
||||
@@ -1057,82 +1075,58 @@ describe("web auto-reply connection", () => {
|
||||
).rejects.toThrow(/legacy flat or canonical nested/);
|
||||
});
|
||||
|
||||
it("processes inbound messages without batching and preserves timestamps", async () => {
|
||||
await withEnvAsync({ TZ: "Europe/Vienna" }, async () => {
|
||||
const originalMax = process.getMaxListeners();
|
||||
process.setMaxListeners?.(1);
|
||||
it("raises the process listener budget before opening the web listener", async () => {
|
||||
const originalMax = process.getMaxListeners();
|
||||
process.setMaxListeners?.(1);
|
||||
try {
|
||||
const capture = createWebListenerFactoryCapture();
|
||||
await monitorWebChannel(
|
||||
false,
|
||||
capture.listenerFactory as never,
|
||||
false,
|
||||
async () => undefined,
|
||||
);
|
||||
expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50);
|
||||
} finally {
|
||||
process.setMaxListeners?.(originalMax);
|
||||
}
|
||||
});
|
||||
|
||||
const store = await makeSessionStore({
|
||||
main: { sessionId: "sid", updatedAt: Date.now() },
|
||||
it("builds separate timestamped inbound envelopes without batching", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const buildLine = (body: string, id: string, timestamp: number) =>
|
||||
buildInboundLine({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
envelope: { timezone: "utc" },
|
||||
msg: createTestWebInboundMessage({
|
||||
event: { id, timestamp },
|
||||
payload: { body },
|
||||
platform: { recipientJid: "+2" },
|
||||
admission: {
|
||||
conversation: { kind: "direct", id: "+1" },
|
||||
sender: { id: "+1" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const { sendMedia, reply, sendComposing } = createWebInboundDeliverySpies();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
const firstBody = buildLine("first", "m1", 1735689600000);
|
||||
const secondBody = buildLine("second", "m2", 1735693200000);
|
||||
const firstTimestamp = formatEnvelopeTimestamp(new Date("2025-01-01T00:00:00Z"), "utc");
|
||||
const secondTimestamp = formatEnvelopeTimestamp(new Date("2025-01-01T01:00:00Z"), "utc");
|
||||
|
||||
const capture = createWebListenerFactoryCapture();
|
||||
|
||||
setLoadConfigMock(() => ({
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
session: { store: store.storePath },
|
||||
}));
|
||||
|
||||
await monitorWebChannel(false, capture.listenerFactory as never, false, resolver);
|
||||
const capturedOnMessage = requireOnMessage(capture.getOnMessage());
|
||||
|
||||
const spies = { sendMedia, reply, sendComposing };
|
||||
await sendWebDirectInboundMessage({
|
||||
onMessage: capturedOnMessage,
|
||||
body: "first",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "m1",
|
||||
timestamp: 1735689600000,
|
||||
spies,
|
||||
});
|
||||
if (!capturedOnMessage) {
|
||||
throw new Error("Expected WhatsApp web runtime to register onMessage.");
|
||||
}
|
||||
await sendWebDirectInboundMessage({
|
||||
onMessage: capturedOnMessage,
|
||||
body: "second",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "m2",
|
||||
timestamp: 1735693200000,
|
||||
spies,
|
||||
});
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
const firstArgs = resolver.mock.calls.at(0)?.[0];
|
||||
const secondArgs = resolver.mock.calls.at(1)?.[0];
|
||||
const firstTimestamp = formatEnvelopeTimestamp(new Date("2025-01-01T00:00:00Z"));
|
||||
const secondTimestamp = formatEnvelopeTimestamp(new Date("2025-01-01T01:00:00Z"));
|
||||
const firstPattern = escapeRegExp(firstTimestamp);
|
||||
const secondPattern = escapeRegExp(secondTimestamp);
|
||||
expect(firstArgs.Body).toMatch(
|
||||
new RegExp(
|
||||
`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${firstPattern}\\] \\+1: \\[openclaw\\] first`,
|
||||
),
|
||||
);
|
||||
expect(firstArgs.Body).not.toContain("second");
|
||||
expect(secondArgs.Body).toMatch(
|
||||
new RegExp(
|
||||
`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${secondPattern}\\] \\+1: \\[openclaw\\] second`,
|
||||
),
|
||||
);
|
||||
expect(secondArgs.Body).not.toContain("first");
|
||||
expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50);
|
||||
} finally {
|
||||
process.setMaxListeners?.(originalMax);
|
||||
await store.cleanup();
|
||||
resetLoadConfigMock();
|
||||
}
|
||||
});
|
||||
expect(firstBody).toMatch(
|
||||
new RegExp(
|
||||
`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${escapeRegExp(firstTimestamp)}\\] \\+1: \\[openclaw\\] first`,
|
||||
),
|
||||
);
|
||||
expect(firstBody).not.toContain("second");
|
||||
expect(secondBody).toMatch(
|
||||
new RegExp(
|
||||
`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${escapeRegExp(secondTimestamp)}\\] \\+1: \\[openclaw\\] second`,
|
||||
),
|
||||
);
|
||||
expect(secondBody).not.toContain("first");
|
||||
});
|
||||
|
||||
it("emits heartbeat logs with connection metadata", async () => {
|
||||
@@ -1167,12 +1161,18 @@ describe("web auto-reply connection", () => {
|
||||
},
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(listenerFactory).toHaveBeenCalledOnce());
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
controller.abort();
|
||||
await vi.runAllTimersAsync();
|
||||
await run.catch(() => {});
|
||||
vi.useRealTimers();
|
||||
|
||||
const content = await fs.readFile(logPath, "utf-8");
|
||||
let content = "";
|
||||
await vi.waitFor(async () => {
|
||||
content = await fs.readFile(logPath, "utf-8").catch(() => "");
|
||||
expect(content).toMatch(/web-heartbeat/);
|
||||
});
|
||||
expect(content).toMatch(/web-heartbeat/);
|
||||
expect(content).toMatch(/connectionId/);
|
||||
expect(content).toMatch(/messagesHandled/);
|
||||
@@ -1181,106 +1181,38 @@ describe("web auto-reply connection", () => {
|
||||
it("logs outbound replies to file", async () => {
|
||||
const logPath = `/tmp/openclaw-log-test-${crypto.randomUUID()}.log`;
|
||||
setLoggerOverride({ level: "trace", file: logPath });
|
||||
|
||||
const capture = createWebListenerFactoryCapture();
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "auto" });
|
||||
await monitorWebChannel(false, capture.listenerFactory as never, false, resolver as never);
|
||||
const capturedOnMessage = requireOnMessage(capture.getOnMessage());
|
||||
|
||||
await capturedOnMessage(
|
||||
createTestWebInboundMessage({
|
||||
event: {
|
||||
id: "msg1",
|
||||
},
|
||||
payload: {
|
||||
body: "hello",
|
||||
},
|
||||
platform: {
|
||||
chatJid: "+1",
|
||||
recipientJid: "+2",
|
||||
sendComposing: vi.fn(),
|
||||
reply: vi.fn(),
|
||||
sendMedia: vi.fn(),
|
||||
},
|
||||
admission: {
|
||||
accountId: "default",
|
||||
conversation: {
|
||||
kind: "direct",
|
||||
id: "+1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const content = await fs.readFile(logPath, "utf-8");
|
||||
expect(content).toMatch(/web-auto-reply/);
|
||||
expect(content).toMatch(/auto/);
|
||||
});
|
||||
|
||||
it("marks dispatch idle after replies flush", async () => {
|
||||
const markDispatchIdle = vi.fn();
|
||||
const typingMock = {
|
||||
onReplyStart: vi.fn(async () => {}),
|
||||
startTypingLoop: vi.fn(async () => {}),
|
||||
startTypingOnText: vi.fn(async () => {}),
|
||||
refreshTypingTtl: vi.fn(),
|
||||
isActive: vi.fn(() => false),
|
||||
markRunComplete: vi.fn(),
|
||||
markDispatchIdle,
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
const { reply, sendComposing, sendMedia } = createWebInboundDeliverySpies();
|
||||
|
||||
const replyResolver = vi.fn().mockImplementation(async (ctx, opts) => {
|
||||
void ctx;
|
||||
opts?.onTypingController?.(typingMock);
|
||||
return { text: "final reply" };
|
||||
const spies = createWebInboundDeliverySpies();
|
||||
const msg = createTestWebInboundMessage({
|
||||
event: { id: "msg1" },
|
||||
payload: { body: "hello" },
|
||||
platform: {
|
||||
chatJid: "+1",
|
||||
recipientJid: "+2",
|
||||
reply: spies.reply,
|
||||
sendMedia: spies.sendMedia,
|
||||
},
|
||||
admission: {
|
||||
accountId: "default",
|
||||
conversation: { kind: "direct", id: "+1" },
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig: OpenClawConfig = {
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
};
|
||||
await deliverWebReply({
|
||||
replyResult: { text: "auto" },
|
||||
transport: createWhatsAppReplyTransportContext(msg),
|
||||
maxMediaBytes: 5 * 1024 * 1024,
|
||||
textLimit: 64_000,
|
||||
replyLogger: getChildLogger({ module: "web-auto-reply", runId: "file-log-test" }),
|
||||
connectionId: "conn-file-log",
|
||||
});
|
||||
|
||||
setLoadConfigMock(mockConfig);
|
||||
|
||||
await monitorWebChannel(
|
||||
false,
|
||||
async ({ onMessage }) => {
|
||||
await onMessage(
|
||||
createTestWebInboundMessage({
|
||||
event: {
|
||||
id: "m1",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
payload: {
|
||||
body: "hello",
|
||||
},
|
||||
platform: {
|
||||
chatJid: "direct:+1000",
|
||||
recipientJid: "+2000",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
},
|
||||
admission: {
|
||||
accountId: "default",
|
||||
conversation: {
|
||||
kind: "direct",
|
||||
id: "+1000",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return createMockWebListener();
|
||||
},
|
||||
false,
|
||||
replyResolver,
|
||||
);
|
||||
|
||||
resetLoadConfigMock();
|
||||
|
||||
expect(markDispatchIdle).toHaveBeenCalled();
|
||||
let content = "";
|
||||
await vi.waitFor(async () => {
|
||||
content = await fs.readFile(logPath, "utf-8").catch(() => "");
|
||||
expect(content).toMatch(/web-auto-reply/);
|
||||
});
|
||||
expect(content).toMatch(/web-auto-reply/);
|
||||
expect(content).toMatch(/auto/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ type PackageManifest = {
|
||||
name: string;
|
||||
version: string;
|
||||
dependencies?: Record<string, string>;
|
||||
scripts?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -184,16 +185,23 @@ function runNpmCommand(
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeWorkspaceDependencies(
|
||||
async function normalizeWorkspaceDependencies(
|
||||
repoRoot: string,
|
||||
dependencies: Record<string, string> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
if (!dependencies) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [name, spec] of Object.entries(dependencies)) {
|
||||
normalized[name] =
|
||||
name.startsWith("@openclaw/") && spec.startsWith("workspace:") ? "0.0.0-private" : spec;
|
||||
if (name.startsWith("@openclaw/") && spec.startsWith("workspace:")) {
|
||||
const dependencyManifest = await readRawPackageManifest(
|
||||
resolveWorkspacePackageRoot(repoRoot, name),
|
||||
);
|
||||
normalized[name] = dependencyManifest.version;
|
||||
continue;
|
||||
}
|
||||
normalized[name] = spec;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -205,9 +213,10 @@ async function readRawPackageManifest(packageRoot: string): Promise<PackageManif
|
||||
|
||||
async function readPackageManifest(packageRoot: string): Promise<PackageManifest> {
|
||||
const manifest = await readRawPackageManifest(packageRoot);
|
||||
const repoRoot = path.resolve(packageRoot, "..", "..");
|
||||
return {
|
||||
...manifest,
|
||||
dependencies: normalizeWorkspaceDependencies(manifest.dependencies),
|
||||
dependencies: await normalizeWorkspaceDependencies(repoRoot, manifest.dependencies),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -392,7 +401,8 @@ describe("OpenClaw SDK package e2e", () => {
|
||||
|
||||
for (const packageRoot of packageRoots) {
|
||||
const manifest = await readRawPackageManifest(packageRoot);
|
||||
await runPnpmCommand(["--filter", manifest.name, "build"], {
|
||||
const buildScript = manifest.scripts?.prepack ? "prepack" : "build";
|
||||
await runPnpmCommand(["--filter", manifest.name, buildScript], {
|
||||
cwd: repoRoot,
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ scenario:
|
||||
gatewayConfigPatch:
|
||||
channels:
|
||||
telegram:
|
||||
groups:
|
||||
"*":
|
||||
requireMention: false
|
||||
streaming:
|
||||
mode: partial
|
||||
successCriteria:
|
||||
|
||||
@@ -34,6 +34,7 @@ scenario:
|
||||
requiredProviderMode: mock-openai
|
||||
conversationId: native-stop-target
|
||||
senderId: qa-native-operator
|
||||
sessionKey: agent:main:telegram:direct:qa-native-operator
|
||||
delayedPrompt: "Subagent recovery worker native command target proof. Wait until stopped."
|
||||
abortReplyNeedle: Agent was aborted
|
||||
recoveryMarker: QA-NATIVE-STOP-RECOVERY-OK
|
||||
@@ -69,7 +70,7 @@ flow:
|
||||
args:
|
||||
- lambda:
|
||||
async: true
|
||||
expr: "env.gateway.call('sessions.list', {}).then((result) => result.sessions?.find((session) => session.hasActiveRun === true))"
|
||||
expr: "env.gateway.call('sessions.list', {}).then((result) => result.sessions?.find((session) => session.key === config.sessionKey && session.hasActiveRun === true))"
|
||||
- expr: liveTurnTimeoutMs(env, 30000)
|
||||
- 100
|
||||
- set: startIndex
|
||||
|
||||
@@ -7,6 +7,12 @@ scenario:
|
||||
primary:
|
||||
- channels.automatic-final-reply
|
||||
objective: Verify Telegram renders transcript-role-looking assistant text as inert authorship-marked content.
|
||||
gatewayConfigPatch:
|
||||
channels:
|
||||
telegram:
|
||||
groups:
|
||||
"*":
|
||||
requireMention: false
|
||||
successCriteria:
|
||||
- The controlled model reply reaches the real Telegram plugin through Crabline.
|
||||
- Telegram HTML wraps only the transcript-role header in a code element.
|
||||
|
||||
@@ -242,12 +242,12 @@ function resultLinksToolCall(call, result, targetCallCount) {
|
||||
return targetCallCount === 1;
|
||||
}
|
||||
|
||||
function createToolEvidenceTracker(toolName, expected) {
|
||||
function createToolEvidenceTracker(toolNames, expected) {
|
||||
const calls = [];
|
||||
return {
|
||||
recordMessage(message) {
|
||||
for (const call of extractTranscriptToolCalls(message)) {
|
||||
if (call.tool === toolName) {
|
||||
if (toolNames.includes(call.tool)) {
|
||||
calls.push(call);
|
||||
}
|
||||
}
|
||||
@@ -276,8 +276,8 @@ function transcriptMessageFromLine(line) {
|
||||
}
|
||||
}
|
||||
|
||||
function scanFileForToolEvidence(file, toolName, expected) {
|
||||
const tracker = createToolEvidenceTracker(toolName, expected);
|
||||
function scanFileForToolEvidence(file, toolNames, expected) {
|
||||
const tracker = createToolEvidenceTracker(toolNames, expected);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(file);
|
||||
@@ -322,7 +322,7 @@ function scanFileForToolEvidence(file, toolName, expected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function scanSessionTranscripts(sessionsDir, toolName, expected) {
|
||||
function scanSessionTranscripts(sessionsDir, toolNames, expected) {
|
||||
const checkedFiles = [];
|
||||
let filesChecked = 0;
|
||||
let stat;
|
||||
@@ -361,7 +361,7 @@ function scanSessionTranscripts(sessionsDir, toolName, expected) {
|
||||
if (checkedFiles.length < SESSION_FILE_LIST_LIMIT) {
|
||||
checkedFiles.push(path.relative(sessionsDir, entryPath));
|
||||
}
|
||||
if (scanFileForToolEvidence(entryPath, toolName, expected)) {
|
||||
if (scanFileForToolEvidence(entryPath, toolNames, expected)) {
|
||||
return { checkedFiles, filesChecked, found: true, missingDir: false };
|
||||
}
|
||||
}
|
||||
@@ -372,7 +372,7 @@ function scanSessionTranscripts(sessionsDir, toolName, expected) {
|
||||
return { checkedFiles, filesChecked, found: false, missingDir: false };
|
||||
}
|
||||
|
||||
function scanSqliteSessionTranscript(databasePath, sessionId, toolName, expected) {
|
||||
function scanSqliteSessionTranscript(databasePath, sessionId, toolNames, expected) {
|
||||
if (!fs.existsSync(databasePath)) {
|
||||
return { eventsChecked: 0, found: false };
|
||||
}
|
||||
@@ -391,7 +391,7 @@ function scanSqliteSessionTranscript(databasePath, sessionId, toolName, expected
|
||||
throw new Error(`session transcript scan exceeded ${SESSION_SCAN_MAX_ENTRIES} SQLite events`);
|
||||
}
|
||||
|
||||
const tracker = createToolEvidenceTracker(toolName, expected);
|
||||
const tracker = createToolEvidenceTracker(toolNames, expected);
|
||||
for (const row of rows) {
|
||||
if (typeof row.event_json !== "string") {
|
||||
continue;
|
||||
@@ -645,15 +645,18 @@ function assertAgentTurn() {
|
||||
);
|
||||
}
|
||||
const agentStateDir = path.join(stateDir(), "agents", "main");
|
||||
// Code Mode exposes plugin tools behind exec/wait, so the durable transcript can
|
||||
// record the outer exec call while the run summary names the nested plugin tool.
|
||||
const transcriptToolNames = [toolName, "exec", "wait"];
|
||||
const sqliteScan = scanSqliteSessionTranscript(
|
||||
path.join(agentStateDir, "agent", "openclaw-agent.sqlite"),
|
||||
LIVE_PLUGIN_TOOL_SESSION_ID,
|
||||
toolName,
|
||||
transcriptToolNames,
|
||||
expected,
|
||||
);
|
||||
const fileScan = sqliteScan.found
|
||||
? { checkedFiles: [], filesChecked: 0, found: false, missingDir: false }
|
||||
: scanSessionTranscripts(path.join(agentStateDir, "sessions"), toolName, expected);
|
||||
: scanSessionTranscripts(path.join(agentStateDir, "sessions"), transcriptToolNames, expected);
|
||||
if (!sqliteScan.found && !fileScan.found) {
|
||||
const checkedFiles =
|
||||
fileScan.checkedFiles.length > 0 ? fileScan.checkedFiles.join(", ") : "<none>";
|
||||
|
||||
@@ -7,6 +7,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { GATEWAY_CLIENT_CAPS } from "../../packages/gateway-protocol/src/client-info.js";
|
||||
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
|
||||
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
|
||||
import { ADMIN_SCOPE } from "../gateway/method-scopes.js";
|
||||
@@ -123,6 +124,7 @@ describe("gateway-hosted exec approvals", () => {
|
||||
clientDisplayName: "approval operator",
|
||||
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||
scopes: [ADMIN_SCOPE],
|
||||
caps: [GATEWAY_CLIENT_CAPS.EXEC_APPROVALS],
|
||||
requestTimeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
|
||||
timeoutMs: GATEWAY_CONNECT_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
+5
-67
@@ -90,7 +90,6 @@ const installRunEmbeddedMocks = () => {
|
||||
};
|
||||
|
||||
let runEmbeddedAgent: typeof import("./embedded-agent-runner/run.js").runEmbeddedAgent;
|
||||
let authProfileUsageTesting: typeof import("./auth-profiles/usage.test-support.js").testing;
|
||||
let createDiagnosticLogRecordCaptureFn: typeof import("../logging/test-helpers/diagnostic-log-capture.js").createDiagnosticLogRecordCapture;
|
||||
let cleanupLogCapture: (() => void) | undefined;
|
||||
let resetLoggerFn: typeof import("../logging/logger.js").resetLogger;
|
||||
@@ -101,7 +100,6 @@ beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
installRunEmbeddedMocks();
|
||||
({ runEmbeddedAgent } = await import("./embedded-agent-runner/run.js"));
|
||||
({ testing: authProfileUsageTesting } = await import("./auth-profiles/usage.test-support.js"));
|
||||
({ createDiagnosticLogRecordCapture: createDiagnosticLogRecordCaptureFn } =
|
||||
await import("../logging/test-helpers/diagnostic-log-capture.js"));
|
||||
({ resetLogger: resetLoggerFn, setLoggerOverride: setLoggerOverrideFn } =
|
||||
@@ -141,7 +139,6 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
authProfileUsageTesting.setDepsForTest(null);
|
||||
cleanupLogCapture?.();
|
||||
cleanupLogCapture = undefined;
|
||||
setLoggerOverrideFn(null);
|
||||
@@ -156,6 +153,7 @@ const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): OpenClawC
|
||||
fallbacks: opts?.fallbacks ?? [],
|
||||
},
|
||||
},
|
||||
list: [{ id: "test" }],
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
@@ -222,6 +220,9 @@ const copilotModelId = "gpt-4o";
|
||||
|
||||
const makeCopilotConfig = (): OpenClawConfig =>
|
||||
({
|
||||
agents: {
|
||||
list: [{ id: "test" }],
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
"github-copilot": {
|
||||
@@ -377,7 +378,6 @@ async function runAutoPinnedOpenAiTurn(params: {
|
||||
await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: params.sessionKey,
|
||||
sessionFile: path.join(params.workspaceDir, "session.jsonl"),
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config ?? makeConfig(),
|
||||
@@ -591,7 +591,6 @@ async function runTurnWithCooldownSeed(params: {
|
||||
await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: params.sessionKey,
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
@@ -625,7 +624,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:read-only-auth-profile-state",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
@@ -693,7 +691,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:copilot-auth-error",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeCopilotConfig(),
|
||||
@@ -782,7 +779,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:copilot-auth-repeat",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeCopilotConfig(),
|
||||
@@ -830,7 +826,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
const runPromise = runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:copilot-shutdown",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeCopilotConfig(),
|
||||
@@ -939,46 +934,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
expect(sleepWithAbortMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not wait for prompt failure cooldown marking before retrying", async () => {
|
||||
let releaseMark: (() => void) | undefined;
|
||||
const markCanFinish = new Promise<void>((resolve) => {
|
||||
releaseMark = resolve;
|
||||
});
|
||||
let markStarted = false;
|
||||
authProfileUsageTesting.setDepsForTest({
|
||||
updateAuthProfileStoreWithLock: async () => {
|
||||
markStarted = true;
|
||||
await markCanFinish;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
|
||||
await writeAuthStore(agentDir);
|
||||
mockPromptErrorThenSuccessfulAttempt("rate limit exceeded");
|
||||
|
||||
const runPromise = runAutoPinnedOpenAiTurn({
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
sessionKey: "agent:test:prompt-deferred-mark",
|
||||
runId: "run:prompt-deferred-mark",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2));
|
||||
expect(markStarted).toBe(true);
|
||||
releaseMark?.();
|
||||
releaseMark = undefined;
|
||||
await runPromise;
|
||||
|
||||
const usageStats = await readUsageStats(agentDir);
|
||||
expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number");
|
||||
});
|
||||
} finally {
|
||||
releaseMark?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("rotates on timeout without cooling down the timed-out profile", async () => {
|
||||
const { usageStats } = await runAutoPinnedRotationCase({
|
||||
errorMessage: "request ended without sending any chunks",
|
||||
@@ -1024,7 +979,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
const result = await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:compaction-timeout",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
@@ -1066,7 +1020,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
const result = await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:compaction-wait-abort",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
@@ -1095,7 +1048,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:user",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
@@ -1145,7 +1097,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:user-order-excluded",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
@@ -1174,7 +1125,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:user-auth-alias",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
@@ -1215,7 +1165,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:mismatch",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
@@ -1257,7 +1206,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:cooldown-failover",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig({ fallbacks: ["openai/mock-2"] }),
|
||||
@@ -1301,7 +1249,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
const result = await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:cooldown-probe",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig({ fallbacks: ["openai/mock-2"] }),
|
||||
@@ -1349,7 +1296,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
const result = await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:overloaded-cooldown-probe",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig({ fallbacks: ["openai/mock-2"] }),
|
||||
@@ -1388,7 +1334,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:billing-cooldown-probe-no-fallbacks",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
@@ -1419,7 +1364,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:support:cooldown-failover",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeAgentOverrideOnlyFallbackConfig("support"),
|
||||
@@ -1464,7 +1408,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:disabled-failover",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig({ fallbacks: ["openai/mock-2"] }),
|
||||
@@ -1491,16 +1434,12 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
try {
|
||||
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
const authStatePath = path.join(agentDir, "auth-state.json");
|
||||
await fs.writeFile(authPath, JSON.stringify({ version: 1, profiles: {} }));
|
||||
await fs.writeFile(authStatePath, JSON.stringify({ version: 1, usageStats: {} }));
|
||||
saveAuthProfileStore({ version: 1, profiles: {}, usageStats: {} }, agentDir);
|
||||
|
||||
await expectFailoverError(
|
||||
runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:auth-unavailable",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig({ fallbacks: ["openai/mock-2"], apiKey: "" }),
|
||||
@@ -1539,7 +1478,6 @@ describe("runEmbeddedAgent auth profile rotation", () => {
|
||||
await runEmbeddedAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:billing-failover-active-model",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig({ fallbacks: ["openai/mock-2"] }),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Prompt failure tests cover retry composition around profile rotation.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleEmbeddedPromptFailure } from "./prompt-failure.js";
|
||||
|
||||
type Params = Parameters<typeof handleEmbeddedPromptFailure>[0];
|
||||
|
||||
function makeParams(overrides: Partial<Params> = {}): Params {
|
||||
const provider = "openai";
|
||||
const modelId = "gpt-5";
|
||||
const defaults: Params = {
|
||||
runParams: {
|
||||
config: undefined,
|
||||
runId: "run:prompt-failure-test",
|
||||
} as Params["runParams"],
|
||||
attempt: {
|
||||
replayMetadata: {
|
||||
replaySafe: true,
|
||||
},
|
||||
} as Params["attempt"],
|
||||
promptError: new Error("rate limit exceeded"),
|
||||
promptErrorSource: "prompt",
|
||||
activeErrorContext: { provider, model: modelId },
|
||||
provider,
|
||||
modelId,
|
||||
authProfileId: "openai:p1",
|
||||
authProfileStore: {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
},
|
||||
sessionIdUsed: "session:prompt-failure-test",
|
||||
lane: "test",
|
||||
agentDir: "/tmp/openclaw-prompt-failure-test",
|
||||
suspensionSessionId: "session:prompt-failure-test",
|
||||
runtimeAuthRetry: false,
|
||||
maybeRefreshRuntimeAuthForAuthError: vi.fn(async () => false),
|
||||
suspendForFailure: vi.fn(),
|
||||
resolveReplayInvalid: vi.fn(() => false),
|
||||
setTerminalLifecycleMeta: vi.fn(),
|
||||
buildErrorAgentMeta: vi.fn(),
|
||||
startedAtMs: 0,
|
||||
fallbackConfigured: true,
|
||||
aborted: false,
|
||||
externalAbort: false,
|
||||
pluginHarnessOwnsTransport: false,
|
||||
timedOutByRunBudget: false,
|
||||
resolveAuthProfileFailureReason: vi.fn<Params["resolveAuthProfileFailureReason"]>(
|
||||
() => "rate_limit",
|
||||
),
|
||||
maybeEscalateRateLimitProfileFallback: vi.fn(),
|
||||
advanceAttemptAuthProfile: vi.fn(async () => true),
|
||||
maybeMarkAuthProfileFailure: vi.fn(async () => {}),
|
||||
maybeBackoffBeforeOverloadFailover: vi.fn(async () => {}),
|
||||
attemptedThinking: new Set(),
|
||||
thinkLevel: "low",
|
||||
getThinkLevel: () => "low",
|
||||
traceAttempts: [],
|
||||
previousRetryFailoverReason: null,
|
||||
};
|
||||
return { ...defaults, ...overrides };
|
||||
}
|
||||
|
||||
describe("handleEmbeddedPromptFailure", () => {
|
||||
it("returns the profile-rotation retry before failure marking finishes", async () => {
|
||||
const events: string[] = [];
|
||||
let releaseMark: (() => void) | undefined;
|
||||
const markCanFinish = new Promise<void>((resolve) => {
|
||||
releaseMark = resolve;
|
||||
});
|
||||
const maybeMarkAuthProfileFailure = vi.fn(async () => {
|
||||
events.push("mark-start");
|
||||
await markCanFinish;
|
||||
events.push("mark-finish");
|
||||
});
|
||||
|
||||
try {
|
||||
const outcome = await handleEmbeddedPromptFailure(
|
||||
makeParams({
|
||||
advanceAttemptAuthProfile: vi.fn(async () => {
|
||||
events.push("advance");
|
||||
return true;
|
||||
}),
|
||||
maybeMarkAuthProfileFailure,
|
||||
maybeBackoffBeforeOverloadFailover: vi.fn(async () => {
|
||||
events.push("backoff");
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({
|
||||
action: "retry",
|
||||
thinkLevel: "low",
|
||||
authRetryPending: false,
|
||||
lastRetryFailoverReason: "rate_limit",
|
||||
});
|
||||
expect(events).toEqual(["advance", "mark-start", "backoff"]);
|
||||
expect(maybeMarkAuthProfileFailure).toHaveBeenCalledWith({
|
||||
profileId: "openai:p1",
|
||||
reason: "rate_limit",
|
||||
modelId: "gpt-5",
|
||||
});
|
||||
} finally {
|
||||
releaseMark?.();
|
||||
}
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toEqual(["advance", "mark-start", "backoff", "mark-finish"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,7 @@ function makeConfig(primaryProvider = "openai"): OpenClawConfig {
|
||||
fallbacks: ["groq/mock-2"],
|
||||
},
|
||||
},
|
||||
list: [{ id: "test" }],
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
@@ -265,7 +266,6 @@ async function runEmbeddedFallback(params: {
|
||||
runEmbeddedAgent({
|
||||
sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionFile: path.join(params.workspaceDir, `${params.runId}.jsonl`),
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentDir: params.agentDir,
|
||||
config: cfg,
|
||||
@@ -443,7 +443,6 @@ describe("runWithModelFallback + runEmbeddedAgent failover behavior", () => {
|
||||
const result = await runEmbeddedAgent({
|
||||
sessionId: "session:tool-side-effect-terminal",
|
||||
sessionKey: "agent:test:tool-side-effect-terminal",
|
||||
sessionFile: path.join(workspaceDir, "tool-side-effect-terminal.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
@@ -630,7 +629,6 @@ describe("runWithModelFallback + runEmbeddedAgent failover behavior", () => {
|
||||
runEmbeddedAgent({
|
||||
sessionId,
|
||||
sessionKey: "agent:test:direct-embedded-suspension",
|
||||
sessionFile: path.join(workspaceDir, "direct-embedded-suspension.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: {
|
||||
|
||||
@@ -246,27 +246,21 @@ describe("Agent-specific sandbox config", () => {
|
||||
{
|
||||
scope: "agent" as const,
|
||||
expectedSetup: "echo work",
|
||||
expectedContainerFragment: "agent-work",
|
||||
},
|
||||
{
|
||||
scope: "shared" as const,
|
||||
expectedSetup: "echo global",
|
||||
expectedContainerFragment: "shared",
|
||||
},
|
||||
])(
|
||||
"should resolve $scope setupCommand overrides",
|
||||
async ({ scope, expectedSetup, expectedContainerFragment }) => {
|
||||
const cfg = createWorkSetupCommandConfig(scope);
|
||||
const context = await resolveContext(cfg, "agent:work:main", "/tmp/test-work");
|
||||
])("should resolve $scope setupCommand overrides", async ({ scope, expectedSetup }) => {
|
||||
const cfg = createWorkSetupCommandConfig(scope);
|
||||
const context = await resolveContext(cfg, "agent:work:main", "/tmp/test-work");
|
||||
|
||||
if (!context) {
|
||||
throw new Error(`Expected sandbox context for ${scope} scoped setup`);
|
||||
}
|
||||
expect(context.docker?.setupCommand).toBe(expectedSetup);
|
||||
expect(context.containerName).toContain(expectedContainerFragment);
|
||||
expectDockerSetupCommand(expectedSetup);
|
||||
},
|
||||
);
|
||||
if (!context) {
|
||||
throw new Error(`Expected sandbox context for ${scope} scoped setup`);
|
||||
}
|
||||
expect(context.docker?.setupCommand).toBe(expectedSetup);
|
||||
expectDockerSetupCommand(expectedSetup);
|
||||
});
|
||||
|
||||
it("should allow agent-specific docker settings beyond setupCommand", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
|
||||
@@ -82,8 +82,13 @@ vi.mock("../plugins/hook-runner-global.js", () => ({
|
||||
|
||||
describe("subagent registry archive behavior", () => {
|
||||
let mod: typeof import("./subagent-registry.test-helpers.js");
|
||||
let createCanonicalSubagentRunFixture: typeof import("./subagent-registry.persistence.test-support.js").createCanonicalSubagentRunFixture;
|
||||
let createSubagentRunRecord: typeof import("./subagent-test-fixtures.test-helpers.js").createSubagentRunRecord;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createCanonicalSubagentRunFixture } =
|
||||
await import("./subagent-registry.persistence.test-support.js"));
|
||||
({ createSubagentRunRecord } = await import("./subagent-test-fixtures.test-helpers.js"));
|
||||
mod = await import("./subagent-registry.test-helpers.js");
|
||||
});
|
||||
|
||||
@@ -94,10 +99,20 @@ describe("subagent registry archive behavior", () => {
|
||||
callGateway,
|
||||
getRuntimeConfig: loadConfigMock as typeof import("../config/config.js").getRuntimeConfig,
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
maybeWakeRequesterAfterAllChildrenSettled: vi.fn(async (params) => {
|
||||
params.completeBatch([params.settledEntry.runId]);
|
||||
return false;
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
const addCanonicalSubagentRunForTests = (
|
||||
entry: Parameters<typeof mod.addSubagentRunForTests>[0],
|
||||
) => {
|
||||
mod.addSubagentRunForTests(createCanonicalSubagentRunFixture(createSubagentRunRecord(entry)));
|
||||
};
|
||||
|
||||
const waitForNoRequesterRuns = async () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(mod.listSubagentRunsForRequester("agent:main:main")).toHaveLength(0);
|
||||
@@ -219,7 +234,7 @@ describe("subagent registry archive behavior", () => {
|
||||
resolveContextEngine: vi.fn(async () => ({ onSubagentEnded }) as never),
|
||||
});
|
||||
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-delete-retry",
|
||||
childSessionKey: "agent:main:subagent:delete-retry",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -250,7 +265,7 @@ describe("subagent registry archive behavior", () => {
|
||||
|
||||
it("stabilizes provisional killed tasks before deleting expired tombstones", async () => {
|
||||
const now = Date.now();
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-killed-tombstone-expired",
|
||||
childSessionKey: "agent:main:subagent:killed-tombstone-expired",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -288,7 +303,7 @@ describe("subagent registry archive behavior", () => {
|
||||
it("retains expired tombstones when provisional task finalization is rejected", async () => {
|
||||
const now = Date.now();
|
||||
taskRuntimeMocks.finalizeTaskRunByRunId.mockReturnValueOnce([]);
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-killed-tombstone-retry",
|
||||
childSessionKey: "agent:main:subagent:killed-tombstone-retry",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -321,7 +336,7 @@ describe("subagent registry archive behavior", () => {
|
||||
it("retires expired tombstones when their task row is already gone", async () => {
|
||||
const now = Date.now();
|
||||
taskStatusMocks.findTaskByRunIdForStatus.mockReturnValue(undefined);
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-killed-task-missing",
|
||||
childSessionKey: "agent:main:subagent:killed-task-missing",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -357,7 +372,7 @@ describe("subagent registry archive behavior", () => {
|
||||
status: "cancelled",
|
||||
error: "Cancelled by operator.",
|
||||
} as never);
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-killed-operator-cancelled",
|
||||
childSessionKey: "agent:main:subagent:killed-operator-cancelled",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -393,7 +408,7 @@ describe("subagent registry archive behavior", () => {
|
||||
status: "cancelled",
|
||||
error: "Cancelled by operator.",
|
||||
} as never);
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-killed-grace",
|
||||
childSessionKey: "agent:main:subagent:killed-grace",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -425,7 +440,7 @@ describe("subagent registry archive behavior", () => {
|
||||
taskStatusMocks.listTasksForSessionKeyForStatus.mockReturnValue([]);
|
||||
taskRuntimeMocks.finalizeTaskRunByRunId.mockReturnValueOnce([]);
|
||||
const now = Date.now();
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-killed-opaque-runtime",
|
||||
childSessionKey: "agent:main:subagent:killed-opaque-runtime",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -463,7 +478,7 @@ describe("subagent registry archive behavior", () => {
|
||||
createdAt: now - 11 * 60_000,
|
||||
},
|
||||
] as never);
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-after-replacement",
|
||||
childSessionKey: "agent:main:subagent:replacement",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -508,7 +523,7 @@ describe("subagent registry archive behavior", () => {
|
||||
createdAt: now - 11 * 60_000,
|
||||
},
|
||||
] as never);
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-after-replacement-direct-kill",
|
||||
childSessionKey,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -548,7 +563,7 @@ describe("subagent registry archive behavior", () => {
|
||||
createdAt: now - 60_000,
|
||||
},
|
||||
] as never);
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-old-generation",
|
||||
childSessionKey: "agent:main:subagent:reused-session",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -578,7 +593,7 @@ describe("subagent registry archive behavior", () => {
|
||||
|
||||
it("retires expired keep-mode reconciliation rows without deleting their sessions", async () => {
|
||||
const now = Date.now();
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-killed-keep-expired",
|
||||
childSessionKey: "agent:main:subagent:killed-keep-expired",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -613,7 +628,7 @@ describe("subagent registry archive behavior", () => {
|
||||
it("stabilizes killed tasks before their configured session archive deadline", async () => {
|
||||
const now = Date.now();
|
||||
const archiveAtMs = now + 55 * 60_000;
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-killed-retained-session",
|
||||
childSessionKey: "agent:main:subagent:killed-retained-session",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -657,7 +672,7 @@ describe("subagent registry archive behavior", () => {
|
||||
throw new Error("plugin load failed");
|
||||
}),
|
||||
});
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-killed-hook-load-failure",
|
||||
childSessionKey: "agent:main:subagent:killed-hook-load-failure",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -699,7 +714,7 @@ describe("subagent registry archive behavior", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
mod.addSubagentRunForTests({
|
||||
addCanonicalSubagentRunForTests({
|
||||
runId: "run-delete-inflight",
|
||||
childSessionKey: "agent:main:subagent:delete-inflight",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
|
||||
@@ -575,10 +575,8 @@ describe("subagent registry lifecycle error grace", () => {
|
||||
await waitForAgentCallCount(1);
|
||||
expect(readFirstAnnounceOutcome()?.status).toBe("ok");
|
||||
|
||||
// Advance past the original grace window; no timeout completion should
|
||||
// re-announce. The exhausted completion announce suspends its delivery,
|
||||
// which fires the one-shot requester settle wake for the undelivered
|
||||
// required completion — that wake is not a completion event.
|
||||
// Advance past the original grace window; no timeout completion or
|
||||
// requester-settle wake should be emitted after successful delivery.
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await flushAsync();
|
||||
const readIdempotencyKey = (request: GatewayRequest) => {
|
||||
@@ -592,7 +590,7 @@ describe("subagent registry lifecycle error grace", () => {
|
||||
getAgentCalls()
|
||||
.map(readIdempotencyKey)
|
||||
.filter((key) => key.startsWith("announce:requester-settle:")),
|
||||
).toEqual(["announce:requester-settle:agent:main:main:run-timeout-cancel"]);
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps parallel child completion results frozen even when late traffic arrives", async () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "../../test/helpers/auto-reply/trigger-handling-test-harness.js";
|
||||
import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
|
||||
import { resolveSessionKey } from "../config/sessions.js";
|
||||
import { parseSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js";
|
||||
import {
|
||||
loadExactSessionEntry,
|
||||
loadSessionEntry,
|
||||
@@ -564,12 +563,12 @@ describe("trigger handling", () => {
|
||||
const text = maybeReplyText(res);
|
||||
expect(text?.startsWith("⚙️ Compacted")).toBe(true);
|
||||
expect(getCompactEmbeddedAgentSessionMock()).toHaveBeenCalledOnce();
|
||||
const sessionKey = resolveSessionKey("per-sender", request);
|
||||
const sessionKey = resolveSessionKey("per-sender", request, undefined, "main");
|
||||
expect(loadSessionEntry({ storePath, sessionKey })?.compactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("compacts worker sessions via the agent session file", async () => {
|
||||
it("compacts worker sessions via the explicit session target", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
getCompactEmbeddedAgentSessionMock().mockReset();
|
||||
mockSuccessfulCompaction();
|
||||
@@ -590,17 +589,16 @@ describe("trigger handling", () => {
|
||||
const text = maybeReplyText(res);
|
||||
expect(text?.startsWith("⚙️ Compacted")).toBe(true);
|
||||
expect(getCompactEmbeddedAgentSessionMock()).toHaveBeenCalledOnce();
|
||||
const sessionFile = firstMockCallArg(
|
||||
const call = firstMockCallArg(
|
||||
getCompactEmbeddedAgentSessionMock(),
|
||||
"embedded OpenClaw compaction",
|
||||
).sessionFile;
|
||||
if (typeof sessionFile !== "string") {
|
||||
throw new Error("expected embedded OpenClaw compaction sessionFile");
|
||||
}
|
||||
expect(parseSqliteSessionFileMarker(sessionFile)).toMatchObject({
|
||||
);
|
||||
expect(call.sessionTarget).toMatchObject({
|
||||
agentId: "worker1",
|
||||
sessionKey: "agent:worker1:telegram:12345",
|
||||
storePath: cfg.session.store,
|
||||
});
|
||||
expect(call.sessionFile).toBe("agent:worker1:telegram:12345");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -881,7 +881,7 @@ describe("runReplyAgent heartbeat followup guard", () => {
|
||||
const sessionStore = {
|
||||
main: {
|
||||
sessionId: "pre-compact-session",
|
||||
sessionFile: "/tmp/pre-compact.jsonl",
|
||||
sessionFile: "main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
@@ -907,7 +907,7 @@ describe("runReplyAgent heartbeat followup guard", () => {
|
||||
active.updateSessionId("post-compact-session");
|
||||
sessionStore.main = {
|
||||
sessionId: "post-compact-session",
|
||||
sessionFile: "/tmp/post-compact.jsonl",
|
||||
sessionFile: "main",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
active.complete();
|
||||
@@ -916,7 +916,7 @@ describe("runReplyAgent heartbeat followup guard", () => {
|
||||
expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(1);
|
||||
const [call] = mockCallArgs(state.runEmbeddedAgentMock, "run embedded agent");
|
||||
expect((call as AgentRunParams).sessionId).toBe("post-compact-session");
|
||||
expect((call as AgentRunParams).sessionFile).toBe("/tmp/post-compact.jsonl");
|
||||
expect((call as AgentRunParams).sessionFile).toBe("main");
|
||||
});
|
||||
|
||||
it("drops runs when reply-lane admission sees an already-aborted caller", async () => {
|
||||
@@ -1977,7 +1977,13 @@ describe("runReplyAgent pending final delivery capture", () => {
|
||||
expect(stored.restartRecoveryDeliveryRequestFingerprint).toBeUndefined();
|
||||
expect(stored.restartRecoveryDeliveryRunId).toBeUndefined();
|
||||
expect(stored.restartRecoveryDeliverySourceRunId).toBeUndefined();
|
||||
expect(stored.pendingFinalDelivery).toBe(true);
|
||||
expect(stored.pendingFinalDelivery).toMatchObject({
|
||||
kind: "replayable",
|
||||
text: "visible final",
|
||||
context: { channel: "webchat" },
|
||||
intentId: expect.any(String),
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(stored.restartRecoverySourceIngress).toBe("control-ui");
|
||||
expect(stored.restartRecoveryTerminalRunIds).toEqual(["control-ui-run"]);
|
||||
});
|
||||
@@ -2567,7 +2573,13 @@ describe("runReplyAgent pending final delivery capture", () => {
|
||||
expect(state.beforeAgentReplyRunMock).toHaveBeenCalledOnce();
|
||||
expect(state.runEmbeddedAgentMock).toHaveBeenCalledOnce();
|
||||
expect(await readStoredMainSession(storePath)).toMatchObject({
|
||||
pendingFinalDelivery: { kind: "transport-only" },
|
||||
pendingFinalDelivery: {
|
||||
kind: "replayable",
|
||||
text: "model reply",
|
||||
context: { channel: "discord" },
|
||||
intentId: expect.any(String),
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
restartRecoveryBeforeAgentReplyState: "continue",
|
||||
restartRecoverySourceIngress: "channel",
|
||||
});
|
||||
|
||||
@@ -301,7 +301,6 @@ describe("claws lifecycle cli e2e", () => {
|
||||
agent: { id: "workspace-agent" },
|
||||
workspace: {
|
||||
bootstrapFiles: {
|
||||
"SOUL.md": { source: "workspace/SOUL.md" },
|
||||
"HEARTBEAT.md": { source: "workspace/HEARTBEAT.md" },
|
||||
},
|
||||
files: [
|
||||
@@ -320,6 +319,9 @@ describe("claws lifecycle cli e2e", () => {
|
||||
type: "module",
|
||||
},
|
||||
);
|
||||
await expect(readFile(join(outputDirectory, "CLAW.md"), "utf8")).resolves.toContain(
|
||||
"Incident Response",
|
||||
);
|
||||
const inspected = await runOpenClaw(["claws", "inspect", outputDirectory, "--json"]);
|
||||
expect(parseJson(inspected.stdout)).toMatchObject({
|
||||
valid: true,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
ConfigFileSnapshot,
|
||||
ConfigValidationIssue,
|
||||
LegacyConfigIssue,
|
||||
OpenClawConfig,
|
||||
} from "../config/types.js";
|
||||
|
||||
export function createDoctorConfigSnapshot(
|
||||
params: {
|
||||
config?: Record<string, unknown>;
|
||||
parsed?: Record<string, unknown>;
|
||||
valid?: boolean;
|
||||
issues?: ConfigValidationIssue[];
|
||||
legacyIssues?: LegacyConfigIssue[];
|
||||
} = {},
|
||||
): ConfigFileSnapshot {
|
||||
const config = (params.config ?? {}) as OpenClawConfig;
|
||||
return {
|
||||
path: "/tmp/openclaw.json",
|
||||
includedPaths: [],
|
||||
exists: true,
|
||||
raw: "{}",
|
||||
parsed: params.parsed ?? {},
|
||||
sourceConfig: config,
|
||||
resolved: config,
|
||||
valid: params.valid ?? true,
|
||||
runtimeConfig: config,
|
||||
config,
|
||||
issues: params.issues ?? [],
|
||||
warnings: [],
|
||||
legacyIssues: params.legacyIssues ?? [],
|
||||
};
|
||||
}
|
||||
@@ -1055,6 +1055,7 @@ export async function noteStateIntegrity(
|
||||
cfg: OpenClawConfig,
|
||||
prompter: DoctorPrompterLike,
|
||||
configPath?: string,
|
||||
options?: { stateDirExistedAtStart?: boolean },
|
||||
) {
|
||||
const warnings: string[] = [];
|
||||
const changes: string[] = [];
|
||||
@@ -1102,6 +1103,11 @@ export async function noteStateIntegrity(
|
||||
}
|
||||
|
||||
let stateDirExists = existsDir(stateDir);
|
||||
if (stateDirExists && options?.stateDirExistedAtStart === false) {
|
||||
warnings.push(
|
||||
`- State directory was missing at doctor start and was initialized during startup checks (${displayStateDir}).`,
|
||||
);
|
||||
}
|
||||
if (!stateDirExists) {
|
||||
warnings.push(
|
||||
`- CRITICAL: state directory missing (${displayStateDir}). Sessions, credentials, logs, and config are stored there.`,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
|
||||
import { createDoctorConfigSnapshot } from "./doctor-config-snapshot.test-helpers.js";
|
||||
import {
|
||||
readEmbeddedGatewayTokenForTest,
|
||||
testServiceAuditCodes,
|
||||
@@ -370,17 +371,6 @@ const runLegacyStateMigrations = vi.fn().mockResolvedValue({
|
||||
warnings: [],
|
||||
}) as unknown as MockFn;
|
||||
|
||||
const DEFAULT_CONFIG_SNAPSHOT = {
|
||||
path: "/tmp/openclaw.json",
|
||||
exists: true,
|
||||
raw: "{}",
|
||||
parsed: {},
|
||||
valid: true,
|
||||
config: {},
|
||||
issues: [],
|
||||
legacyIssues: [],
|
||||
} as const;
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
confirm,
|
||||
intro: vi.fn(),
|
||||
@@ -624,14 +614,7 @@ export function mockDoctorConfigSnapshot(
|
||||
legacyIssues?: Array<{ path: string; message: string }>;
|
||||
} = {},
|
||||
) {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
...DEFAULT_CONFIG_SNAPSHOT,
|
||||
config: params.config ?? DEFAULT_CONFIG_SNAPSHOT.config,
|
||||
parsed: params.parsed ?? DEFAULT_CONFIG_SNAPSHOT.parsed,
|
||||
valid: params.valid ?? DEFAULT_CONFIG_SNAPSHOT.valid,
|
||||
issues: params.issues ?? DEFAULT_CONFIG_SNAPSHOT.issues,
|
||||
legacyIssues: params.legacyIssues ?? DEFAULT_CONFIG_SNAPSHOT.legacyIssues,
|
||||
});
|
||||
readConfigFileSnapshot.mockResolvedValue(createDoctorConfigSnapshot(params));
|
||||
}
|
||||
|
||||
/** Creates a runtime mock that captures doctor command output and exits. */
|
||||
|
||||
@@ -56,6 +56,9 @@ vi.mock("./doctor/cron/index.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./doctor/cron/legacy-repair.js", () => ({
|
||||
collectCronCodexRuntimePolicyTargetsReadOnly: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ targets: [], warnings: [] }),
|
||||
repairLegacyCronStoreWithoutPrompt: vi.fn().mockResolvedValue({ changes: [], warnings: [] }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("doctor command", () => {
|
||||
terminalNoteMock.mockClear();
|
||||
});
|
||||
|
||||
it("warns when the state directory is missing", async () => {
|
||||
it("reports when the state directory was missing at doctor start", async () => {
|
||||
mockDoctorConfigSnapshot();
|
||||
|
||||
const missingDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-missing-state-"));
|
||||
@@ -146,8 +146,10 @@ describe("doctor command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const stateNote = requireTerminalNote({ messageIncludes: "state directory missing" });
|
||||
expect(String(stateNote[0])).toContain("CRITICAL");
|
||||
requireTerminalNote({
|
||||
title: "State integrity",
|
||||
messageIncludes: "State directory was missing at doctor start",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes browser readiness through health contributions and degrades gracefully when browser facade is unavailable", async () => {
|
||||
@@ -873,7 +875,7 @@ describe("doctor command", () => {
|
||||
expect(skippedGatewayHealth).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps gateway health probes when env password wins over an exec password ref", async () => {
|
||||
it("skips password-mode probes when configured password is an exec SecretRef", async () => {
|
||||
mockDoctorConfigSnapshot({
|
||||
config: {
|
||||
gateway: {
|
||||
@@ -901,6 +903,7 @@ describe("doctor command", () => {
|
||||
const previousPassword = process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
process.env.OPENCLAW_GATEWAY_PASSWORD = "fallback-password";
|
||||
try {
|
||||
callGateway.mockClear();
|
||||
await doctorCommand(createDoctorRuntime(), {
|
||||
nonInteractive: true,
|
||||
workspaceSuggestions: false,
|
||||
@@ -913,15 +916,12 @@ describe("doctor command", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const skippedGatewayHealth = terminalNoteMock.mock.calls.some(([message, title]) => {
|
||||
return (
|
||||
title === "Gateway" &&
|
||||
String(message).includes(
|
||||
"Gateway health probes skipped because gateway credentials use an exec SecretRef.",
|
||||
)
|
||||
);
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
requireTerminalNote({
|
||||
title: "Gateway",
|
||||
messageIncludes:
|
||||
"Gateway health probes skipped because gateway credentials use an exec SecretRef.",
|
||||
});
|
||||
expect(skippedGatewayHealth).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps remote gateway health probes when env token wins over an exec password ref", async () => {
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
import { resolveAuthProfileOrderWithMetadata } from "../../agents/auth-profiles/order.js";
|
||||
import { resolveAuthProfileDatabasePath } from "../../agents/auth-profiles/sqlite.js";
|
||||
import { describeFailoverError } from "../../agents/failover-error.js";
|
||||
import {
|
||||
prepareInternalSessionEffectsSession,
|
||||
removeInternalSessionEffectsSession,
|
||||
} from "../../agents/internal-session-effects.js";
|
||||
import {
|
||||
hasUsableCustomProviderApiKey,
|
||||
resolveEnvApiKey,
|
||||
@@ -35,10 +39,7 @@ import { findNormalizedProviderValue, normalizeProviderId } from "../../agents/m
|
||||
import { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js";
|
||||
import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
|
||||
import {
|
||||
resolveSessionTranscriptPath,
|
||||
resolveSessionTranscriptsDirForAgent,
|
||||
} from "../../config/sessions/paths.js";
|
||||
import { resolveStorePath } from "../../config/sessions/paths.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
coerceSecretRef,
|
||||
@@ -718,12 +719,12 @@ async function probeTarget(params: {
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
workspaceDir: string;
|
||||
sessionDir: string;
|
||||
storePath: string;
|
||||
target: AuthProbeTarget;
|
||||
timeoutMs: number;
|
||||
maxTokens: number;
|
||||
}): Promise<AuthProbeResult> {
|
||||
const { cfg, agentId, agentDir, workspaceDir, sessionDir, target, timeoutMs, maxTokens } = params;
|
||||
const { cfg, agentId, agentDir, workspaceDir, storePath, target, timeoutMs, maxTokens } = params;
|
||||
// Marker credentials must be resolved by the runtime from config, but the
|
||||
// "config" probe must reflect only that credential — empty the provider auth
|
||||
// order and isolate the agent dir so stored profiles cannot satisfy it via
|
||||
@@ -748,11 +749,10 @@ async function probeTarget(params: {
|
||||
}
|
||||
const model = target.model;
|
||||
|
||||
const sessionId = `probe-${target.provider}-${crypto.randomUUID()}`;
|
||||
const sessionFile = resolveSessionTranscriptPath(sessionId, agentId);
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
const runId = `probe-${target.provider}-${crypto.randomUUID()}`;
|
||||
let isolatedAgentDir: string | null = null;
|
||||
let isolatedProfileId: string | undefined;
|
||||
let sessionTarget: Awaited<ReturnType<typeof prepareInternalSessionEffectsSession>> | undefined;
|
||||
|
||||
const start = Date.now();
|
||||
const buildResult = (status: AuthProbeResult["status"], error?: string): AuthProbeResult => ({
|
||||
@@ -767,6 +767,12 @@ async function probeTarget(params: {
|
||||
latencyMs: Date.now() - start,
|
||||
});
|
||||
try {
|
||||
sessionTarget = await prepareInternalSessionEffectsSession({
|
||||
agentId,
|
||||
cwd: workspaceDir,
|
||||
runId,
|
||||
storePath,
|
||||
});
|
||||
// Any bound-value target runs in an empty agent dir so stored profiles are
|
||||
// absent and cannot satisfy the probe via failover. Direct values pin a
|
||||
// synthetic profile; marker values are resolved by the runtime from the
|
||||
@@ -806,8 +812,9 @@ async function probeTarget(params: {
|
||||
}
|
||||
const { runEmbeddedAgent } = await loadEmbeddedRunnerModule();
|
||||
await runEmbeddedAgent({
|
||||
sessionId,
|
||||
sessionFile,
|
||||
sessionId: sessionTarget.sessionId,
|
||||
sessionKey: sessionTarget.sessionKey,
|
||||
sessionTarget,
|
||||
agentId,
|
||||
workspaceDir,
|
||||
agentDir: isolatedAgentDir ?? agentDir,
|
||||
@@ -818,7 +825,7 @@ async function probeTarget(params: {
|
||||
authProfileId: isolatedProfileId ?? target.profileId,
|
||||
authProfileIdSource: isolatedProfileId || target.profileId ? "user" : undefined,
|
||||
timeoutMs,
|
||||
runId: `probe-${crypto.randomUUID()}`,
|
||||
runId,
|
||||
lane: `auth-probe:${target.provider}:${target.profileId ?? target.source}`,
|
||||
thinkLevel: "off",
|
||||
reasoningLevel: "off",
|
||||
@@ -836,6 +843,7 @@ async function probeTarget(params: {
|
||||
redactAuthProbeError(described.message),
|
||||
);
|
||||
} finally {
|
||||
await removeInternalSessionEffectsSession(sessionTarget);
|
||||
if (isolatedAgentDir) {
|
||||
clearRuntimeAuthProfileStoreSnapshot(isolatedAgentDir);
|
||||
disposeOpenClawAgentDatabaseByPath(resolveAuthProfileDatabasePath(isolatedAgentDir));
|
||||
@@ -864,7 +872,7 @@ async function runTargetsWithConcurrency(params: {
|
||||
params.workspaceDir ??
|
||||
resolveAgentWorkspaceDir(cfg, agentId) ??
|
||||
resolveDefaultAgentWorkspaceDir();
|
||||
const sessionDir = resolveSessionTranscriptsDirForAgent(agentId);
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
@@ -882,7 +890,7 @@ async function runTargetsWithConcurrency(params: {
|
||||
agentId,
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
sessionDir,
|
||||
storePath,
|
||||
target,
|
||||
timeoutMs,
|
||||
maxTokens,
|
||||
|
||||
@@ -56,8 +56,13 @@ function lastEmbeddedAgentCall(): {
|
||||
prompt?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
sessionTarget?: {
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
};
|
||||
workspaceDir?: string;
|
||||
sessionFile?: string;
|
||||
} {
|
||||
const calls = runEmbeddedAgentMock.mock.calls;
|
||||
const call = calls[calls.length - 1];
|
||||
@@ -74,8 +79,13 @@ function lastEmbeddedAgentCall(): {
|
||||
prompt?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
sessionTarget?: {
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
};
|
||||
workspaceDir?: string;
|
||||
sessionFile?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,18 +216,28 @@ describe("runCronIsolatedAgentTurn session identity", () => {
|
||||
const call = lastEmbeddedAgentCall();
|
||||
expect(call.sessionKey).toMatch(/^agent:ops:cron:job-ops:run:/);
|
||||
expect(call.workspaceDir).toBe(opsWorkspace);
|
||||
expect(call.sessionFile).toBe(call.sessionKey);
|
||||
expect(call.sessionTarget).toEqual({
|
||||
agentId: "ops",
|
||||
sessionId: call.sessionId,
|
||||
sessionKey: call.sessionKey,
|
||||
storePath: path.join(home, ".openclaw", "agents", "ops", "sessions", "sessions.json"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the canonical key through the deprecated sessionFile field", async () => {
|
||||
it("passes the canonical identity through the structured session target", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await runCronTurn(home, {
|
||||
jobPayload: DEFAULT_AGENT_TURN_PAYLOAD,
|
||||
});
|
||||
const call = lastEmbeddedAgentCall();
|
||||
|
||||
expect(call.sessionFile).toBe(call.sessionKey);
|
||||
expect(call.sessionTarget).toEqual({
|
||||
agentId: "main",
|
||||
sessionId: call.sessionId,
|
||||
sessionKey: call.sessionKey,
|
||||
storePath: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -558,6 +558,12 @@ function createCronPromptExecutor(params: {
|
||||
const result = await runEmbeddedAgent({
|
||||
sessionId: params.cronSession.sessionEntry.sessionId,
|
||||
sessionKey: params.runSessionKey,
|
||||
sessionTarget: {
|
||||
agentId: params.agentId,
|
||||
sessionId: params.cronSession.sessionEntry.sessionId,
|
||||
sessionKey: params.runSessionKey,
|
||||
storePath: params.cronSession.storePath,
|
||||
},
|
||||
promptCacheKey,
|
||||
agentId: params.agentId,
|
||||
trigger: "cron",
|
||||
@@ -569,7 +575,6 @@ function createCronPromptExecutor(params: {
|
||||
messageTo: params.resolvedDelivery.to,
|
||||
messageThreadId: params.resolvedDelivery.threadId,
|
||||
currentChannelId,
|
||||
sessionFile,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
config: params.cfgWithAgentDefaults,
|
||||
|
||||
@@ -65,12 +65,26 @@ describe("runCronIsolatedAgentTurn isolated session identity", () => {
|
||||
const runRequest = requireFirstMockArg(runEmbeddedAgentMock, "runEmbeddedAgentMock") as {
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
sessionFile?: string;
|
||||
sessionTarget?: {
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
};
|
||||
promptCacheKey?: string;
|
||||
bootstrapContextMode?: string;
|
||||
bootstrapContextRunKind?: string;
|
||||
};
|
||||
expect(runRequest.sessionId).toBe("isolated-run-1");
|
||||
expect(runRequest.sessionKey).toBe("agent:default:cron:daily-monitor:run:isolated-run-1");
|
||||
expect(runRequest.sessionFile).toBeUndefined();
|
||||
expect(runRequest.sessionTarget).toEqual({
|
||||
agentId: "default",
|
||||
sessionId: "isolated-run-1",
|
||||
sessionKey: "agent:default:cron:daily-monitor:run:isolated-run-1",
|
||||
storePath: expect.any(String),
|
||||
});
|
||||
expect(runRequest.sessionKey).not.toBe("agent:default:cron:daily-monitor");
|
||||
expect(runRequest.promptCacheKey).toMatch(/^openclaw-cron-[a-f0-9]{32}$/u);
|
||||
expect(runRequest.promptCacheKey).not.toContain("isolated-run-1");
|
||||
|
||||
@@ -68,6 +68,21 @@ if [[ "\${1:-}" == "build" ]]; then
|
||||
echo "build DOCKER_BUILDKIT=\${DOCKER_BUILDKIT:-} $*" >>"$log"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "\${1:-}" == "run" ]]; then
|
||||
echo "run $*" >>"$log"
|
||||
args=("$@")
|
||||
for ((i = 0; i + 3 < \${#args[@]}; i++)); do
|
||||
if [[ "\${args[$i]}" == "--entrypoint" && "\${args[$((i + 1))]}" == "node" ]]; then
|
||||
for ((j = i + 2; j + 1 < \${#args[@]}; j++)); do
|
||||
if [[ "\${args[$j]}" == "-e" ]]; then
|
||||
node -e "\${args[$((j + 1))]}" "\${args[@]:$((j + 2))}"
|
||||
exit $?
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
if [[ "\${1:-}" == "compose" ]]; then
|
||||
if [[ -n "$fail_match" && "$*" == *"$fail_match"* ]]; then
|
||||
echo "compose-fail $*" >>"$log"
|
||||
@@ -162,6 +177,10 @@ export async function createDockerSetupSandbox(): Promise<DockerSetupSandbox> {
|
||||
join(repoRoot, "scripts", "lib", "docker-e2e-container.sh"),
|
||||
join(rootDir, "scripts", "lib", "docker-e2e-container.sh"),
|
||||
);
|
||||
await copyFile(
|
||||
join(repoRoot, "scripts", "lib", "docker-e2e-resource-diagnostics.sh"),
|
||||
join(rootDir, "scripts", "lib", "docker-e2e-resource-diagnostics.sh"),
|
||||
);
|
||||
await copyFile(
|
||||
join(repoRoot, "scripts", "lib", "host-timeout.sh"),
|
||||
join(rootDir, "scripts", "lib", "host-timeout.sh"),
|
||||
|
||||
@@ -807,7 +807,7 @@ describe("scripts/docker/setup.sh", () => {
|
||||
expect(result.stderr).toContain("OPENCLAW_HOME_VOLUME must match");
|
||||
});
|
||||
|
||||
it("rejects OPENCLAW_TZ values that are not present in zoneinfo", () => {
|
||||
it("rejects OPENCLAW_TZ values that are unsupported by the runtime image", () => {
|
||||
const activeSandbox = requireSandbox(sandbox);
|
||||
|
||||
const result = runDockerSetup(activeSandbox, {
|
||||
@@ -815,7 +815,7 @@ describe("scripts/docker/setup.sh", () => {
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo");
|
||||
expect(result.stderr).toContain("OPENCLAW_TZ must be supported by openclaw:local");
|
||||
});
|
||||
|
||||
it("skips onboarding when OPENCLAW_SKIP_ONBOARDING is set", async () => {
|
||||
|
||||
@@ -87,7 +87,9 @@ export async function runChannelIngressDeadLettersHealth(): Promise<void> {
|
||||
|
||||
export async function runStateIntegrityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
const { noteStateIntegrity } = await loadDoctorStateIntegrityModule();
|
||||
await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath);
|
||||
await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath, {
|
||||
stateDirExistedAtStart: ctx.stateDirExistedAtStart,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCodexSessionRouteHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
|
||||
@@ -36,6 +36,8 @@ export type DoctorHealthFlowContext = {
|
||||
postConfigWriteRepairsCommitted?: boolean;
|
||||
sourceConfigValid: boolean;
|
||||
configPath: string;
|
||||
/** Whether the selected state directory already existed before doctor startup work. */
|
||||
stateDirExistedAtStart?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
gatewayDetails?: ReturnType<typeof buildGatewayConnectionDetails>;
|
||||
healthOk?: boolean;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Doctor health flow renders interactive health check output.
|
||||
import fs from "node:fs";
|
||||
import { intro as clackIntro, outro as clackOutro } from "@clack/prompts";
|
||||
import { stylePromptTitle } from "../../packages/terminal-core/src/prompt-style.js";
|
||||
import type { DoctorOptions } from "../commands/doctor-prompter.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||
import type { DoctorHealthFlowContext } from "./doctor-health-contributions.js";
|
||||
@@ -12,9 +14,20 @@ const outro = (message: string) => clackOutro(stylePromptTitle(message) ?? messa
|
||||
|
||||
const loadConfigModule = createLazyRuntimeModule(() => import("../config/config.js"));
|
||||
|
||||
function stateDirectoryExistsAtDoctorStart(): boolean {
|
||||
try {
|
||||
return fs.statSync(resolveStateDir()).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the full interactive doctor flow against the provided or default runtime. */
|
||||
export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions = {}) {
|
||||
const effectiveRuntime = runtime ?? (await import("../runtime.js")).defaultRuntime;
|
||||
// Config loading can initialize SQLite-backed state before integrity runs.
|
||||
// Preserve the entry fact so doctor can report that automatic initialization.
|
||||
const stateDirExistedAtStart = stateDirectoryExistsAtDoctorStart();
|
||||
if (options.repair === true || options.yes === true || options.generateGatewayToken === true) {
|
||||
const { assertConfigWriteAllowedInCurrentMode } = await loadConfigModule();
|
||||
assertConfigWriteAllowedInCurrentMode();
|
||||
@@ -71,6 +84,7 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions
|
||||
cfgForPersistence: structuredClone(configResult.cfg),
|
||||
sourceConfigValid: configResult.sourceConfigValid ?? true,
|
||||
configPath: configResult.path ?? CONFIG_PATH,
|
||||
stateDirExistedAtStart,
|
||||
};
|
||||
const { runDoctorHealthContributions } = await import("./doctor-health-contributions.js");
|
||||
await runDoctorHealthContributions(ctx);
|
||||
|
||||
@@ -681,7 +681,6 @@ describeLive("gateway live (ACP bind)", () => {
|
||||
cron: {
|
||||
...cfg.cron,
|
||||
enabled: true,
|
||||
store: path.join(tempRoot, "cron.json"),
|
||||
},
|
||||
};
|
||||
await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`);
|
||||
|
||||
+12
-23
@@ -8,7 +8,6 @@ import {
|
||||
clearConfigCache,
|
||||
clearRuntimeConfigSnapshot,
|
||||
getRuntimeConfig,
|
||||
getRuntimeConfigSnapshotMetadata,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { resetConfigOverrides, setConfigOverride } from "../config/runtime-overrides.js";
|
||||
@@ -345,10 +344,11 @@ describe("gateway e2e", () => {
|
||||
callerAuthOverride.rateLimit!.maxAttempts = 99;
|
||||
callerTailscaleOverride.serviceName = "svc:mutated";
|
||||
}
|
||||
await writeConfigFile({
|
||||
const nextLoggingSource = {
|
||||
...initialConfig,
|
||||
logging: { level: "debug" },
|
||||
});
|
||||
} satisfies OpenClawConfig;
|
||||
await writeConfigFile(nextLoggingSource);
|
||||
await expect
|
||||
.poll(() => getRuntimeConfig().logging?.level, { timeout: 5_000, interval: 50 })
|
||||
.toBe("debug");
|
||||
@@ -362,8 +362,7 @@ describe("gateway e2e", () => {
|
||||
expect(getRuntimeConfig().channels?.whatsapp?.allowFrom).toEqual(["*"]);
|
||||
|
||||
const sourceBeforePolicyEdit = (await configIO.readConfigFileSnapshot()).sourceConfig;
|
||||
const revisionBeforePolicyEdit = getRuntimeConfigSnapshotMetadata()?.revision ?? -1;
|
||||
await writeConfigFile({
|
||||
const nextPolicySource = {
|
||||
...sourceBeforePolicyEdit,
|
||||
channels: {
|
||||
...sourceBeforePolicyEdit.channels,
|
||||
@@ -372,13 +371,8 @@ describe("gateway e2e", () => {
|
||||
dmPolicy: "disabled",
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect
|
||||
.poll(() => getRuntimeConfigSnapshotMetadata()?.revision ?? -1, {
|
||||
timeout: 5_000,
|
||||
interval: 50,
|
||||
})
|
||||
.toBeGreaterThan(revisionBeforePolicyEdit);
|
||||
} satisfies OpenClawConfig;
|
||||
await writeConfigFile(nextPolicySource);
|
||||
const persistedPolicyEdit = JSON.parse(
|
||||
await fs.readFile(configPath, "utf-8"),
|
||||
) as OpenClawConfig;
|
||||
@@ -386,21 +380,16 @@ describe("gateway e2e", () => {
|
||||
expect(getRuntimeConfig().channels?.whatsapp?.dmPolicy).toBe("open");
|
||||
|
||||
const sourceBeforeUnrelatedWrite = (await configIO.readConfigFileSnapshot()).sourceConfig;
|
||||
const revisionBeforeUnrelatedWrite = getRuntimeConfigSnapshotMetadata()?.revision ?? -1;
|
||||
await writeConfigFile({
|
||||
const nextUnrelatedSource = {
|
||||
...sourceBeforeUnrelatedWrite,
|
||||
ui: { assistant: { name: "unrelated-managed-write" } },
|
||||
});
|
||||
await expect
|
||||
.poll(() => getRuntimeConfigSnapshotMetadata()?.revision ?? -1, {
|
||||
timeout: 5_000,
|
||||
interval: 50,
|
||||
})
|
||||
.toBeGreaterThan(revisionBeforeUnrelatedWrite);
|
||||
} satisfies OpenClawConfig;
|
||||
await writeConfigFile(nextUnrelatedSource);
|
||||
const persistedAfterUnrelatedWrite = JSON.parse(
|
||||
await fs.readFile(configPath, "utf-8"),
|
||||
) as OpenClawConfig;
|
||||
expect(persistedAfterUnrelatedWrite.channels?.whatsapp?.dmPolicy).toBe("disabled");
|
||||
expect(persistedAfterUnrelatedWrite.ui?.assistant?.name).toBe("unrelated-managed-write");
|
||||
}
|
||||
|
||||
const reconnected = await connectGatewayClient({
|
||||
@@ -588,7 +577,7 @@ describe("gateway e2e", () => {
|
||||
},
|
||||
// The request below runs sessionKey "agent:dev:mock-openai"; the
|
||||
// gateway rejects session keys whose agent id is not declared.
|
||||
list: [{ id: "dev", default: true }],
|
||||
entries: { dev: { default: true } },
|
||||
},
|
||||
models: {
|
||||
mode: "replace",
|
||||
@@ -674,7 +663,7 @@ module.exports = {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: workspaceDir },
|
||||
list: [{ id: "main", default: true, tools: { allow: ["agents_list"] } }],
|
||||
entries: { main: { default: true, tools: { allow: ["agents_list"] } } },
|
||||
},
|
||||
plugins: {
|
||||
allow: ["http-probe"],
|
||||
|
||||
@@ -500,8 +500,10 @@ describe("SSRF external proxy routing", () => {
|
||||
expect(child.stdout).toContain('"body":"from loopback target"');
|
||||
expect(seenConnectTargets).toContain(`127.0.0.1:${wsTargetPort}`);
|
||||
expect(seenConnectTargets).toContain(`127.0.0.1:${httpsLikeTargetPort}`);
|
||||
expect(seenConnectTargets).toContain(`127.0.0.1:${targetPort}`);
|
||||
expect(seenConnectTargets).toContain(`127.0.0.1:${globalFetchTargetPort}`);
|
||||
expect(seenConnectTargets).toContain(`http://127.0.0.1:${targetPort}/private-metadata`);
|
||||
expect(seenConnectTargets).toContain(
|
||||
`http://127.0.0.1:${globalFetchTargetPort}/global-fetch-metadata`,
|
||||
);
|
||||
expect(seenConnectTargets).toContain(`http://127.0.0.1:${targetPort}/node-http-metadata`);
|
||||
expect(seenConnectTargets).toContain(`http://127.0.0.1:${targetPort}/explicit-agent`);
|
||||
expect(seenConnectTargets).not.toContain(`127.0.0.1:${gatewayBypassWsTargetPort}`);
|
||||
|
||||
@@ -55,6 +55,7 @@ function liveConfig(): OpenClawConfig {
|
||||
},
|
||||
},
|
||||
},
|
||||
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
||||
},
|
||||
skills: { workshop: { autonomous: { mode: "off" } } },
|
||||
};
|
||||
|
||||
@@ -205,7 +205,12 @@ describe("Gateway queued session rotation", () => {
|
||||
enabled: true,
|
||||
allow: ["queued-rotation-tracer"],
|
||||
load: { paths: [pluginDir] },
|
||||
entries: { "queued-rotation-tracer": { enabled: true } },
|
||||
entries: {
|
||||
"queued-rotation-tracer": {
|
||||
enabled: true,
|
||||
hooks: { allowConversationAccess: true },
|
||||
},
|
||||
},
|
||||
slots: { memory: "none" },
|
||||
},
|
||||
agents: {
|
||||
|
||||
@@ -305,12 +305,19 @@ export function makeCfg(home: string): OpenClawConfig {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-7" },
|
||||
models: {
|
||||
"anthropic/claude-haiku-4-5-20251001": {},
|
||||
"anthropic/claude-opus-4-7": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
"openai/gpt-5.4": {},
|
||||
},
|
||||
workspace: join(home, "openclaw"),
|
||||
// Test harness: avoid 1s coalescer idle sleeps that dominate trigger suites.
|
||||
blockStreamingCoalesce: { idleMs: 1 },
|
||||
// Trigger tests assert routing/authorization behavior, not delivery pacing.
|
||||
humanDelay: { mode: "off" },
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
channels: {
|
||||
whatsapp: {
|
||||
|
||||
@@ -2465,6 +2465,14 @@ describe("ci workflow guards", () => {
|
||||
const source = readFileSync(workflowPath, "utf8");
|
||||
expect(source, workflowPath).not.toContain("build-all-cache-scope:");
|
||||
}
|
||||
|
||||
const releaseChecks = parse(
|
||||
readFileSync(".github/workflows/openclaw-live-and-e2e-checks-reusable.yml", "utf8"),
|
||||
);
|
||||
expect(releaseChecks.jobs.validate_repo_e2e.env).toMatchObject({
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1",
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists Node 22 declarations through trusted bounded artifacts", () => {
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("live plugin tool assertions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reads causal tool evidence from the canonical SQLite transcript", () => {
|
||||
it("reads Code Mode exec evidence from the canonical SQLite transcript", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "openclaw-live-plugin-tool-"));
|
||||
const databasePath = path.join(
|
||||
root,
|
||||
@@ -161,7 +161,7 @@ describe("live plugin tool assertions", () => {
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call-live-plugin-tool",
|
||||
name: "e2e_slug_probe",
|
||||
name: "exec",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -174,6 +174,33 @@ describe("live plugin tool assertions", () => {
|
||||
message: {
|
||||
role: "tool",
|
||||
tool_call_id: "call-live-plugin-tool",
|
||||
content: "Code cell still running: cell-live-plugin-tool",
|
||||
},
|
||||
}),
|
||||
);
|
||||
insert.run(
|
||||
"live-plugin-tool",
|
||||
3,
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "wait-live-plugin-tool",
|
||||
name: "wait",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
insert.run(
|
||||
"live-plugin-tool",
|
||||
4,
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "tool",
|
||||
tool_call_id: "wait-live-plugin-tool",
|
||||
content: "live-plugin-slug",
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user