Compare commits

..

31 Commits

Author SHA1 Message Date
Vincent Koc
85b0c5aea5 fix(ci): preserve gateway watch ready detection 2026-04-17 10:37:40 -07:00
Vincent Koc
6edfb61d29 fix(ci): scope extension boundary package checks 2026-04-17 10:29:38 -07:00
Vincent Koc
eb5caaf5b9 fix(ci): slim gateway watch regression harness 2026-04-17 10:22:56 -07:00
Peter Steinberger
89d3117ad0 test: narrow auth profile runtime mocks 2026-04-17 18:06:01 +01:00
Gustavo Madeira Santana
42817a1707 Tests: isolate OAuth mirror external auth lookup
Use the existing external auth test hook and a lightweight OAuth package mock so mirror-refresh coverage does not load provider runtime work while seeding test stores.
2026-04-17 12:50:52 -04:00
Peter Steinberger
8c249a8cca fix(matrix): keep guarded transport mockable 2026-04-17 17:44:11 +01:00
Gustavo Madeira Santana
8d7a722487 Tests: register models command text surfaces
Keep models command tests inside the in-memory channel registry for Discord and WhatsApp so text-surface assertions do not load bundled channel runtimes.
2026-04-17 12:40:04 -04:00
Peter Steinberger
f8a0ae0b08 test: merge redundant navigation shell assertions 2026-04-17 17:36:29 +01:00
Gustavo Madeira Santana
06e3d53c8a Tests: avoid bundled channel fallback in adapter test
Register a lightweight Telegram test plugin so the default-adapter assertion stays inside the in-memory registry instead of loading the real bundled channel runtime.
2026-04-17 12:34:38 -04:00
Peter Steinberger
7815d25eef fix: keep Matrix transport tests on mocked fetch 2026-04-17 17:33:34 +01:00
Peter Steinberger
8caad53f57 test(matrix): mock runtime fetch seam 2026-04-17 17:33:01 +01:00
Peter Steinberger
769198e67e perf: skip Matrix secret resolver for plain credentials 2026-04-17 17:31:00 +01:00
Peter Steinberger
41ef752dd8 fix(extensions): guard channel runtime fetches 2026-04-17 17:28:21 +01:00
Peter Steinberger
c580933623 test: shorten Matrix thread binding waits 2026-04-17 17:26:46 +01:00
Peter Steinberger
b9d5c1a58b test: narrow Matrix storage path test imports 2026-04-17 17:24:01 +01:00
Peter Steinberger
1d26f0cc6e test: flush navigation scroll frames directly 2026-04-17 17:21:49 +01:00
Peter Steinberger
75e09e21f2 test: remove gateway handshake waits 2026-04-17 17:20:26 +01:00
Peter Steinberger
a027a40c90 test(plugins): allow secret input runtime sdk subpath 2026-04-17 17:18:12 +01:00
Peter Steinberger
97f713f459 test(agents): isolate compaction token estimator mocks 2026-04-17 17:18:12 +01:00
Peter Steinberger
c0a16650d5 test(commands): fix command fixture typing 2026-04-17 17:18:12 +01:00
Peter Steinberger
a71b810e43 fix(plugin-sdk): expose session store runtime helpers 2026-04-17 17:18:12 +01:00
Peter Steinberger
ccc23f6cb6 test: trim navigation scroll fixture 2026-04-17 17:18:02 +01:00
Gustavo Madeira Santana
c66703300a Tests: narrow bootstrap routing coverage 2026-04-17 12:17:41 -04:00
Peter Steinberger
79cd5ed368 test: split Matrix client config tests 2026-04-17 17:16:21 +01:00
Peter Steinberger
54d9a09912 perf: narrow Matrix monitor reply imports 2026-04-17 17:14:44 +01:00
Peter Steinberger
24f8d6470e test: split chat session control tests 2026-04-17 17:11:28 +01:00
Peter Steinberger
73d8d3b2eb test: remove duplicate Matrix runtime API check 2026-04-17 17:08:37 +01:00
Peter Steinberger
d851f9e816 perf: narrow Matrix thread binding runtime imports 2026-04-17 17:04:31 +01:00
Peter Steinberger
d7f9f67296 perf: narrow Matrix onboarding resolution test 2026-04-17 16:58:19 +01:00
Peter Steinberger
14c4d6457a perf: narrow Matrix account runtime imports 2026-04-17 16:53:46 +01:00
Peter Steinberger
1fad8efa12 perf: split chat session controls 2026-04-17 16:45:37 +01:00
67 changed files with 2748 additions and 2504 deletions

View File

@@ -36,6 +36,7 @@ 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 }}
@@ -108,8 +109,16 @@ jobs:
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import { listChangedExtensionIds } from "./scripts/lib/changed-extensions.mjs";
import {
listChangedExtensionIds,
listChangedPathsForScope,
} 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",
@@ -117,9 +126,11 @@ 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
@@ -135,6 +146,7 @@ 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";
@@ -203,6 +215,7 @@ 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(
@@ -956,6 +969,8 @@ 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,4 +1,5 @@
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";
@@ -83,13 +84,20 @@ async function fetchChatCerts(): Promise<Record<string, string>> {
if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) {
return cachedCerts.certs;
}
const res = await fetch(CHAT_CERTS_URL);
if (!res.ok) {
throw new Error(`Failed to fetch Chat certs (${res.status})`);
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 certs = (await res.json()) as Record<string, string>;
cachedCerts = { fetchedAt: now, certs };
return certs;
}
export type GoogleChatAudienceType = "app-url" | "project-number";

View File

@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
response: await fetch(params.url, params.init),
release: async () => {},
})),
verifySignedJwtWithCertsAsync: vi.fn(),
verifyIdToken: vi.fn(),
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
}));
@@ -28,6 +29,7 @@ vi.mock("google-auth-library", () => ({
GoogleAuth: function GoogleAuth() {},
OAuth2Client: class {
verifyIdToken = mocks.verifyIdToken;
verifySignedJwtWithCertsAsync = mocks.verifySignedJwtWithCertsAsync;
},
}));
@@ -293,4 +295,34 @@ 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";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
} from "openclaw/plugin-sdk/account-resolution-runtime";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
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";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
resolveConfiguredMatrixAccountIds,

View File

