From 5c39e2da3ac80c64f7202f1474e79f926ae6a794 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:01:34 +0100 Subject: [PATCH 01/12] test: accept utc timestamp label --- src/auto-reply/reply/inbound-meta.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index 66d49fb5d5c..1de1fdd4f3a 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -389,7 +389,7 @@ describe("buildInboundUserContextPrefix", () => { } as TemplateContext); const conversationInfo = parseConversationInfoPayload(text); - expect(conversationInfo["timestamp"]).toBe("Sun 2026-02-15 13:35 GMT"); + expect(conversationInfo["timestamp"]).toMatch(/^Sun 2026-02-15 13:35 (?:GMT|UTC)$/); }); it("honors envelope user timezone for conversation timestamps", () => { From 7d20be5fb5e8e12354cc76fed74dd44a6dc1dae1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:02:20 +0100 Subject: [PATCH 02/12] test: tighten gateway health auth assertions --- src/gateway/server.auth.compat-baseline.test.ts | 2 +- src/gateway/server.health.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index 699f567eead..2155545a668 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -217,7 +217,7 @@ describe("gateway auth compatibility baseline", () => { }); expect(rotated.ok).toBe(true); const rotatedToken = rotated.ok ? rotated.entry.token : ""; - expect(rotatedToken).toEqual(expect.any(String)); + expect(rotatedToken).toBeTypeOf("string"); expect(rotatedToken.length).toBeGreaterThan(0); const ws = await openWs(port); diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.test.ts index 6b6611db0c6..da0e2fd2353 100644 --- a/src/gateway/server.health.test.ts +++ b/src/gateway/server.health.test.ts @@ -167,7 +167,7 @@ describe("gateway server health/presence", () => { await localHarness.close(); const evt = await shutdownP; const evtPayload = evt.payload as { reason?: unknown } | undefined; - expect(evtPayload?.reason).toEqual(expect.any(String)); + expect(evtPayload?.reason).toBe("gateway stopping"); }); test( From 94911768116ec19ddae9cfe74019e8197af91225 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:03:49 +0100 Subject: [PATCH 03/12] test: tighten provider rewrite assertions --- ...config.uses-first-github-copilot-profile-env-tokens.test.ts | 3 ++- .../pi-embedded-runner/context-engine-maintenance.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts index bbedd794d31..609d720794e 100644 --- a/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts +++ b/src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts @@ -271,6 +271,7 @@ function expectCopilotProviderFromPlan( plan.action === "write" ? (JSON.parse(plan.contents) as { providers?: Record }) : {}; - expect(parsed.providers?.["github-copilot"]).toEqual(expect.any(Object)); + expect(parsed.providers?.["github-copilot"]).toBeDefined(); + expect(parsed.providers?.["github-copilot"]).not.toBeNull(); return expect(parsed.providers?.["github-copilot"]); } diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts index 079c387e562..62b04f772c7 100644 --- a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts +++ b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts @@ -201,7 +201,8 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => { { entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } }, ], }); - expect(rewritePromise).toEqual(expect.any(Promise)); + expect(rewritePromise).toBeDefined(); + expect(rewritePromise?.then).toBeTypeOf("function"); await flushAsyncWork(); expect(rewriteTranscriptEntriesInSessionFileMock).not.toHaveBeenCalled(); From eecef7e10cd94ef3b3ed7d4d60a9cd50f62ddd2e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:05:29 +0100 Subject: [PATCH 04/12] test: tighten storage doctor assertions --- ...r.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts | 3 ++- ui/src/ui/storage.node.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts index 71369a59672..50d8b4331a6 100644 --- a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts +++ b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts @@ -174,7 +174,8 @@ describe("doctor command", () => { throw new Error("Expected doctor to write migrated auth profiles"); } const profiles = (written.auth as { profiles: Record }).profiles; - expect(profiles["anthropic:me@example.com"]).toEqual(expect.any(Object)); + expect(profiles).toHaveProperty("anthropic:me@example.com"); + expect(profiles["anthropic:me@example.com"]).not.toBeNull(); expect(profiles["anthropic:default"]).toBeUndefined(); }, 30_000); }); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index e8f39474a9d..3cc9979433e 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -571,7 +571,8 @@ describe("loadSettings default gateway URL derivation", () => { const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}"); - expect(persisted.sessionsByGateway).toEqual(expect.any(Object)); + expect(persisted.sessionsByGateway).toBeTypeOf("object"); + expect(persisted.sessionsByGateway).not.toBeNull(); const scopes = Object.keys(persisted.sessionsByGateway); expect(scopes).toHaveLength(10); // oldest stale entries should be evicted From ff860dcf6eb2c4ee4ebe6d1db77be29003a89baa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:06:59 +0100 Subject: [PATCH 05/12] test: tighten slack slash session key --- extensions/slack/src/monitor/slash.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index e4e02bf4d10..b2dbc7d2f6d 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -1226,7 +1226,8 @@ describe("slack slash command session metadata", () => { }; expect(call.ctx?.OriginatingChannel).toBe("slack"); expect(call.ctx?.GroupSpace).toBe("T1"); - expect(call.sessionKey).toEqual(expect.any(String)); + expect(call.sessionKey).toBeTypeOf("string"); + expect(call.sessionKey).not.toBe(""); }); it("awaits session metadata persistence before dispatch", async () => { From dce9261415f79e9e4bff2b3d83ee438d002ad5ad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:08:47 +0100 Subject: [PATCH 06/12] test: tighten e2e helper assertions --- src/gateway/android-node.capabilities.live.test.ts | 3 ++- src/gateway/gateway-cli-backend.live-helpers.test.ts | 3 ++- test/cli-json-stdout.e2e.test.ts | 4 +++- test/scripts/parallels-smoke-model.test.ts | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index fdfe045538c..977f8660396 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -47,7 +47,8 @@ function asRecord(value: unknown): Record { } function expectRecord(value: unknown, label: string): Record { - expect(value, label).toEqual(expect.any(Object)); + expect(value, label).toBeTypeOf("object"); + expect(value, label).not.toBeNull(); expect(Array.isArray(value), label).toBe(false); return value as Record; } diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 7faf0e5a85b..0a37a19a00e 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -88,7 +88,8 @@ describe("gateway cli backend live helpers", () => { token: "gateway-token", }); - expect(client).toEqual(expect.any(Object)); + expect(client).toBeTypeOf("object"); + expect(client).not.toBeNull(); expect(gatewayClientState.lastOptions).toMatchObject({ url: "ws://127.0.0.1:18789", token: "gateway-token", diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index d12f239feac..6280f755cf8 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -34,7 +34,9 @@ describe("cli json stdout contract", () => { const stdout = result.stdout.trim(); expect(stdout.length).toBeGreaterThan(0); const parsed = JSON.parse(stdout) as unknown; - expect(parsed).toEqual(expect.any(Object)); + expect(parsed).toBeTypeOf("object"); + expect(parsed).not.toBeNull(); + expect(Array.isArray(parsed)).toBe(false); expect(stdout).not.toContain("Doctor warnings"); expect(stdout).not.toContain("Doctor changes"); expect(stdout).not.toContain("Config invalid"); diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index c2cea49a4dc..eb4c8bc821e 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -481,7 +481,7 @@ console.log(JSON.stringify(result)); ) as { status: number; stdout: string }; expect(result.status).toBe(124); - expect(result.stdout).toEqual(expect.any(String)); + expect(result.stdout).toBeTypeOf("string"); }); it("runs the Windows agent turn through the detached done-file runner", () => { From 9da2f7cf812cf8ad232fbaedf2edd96994dfa953 Mon Sep 17 00:00:00 2001 From: Statxc Date: Fri, 8 May 2026 09:11:17 -0500 Subject: [PATCH 07/12] fix(gateway): reset webchat /new in place when dmScope is main (#77434) (#71170) Merged via squash. Prepared head SHA: 96a9a83eaccc615466c92039d2b43e15057f309a Co-authored-by: statxc <181730535+statxc@users.noreply.github.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark --- CHANGELOG.md | 1 + docs/tools/slash-commands.md | 2 +- docs/web/control-ui.md | 2 +- src/gateway/server-methods/sessions.ts | 41 ++++++++++++ .../server.sessions.reset-hooks.test.ts | 64 ++++++++++++++++++- 5 files changed, 107 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b10de4941..b4c94d7c930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -653,6 +653,7 @@ Docs: https://docs.openclaw.ai - Agents/current-time: split UTC into a separate `Reference UTC:` prompt line so local `Current time:` stays anchored to the user's timezone. (#42654) Thanks @chencheng-li. - Agents/reasoning: keep embedded reasoning deltas raw for correct same-line streaming while preserving formatted Telegram, Feishu, Discord, and heartbeat delivery at the channel edge. (#78397) Thanks @medns. - Agents/failover: rotate auth profiles before deferred cooldown marking on rate-limit failures, so file-lock contention cannot stall profile failover. Fixes #57281. (#57283) Thanks @jeremyknows. +- Gateway/sessions: when `session.dmScope: "main"` is configured, route a bare webchat `/new` against the agent's main session (`sessions.create` with `emitCommandHooks=true`) to an in-place reset instead of creating a parallel `dashboard:` child, matching `/new` behavior on Telegram/Discord. Fixes #77434. (#71170) Thanks @statxc. ## 2026.5.3-1 diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 07735a3a15b..f0a32073f1e 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -125,7 +125,7 @@ Current source-of-truth: - `/new [model]` starts a new session; `/reset` is the reset alias. - - Control UI intercepts typed `/new` to create and switch to a fresh dashboard session; typed `/reset` still runs the Gateway's in-place reset. + - Control UI intercepts typed `/new` to create and switch to a fresh dashboard session, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; in that case `/new` resets the main session in place. Typed `/reset` still runs the Gateway's in-place reset. - `/reset soft [message]` keeps the current transcript, drops reused CLI backend session ids, and reruns startup/system-prompt loading in-place. - `/compact [instructions]` compacts the session context. See [Compaction](/concepts/compaction). - `/stop` aborts the current run. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 8a9edd50643..ce014cb893b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -165,7 +165,7 @@ Imported themes are stored only in the current browser profile. They are not wri - Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed. - The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options. - If you send a message while a model picker change for the same session is still saving, the composer waits for that session patch before calling `chat.send` so the send uses the selected model. - - Typing `/new` in the Control UI creates and switches to the same fresh dashboard session as New Chat. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session. + - Typing `/new` in the Control UI creates and switches to the same fresh dashboard session as New Chat, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; in that case it resets the main session in place. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session. - The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker. Otherwise the picker shows explicit `models.providers.*.models` entries plus providers with usable auth. The full catalog stays available through the debug `models.list` RPC with `view: "all"`. - When fresh Gateway session usage reports include current context tokens, the chat composer area shows a compact context usage indicator. It switches to warning styling at high context pressure and, at recommended compaction levels, shows a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again. diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index dd3de12dff0..0b29745097c 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -26,6 +26,7 @@ import { type SessionEntry, updateSessionStore, } from "../../config/sessions.js"; +import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createInternalHookEvent, @@ -1023,6 +1024,46 @@ export const sessionsHandlers: GatewayRequestHandlers = { } canonicalParentSessionKey = parent.canonicalKey; } + if ( + canonicalParentSessionKey && + p.emitCommandHooks === true && + !requestedKey && + !resolveOptionalInitialSessionMessage(p) && + cfg.session?.dmScope === "main" + ) { + const parentAgentId = normalizeAgentId( + resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? resolveDefaultAgentId(cfg), + ); + const parentMainKey = resolveAgentMainSessionKey({ cfg, agentId: parentAgentId }); + if (canonicalParentSessionKey === parentMainKey) { + const { performGatewaySessionReset } = await loadSessionsRuntimeModule(); + const resetResult = await performGatewaySessionReset({ + key: canonicalParentSessionKey, + reason: "new", + commandSource: "webchat", + }); + if (!resetResult.ok) { + respond(false, undefined, resetResult.error); + return; + } + respond( + true, + { + ok: true, + key: resetResult.key, + sessionId: resetResult.entry.sessionId, + entry: resetResult.entry, + runStarted: false, + }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: resetResult.key, + reason: "new", + }); + return; + } + } if (canonicalParentSessionKey && p.emitCommandHooks === true) { const { entry: parentEntry } = loadSessionEntry(canonicalParentSessionKey); const parentAgentId = normalizeAgentId( diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index f0efa97cda8..1557718667e 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; -import { embeddedRunMock, writeSessionStore } from "./test-helpers.js"; +import { embeddedRunMock, testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, bootstrapCacheMocks, @@ -410,6 +410,68 @@ test("sessions.create with emitCommandHooks=true emits reset lifecycle hooks aga ); }); +test("sessions.create with emitCommandHooks=true resets parent in place when session.dmScope is 'main' (#77434)", async () => { + const { dir } = await createSessionStoreDir(); + const transcriptPath = path.join(dir, "sess-parent-dms.jsonl"); + await fs.writeFile( + transcriptPath, + `${JSON.stringify({ + type: "message", + id: "m1", + message: { role: "user", content: "hello before /new" }, + })}\n`, + "utf-8", + ); + + testState.sessionConfig = { dmScope: "main" }; + try { + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-parent-dms", + sessionFile: transcriptPath, + updatedAt: Date.now(), + }, + }, + }); + + const result = await directSessionReq<{ + ok: boolean; + key: string; + sessionId: string; + runStarted: boolean; + }>("sessions.create", { + parentSessionKey: "main", + emitCommandHooks: true, + }); + expect(result.ok).toBe(true); + // Reset-in-place: response key matches the parent main key, NOT a dashboard child. + expect(result.payload?.key).toBe("agent:main:main"); + expect(result.payload?.runStarted).toBe(false); + expect(result.payload?.sessionId).not.toBe("sess-parent-dms"); + + expect(sessionLifecycleHookMocks.runSessionEnd).toHaveBeenCalledTimes(1); + expect(sessionLifecycleHookMocks.runSessionStart).toHaveBeenCalledTimes(1); + const [endEvent] = ( + sessionLifecycleHookMocks.runSessionEnd.mock.calls as unknown as Array<[unknown, unknown]> + )[0] ?? [undefined, undefined]; + const [startEvent] = ( + sessionLifecycleHookMocks.runSessionStart.mock.calls as unknown as Array<[unknown, unknown]> + )[0] ?? [undefined, undefined]; + expect(endEvent).toMatchObject({ + sessionId: "sess-parent-dms", + sessionKey: "agent:main:main", + reason: "new", + }); + expect(startEvent).toMatchObject({ + sessionKey: "agent:main:main", + resumedFrom: "sess-parent-dms", + }); + } finally { + testState.sessionConfig = undefined; + } +}); + test("sessions.create without emitCommandHooks does not fire command:new hook (#76957)", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-parent2", "hello from parent 2"); From f6476140d25ffcbd7512dbed688fa0a7166fe4e8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:11:17 +0100 Subject: [PATCH 08/12] test: tighten live provider assertions --- .../browser/src/browser/pw-session.browserless.live.test.ts | 3 ++- src/agents/pi-embedded-runner-extraparams.live.test.ts | 4 ++-- src/agents/xai.live.test.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/extensions/browser/src/browser/pw-session.browserless.live.test.ts b/extensions/browser/src/browser/pw-session.browserless.live.test.ts index abd2d71bf73..19030387c1f 100644 --- a/extensions/browser/src/browser/pw-session.browserless.live.test.ts +++ b/extensions/browser/src/browser/pw-session.browserless.live.test.ts @@ -18,7 +18,8 @@ describeLive("browser (live): remote CDP tab persistence", () => { await pw.closePlaywrightBrowserConnection().catch(() => {}); const created = await pw.createPageViaPlaywright({ cdpUrl: CDP_URL, url: "about:blank" }); - expect(created.targetId).toEqual(expect.any(String)); + expect(created.targetId).toBeTypeOf("string"); + expect(created.targetId).not.toBe(""); try { await waitFor( async () => { diff --git a/src/agents/pi-embedded-runner-extraparams.live.test.ts b/src/agents/pi-embedded-runner-extraparams.live.test.ts index 6cd1362d42f..18971aa50d5 100644 --- a/src/agents/pi-embedded-runner-extraparams.live.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.live.test.ts @@ -59,8 +59,8 @@ describeLive("pi embedded extra params (live)", () => { } } - expect(stopReason).toEqual(expect.any(String)); - expect(outputTokens).toEqual(expect.any(Number)); + expect(stopReason).toBeTypeOf("string"); + expect(outputTokens).toBeTypeOf("number"); // Should respect maxTokens from config (16) — allow a small buffer for provider rounding. expect(outputTokens ?? 0).toBeLessThanOrEqual(20); }, 30_000); diff --git a/src/agents/xai.live.test.ts b/src/agents/xai.live.test.ts index 2201ccbdeee..0eb973b196f 100644 --- a/src/agents/xai.live.test.ts +++ b/src/agents/xai.live.test.ts @@ -119,7 +119,7 @@ describeLive("xai live", () => { const doneMessage = await collectDoneMessage( stream as AsyncIterable<{ type: string; message?: AssistantLikeMessage }>, ); - expect(doneMessage.content).toEqual(expect.any(Array)); + expect(Array.isArray(doneMessage.content)).toBe(true); const payload = requireLiveValue(capturedPayload, "captured xAI payload"); if ("tool_stream" in payload) { expect(payload.tool_stream).toBe(true); From 60068c52b04eced4ffae601460607f405526b310 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 8 May 2026 15:17:48 +0100 Subject: [PATCH 09/12] test: run json stdout e2e from source --- test/cli-json-stdout.e2e.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index 6280f755cf8..61d5905028a 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -23,10 +23,10 @@ describe("cli json stdout contract", () => { delete env.OPENCLAW_CONFIG_PATH; delete env.VITEST; - const entry = path.resolve(process.cwd(), "openclaw.mjs"); + const entry = path.resolve(process.cwd(), "src/entry.ts"); const result = spawnSync( process.execPath, - [entry, "update", "status", "--json", "--timeout", "1"], + ["--import", "tsx", entry, "update", "status", "--json", "--timeout", "1"], { cwd: process.cwd(), env, encoding: "utf8" }, ); From d0ea4056622f0d830c6699ba61ebd1e7dc061abd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:17:51 +0100 Subject: [PATCH 10/12] test: tighten object shape assertions --- src/agents/runtime-plan/build.test.ts | 2 +- src/commands/tasks.test.ts | 15 +++++++++++---- src/cron/service.read-ops-nonblocking.test.ts | 10 ++++++---- src/plugin-sdk/provider-stream.test.ts | 8 ++++++-- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/agents/runtime-plan/build.test.ts b/src/agents/runtime-plan/build.test.ts index 628e8b860ae..0a4b8eee15b 100644 --- a/src/agents/runtime-plan/build.test.ts +++ b/src/agents/runtime-plan/build.test.ts @@ -156,7 +156,7 @@ describe("AgentRuntimePlan", () => { expect(normalized).toHaveLength(1); expect(normalized[0]?.name).toBe("ping"); - expect(normalized[0]?.parameters).toBeTypeOf("object"); + expect(normalized[0]?.parameters).toStrictEqual({}); }); it("does not forward OpenAI API-key profiles into the Codex harness auth slot", () => { diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts index 4b99ed596ce..5e7039d3c1a 100644 --- a/src/commands/tasks.test.ts +++ b/src/commands/tasks.test.ts @@ -20,6 +20,15 @@ function createRuntime(): RuntimeEnv { } as unknown as RuntimeEnv; } +const zeroTaskAuditCounts = { + delivery_failed: 0, + inconsistent_timestamps: 0, + lost: 0, + missing_cleanup: 0, + stale_queued: 0, + stale_running: 0, +}; + async function withTaskCommandStateDir(run: () => Promise): Promise { await withOpenClawTestState( { layout: "state-only", prefix: "openclaw-tasks-command-" }, @@ -150,11 +159,9 @@ describe("tasks commands", () => { expect(payload.mode).toBe("preview"); expect(payload.maintenance.taskFlows.pruned).toBe(1); - expect(payload.auditBefore.byCode).toBeTypeOf("object"); - expect(Array.isArray(payload.auditBefore.byCode)).toBe(false); + expect(payload.auditBefore.byCode).toStrictEqual(zeroTaskAuditCounts); expect(payload.auditBefore.taskFlows.byCode.stale_running).toBe(0); - expect(payload.auditAfter.byCode).toBeTypeOf("object"); - expect(Array.isArray(payload.auditAfter.byCode)).toBe(false); + expect(payload.auditAfter.byCode).toStrictEqual(zeroTaskAuditCounts); expect(payload.auditAfter.taskFlows.byCode.stale_running).toBe(0); }); }); diff --git a/src/cron/service.read-ops-nonblocking.test.ts b/src/cron/service.read-ops-nonblocking.test.ts index 311dffb995c..72d5c74337a 100644 --- a/src/cron/service.read-ops-nonblocking.test.ts +++ b/src/cron/service.read-ops-nonblocking.test.ts @@ -128,8 +128,10 @@ describe("CronService read ops while job is running", () => { await isolatedRun.runStarted; expect(isolatedRun.runIsolatedAgentJob).toHaveBeenCalledTimes(1); - await expect(cron.list({ includeDisabled: true })).resolves.toBeTypeOf("object"); - await expect(cron.status()).resolves.toBeTypeOf("object"); + await expect(cron.list({ includeDisabled: true })).resolves.toHaveLength(1); + await expect(cron.status()).resolves.toEqual( + expect.objectContaining({ enabled: true, storePath: store.storePath }), + ); const running = await cron.list({ includeDisabled: true }); expect(running[0]?.state.runningAtMs).toBeTypeOf("number"); @@ -197,7 +199,7 @@ describe("CronService read ops while job is running", () => { await expect( withTimeout(cron.list({ includeDisabled: true }), 300, "cron.list during cron.run"), - ).resolves.toBeTypeOf("object"); + ).resolves.toHaveLength(1); await expect(withTimeout(cron.status(), 300, "cron.status during cron.run")).resolves.toEqual( expect.objectContaining({ enabled: true, storePath: store.storePath }), ); @@ -258,7 +260,7 @@ describe("CronService read ops while job is running", () => { await expect( withTimeout(cron.list({ includeDisabled: true }), 300, "cron.list during startup"), - ).resolves.toBeTypeOf("object"); + ).resolves.toHaveLength(1); await expect(withTimeout(cron.status(), 300, "cron.status during startup")).resolves.toEqual( expect.objectContaining({ enabled: true, storePath: store.storePath }), ); diff --git a/src/plugin-sdk/provider-stream.test.ts b/src/plugin-sdk/provider-stream.test.ts index 05c32338af4..34c2e1a0ec2 100644 --- a/src/plugin-sdk/provider-stream.test.ts +++ b/src/plugin-sdk/provider-stream.test.ts @@ -1,5 +1,6 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { describe, expect, it } from "vitest"; +import { VERSION } from "../version.js"; import { composeProviderStreamWrappers as composeProviderStreamWrappersShared, createMoonshotThinkingWrapper as createMoonshotThinkingWrapperShared, @@ -239,8 +240,11 @@ describe("buildProviderStreamFamilyHooks", () => { config: { thinkingConfig: { thinkingBudget: -1 } }, service_tier: "flex", }); - expect(capturedHeaders).toBeTypeOf("object"); - expect(capturedHeaders).not.toBeNull(); + expect(capturedHeaders).toEqual({ + "User-Agent": `openclaw/${VERSION}`, + originator: "openclaw", + version: VERSION, + }); const openRouterHooks = OPENROUTER_THINKING_STREAM_HOOKS; void requireStreamFn( From b7033369a66b975f1bccd34eca5f6e7ca8a99fe7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:21:00 +0100 Subject: [PATCH 11/12] test: tighten non-live object guards --- extensions/memory-wiki/src/tool.test.ts | 3 ++- src/gateway/server.talk-config.test.ts | 7 +++++-- src/infra/net/fetch-guard.ssrf.test.ts | 3 +-- test/cli-json-stdout.e2e.test.ts | 7 ++++++- ui/src/ui/storage.node.test.ts | 11 +++++++---- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/extensions/memory-wiki/src/tool.test.ts b/extensions/memory-wiki/src/tool.test.ts index e6469b964d9..9fd0acf780e 100644 --- a/extensions/memory-wiki/src/tool.test.ts +++ b/extensions/memory-wiki/src/tool.test.ts @@ -3,8 +3,9 @@ import type { ResolvedMemoryWikiConfig } from "./config.js"; import { createWikiApplyTool } from "./tool.js"; function asSchemaObject(value: unknown): Record { - expect(value).toBeTypeOf("object"); + expect(typeof value).toBe("object"); expect(value).not.toBeNull(); + expect(Array.isArray(value)).toBe(false); return value as Record; } diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index f85342f74c7..dcae34484ec 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -383,8 +383,11 @@ describe("gateway talk.config", () => { // the UI keeps the SecretRef context, but every field becomes the // sentinel so no credential material leaks to read-scope callers. const redactedApiKey = talk?.providers?.[GENERIC_TALK_PROVIDER_ID]?.apiKey; - expect(redactedApiKey).toBeTypeOf("object"); - expect((redactedApiKey as SecretRef).id).toBe("__OPENCLAW_REDACTED__"); + expect(redactedApiKey).toEqual({ + id: "__OPENCLAW_REDACTED__", + provider: "__OPENCLAW_REDACTED__", + source: "__OPENCLAW_REDACTED__", + }); expect(talk?.resolved?.config?.apiKey).toEqual(redactedApiKey); }); diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 1246142abf2..684d0ab43fe 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -74,8 +74,7 @@ function getDispatcherClassName(value: unknown): string | null { } function expectDispatcherAttached(value: unknown): void { - expect(value).toBeTypeOf("object"); - expect(value).not.toBeNull(); + expect(getDispatcherClassName(value)).toMatch(/^(Agent|Mock)$/u); } function getSecondRequestHeaders(fetchImpl: ReturnType): Headers { diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index 61d5905028a..f47d3678d16 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -34,9 +34,14 @@ describe("cli json stdout contract", () => { const stdout = result.stdout.trim(); expect(stdout.length).toBeGreaterThan(0); const parsed = JSON.parse(stdout) as unknown; - expect(parsed).toBeTypeOf("object"); + expect(typeof parsed).toBe("object"); expect(parsed).not.toBeNull(); expect(Array.isArray(parsed)).toBe(false); + expect(Object.keys(parsed as Record).sort()).toEqual([ + "availability", + "channel", + "update", + ]); expect(stdout).not.toContain("Doctor warnings"); expect(stdout).not.toContain("Doctor changes"); expect(stdout).not.toContain("Config invalid"); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 3cc9979433e..31c8a4a48b3 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -571,9 +571,12 @@ describe("loadSettings default gateway URL derivation", () => { const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}"); - expect(persisted.sessionsByGateway).toBeTypeOf("object"); - expect(persisted.sessionsByGateway).not.toBeNull(); - const scopes = Object.keys(persisted.sessionsByGateway); + const sessionsByGateway = persisted.sessionsByGateway as unknown; + expect(typeof sessionsByGateway).toBe("object"); + expect(sessionsByGateway).not.toBeNull(); + expect(Array.isArray(sessionsByGateway)).toBe(false); + const scopedSessions = sessionsByGateway as Record; + const scopes = Object.keys(scopedSessions); expect(scopes).toHaveLength(10); // oldest stale entries should be evicted expect(scopes).not.toContain("wss://stale-0.example:8443"); @@ -581,7 +584,7 @@ describe("loadSettings default gateway URL derivation", () => { // newest stale entries and the current gateway should be retained expect(scopes).toContain("wss://stale-10.example:8443"); expect(scopes).toContain("wss://gateway.example:8443"); - expect(persisted.sessionsByGateway["wss://gateway.example:8443"]).toEqual({ + expect(scopedSessions["wss://gateway.example:8443"]).toEqual({ sessionKey: "agent:current:main", lastActiveSessionKey: "agent:current:main", }); From 7c31a9aafc1cd07dc83463aea45db0d36fbd6098 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 15:23:09 +0100 Subject: [PATCH 12/12] test: clear object shape matcher scan --- src/cli/config-cli.test.ts | 6 ++++-- src/docs/clawhub-plugin-docs.test.ts | 3 ++- src/gateway/android-node.capabilities.live.test.ts | 2 +- src/gateway/gateway-cli-backend.live-helpers.test.ts | 4 +++- src/plugins/bundled-plugin-metadata.test.ts | 3 ++- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 3500d7cfb11..6b9034ef28e 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -673,8 +673,10 @@ describe("config cli", () => { properties?: Record; }; expect(payload.properties?.$schema).toEqual({ type: "string" }); - expect(payload.properties?.channels).toBeTypeOf("object"); - expect(payload.properties?.channels).not.toBeNull(); + expect(payload.properties?.channels).toMatchObject({ + type: "object", + properties: { telegram: { type: "object" } }, + }); expect(payload.properties?.plugins).toBeUndefined(); expect(mockError).not.toHaveBeenCalled(); }); diff --git a/src/docs/clawhub-plugin-docs.test.ts b/src/docs/clawhub-plugin-docs.test.ts index 968b6beca17..3fd19fa4640 100644 --- a/src/docs/clawhub-plugin-docs.test.ts +++ b/src/docs/clawhub-plugin-docs.test.ts @@ -42,8 +42,9 @@ describe("ClawHub plugin docs", () => { expect(validateExternalCodePluginPackageJson(packageJson).issues).toEqual([]); expect(typeof pluginManifest.id).toBe("string"); - expect(pluginManifest.configSchema).toBeTypeOf("object"); + expect(typeof pluginManifest.configSchema).toBe("object"); expect(pluginManifest.configSchema).not.toBeNull(); + expect(Array.isArray(pluginManifest.configSchema)).toBe(false); }); it("does not tell plugin authors to use bare clawhub publish", async () => { diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index 977f8660396..bd295aa97d8 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -47,7 +47,7 @@ function asRecord(value: unknown): Record { } function expectRecord(value: unknown, label: string): Record { - expect(value, label).toBeTypeOf("object"); + expect(typeof value, label).toBe("object"); expect(value, label).not.toBeNull(); expect(Array.isArray(value), label).toBe(false); return value as Record; diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 0a37a19a00e..5a0eb4e88bb 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -88,8 +88,10 @@ describe("gateway cli backend live helpers", () => { token: "gateway-token", }); - expect(client).toBeTypeOf("object"); + expect(typeof client).toBe("object"); expect(client).not.toBeNull(); + expect(typeof (client as { start?: unknown }).start).toBe("function"); + expect(typeof (client as { stopAndWait?: unknown }).stopAndWait).toBe("function"); expect(gatewayClientState.lastOptions).toMatchObject({ url: "ws://127.0.0.1:18789", token: "gateway-token", diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 0d1dcf7c07a..8860299ad11 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -454,8 +454,9 @@ describe("bundled plugin metadata", () => { it("keeps config schemas on all bundled plugin manifests", () => { for (const entry of listRepoBundledPluginMetadata()) { - expect(entry.manifest.configSchema).toBeTypeOf("object"); + expect(typeof entry.manifest.configSchema).toBe("object"); expect(entry.manifest.configSchema).not.toBeNull(); + expect(Array.isArray(entry.manifest.configSchema)).toBe(false); } });