Compare commits

..

1 Commits

Author SHA1 Message Date
Tak Hoffman
36a7c93389 feat(workspace): add safe workspace reset command 2026-04-17 16:48:52 -05:00
67 changed files with 2504 additions and 2748 deletions

View File

@@ -36,7 +36,6 @@ jobs:
run_windows: ${{ steps.manifest.outputs.run_windows }}
has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
changed_paths_json: ${{ steps.manifest.outputs.changed_paths_json }}
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }}
@@ -109,16 +108,8 @@ jobs:
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import {
listChangedExtensionIds,
listChangedPathsForScope,
} from "./scripts/lib/changed-extensions.mjs";
import { listChangedExtensionIds } from "./scripts/lib/changed-extensions.mjs";
const changedPaths = listChangedPathsForScope({
base: process.env.BASE_SHA,
head: "HEAD",
fallbackBaseRef: process.env.BASE_REF,
});
const extensionIds = listChangedExtensionIds({
base: process.env.BASE_SHA,
head: "HEAD",
@@ -126,11 +117,9 @@ jobs:
unavailableBaseBehavior: "all",
});
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
const changedPathsJson = JSON.stringify(changedPaths);
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
appendFileSync(process.env.GITHUB_OUTPUT, `changed_paths_json=${changedPathsJson}\n`, "utf8");
EOF
- name: Build CI manifest
@@ -146,7 +135,6 @@ jobs:
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_extensions.outputs.changed_paths_json || '[]' }}
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
@@ -215,7 +203,6 @@ jobs:
run_windows: runWindows,
has_changed_extensions: hasChangedExtensions,
changed_extensions_matrix: changedExtensionsMatrix,
changed_paths_json: process.env.OPENCLAW_CI_CHANGED_PATHS_JSON ?? "[]",
run_build_artifacts: runNode,
run_checks_fast: runNode,
checks_fast_core_matrix: createMatrix(
@@ -969,8 +956,6 @@ jobs:
continue-on-error: true
env:
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 4
OPENCLAW_EXTENSION_BOUNDARY_CHANGED_EXTENSIONS_MATRIX: ${{ needs.preflight.outputs.changed_extensions_matrix }}
OPENCLAW_EXTENSION_BOUNDARY_CHANGED_PATHS_JSON: ${{ needs.preflight.outputs.changed_paths_json }}
run: pnpm run test:extensions:package-boundary
- name: Enforce safe external URL opening policy

View File

@@ -1,5 +1,4 @@
import { GoogleAuth, OAuth2Client } from "google-auth-library";
import { fetchWithSsrFGuard } from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
@@ -84,20 +83,13 @@ async function fetchChatCerts(): Promise<Record<string, string>> {
if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) {
return cachedCerts.certs;
}
const { response, release } = await fetchWithSsrFGuard({
url: CHAT_CERTS_URL,
auditContext: "googlechat.auth.certs",
});
try {
if (!response.ok) {
throw new Error(`Failed to fetch Chat certs (${response.status})`);
}
const certs = (await response.json()) as Record<string, string>;
cachedCerts = { fetchedAt: now, certs };
return certs;
} finally {
await release();
const res = await fetch(CHAT_CERTS_URL);
if (!res.ok) {
throw new Error(`Failed to fetch Chat certs (${res.status})`);
}
const certs = (await res.json()) as Record<string, string>;
cachedCerts = { fetchedAt: now, certs };
return certs;
}
export type GoogleChatAudienceType = "app-url" | "project-number";

View File

@@ -14,7 +14,6 @@ const mocks = vi.hoisted(() => ({
response: await fetch(params.url, params.init),
release: async () => {},
})),
verifySignedJwtWithCertsAsync: vi.fn(),
verifyIdToken: vi.fn(),
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
}));
@@ -29,7 +28,6 @@ vi.mock("google-auth-library", () => ({
GoogleAuth: function GoogleAuth() {},
OAuth2Client: class {
verifyIdToken = mocks.verifyIdToken;
verifySignedJwtWithCertsAsync = mocks.verifySignedJwtWithCertsAsync;
},
}));
@@ -295,34 +293,4 @@ describe("verifyGoogleChatRequest", () => {
reason: "unexpected add-on principal: principal-2",
});
});
it("fetches Chat certs through the guarded fetch for project-number tokens", async () => {
const release = vi.fn();
mocks.fetchWithSsrFGuard.mockClear();
mocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: new Response(JSON.stringify({ "kid-1": "cert-body" }), { status: 200 }),
release,
});
mocks.verifySignedJwtWithCertsAsync.mockReset().mockResolvedValue(undefined);
await expect(
verifyGoogleChatRequest({
bearer: "token",
audienceType: "project-number",
audience: "123456789",
}),
).resolves.toEqual({ ok: true });
expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({
url: "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com",
auditContext: "googlechat.auth.certs",
});
expect(mocks.verifySignedJwtWithCertsAsync).toHaveBeenCalledWith(
"token",
{ "kid-1": "cert-body" },
"123456789",
["chat@system.gserviceaccount.com"],
);
expect(release).toHaveBeenCalledOnce();
});
});

View File

@@ -4,8 +4,8 @@ import {
listConfiguredAccountIds,
resolveMergedAccountConfig,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-resolution-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
} from "openclaw/plugin-sdk/account-resolution";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
type MatrixRoomEntries = Record<string, NonNullable<MatrixConfig["groups"]>[string]>;

View File

@@ -1,5 +1,5 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
resolveConfiguredMatrixAccountIds,

View File

@@ -1,18 +1,24 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { LookupFn } from "../../runtime-api.js";
import { installMatrixTestRuntime } from "../test-runtime.js";
import type { CoreConfig } from "../types.js";
import {
backfillMatrixAuthDeviceIdAfterStartup,
resolveMatrixAuth,
setMatrixAuthClientDepsForTest,
} from "./client/config.js";
import * as credentialsReadModule from "./credentials-read.js";
function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn {
return vi.fn(async (_hostname: string, options?: unknown) => {
if (typeof options === "number" || !options || !(options as { all?: boolean }).all) {
return addresses[0];
}
return addresses;
}) as unknown as LookupFn;
}
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
const saveBackfilledMatrixDeviceIdMock = vi.hoisted(() => vi.fn(async () => "saved"));
const touchMatrixCredentialsMock = vi.hoisted(() => vi.fn());
const repairCurrentTokenStorageMetaDeviceIdMock = vi.hoisted(() => vi.fn());
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
vi.mock("./credentials-read.js", () => ({
loadMatrixCredentials: vi.fn(() => null),
@@ -33,10 +39,18 @@ vi.mock("./client/storage.js", async () => {
};
});
vi.mock("./client/config-secret-input.runtime.js", () => ({
resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock,
}));
const {
backfillMatrixAuthDeviceIdAfterStartup,
getMatrixScopedEnvVarNames,
resolveMatrixConfigForAccount,
resolveMatrixAuth,
resolveMatrixAuthContext,
setMatrixAuthClientDepsForTest,
resolveValidatedMatrixHomeserverUrl,
validateMatrixHomeserverUrl,
} = await import("./client/config.js");
let credentialsReadModule: typeof import("./credentials-read.js") | undefined;
const ensureMatrixSdkLoggingConfiguredMock = vi.fn();
const matrixDoRequestMock = vi.fn();
@@ -46,17 +60,721 @@ class MockMatrixClient {
}
}
function requireCredentialsReadModule(): typeof import("./credentials-read.js") {
if (!credentialsReadModule) {
throw new Error("credentials-read test module not initialized");
}
return credentialsReadModule;
}
function resolveDefaultMatrixAuthContext(
cfg: CoreConfig,
env: NodeJS.ProcessEnv = {} as NodeJS.ProcessEnv,
) {
return resolveMatrixAuthContext({ cfg, env });
}
beforeEach(() => {
installMatrixTestRuntime();
});
describe("Matrix auth/config live surfaces", () => {
it("prefers config over env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceName: "CfgDevice",
initialSyncLimit: 5,
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
expect(resolved).toEqual({
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceId: undefined,
deviceName: "CfgDevice",
initialSyncLimit: 5,
encryption: false,
});
});
it("uses env when config is missing", () => {
const cfg = {} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_ID: "ENVDEVICE",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
expect(resolved.homeserver).toBe("https://env.example.org");
expect(resolved.userId).toBe("@env:example.org");
expect(resolved.accessToken).toBe("env-token");
expect(resolved.password).toBe("env-pass");
expect(resolved.deviceId).toBe("ENVDEVICE");
expect(resolved.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
expect(resolved.encryption).toBe(false);
});
it("resolves accessToken SecretRef against the provided env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "env-token",
} as NodeJS.ProcessEnv;
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
expect(resolved.accessToken).toBe("env-token");
});
it("resolves password SecretRef against the provided env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
const env = {
MATRIX_PASSWORD: "env-pass",
} as NodeJS.ProcessEnv;
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
expect(resolved.password).toBe("env-pass");
});
it("resolves account accessToken SecretRef against the provided env", () => {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: { source: "env", provider: "default", id: "MATRIX_OPS_ACCESS_TOKEN" },
},
},
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.accessToken).toBe("ops-token");
});
it("does not resolve account password SecretRefs when scoped token auth is configured", () => {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.example.org",
password: { source: "env", provider: "default", id: "MATRIX_OPS_PASSWORD" },
},
},
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.accessToken).toBe("ops-token");
expect(resolved.password).toBeUndefined();
});
it("keeps unresolved accessToken SecretRef errors when env fallback is missing", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
expect(() => resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv)).toThrow(
/channels\.matrix\.accessToken: unresolved SecretRef "env:default:MATRIX_ACCESS_TOKEN"/i,
);
});
it("does not bypass env provider allowlists during startup fallback", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
accessToken: { source: "env", provider: "matrix-env", id: "MATRIX_ACCESS_TOKEN" },
},
},
secrets: {
providers: {
"matrix-env": {
source: "env",
allowlist: ["OTHER_MATRIX_ACCESS_TOKEN"],
},
},
},
} as CoreConfig;
expect(() =>
resolveDefaultMatrixAuthContext(cfg, {
MATRIX_ACCESS_TOKEN: "env-token",
} as NodeJS.ProcessEnv),
).toThrow(/not allowlisted in secrets\.providers\.matrix-env\.allowlist/i);
});
it("does not throw when accessToken uses a non-env SecretRef", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
accessToken: { source: "file", provider: "matrix-file", id: "value" },
},
},
secrets: {
providers: {
"matrix-file": {
source: "file",
path: "/tmp/matrix-token",
},
},
},
} as CoreConfig;
expect(
resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved.accessToken,
).toBeUndefined();
});
it("uses account-scoped env vars for non-default accounts before global env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://global.example.org",
MATRIX_ACCESS_TOKEN: "global-token",
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
MATRIX_OPS_DEVICE_NAME: "Ops Device",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.homeserver).toBe("https://ops.example.org");
expect(resolved.accessToken).toBe("ops-token");
expect(resolved.deviceName).toBe("Ops Device");
});
it("uses collision-free scoped env var names for normalized account ids", () => {
expect(getMatrixScopedEnvVarNames("ops-prod").accessToken).toBe(
"MATRIX_OPS_X2D_PROD_ACCESS_TOKEN",
);
expect(getMatrixScopedEnvVarNames("ops_prod").accessToken).toBe(
"MATRIX_OPS_X5F_PROD_ACCESS_TOKEN",
);
});
it("prefers channels.matrix.accounts.default over global env for the default account", () => {
const cfg = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
password: "cfg-pass", // pragma: allowlist secret
deviceName: "OpenClaw Gateway Pinguini",
encryption: true,
},
},
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixAuthContext({ cfg, env });
expect(resolved.accountId).toBe("default");
expect(resolved.resolved).toMatchObject({
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
password: "cfg-pass",
deviceName: "OpenClaw Gateway Pinguini",
encryption: true,
});
});
it("ignores typoed defaultAccount values that do not map to a real Matrix account", () => {
const cfg = {
channels: {
matrix: {
defaultAccount: "ops",
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
"default",
);
});
it("requires explicit defaultAccount selection when multiple named Matrix accounts exist", () => {
const cfg = {
channels: {
matrix: {
accounts: {
assistant: {
homeserver: "https://matrix.assistant.example.org",
accessToken: "assistant-token",
},
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(() => resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv })).toThrow(
/channels\.matrix\.defaultAccount.*--account <id>/i,
);
});
it('uses a named "default" account implicitly when multiple Matrix accounts exist', () => {
const cfg = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.default.example.org",
accessToken: "default-token",
},
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
"default",
);
});
it("does not materialize a default account from shared top-level defaults alone", () => {
const cfg = {
channels: {
matrix: {
name: "Shared Defaults",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
});
it("does not materialize a default account from partial top-level auth defaults", () => {
const cfg = {
channels: {
matrix: {
accessToken: "shared-token",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
});
it('uses the injected env-backed "default" Matrix account when implicit selection is available', () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "default-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("default");
});
it("does not materialize a default env account from partial global auth fields", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "shared-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("does not materialize a default account from top-level homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
});
it("does not materialize a default env account from global homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_USER_ID: "@default:example.org",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("keeps implicit selection for env-backed accounts that can use cached credentials", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_USER_ID: "@ops:example.org",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("rejects explicit non-default account ids that are neither configured nor scoped in env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(() =>
resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv, accountId: "typo" }),
).toThrow(/Matrix account "typo" is not configured/i);
});
it("allows explicit non-default account ids backed only by scoped env vars", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env, accountId: "ops" }).accountId).toBe("ops");
});
it("does not inherit the base deviceId for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
accessToken: "base-token",
deviceId: "BASEDEVICE",
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
expect(resolved.deviceId).toBeUndefined();
});
it("does not inherit the base userId for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
userId: "@base:example.org",
accessToken: "base-token",
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
expect(resolved.userId).toBe("");
});
it("does not inherit base or global auth secrets for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
accessToken: "base-token",
password: "base-pass", // pragma: allowlist secret
deviceId: "BASEDEVICE",
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
password: "ops-pass", // pragma: allowlist secret
},
},
},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "global-token",
MATRIX_PASSWORD: "global-pass",
MATRIX_DEVICE_ID: "GLOBALDEVICE",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.accessToken).toBeUndefined();
expect(resolved.password).toBe("ops-pass");
expect(resolved.deviceId).toBeUndefined();
});
it("does not inherit a base password for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
password: "base-pass", // pragma: allowlist secret
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
},
},
},
},
} as CoreConfig;
const env = {
MATRIX_PASSWORD: "global-pass",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.password).toBeUndefined();
});
it("rejects insecure public http Matrix homeservers", () => {
expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow(
"Matrix homeserver must use https:// unless it targets a private or loopback host",
);
expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008");
});
it("accepts internal http homeservers only when private-network access is enabled", () => {
expect(() => validateMatrixHomeserverUrl("http://matrix-synapse:8008")).toThrow(
"Matrix homeserver must use https:// unless it targets a private or loopback host",
);
expect(
validateMatrixHomeserverUrl("http://matrix-synapse:8008", {
allowPrivateNetwork: true,
}),
).toBe("http://matrix-synapse:8008");
});
it("resolves an explicit proxy dispatcher from top-level Matrix config", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
proxy: "http://127.0.0.1:7890",
},
},
} as CoreConfig;
const resolved = resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved;
expect(resolved.dispatcherPolicy).toEqual({
mode: "explicit-proxy",
proxyUrl: "http://127.0.0.1:7890",
});
});
it("prefers account proxy overrides over top-level Matrix proxy config", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "base-token",
proxy: "http://127.0.0.1:7890",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
proxy: "http://127.0.0.1:7891",
},
},
},
},
} as CoreConfig;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
expect(resolved.dispatcherPolicy).toEqual({
mode: "explicit-proxy",
proxyUrl: "http://127.0.0.1:7891",
});
});
it("rejects public http homeservers even when private-network access is enabled", async () => {
await expect(
resolveValidatedMatrixHomeserverUrl("http://matrix.example.org:8008", {
allowPrivateNetwork: true,
lookupFn: createLookupFn([{ address: "93.184.216.34", family: 4 }]),
}),
).rejects.toThrow(
"Matrix homeserver must use https:// unless it targets a private or loopback host",
);
});
it("accepts internal http hostnames when the private-network opt-in is explicit", async () => {
await expect(
resolveValidatedMatrixHomeserverUrl("http://localhost.localdomain:8008", {
dangerouslyAllowPrivateNetwork: true,
lookupFn: createLookupFn([{ address: "127.0.0.1", family: 4 }]),
}),
).resolves.toBe("http://localhost.localdomain:8008");
});
});
describe("resolveMatrixAuth", () => {
beforeAll(async () => {
credentialsReadModule = await import("./credentials-read.js");
});
beforeEach(() => {
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReset();
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReset();
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
const readModule = requireCredentialsReadModule();
vi.mocked(readModule.loadMatrixCredentials).mockReset();
vi.mocked(readModule.loadMatrixCredentials).mockReturnValue(null);
vi.mocked(readModule.credentialsMatchConfig).mockReset();
vi.mocked(readModule.credentialsMatchConfig).mockReturnValue(false);
saveMatrixCredentialsMock.mockReset();
saveBackfilledMatrixDeviceIdMock.mockReset().mockResolvedValue("saved");
touchMatrixCredentialsMock.mockReset();
repairCurrentTokenStorageMetaDeviceIdMock.mockReset().mockReturnValue(true);
resolveConfiguredSecretInputStringMock.mockReset().mockResolvedValue({});
ensureMatrixSdkLoggingConfiguredMock.mockReset();
matrixDoRequestMock.mockReset();
setMatrixAuthClientDepsForTest({
@@ -155,14 +873,14 @@ describe("resolveMatrixAuth", () => {
});
it("uses cached matching credentials when access token is not configured", async () => {
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
@@ -190,14 +908,14 @@ describe("resolveMatrixAuth", () => {
});
it("uses cached matching credentials for env-backed named accounts without fresh auth", async () => {
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
@@ -242,13 +960,13 @@ describe("resolveMatrixAuth", () => {
});
it("falls back to config deviceId when cached credentials are missing it", async () => {
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
@@ -333,8 +1051,8 @@ describe("resolveMatrixAuth", () => {
});
it("uses named-account password auth instead of inheriting the base access token", async () => {
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue(null);
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(false);
matrixDoRequestMock.mockResolvedValue({
access_token: "ops-token",
user_id: "@ops:example.org",
@@ -602,7 +1320,7 @@ describe("resolveMatrixAuth", () => {
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
vi.mocked(requireCredentialsReadModule().loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-new",
@@ -659,51 +1377,52 @@ describe("resolveMatrixAuth", () => {
expect(saveBackfilledMatrixDeviceIdMock).not.toHaveBeenCalled();
});
it("resolves configured accessToken SecretRefs during Matrix auth", async () => {
it("resolves file-backed accessToken SecretRefs during Matrix auth", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "matrix-secret-ref-"));
const secretPath = path.join(tempDir, "token.txt");
await fs.writeFile(secretPath, "file-token\n", "utf8");
await fs.chmod(secretPath, 0o600);
matrixDoRequestMock.mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "resolved-token" });
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: { source: "file", provider: "matrix-file", id: "value" },
},
},
secrets: {
providers: {
"matrix-file": {
source: "file",
path: "/tmp/matrix-token.txt",
mode: "singleValue",
try {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: { source: "file", provider: "matrix-file", id: "value" },
},
},
},
} as CoreConfig;
secrets: {
providers: {
"matrix-file": {
source: "file",
path: secretPath,
mode: "singleValue",
},
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(resolveConfiguredSecretInputStringMock).toHaveBeenCalledWith(
expect.objectContaining({
config: cfg,
value: { source: "file", provider: "matrix-file", id: "value" },
path: "channels.matrix.accessToken",
}),
);
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(auth).toMatchObject({
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "resolved-token",
deviceId: "DEVICE123",
});
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(auth).toMatchObject({
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "file-token",
deviceId: "DEVICE123",
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("does not resolve inactive password SecretRefs when scoped token auth wins", async () => {
@@ -752,13 +1471,13 @@ describe("resolveMatrixAuth", () => {
});
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {

View File

@@ -1,713 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LookupFn } from "../../runtime-api.js";
import { installMatrixTestRuntime } from "../../test-runtime.js";
import type { CoreConfig } from "../../types.js";
import {
getMatrixScopedEnvVarNames,
resolveMatrixConfigForAccount,
resolveMatrixAuthContext,
resolveValidatedMatrixHomeserverUrl,
validateMatrixHomeserverUrl,
} from "./config.js";
function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn {
return vi.fn(async (_hostname: string, options?: unknown) => {
if (typeof options === "number" || !options || !(options as { all?: boolean }).all) {
return addresses[0];
}
return addresses;
}) as unknown as LookupFn;
}
function resolveDefaultMatrixAuthContext(
cfg: CoreConfig,
env: NodeJS.ProcessEnv = {} as NodeJS.ProcessEnv,
) {
return resolveMatrixAuthContext({ cfg, env });
}
beforeEach(() => {
installMatrixTestRuntime();
});
describe("Matrix auth/config live surfaces", () => {
it("prefers config over env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceName: "CfgDevice",
initialSyncLimit: 5,
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
expect(resolved).toEqual({
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceId: undefined,
deviceName: "CfgDevice",
initialSyncLimit: 5,
encryption: false,
});
});
it("uses env when config is missing", () => {
const cfg = {} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_ID: "ENVDEVICE",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
expect(resolved.homeserver).toBe("https://env.example.org");
expect(resolved.userId).toBe("@env:example.org");
expect(resolved.accessToken).toBe("env-token");
expect(resolved.password).toBe("env-pass");
expect(resolved.deviceId).toBe("ENVDEVICE");
expect(resolved.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
expect(resolved.encryption).toBe(false);
});
it("resolves accessToken SecretRef against the provided env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "env-token",
} as NodeJS.ProcessEnv;
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
expect(resolved.accessToken).toBe("env-token");
});
it("resolves password SecretRef against the provided env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
const env = {
MATRIX_PASSWORD: "env-pass",
} as NodeJS.ProcessEnv;
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
expect(resolved.password).toBe("env-pass");
});
it("resolves account accessToken SecretRef against the provided env", () => {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: { source: "env", provider: "default", id: "MATRIX_OPS_ACCESS_TOKEN" },
},
},
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.accessToken).toBe("ops-token");
});
it("does not resolve account password SecretRefs when scoped token auth is configured", () => {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.example.org",
password: { source: "env", provider: "default", id: "MATRIX_OPS_PASSWORD" },
},
},
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.accessToken).toBe("ops-token");
expect(resolved.password).toBeUndefined();
});
it("keeps unresolved accessToken SecretRef errors when env fallback is missing", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
},
},
secrets: {
defaults: {
env: "default",
},
},
} as CoreConfig;
expect(() => resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv)).toThrow(
/channels\.matrix\.accessToken: unresolved SecretRef "env:default:MATRIX_ACCESS_TOKEN"/i,
);
});
it("does not bypass env provider allowlists during startup fallback", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
accessToken: { source: "env", provider: "matrix-env", id: "MATRIX_ACCESS_TOKEN" },
},
},
secrets: {
providers: {
"matrix-env": {
source: "env",
allowlist: ["OTHER_MATRIX_ACCESS_TOKEN"],
},
},
},
} as CoreConfig;
expect(() =>
resolveDefaultMatrixAuthContext(cfg, {
MATRIX_ACCESS_TOKEN: "env-token",
} as NodeJS.ProcessEnv),
).toThrow(/not allowlisted in secrets\.providers\.matrix-env\.allowlist/i);
});
it("does not throw when accessToken uses a non-env SecretRef", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://cfg.example.org",
accessToken: { source: "file", provider: "matrix-file", id: "value" },
},
},
secrets: {
providers: {
"matrix-file": {
source: "file",
path: "/tmp/matrix-token",
},
},
},
} as CoreConfig;
expect(
resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved.accessToken,
).toBeUndefined();
});
it("uses account-scoped env vars for non-default accounts before global env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://global.example.org",
MATRIX_ACCESS_TOKEN: "global-token",
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
MATRIX_OPS_DEVICE_NAME: "Ops Device",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.homeserver).toBe("https://ops.example.org");
expect(resolved.accessToken).toBe("ops-token");
expect(resolved.deviceName).toBe("Ops Device");
});
it("uses collision-free scoped env var names for normalized account ids", () => {
expect(getMatrixScopedEnvVarNames("ops-prod").accessToken).toBe(
"MATRIX_OPS_X2D_PROD_ACCESS_TOKEN",
);
expect(getMatrixScopedEnvVarNames("ops_prod").accessToken).toBe(
"MATRIX_OPS_X5F_PROD_ACCESS_TOKEN",
);
});
it("prefers channels.matrix.accounts.default over global env for the default account", () => {
const cfg = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
password: "cfg-pass", // pragma: allowlist secret
deviceName: "OpenClaw Gateway Pinguini",
encryption: true,
},
},
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixAuthContext({ cfg, env });
expect(resolved.accountId).toBe("default");
expect(resolved.resolved).toMatchObject({
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
password: "cfg-pass",
deviceName: "OpenClaw Gateway Pinguini",
encryption: true,
});
});
it("ignores typoed defaultAccount values that do not map to a real Matrix account", () => {
const cfg = {
channels: {
matrix: {
defaultAccount: "ops",
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
"default",
);
});
it("requires explicit defaultAccount selection when multiple named Matrix accounts exist", () => {
const cfg = {
channels: {
matrix: {
accounts: {
assistant: {
homeserver: "https://matrix.assistant.example.org",
accessToken: "assistant-token",
},
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(() => resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv })).toThrow(
/channels\.matrix\.defaultAccount.*--account <id>/i,
);
});
it('uses a named "default" account implicitly when multiple Matrix accounts exist', () => {
const cfg = {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.default.example.org",
accessToken: "default-token",
},
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
"default",
);
});
it("does not materialize a default account from shared top-level defaults alone", () => {
const cfg = {
channels: {
matrix: {
name: "Shared Defaults",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
});
it("does not materialize a default account from partial top-level auth defaults", () => {
const cfg = {
channels: {
matrix: {
accessToken: "shared-token",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
});
it('uses the injected env-backed "default" Matrix account when implicit selection is available', () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "default-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("default");
});
it("does not materialize a default env account from partial global auth fields", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "shared-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("does not materialize a default account from top-level homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
});
it("does not materialize a default env account from global homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_USER_ID: "@default:example.org",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("keeps implicit selection for env-backed accounts that can use cached credentials", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_USER_ID: "@ops:example.org",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("rejects explicit non-default account ids that are neither configured nor scoped in env", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(() =>
resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv, accountId: "typo" }),
).toThrow(/Matrix account "typo" is not configured/i);
});
it("allows explicit non-default account ids backed only by scoped env vars", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveMatrixAuthContext({ cfg, env, accountId: "ops" }).accountId).toBe("ops");
});
it("does not inherit the base deviceId for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
accessToken: "base-token",
deviceId: "BASEDEVICE",
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
expect(resolved.deviceId).toBeUndefined();
});
it("does not inherit the base userId for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
userId: "@base:example.org",
accessToken: "base-token",
accounts: {
ops: {
homeserver: "https://ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
expect(resolved.userId).toBe("");
});
it("does not inherit base or global auth secrets for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
accessToken: "base-token",
password: "base-pass", // pragma: allowlist secret
deviceId: "BASEDEVICE",
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
password: "ops-pass", // pragma: allowlist secret
},
},
},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "global-token",
MATRIX_PASSWORD: "global-pass",
MATRIX_DEVICE_ID: "GLOBALDEVICE",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.accessToken).toBeUndefined();
expect(resolved.password).toBe("ops-pass");
expect(resolved.deviceId).toBeUndefined();
});
it("does not inherit a base password for non-default accounts", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://base.example.org",
password: "base-pass", // pragma: allowlist secret
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
},
},
},
},
} as CoreConfig;
const env = {
MATRIX_PASSWORD: "global-pass",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.password).toBeUndefined();
});
it("rejects insecure public http Matrix homeservers", () => {
expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow(
"Matrix homeserver must use https:// unless it targets a private or loopback host",
);
expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008");
});
it("accepts internal http homeservers only when private-network access is enabled", () => {
expect(() => validateMatrixHomeserverUrl("http://matrix-synapse:8008")).toThrow(
"Matrix homeserver must use https:// unless it targets a private or loopback host",
);
expect(
validateMatrixHomeserverUrl("http://matrix-synapse:8008", {
allowPrivateNetwork: true,
}),
).toBe("http://matrix-synapse:8008");
});
it("resolves an explicit proxy dispatcher from top-level Matrix config", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
proxy: "http://127.0.0.1:7890",
},
},
} as CoreConfig;
const resolved = resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved;
expect(resolved.dispatcherPolicy).toEqual({
mode: "explicit-proxy",
proxyUrl: "http://127.0.0.1:7890",
});
});
it("prefers account proxy overrides over top-level Matrix proxy config", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "base-token",
proxy: "http://127.0.0.1:7890",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
proxy: "http://127.0.0.1:7891",
},
},
},
},
} as CoreConfig;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
expect(resolved.dispatcherPolicy).toEqual({
mode: "explicit-proxy",
proxyUrl: "http://127.0.0.1:7891",
});
});
it("rejects public http homeservers even when private-network access is enabled", async () => {
await expect(
resolveValidatedMatrixHomeserverUrl("http://matrix.example.org:8008", {
allowPrivateNetwork: true,
lookupFn: createLookupFn([{ address: "93.184.216.34", family: 4 }]),
}),
).rejects.toThrow(
"Matrix homeserver must use https:// unless it targets a private or loopback host",
);
});
it("accepts internal http hostnames when the private-network opt-in is explicit", async () => {
await expect(
resolveValidatedMatrixHomeserverUrl("http://localhost.localdomain:8008", {
dangerouslyAllowPrivateNetwork: true,
lookupFn: createLookupFn([{ address: "127.0.0.1", family: 4 }]),
}),
).resolves.toBe("http://localhost.localdomain:8008");
});
});

View File

@@ -3,7 +3,7 @@ import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
import {
coerceSecretRef,
normalizeResolvedSecretInputString,
} from "openclaw/plugin-sdk/secret-input-runtime";
} from "openclaw/plugin-sdk/secret-input";
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
import {
requiresExplicitMatrixDefaultAccount,
@@ -351,15 +351,6 @@ async function resolveConfiguredMatrixAuthSecretInput(params: {
return undefined;
}
const ref = coerceSecretRef(configured.value, params.cfg.secrets?.defaults);
if (!ref) {
return normalizeResolvedSecretInputString({
value: configured.value,
path: configured.path,
defaults: params.cfg.secrets?.defaults,
});
}
const { resolveConfiguredSecretInputString } = await loadMatrixSecretInputDeps();
const resolved = await resolveConfiguredSecretInputString({
config: params.cfg,
@@ -372,9 +363,13 @@ async function resolveConfiguredMatrixAuthSecretInput(params: {
return resolved.value;
}
throw new Error(
resolved.unresolvedRefReason ?? `${configured.path} SecretRef could not be resolved.`,
);
if (coerceSecretRef(configured.value, params.cfg.secrets?.defaults)) {
throw new Error(
resolved.unresolvedRefReason ?? `${configured.path} SecretRef could not be resolved.`,
);
}
return undefined;
}
function readMatrixBaseConfigField(

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveMatrixAccountStorageRoot } from "../../storage-paths.js";
import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js";
import { installMatrixTestRuntime } from "../../test-runtime.js";
import {
claimCurrentTokenStorageState,

View File

@@ -1,5 +1,7 @@
import path from "node:path";
import { z } from "openclaw/plugin-sdk/zod";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { loadRuntimeApiExportTypesViaJiti } from "../../../../../test/helpers/plugins/jiti-runtime-api.ts";
import type { MatrixRoomInfo } from "./room-info.js";
type DirectRoomTrackerOptions = {
@@ -947,3 +949,26 @@ describe("monitorMatrixProvider", () => {
).resolves.toBeUndefined();
});
});
describe("matrix plugin registration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("loads the matrix runtime api through Jiti", () => {
const runtimeApiPath = path.join(process.cwd(), "extensions", "matrix", "runtime-api.ts");
expect(
loadRuntimeApiExportTypesViaJiti({
modulePath: runtimeApiPath,
exportNames: [
"requiresExplicitMatrixDefaultAccount",
"resolveMatrixDefaultOrOnlyAccountId",
],
realPluginSdkSpecifiers: [],
}),
).toEqual({
requiresExplicitMatrixDefaultAccount: "function",
resolveMatrixDefaultOrOnlyAccountId: "function",
});
}, 240_000);
});

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../../../../test/helpers/temp-home.js";
import { resolveMatrixAccountStorageRoot } from "../../storage-paths.js";
import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js";
import type { MatrixRoomKeyBackupRestoreResult } from "../sdk.js";
import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js";

View File

@@ -15,10 +15,8 @@ export {
patchAllowlistUsersInConfigEntries,
summarizeMapping,
} from "openclaw/plugin-sdk/allow-from";
export {
createReplyPrefixOptions,
createTypingCallbacks,
} from "openclaw/plugin-sdk/channel-reply-options-runtime";
export { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { createTypingCallbacks } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { formatLocationText, toLocationContext } from "openclaw/plugin-sdk/channel-location";
export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/agent-media-payload";
export { logInboundDrop, logTypingFailure } from "openclaw/plugin-sdk/channel-logging";

View File

@@ -18,21 +18,6 @@ function requestUrl(input: RequestInfo | URL | undefined): string {
return input.url;
}
const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS__";
function clearTestUndiciRuntimeDepsOverride(): void {
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
}
function stubRuntimeFetch(fetchImpl: typeof fetch): void {
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
Agent: function MockAgent() {},
EnvHttpProxyAgent: function MockEnvHttpProxyAgent() {},
ProxyAgent: function MockProxyAgent() {},
fetch: fetchImpl,
};
}
class FakeMatrixEvent extends EventEmitter {
private readonly roomId: string;
private readonly eventId: string;
@@ -239,13 +224,11 @@ describe("MatrixClient request hardening", () => {
lastCreateClientOpts = null;
vi.useRealTimers();
vi.unstubAllGlobals();
clearTestUndiciRuntimeDepsOverride();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
clearTestUndiciRuntimeDepsOverride();
});
it("blocks absolute endpoints unless explicitly allowed", async () => {
@@ -255,7 +238,7 @@ describe("MatrixClient request hardening", () => {
headers: { "content-type": "application/json" },
});
});
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow(
@@ -282,7 +265,7 @@ describe("MatrixClient request hardening", () => {
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
async () => new Response(payload, { status: 200 }),
);
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
ssrfPolicy: { allowPrivateNetwork: true },
@@ -313,7 +296,7 @@ describe("MatrixClient request hardening", () => {
}
return new Response(payload, { status: 200 });
});
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
ssrfPolicy: { allowPrivateNetwork: true },
@@ -492,7 +475,7 @@ describe("MatrixClient request hardening", () => {
},
});
});
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
ssrfPolicy: { allowPrivateNetwork: true },
@@ -523,7 +506,7 @@ describe("MatrixClient request hardening", () => {
headers: { "content-type": "application/json" },
});
});
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
ssrfPolicy: { allowPrivateNetwork: true },
@@ -548,7 +531,7 @@ describe("MatrixClient request hardening", () => {
});
});
});
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
localTimeoutMs: 25,

View File

