diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index cf474f395c84..5a6825246667 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -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 diff --git a/extensions/browser/src/browser/client-fetch.attach-only.e2e.test.ts b/extensions/browser/src/browser/client-fetch.attach-only.e2e.test.ts index e0aa3f91837b..da6a0a4fc4d9 100644 --- a/extensions/browser/src/browser/client-fetch.attach-only.e2e.test.ts +++ b/extensions/browser/src/browser/client-fetch.attach-only.e2e.test.ts @@ -55,7 +55,6 @@ describe("browser client fetch attachOnly diagnostics", () => { hung: { cdpUrl: `http://127.0.0.1:${port}`, attachOnly: true, - color: "#00AA00", }, }, }, diff --git a/extensions/qa-lab/src/scenario-catalog-channels.test.ts b/extensions/qa-lab/src/scenario-catalog-channels.test.ts index 060822f90ede..810532b40b4f 100644 --- a/extensions/qa-lab/src/scenario-catalog-channels.test.ts +++ b/extensions/qa-lab/src/scenario-catalog-channels.test.ts @@ -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", () => { @@ -110,7 +115,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 } } } }, }); }); diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 85ef67cd0541..1d5e98d3ef5d 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -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/); }); }); diff --git a/packages/sdk/src/package.e2e.test.ts b/packages/sdk/src/package.e2e.test.ts index e6236cf07067..2f7f128e30a9 100644 --- a/packages/sdk/src/package.e2e.test.ts +++ b/packages/sdk/src/package.e2e.test.ts @@ -23,6 +23,7 @@ type PackageManifest = { name: string; version: string; dependencies?: Record; + scripts?: Record; [key: string]: unknown; }; @@ -184,16 +185,23 @@ function runNpmCommand( }); } -function normalizeWorkspaceDependencies( +async function normalizeWorkspaceDependencies( + repoRoot: string, dependencies: Record | undefined, -): Record | undefined { +): Promise | undefined> { if (!dependencies) { return undefined; } const normalized: Record = {}; 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 { 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, }); diff --git a/qa/scenarios/channels/channel-message-flows.yaml b/qa/scenarios/channels/channel-message-flows.yaml index c7fd1dfa0e07..cafb29c6d864 100644 --- a/qa/scenarios/channels/channel-message-flows.yaml +++ b/qa/scenarios/channels/channel-message-flows.yaml @@ -12,6 +12,9 @@ scenario: gatewayConfigPatch: channels: telegram: + groups: + "*": + requireMention: false streaming: mode: partial successCriteria: diff --git a/qa/scenarios/channels/native-command-session-target.yaml b/qa/scenarios/channels/native-command-session-target.yaml index 6cedb443b018..a70eecb76453 100644 --- a/qa/scenarios/channels/native-command-session-target.yaml +++ b/qa/scenarios/channels/native-command-session-target.yaml @@ -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 diff --git a/qa/scenarios/channels/telegram-assistant-transcript-role-boundary.yaml b/qa/scenarios/channels/telegram-assistant-transcript-role-boundary.yaml index ddf68ea8daf4..742ea1e9564c 100644 --- a/qa/scenarios/channels/telegram-assistant-transcript-role-boundary.yaml +++ b/qa/scenarios/channels/telegram-assistant-transcript-role-boundary.yaml @@ -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. diff --git a/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts b/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts index 860af9ed2ad5..936814714b3c 100644 --- a/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts +++ b/src/agents/bash-tools.exec-gateway-approval.e2e.test.ts @@ -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, }); diff --git a/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts b/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts index b348a873f578..9beed1599acc 100644 --- a/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts @@ -156,6 +156,7 @@ const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): OpenClawC fallbacks: opts?.fallbacks ?? [], }, }, + list: [{ id: "test" }], }, models: { providers: { @@ -222,6 +223,9 @@ const copilotModelId = "gpt-4o"; const makeCopilotConfig = (): OpenClawConfig => ({ + agents: { + list: [{ id: "test" }], + }, models: { providers: { "github-copilot": { @@ -377,7 +381,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 +594,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 +627,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 +694,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 +782,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 +829,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(), @@ -1024,7 +1022,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 +1063,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 +1091,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 +1140,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 +1168,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 +1208,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 +1249,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 +1292,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 +1339,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 +1377,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 +1407,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 +1451,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"] }), @@ -1500,7 +1486,6 @@ describe("runEmbeddedAgent auth profile rotation", () => { 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 +1524,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"] }), diff --git a/src/agents/model-fallback.run-embedded.e2e.test.ts b/src/agents/model-fallback.run-embedded.e2e.test.ts index 0061678fbc6c..a2306acef6b7 100644 --- a/src/agents/model-fallback.run-embedded.e2e.test.ts +++ b/src/agents/model-fallback.run-embedded.e2e.test.ts @@ -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: { diff --git a/src/agents/sandbox-agent-config.agent-specific-sandbox-config.e2e.test.ts b/src/agents/sandbox-agent-config.agent-specific-sandbox-config.e2e.test.ts index daac4f974c00..fb2e3e350bd4 100644 --- a/src/agents/sandbox-agent-config.agent-specific-sandbox-config.e2e.test.ts +++ b/src/agents/sandbox-agent-config.agent-specific-sandbox-config.e2e.test.ts @@ -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 = { diff --git a/src/agents/subagent-registry.archive.e2e.test.ts b/src/agents/subagent-registry.archive.e2e.test.ts index 5fa6b9aa8038..504a67441424 100644 --- a/src/agents/subagent-registry.archive.e2e.test.ts +++ b/src/agents/subagent-registry.archive.e2e.test.ts @@ -82,8 +82,11 @@ 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; beforeAll(async () => { + ({ createCanonicalSubagentRunFixture } = + await import("./subagent-registry.persistence.test-support.js")); mod = await import("./subagent-registry.test-helpers.js"); }); @@ -94,10 +97,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[0], + ) => { + mod.addSubagentRunForTests(createCanonicalSubagentRunFixture(entry)); + }; + const waitForNoRequesterRuns = async () => { await vi.waitFor(() => { expect(mod.listSubagentRunsForRequester("agent:main:main")).toHaveLength(0); @@ -219,7 +232,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 +263,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 +301,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 +334,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 +370,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 +406,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 +438,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 +476,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 +521,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 +561,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 +591,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 +626,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 +670,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 +712,7 @@ describe("subagent registry archive behavior", () => { return {}; }); - mod.addSubagentRunForTests({ + addCanonicalSubagentRunForTests({ runId: "run-delete-inflight", childSessionKey: "agent:main:subagent:delete-inflight", requesterSessionKey: "agent:main:main", diff --git a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts index 9afa3234e022..b5ff1bddbc91 100644 --- a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts +++ b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts @@ -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 () => { diff --git a/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts index dcba7c0a7adc..193c3b9040cb 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts @@ -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"); }); }); diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 3fd53fffb780..32d86fafb6d0 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -845,7 +845,7 @@ describe("runReplyAgent heartbeat followup guard", () => { const sessionStore = { main: { sessionId: "pre-compact-session", - sessionFile: "/tmp/pre-compact.jsonl", + sessionFile: "main", updatedAt: Date.now(), }, }; @@ -871,7 +871,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(); @@ -880,7 +880,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 () => { @@ -1941,7 +1941,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"]); }); @@ -2602,7 +2608,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", }); diff --git a/src/claws/lifecycle.e2e.test.ts b/src/claws/lifecycle.e2e.test.ts index 3601b86da049..942c05fbf3f4 100644 --- a/src/claws/lifecycle.e2e.test.ts +++ b/src/claws/lifecycle.e2e.test.ts @@ -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, diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 21d97a904f3f..d4d6010cf4e1 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { afterEach, beforeEach, vi } from "vitest"; +import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import type { MockFn } from "../test-utils/vitest-mock-fn.js"; import { @@ -372,14 +373,19 @@ const runLegacyStateMigrations = vi.fn().mockResolvedValue({ const DEFAULT_CONFIG_SNAPSHOT = { path: "/tmp/openclaw.json", + includedPaths: [], exists: true, raw: "{}", parsed: {}, + sourceConfig: {}, + resolved: {}, valid: true, + runtimeConfig: {}, config: {}, issues: [], + warnings: [], legacyIssues: [], -} as const; +} as unknown as ConfigFileSnapshot; vi.mock("@clack/prompts", () => ({ confirm, @@ -624,14 +630,18 @@ export function mockDoctorConfigSnapshot( legacyIssues?: Array<{ path: string; message: string }>; } = {}, ) { + const config = (params.config ?? DEFAULT_CONFIG_SNAPSHOT.config) as OpenClawConfig; readConfigFileSnapshot.mockResolvedValue({ ...DEFAULT_CONFIG_SNAPSHOT, - config: params.config ?? DEFAULT_CONFIG_SNAPSHOT.config, parsed: params.parsed ?? DEFAULT_CONFIG_SNAPSHOT.parsed, + sourceConfig: config, + resolved: config, + runtimeConfig: config, + config, valid: params.valid ?? DEFAULT_CONFIG_SNAPSHOT.valid, issues: params.issues ?? DEFAULT_CONFIG_SNAPSHOT.issues, legacyIssues: params.legacyIssues ?? DEFAULT_CONFIG_SNAPSHOT.legacyIssues, - }); + } as ConfigFileSnapshot); } /** Creates a runtime mock that captures doctor command output and exits. */ diff --git a/src/commands/doctor.fast-path-mocks.ts b/src/commands/doctor.fast-path-mocks.ts index 84d4ccf3201e..348976aac78b 100644 --- a/src/commands/doctor.fast-path-mocks.ts +++ b/src/commands/doctor.fast-path-mocks.ts @@ -59,6 +59,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: [] }), })); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index c3b10f429565..aa4e7267b411 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -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 { - 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> | 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, diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index a40823bf8e0b..1fe8fe085cfa 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -491,6 +491,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", @@ -502,7 +508,6 @@ function createCronPromptExecutor(params: { messageTo: params.resolvedDelivery.to, messageThreadId: params.resolvedDelivery.threadId, currentChannelId, - sessionFile, agentDir: params.agentDir, workspaceDir: params.workspaceDir, config: params.cfgWithAgentDefaults, diff --git a/src/cron/isolated-agent/run.session-key-isolation.test.ts b/src/cron/isolated-agent/run.session-key-isolation.test.ts index ef4653b3fb81..cb6b4a151c2c 100644 --- a/src/cron/isolated-agent/run.session-key-isolation.test.ts +++ b/src/cron/isolated-agent/run.session-key-isolation.test.ts @@ -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"); diff --git a/src/docker-setup.e2e.test-support.ts b/src/docker-setup.e2e.test-support.ts index f020c3f5308c..37fe8b0cff74 100644 --- a/src/docker-setup.e2e.test-support.ts +++ b/src/docker-setup.e2e.test-support.ts @@ -162,6 +162,10 @@ export async function createDockerSetupSandbox(): Promise { 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"), diff --git a/src/infra/net/proxy/external-proxy.e2e.test.ts b/src/infra/net/proxy/external-proxy.e2e.test.ts index 146b2736d579..cac3340bf2f6 100644 --- a/src/infra/net/proxy/external-proxy.e2e.test.ts +++ b/src/infra/net/proxy/external-proxy.e2e.test.ts @@ -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}`); diff --git a/test/gateway-queued-session-rotation.e2e.test.ts b/test/gateway-queued-session-rotation.e2e.test.ts index 35c7b8456ec1..ad7741d54617 100644 --- a/test/gateway-queued-session-rotation.e2e.test.ts +++ b/test/gateway-queued-session-rotation.e2e.test.ts @@ -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: { diff --git a/test/helpers/auto-reply/trigger-handling-test-harness.ts b/test/helpers/auto-reply/trigger-handling-test-harness.ts index 2518eb3f2f16..e878a60488d4 100644 --- a/test/helpers/auto-reply/trigger-handling-test-harness.ts +++ b/test/helpers/auto-reply/trigger-handling-test-harness.ts @@ -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: { diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 30a5fa9d77ea..29fe60831ffe 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -2409,6 +2409,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", () => {