@@ -1,24 +1,18 @@
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installMatrixTestRuntime } from "../test-runtime.js";
import type { CoreConfig } from "../types.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;
}
import {
backfillMatrixAuthDeviceIdAfterStartup,
resolveMatrixAuth,
setMatrixAuthClientDepsForTest,
} from "./client/config.js";
import * as credentialsReadModule from "./credentials-read.js";
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),
@@ -39,18 +33,10 @@ vi.mock("./client/storage.js", async () => {
};
});
const {
backfillMatrixAuthDeviceIdAfterStartup,
getMatrixScopedEnvVarNames,
resolveMatrixConfigForAccount,
resolveMatrixAuth,
resolveMatrixAuthContext,
setMatrixAuthClientDepsForTest,
resolveValidatedMatrixHomeserverUrl,
validateMatrixHomeserverUrl,
} = await import("./client/config.js");
vi.mock("./client/config-secret-input.runtime.js", () => ({
resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock,
}));
let credentialsReadModule: typeof import("./credentials-read.js") | undefined;
const ensureMatrixSdkLoggingConfiguredMock = vi.fn();
const matrixDoRequestMock = vi.fn();
@@ -60,721 +46,17 @@ 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(() => {
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);
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReset();
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReset();
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
saveMatrixCredentialsMock.mockReset();
saveBackfilledMatrixDeviceIdMock.mockReset().mockResolvedValue("saved");
touchMatrixCredentialsMock.mockReset();
repairCurrentTokenStorageMetaDeviceIdMock.mockReset().mockReturnValue(true);
resolveConfiguredSecretInputStringMock.mockReset().mockResolvedValue({});
ensureMatrixSdkLoggingConfiguredMock.mockReset();
matrixDoRequestMock.mockReset();
setMatrixAuthClientDepsForTest({
@@ -873,14 +155,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: {
@@ -908,14 +190,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: {
@@ -960,13 +242,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: {
@@ -1051,8 +333,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",
@@ -1320,7 +602,7 @@ describe("resolveMatrixAuth", () => {
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
vi.mocked(requireCredentialsReadModule().loadMatrixCredentials).mockReturnValue({
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-new",
@@ -1377,52 +659,51 @@ describe("resolveMatrixAuth", () => {
expect(saveBackfilledMatrixDeviceIdMock).not.toHaveBeenCalled();
});
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);
it("resolves configured accessToken SecretRefs during Matrix auth", async () => {
matrixDoRequestMock.mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "resolved-token" });
try {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: { source: "file", provider: "matrix-file", id: "value" },
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",
},
},
secrets: {
providers: {
"matrix-file": {
source: "file",
path: secretPath,
mode: "singleValue",
},
},
},
} as CoreConfig;
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
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 });
}
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",
});
});
it("does not resolve inactive password SecretRefs when scoped token auth wins", async () => {
@@ -1471,13 +752,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

@@ -0,0 +1,713 @@
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";
} from "openclaw/plugin-sdk/secret-input-runtime";
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
import {
requiresExplicitMatrixDefaultAccount,
@@ -351,6 +351,15 @@ 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,
@@ -363,13 +372,9 @@ async function resolveConfiguredMatrixAuthSecretInput(params: {
return resolved.value;
}
if (coerceSecretRef(configured.value, params.cfg.secrets?.defaults)) {
throw new Error(
resolved.unresolvedRefReason ?? `${configured.path} SecretRef could not be resolved.`,
);
}
return undefined;
throw new Error(
resolved.unresolvedRefReason ?? `${configured.path} SecretRef could not be resolved.`,
);
}
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 "../../../runtime-api.js";
import { resolveMatrixAccountStorageRoot } from "../../storage-paths.js";
import { installMatrixTestRuntime } from "../../test-runtime.js";
import {
claimCurrentTokenStorageState,

View File

@@ -1,7 +1,5 @@
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 = {
@@ -949,26 +947,3 @@ 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 "../../../runtime-api.js";
import { resolveMatrixAccountStorageRoot } from "../../storage-paths.js";
import type { MatrixRoomKeyBackupRestoreResult } from "../sdk.js";
import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js";

View File

@@ -15,8 +15,10 @@ export {
patchAllowlistUsersInConfigEntries,
summarizeMapping,
} from "openclaw/plugin-sdk/allow-from";
export { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { createTypingCallbacks } from "openclaw/plugin-sdk/channel-reply-pipeline";
export {
createReplyPrefixOptions,
createTypingCallbacks,
} from "openclaw/plugin-sdk/channel-reply-options-runtime";
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,6 +18,21 @@ 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;
@@ -224,11 +239,13 @@ 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 () => {
@@ -238,7 +255,7 @@ describe("MatrixClient request hardening", () => {
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
stubRuntimeFetch(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(
@@ -265,7 +282,7 @@ describe("MatrixClient request hardening", () => {
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
async () => new Response(payload, { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
ssrfPolicy: { allowPrivateNetwork: true },
@@ -296,7 +313,7 @@ describe("MatrixClient request hardening", () => {
}
return new Response(payload, { status: 200 });
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
ssrfPolicy: { allowPrivateNetwork: true },
@@ -475,7 +492,7 @@ describe("MatrixClient request hardening", () => {
},
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
ssrfPolicy: { allowPrivateNetwork: true },
@@ -506,7 +523,7 @@ describe("MatrixClient request hardening", () => {
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
ssrfPolicy: { allowPrivateNetwork: true },
@@ -531,7 +548,7 @@ describe("MatrixClient request hardening", () => {
});
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
stubRuntimeFetch(fetchMock as unknown as typeof fetch);
const client = new MatrixClient("http://127.0.0.1:8008", "token", {
localTimeoutMs: 25,

View File

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

View File

@@ -8,6 +8,15 @@ 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();
@@ -19,8 +28,7 @@ describe("performMatrixRequest", () => {
});
it("rejects oversized raw responses before buffering the whole body", async () => {
vi.stubGlobal(
"fetch",
stubRuntimeFetch(
vi.fn(
async () =>
new Response("too-big", {
@@ -55,8 +63,7 @@ describe("performMatrixRequest", () => {
controller.close();
},
});
vi.stubGlobal(
"fetch",
stubRuntimeFetch(
vi.fn(
async () =>
new Response(stream, {
@@ -87,8 +94,7 @@ describe("performMatrixRequest", () => {
controller.enqueue(new Uint8Array([1, 2, 3]));
},
});
vi.stubGlobal(
"fetch",
stubRuntimeFetch(
vi.fn(
async () =>
new Response(stream, {
@@ -135,12 +141,7 @@ describe("performMatrixRequest", () => {
},
});
});
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
Agent: function MockAgent() {},
EnvHttpProxyAgent: function MockEnvHttpProxyAgent() {},
ProxyAgent: function MockProxyAgent() {},
fetch: runtimeFetch,
};
stubRuntimeFetch(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,13 +89,6 @@ 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;
@@ -104,10 +97,7 @@ 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.
if (params.init.dispatcher && !isMockedFetch(globalThis.fetch)) {
return await fetchWithRuntimeDispatcher(params.url, params.init);
}
return await fetch(params.url, params.init);
return await fetchWithRuntimeDispatcherOrMockedGlobal(params.url, params.init);
}
async function fetchWithMatrixGuardedRedirects(params: {

View File

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

View File

@@ -311,15 +311,17 @@ 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: [],
});
});
await vi.waitFor(
async () => {
const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8");
expect(JSON.parse(persistedRaw)).toMatchObject({
version: 1,
bindings: [],
});
},
{ interval: 1, timeout: 100 },
);
} finally {
vi.useRealTimers();
}
@@ -353,23 +355,22 @@ 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);
});
await vi.waitFor(() => {
expect(logVerboseMessage).toHaveBeenCalledWith(
expect.stringContaining("matrix: auto-unbinding $thread due to idle-expired"),
);
});
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 },
);
expect(
getSessionBindingService().resolveByConversation({
@@ -640,9 +641,12 @@ 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);
});
await vi.waitFor(
async () => {
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(secondTouchedAt);
},
{ interval: 1, timeout: 100 },
);
} finally {
vi.useRealTimers();
}
@@ -661,9 +665,12 @@ describe("matrix thread bindings", () => {
vi.useRealTimers();
const bindingsPath = resolveBindingsFilePath();
await vi.waitFor(async () => {
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(touchedAt);
});
await vi.waitFor(
async () => {
expect(await readPersistedLastActivityAt(bindingsPath)).toBe(touchedAt);
},
{ interval: 1, timeout: 100 },
);
} 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/routing";
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/session-key-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
registerSessionBindingAdapter,
resolveThreadBindingFarewellText,
type SessionBindingAdapter,
unregisterSessionBindingAdapter,
} from "openclaw/plugin-sdk/thread-bindings-runtime";
} from "openclaw/plugin-sdk/thread-bindings-session-runtime";
import { claimCurrentTokenStorageState, resolveMatrixStateFilePath } from "./client/storage.js";
import type { MatrixAuth } from "./client/types.js";
import type { MatrixClient } from "./sdk.js";

View File

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

View File

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

View File

@@ -212,6 +212,10 @@
"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"
@@ -284,6 +288,10 @@
"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"
@@ -420,6 +428,10 @@
"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"
@@ -676,6 +688,10 @@
"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,6 +28,33 @@ 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="));
@@ -86,6 +113,9 @@ 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}`);
}
@@ -195,6 +225,97 @@ 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 (
@@ -768,7 +889,15 @@ export async function main(argv = process.argv.slice(2)) {
const startedAt = Date.now();
const mode = parseMode(argv);
const optInExtensionIds = collectOptInExtensionIds();
const canaryExtensionIds = collectCanaryExtensionIds(optInExtensionIds);
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 cleanupExtensionIds = optInExtensionIds;
const shouldRunCanary = mode === "all" || mode === "canary";
const releaseBoundaryLock = acquireBoundaryCheckLock();
@@ -782,16 +911,17 @@ export async function main(argv = process.argv.slice(2)) {
try {
cleanupCanaryArtifactsForExtensions(cleanupExtensionIds);
if (mode === "all" || mode === "compile") {
if ((mode === "all" || mode === "compile") && compileExtensionIds.length > 0) {
({ prepElapsedMs, compileCount, skippedCompileCount, compileElapsedMs, compileTimings } =
await runCompileCheck(optInExtensionIds));
await runCompileCheck(compileExtensionIds));
}
if (shouldRunCanary) {
if (shouldRunCanary && canaryExtensionIds.length > 0) {
({ 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: 20_000,
readyTimeoutMs: 5_000,
readySettleMs: 500,
sigkillGraceMs: 10_000,
cpuWarnMs: 1_000,
@@ -34,6 +34,8 @@ 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) {
@@ -96,9 +98,54 @@ function normalizePath(filePath) {
return filePath.replaceAll("\\", "/");
}
function listTreeEntries(rootName) {
const rootPath = path.join(process.cwd(), rootName);
if (!fs.existsSync(rootPath)) {
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)) {
return [`${rootName} (missing)`];
}
@@ -109,10 +156,10 @@ function listTreeEntries(rootName) {
if (!current) {
continue;
}
const dirents = fs.readdirSync(current, { withFileTypes: true });
const dirents = safeReaddirSync(fsImpl, current);
for (const dirent of dirents) {
const fullPath = path.join(current, dirent.name);
const relativePath = normalizePath(path.relative(process.cwd(), fullPath));
const relativePath = normalizePath(path.relative(cwd, fullPath));
entries.push(relativePath);
if (dirent.isDirectory()) {
queue.push(fullPath);
@@ -135,10 +182,12 @@ function humanBytes(bytes) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
}
function snapshotTree(rootName) {
const rootPath = path.join(process.cwd(), rootName);
export function snapshotTree(rootName, params = {}) {
const fsImpl = params.fs ?? fs;
const cwd = params.cwd ?? process.cwd();
const rootPath = path.join(cwd, rootName);
const stats = {
exists: fs.existsSync(rootPath),
exists: fsImpl.existsSync(rootPath),
files: 0,
directories: 0,
symlinks: 0,
@@ -156,11 +205,14 @@ function snapshotTree(rootName) {
if (!current) {
continue;
}
const currentStats = fs.lstatSync(current);
const currentStats = safeLstatSync(fsImpl, current);
if (!currentStats) {
continue;
}
stats.entries += 1;
if (currentStats.isDirectory()) {
stats.directories += 1;
for (const dirent of fs.readdirSync(current, { withFileTypes: true })) {
for (const dirent of safeReaddirSync(fsImpl, current)) {
queue.push(path.join(current, dirent.name));
}
continue;
@@ -312,13 +364,15 @@ function readProcessTreeCpuMs(rootPid) {
async function waitForGatewayReady(readText, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (/\[gateway\] ready \(/.test(readText())) {
while (true) {
if (hasGatewayReadyLog(readText())) {
return true;
}
if (Date.now() >= deadline) {
return false;
}
await sleep(100);
}
return false;
}
async function allocateLoopbackPort() {
@@ -435,7 +489,7 @@ async function runTimedWatch(options, outputDir) {
});
const exitPromise = new Promise((resolve) => {
child.on("exit", (code, signal) => resolve({ code, signal }));
child.on("close", (code, signal) => resolve({ code, signal }));
});
let watchPid = null;
@@ -447,7 +501,7 @@ async function runTimedWatch(options, outputDir) {
await sleep(100);
}
const readyBeforeWindow = await waitForGatewayReady(
let readyBeforeWindow = await waitForGatewayReady(
() => `${stdout}\n${stderr}`,
options.readyTimeoutMs,
);
@@ -482,6 +536,7 @@ 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)
@@ -559,11 +614,13 @@ function buildRunNodeDeps(env) {
};
}
async function main() {
export async function main() {
const options = parseArgs(process.argv.slice(2));
ensureDir(options.outputDir);
if (!options.skipBuild) {
runCheckedCommand("pnpm", ["build"]);
// 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"]);
// 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.
@@ -697,4 +754,6 @@ async function main() {
process.exit(0);
}
await main();
if (import.meta.main) {
await main();
}

View File

@@ -70,6 +70,11 @@ 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"));
}
@@ -122,8 +127,7 @@ export function listChangedExtensionIds(params = {}) {
const unavailableBaseBehavior = params.unavailableBaseBehavior ?? "error";
try {
const base = resolveChangedPathsBase(params);
return detectChangedExtensionIds(listChangedPaths(base, head));
return detectChangedExtensionIds(listChangedPathsForScope({ ...params, head }));
} catch (error) {
if (unavailableBaseBehavior === "all") {
return listAvailableExtensionIds();

View File

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

View File

@@ -25,6 +25,12 @@ 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,12 +48,32 @@ 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,
}));
@@ -74,7 +100,11 @@ function storeWith(profileId: string, cred: OAuthCredential): AuthProfileStore {
}
describe("OAuth credential adoption is identity-gated", () => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
const envSnapshot = captureEnv([
"OPENCLAW_STATE_DIR",
"OPENCLAW_AGENT_DIR",
"PI_CODING_AGENT_DIR",
]);
let tempRoot = "";
let mainAgentDir = "";
@@ -88,6 +118,8 @@ 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();
@@ -143,7 +175,7 @@ describe("OAuth credential adoption is identity-gated", () => {
mainAgentDir,
);
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -212,7 +244,7 @@ describe("OAuth credential adoption is identity-gated", () => {
}) as never,
);
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -274,7 +306,7 @@ describe("OAuth credential adoption is identity-gated", () => {
mainAgentDir,
);
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -331,7 +363,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 resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -380,7 +412,7 @@ describe("OAuth credential adoption is identity-gated", () => {
mainAgentDir,
);
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -444,7 +476,7 @@ describe("OAuth credential adoption is identity-gated", () => {
}) as never,
);
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -511,7 +543,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(
resolveApiKeyForProfile({
resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -574,7 +606,7 @@ describe("OAuth credential adoption is identity-gated", () => {
throw new Error("upstream 503 service unavailable");
});
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -643,7 +675,7 @@ describe("OAuth credential adoption is identity-gated", () => {
});
await expect(
resolveApiKeyForProfile({
resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,

View File

@@ -18,6 +18,12 @@ 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,
@@ -35,12 +41,22 @@ 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,
}));
@@ -79,7 +95,11 @@ function createExpiredOauthStore(params: {
}
describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", () => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
const envSnapshot = captureEnv([
"OPENCLAW_STATE_DIR",
"OPENCLAW_AGENT_DIR",
"PI_CODING_AGENT_DIR",
]);
let tempRoot = "";
let mainAgentDir = "";
@@ -93,6 +113,8 @@ 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.
@@ -150,7 +172,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) =>
resolveApiKeyForProfile({
resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
@@ -165,6 +187,5 @@ describe("resolveApiKeyForProfile cross-agent refresh coordination (#26322)", ()
expect(result?.apiKey).toBe("cross-agent-refreshed-access");
expect(result?.provider).toBe(provider);
}
}, // Generous timeout; the fix should complete well under 5s in practice.
60_000);
}, 60_000); // Generous timeout; the fix should complete well under 5s in practice.
});

View File

@@ -4,6 +4,7 @@ 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,
@@ -18,6 +19,12 @@ 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,
@@ -35,12 +42,35 @@ 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,
}));
@@ -76,7 +106,11 @@ function createExpiredOauthStore(params: {
}
describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
const envSnapshot = captureEnv([
"OPENCLAW_STATE_DIR",
"OPENCLAW_AGENT_DIR",
"PI_CODING_AGENT_DIR",
]);
let tempRoot = "";
let mainAgentDir = "";
@@ -86,10 +120,13 @@ 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();
@@ -98,6 +135,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
afterEach(async () => {
envSnapshot.restore();
resetFileLockStateForTest();
externalAuthTesting.resetResolveExternalAuthProfilesForTest();
clearRuntimeAuthProfileStoreSnapshots();
if (resetOAuthRefreshQueuesForTest) {
resetOAuthRefreshQueuesForTest();
@@ -130,7 +168,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -174,7 +212,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 resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(undefined),
profileId,
agentDir: undefined,
@@ -228,7 +266,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -294,7 +332,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -359,7 +397,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 resolveApiKeyForProfile({
await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -415,7 +453,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -473,7 +511,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
await resolveApiKeyForProfile({
await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -522,7 +560,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 resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -585,7 +623,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
throw new Error("upstream 503 service unavailable");
});
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -649,7 +687,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -707,7 +745,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
await resolveApiKeyForProfile({
await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,
@@ -747,7 +785,7 @@ describe("resolveApiKeyForProfile OAuth refresh mirror-to-main (#26322)", () =>
}) as never,
);
const result = await resolveApiKeyForProfile({
const result = await resolveApiKeyForProfileInTest({
store: ensureAuthProfileStore(subAgentDir),
profileId,
agentDir: subAgentDir,

View File

@@ -23,6 +23,11 @@ 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,13 +1,50 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import {
estimateMessagesTokens,
pruneHistoryForContextShare,
splitMessagesByTokenShare,
} from "./compaction.js";
import { beforeAll, describe, expect, it, vi } from "vitest";
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

@@ -0,0 +1,71 @@
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,60 +1,28 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTempPaths,
createContextEngineAttemptRunner,
getHoisted,
resetEmbeddedAttemptHarness,
} from "./attempt.spawn-workspace.test-support.js";
const hoisted = getHoisted();
import { describe, expect, it, vi } from "vitest";
import { resolveAttemptWorkspaceBootstrapRouting } from "./attempt-bootstrap-routing.js";
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";
let capturedPrompt = "";
hoisted.resolveSandboxContextMock.mockResolvedValue({
enabled: true,
workspaceAccess: "ro",
workspaceDir: sandboxWorkspace,
});
hoisted.isWorkspaceBootstrapPendingMock.mockImplementation(async (workspaceDir: string) => {
const canonicalWorkspace = "/tmp/openclaw-canonical-workspace";
const isWorkspaceBootstrapPending = vi.fn(async (workspaceDir: string) => {
return workspaceDir === sandboxWorkspace;
});
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,
];
},
const routing = await resolveAttemptWorkspaceBootstrapRouting({
isWorkspaceBootstrapPending,
trigger: "user",
isPrimaryRun: true,
isCanonicalWorkspace: true,
effectiveWorkspace: sandboxWorkspace,
resolvedWorkspace: canonicalWorkspace,
hasBootstrapFileAccess: true,
});
expect(hoisted.isWorkspaceBootstrapPendingMock).toHaveBeenCalledTimes(1);
expect(hoisted.isWorkspaceBootstrapPendingMock).not.toHaveBeenCalledWith(sandboxWorkspace);
expect(capturedPrompt).not.toContain("[Bootstrap pending]");
expect(isWorkspaceBootstrapPending).toHaveBeenCalledOnce();
expect(isWorkspaceBootstrapPending).toHaveBeenCalledWith(canonicalWorkspace);
expect(isWorkspaceBootstrapPending).not.toHaveBeenCalledWith(sandboxWorkspace);
expect(routing.bootstrapMode).toBe("none");
expect(routing.userPromptPrefixText).toBeUndefined();
});
});

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,7 +52,6 @@ import {
resolveBootstrapContextForRun,
resolveContextInjectionMode,
} from "../../bootstrap-files.js";
import { resolveBootstrapMode } from "../../bootstrap-mode.js";
import { createCacheTrace } from "../../cache-trace.js";
import {
listChannelSupportedActions,
@@ -114,7 +113,6 @@ 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 {
@@ -182,6 +180,11 @@ 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,
@@ -318,12 +321,6 @@ 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);
}
@@ -338,8 +335,7 @@ 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,
@@ -484,8 +480,6 @@ 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
? []
@@ -555,20 +549,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 bootstrapMode = resolveBootstrapMode({
bootstrapPending: workspaceBootstrapPending,
runKind: bootstrapRunKind,
isInteractiveUserFacing: params.trigger === "user" || params.trigger === "manual",
const bootstrapRouting = await resolveAttemptWorkspaceBootstrapRouting({
isWorkspaceBootstrapPending,
bootstrapContextRunKind: params.bootstrapContextRunKind,
trigger: params.trigger,
sessionKey: params.sessionKey,
isPrimaryRun: isPrimaryBootstrapRun(params.sessionKey),
isCanonicalWorkspace:
(params.isCanonicalWorkspace ?? true) && effectiveWorkspace === resolvedWorkspace,
isCanonicalWorkspace: params.isCanonicalWorkspace,
effectiveWorkspace,
resolvedWorkspace,
hasBootstrapFileAccess: bootstrapHasFileAccess,
});
const shouldStripBootstrapFromContext = shouldStripBootstrapFromEmbeddedContext({
bootstrapMode,
});
const bootstrapMode = bootstrapRouting.bootstrapMode;
const shouldStripBootstrapFromContext = bootstrapRouting.shouldStripBootstrapFromContext;
const {
bootstrapFiles: hookAdjustedBootstrapFiles,
contextFiles: resolvedContextFiles,
@@ -576,7 +570,7 @@ export async function runEmbeddedAttempt(
} = await resolveAttemptBootstrapContext({
contextInjectionMode,
bootstrapContextMode: params.bootstrapContextMode,
bootstrapContextRunKind: bootstrapRunKind,
bootstrapContextRunKind: params.bootstrapContextRunKind ?? "default",
bootstrapMode,
sessionFile: params.sessionFile,
hasCompletedBootstrapTurn,
@@ -927,9 +921,7 @@ export async function runEmbeddedAttempt(
});
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
let systemPromptText = systemPromptOverride();
const userPromptPrefixText = buildAgentUserPromptPrefix({
bootstrapMode,
});
const userPromptPrefixText = bootstrapRouting.userPromptPrefixText;
let sessionManager: ReturnType<typeof guardSessionManager> | undefined;
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;

View File

@@ -1,11 +1,51 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { estimateToolResultReductionPotential } from "../tool-result-truncation.js";
import {
PREEMPTIVE_OVERFLOW_ERROR_TEXT,
estimatePrePromptTokens,
shouldPreemptivelyCompactBeforePrompt,
} from "./preemptive-compaction.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"));
});
let timestamp = 1;

View File

@@ -54,6 +54,12 @@ 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([
@@ -67,6 +73,7 @@ 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>
}
}
export const resolveDevWorkspaceDir = (env: NodeJS.ProcessEnv = process.env): string => {
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) {
}
}
export async function ensureDevWorkspace(dir: string) {
async function ensureDevWorkspace(dir: string) {
const resolvedDir = resolveUserPath(dir);
await fs.promises.mkdir(resolvedDir, { recursive: true });

View File

@@ -76,11 +76,6 @@ 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,13 +18,6 @@ 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");
@@ -77,7 +70,6 @@ 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", () => {
@@ -85,7 +77,6 @@ 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,11 +50,6 @@ 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

@@ -1,57 +0,0 @@
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

@@ -1,57 +0,0 @@
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,6 +4,7 @@ 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";
@@ -401,9 +402,10 @@ describe("agentCommand ACP runtime routing", () => {
return {
kind: "stale",
sessionKey,
error: Object.assign(new Error(`ACP metadata is missing for session ${sessionKey}.`), {
code: "ACP_SESSION_INIT_FAILED",
}),
error: new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`ACP metadata is missing for session ${sessionKey}.`,
),
};
},
});

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(() => []);
const listChannelPluginCatalogEntries = vi.fn((..._args: unknown[]) => []);
vi.mock("../../channels/plugins/catalog.js", () => {
return {
getChannelPluginCatalogEntry: (...args: unknown[]) => getChannelPluginCatalogEntry(...args),

View File

@@ -4,6 +4,7 @@ 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,9 +1,6 @@
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,
@@ -226,43 +223,6 @@ 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,5 +1,4 @@
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";
@@ -188,28 +187,6 @@ 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;
@@ -221,26 +198,8 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
}
try {
await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 });
if (!(await pathExists(pathname))) {
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
return;
}
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
} 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,6 +25,7 @@ function createDefaultSessionStoreEntry() {
cacheRead: 2_000,
cacheWrite: 1_000,
totalTokens: 5_000,
totalTokensFresh: true as boolean,
contextTokens: 10_000,
model: "pi:opus",
sessionId: "abc123",
@@ -188,7 +189,11 @@ async function createStatusServiceSummary(
}
function createSessionStatusRows() {
const agents = mocks.listGatewayAgentsBasic().agents ?? [{ id: "main", name: "Main" }];
const agents = (mocks.listGatewayAgentsBasic().agents ?? [
{ id: "main", name: "Main" },
]) as Array<{
id: string;
}>;
const byAgent = agents.map((agent: { id: string }) => {
const path = mocks.resolveStorePath("sessions", { agentId: agent.id });
const store = mocks.loadSessionStore(path) as Record<
@@ -198,7 +203,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 !== false
typeof entry.totalTokens === "number" && (entry.totalTokensFresh ?? true)
? entry.totalTokens
: null;
return {

View File

@@ -1,195 +0,0 @@
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",
);
});
});

View File

@@ -1,134 +0,0 @@
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,3 +90,13 @@ 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,6 +36,11 @@ describe("getChannelMessageAdapter", () => {
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "discord", plugin: discordCrossContextPlugin, source: "test" },
{
pluginId: "telegram",
plugin: createChannelTestPluginBase({ id: "telegram" }),
source: "test",
},
]),
);
});

View File

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,4 @@
// 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,5 +3,7 @@
export {
fetchWithRuntimeDispatcher,
fetchWithRuntimeDispatcherOrMockedGlobal,
isMockedFetch,
type DispatcherAwareRequestInit,
} from "../infra/net/runtime-fetch.js";

View File

@@ -0,0 +1,6 @@
// 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,3 +2,5 @@
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

@@ -0,0 +1,52 @@
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,7 +346,6 @@ 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,6 +12,7 @@ import {
formatStepFailure,
installCanaryArtifactCleanup,
isBoundaryCompileFresh,
resolveBoundaryCheckSelection,
resolveBoundaryCheckLockPath,
resolveCanaryArtifactPaths,
runNodeStepAsync,
@@ -165,6 +166,29 @@ 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({
@@ -220,6 +244,50 @@ 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

@@ -0,0 +1,91 @@
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,30 +1,28 @@
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 {
resolveChatModelOverrideValue,
resolveChatModelSelectState,
} from "./chat-model-select-state.ts";
isCronSessionKey,
parseSessionKey,
renderChatSessionSelect as renderChatSessionSelectBase,
renderChatThinkingSelect,
resolveSessionDisplayName,
resolveSessionOptionGroups,
} from "./chat/session-controls.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 { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts";
import { 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;
@@ -174,51 +172,7 @@ function renderCronFilterIcon(hiddenCount: number) {
}
export function renderChatSessionSelect(state: AppViewState) {
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>
`;
return renderChatSessionSelectBase(state, switchChatSession);
}
export function renderChatControls(state: AppViewState) {
@@ -579,520 +533,6 @@ 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) {
@@ -1102,35 +542,6 @@ 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

@@ -0,0 +1,276 @@
/* @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

@@ -0,0 +1,623 @@
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,8 +96,11 @@ 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[]) =>
globalThis.setTimeout(() => handler(...args), timeout),
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);
},
clearTimeout: (timeoutId: number | undefined) => globalThis.clearTimeout(timeoutId),
});
}
@@ -127,10 +130,19 @@ async function continueConnect(ws: MockWebSocket, nonce = "nonce-1") {
event: "connect.challenge",
payload: { nonce },
});
await vi.waitFor(() => expect(ws.sent.length).toBeGreaterThan(0));
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(0);
} else {
await new Promise((resolve) => setTimeout(resolve, 0));
}
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);
@@ -163,7 +175,7 @@ async function startRetriedDeviceTokenConnect(params: {
expect(firstConnect.params?.auth?.deviceToken).toBeUndefined();
emitRetryableTokenMismatch(firstWs, firstConnect.id);
await vi.waitFor(() => expect(firstWs.readyState).toBe(3));
await expectSocketClosed(firstWs);
firstWs.emitClose(4008, "connect failed");
await vi.advanceTimersByTimeAsync(800);
@@ -331,7 +343,7 @@ describe("GatewayBrowserClient", () => {
details: { code: "AUTH_TOKEN_MISMATCH" },
},
});
await vi.waitFor(() => expect(secondWs.readyState).toBe(3));
await expectSocketClosed(secondWs);
secondWs.emitClose(4008, "connect failed");
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })?.token).toBe(
"stored-device-token",
@@ -374,7 +386,7 @@ describe("GatewayBrowserClient", () => {
details: { code: "AUTH_TOKEN_MISMATCH" },
},
});
await vi.waitFor(() => expect(ws1.readyState).toBe(3));
await expectSocketClosed(ws1);
ws1.emitClose(4008, "connect failed");
await vi.advanceTimersByTimeAsync(800);
@@ -444,7 +456,7 @@ describe("GatewayBrowserClient", () => {
details: { code: "AUTH_TOKEN_MISSING" },
},
});
await vi.waitFor(() => expect(ws1.readyState).toBe(3));
await expectSocketClosed(ws1);
ws1.emitClose(4008, "connect failed");
await vi.advanceTimersByTimeAsync(30_000);

View File

@@ -1,9 +1,13 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } 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);
}
@@ -158,7 +162,7 @@ describe("control UI routing", () => {
expect(app.querySelector(".dreams__lobster")).not.toBeNull();
});
it("renders the refreshed top navigation shell", async () => {
it("renders the refreshed desktop navigation shell and collapsed state", async () => {
const app = mountApp("/chat");
await app.updateComplete;
@@ -166,11 +170,6 @@ 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();
@@ -179,11 +178,6 @@ 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;
@@ -191,36 +185,15 @@ 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");
@@ -375,6 +348,10 @@ 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;
@@ -407,9 +384,9 @@ describe("control UI routing", () => {
scrollTop = Math.max(0, Math.min(top, 2400 - 180));
}) as typeof initialContainer.scrollTo;
app.chatMessages = Array.from({ length: 60 }, (_, index) => ({
app.chatMessages = Array.from({ length: 3 }, (_, index) => ({
role: "assistant",
content: `Line ${index} - ${"x".repeat(200)}`,
content: `Line ${index}`,
timestamp: Date.now() + index,
}));
@@ -451,8 +428,8 @@ describe("control UI routing", () => {
...app.chatMessages,
{
role: "assistant",
content: `Line 60 - ${"x".repeat(200)}`,
timestamp: Date.now() + 60,
content: "Line 3",
timestamp: Date.now() + 3,
},
];
await app.updateComplete;

View File

@@ -3,17 +3,8 @@
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";
@@ -27,125 +18,6 @@ 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));
}
@@ -808,144 +680,6 @@ 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({