diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 6c2c1a0b2a5..675da2de4e3 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -151,21 +151,29 @@ export async function ensureOpenClawModelsJson( agentDirOverride?: string, options: { pluginMetadataSnapshot?: Pick; + workspaceDir?: string; } = {}, ): Promise<{ agentDir: string; wrote: boolean }> { const resolved = resolveModelsConfigInput(config); const cfg = resolved.config; - const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + const workspaceDir = + options.workspaceDir ?? + (agentDirOverride?.trim() + ? undefined + : resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg))); const pluginMetadataSnapshot = options.pluginMetadataSnapshot ?? - getCurrentPluginMetadataSnapshot({ config: cfg, workspaceDir }); + getCurrentPluginMetadataSnapshot({ + config: cfg, + ...(workspaceDir ? { workspaceDir } : {}), + }); const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir(); const targetPath = path.join(agentDir, "models.json"); const fingerprint = await buildModelsJsonFingerprint({ config: cfg, sourceConfigForSecrets: resolved.sourceConfigForSecrets, agentDir, - workspaceDir, + ...(workspaceDir ? { workspaceDir } : {}), ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), }); const cached = MODELS_JSON_STATE.readyCache.get(targetPath); @@ -187,7 +195,7 @@ export async function ensureOpenClawModelsJson( sourceConfigForSecrets: resolved.sourceConfigForSecrets, agentDir, env, - workspaceDir, + ...(workspaceDir ? { workspaceDir } : {}), existingRaw: existingModelsFile.raw, existingParsed: existingModelsFile.parsed, ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), diff --git a/src/agents/models-config.write-serialization.test.ts b/src/agents/models-config.write-serialization.test.ts index d8d2101a980..d10f68d870b 100644 --- a/src/agents/models-config.write-serialization.test.ts +++ b/src/agents/models-config.write-serialization.test.ts @@ -1,6 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js"; +import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { CUSTOM_PROXY_MODELS_CONFIG, installModelsConfigTestHooks, @@ -13,15 +15,63 @@ const planOpenClawModelsJsonMock = vi.fn(); installModelsConfigTestHooks(); let ensureOpenClawModelsJson: typeof import("./models-config.js").ensureOpenClawModelsJson; +let clearCurrentPluginMetadataSnapshot: typeof import("../plugins/current-plugin-metadata-snapshot.js").clearCurrentPluginMetadataSnapshot; +let setCurrentPluginMetadataSnapshot: typeof import("../plugins/current-plugin-metadata-snapshot.js").setCurrentPluginMetadataSnapshot; + +function createPluginMetadataSnapshot(workspaceDir: string): PluginMetadataSnapshot { + const policyHash = resolveInstalledPluginIndexPolicyHash({}); + return { + policyHash, + workspaceDir, + index: { + version: 1, + hostContractVersion: "test", + compatRegistryVersion: "test", + migrationVersion: 1, + policyHash, + generatedAtMs: 1, + installRecords: {}, + plugins: [], + diagnostics: [], + }, + registryDiagnostics: [], + manifestRegistry: { plugins: [], diagnostics: [] }, + plugins: [], + diagnostics: [], + byPluginId: new Map(), + normalizePluginId: (pluginId) => pluginId, + owners: { + channels: new Map(), + channelConfigs: new Map(), + providers: new Map(), + modelCatalogProviders: new Map(), + cliBackends: new Map(), + setupProviders: new Map(), + commandAliases: new Map(), + contracts: new Map(), + }, + metrics: { + registrySnapshotMs: 0, + manifestRegistryMs: 0, + ownerMapsMs: 0, + totalMs: 0, + indexPluginCount: 0, + manifestPluginCount: 0, + }, + }; +} beforeAll(async () => { vi.doMock("./models-config.plan.js", () => ({ planOpenClawModelsJson: (...args: unknown[]) => planOpenClawModelsJsonMock(...args), })); ({ ensureOpenClawModelsJson } = await import("./models-config.js")); + ({ clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot } = + await import("../plugins/current-plugin-metadata-snapshot.js")); }); beforeEach(() => { + clearCurrentPluginMetadataSnapshot(); planOpenClawModelsJsonMock .mockReset() .mockImplementation(async (params: { cfg?: typeof CUSTOM_PROXY_MODELS_CONFIG }) => ({ @@ -31,6 +81,38 @@ beforeEach(() => { }); describe("models-config write serialization", () => { + it("does not reuse default workspace plugin metadata for explicit agent dirs without workspace", async () => { + await withModelsTempHome(async (home) => { + const snapshot = createPluginMetadataSnapshot(path.join(home, "default-workspace")); + setCurrentPluginMetadataSnapshot(snapshot, { config: {} }); + const agentDir = path.join(home, "agent-non-default"); + + await ensureOpenClawModelsJson({}, agentDir); + + expect(planOpenClawModelsJsonMock).toHaveBeenCalledWith( + expect.not.objectContaining({ pluginMetadataSnapshot: snapshot }), + ); + }); + }); + + it("reuses current plugin metadata for explicit agent dirs with matching workspace", async () => { + await withModelsTempHome(async (home) => { + const workspaceDir = path.join(home, "agent-workspace"); + const snapshot = createPluginMetadataSnapshot(workspaceDir); + setCurrentPluginMetadataSnapshot(snapshot, { config: {} }); + const agentDir = path.join(home, "agent-non-default"); + + await ensureOpenClawModelsJson({}, agentDir, { workspaceDir }); + + expect(planOpenClawModelsJsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDir, + pluginMetadataSnapshot: snapshot, + }), + ); + }); + }); + it("serializes concurrent models.json writes to avoid overlap", async () => { await withModelsTempHome(async () => { const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG);