Merge origin/main into nix-store plugin hardlinks

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
joshp123
2026-05-08 22:24:04 +08:00
30 changed files with 187 additions and 43 deletions

View File

@@ -654,6 +654,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

View File

@@ -125,7 +125,7 @@ Current source-of-truth:
<AccordionGroup>
<Accordion title="Sessions and runs">
- `/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.

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -3,8 +3,9 @@ import type { ResolvedMemoryWikiConfig } from "./config.js";
import { createWikiApplyTool } from "./tool.js";
function asSchemaObject(value: unknown): Record<string, unknown> {
expect(value).toBeTypeOf("object");
expect(typeof value).toBe("object");
expect(value).not.toBeNull();
expect(Array.isArray(value)).toBe(false);
return value as Record<string, unknown>;
}

View File

@@ -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 () => {

View File

@@ -271,6 +271,7 @@ function expectCopilotProviderFromPlan(
plan.action === "write"
? (JSON.parse(plan.contents) as { providers?: Record<string, unknown> })
: {};
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"]);
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -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", () => {

View File

@@ -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);

View File

@@ -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", () => {

View File

@@ -673,8 +673,10 @@ describe("config cli", () => {
properties?: Record<string, unknown>;
};
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();
});

View File

@@ -174,7 +174,8 @@ describe("doctor command", () => {
throw new Error("Expected doctor to write migrated auth profiles");
}
const profiles = (written.auth as { profiles: Record<string, unknown> }).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);
});

View File

@@ -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<void>): Promise<void> {
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);
});
});

View File

@@ -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 }),
);

View File

@@ -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 () => {

View File

@@ -47,7 +47,8 @@ function asRecord(value: unknown): Record<string, unknown> {
}
function expectRecord(value: unknown, label: string): Record<string, unknown> {
expect(value, label).toEqual(expect.any(Object));
expect(typeof value, label).toBe("object");
expect(value, label).not.toBeNull();
expect(Array.isArray(value), label).toBe(false);
return value as Record<string, unknown>;
}

View File

@@ -88,7 +88,10 @@ describe("gateway cli backend live helpers", () => {
token: "gateway-token",
});
expect(client).toEqual(expect.any(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",

View File

@@ -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(

View File

@@ -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);

View File

@@ -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(

View File

@@ -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");

View File

@@ -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);
});

View File

@@ -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<typeof vi.fn>): Headers {

View File

@@ -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(

View File

@@ -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);
}
});

View File

@@ -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" },
);
@@ -34,7 +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).toEqual(expect.any(Object));
expect(typeof parsed).toBe("object");
expect(parsed).not.toBeNull();
expect(Array.isArray(parsed)).toBe(false);
expect(Object.keys(parsed as Record<string, unknown>).sort()).toEqual([
"availability",
"channel",
"update",
]);
expect(stdout).not.toContain("Doctor warnings");
expect(stdout).not.toContain("Doctor changes");
expect(stdout).not.toContain("Config invalid");

View File

@@ -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", () => {

View File

@@ -571,8 +571,12 @@ describe("loadSettings default gateway URL derivation", () => {
const persisted = JSON.parse(localStorage.getItem(scopedKey) ?? "{}");
expect(persisted.sessionsByGateway).toEqual(expect.any(Object));
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<string, unknown>;
const scopes = Object.keys(scopedSessions);
expect(scopes).toHaveLength(10);
// oldest stale entries should be evicted
expect(scopes).not.toContain("wss://stale-0.example:8443");
@@ -580,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",
});