@@ -1,7 +1,4 @@
import {
fetchWithRuntimeDispatcherOrMockedGlobal,
isMockedFetch,
} from "openclaw/plugin-sdk/runtime-fetch";
import { fetchWithRuntimeDispatcher } from "openclaw/plugin-sdk/runtime-fetch";
import {
closeDispatcher,
createPinnedDispatcher,
@@ -13,8 +10,7 @@ import {
export {
closeDispatcher,
createPinnedDispatcher,
fetchWithRuntimeDispatcherOrMockedGlobal,
isMockedFetch,
fetchWithRuntimeDispatcher,
resolvePinnedHostnameWithPolicy,
type PinnedDispatcherPolicy,
type SsrFPolicy,

View File

@@ -8,15 +8,6 @@ function clearTestUndiciRuntimeDepsOverride(): void {
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
}
function stubRuntimeFetch(fetchImpl: typeof fetch): void {
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
Agent: function MockAgent() {},
EnvHttpProxyAgent: function MockEnvHttpProxyAgent() {},
ProxyAgent: function MockProxyAgent() {},
fetch: fetchImpl,
};
}
describe("performMatrixRequest", () => {
beforeEach(() => {
vi.unstubAllGlobals();
@@ -28,7 +19,8 @@ describe("performMatrixRequest", () => {
});
it("rejects oversized raw responses before buffering the whole body", async () => {
stubRuntimeFetch(
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response("too-big", {
@@ -63,7 +55,8 @@ describe("performMatrixRequest", () => {
controller.close();
},
});
stubRuntimeFetch(
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(stream, {
@@ -94,7 +87,8 @@ describe("performMatrixRequest", () => {
controller.enqueue(new Uint8Array([1, 2, 3]));
},
});
stubRuntimeFetch(
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(stream, {
@@ -141,7 +135,12 @@ describe("performMatrixRequest", () => {
},
});
});
stubRuntimeFetch(runtimeFetch);
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
Agent: function MockAgent() {},
EnvHttpProxyAgent: function MockEnvHttpProxyAgent() {},
ProxyAgent: function MockProxyAgent() {},
fetch: runtimeFetch,
};
const result = await performMatrixRequest({
homeserver: "http://127.0.0.1:8008",

View File

@@ -4,9 +4,9 @@ import {
buildTimeoutAbortSignal,
closeDispatcher,
createPinnedDispatcher,
fetchWithRuntimeDispatcherOrMockedGlobal,
resolvePinnedHostnameWithPolicy,
type SsrFPolicy,
fetchWithRuntimeDispatcher,
type PinnedDispatcherPolicy,
} from "./transport-runtime-api.js";
@@ -89,6 +89,13 @@ function buildBufferedResponse(params: {
return response;
}
function isMockedFetch(fetchImpl: typeof fetch | undefined): boolean {
if (typeof fetchImpl !== "function") {
return false;
}
return typeof (fetchImpl as typeof fetch & { mock?: unknown }).mock === "object";
}
async function fetchWithMatrixDispatcher(params: {
url: string;
init: MatrixDispatcherRequestInit;
@@ -97,7 +104,10 @@ async function fetchWithMatrixDispatcher(params: {
// fetches must stay fail-closed unless a retry path can preserve the
// validated pinned-address binding. Route dispatcher-attached requests
// through undici runtime fetch so the pinned dispatcher is preserved.
return await fetchWithRuntimeDispatcherOrMockedGlobal(params.url, params.init);
if (params.init.dispatcher && !isMockedFetch(globalThis.fetch)) {
return await fetchWithRuntimeDispatcher(params.url, params.init);
}
return await fetch(params.url, params.init);
}
async function fetchWithMatrixGuardedRedirects(params: {

View File

@@ -1,8 +1,8 @@
import type {
BindingTargetKind,
SessionBindingRecord,
} from "openclaw/plugin-sdk/thread-bindings-session-runtime";
import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/thread-bindings-session-runtime";
} from "openclaw/plugin-sdk/thread-bindings-runtime";
import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/thread-bindings-runtime";
export type MatrixThreadBindingTargetKind = "subagent" | "acp";

View File

@@ -311,17 +311,15 @@ describe("matrix thread bindings", () => {
});
await vi.advanceTimersByTimeAsync(61_000);
await Promise.resolve();
await vi.waitFor(
async () => {
const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8");
expect(JSON.parse(persistedRaw)).toMatchObject({
version: 1,
bindings: [],
});
},
{ interval: 1, timeout: 100 },
);
await vi.waitFor(async () => {
const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8");
expect(JSON.parse(persistedRaw)).toMatchObject({
version: 1,
bindings: [],
});
});
} finally {
vi.useRealTimers();
}
@@ -355,22 +353,23 @@ describe("matrix thread bindings", () => {
renameMock.mockRejectedValueOnce(new Error("disk full"));
await vi.advanceTimersByTimeAsync(61_000);
await Promise.resolve();
await vi.waitFor(
() => {
expect(
logVerboseMessage.mock.calls.some(
([message]) =>
typeof message === "string" &&
message.includes("failed auto-unbinding expired bindings"),
),
).toBe(true);
expect(logVerboseMessage).toHaveBeenCalledWith(
expect.stringContaining("matrix: auto-unbinding $thread due to idle-expired"),
);
},
{ interval: 1, timeout: 100 },
);
await vi.waitFor(() => {
expect(
logVerboseMessage.mock.calls.some(
([message]) =>
typeof message === "string" &&
message.includes("failed auto-unbinding expired bindings"),
),
).toBe(true);
});
await vi.waitFor(() => {
expect(logVerboseMessage).toHaveBeenCalledWith(
expect.stringContaining("matrix: auto-unbinding $thread due to idle-expired"),
);
});
expect(
getSessionBindingService().resolveByConversation({
@@ -641,12 +640,9 @@ describe("matrix thread bindings", () => {
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(originalLastActivityAt);
await vi.advanceTimersByTimeAsync(1_000);
await vi.waitFor(
async () => {
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(secondTouchedAt);
},
{ interval: 1, timeout: 100 },
);
await vi.waitFor(async () => {
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(secondTouchedAt);
});
} finally {
vi.useRealTimers();
}
@@ -665,12 +661,9 @@ describe("matrix thread bindings", () => {
vi.useRealTimers();
const bindingsPath = resolveBindingsFilePath();
await vi.waitFor(
async () => {
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(touchedAt);
},
{ interval: 1, timeout: 100 },
);
await vi.waitFor(async () => {
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(touchedAt);
});
} finally {
vi.useRealTimers();
}

View File

@@ -1,13 +1,13 @@
import path from "node:path";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/session-key-runtime";
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
registerSessionBindingAdapter,
resolveThreadBindingFarewellText,
type SessionBindingAdapter,
unregisterSessionBindingAdapter,
} from "openclaw/plugin-sdk/thread-bindings-session-runtime";
} from "openclaw/plugin-sdk/thread-bindings-runtime";
import { claimCurrentTokenStorageState, resolveMatrixStateFilePath } from "./client/storage.js";
import type { MatrixAuth } from "./client/types.js";
import type { MatrixClient } from "./sdk.js";

View File

@@ -1,5 +1,4 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { WizardPrompter } from "../runtime-api.js";
import { installMatrixTestRuntime } from "./test-runtime.js";
import type { CoreConfig } from "./types.js";
@@ -11,11 +10,11 @@ vi.mock("./resolve-targets.js", () => ({
resolveMatrixTargets: resolveMatrixTargetsMock,
}));
let promptMatrixAllowFrom: typeof import("./onboarding.js").__testing.promptMatrixAllowFrom;
let runMatrixAddAccountAllowlistConfigure: typeof import("./onboarding.test-harness.js").runMatrixAddAccountAllowlistConfigure;
describe("matrix onboarding account-scoped resolution", () => {
beforeAll(async () => {
({ promptMatrixAllowFrom } = (await import("./onboarding.js")).__testing);
({ runMatrixAddAccountAllowlistConfigure } = await import("./onboarding.test-harness.js"));
});
beforeEach(() => {
@@ -28,11 +27,7 @@ describe("matrix onboarding account-scoped resolution", () => {
});
it("passes accountId into Matrix allowlist target resolution during onboarding", async () => {
const prompter = {
note: vi.fn(async () => {}),
text: vi.fn(async () => "Alice"),
} as unknown as WizardPrompter;
const result = await promptMatrixAllowFrom({
const result = await runMatrixAddAccountAllowlistConfigure({
cfg: {
channels: {
matrix: {
@@ -41,19 +36,15 @@ describe("matrix onboarding account-scoped resolution", () => {
homeserver: "https://matrix.main.example.org",
accessToken: "main-token",
},
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig,
prompter,
accountId: "ops",
allowFromInput: "Alice",
roomsAllowlistInput: "",
});
expect(result.channels?.matrix?.accounts?.ops?.dm?.allowFrom).toEqual(["@alice:example.org"]);
expect(result).not.toBe("skip");
expect(resolveMatrixTargetsMock).toHaveBeenCalledWith({
cfg: expect.any(Object),
accountId: "ops",

View File

@@ -769,7 +769,3 @@ export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
},
}),
};
export const __testing = {
promptMatrixAllowFrom,
};

View File

@@ -212,10 +212,6 @@
"types": "./dist/plugin-sdk/channel-reply-pipeline.d.ts",
"default": "./dist/plugin-sdk/channel-reply-pipeline.js"
},
"./plugin-sdk/channel-reply-options-runtime": {
"types": "./dist/plugin-sdk/channel-reply-options-runtime.d.ts",
"default": "./dist/plugin-sdk/channel-reply-options-runtime.js"
},
"./plugin-sdk/channel-runtime": {
"types": "./dist/plugin-sdk/channel-runtime.d.ts",
"default": "./dist/plugin-sdk/channel-runtime.js"
@@ -288,10 +284,6 @@
"types": "./dist/plugin-sdk/thread-bindings-runtime.d.ts",
"default": "./dist/plugin-sdk/thread-bindings-runtime.js"
},
"./plugin-sdk/thread-bindings-session-runtime": {
"types": "./dist/plugin-sdk/thread-bindings-session-runtime.d.ts",
"default": "./dist/plugin-sdk/thread-bindings-session-runtime.js"
},
"./plugin-sdk/text-runtime": {
"types": "./dist/plugin-sdk/text-runtime.d.ts",
"default": "./dist/plugin-sdk/text-runtime.js"
@@ -428,10 +420,6 @@
"types": "./dist/plugin-sdk/account-resolution.d.ts",
"default": "./dist/plugin-sdk/account-resolution.js"
},
"./plugin-sdk/account-resolution-runtime": {
"types": "./dist/plugin-sdk/account-resolution-runtime.d.ts",
"default": "./dist/plugin-sdk/account-resolution-runtime.js"
},
"./plugin-sdk/agent-config-primitives": {
"types": "./dist/plugin-sdk/agent-config-primitives.d.ts",
"default": "./dist/plugin-sdk/agent-config-primitives.js"
@@ -688,10 +676,6 @@
"types": "./dist/plugin-sdk/session-binding-runtime.d.ts",
"default": "./dist/plugin-sdk/session-binding-runtime.js"
},
"./plugin-sdk/session-key-runtime": {
"types": "./dist/plugin-sdk/session-key-runtime.d.ts",
"default": "./dist/plugin-sdk/session-key-runtime.js"
},
"./plugin-sdk/session-store-runtime": {
"types": "./dist/plugin-sdk/session-store-runtime.d.ts",
"default": "./dist/plugin-sdk/session-store-runtime.js"

View File

@@ -28,33 +28,6 @@ const COMPILE_INPUT_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js",
const ROOTDIR_BOUNDARY_CANARY_IMPORT_PATH =
"../../src/plugins/contracts/rootdir-boundary-canary.ts";
const ROOTDIR_BOUNDARY_CANARY_OUTPUT_HINT = "src/plugins/contracts/rootdir-boundary-canary.ts";
const BOUNDARY_FULL_SWEEP_PATHS = new Set([
"package.json",
"pnpm-lock.yaml",
"scripts/check-extension-package-tsc-boundary.mjs",
"scripts/prepare-extension-package-boundary-artifacts.mjs",
"scripts/write-plugin-sdk-entry-dts.ts",
"scripts/lib/plugin-sdk-entries.mjs",
"scripts/lib/plugin-sdk-entrypoints.json",
"src/plugins/contracts/rootdir-boundary-canary.ts",
"src/video-generation/dashscope-compatible.ts",
"src/video-generation/types.ts",
"tsconfig.json",
"tsconfig.package-boundary.base.json",
"tsconfig.plugin-sdk.dts.json",
]);
const BOUNDARY_FULL_SWEEP_PREFIXES = [
"packages/plugin-sdk/",
"src/channels/plugins/",
"src/plugin-sdk/",
"src/types/",
];
function normalizeRepoPath(filePath) {
return String(filePath ?? "")
.replaceAll("\\", "/")
.replace(/^\.\/+/, "");
}
function parseMode(argv) {
const modeArg = argv.find((arg) => arg.startsWith("--mode="));
@@ -113,9 +86,6 @@ export function formatBoundaryCheckSuccessSummary(params = {}) {
if (params.mode) {
lines.push(`mode: ${params.mode}`);
}
if (params.scope) {
lines.push(`scope: ${params.scope}`);
}
if (Number.isInteger(params.compileCount)) {
lines.push(`compiled plugins: ${params.compileCount}`);
}
@@ -225,97 +195,6 @@ function collectCanaryExtensionIds(extensionIds) {
];
}
function isBoundaryFullSweepPath(filePath) {
const normalizedPath = normalizeRepoPath(filePath);
return (
BOUNDARY_FULL_SWEEP_PATHS.has(normalizedPath) ||
BOUNDARY_FULL_SWEEP_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix))
);
}
function parseChangedExtensionsMatrix(rawMatrix) {
if (!rawMatrix || !rawMatrix.trim()) {
return [];
}
try {
const parsed = JSON.parse(rawMatrix);
if (!Array.isArray(parsed?.include)) {
return [];
}
return [
...new Set(
parsed.include
.map((entry) => (typeof entry?.extension === "string" ? entry.extension : ""))
.filter(Boolean),
),
].toSorted((left, right) => left.localeCompare(right));
} catch {
return [];
}
}
function parseChangedPathsJson(rawPaths) {
if (!rawPaths || !rawPaths.trim()) {
return null;
}
try {
const parsed = JSON.parse(rawPaths);
if (!Array.isArray(parsed)) {
return null;
}
return [...new Set(parsed.map((entry) => normalizeRepoPath(entry)).filter(Boolean))].toSorted();
} catch {
return null;
}
}
export function resolveBoundaryCheckSelection(params = {}) {
const optInExtensionIds = Array.isArray(params.optInExtensionIds)
? [...params.optInExtensionIds]
: [];
const changedPaths = params.changedPaths;
const changedExtensionIds = Array.isArray(params.changedExtensionIds)
? [...params.changedExtensionIds]
: [];
const resolveCanaryIds = params.resolveCanaryExtensionIds ?? collectCanaryExtensionIds;
if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
return {
scope: "full",
compileExtensionIds: optInExtensionIds,
canaryExtensionIds: resolveCanaryIds(optInExtensionIds),
};
}
if (changedPaths.some((filePath) => isBoundaryFullSweepPath(filePath))) {
return {
scope: "full",
compileExtensionIds: optInExtensionIds,
canaryExtensionIds: resolveCanaryIds(optInExtensionIds),
};
}
const optInExtensionIdSet = new Set(optInExtensionIds);
const scopedExtensionIds = changedExtensionIds
.filter((extensionId) => optInExtensionIdSet.has(extensionId))
.toSorted((left, right) => left.localeCompare(right));
if (scopedExtensionIds.length === 0) {
return {
scope: "skip",
compileExtensionIds: [],
canaryExtensionIds: [],
};
}
return {
scope: "scoped",
compileExtensionIds: scopedExtensionIds,
canaryExtensionIds: resolveCanaryIds(scopedExtensionIds),
};
}
function isRelevantCompileInput(filePath) {
const basename = path.basename(filePath);
if (
@@ -889,15 +768,7 @@ export async function main(argv = process.argv.slice(2)) {
const startedAt = Date.now();
const mode = parseMode(argv);
const optInExtensionIds = collectOptInExtensionIds();
const selection = resolveBoundaryCheckSelection({
optInExtensionIds,
changedExtensionIds: parseChangedExtensionsMatrix(
process.env.OPENCLAW_EXTENSION_BOUNDARY_CHANGED_EXTENSIONS_MATRIX,
),
changedPaths: parseChangedPathsJson(process.env.OPENCLAW_EXTENSION_BOUNDARY_CHANGED_PATHS_JSON),
});
const compileExtensionIds = selection.compileExtensionIds;
const canaryExtensionIds = selection.canaryExtensionIds;
const canaryExtensionIds = collectCanaryExtensionIds(optInExtensionIds);
const cleanupExtensionIds = optInExtensionIds;
const shouldRunCanary = mode === "all" || mode === "canary";
const releaseBoundaryLock = acquireBoundaryCheckLock();
@@ -911,17 +782,16 @@ export async function main(argv = process.argv.slice(2)) {
try {
cleanupCanaryArtifactsForExtensions(cleanupExtensionIds);
if ((mode === "all" || mode === "compile") && compileExtensionIds.length > 0) {
if (mode === "all" || mode === "compile") {
({ prepElapsedMs, compileCount, skippedCompileCount, compileElapsedMs, compileTimings } =
await runCompileCheck(compileExtensionIds));
await runCompileCheck(optInExtensionIds));
}
if (shouldRunCanary && canaryExtensionIds.length > 0) {
if (shouldRunCanary) {
({ canaryElapsedMs } = await runCanaryCheck(canaryExtensionIds));
}
process.stdout.write(
formatBoundaryCheckSuccessSummary({
mode,
scope: selection.scope,
compileCount,
skippedCompileCount,
canaryCount: shouldRunCanary ? canaryExtensionIds.length : 0,

View File

@@ -12,7 +12,7 @@ import { resolveBuildRequirement } from "./run-node.mjs";
const DEFAULTS = {
outputDir: path.join(process.cwd(), ".local", "gateway-watch-regression"),
windowMs: 10_000,
readyTimeoutMs: 5_000,
readyTimeoutMs: 20_000,
readySettleMs: 500,
sigkillGraceMs: 10_000,
cpuWarnMs: 1_000,
@@ -34,8 +34,6 @@ const WATCH_GATEWAY_SKIP_ENV = {
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
};
const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\u001B\[[0-9;]*m`, "g");
function parseArgs(argv) {
const options = { ...DEFAULTS };
for (let i = 0; i < argv.length; i += 1) {
@@ -98,54 +96,9 @@ function normalizePath(filePath) {
return filePath.replaceAll("\\", "/");
}
function isMissingPathError(error) {
return (
Boolean(error) &&
typeof error === "object" &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ENOTDIR")
);
}
function safeReaddirSync(fsImpl, dirPath) {
try {
return fsImpl.readdirSync(dirPath, { withFileTypes: true });
} catch (error) {
if (isMissingPathError(error)) {
return [];
}
throw error;
}
}
function safeLstatSync(fsImpl, filePath) {
try {
return fsImpl.lstatSync(filePath);
} catch (error) {
if (isMissingPathError(error)) {
return null;
}
throw error;
}
}
export function stripAnsi(text) {
return String(text ?? "").replaceAll(ANSI_ESCAPE_PATTERN, "");
}
export function hasGatewayReadyLog(text) {
return stripAnsi(text).includes("[gateway] ready (");
}
export function resolveReadyObservation(readyBeforeWindow, stdout, stderr) {
return readyBeforeWindow || hasGatewayReadyLog(stdout) || hasGatewayReadyLog(stderr);
}
export function listTreeEntries(rootName, params = {}) {
const fsImpl = params.fs ?? fs;
const cwd = params.cwd ?? process.cwd();
const rootPath = path.join(cwd, rootName);
if (!fsImpl.existsSync(rootPath)) {
function listTreeEntries(rootName) {
const rootPath = path.join(process.cwd(), rootName);
if (!fs.existsSync(rootPath)) {
return [`${rootName} (missing)`];
}
@@ -156,10 +109,10 @@ export function listTreeEntries(rootName, params = {}) {
if (!current) {
continue;
}
const dirents = safeReaddirSync(fsImpl, current);
const dirents = fs.readdirSync(current, { withFileTypes: true });
for (const dirent of dirents) {
const fullPath = path.join(current, dirent.name);
const relativePath = normalizePath(path.relative(cwd, fullPath));
const relativePath = normalizePath(path.relative(process.cwd(), fullPath));
entries.push(relativePath);
if (dirent.isDirectory()) {
queue.push(fullPath);
@@ -182,12 +135,10 @@ function humanBytes(bytes) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
}
export function snapshotTree(rootName, params = {}) {
const fsImpl = params.fs ?? fs;
const cwd = params.cwd ?? process.cwd();
const rootPath = path.join(cwd, rootName);
function snapshotTree(rootName) {
const rootPath = path.join(process.cwd(), rootName);
const stats = {
exists: fsImpl.existsSync(rootPath),
exists: fs.existsSync(rootPath),
files: 0,
directories: 0,
symlinks: 0,
@@ -205,14 +156,11 @@ export function snapshotTree(rootName, params = {}) {
if (!current) {
continue;
}
const currentStats = safeLstatSync(fsImpl, current);
if (!currentStats) {
continue;
}
const currentStats = fs.lstatSync(current);
stats.entries += 1;
if (currentStats.isDirectory()) {
stats.directories += 1;
for (const dirent of safeReaddirSync(fsImpl, current)) {
for (const dirent of fs.readdirSync(current, { withFileTypes: true })) {
queue.push(path.join(current, dirent.name));
}
continue;
@@ -364,15 +312,13 @@ function readProcessTreeCpuMs(rootPid) {
async function waitForGatewayReady(readText, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (true) {
if (hasGatewayReadyLog(readText())) {
while (Date.now() < deadline) {
if (/\[gateway\] ready \(/.test(readText())) {
return true;
}
if (Date.now() >= deadline) {
return false;
}
await sleep(100);
}
return false;
}
async function allocateLoopbackPort() {
@@ -489,7 +435,7 @@ async function runTimedWatch(options, outputDir) {
});
const exitPromise = new Promise((resolve) => {
child.on("close", (code, signal) => resolve({ code, signal }));
child.on("exit", (code, signal) => resolve({ code, signal }));
});
let watchPid = null;
@@ -501,7 +447,7 @@ async function runTimedWatch(options, outputDir) {
await sleep(100);
}
let readyBeforeWindow = await waitForGatewayReady(
const readyBeforeWindow = await waitForGatewayReady(
() => `${stdout}\n${stderr}`,
options.readyTimeoutMs,
);
@@ -536,7 +482,6 @@ async function runTimedWatch(options, outputDir) {
}
const exit = (await exitPromise) ?? { code: null, signal: null };
readyBeforeWindow = resolveReadyObservation(readyBeforeWindow, stdout, stderr);
fs.writeFileSync(stdoutPath, stdout, "utf8");
fs.writeFileSync(stderrPath, stderr, "utf8");
const timing = fs.existsSync(timeFilePath)
@@ -614,13 +559,11 @@ function buildRunNodeDeps(env) {
};
}
export async function main() {
async function main() {
const options = parseArgs(process.argv.slice(2));
ensureDir(options.outputDir);
if (!options.skipBuild) {
// This regression only needs runtime dist artifacts; plugin-sdk d.ts output
// adds CI time without affecting watch startup behavior.
runCheckedCommand("pnpm", ["build:ci-artifacts"]);
runCheckedCommand("pnpm", ["build"]);
// The watch harness must start from a completed-build baseline. Refresh
// the build stamp after the full build pipeline finishes so run-node does
// not spuriously rebuild inside the bounded watch window.
@@ -754,6 +697,4 @@ export async function main() {
process.exit(0);
}
if (import.meta.main) {
await main();
}
await main();

View File

@@ -70,11 +70,6 @@ function listChangedPaths(base, head = "HEAD") {
.filter((line) => line.length > 0);
}
export function listChangedPathsForScope(params = {}) {
const head = params.head ?? "HEAD";
return listChangedPaths(resolveChangedPathsBase(params), head);
}
function hasExtensionPackage(extensionId) {
return fs.existsSync(path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR, extensionId, "package.json"));
}
@@ -127,7 +122,8 @@ export function listChangedExtensionIds(params = {}) {
const unavailableBaseBehavior = params.unavailableBaseBehavior ?? "error";
try {
return detectChangedExtensionIds(listChangedPathsForScope({ ...params, head }));
const base = resolveChangedPathsBase(params);
return detectChangedExtensionIds(listChangedPaths(base, head));
} catch (error) {
if (unavailableBaseBehavior === "all") {
return listAvailableExtensionIds();

View File

@@ -39,7 +39,6 @@
"inbound-reply-dispatch",
"inbound-envelope",
"channel-reply-pipeline",
"channel-reply-options-runtime",
"channel-runtime",
"interactive-runtime",
"outbound-media",
@@ -58,7 +57,6 @@
"matrix-runtime-heavy",
"matrix-runtime-shared",
"thread-bindings-runtime",
"thread-bindings-session-runtime",
"text-runtime",
"text-chunking",
"agent-runtime",
@@ -93,7 +91,6 @@
"account-core",
"account-id",
"account-resolution",
"account-resolution-runtime",
"agent-config-primitives",
"allow-from",
"allowlist-config-edit",
@@ -158,7 +155,6 @@
"runtime-fetch",
"response-limit-runtime",
"session-binding-runtime",
"session-key-runtime",
"session-store-runtime",
"ssrf-dispatcher",
"string-coerce-runtime",

View File

@@ -25,12 +25,6 @@ async function loadOAuthModuleForTest() {
({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js"));
}
function resolveApiKeyForProfileInTest(
params: Omit<Parameters<typeof resolveApiKeyForProfile>[0], "cfg">,
) {
return resolveApiKeyForProfile({ cfg: {}, ...params });
}
const {
refreshProviderOAuthCredentialWithPluginMock,
formatProviderAuthProfileApiKeyWithPluginMock,
@@ -48,32 +42,12 @@ vi.mock("../cli-credentials.js", () => ({
writeCodexCliCredentials: () => true,
}));
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthApiKey: vi.fn(async () => null),
getOAuthProviders: () => [{ id: "openai-codex" }, { id: "anthropic" }],
}));
vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
formatProviderAuthProfileApiKeyWithPlugin: (params: { context?: { access?: string } }) =>
formatProviderAuthProfileApiKeyWithPluginMock() ?? params?.context?.access,
refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock,
}));
vi.mock("../../infra/file-lock.js", () => ({
resetFileLockStateForTest: () => undefined,
withFileLock: async <T>(_filePath: string, _options: unknown, run: () => Promise<T>) => run(),
}));
vi.mock("../../plugin-sdk/file-lock.js", () => ({
resetFileLockStateForTest: () => undefined,
withFileLock: async <T>(_filePath: string, _options: unknown, run: () => Promise<T>) => run(),
}));
vi.mock("./external-auth.js", () => ({
overlayExternalAuthProfiles: <T>(store: T) => store,
shouldPersistExternalAuthProfile: () => true,
}));
vi.mock("./doctor.js", () => ({
formatAuthDoctorHint: async () => undefined,
}));
@@ -100,11 +74,7 @@ function storeWith(profileId: string, cred: OAuthCredential): AuthProfileStore {
}
describe("OAuth credential adoption is identity-gated", () => {
const envSnapshot = captureEnv([
"OPENCLAW_STATE_DIR",
"OPENCLAW_AGENT_DIR",
"PI_CODING_AGENT_DIR",
]);
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
let tempRoot = "";
let mainAgentDir = "";
@@ -118,8 +88,6 @@ describe("OAuth credential adoption is identity-gated", () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-adopt-identity-"));
process.env.OPENCLAW_STATE_DIR = tempRoot;
mainAgentDir = path.join(tempRoot, "agents", "main", "agent");
process.env.OPENCLAW_AGENT_DIR = mainAgentDir;
process.env.PI_CODING_AGENT_DIR = mainAgentDir;
await fs.mkdir(mainAgentDir, { recursive: true });
await loadOAuthModuleForTest();
resetOAuthRefreshQueuesForTest();
@@ -175,7 +143,7 @@ describe("OAuth credential adoption is identity-gated", () => {
mainAgentDir,
);
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -244,7 +212,7 @@ describe("OAuth credential adoption is identity-gated", () => {
}) as never,
);
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -306,7 +274,7 @@ describe("OAuth credential adoption is identity-gated", () => {
mainAgentDir,
);
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -363,7 +331,7 @@ describe("OAuth credential adoption is identity-gated", () => {
// Plugin refresh must NOT be called — sub should adopt main's fresh
// cred rather than performing its own refresh.
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -412,7 +380,7 @@ describe("OAuth credential adoption is identity-gated", () => {
mainAgentDir,
);
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -476,7 +444,7 @@ describe("OAuth credential adoption is identity-gated", () => {
}) as never,
);
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -543,7 +511,7 @@ describe("OAuth credential adoption is identity-gated", () => {
// Catch-block main-inherit must refuse the non-overlapping cred and
// propagate the original error rather than leaking main's credential.
await expect(
resolveApiKeyForProfileInTest({
resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -606,7 +574,7 @@ describe("OAuth credential adoption is identity-gated", () => {
throw new Error("upstream 503 service unavailable");
});
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -675,7 +643,7 @@ describe("OAuth credential adoption is identity-gated", () => {
});
await expect(
resolveApiKeyForProfileInTest({
resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,

View File

@@ -18,12 +18,6 @@ async function loadOAuthModuleForTest() {
({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js"));
}
function resolveApiKeyForProfileInTest(
params: Omit<Parameters<typeof resolveApiKeyForProfile>[0], "cfg">,
) {
return resolveApiKeyForProfile({ cfg: {}, ...params });
}
const {
refreshProviderOAuthCredentialWithPluginMock,
formatProviderAuthProfileApiKeyWithPluginMock,
@@ -41,22 +35,12 @@ vi.mock("../cli-credentials.js", () => ({
writeCodexCliCredentials: () => true,
}));
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthApiKey: vi.fn(async () => null),
getOAuthProviders: () => [{ id: "openai-codex" }],
}));
vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
formatProviderAuthProfileApiKeyWithPlugin: (params: { context?: { access?: string } }) =>
formatProviderAuthProfileApiKeyWithPluginMock() ?? params?.context?.access,
refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock,
}));
vi.mock("./external-auth.js", () => ({
overlayExternalAuthProfiles: <T>(store: T) => store,
shouldPersistExternalAuthProfile: () => true,
}));
vi.mock("./doctor.js", () => ({
formatAuthDoctorHint: async () => undefined,
}));
@@ -95,11 +79,7 @@ function createExpiredOauthStore(params: {
}
describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () => {
const envSnapshot = captureEnv([
"OPENCLAW_STATE_DIR",
"OPENCLAW_AGENT_DIR",
"PI_CODING_AGENT_DIR",
]);
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
let tempRoot = "";
let mainAgentDir = "";
@@ -113,8 +93,6 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", ()
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-concurrent-"));
process.env.OPENCLAW_STATE_DIR = tempRoot;
mainAgentDir = path.join(tempRoot, "agents", "main", "agent");
process.env.OPENCLAW_AGENT_DIR = mainAgentDir;
process.env.PI_CODING_AGENT_DIR = mainAgentDir;
await fs.mkdir(mainAgentDir, { recursive: true });
await loadOAuthModuleForTest();
// Drop any refresh-queue entries left behind by a prior timed-out test.
@@ -172,7 +150,7 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", ()
// performed; the remaining 19 adopt the resulting fresh credentials.
const results = await Promise.all(
subAgents.map((agentDir) =>
resolveApiKeyForProfileInTest({
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
@@ -187,5 +165,6 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", ()
expect(result?.apiKey).toBe("cross-agent-refreshed-access");
expect(result?.provider).toBe(provider);
}
}, 60_000); // Generous timeout; the fix should complete well under 5s in practice.
}, // Generous timeout; the fix should complete well under 5s in practice.
60_000);
});

View File

@@ -4,7 +4,6 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetFileLockStateForTest } from "../../infra/file-lock.js";
import { captureEnv } from "../../test-utils/env.js";
import { __testing as externalAuthTesting } from "./external-auth.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
@@ -19,12 +18,6 @@ async function loadOAuthModuleForTest() {
({ resolveApiKeyForProfile, resetOAuthRefreshQueuesForTest } = await import("./oauth.js"));
}
function resolveApiKeyForProfileInTest(
params: Omit<Parameters<typeof resolveApiKeyForProfile>[0], "cfg">,
) {
return resolveApiKeyForProfile({ cfg: {}, ...params });
}
const {
refreshProviderOAuthCredentialWithPluginMock,
formatProviderAuthProfileApiKeyWithPluginMock,
@@ -42,35 +35,12 @@ vi.mock("../cli-credentials.js", () => ({
writeCodexCliCredentials: () => true,
}));
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthProviders: () => [{ id: "anthropic" }, { id: "openai-codex" }],
getOAuthApiKey: vi.fn(async (provider: string, credentials: Record<string, OAuthCredential>) => {
const credential = credentials[provider];
return credential
? {
apiKey: credential.access,
newCredentials: credential,
}
: null;
}),
}));
vi.mock("../../plugins/provider-runtime.runtime.js", () => ({
formatProviderAuthProfileApiKeyWithPlugin: (params: { context?: { access?: string } }) =>
formatProviderAuthProfileApiKeyWithPluginMock() ?? params?.context?.access,
refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock,
}));
vi.mock("../../infra/file-lock.js", () => ({
resetFileLockStateForTest: () => undefined,
withFileLock: async <T>(_filePath: string, _options: unknown, run: () => Promise<T>) => run(),
}));
vi.mock("../../plugin-sdk/file-lock.js", () => ({
resetFileLockStateForTest: () => undefined,
withFileLock: async <T>(_filePath: string, _options: unknown, run: () => Promise<T>) => run(),
}));
vi.mock("./doctor.js", () => ({
formatAuthDoctorHint: async () => undefined,
}));
@@ -106,11 +76,7 @@ function createExpiredOauthStore(params: {
}
describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => {
const envSnapshot = captureEnv([
"OPENCLAW_STATE_DIR",
"OPENCLAW_AGENT_DIR",
"PI_CODING_AGENT_DIR",
]);
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
let tempRoot = "";
let mainAgentDir = "";
@@ -120,13 +86,10 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
refreshProviderOAuthCredentialWithPluginMock.mockResolvedValue(undefined);
formatProviderAuthProfileApiKeyWithPluginMock.mockReset();
formatProviderAuthProfileApiKeyWithPluginMock.mockReturnValue(undefined);
externalAuthTesting.setResolveExternalAuthProfilesForTest(() => []);
clearRuntimeAuthProfileStoreSnapshots();
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-mirror-"));
process.env.OPENCLAW_STATE_DIR = tempRoot;
mainAgentDir = path.join(tempRoot, "agents", "main", "agent");
process.env.OPENCLAW_AGENT_DIR = mainAgentDir;
process.env.PI_CODING_AGENT_DIR = mainAgentDir;
await fs.mkdir(mainAgentDir, { recursive: true });
await loadOAuthModuleForTest();
resetOAuthRefreshQueuesForTest();
@@ -135,7 +98,6 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
afterEach(async () => {
envSnapshot.restore();
resetFileLockStateForTest();
externalAuthTesting.resetResolveExternalAuthProfilesForTest();
clearRuntimeAuthProfileStoreSnapshots();
if (resetOAuthRefreshQueuesForTest) {
resetOAuthRefreshQueuesForTest();
@@ -168,7 +130,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -212,7 +174,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
// Main-agent refresh uses undefined agentDir; the mirror path is a no-op
// (local == main). Just make sure the main store still reflects the refresh
// and no double-write happens.
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(undefined),
profileId,
agentDir: undefined,
@@ -266,7 +228,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -332,7 +294,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -397,7 +359,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
// The sub-agent will actually adopt main's fresher creds via the inside-
// lock recheck (that's the whole point of #26322), so refresh may not
// even fire. We only care that the main store is not regressed.
await resolveApiKeyForProfileInTest({
await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -453,7 +415,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -511,7 +473,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
await resolveApiKeyForProfileInTest({
await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -560,7 +522,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
// Refresh mock intentionally left as default-undefined — it should not
// be called, the pre-refresh adopt wins.
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -623,7 +585,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
throw new Error("upstream 503 service unavailable");
});
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -687,7 +649,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -745,7 +707,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
await resolveApiKeyForProfileInTest({
await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -785,7 +747,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
const result = await resolveApiKeyForProfileInTest({
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,

View File

@@ -23,11 +23,6 @@ vi.mock("../../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry,
}));
vi.mock("./external-auth.js", () => ({
overlayExternalAuthProfiles: <T>(store: T) => store,
shouldPersistExternalAuthProfile: () => true,
}));
describe("resolveAuthProfileOrder", () => {
beforeEach(() => {
loadPluginManifestRegistry.mockClear();

View File

@@ -1,50 +1,13 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage } from "@mariozechner/pi-ai";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
estimateMessagesTokens,
pruneHistoryForContextShare,
splitMessagesByTokenShare,
} from "./compaction.js";
import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js";
const piCodingAgentMocks = vi.hoisted(() => ({
estimateTokens: vi.fn((message: unknown) => estimateTokenish(message)),
}));
function readText(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
return value.map(readText).join("");
}
if (value && typeof value === "object") {
const record = value as { text?: unknown; content?: unknown; arguments?: unknown };
return `${readText(record.text)}${readText(record.content)}${readText(record.arguments)}`;
}
return "";
}
function estimateTokenish(message: unknown): number {
return Math.max(1, Math.ceil(readText(message).length / 4));
}
vi.mock("@mariozechner/pi-coding-agent", async () => {
const actual = await vi.importActual<typeof import("@mariozechner/pi-coding-agent")>(
"@mariozechner/pi-coding-agent",
);
return {
...actual,
estimateTokens: piCodingAgentMocks.estimateTokens,
};
});
let estimateMessagesTokens: typeof import("./compaction.js").estimateMessagesTokens;
let pruneHistoryForContextShare: typeof import("./compaction.js").pruneHistoryForContextShare;
let splitMessagesByTokenShare: typeof import("./compaction.js").splitMessagesByTokenShare;
beforeAll(async () => {
vi.resetModules();
({ estimateMessagesTokens, pruneHistoryForContextShare, splitMessagesByTokenShare } =
await import("./compaction.js"));
});
function makeMessage(id: number, size: number): AgentMessage {
return {
role: "user",

View File

@@ -1,71 +0,0 @@
import type { BootstrapMode } from "../../bootstrap-mode.js";
import { resolveBootstrapMode } from "../../bootstrap-mode.js";
import { buildAgentUserPromptPrefix } from "../../system-prompt.js";
export type AttemptBootstrapRoutingInput = {
workspaceBootstrapPending: boolean;
bootstrapContextRunKind?: "default" | "heartbeat" | "cron";
trigger?: string;
sessionKey?: string;
isPrimaryRun: boolean;
isCanonicalWorkspace?: boolean;
effectiveWorkspace: string;
resolvedWorkspace: string;
hasBootstrapFileAccess: boolean;
};
export type AttemptBootstrapRouting = {
bootstrapMode: BootstrapMode;
shouldStripBootstrapFromContext: boolean;
userPromptPrefixText?: string;
};
export type AttemptWorkspaceBootstrapRoutingInput = Omit<
AttemptBootstrapRoutingInput,
"workspaceBootstrapPending"
> & {
isWorkspaceBootstrapPending: (workspaceDir: string) => Promise<boolean>;
};
export function shouldStripBootstrapFromEmbeddedContext(_params: {
bootstrapMode: BootstrapMode;
}): boolean {
return true;
}
export function resolveAttemptBootstrapRouting(
params: AttemptBootstrapRoutingInput,
): AttemptBootstrapRouting {
const bootstrapMode = resolveBootstrapMode({
bootstrapPending: params.workspaceBootstrapPending,
runKind: params.bootstrapContextRunKind ?? "default",
isInteractiveUserFacing: params.trigger === "user" || params.trigger === "manual",
isPrimaryRun: params.isPrimaryRun,
isCanonicalWorkspace:
(params.isCanonicalWorkspace ?? true) &&
params.effectiveWorkspace === params.resolvedWorkspace,
hasBootstrapFileAccess: params.hasBootstrapFileAccess,
});
return {
bootstrapMode,
shouldStripBootstrapFromContext: shouldStripBootstrapFromEmbeddedContext({
bootstrapMode,
}),
userPromptPrefixText: buildAgentUserPromptPrefix({
bootstrapMode,
}),
};
}
export async function resolveAttemptWorkspaceBootstrapRouting(
params: AttemptWorkspaceBootstrapRoutingInput,
): Promise<AttemptBootstrapRouting> {
const workspaceBootstrapPending = await params.isWorkspaceBootstrapPending(
params.resolvedWorkspace,
);
return resolveAttemptBootstrapRouting({
...params,
workspaceBootstrapPending,
});
}

View File

@@ -1,28 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import { resolveAttemptWorkspaceBootstrapRouting } from "./attempt-bootstrap-routing.js";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTempPaths,
createContextEngineAttemptRunner,
getHoisted,
resetEmbeddedAttemptHarness,
} from "./attempt.spawn-workspace.test-support.js";
const hoisted = getHoisted();
describe("runEmbeddedAttempt bootstrap routing", () => {
const tempPaths: string[] = [];
beforeEach(() => {
resetEmbeddedAttemptHarness();
});
afterEach(async () => {
await cleanupTempPaths(tempPaths);
});
it("resolves bootstrap pending from the canonical workspace instead of a copied sandbox", async () => {
const sandboxWorkspace = "/tmp/openclaw-sandbox-copy";
const canonicalWorkspace = "/tmp/openclaw-canonical-workspace";
const isWorkspaceBootstrapPending = vi.fn(async (workspaceDir: string) => {
let capturedPrompt = "";
hoisted.resolveSandboxContextMock.mockResolvedValue({
enabled: true,
workspaceAccess: "ro",
workspaceDir: sandboxWorkspace,
});
hoisted.isWorkspaceBootstrapPendingMock.mockImplementation(async (workspaceDir: string) => {
return workspaceDir === sandboxWorkspace;
});
const routing = await resolveAttemptWorkspaceBootstrapRouting({
isWorkspaceBootstrapPending,
trigger: "user",
isPrimaryRun: true,
isCanonicalWorkspace: true,
effectiveWorkspace: sandboxWorkspace,
resolvedWorkspace: canonicalWorkspace,
hasBootstrapFileAccess: true,
await createContextEngineAttemptRunner({
sessionKey: "agent:main:bootstrap-canonical-workspace",
tempPaths,
contextEngine: {
assemble: async ({ messages }) => ({
messages,
estimatedTokens: 1,
}),
},
attemptOverrides: {
disableTools: true,
},
sessionPrompt: async (session, prompt) => {
capturedPrompt = prompt;
session.messages = [
...session.messages,
{ role: "assistant", content: "done", timestamp: 2 } as never,
];
},
});
expect(isWorkspaceBootstrapPending).toHaveBeenCalledOnce();
expect(isWorkspaceBootstrapPending).toHaveBeenCalledWith(canonicalWorkspace);
expect(isWorkspaceBootstrapPending).not.toHaveBeenCalledWith(sandboxWorkspace);
expect(routing.bootstrapMode).toBe("none");
expect(routing.userPromptPrefixText).toBeUndefined();
expect(hoisted.isWorkspaceBootstrapPendingMock).toHaveBeenCalledTimes(1);
expect(hoisted.isWorkspaceBootstrapPendingMock).not.toHaveBeenCalledWith(sandboxWorkspace);
expect(capturedPrompt).not.toContain("[Bootstrap pending]");
});
});

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import {
createAgentSession,
@@ -52,6 +52,7 @@ import {
resolveBootstrapContextForRun,
resolveContextInjectionMode,
} from "../../bootstrap-files.js";
import { resolveBootstrapMode } from "../../bootstrap-mode.js";
import { createCacheTrace } from "../../cache-trace.js";
import {
listChannelSupportedActions,
@@ -113,6 +114,7 @@ import {
import { resolveSystemPromptOverride } from "../../system-prompt-override.js";
import { buildSystemPromptParams } from "../../system-prompt-params.js";
import { buildSystemPromptReport } from "../../system-prompt-report.js";
import { buildAgentUserPromptPrefix } from "../../system-prompt.js";
import { resolveAgentTimeoutMs } from "../../timeout.js";
import { UNKNOWN_TOOL_THRESHOLD } from "../../tool-loop-detection.js";
import {
@@ -180,11 +182,6 @@ import { splitSdkTools } from "../tool-split.js";
import { mapThinkingLevel } from "../utils.js";
import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush.js";
export { buildContextEnginePromptCacheInfo } from "./attempt.context-engine-helpers.js";
import {
resolveAttemptWorkspaceBootstrapRouting,
shouldStripBootstrapFromEmbeddedContext,
} from "./attempt-bootstrap-routing.js";
export { shouldStripBootstrapFromEmbeddedContext } from "./attempt-bootstrap-routing.js";
import { configureEmbeddedAttemptHttpRuntime } from "./attempt-http-runtime.js";
import {
assembleAttemptContextEngine,
@@ -321,6 +318,12 @@ export function resolveUnknownToolGuardThreshold(loopDetection?: {
return UNKNOWN_TOOL_THRESHOLD;
}
export function shouldStripBootstrapFromEmbeddedContext(_params: {
bootstrapMode: "full" | "limited" | "none";
}): boolean {
return true;
}
export function isPrimaryBootstrapRun(sessionKey?: string): boolean {
return !isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey);
}
@@ -335,7 +338,8 @@ export function remapInjectedContextFilesToWorkspace(params: {
}
return params.files.map((file) => {
const relative = path.relative(params.sourceWorkspaceDir, file.path);
const canRemap = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
const canRemap =
relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
return canRemap
? {
...file,
@@ -480,6 +484,8 @@ export async function runEmbeddedAttempt(
const sessionLabel = params.sessionKey ?? params.sessionId;
const contextInjectionMode = resolveContextInjectionMode(params.config);
// Bootstrap lifecycle is owned by the canonical workspace, not a copied sandbox view.
const workspaceBootstrapPending = await isWorkspaceBootstrapPending(resolvedWorkspace);
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const toolsRaw = params.disableTools
? []
@@ -549,20 +555,20 @@ export async function runEmbeddedAttempt(
return allTools;
})();
const toolsEnabled = supportsModelTools(params.model);
const bootstrapRunKind = params.bootstrapContextRunKind ?? "default";
const bootstrapHasFileAccess = toolsEnabled && toolsRaw.some((tool) => tool.name === "read");
const bootstrapRouting = await resolveAttemptWorkspaceBootstrapRouting({
isWorkspaceBootstrapPending,
bootstrapContextRunKind: params.bootstrapContextRunKind,
trigger: params.trigger,
sessionKey: params.sessionKey,
const bootstrapMode = resolveBootstrapMode({
bootstrapPending: workspaceBootstrapPending,
runKind: bootstrapRunKind,
isInteractiveUserFacing: params.trigger === "user" || params.trigger === "manual",
isPrimaryRun: isPrimaryBootstrapRun(params.sessionKey),
isCanonicalWorkspace: params.isCanonicalWorkspace,
effectiveWorkspace,
resolvedWorkspace,
isCanonicalWorkspace:
(params.isCanonicalWorkspace ?? true) && effectiveWorkspace === resolvedWorkspace,
hasBootstrapFileAccess: bootstrapHasFileAccess,
});
const bootstrapMode = bootstrapRouting.bootstrapMode;
const shouldStripBootstrapFromContext = bootstrapRouting.shouldStripBootstrapFromContext;
const shouldStripBootstrapFromContext = shouldStripBootstrapFromEmbeddedContext({
bootstrapMode,
});
const {
bootstrapFiles: hookAdjustedBootstrapFiles,
contextFiles: resolvedContextFiles,
@@ -570,7 +576,7 @@ export async function runEmbeddedAttempt(
} = await resolveAttemptBootstrapContext({
contextInjectionMode,
bootstrapContextMode: params.bootstrapContextMode,
bootstrapContextRunKind: params.bootstrapContextRunKind ?? "default",
bootstrapContextRunKind: bootstrapRunKind,
bootstrapMode,
sessionFile: params.sessionFile,
hasCompletedBootstrapTurn,
@@ -921,7 +927,9 @@ export async function runEmbeddedAttempt(
});
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
let systemPromptText = systemPromptOverride();
const userPromptPrefixText = bootstrapRouting.userPromptPrefixText;
const userPromptPrefixText = buildAgentUserPromptPrefix({
bootstrapMode,
});
let sessionManager: ReturnType<typeof guardSessionManager> | undefined;
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;

View File

@@ -1,51 +1,11 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { estimateToolResultReductionPotential } from "../tool-result-truncation.js";
const piCodingAgentMocks = vi.hoisted(() => ({
estimateTokens: vi.fn((message: unknown) => estimateTokenish(message)),
}));
function readText(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
return value.map(readText).join("");
}
if (value && typeof value === "object") {
const record = value as { text?: unknown; content?: unknown };
return `${readText(record.text)}${readText(record.content)}`;
}
return "";
}
function estimateTokenish(message: unknown): number {
return Math.max(1, Math.ceil(readText(message).length / 4));
}
vi.mock("@mariozechner/pi-coding-agent", async () => {
const actual = await vi.importActual<typeof import("@mariozechner/pi-coding-agent")>(
"@mariozechner/pi-coding-agent",
);
return {
...actual,
estimateTokens: piCodingAgentMocks.estimateTokens,
};
});
let PREEMPTIVE_OVERFLOW_ERROR_TEXT: typeof import("./preemptive-compaction.js").PREEMPTIVE_OVERFLOW_ERROR_TEXT;
let estimatePrePromptTokens: typeof import("./preemptive-compaction.js").estimatePrePromptTokens;
let shouldPreemptivelyCompactBeforePrompt: typeof import("./preemptive-compaction.js").shouldPreemptivelyCompactBeforePrompt;
beforeAll(async () => {
vi.resetModules();
({
PREEMPTIVE_OVERFLOW_ERROR_TEXT,
estimatePrePromptTokens,
shouldPreemptivelyCompactBeforePrompt,
} = await import("./preemptive-compaction.js"));
});
import {
PREEMPTIVE_OVERFLOW_ERROR_TEXT,
estimatePrePromptTokens,
shouldPreemptivelyCompactBeforePrompt,
} from "./preemptive-compaction.js";
let timestamp = 1;

View File

@@ -54,12 +54,6 @@ const telegramModelsTestPlugin: ChannelPlugin = {
},
};
const textSurfaceModelsTestPlugins = (["discord", "whatsapp"] as const).map((id) => ({
pluginId: id,
plugin: createChannelTestPluginBase({ id }),
source: "test",
}));
beforeEach(() => {
modelCatalogMocks.loadModelCatalog.mockReset();
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
@@ -73,7 +67,6 @@ beforeEach(() => {
modelAuthLabelMocks.resolveModelAuthLabel.mockReturnValue(undefined);
setActivePluginRegistry(
createTestRegistry([
...textSurfaceModelsTestPlugins,
{
pluginId: "telegram",
plugin: telegramModelsTestPlugin,

View File

@@ -31,7 +31,7 @@ async function loadDevTemplate(name: string, fallback: string): Promise<string>
}
}
const resolveDevWorkspaceDir = (env: NodeJS.ProcessEnv = process.env): string => {
export const resolveDevWorkspaceDir = (env: NodeJS.ProcessEnv = process.env): string => {
const baseDir = resolveDefaultAgentWorkspaceDir(env, os.homedir);
const profile = normalizeOptionalLowercaseString(env.OPENCLAW_PROFILE);
if (profile === "dev") {
@@ -54,7 +54,7 @@ async function writeFileIfMissing(filePath: string, content: string) {
}
}
async function ensureDevWorkspace(dir: string) {
export async function ensureDevWorkspace(dir: string) {
const resolvedDir = resolveUserPath(dir);
await fs.promises.mkdir(resolvedDir, { recursive: true });

View File

@@ -76,6 +76,11 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec<
loadModule: () => import("./register.backup.js"),
exportName: "registerBackupCommand",
},
{
commandNames: ["workspace"],
loadModule: () => import("./register.workspace.js"),
exportName: "registerWorkspaceCommand",
},
{
commandNames: ["doctor", "dashboard", "reset", "uninstall"],
loadModule: () => import("./register.maintenance.js"),

View File

@@ -18,6 +18,13 @@ vi.mock("./register.backup.js", () => ({
},
}));
vi.mock("./register.workspace.js", () => ({
registerWorkspaceCommand: (program: Command) => {
const workspace = program.command("workspace");
workspace.command("reset");
},
}));
vi.mock("./register.maintenance.js", () => ({
registerMaintenanceCommands: (program: Command) => {
program.command("doctor");
@@ -70,6 +77,7 @@ describe("command-registry", () => {
expect(names).toContain("mcp");
expect(names).toContain("agent");
expect(names).toContain("agents");
expect(names).toContain("workspace");
});
it("returns only commands that support subcommands", () => {
@@ -77,6 +85,7 @@ describe("command-registry", () => {
expect(names).toContain("config");
expect(names).toContain("agents");
expect(names).toContain("backup");
expect(names).toContain("workspace");
expect(names).toContain("mcp");
expect(names).toContain("sessions");
expect(names).toContain("tasks");

View File

@@ -50,6 +50,11 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
description: "Uninstall the gateway service + local data (CLI remains)",
hasSubcommands: false,
},
{
name: "workspace",
description: "Manage agent workspaces",
hasSubcommands: true,
},
{
name: "message",
description: "Send, read, and manage messages",

View File

@@ -0,0 +1,57 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerWorkspaceCommand } from "./register.workspace.js";
const mocks = vi.hoisted(() => ({
workspaceResetCommand: vi.fn(),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
}));
vi.mock("../../commands/workspace.js", () => ({
workspaceResetCommand: mocks.workspaceResetCommand,
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: mocks.runtime,
}));
describe("registerWorkspaceCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("passes workspace reset options through to the command", async () => {
const program = new Command();
registerWorkspaceCommand(program);
await program.parseAsync(
[
"workspace",
"reset",
"--workspace",
"/tmp/ws",
"--agent",
"ops",
"--include-sessions",
"--yes",
"--dry-run",
],
{ from: "user" },
);
expect(mocks.workspaceResetCommand).toHaveBeenCalledWith(
mocks.runtime,
expect.objectContaining({
workspace: "/tmp/ws",
agent: "ops",
includeSessions: true,
yes: true,
dryRun: true,
}),
);
});
});

View File

@@ -0,0 +1,57 @@
import type { Command } from "commander";
import { workspaceResetCommand } from "../../commands/workspace.js";
import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { formatHelpExamples } from "../help-format.js";
export function registerWorkspaceCommand(program: Command) {
const workspace = program
.command("workspace")
.description("Manage agent workspaces")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/workspace", "docs.openclaw.ai/cli/workspace")}\n`,
);
workspace
.command("reset")
.description("Reset only the active agent workspace and reseed it as a fresh agent")
.option("--workspace <dir>", "Explicit workspace directory to reset")
.option("--agent <id>", "Agent id to resolve workspace/sessions from the active config")
.option("--include-sessions", "Also clear this agent's session transcripts", false)
.option("--yes", "Skip the confirmation prompt", false)
.option("--dry-run", "Print the reset plan without moving anything", false)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["openclaw workspace reset", "Trash and reseed only the active workspace."],
[
"openclaw workspace reset --include-sessions",
"Also clear the active agent's session transcripts.",
],
[
"openclaw workspace reset --agent ops",
"Reset the workspace resolved for a specific agent id.",
],
[
"openclaw workspace reset --workspace ~/tmp/test-workspace --dry-run",
"Preview a custom workspace-only reset.",
],
])}`,
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await workspaceResetCommand(defaultRuntime, {
workspace: opts.workspace as string | undefined,
agent: opts.agent as string | undefined,
includeSessions: Boolean(opts.includeSessions),
yes: Boolean(opts.yes),
dryRun: Boolean(opts.dryRun),
});
});
});
}

View File

@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import "./agent-command.test-mocks.js";
import * as acpManagerModule from "../acp/control-plane/manager.js";
import { AcpRuntimeError } from "../acp/runtime/errors.js";
import * as embeddedModule from "../agents/pi-embedded.js";
import * as configIoModule from "../config/io.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -402,10 +401,9 @@ describe("agentCommand ACP runtime routing", () => {
return {
kind: "stale",
sessionKey,
error: new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`ACP metadata is missing for session ${sessionKey}.`,
),
error: Object.assign(new Error(`ACP metadata is missing for session ${sessionKey}.`), {
code: "ACP_SESSION_INIT_FAILED",
}),
};
},
});

View File

@@ -30,7 +30,7 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({
const resolveBundledPluginSources = vi.fn();
const getChannelPluginCatalogEntry = vi.fn();
const listChannelPluginCatalogEntries = vi.fn((..._args: unknown[]) => []);
const listChannelPluginCatalogEntries = vi.fn(() => []);
vi.mock("../../channels/plugins/catalog.js", () => {
return {
getChannelPluginCatalogEntry: (...args: unknown[]) => getChannelPluginCatalogEntry(...args),

View File

@@ -4,7 +4,6 @@ import type { RuntimeEnv } from "../runtime.js";
import { captureEnv } from "../test-utils/env.js";
type RunMessageActionParams = {
cfg?: unknown;
action: string;
params: Record<string, unknown>;
};

View File

@@ -1,6 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
moveToTrash,
normalizeGatewayTokenInput,
openUrl,
probeGatewayReachable,
@@ -223,6 +226,43 @@ describe("normalizeGatewayTokenInput", () => {
});
});
describe("moveToTrash", () => {
it("falls back to mv into ~/.Trash when trash exits successfully but leaves the path in place", async () => {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-trash-home-"));
const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-trash-target-"));
const target = path.join(targetDir, "workspace");
await fs.mkdir(target, { recursive: true });
await fs.writeFile(path.join(target, "stale.txt"), "old", "utf-8");
vi.spyOn(os, "homedir").mockReturnValue(tempHome);
mocks.runCommandWithTimeout.mockImplementationOnce(async () => ({
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
}));
mocks.runCommandWithTimeout.mockImplementationOnce(async (argv) => {
await fs.rename(argv[1], argv[2]);
return {
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
};
});
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await moveToTrash(target, runtime as never);
await expect(fs.access(target)).rejects.toThrow();
await expect(fs.access(path.join(tempHome, ".Trash", "workspace"))).resolves.toBeUndefined();
expect(runtime.log).toHaveBeenCalledWith(`Moved to Trash: ${target}`);
});
});
describe("validateGatewayPasswordInput", () => {
it("requires a non-empty password", () => {
expect(validateGatewayPasswordInput("")).toBe("Required");

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { inspect } from "node:util";
import { cancel, isCancel } from "@clack/prompts";
@@ -187,6 +188,28 @@ export function resolveNodeManagerOptions(): Array<{
];
}
async function pathExists(pathname: string): Promise<boolean> {
try {
await fs.access(pathname);
return true;
} catch {
return false;
}
}
async function resolveTrashDestination(pathname: string): Promise<string> {
const trashDir = path.join(os.homedir(), ".Trash");
await fs.mkdir(trashDir, { recursive: true });
const baseName = path.basename(pathname);
let candidate = path.join(trashDir, baseName);
let suffix = 1;
while (await pathExists(candidate)) {
candidate = path.join(trashDir, `${baseName}.${suffix}`);
suffix += 1;
}
return candidate;
}
export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promise<void> {
if (!pathname) {
return;
@@ -198,8 +221,26 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
}
try {
await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 });
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
if (!(await pathExists(pathname))) {
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
return;
}
} catch {
// Fall through to a verified mv-based fallback below.
}
try {
const destination = await resolveTrashDestination(pathname);
await runCommandWithTimeout(["mv", pathname, destination], { timeoutMs: 5000 });
if (!(await pathExists(pathname))) {
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
return;
}
} catch {
// Surface the manual action guidance below.
}
if (await pathExists(pathname)) {
runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`);
}
}

View File

@@ -25,7 +25,6 @@ function createDefaultSessionStoreEntry() {
cacheRead: 2_000,
cacheWrite: 1_000,
totalTokens: 5_000,
totalTokensFresh: true as boolean,
contextTokens: 10_000,
model: "pi:opus",
sessionId: "abc123",
@@ -189,11 +188,7 @@ async function createStatusServiceSummary(
}
function createSessionStatusRows() {
const agents = (mocks.listGatewayAgentsBasic().agents ?? [
{ id: "main", name: "Main" },
]) as Array<{
id: string;
}>;
const agents = mocks.listGatewayAgentsBasic().agents ?? [{ id: "main", name: "Main" }];
const byAgent = agents.map((agent: { id: string }) => {
const path = mocks.resolveStorePath("sessions", { agentId: agent.id });
const store = mocks.loadSessionStore(path) as Record<
@@ -203,7 +198,7 @@ function createSessionStatusRows() {
const recent = Object.entries(store).map(([key, entry]) => {
const contextTokens = typeof entry.contextTokens === "number" ? entry.contextTokens : null;
const freshTotal =
typeof entry.totalTokens === "number" && (entry.totalTokensFresh ?? true)
typeof entry.totalTokens === "number" && entry.totalTokensFresh !== false
? entry.totalTokens
: null;
return {

View File

@@ -0,0 +1,195 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { workspaceResetCommand } from "./workspace.js";
const mocks = vi.hoisted(() => ({
readBestEffortConfig: vi.fn(async () => ({})),
resolveDefaultAgentId: vi.fn(() => "main"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace-main"),
resolveSessionTranscriptsDirForAgent: vi.fn(() => "/tmp/sessions-main"),
ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "BOOTSTRAP.md"), "# BOOTSTRAP\n", "utf-8");
return { dir };
}),
ensureDevWorkspace: vi.fn(async (dir: string) => {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "AGENTS.md"), "# DEV AGENTS\n", "utf-8");
}),
resolveDevWorkspaceDir: vi.fn(() => "/tmp/workspace-dev"),
moveToTrash: vi.fn(async (target: string) => {
await fs.rm(target, { recursive: true, force: true });
}),
confirm: vi.fn(async () => true),
cancel: vi.fn(),
}));
vi.mock("../config/config.js", () => ({
readBestEffortConfig: mocks.readBestEffortConfig,
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
}));
vi.mock("../config/sessions/paths.js", () => ({
resolveSessionTranscriptsDirForAgent: mocks.resolveSessionTranscriptsDirForAgent,
}));
vi.mock("../agents/workspace.js", () => ({
ensureAgentWorkspace: mocks.ensureAgentWorkspace,
}));
vi.mock("../cli/gateway-cli/dev.js", () => ({
ensureDevWorkspace: mocks.ensureDevWorkspace,
resolveDevWorkspaceDir: mocks.resolveDevWorkspaceDir,
}));
vi.mock("./onboard-helpers.js", () => ({
moveToTrash: mocks.moveToTrash,
}));
vi.mock("@clack/prompts", () => ({
confirm: mocks.confirm,
cancel: mocks.cancel,
isCancel: (value: unknown) => value === Symbol.for("clack.cancel"),
}));
describe("workspaceResetCommand", () => {
let tempRoot: string;
let runtime: {
log: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
exit: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
vi.clearAllMocks();
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-reset-"));
runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
});
it("resets only the workspace by default and reseeds it", async () => {
const workspaceDir = path.join(tempRoot, "workspace-main");
const sessionsDir = path.join(tempRoot, "sessions-main");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "stale.txt"), "old", "utf-8");
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
mocks.resolveSessionTranscriptsDirForAgent.mockReturnValue(sessionsDir);
await workspaceResetCommand(runtime as never, {});
expect(mocks.confirm).toHaveBeenCalledTimes(1);
expect(mocks.moveToTrash).toHaveBeenCalledWith(workspaceDir, runtime);
expect(mocks.moveToTrash).not.toHaveBeenCalledWith(sessionsDir, runtime);
expect(await fs.readFile(path.join(workspaceDir, "BOOTSTRAP.md"), "utf-8")).toContain(
"BOOTSTRAP",
);
expect(await fs.stat(sessionsDir)).toBeDefined();
});
it("also resets sessions when --include-sessions is enabled", async () => {
const workspaceDir = path.join(tempRoot, "workspace-main");
const sessionsDir = path.join(tempRoot, "sessions-main");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(path.join(sessionsDir, "old.log"), "old", "utf-8");
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
mocks.resolveSessionTranscriptsDirForAgent.mockReturnValue(sessionsDir);
await workspaceResetCommand(runtime as never, { includeSessions: true });
expect(mocks.moveToTrash).toHaveBeenCalledWith(workspaceDir, runtime);
expect(mocks.moveToTrash).toHaveBeenCalledWith(sessionsDir, runtime);
expect(await fs.readFile(path.join(workspaceDir, "BOOTSTRAP.md"), "utf-8")).toContain(
"BOOTSTRAP",
);
expect(await fs.readdir(sessionsDir)).toEqual([]);
});
it("rejects combining --workspace with --include-sessions", async () => {
const customWorkspace = path.join(tempRoot, "custom-workspace");
await fs.mkdir(customWorkspace, { recursive: true });
await expect(
workspaceResetCommand(runtime as never, {
workspace: customWorkspace,
includeSessions: true,
}),
).rejects.toThrow(
"--include-sessions cannot be combined with --workspace; sessions are resolved from configured agent state only.",
);
expect(mocks.moveToTrash).not.toHaveBeenCalled();
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(mocks.ensureDevWorkspace).not.toHaveBeenCalled();
});
it("supports dry-run without modifying workspace or sessions", async () => {
const workspaceDir = path.join(tempRoot, "workspace-main");
const sessionsDir = path.join(tempRoot, "sessions-main");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(sessionsDir, { recursive: true });
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
mocks.resolveSessionTranscriptsDirForAgent.mockReturnValue(sessionsDir);
await workspaceResetCommand(runtime as never, { includeSessions: true, dryRun: true });
expect(mocks.moveToTrash).not.toHaveBeenCalled();
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(await fs.stat(workspaceDir)).toBeDefined();
expect(await fs.stat(sessionsDir)).toBeDefined();
});
it("skips confirmation when --yes is enabled", async () => {
const workspaceDir = path.join(tempRoot, "workspace-main");
await fs.mkdir(workspaceDir, { recursive: true });
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
await workspaceResetCommand(runtime as never, { yes: true });
expect(mocks.confirm).not.toHaveBeenCalled();
expect(mocks.moveToTrash).toHaveBeenCalledWith(workspaceDir, runtime);
});
it("cancels without touching anything when confirmation is declined", async () => {
const workspaceDir = path.join(tempRoot, "workspace-main");
await fs.mkdir(workspaceDir, { recursive: true });
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
mocks.confirm.mockResolvedValueOnce(false);
await workspaceResetCommand(runtime as never, {});
expect(mocks.moveToTrash).not.toHaveBeenCalled();
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(0);
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("Workspace reseeded"));
});
it("reuses the dev reseed helper for the active dev workspace", async () => {
const devWorkspace = path.join(tempRoot, "workspace-dev");
await fs.mkdir(devWorkspace, { recursive: true });
mocks.resolveAgentWorkspaceDir.mockReturnValue(devWorkspace);
mocks.resolveDevWorkspaceDir.mockReturnValue(devWorkspace);
await workspaceResetCommand(runtime as never, { yes: true });
expect(mocks.ensureDevWorkspace).toHaveBeenCalledWith(devWorkspace);
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
expect(await fs.readFile(path.join(devWorkspace, "AGENTS.md"), "utf-8")).toContain(
"DEV AGENTS",
);
});
});

134
src/commands/workspace.ts Normal file
View File

@@ -0,0 +1,134 @@
import fs from "node:fs/promises";
import { cancel, confirm, isCancel } from "@clack/prompts";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { ensureAgentWorkspace } from "../agents/workspace.js";
import { formatCliCommand } from "../cli/command-format.js";
import { ensureDevWorkspace, resolveDevWorkspaceDir } from "../cli/gateway-cli/dev.js";
import { readBestEffortConfig } from "../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { moveToTrash } from "./onboard-helpers.js";
export type WorkspaceResetOptions = {
workspace?: string;
agent?: string;
includeSessions?: boolean;
yes?: boolean;
dryRun?: boolean;
};
function hasValue(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
async function pathExists(target: string): Promise<boolean> {
try {
await fs.access(target);
return true;
} catch {
return false;
}
}
function describeResetPlan(params: {
workspaceDir: string;
sessionsDir: string;
includeSessions: boolean;
}): string[] {
return [
`Workspace: ${shortenHomePath(params.workspaceDir)}`,
params.includeSessions ? `Sessions: ${shortenHomePath(params.sessionsDir)}` : undefined,
"Preserves: config, credentials, channels, and gateway auth.",
].filter((line): line is string => Boolean(line));
}
function isActiveDevWorkspaceTarget(workspaceDir: string): boolean {
return resolveUserPath(workspaceDir) === resolveUserPath(resolveDevWorkspaceDir(process.env));
}
export async function workspaceResetCommand(
runtime: RuntimeEnv,
opts: WorkspaceResetOptions,
): Promise<void> {
const cfg = await readBestEffortConfig();
const hasExplicitWorkspace = hasValue(opts.workspace);
const agentId = hasValue(opts.agent) ? opts.agent.trim() : resolveDefaultAgentId(cfg);
const workspaceDir = hasExplicitWorkspace
? resolveUserPath(opts.workspace!.trim())
: resolveAgentWorkspaceDir(cfg, agentId);
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
const includeSessions = Boolean(opts.includeSessions);
const dryRun = Boolean(opts.dryRun);
if (hasExplicitWorkspace && includeSessions) {
throw new Error(
"--include-sessions cannot be combined with --workspace; sessions are resolved from configured agent state only.",
);
}
for (const line of describeResetPlan({ workspaceDir, sessionsDir, includeSessions })) {
runtime.log(line);
}
if (dryRun) {
runtime.log(`[dry-run] trash ${shortenHomePath(workspaceDir)}`);
if (includeSessions) {
runtime.log(`[dry-run] trash ${shortenHomePath(sessionsDir)}`);
}
runtime.log(
`[dry-run] reseed ${shortenHomePath(workspaceDir)} with default workspace files and BOOTSTRAP.md`,
);
return;
}
if (!opts.yes) {
const ok = await confirm({
message: stylePromptMessage(
`Trash and reseed ${shortenHomePath(workspaceDir)}${includeSessions ? " and clear this agent's sessions" : ""}?`,
),
});
if (isCancel(ok) || !ok) {
cancel(stylePromptTitle("Workspace reset cancelled.") ?? "Workspace reset cancelled.");
runtime.exit(0);
return;
}
}
await moveToTrash(workspaceDir, runtime);
if (await pathExists(workspaceDir)) {
throw new Error(
`Workspace reset did not remove ${shortenHomePath(workspaceDir)}. Move it manually or retry.`,
);
}
if (includeSessions) {
await moveToTrash(sessionsDir, runtime);
if (await pathExists(sessionsDir)) {
throw new Error(
`Session reset did not remove ${shortenHomePath(sessionsDir)}. Move it manually or retry.`,
);
}
}
if (isActiveDevWorkspaceTarget(workspaceDir)) {
await ensureDevWorkspace(workspaceDir);
} else {
await ensureAgentWorkspace({
dir: workspaceDir,
ensureBootstrapFiles: true,
});
}
runtime.log(`Workspace reseeded: ${shortenHomePath(workspaceDir)}`);
if (includeSessions) {
await fs.mkdir(sessionsDir, { recursive: true });
runtime.log(`Sessions reset: ${shortenHomePath(sessionsDir)}`);
}
runtime.log("Workspace reset complete.");
runtime.log("Recommended next steps:");
runtime.log(`- ${formatCliCommand("openclaw onboard")}`);
runtime.log(`- ${formatCliCommand("openclaw gateway run")}`);
}

View File

@@ -90,13 +90,3 @@ export async function fetchWithRuntimeDispatcher(
normalizeRuntimeRequestInit(init, runtimeDeps.FormData),
)) as Response;
}
export async function fetchWithRuntimeDispatcherOrMockedGlobal(
input: RequestInfo | URL,
init?: DispatcherAwareRequestInit,
): Promise<Response> {
if (isMockedFetch(globalThis.fetch)) {
return await globalThis.fetch(input, init);
}
return await fetchWithRuntimeDispatcher(input, init);
}

View File

@@ -36,11 +36,6 @@ describe("getChannelMessageAdapter", () => {
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "discord", plugin: discordCrossContextPlugin, source: "test" },
{
pluginId: "telegram",
plugin: createChannelTestPluginBase({ id: "telegram" }),
source: "test",
},
]),
);
});

View File

@@ -1,20 +0,0 @@
export { resolveMergedAccountConfig } from "../channels/plugins/account-helpers.js";
export { resolveNormalizedAccountEntry } from "../routing/account-lookup.js";
/** List normalized configured account ids from a raw channel account record map. */
export function listConfiguredAccountIds(params: {
accounts: Record<string, unknown> | undefined;
normalizeAccountId: (accountId: string) => string;
}): string[] {
if (!params.accounts) {
return [];
}
const ids = new Set<string>();
for (const key of Object.keys(params.accounts)) {
if (!key) {
continue;
}
ids.add(params.normalizeAccountId(key));
}
return [...ids];
}

View File

@@ -1,4 +0,0 @@
// Narrow reply helper surface for channel handlers that do not need the full
// reply pipeline factory.
export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
export { createTypingCallbacks } from "../channels/typing.js";

View File

@@ -3,7 +3,5 @@
export {
fetchWithRuntimeDispatcher,
fetchWithRuntimeDispatcherOrMockedGlobal,
isMockedFetch,
type DispatcherAwareRequestInit,
} from "../infra/net/runtime-fetch.js";

View File

@@ -1,6 +0,0 @@
// Narrow session-key helpers for channel hot paths that should not import the
// broader routing SDK barrel.
export {
resolveAgentIdFromSessionKey,
type ParsedAgentSessionKey,
} from "../routing/session-key.js";

View File

@@ -2,5 +2,3 @@
export { loadSessionStore } from "../config/sessions/store-load.js";
export { resolveSessionStoreEntry } from "../config/sessions/store-entry.js";
export { resolveStorePath } from "../config/sessions/paths.js";
export { readSessionUpdatedAt } from "../config/sessions/store.js";

View File

@@ -1,52 +0,0 @@
export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js";
export {
registerSessionBindingAdapter,
unregisterSessionBindingAdapter,
type BindingTargetKind,
type SessionBindingAdapter,
type SessionBindingRecord,
} from "../infra/outbound/session-binding-service.js";
type ThreadBindingLifecycleRecord = {
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
};
export function resolveThreadBindingLifecycle(params: {
record: ThreadBindingLifecycleRecord;
defaultIdleTimeoutMs: number;
defaultMaxAgeMs: number;
}): {
expiresAt?: number;
reason?: "idle-expired" | "max-age-expired";
} {
const idleTimeoutMs =
typeof params.record.idleTimeoutMs === "number"
? Math.max(0, Math.floor(params.record.idleTimeoutMs))
: params.defaultIdleTimeoutMs;
const maxAgeMs =
typeof params.record.maxAgeMs === "number"
? Math.max(0, Math.floor(params.record.maxAgeMs))
: params.defaultMaxAgeMs;
const inactivityExpiresAt =
idleTimeoutMs > 0
? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs
: undefined;
const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined;
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
return inactivityExpiresAt <= maxAgeExpiresAt
? { expiresAt: inactivityExpiresAt, reason: "idle-expired" }
: { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
}
if (inactivityExpiresAt != null) {
return { expiresAt: inactivityExpiresAt, reason: "idle-expired" };
}
if (maxAgeExpiresAt != null) {
return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
}
return {};
}

View File

@@ -346,6 +346,7 @@ describe("plugin-sdk subpath exports", () => {
"pairing-access",
"provider-model-definitions",
"reply-prefix",
"secret-input-runtime",
"secret-input-schema",
"signal-core",
"synology-chat",

View File

@@ -12,7 +12,6 @@ import {
formatStepFailure,
installCanaryArtifactCleanup,
isBoundaryCompileFresh,
resolveBoundaryCheckSelection,
resolveBoundaryCheckLockPath,
resolveCanaryArtifactPaths,
runNodeStepAsync,
@@ -166,29 +165,6 @@ describe("check-extension-package-tsc-boundary", () => {
);
});
it("adds the scope label when the boundary check was CI-scoped", () => {
expect(
formatBoundaryCheckSuccessSummary({
mode: "all",
scope: "scoped",
compileCount: 2,
skippedCompileCount: 0,
canaryCount: 1,
elapsedMs: 1_234,
}),
).toBe(
[
"extension package boundary check passed",
"mode: all",
"scope: scoped",
"compiled plugins: 2",
"canary plugins: 1",
"elapsed: 1234ms",
"",
].join("\n"),
);
});
it("omits phase timings that never ran", () => {
expect(
formatBoundaryCheckSuccessSummary({
@@ -244,50 +220,6 @@ describe("check-extension-package-tsc-boundary", () => {
).toBe(["slowest plugin compiles:", "- slow: 900ms", "- medium: 250ms", ""].join("\n"));
});
it("keeps the full sweep when shared plugin-sdk paths changed", () => {
expect(
resolveBoundaryCheckSelection({
optInExtensionIds: ["browser", "matrix", "zalo"],
changedExtensionIds: ["browser"],
changedPaths: ["src/plugin-sdk/provider-entry.ts"],
resolveCanaryExtensionIds: (extensionIds: string[]) => extensionIds.slice(0, 2),
}),
).toEqual({
scope: "full",
compileExtensionIds: ["browser", "matrix", "zalo"],
canaryExtensionIds: ["browser", "matrix"],
});
});
it("scopes CI compiles to changed opt-in extensions when only extension-local paths changed", () => {
expect(
resolveBoundaryCheckSelection({
optInExtensionIds: ["browser", "matrix", "zalo"],
changedExtensionIds: ["browser", "matrix"],
changedPaths: ["extensions/browser/src/runtime.ts", "docs/plugins/sdk-overview.md"],
resolveCanaryExtensionIds: (extensionIds: string[]) => extensionIds,
}),
).toEqual({
scope: "scoped",
compileExtensionIds: ["browser", "matrix"],
canaryExtensionIds: ["browser", "matrix"],
});
});
it("skips the boundary sweep when no opt-in extension or shared path changed", () => {
expect(
resolveBoundaryCheckSelection({
optInExtensionIds: ["browser", "matrix", "zalo"],
changedExtensionIds: ["docs-helper"],
changedPaths: ["docs/reference/cli.md"],
}),
).toEqual({
scope: "skip",
compileExtensionIds: [],
canaryExtensionIds: [],
});
});
it("treats a plugin compile as fresh only when its outputs are newer than plugin and shared sdk inputs", () => {
const { rootDir, extensionRoot } = createTempExtensionRoot();
const extensionSourcePath = path.join(extensionRoot, "index.ts");

View File

@@ -1,91 +0,0 @@
import { describe, expect, it } from "vitest";
import {
hasGatewayReadyLog,
listTreeEntries,
resolveReadyObservation,
snapshotTree,
stripAnsi,
} from "../../scripts/check-gateway-watch-regression.mjs";
function createDirent(name: string, kind: "dir" | "file" | "symlink") {
return {
name,
isDirectory: () => kind === "dir",
isFile: () => kind === "file",
isSymbolicLink: () => kind === "symlink",
};
}
describe("check-gateway-watch-regression", () => {
it("detects the gateway ready line even when logs are ANSI-colorized", () => {
const line =
"\u001b[90m2026-04-17T16:47:21.723+00:00\u001b[39m \u001b[36m[gateway]\u001b[39m \u001b[36mready (5 plugins: acpx; 1.8s)\u001b[39m";
expect(stripAnsi(line)).toContain("[gateway] ready (5 plugins: acpx; 1.8s)");
expect(hasGatewayReadyLog(line)).toBe(true);
});
it("treats a buffered ready log as a successful ready observation", () => {
expect(
resolveReadyObservation(
false,
"2026-04-17T10:34:39.684-07:00 [gateway] ready (5 plugins: acpx; 3.9s)\n",
"",
),
).toBe(true);
});
it("keeps missing trees explicit in snapshot path listings", () => {
const entries = listTreeEntries("dist-runtime", {
cwd: "/repo",
fs: {
existsSync: () => false,
},
});
expect(entries).toEqual(["dist-runtime (missing)"]);
});
it("ignores files that disappear between readdir and lstat while snapshotting", () => {
const fakeFs = {
existsSync(filePath: string) {
return filePath === "/repo/dist";
},
readdirSync(filePath: string) {
if (filePath === "/repo/dist") {
return [createDirent("kept.js", "file"), createDirent("gone.js", "file")];
}
return [];
},
lstatSync(filePath: string) {
if (filePath === "/repo/dist") {
return {
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
};
}
if (filePath === "/repo/dist/kept.js") {
return {
size: 7,
isDirectory: () => false,
isFile: () => true,
isSymbolicLink: () => false,
};
}
const error = new Error(`ENOENT: ${filePath}`) as NodeJS.ErrnoException;
error.code = "ENOENT";
throw error;
},
};
expect(snapshotTree("dist", { cwd: "/repo", fs: fakeFs })).toEqual({
exists: true,
files: 1,
directories: 1,
symlinks: 0,
entries: 2,
apparentBytes: 7,
});
});
});

View File

@@ -1,28 +1,30 @@
import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { t } from "../i18n/index.ts";
import { refreshChat, refreshChatAvatar } from "./app-chat.ts";
import { syncUrlWithSessionKey } from "./app-settings.ts";
import type { AppViewState } from "./app-view-state.ts";
import { createChatModelOverride } from "./chat-model-ref.ts";
import {
isCronSessionKey,
parseSessionKey,
renderChatSessionSelect as renderChatSessionSelectBase,
renderChatThinkingSelect,
resolveSessionDisplayName,
resolveSessionOptionGroups,
} from "./chat/session-controls.ts";
resolveChatModelOverrideValue,
resolveChatModelSelectState,
} from "./chat-model-select-state.ts";
import { refreshSlashCommands } from "./chat/slash-commands.ts";
import { refreshVisibleToolsEffectiveForCurrentSession } from "./controllers/agents.ts";
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
import { loadSessions } from "./controllers/sessions.ts";
import { icons } from "./icons.ts";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
import { parseAgentSessionKey } from "./session-key.ts";
import { normalizeOptionalString } from "./string-coerce.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts";
import type { ThemeMode } from "./theme.ts";
import {
listThinkingLevelLabels,
normalizeThinkLevel,
resolveThinkingDefaultForModel,
} from "./thinking.ts";
import type { SessionsListResult } from "./types.ts";
export { isCronSessionKey, parseSessionKey, resolveSessionDisplayName, resolveSessionOptionGroups };
type SessionDefaultsSnapshot = {
mainSessionKey?: string;
mainKey?: string;
@@ -172,7 +174,51 @@ function renderCronFilterIcon(hiddenCount: number) {
}
export function renderChatSessionSelect(state: AppViewState) {
return renderChatSessionSelectBase(state, switchChatSession);
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const modelSelect = renderChatModelSelect(state);
const thinkingSelect = renderChatThinkingSelect(state);
const selectedSessionLabel =
sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey)
?.label ?? state.sessionKey;
return html`
<div class="chat-controls__session-row">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
title=${selectedSessionLabel}
?disabled=${!state.connected || sessionGroups.length === 0}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
if (state.sessionKey === next) {
return;
}
switchChatSession(state, next);
}}
>
${repeat(
sessionGroups,
(group) => group.id,
(group) =>
html`<optgroup label=${group.label}>
${repeat(
group.options,
(entry) => entry.key,
(entry) =>
html`<option
value=${entry.key}
title=${entry.title}
?selected=${entry.key === state.sessionKey}
>
${entry.label}
</option>`,
)}
</optgroup>`,
)}
</select>
</label>
${modelSelect} ${thinkingSelect}
</div>
`;
}
export function renderChatControls(state: AppViewState) {
@@ -533,6 +579,520 @@ async function refreshSessionOptions(state: AppViewState) {
});
}
function renderChatModelSelect(state: AppViewState) {
const { currentOverride, defaultLabel, options } = resolveChatModelSelectState(state);
const busy =
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled =
!state.connected || busy || (state.chatModelsLoading && options.length === 0) || !state.client;
const selectedLabel =
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
return html`
<label class="field chat-controls__session chat-controls__model">
<select
data-chat-model-select="true"
aria-label="Chat model"
title=${selectedLabel}
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatModel(state, next);
}}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
options,
(entry) => entry.value,
(entry) =>
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
${entry.label}
</option>`,
)}
</select>
</label>
`;
}
type ChatThinkingSelectOption = {
value: string;
label: string;
};
type ChatThinkingSelectState = {
currentOverride: string;
defaultLabel: string;
options: ChatThinkingSelectOption[];
};
function resolveThinkingTargetModel(state: AppViewState): {
provider: string | null;
model: string | null;
} {
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
return {
provider: activeRow?.modelProvider ?? state.sessionsResult?.defaults?.modelProvider ?? null,
model: activeRow?.model ?? state.sessionsResult?.defaults?.model ?? null,
};
}
function buildThinkingOptions(
provider: string | null,
model: string | null,
currentOverride: string,
): ChatThinkingSelectOption[] {
const seen = new Set<string>();
const options: ChatThinkingSelectOption[] = [];
const addOption = (value: string, label?: string) => {
const trimmed = value.trim();
if (!trimmed) {
return;
}
const key = normalizeLowercaseStringOrEmpty(trimmed);
if (seen.has(key)) {
return;
}
seen.add(key);
options.push({
value: trimmed,
label:
label ??
trimmed
.split(/[-_]/g)
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join(" "),
});
};
for (const label of listThinkingLevelLabels(provider)) {
const normalized = normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label);
addOption(normalized);
}
if (currentOverride) {
addOption(currentOverride);
}
return options;
}
function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelectState {
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
const persisted = activeRow?.thinkingLevel;
const currentOverride =
typeof persisted === "string" && persisted.trim()
? (normalizeThinkLevel(persisted) ?? persisted.trim())
: "";
const { provider, model } = resolveThinkingTargetModel(state);
const defaultLevel =
provider && model
? resolveThinkingDefaultForModel({
provider,
model,
catalog: state.chatModelCatalog ?? [],
})
: "off";
return {
currentOverride,
defaultLabel: `Default (${defaultLevel})`,
options: buildThinkingOptions(provider, model, currentOverride),
};
}
function renderChatThinkingSelect(state: AppViewState) {
const { currentOverride, defaultLabel, options } = resolveChatThinkingSelectState(state);
const busy =
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled = !state.connected || busy || !state.client;
const selectedLabel =
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
return html`
<label class="field chat-controls__session chat-controls__thinking-select">
<select
data-chat-thinking-select="true"
aria-label="Chat thinking level"
title=${selectedLabel}
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatThinkingLevel(state, next);
}}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
options,
(entry) => entry.value,
(entry) =>
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
${entry.label}
</option>`,
)}
</select>
</label>
`;
}
async function switchChatModel(state: AppViewState, nextModel: string) {
if (!state.client || !state.connected) {
return;
}
const currentOverride = resolveChatModelOverrideValue(state);
if (currentOverride === nextModel) {
return;
}
const targetSessionKey = state.sessionKey;
const prevOverride = state.chatModelOverrides[targetSessionKey];
state.lastError = null;
// Write the override cache immediately so the picker stays in sync during the RPC round-trip.
state.chatModelOverrides = {
...state.chatModelOverrides,
[targetSessionKey]: createChatModelOverride(nextModel),
};
try {
await state.client.request("sessions.patch", {
key: targetSessionKey,
model: nextModel || null,
});
void refreshVisibleToolsEffectiveForCurrentSession(state);
await refreshSessionOptions(state);
} catch (err) {
// Roll back so the picker reflects the actual server model.
state.chatModelOverrides = { ...state.chatModelOverrides, [targetSessionKey]: prevOverride };
state.lastError = `Failed to set model: ${String(err)}`;
}
}
function patchSessionThinkingLevel(
state: AppViewState,
sessionKey: string,
thinkingLevel: string | undefined,
) {
const current = state.sessionsResult;
if (!current) {
return;
}
state.sessionsResult = {
...current,
sessions: current.sessions.map((row) =>
row.key === sessionKey
? {
...row,
thinkingLevel,
}
: row,
),
};
}
async function switchChatThinkingLevel(state: AppViewState, nextThinkingLevel: string) {
if (!state.client || !state.connected) {
return;
}
const targetSessionKey = state.sessionKey;
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey);
const previousThinkingLevel = activeRow?.thinkingLevel;
const normalizedNext =
(normalizeThinkLevel(nextThinkingLevel) ?? nextThinkingLevel.trim()) || undefined;
const normalizedPrev =
typeof previousThinkingLevel === "string" && previousThinkingLevel.trim()
? (normalizeThinkLevel(previousThinkingLevel) ?? previousThinkingLevel.trim())
: undefined;
if ((normalizedPrev ?? "") === (normalizedNext ?? "")) {
return;
}
state.lastError = null;
patchSessionThinkingLevel(state, targetSessionKey, normalizedNext);
state.chatThinkingLevel = normalizedNext ?? null;
try {
await state.client.request("sessions.patch", {
key: targetSessionKey,
thinkingLevel: normalizedNext ?? null,
});
await refreshSessionOptions(state);
} catch (err) {
patchSessionThinkingLevel(state, targetSessionKey, previousThinkingLevel);
state.chatThinkingLevel = normalizedPrev ?? null;
state.lastError = `Failed to set thinking level: ${String(err)}`;
}
}
/* ── Channel display labels ────────────────────────────── */
const CHANNEL_LABELS: Record<string, string> = {
bluebubbles: "iMessage",
telegram: "Telegram",
discord: "Discord",
signal: "Signal",
slack: "Slack",
whatsapp: "WhatsApp",
matrix: "Matrix",
email: "Email",
sms: "SMS",
};
const KNOWN_CHANNEL_KEYS = Object.keys(CHANNEL_LABELS);
/** Parsed type / context extracted from a session key. */
export type SessionKeyInfo = {
/** Prefix for typed sessions (Subagent:/Cron:). Empty for others. */
prefix: string;
/** Human-readable fallback when no label / displayName is available. */
fallbackName: string;
};
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Parse a session key to extract type information and a human-readable
* fallback display name. Exported for testing.
*/
export function parseSessionKey(key: string): SessionKeyInfo {
const normalized = normalizeLowercaseStringOrEmpty(key);
// ── Main session ─────────────────────────────────
if (key === "main" || key === "agent:main:main") {
return { prefix: "", fallbackName: "Main Session" };
}
// ── Subagent ─────────────────────────────────────
if (key.includes(":subagent:")) {
return { prefix: "Subagent:", fallbackName: "Subagent:" };
}
// ── Cron job ─────────────────────────────────────
if (normalized.startsWith("cron:") || key.includes(":cron:")) {
return { prefix: "Cron:", fallbackName: "Cron Job:" };
}
// ── Direct chat (agent:<x>:<channel>:direct:<id>) ──
const directMatch = key.match(/^agent:[^:]+:([^:]+):direct:(.+)$/);
if (directMatch) {
const channel = directMatch[1];
const identifier = directMatch[2];
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
return { prefix: "", fallbackName: `${channelLabel} · ${identifier}` };
}
// ── Group chat (agent:<x>:<channel>:group:<id>) ────
const groupMatch = key.match(/^agent:[^:]+:([^:]+):group:(.+)$/);
if (groupMatch) {
const channel = groupMatch[1];
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
return { prefix: "", fallbackName: `${channelLabel} Group` };
}
// ── Channel-prefixed legacy keys (e.g. "bluebubbles:g-…") ──
for (const ch of KNOWN_CHANNEL_KEYS) {
if (key === ch || key.startsWith(`${ch}:`)) {
return { prefix: "", fallbackName: `${CHANNEL_LABELS[ch]} Session` };
}
}
// ── Unknown — return key as-is ───────────────────
return { prefix: "", fallbackName: key };
}
export function resolveSessionDisplayName(
key: string,
row?: SessionsListResult["sessions"][number],
): string {
const label = normalizeOptionalString(row?.label) ?? "";
const displayName = normalizeOptionalString(row?.displayName) ?? "";
const { prefix, fallbackName } = parseSessionKey(key);
const applyTypedPrefix = (name: string): string => {
if (!prefix) {
return name;
}
const prefixPattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*`, "i");
return prefixPattern.test(name) ? name : `${prefix} ${name}`;
};
if (label && label !== key) {
return applyTypedPrefix(label);
}
if (displayName && displayName !== key) {
return applyTypedPrefix(displayName);
}
return fallbackName;
}
export function isCronSessionKey(key: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(key);
if (!normalized) {
return false;
}
if (normalized.startsWith("cron:")) {
return true;
}
if (!normalized.startsWith("agent:")) {
return false;
}
const parts = normalized.split(":").filter(Boolean);
if (parts.length < 3) {
return false;
}
const rest = parts.slice(2).join(":");
return rest.startsWith("cron:");
}
type SessionOptionEntry = {
key: string;
label: string;
scopeLabel: string;
title: string;
};
type SessionOptionGroup = {
id: string;
label: string;
options: SessionOptionEntry[];
};
export function resolveSessionOptionGroups(
state: AppViewState,
sessionKey: string,
sessions: SessionsListResult | null,
): SessionOptionGroup[] {
const rows = sessions?.sessions ?? [];
const hideCron = state.sessionsHideCron ?? true;
const byKey = new Map<string, SessionsListResult["sessions"][number]>();
for (const row of rows) {
byKey.set(row.key, row);
}
const seenKeys = new Set<string>();
const groups = new Map<string, SessionOptionGroup>();
const ensureGroup = (groupId: string, label: string): SessionOptionGroup => {
const existing = groups.get(groupId);
if (existing) {
return existing;
}
const created: SessionOptionGroup = {
id: groupId,
label,
options: [],
};
groups.set(groupId, created);
return created;
};
const addOption = (key: string) => {
if (!key || seenKeys.has(key)) {
return;
}
seenKeys.add(key);
const row = byKey.get(key);
const parsed = parseAgentSessionKey(key);
const group = parsed
? ensureGroup(
`agent:${normalizeLowercaseStringOrEmpty(parsed.agentId)}`,
resolveAgentGroupLabel(state, parsed.agentId),
)
: ensureGroup("other", "Other Sessions");
const scopeLabel = normalizeOptionalString(parsed?.rest) ?? key;
const label = resolveSessionScopedOptionLabel(key, row, parsed?.rest);
group.options.push({
key,
label,
scopeLabel,
title: key,
});
};
for (const row of rows) {
if (row.key !== sessionKey && (row.kind === "global" || row.kind === "unknown")) {
continue;
}
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
continue;
}
addOption(row.key);
}
addOption(sessionKey);
for (const group of groups.values()) {
const counts = new Map<string, number>();
for (const option of group.options) {
counts.set(option.label, (counts.get(option.label) ?? 0) + 1);
}
for (const option of group.options) {
if ((counts.get(option.label) ?? 0) > 1 && option.scopeLabel !== option.label) {
option.label = `${option.label} · ${option.scopeLabel}`;
}
}
}
const allOptions = Array.from(groups.values()).flatMap((group) =>
group.options.map((option) => ({ groupLabel: group.label, option })),
);
const labels = new Map(allOptions.map(({ option }) => [option, option.label]));
const countAssignedLabels = () => {
const counts = new Map<string, number>();
for (const { option } of allOptions) {
const label = labels.get(option) ?? option.label;
counts.set(label, (counts.get(label) ?? 0) + 1);
}
return counts;
};
const labelIncludesScopeLabel = (label: string, scopeLabel: string) => {
const trimmedScope = scopeLabel.trim();
if (!trimmedScope) {
return false;
}
return (
label === trimmedScope ||
label.endsWith(` · ${trimmedScope}`) ||
label.endsWith(` / ${trimmedScope}`)
);
};
const globalCounts = countAssignedLabels();
for (const { groupLabel, option } of allOptions) {
const currentLabel = labels.get(option) ?? option.label;
if ((globalCounts.get(currentLabel) ?? 0) <= 1) {
continue;
}
const scopedPrefix = `${groupLabel} / `;
if (currentLabel.startsWith(scopedPrefix)) {
continue;
}
// Keep the agent visible once the native select collapses to a single chosen label.
labels.set(option, `${groupLabel} / ${currentLabel}`);
}
const scopedCounts = countAssignedLabels();
for (const { option } of allOptions) {
const currentLabel = labels.get(option) ?? option.label;
if ((scopedCounts.get(currentLabel) ?? 0) <= 1) {
continue;
}
if (labelIncludesScopeLabel(currentLabel, option.scopeLabel)) {
continue;
}
labels.set(option, `${currentLabel} · ${option.scopeLabel}`);
}
const finalCounts = countAssignedLabels();
for (const { option } of allOptions) {
const currentLabel = labels.get(option) ?? option.label;
if ((finalCounts.get(currentLabel) ?? 0) <= 1) {
continue;
}
// Fall back to the full key only when every friendlier disambiguator still collides.
labels.set(option, `${currentLabel} · ${option.key}`);
}
for (const { option } of allOptions) {
option.label = labels.get(option) ?? option.label;
}
return Array.from(groups.values());
}
/** Count sessions with a cron: key that would be hidden when hideCron=true. */
function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResult | null): number {
if (!sessions?.sessions) {
@@ -542,6 +1102,35 @@ function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResul
return sessions.sessions.filter((s) => isCronSessionKey(s.key) && s.key !== sessionKey).length;
}
function resolveAgentGroupLabel(state: AppViewState, agentIdRaw: string): string {
const normalized = normalizeLowercaseStringOrEmpty(agentIdRaw);
const agent = (state.agentsList?.agents ?? []).find(
(entry) => normalizeLowercaseStringOrEmpty(entry.id) === normalized,
);
const name =
normalizeOptionalString(agent?.identity?.name) ?? normalizeOptionalString(agent?.name) ?? "";
return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw;
}
function resolveSessionScopedOptionLabel(
key: string,
row?: SessionsListResult["sessions"][number],
rest?: string,
) {
const base = normalizeOptionalString(rest) ?? key;
if (!row) {
return base;
}
const label = normalizeOptionalString(row.label) ?? "";
const displayName = normalizeOptionalString(row.displayName) ?? "";
if ((label && label !== key) || (displayName && displayName !== key)) {
return resolveSessionDisplayName(key, row);
}
return base;
}
type ThemeModeOption = { id: ThemeMode; label: string; short: string };
const THEME_MODE_OPTIONS: ThemeModeOption[] = [
{ id: "system", label: "System", short: "SYS" },

View File

@@ -1,276 +0,0 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { AppViewState } from "../app-view-state.ts";
import {
createModelCatalog,
createSessionsListResult,
DEFAULT_CHAT_MODEL_CATALOG,
} from "../chat-model.test-helpers.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ModelCatalogEntry } from "../types.ts";
import { renderChatSessionSelect } from "./session-controls.ts";
function createChatHeaderState(
overrides: {
model?: string | null;
modelProvider?: string | null;
models?: ModelCatalogEntry[];
omitSessionFromList?: boolean;
} = {},
): { state: AppViewState; request: ReturnType<typeof vi.fn> } {
let currentModel = overrides.model ?? null;
let currentModelProvider = overrides.modelProvider ?? (currentModel ? "openai" : null);
const omitSessionFromList = overrides.omitSessionFromList ?? false;
const catalog = overrides.models ?? createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG);
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method === "sessions.patch") {
const nextModel = (params.model as string | null | undefined) ?? null;
if (!nextModel) {
currentModel = null;
currentModelProvider = null;
} else {
const normalized = nextModel.trim();
const slashIndex = normalized.indexOf("/");
if (slashIndex > 0) {
currentModelProvider = normalized.slice(0, slashIndex);
currentModel = normalized.slice(slashIndex + 1);
} else {
currentModel = normalized;
const matchingProviders = catalog
.filter((entry) => entry.id === normalized)
.map((entry) => entry.provider)
.filter(Boolean);
currentModelProvider =
matchingProviders.length === 1 ? matchingProviders[0] : currentModelProvider;
}
}
return { ok: true, key: "main" };
}
if (method === "chat.history") {
return { messages: [], thinkingLevel: null };
}
if (method === "sessions.list") {
return createSessionsListResult({
model: currentModel,
modelProvider: currentModelProvider,
omitSessionFromList,
});
}
if (method === "models.list") {
return { models: catalog };
}
if (method === "tools.effective") {
return {
agentId: "main",
profile: "coding",
groups: [],
};
}
throw new Error(`Unexpected request: ${method}`);
});
const state = {
sessionKey: "main",
connected: true,
sessionsHideCron: true,
sessionsResult: createSessionsListResult({
model: currentModel,
modelProvider: currentModelProvider,
omitSessionFromList,
}),
chatModelOverrides: {},
chatModelCatalog: catalog,
chatModelsLoading: false,
client: { request } as unknown as GatewayBrowserClient,
settings: {
gatewayUrl: "",
token: "",
locale: "en",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "dark",
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
borderRadius: 50,
chatFocusMode: false,
chatShowThinking: false,
},
chatMessage: "",
chatStream: null,
chatStreamStartedAt: null,
chatRunId: null,
chatQueue: [],
chatMessages: [],
chatLoading: false,
chatThinkingLevel: null,
lastError: null,
chatAvatarUrl: null,
basePath: "",
hello: null,
agentsList: null,
agentsPanel: "overview",
agentsSelectedId: null,
toolsEffectiveLoading: false,
toolsEffectiveLoadingKey: null,
toolsEffectiveResultKey: null,
toolsEffectiveError: null,
toolsEffectiveResult: null,
applySettings(next: AppViewState["settings"]) {
state.settings = next;
},
loadAssistantIdentity: vi.fn(),
resetToolStream: vi.fn(),
resetChatScroll: vi.fn(),
} as unknown as AppViewState & {
client: GatewayBrowserClient;
settings: AppViewState["settings"];
};
return { state, request };
}
function flushTasks() {
return new Promise<void>((resolve) => queueMicrotask(resolve));
}
describe("chat session controls", () => {
it("patches the current session model from the chat header picker", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state, request } = createChatHeaderState();
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.value).toBe("");
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: "openai/gpt-5-mini",
});
expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything());
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai");
vi.unstubAllGlobals();
});
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state, request } = createChatHeaderState();
state.agentsPanel = "tools";
state.agentsSelectedId = "main";
state.toolsEffectiveResultKey = "main:main";
state.toolsEffectiveResult = {
agentId: "main",
profile: "coding",
groups: [],
};
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("tools.effective", {
agentId: "main",
sessionKey: "main",
});
expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini");
vi.unstubAllGlobals();
});
it("clears the session model override back to the default model", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state, request } = createChatHeaderState({ model: "gpt-5-mini" });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.value).toBe("openai/gpt-5-mini");
modelSelect!.value = "";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: null,
});
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
vi.unstubAllGlobals();
});
it("disables the chat header model picker while a run is active", () => {
const { state } = createChatHeaderState();
state.chatRunId = "run-123";
state.chatStream = "Working";
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.disabled).toBe(true);
});
it("keeps the selected model visible when the active session is absent from sessions.list", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state } = createChatHeaderState({ omitSessionFromList: true });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
render(renderChatSessionSelect(state), container);
const rerendered = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(rerendered?.value).toBe("openai/gpt-5-mini");
vi.unstubAllGlobals();
});
});

View File

@@ -1,623 +0,0 @@
import { html } from "lit";
import { repeat } from "lit/directives/repeat.js";
import type { AppViewState } from "../app-view-state.ts";
import { createChatModelOverride } from "../chat-model-ref.ts";
import {
resolveChatModelOverrideValue,
resolveChatModelSelectState,
} from "../chat-model-select-state.ts";
import { refreshVisibleToolsEffectiveForCurrentSession } from "../controllers/agents.ts";
import { loadSessions } from "../controllers/sessions.ts";
import { parseAgentSessionKey } from "../session-key.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
import {
listThinkingLevelLabels,
normalizeThinkLevel,
resolveThinkingDefaultForModel,
} from "../thinking.ts";
import type { SessionsListResult } from "../types.ts";
type ChatSessionSwitchHandler = (state: AppViewState, nextSessionKey: string) => void;
export function renderChatSessionSelect(
state: AppViewState,
onSwitchSession: ChatSessionSwitchHandler = () => undefined,
) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const modelSelect = renderChatModelSelect(state);
const thinkingSelect = renderChatThinkingSelect(state);
const selectedSessionLabel =
sessionGroups.flatMap((group) => group.options).find((entry) => entry.key === state.sessionKey)
?.label ?? state.sessionKey;
return html`
<div class="chat-controls__session-row">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
title=${selectedSessionLabel}
?disabled=${!state.connected || sessionGroups.length === 0}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
if (state.sessionKey === next) {
return;
}
onSwitchSession(state, next);
}}
>
${repeat(
sessionGroups,
(group) => group.id,
(group) =>
html`<optgroup label=${group.label}>
${repeat(
group.options,
(entry) => entry.key,
(entry) =>
html`<option
value=${entry.key}
title=${entry.title}
?selected=${entry.key === state.sessionKey}
>
${entry.label}
</option>`,
)}
</optgroup>`,
)}
</select>
</label>
${modelSelect} ${thinkingSelect}
</div>
`;
}
async function refreshSessionOptions(state: AppViewState) {
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
activeMinutes: 0,
limit: 0,
includeGlobal: true,
includeUnknown: true,
});
}
function renderChatModelSelect(state: AppViewState) {
const { currentOverride, defaultLabel, options } = resolveChatModelSelectState(state);
const busy =
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled =
!state.connected || busy || (state.chatModelsLoading && options.length === 0) || !state.client;
const selectedLabel =
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
return html`
<label class="field chat-controls__session chat-controls__model">
<select
data-chat-model-select="true"
aria-label="Chat model"
title=${selectedLabel}
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatModel(state, next);
}}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
options,
(entry) => entry.value,
(entry) =>
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
${entry.label}
</option>`,
)}
</select>
</label>
`;
}
type ChatThinkingSelectOption = {
value: string;
label: string;
};
type ChatThinkingSelectState = {
currentOverride: string;
defaultLabel: string;
options: ChatThinkingSelectOption[];
};
function resolveThinkingTargetModel(state: AppViewState): {
provider: string | null;
model: string | null;
} {
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
return {
provider: activeRow?.modelProvider ?? state.sessionsResult?.defaults?.modelProvider ?? null,
model: activeRow?.model ?? state.sessionsResult?.defaults?.model ?? null,
};
}
function buildThinkingOptions(
provider: string | null,
model: string | null,
currentOverride: string,
): ChatThinkingSelectOption[] {
const seen = new Set<string>();
const options: ChatThinkingSelectOption[] = [];
const addOption = (value: string, label?: string) => {
const trimmed = value.trim();
if (!trimmed) {
return;
}
const key = normalizeLowercaseStringOrEmpty(trimmed);
if (seen.has(key)) {
return;
}
seen.add(key);
options.push({
value: trimmed,
label:
label ??
trimmed
.split(/[-_]/g)
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join(" "),
});
};
for (const label of listThinkingLevelLabels(provider)) {
const normalized = normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label);
addOption(normalized);
}
if (currentOverride) {
addOption(currentOverride);
}
return options;
}
function resolveChatThinkingSelectState(state: AppViewState): ChatThinkingSelectState {
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
const persisted = activeRow?.thinkingLevel;
const currentOverride =
typeof persisted === "string" && persisted.trim()
? (normalizeThinkLevel(persisted) ?? persisted.trim())
: "";
const { provider, model } = resolveThinkingTargetModel(state);
const defaultLevel =
provider && model
? resolveThinkingDefaultForModel({
provider,
model,
catalog: state.chatModelCatalog ?? [],
})
: "off";
return {
currentOverride,
defaultLabel: `Default (${defaultLevel})`,
options: buildThinkingOptions(provider, model, currentOverride),
};
}
export function renderChatThinkingSelect(state: AppViewState) {
const { currentOverride, defaultLabel, options } = resolveChatThinkingSelectState(state);
const busy =
state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null;
const disabled = !state.connected || busy || !state.client;
const selectedLabel =
currentOverride === ""
? defaultLabel
: (options.find((entry) => entry.value === currentOverride)?.label ?? currentOverride);
return html`
<label class="field chat-controls__session chat-controls__thinking-select">
<select
data-chat-thinking-select="true"
aria-label="Chat thinking level"
title=${selectedLabel}
?disabled=${disabled}
@change=${async (e: Event) => {
const next = (e.target as HTMLSelectElement).value.trim();
await switchChatThinkingLevel(state, next);
}}
>
<option value="" ?selected=${currentOverride === ""}>${defaultLabel}</option>
${repeat(
options,
(entry) => entry.value,
(entry) =>
html`<option value=${entry.value} ?selected=${entry.value === currentOverride}>
${entry.label}
</option>`,
)}
</select>
</label>
`;
}
async function switchChatModel(state: AppViewState, nextModel: string) {
if (!state.client || !state.connected) {
return;
}
const currentOverride = resolveChatModelOverrideValue(state);
if (currentOverride === nextModel) {
return;
}
const targetSessionKey = state.sessionKey;
const prevOverride = state.chatModelOverrides[targetSessionKey];
state.lastError = null;
// Write the override cache immediately so the picker stays in sync during the RPC round-trip.
state.chatModelOverrides = {
...state.chatModelOverrides,
[targetSessionKey]: createChatModelOverride(nextModel),
};
try {
await state.client.request("sessions.patch", {
key: targetSessionKey,
model: nextModel || null,
});
void refreshVisibleToolsEffectiveForCurrentSession(state);
await refreshSessionOptions(state);
} catch (err) {
// Roll back so the picker reflects the actual server model.
state.chatModelOverrides = { ...state.chatModelOverrides, [targetSessionKey]: prevOverride };
state.lastError = `Failed to set model: ${String(err)}`;
}
}
function patchSessionThinkingLevel(
state: AppViewState,
sessionKey: string,
thinkingLevel: string | undefined,
) {
const current = state.sessionsResult;
if (!current) {
return;
}
state.sessionsResult = {
...current,
sessions: current.sessions.map((row) =>
row.key === sessionKey
? {
...row,
thinkingLevel,
}
: row,
),
};
}
async function switchChatThinkingLevel(state: AppViewState, nextThinkingLevel: string) {
if (!state.client || !state.connected) {
return;
}
const targetSessionKey = state.sessionKey;
const activeRow = state.sessionsResult?.sessions?.find((row) => row.key === targetSessionKey);
const previousThinkingLevel = activeRow?.thinkingLevel;
const normalizedNext =
(normalizeThinkLevel(nextThinkingLevel) ?? nextThinkingLevel.trim()) || undefined;
const normalizedPrev =
typeof previousThinkingLevel === "string" && previousThinkingLevel.trim()
? (normalizeThinkLevel(previousThinkingLevel) ?? previousThinkingLevel.trim())
: undefined;
if ((normalizedPrev ?? "") === (normalizedNext ?? "")) {
return;
}
state.lastError = null;
patchSessionThinkingLevel(state, targetSessionKey, normalizedNext);
state.chatThinkingLevel = normalizedNext ?? null;
try {
await state.client.request("sessions.patch", {
key: targetSessionKey,
thinkingLevel: normalizedNext ?? null,
});
await refreshSessionOptions(state);
} catch (err) {
patchSessionThinkingLevel(state, targetSessionKey, previousThinkingLevel);
state.chatThinkingLevel = normalizedPrev ?? null;
state.lastError = `Failed to set thinking level: ${String(err)}`;
}
}
/* Channel display labels. */
const CHANNEL_LABELS: Record<string, string> = {
bluebubbles: "iMessage",
telegram: "Telegram",
discord: "Discord",
signal: "Signal",
slack: "Slack",
whatsapp: "WhatsApp",
matrix: "Matrix",
email: "Email",
sms: "SMS",
};
const KNOWN_CHANNEL_KEYS = Object.keys(CHANNEL_LABELS);
/** Parsed type / context extracted from a session key. */
export type SessionKeyInfo = {
/** Prefix for typed sessions (Subagent:/Cron:). Empty for others. */
prefix: string;
/** Human-readable fallback when no label / displayName is available. */
fallbackName: string;
};
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* Parse a session key to extract type information and a human-readable
* fallback display name. Exported for testing.
*/
export function parseSessionKey(key: string): SessionKeyInfo {
const normalized = normalizeLowercaseStringOrEmpty(key);
// Main session.
if (key === "main" || key === "agent:main:main") {
return { prefix: "", fallbackName: "Main Session" };
}
// Subagent.
if (key.includes(":subagent:")) {
return { prefix: "Subagent:", fallbackName: "Subagent:" };
}
// Cron job.
if (normalized.startsWith("cron:") || key.includes(":cron:")) {
return { prefix: "Cron:", fallbackName: "Cron Job:" };
}
// Direct chat: agent:<x>:<channel>:direct:<id>.
const directMatch = key.match(/^agent:[^:]+:([^:]+):direct:(.+)$/);
if (directMatch) {
const channel = directMatch[1];
const identifier = directMatch[2];
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
return { prefix: "", fallbackName: `${channelLabel} · ${identifier}` };
}
// Group chat: agent:<x>:<channel>:group:<id>.
const groupMatch = key.match(/^agent:[^:]+:([^:]+):group:(.+)$/);
if (groupMatch) {
const channel = groupMatch[1];
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
return { prefix: "", fallbackName: `${channelLabel} Group` };
}
// Channel-prefixed legacy keys, for example "bluebubbles:g-...".
for (const ch of KNOWN_CHANNEL_KEYS) {
if (key === ch || key.startsWith(`${ch}:`)) {
return { prefix: "", fallbackName: `${CHANNEL_LABELS[ch]} Session` };
}
}
// Unknown: return key as-is.
return { prefix: "", fallbackName: key };
}
export function resolveSessionDisplayName(
key: string,
row?: SessionsListResult["sessions"][number],
): string {
const label = normalizeOptionalString(row?.label) ?? "";
const displayName = normalizeOptionalString(row?.displayName) ?? "";
const { prefix, fallbackName } = parseSessionKey(key);
const applyTypedPrefix = (name: string): string => {
if (!prefix) {
return name;
}
const prefixPattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*`, "i");
return prefixPattern.test(name) ? name : `${prefix} ${name}`;
};
if (label && label !== key) {
return applyTypedPrefix(label);
}
if (displayName && displayName !== key) {
return applyTypedPrefix(displayName);
}
return fallbackName;
}
export function isCronSessionKey(key: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(key);
if (!normalized) {
return false;
}
if (normalized.startsWith("cron:")) {
return true;
}
if (!normalized.startsWith("agent:")) {
return false;
}
const parts = normalized.split(":").filter(Boolean);
if (parts.length < 3) {
return false;
}
const rest = parts.slice(2).join(":");
return rest.startsWith("cron:");
}
type SessionOptionEntry = {
key: string;
label: string;
scopeLabel: string;
title: string;
};
export type SessionOptionGroup = {
id: string;
label: string;
options: SessionOptionEntry[];
};
export function resolveSessionOptionGroups(
state: AppViewState,
sessionKey: string,
sessions: SessionsListResult | null,
): SessionOptionGroup[] {
const rows = sessions?.sessions ?? [];
const hideCron = state.sessionsHideCron ?? true;
const byKey = new Map<string, SessionsListResult["sessions"][number]>();
for (const row of rows) {
byKey.set(row.key, row);
}
const seenKeys = new Set<string>();
const groups = new Map<string, SessionOptionGroup>();
const ensureGroup = (groupId: string, label: string): SessionOptionGroup => {
const existing = groups.get(groupId);
if (existing) {
return existing;
}
const created: SessionOptionGroup = {
id: groupId,
label,
options: [],
};
groups.set(groupId, created);
return created;
};
const addOption = (key: string) => {
if (!key || seenKeys.has(key)) {
return;
}
seenKeys.add(key);
const row = byKey.get(key);
const parsed = parseAgentSessionKey(key);
const group = parsed
? ensureGroup(
`agent:${normalizeLowercaseStringOrEmpty(parsed.agentId)}`,
resolveAgentGroupLabel(state, parsed.agentId),
)
: ensureGroup("other", "Other Sessions");
const scopeLabel = normalizeOptionalString(parsed?.rest) ?? key;
const label = resolveSessionScopedOptionLabel(key, row, parsed?.rest);
group.options.push({
key,
label,
scopeLabel,
title: key,
});
};
for (const row of rows) {
if (row.key !== sessionKey && (row.kind === "global" || row.kind === "unknown")) {
continue;
}
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
continue;
}
addOption(row.key);
}
addOption(sessionKey);
for (const group of groups.values()) {
const counts = new Map<string, number>();
for (const option of group.options) {
counts.set(option.label, (counts.get(option.label) ?? 0) + 1);
}
for (const option of group.options) {
if ((counts.get(option.label) ?? 0) > 1 && option.scopeLabel !== option.label) {
option.label = `${option.label} · ${option.scopeLabel}`;
}
}
}
const allOptions = Array.from(groups.values()).flatMap((group) =>
group.options.map((option) => ({ groupLabel: group.label, option })),
);
const labels = new Map(allOptions.map(({ option }) => [option, option.label]));
const countAssignedLabels = () => {
const counts = new Map<string, number>();
for (const { option } of allOptions) {
const label = labels.get(option) ?? option.label;
counts.set(label, (counts.get(label) ?? 0) + 1);
}
return counts;
};
const labelIncludesScopeLabel = (label: string, scopeLabel: string) => {
const trimmedScope = scopeLabel.trim();
if (!trimmedScope) {
return false;
}
return (
label === trimmedScope ||
label.endsWith(` · ${trimmedScope}`) ||
label.endsWith(` / ${trimmedScope}`)
);
};
const globalCounts = countAssignedLabels();
for (const { groupLabel, option } of allOptions) {
const currentLabel = labels.get(option) ?? option.label;
if ((globalCounts.get(currentLabel) ?? 0) <= 1) {
continue;
}
const scopedPrefix = `${groupLabel} / `;
if (currentLabel.startsWith(scopedPrefix)) {
continue;
}
// Keep the agent visible once the native select collapses to a single chosen label.
labels.set(option, `${groupLabel} / ${currentLabel}`);
}
const scopedCounts = countAssignedLabels();
for (const { option } of allOptions) {
const currentLabel = labels.get(option) ?? option.label;
if ((scopedCounts.get(currentLabel) ?? 0) <= 1) {
continue;
}
if (labelIncludesScopeLabel(currentLabel, option.scopeLabel)) {
continue;
}
labels.set(option, `${currentLabel} · ${option.scopeLabel}`);
}
const finalCounts = countAssignedLabels();
for (const { option } of allOptions) {
const currentLabel = labels.get(option) ?? option.label;
if ((finalCounts.get(currentLabel) ?? 0) <= 1) {
continue;
}
// Fall back to the full key only when every friendlier disambiguator still collides.
labels.set(option, `${currentLabel} · ${option.key}`);
}
for (const { option } of allOptions) {
option.label = labels.get(option) ?? option.label;
}
return Array.from(groups.values());
}
function resolveAgentGroupLabel(state: AppViewState, agentIdRaw: string): string {
const normalized = normalizeLowercaseStringOrEmpty(agentIdRaw);
const agent = (state.agentsList?.agents ?? []).find(
(entry) => normalizeLowercaseStringOrEmpty(entry.id) === normalized,
);
const name =
normalizeOptionalString(agent?.identity?.name) ?? normalizeOptionalString(agent?.name) ?? "";
return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw;
}
function resolveSessionScopedOptionLabel(
key: string,
row?: SessionsListResult["sessions"][number],
rest?: string,
) {
const base = normalizeOptionalString(rest) ?? key;
if (!row) {
return base;
}
const label = normalizeOptionalString(row.label) ?? "";
const displayName = normalizeOptionalString(row.displayName) ?? "";
if ((label && label !== key) || (displayName && displayName !== key)) {
return resolveSessionDisplayName(key, row);
}
return base;
}

View File

@@ -96,11 +96,8 @@ function stubWindowGlobals(storage?: ReturnType<typeof createStorageMock>) {
vi.stubGlobal("window", {
location: { href: "http://127.0.0.1:18789/" },
localStorage: storage,
setTimeout: (handler: (...args: unknown[]) => void, timeout?: number, ...args: unknown[]) => {
// Keep connect debounce behavior testable without paying real 750ms waits per handshake.
const effectiveTimeout = timeout === 750 ? 0 : timeout;
return globalThis.setTimeout(() => handler(...args), effectiveTimeout);
},
setTimeout: (handler: (...args: unknown[]) => void, timeout?: number, ...args: unknown[]) =>
globalThis.setTimeout(() => handler(...args), timeout),
clearTimeout: (timeoutId: number | undefined) => globalThis.clearTimeout(timeoutId),
});
}
@@ -130,19 +127,10 @@ async function continueConnect(ws: MockWebSocket, nonce = "nonce-1") {
event: "connect.challenge",
payload: { nonce },
});
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(0);
} else {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(ws.sent.length).toBeGreaterThan(0);
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
return { ws, connectFrame: parseLatestConnectFrame(ws) };
}
async function expectSocketClosed(ws: MockWebSocket) {
await vi.waitFor(() => expect(ws.readyState).toBe(3), { interval: 1, timeout: 50 });
}
async function startConnect(client: InstanceType<typeof GatewayBrowserClient>, nonce = "nonce-1") {
client.start();
return await continueConnect(getLatestWebSocket(), nonce);
@@ -175,7 +163,7 @@ async function startRetriedDeviceTokenConnect(params: {
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
emitRetryableTokenMismatch(firstWs, firstConnect.id);
await expectSocketClosed(firstWs);
await vi.waitFor(() => expect(firstWs.readyState).toBe(3));
firstWs.emitClose(4008, "connect failed");
await vi.advanceTimersByTimeAsync(800);
@@ -343,7 +331,7 @@ describe("GatewayBrowserClient", () => {
details: { code: "AUTH_TOKEN_MISMATCH" },
},
});
await expectSocketClosed(secondWs);
await vi.waitFor(() => expect(secondWs.readyState).toBe(3));
secondWs.emitClose(4008, "connect failed");
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })?.token).toBe(
"stored-device-token",
@@ -386,7 +374,7 @@ describe("GatewayBrowserClient", () => {
details: { code: "AUTH_TOKEN_MISMATCH" },
},
});
await expectSocketClosed(ws1);
await vi.waitFor(() => expect(ws1.readyState).toBe(3));
ws1.emitClose(4008, "connect failed");
await vi.advanceTimersByTimeAsync(800);
@@ -456,7 +444,7 @@ describe("GatewayBrowserClient", () => {
details: { code: "AUTH_TOKEN_MISSING" },
},
});
await expectSocketClosed(ws1);
await vi.waitFor(() => expect(ws1.readyState).toBe(3));
ws1.emitClose(4008, "connect failed");
await vi.advanceTimersByTimeAsync(30_000);

View File

@@ -1,13 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import "../test-helpers/load-styles.ts";
import { mountApp as mountTestApp, registerAppMountHooks } from "./test-helpers/app-mount.ts";
registerAppMountHooks();
afterEach(() => {
vi.restoreAllMocks();
});
function mountApp(pathname: string) {
return mountTestApp(pathname);
}
@@ -162,7 +158,7 @@ describe("control UI routing", () => {
expect(app.querySelector(".dreams__lobster")).not.toBeNull();
});
it("renders the refreshed desktop navigation shell and collapsed state", async () => {
it("renders the refreshed top navigation shell", async () => {
const app = mountApp("/chat");
await app.updateComplete;
@@ -170,6 +166,11 @@ describe("control UI routing", () => {
expect(app.querySelector(".topnav-shell__content")).not.toBeNull();
expect(app.querySelector(".topnav-shell__actions")).not.toBeNull();
expect(app.querySelector(".topnav-shell .brand-title")).toBeNull();
});
it("renders the refreshed sidebar shell structure", async () => {
const app = mountApp("/chat");
await app.updateComplete;
expect(app.querySelector(".sidebar-shell")).not.toBeNull();
expect(app.querySelector(".sidebar-shell__header")).not.toBeNull();
@@ -178,6 +179,11 @@ describe("control UI routing", () => {
expect(app.querySelector(".sidebar-brand")).not.toBeNull();
expect(app.querySelector(".sidebar-brand__logo")).not.toBeNull();
expect(app.querySelector(".sidebar-brand__copy")).not.toBeNull();
});
it("does not render a desktop sidebar resizer or inject a custom nav width", async () => {
const app = mountApp("/chat");
await app.updateComplete;
app.applySettings({ ...app.settings, navWidth: 360 });
await app.updateComplete;
@@ -185,15 +191,36 @@ describe("control UI routing", () => {
expect(app.querySelector(".sidebar-resizer")).toBeNull();
const shell = app.querySelector<HTMLElement>(".shell");
expect(shell?.style.getPropertyValue("--shell-nav-width")).toBe("");
});
it("hides section labels in collapsed mode", async () => {
const app = mountApp("/chat");
await app.updateComplete;
app.applySettings({ ...app.settings, navCollapsed: true });
await app.updateComplete;
expect(app.querySelector(".nav-section__label")).toBeNull();
expect(app.querySelector(".sidebar-brand__logo")).toBeNull();
});
it("keeps footer utilities available in collapsed mode", async () => {
const app = mountApp("/chat");
await app.updateComplete;
app.applySettings({ ...app.settings, navCollapsed: true });
await app.updateComplete;
expect(app.querySelector(".sidebar-shell__footer")).not.toBeNull();
expect(app.querySelector(".sidebar-utility-link")).not.toBeNull();
});
it("keeps the collapsed desktop rail compact", async () => {
const app = mountApp("/chat");
await app.updateComplete;
app.applySettings({ ...app.settings, navCollapsed: true });
await app.updateComplete;
const item = app.querySelector<HTMLElement>(".sidebar .nav-item");
const header = app.querySelector<HTMLElement>(".sidebar-shell__header");
@@ -348,10 +375,6 @@ describe("control UI routing", () => {
});
it("auto-scrolls chat history to the latest message", async () => {
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
queueMicrotask(() => callback(performance.now()));
return 1;
});
const app = mountApp("/chat");
await app.updateComplete;
@@ -384,9 +407,9 @@ describe("control UI routing", () => {
scrollTop = Math.max(0, Math.min(top, 2400 - 180));
}) as typeof initialContainer.scrollTo;
app.chatMessages = Array.from({ length: 3 }, (_, index) => ({
app.chatMessages = Array.from({ length: 60 }, (_, index) => ({
role: "assistant",
content: `Line ${index}`,
content: `Line ${index} - ${"x".repeat(200)}`,
timestamp: Date.now() + index,
}));
@@ -428,8 +451,8 @@ describe("control UI routing", () => {
...app.chatMessages,
{
role: "assistant",
content: "Line 3",
timestamp: Date.now() + 3,
content: `Line 60 - ${"x".repeat(200)}`,
timestamp: Date.now() + 60,
},
];
await app.updateComplete;

View File

@@ -3,8 +3,17 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { getSafeLocalStorage } from "../../local-storage.ts";
import { renderChatSessionSelect } from "../app-render.helpers.ts";
import type { AppViewState } from "../app-view-state.ts";
import {
createModelCatalog,
createSessionsListResult,
DEFAULT_CHAT_MODEL_CATALOG,
} from "../chat-model.test-helpers.ts";
import { resetAssistantAttachmentAvailabilityCacheForTest } from "../chat/grouped-render.ts";
import { normalizeMessage } from "../chat/message-normalizer.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ModelCatalogEntry } from "../types.ts";
import type { SessionsListResult } from "../types.ts";
import { renderChat, type ChatProps } from "./chat.ts";
@@ -18,6 +27,125 @@ function createSessions(): SessionsListResult {
};
}
function createChatHeaderState(
overrides: {
model?: string | null;
modelProvider?: string | null;
models?: ModelCatalogEntry[];
omitSessionFromList?: boolean;
} = {},
): { state: AppViewState; request: ReturnType<typeof vi.fn> } {
let currentModel = overrides.model ?? null;
let currentModelProvider = overrides.modelProvider ?? (currentModel ? "openai" : null);
const omitSessionFromList = overrides.omitSessionFromList ?? false;
const catalog = overrides.models ?? createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG);
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method === "sessions.patch") {
const nextModel = (params.model as string | null | undefined) ?? null;
if (!nextModel) {
currentModel = null;
currentModelProvider = null;
} else {
const normalized = nextModel.trim();
const slashIndex = normalized.indexOf("/");
if (slashIndex > 0) {
currentModelProvider = normalized.slice(0, slashIndex);
currentModel = normalized.slice(slashIndex + 1);
} else {
currentModel = normalized;
const matchingProviders = catalog
.filter((entry) => entry.id === normalized)
.map((entry) => entry.provider)
.filter(Boolean);
currentModelProvider =
matchingProviders.length === 1 ? matchingProviders[0] : currentModelProvider;
}
}
return { ok: true, key: "main" };
}
if (method === "chat.history") {
return { messages: [], thinkingLevel: null };
}
if (method === "sessions.list") {
return createSessionsListResult({
model: currentModel,
modelProvider: currentModelProvider,
omitSessionFromList,
});
}
if (method === "models.list") {
return { models: catalog };
}
if (method === "tools.effective") {
return {
agentId: "main",
profile: "coding",
groups: [],
};
}
throw new Error(`Unexpected request: ${method}`);
});
const state = {
sessionKey: "main",
connected: true,
sessionsHideCron: true,
sessionsResult: createSessionsListResult({
model: currentModel,
modelProvider: currentModelProvider,
omitSessionFromList,
}),
chatModelOverrides: {},
chatModelCatalog: catalog,
chatModelsLoading: false,
client: { request } as unknown as GatewayBrowserClient,
settings: {
gatewayUrl: "",
token: "",
locale: "en",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "dark",
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
borderRadius: 50,
chatFocusMode: false,
chatShowThinking: false,
},
chatMessage: "",
chatStream: null,
chatStreamStartedAt: null,
chatRunId: null,
chatQueue: [],
chatMessages: [],
chatLoading: false,
chatThinkingLevel: null,
lastError: null,
chatAvatarUrl: null,
basePath: "",
hello: null,
agentsList: null,
agentsPanel: "overview",
agentsSelectedId: null,
toolsEffectiveLoading: false,
toolsEffectiveLoadingKey: null,
toolsEffectiveResultKey: null,
toolsEffectiveError: null,
toolsEffectiveResult: null,
applySettings(next: AppViewState["settings"]) {
state.settings = next;
},
loadAssistantIdentity: vi.fn(),
resetToolStream: vi.fn(),
resetChatScroll: vi.fn(),
} as unknown as AppViewState & {
client: GatewayBrowserClient;
settings: AppViewState["settings"];
};
return { state, request };
}
function flushTasks() {
return new Promise<void>((resolve) => queueMicrotask(resolve));
}
@@ -680,6 +808,144 @@ describe("chat view", () => {
expect(confirm?.classList.contains("chat-delete-confirm--right")).toBe(true);
});
it("patches the current session model from the chat header picker", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state, request } = createChatHeaderState();
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.value).toBe("");
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: "openai/gpt-5-mini",
});
expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything());
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai");
vi.unstubAllGlobals();
});
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state, request } = createChatHeaderState();
state.agentsPanel = "tools";
state.agentsSelectedId = "main";
state.toolsEffectiveResultKey = "main:main";
state.toolsEffectiveResult = {
agentId: "main",
profile: "coding",
groups: [],
};
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("tools.effective", {
agentId: "main",
sessionKey: "main",
});
expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini");
vi.unstubAllGlobals();
});
it("clears the session model override back to the default model", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state, request } = createChatHeaderState({ model: "gpt-5-mini" });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.value).toBe("openai/gpt-5-mini");
modelSelect!.value = "";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "main",
model: null,
});
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
vi.unstubAllGlobals();
});
it("disables the chat header model picker while a run is active", () => {
const { state } = createChatHeaderState();
state.chatRunId = "run-123";
state.chatStream = "Working";
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
expect(modelSelect?.disabled).toBe(true);
});
it("keeps the selected model visible when the active session is absent from sessions.list", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
} satisfies Partial<Response>),
);
const { state } = createChatHeaderState({ omitSessionFromList: true });
const container = document.createElement("div");
render(renderChatSessionSelect(state), container);
const modelSelect = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(modelSelect).not.toBeNull();
modelSelect!.value = "openai/gpt-5-mini";
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
await flushTasks();
render(renderChatSessionSelect(state), container);
const rerendered = container.querySelector<HTMLSelectElement>(
'select[data-chat-model-select="true"]',
);
expect(rerendered?.value).toBe("openai/gpt-5-mini");
vi.unstubAllGlobals();
});
it("keeps tool cards collapsed by default and expands them inline on demand", async () => {
const container = document.createElement("div");
const props = createProps({