mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
f09a33ce41
Amp-Thread-ID: https://ampcode.com/threads/T-019fe95b-d75c-70fc-82b6-4825ca874826 Co-authored-by: Amp <amp@ampcode.com>
1052 lines
40 KiB
TypeScript
1052 lines
40 KiB
TypeScript
import "./prepared-model-runtime.test-harness.js";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
acquireAgentRunPreparedModelRuntime,
|
|
acquireReadOnlyPreparedModelRuntime,
|
|
activateStandalonePreparedModelRuntime,
|
|
getPreparedModelRuntimeSnapshot,
|
|
markPreparedModelRuntimeSnapshotsStale,
|
|
prepareModelRuntimeSnapshot,
|
|
publishPreparedModelRuntimeSnapshot,
|
|
rejectPendingPreparedModelRuntimeReplacement,
|
|
registerPreparedModelRuntimePublicationListener,
|
|
refreshPreparedModelRuntimeSnapshots,
|
|
} from "./prepared-model-runtime.js";
|
|
import {
|
|
getPreparedModelRuntimeMocks,
|
|
getPreparedModelRuntimeTestApi,
|
|
resetPreparedModelRuntimeHarness,
|
|
} from "./prepared-model-runtime.test-harness.js";
|
|
|
|
const mocks = getPreparedModelRuntimeMocks();
|
|
|
|
describe("prepared model runtime snapshots", () => {
|
|
beforeEach(() => {
|
|
resetPreparedModelRuntimeHarness();
|
|
});
|
|
|
|
it("does not discover missing owners from a gateway request", async () => {
|
|
await refreshPreparedModelRuntimeSnapshots({}, { gatewayLifecycle: true });
|
|
const input = { config: {}, agentDir: "/tmp/prepared-model-runtime-gateway-missing" };
|
|
|
|
await activateStandalonePreparedModelRuntime(input);
|
|
await expect(prepareModelRuntimeSnapshot(input)).rejects.toThrow(
|
|
"prepared model runtime owner was not published",
|
|
);
|
|
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("publishes invalidation before the replacement generation", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const events: string[] = [];
|
|
const unregister = registerPreparedModelRuntimePublicationListener((event) => {
|
|
events.push(event.phase);
|
|
});
|
|
|
|
await refreshPreparedModelRuntimeSnapshots({}, { gatewayLifecycle: true });
|
|
unregister();
|
|
await refreshPreparedModelRuntimeSnapshots({});
|
|
|
|
expect(events).toEqual(["invalidated", "published"]);
|
|
});
|
|
|
|
it("makes the replacement owner readable before announcing publication", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const initialConfig = {};
|
|
const replacementConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
|
await refreshPreparedModelRuntimeSnapshots(initialConfig, { gatewayLifecycle: true });
|
|
let publishedOwner: ReturnType<typeof getPreparedModelRuntimeSnapshot>;
|
|
const unregister = registerPreparedModelRuntimePublicationListener((event) => {
|
|
if (event.phase === "published") {
|
|
publishedOwner = getPreparedModelRuntimeSnapshot({
|
|
agentId: "default",
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
config: replacementConfig,
|
|
});
|
|
}
|
|
});
|
|
|
|
await refreshPreparedModelRuntimeSnapshots(replacementConfig);
|
|
unregister();
|
|
|
|
expect(publishedOwner).toMatchObject({ config: replacementConfig });
|
|
});
|
|
|
|
it("announces invalidation from the direct stale owner boundary", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
await refreshPreparedModelRuntimeSnapshots({}, { gatewayLifecycle: true });
|
|
const events: string[] = [];
|
|
const unregister = registerPreparedModelRuntimePublicationListener((event) => {
|
|
events.push(event.phase);
|
|
});
|
|
|
|
markPreparedModelRuntimeSnapshotsStale("test direct reload stale edge", {
|
|
waitForReplacement: true,
|
|
});
|
|
unregister();
|
|
|
|
expect(events).toEqual(["invalidated"]);
|
|
});
|
|
|
|
it("terminates direct invalidation when no replacement owns it", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
await refreshPreparedModelRuntimeSnapshots({}, { gatewayLifecycle: true });
|
|
const events: Array<{ phase: string; error?: Error }> = [];
|
|
const unregister = registerPreparedModelRuntimePublicationListener((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
markPreparedModelRuntimeSnapshotsStale("direct invalidation has no replacement");
|
|
unregister();
|
|
|
|
expect(events).toEqual([
|
|
{ phase: "invalidated" },
|
|
{
|
|
phase: "failed",
|
|
error: expect.objectContaining({ message: "direct invalidation has no replacement" }),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("announces a failed replacement so lifecycle readers do not wait indefinitely", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
await refreshPreparedModelRuntimeSnapshots({}, { gatewayLifecycle: true });
|
|
const events: Array<{ phase: string; error?: Error }> = [];
|
|
const unregister = registerPreparedModelRuntimePublicationListener((event) => {
|
|
events.push(event);
|
|
});
|
|
const replacementError = new Error("replacement aborted");
|
|
|
|
const gateId = markPreparedModelRuntimeSnapshotsStale("test failed reload", {
|
|
waitForReplacement: true,
|
|
});
|
|
rejectPendingPreparedModelRuntimeReplacement(gateId, replacementError);
|
|
unregister();
|
|
|
|
expect(events).toEqual([
|
|
{ phase: "invalidated" },
|
|
{ phase: "failed", error: replacementError },
|
|
]);
|
|
});
|
|
|
|
it("does not let a read-only draft replace a configured gateway owner", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const configured = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
|
await refreshPreparedModelRuntimeSnapshots(configured, {
|
|
gatewayLifecycle: true,
|
|
defaultWorkspaceDir: "/tmp/gateway-launch-workspace",
|
|
});
|
|
|
|
const activated = await activateStandalonePreparedModelRuntime({
|
|
config: { agents: { defaults: { model: "openai/gpt-5.4" } } },
|
|
agentId: "default",
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/gateway-launch-workspace",
|
|
readOnly: true,
|
|
});
|
|
|
|
expect(activated).toBeUndefined();
|
|
await expect(
|
|
prepareModelRuntimeSnapshot({
|
|
config: configured,
|
|
agentId: "default",
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/gateway-launch-workspace",
|
|
}),
|
|
).resolves.toMatchObject({ config: configured });
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("retires a standalone run owner when its final lease releases", async () => {
|
|
const input = {
|
|
config: {},
|
|
agentId: "default",
|
|
agentDir: "/tmp/standalone-run-agent",
|
|
workspaceDir: "/tmp/one-off-run-workspace",
|
|
};
|
|
const lease = await acquireAgentRunPreparedModelRuntime(input);
|
|
|
|
await expect(prepareModelRuntimeSnapshot(input)).resolves.toBe(lease.snapshot);
|
|
lease.release();
|
|
await expect(prepareModelRuntimeSnapshot(input)).rejects.toThrow(
|
|
"prepared model runtime owner was not published",
|
|
);
|
|
});
|
|
|
|
it("publishes a static turn generation without live catalog discovery", async () => {
|
|
const lease = await acquireAgentRunPreparedModelRuntime(
|
|
{
|
|
config: {},
|
|
agentId: "default",
|
|
agentDir: "/tmp/static-run-agent",
|
|
workspaceDir: "/tmp/static-run-workspace",
|
|
},
|
|
{ catalogMode: "static" },
|
|
);
|
|
|
|
expect(mocks.prepareStaticCatalog).toHaveBeenCalledOnce();
|
|
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
|
|
lease.release();
|
|
});
|
|
|
|
it("retains only the latest idle direct-run owner", async () => {
|
|
const firstInput = {
|
|
config: {},
|
|
agentId: "default",
|
|
agentDir: "/tmp/standalone-retained-run-agent",
|
|
workspaceDir: "/tmp/standalone-retained-run-workspace",
|
|
};
|
|
const firstLease = await acquireAgentRunPreparedModelRuntime(firstInput, {
|
|
retainIdleRunOwner: true,
|
|
});
|
|
firstLease.release();
|
|
|
|
await expect(prepareModelRuntimeSnapshot(firstInput)).resolves.toBe(firstLease.snapshot);
|
|
const reusedLease = await acquireAgentRunPreparedModelRuntime(firstInput, {
|
|
retainIdleRunOwner: true,
|
|
});
|
|
reusedLease.release();
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce();
|
|
|
|
const secondInput = {
|
|
...firstInput,
|
|
workspaceDir: "/tmp/standalone-retained-run-workspace-2",
|
|
};
|
|
const secondLease = await acquireAgentRunPreparedModelRuntime(secondInput, {
|
|
retainIdleRunOwner: true,
|
|
});
|
|
secondLease.release();
|
|
|
|
await expect(prepareModelRuntimeSnapshot(firstInput)).rejects.toThrow(
|
|
"prepared model runtime owner was not published",
|
|
);
|
|
await expect(prepareModelRuntimeSnapshot(secondInput)).resolves.toBe(secondLease.snapshot);
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("retains an exact dynamic workspace owner after gateway run admission", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const config = {};
|
|
await refreshPreparedModelRuntimeSnapshots(config, {
|
|
gatewayLifecycle: true,
|
|
defaultWorkspaceDir: "/tmp/gateway-launch-workspace",
|
|
});
|
|
|
|
const firstLease = await acquireAgentRunPreparedModelRuntime({
|
|
agentId: "default",
|
|
config,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/spawned-workspace",
|
|
});
|
|
const secondLease = await acquireAgentRunPreparedModelRuntime({
|
|
agentId: "default",
|
|
config,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/spawned-workspace",
|
|
});
|
|
|
|
expect(firstLease.snapshot.workspaceDir).toBe("/tmp/spawned-workspace");
|
|
expect(secondLease.snapshot).toBe(firstLease.snapshot);
|
|
firstLease.release();
|
|
secondLease.release();
|
|
const retainedLease = await acquireAgentRunPreparedModelRuntime({
|
|
agentId: "default",
|
|
config,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/spawned-workspace",
|
|
});
|
|
expect(retainedLease.snapshot).toBe(firstLease.snapshot);
|
|
retainedLease.release();
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("joins an in-flight dynamic owner publication", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const config = {};
|
|
await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true });
|
|
let finishDynamic!: () => void;
|
|
mocks.ensureOpenClawModelsJson.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ agentDir: string; wrote: false }>((resolve) => {
|
|
finishDynamic = () => resolve({ agentDir: "/tmp/unused-agent", wrote: false });
|
|
}),
|
|
);
|
|
const input = {
|
|
agentId: "default",
|
|
config,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/concurrent-dynamic-workspace",
|
|
};
|
|
|
|
const firstPending = acquireAgentRunPreparedModelRuntime(input);
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2));
|
|
const secondPending = acquireAgentRunPreparedModelRuntime(input);
|
|
await Promise.resolve();
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2);
|
|
finishDynamic();
|
|
const [first, second] = await Promise.all([firstPending, secondPending]);
|
|
|
|
expect(second.snapshot).toBe(first.snapshot);
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2);
|
|
first.release();
|
|
second.release();
|
|
});
|
|
|
|
it("does not let a stale dynamic lease authorize a replacement generation", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const config = {};
|
|
await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true });
|
|
const input = {
|
|
agentId: "default",
|
|
config,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/stale-dynamic-workspace",
|
|
};
|
|
const firstLease = await acquireAgentRunPreparedModelRuntime(input);
|
|
|
|
markPreparedModelRuntimeSnapshotsStale("test dynamic owner staling");
|
|
await expect(acquireAgentRunPreparedModelRuntime(input)).rejects.toThrow(
|
|
"prepared model runtime owner was not committed",
|
|
);
|
|
firstLease.release();
|
|
});
|
|
|
|
it("activates a standalone lease on a configless gateway with no configured owners", async () => {
|
|
mocks.configuredAgentIds = [];
|
|
const config = {};
|
|
await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true });
|
|
const input = {
|
|
agentId: "openclaw",
|
|
config,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/configless-workspace",
|
|
};
|
|
const lease = await acquireAgentRunPreparedModelRuntime(input);
|
|
expect(lease.snapshot.agentDir).toBe("/tmp/unused-agent");
|
|
lease.release();
|
|
});
|
|
|
|
it("activates a standalone lease for a configless runtime while another agent is configured", async () => {
|
|
mocks.configuredAgentIds = ["other"];
|
|
const config = {};
|
|
await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true });
|
|
const input = {
|
|
agentId: "openclaw",
|
|
config,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/configless-mixed-workspace",
|
|
};
|
|
const lease = await acquireAgentRunPreparedModelRuntime(input);
|
|
expect(lease.snapshot.agentDir).toBe("/tmp/unused-agent");
|
|
lease.release();
|
|
});
|
|
|
|
it("rejects an ordinary unconfigured agent on an active gateway", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const config = {};
|
|
await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true });
|
|
|
|
await expect(
|
|
acquireAgentRunPreparedModelRuntime({
|
|
agentId: "missing",
|
|
config,
|
|
agentDir: "/tmp/configured-missing",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/workspace-missing",
|
|
}),
|
|
).rejects.toThrow("prepared model runtime owner was not committed");
|
|
});
|
|
|
|
it("rebases a stale dynamic owner onto the committed configured generation", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const initialConfig = {};
|
|
const latestConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
|
await refreshPreparedModelRuntimeSnapshots(initialConfig, { gatewayLifecycle: true });
|
|
const dynamicInput = {
|
|
agentId: "default",
|
|
config: initialConfig,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/rebased-dynamic-workspace",
|
|
};
|
|
const firstLease = await acquireAgentRunPreparedModelRuntime(dynamicInput);
|
|
markPreparedModelRuntimeSnapshotsStale("test committed dynamic rebase");
|
|
await publishPreparedModelRuntimeSnapshot(
|
|
{
|
|
...dynamicInput,
|
|
config: latestConfig,
|
|
workspaceDir: "/tmp/unused-workspace",
|
|
},
|
|
{ force: true, provenance: "configured" },
|
|
);
|
|
|
|
const secondLease = await acquireAgentRunPreparedModelRuntime(dynamicInput);
|
|
expect(secondLease.snapshot.config).toBe(latestConfig);
|
|
expect(secondLease.snapshot.workspaceDir).toBe(dynamicInput.workspaceDir);
|
|
firstLease.release();
|
|
secondLease.release();
|
|
});
|
|
|
|
it("rebases a reserved run identity through its configured agent directory", async () => {
|
|
mocks.configuredAgentIds = ["default", "openclaw"];
|
|
const config = {};
|
|
await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true });
|
|
|
|
const lease = await acquireAgentRunPreparedModelRuntime({
|
|
agentId: "openclaw",
|
|
config,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/setup-probe-workspace",
|
|
});
|
|
|
|
expect(lease.snapshot).toMatchObject({
|
|
agentId: "openclaw",
|
|
agentDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/setup-probe-workspace",
|
|
config,
|
|
});
|
|
lease.release();
|
|
});
|
|
|
|
it("keeps an ordinary run bound to its configured agent identity", async () => {
|
|
mocks.configuredAgentIds = ["default", "secondary"];
|
|
const config = {};
|
|
await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true });
|
|
|
|
const lease = await acquireAgentRunPreparedModelRuntime({
|
|
agentId: "secondary",
|
|
config,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/secondary-probe-workspace",
|
|
});
|
|
|
|
expect(lease.snapshot).toMatchObject({
|
|
agentId: "secondary",
|
|
agentDir: "/tmp/configured-secondary",
|
|
workspaceDir: "/tmp/secondary-probe-workspace",
|
|
config,
|
|
});
|
|
lease.release();
|
|
});
|
|
|
|
it("keeps a configured replacement after the matching dynamic lease releases", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const config = {};
|
|
const input = {
|
|
agentId: "default",
|
|
config,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/unused-workspace",
|
|
};
|
|
const dynamicLease = await acquireAgentRunPreparedModelRuntime(input);
|
|
|
|
await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true });
|
|
const configuredSnapshot = await prepareModelRuntimeSnapshot(input);
|
|
|
|
expect(configuredSnapshot).not.toBe(dynamicLease.snapshot);
|
|
dynamicLease.release();
|
|
await expect(prepareModelRuntimeSnapshot(input)).resolves.toBe(configuredSnapshot);
|
|
});
|
|
|
|
it("blocks new dynamic lease owners until lifecycle replacement commits", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const initialConfig = {};
|
|
const latestConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
|
await refreshPreparedModelRuntimeSnapshots(initialConfig, { gatewayLifecycle: true });
|
|
let finishReplacement!: () => void;
|
|
mocks.ensureOpenClawModelsJson.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ agentDir: string; wrote: boolean }>((resolve) => {
|
|
finishReplacement = () => resolve({ agentDir: "/tmp/unused-agent", wrote: false });
|
|
}),
|
|
);
|
|
|
|
markPreparedModelRuntimeSnapshotsStale("test lease replacement", {
|
|
waitForReplacement: true,
|
|
});
|
|
const leasePending = acquireAgentRunPreparedModelRuntime({
|
|
agentId: "default",
|
|
config: initialConfig,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/dynamic-replacement-workspace",
|
|
});
|
|
await Promise.resolve();
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(1);
|
|
|
|
const refresh = refreshPreparedModelRuntimeSnapshots(latestConfig);
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2));
|
|
finishReplacement();
|
|
await refresh;
|
|
const lease = await leasePending;
|
|
|
|
expect(lease.snapshot.config).toBe(latestConfig);
|
|
expect(lease.snapshot.workspaceDir).toBe("/tmp/dynamic-replacement-workspace");
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3);
|
|
lease.release();
|
|
});
|
|
|
|
it("rebases a stale dynamic run after the replacement gate has closed", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const initialConfig = {};
|
|
const latestConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
|
await refreshPreparedModelRuntimeSnapshots(initialConfig, { gatewayLifecycle: true });
|
|
await refreshPreparedModelRuntimeSnapshots(latestConfig);
|
|
|
|
const lease = await acquireAgentRunPreparedModelRuntime({
|
|
agentId: "default",
|
|
config: initialConfig,
|
|
agentDir: "/tmp/stale-agent-dir",
|
|
inheritedAuthDir: "/tmp/stale-agent-dir",
|
|
workspaceDir: "/tmp/dynamic-post-reload-workspace",
|
|
});
|
|
|
|
expect(lease.snapshot.config).toBe(latestConfig);
|
|
expect(lease.snapshot.agentDir).toBe("/tmp/unused-agent");
|
|
expect(lease.snapshot.workspaceDir).toBe("/tmp/dynamic-post-reload-workspace");
|
|
lease.release();
|
|
});
|
|
|
|
it("rebinds a queued canonical run to committed directories", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const initialConfig = {};
|
|
const latestConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
|
await refreshPreparedModelRuntimeSnapshots(initialConfig, { gatewayLifecycle: true });
|
|
|
|
markPreparedModelRuntimeSnapshotsStale("test directory replacement", {
|
|
waitForReplacement: true,
|
|
});
|
|
const leasePending = acquireAgentRunPreparedModelRuntime({
|
|
agentId: "default",
|
|
config: initialConfig,
|
|
agentDir: "/tmp/old-agent-dir",
|
|
inheritedAuthDir: "/tmp/old-agent-dir",
|
|
workspaceDir: "/tmp/old-workspace-dir",
|
|
preserveWorkspaceDirOnRefresh: false,
|
|
});
|
|
const refresh = refreshPreparedModelRuntimeSnapshots(latestConfig);
|
|
await refresh;
|
|
const lease = await leasePending;
|
|
|
|
expect(lease.snapshot.config).toBe(latestConfig);
|
|
expect(lease.snapshot.agentDir).toBe("/tmp/unused-agent");
|
|
expect(lease.snapshot.workspaceDir).toBe("/tmp/unused-workspace");
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2);
|
|
lease.release();
|
|
});
|
|
|
|
it("reuses the configured owner at canonical gateway run admission", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const config = {};
|
|
await refreshPreparedModelRuntimeSnapshots(config, {
|
|
gatewayLifecycle: true,
|
|
defaultWorkspaceDir: "/tmp/gateway-launch-workspace",
|
|
});
|
|
|
|
const lease = await acquireAgentRunPreparedModelRuntime({
|
|
agentId: "default",
|
|
config,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/gateway-launch-workspace",
|
|
});
|
|
|
|
expect(lease.snapshot.workspaceDir).toBe("/tmp/gateway-launch-workspace");
|
|
lease.release();
|
|
await expect(
|
|
prepareModelRuntimeSnapshot({
|
|
agentId: "default",
|
|
config,
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/gateway-launch-workspace",
|
|
}),
|
|
).resolves.toBe(lease.snapshot);
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("releases a one-read dynamic metadata generation", async () => {
|
|
await refreshPreparedModelRuntimeSnapshots({}, { gatewayLifecycle: true });
|
|
const input = {
|
|
agentId: "default",
|
|
config: {},
|
|
agentDir: "/tmp/prepared-model-runtime-metadata-agent",
|
|
workspaceDir: "/tmp/prepared-model-runtime-metadata-workspace",
|
|
};
|
|
|
|
const lease = await acquireReadOnlyPreparedModelRuntime(input);
|
|
expect(lease.snapshot.workspaceDir).toBe(input.workspaceDir);
|
|
lease.release();
|
|
|
|
await expect(prepareModelRuntimeSnapshot({ ...input, readOnly: true })).rejects.toThrow(
|
|
"prepared model runtime owner was not published",
|
|
);
|
|
});
|
|
|
|
it("fails a timed-out publication without overlapping its late build with a retry", async () => {
|
|
getPreparedModelRuntimeTestApi().setModelRuntimeBuildTimeoutMsForTest(1);
|
|
let finishTimedOutBuild: (() => void) | undefined;
|
|
mocks.ensureOpenClawModelsJson.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ agentDir: string; wrote: false }>((resolve) => {
|
|
finishTimedOutBuild = () => resolve({ agentDir: "/tmp/agent", wrote: false });
|
|
}),
|
|
);
|
|
const input = { config: {}, agentDir: "/tmp/prepared-model-runtime-timeout" };
|
|
|
|
await expect(publishPreparedModelRuntimeSnapshot(input)).rejects.toThrow(
|
|
"prepared model runtime publication timed out",
|
|
);
|
|
await expect(prepareModelRuntimeSnapshot(input)).rejects.toThrow(
|
|
"prepared model runtime publication timed out",
|
|
);
|
|
await expect(publishPreparedModelRuntimeSnapshot(input)).rejects.toThrow(
|
|
"prepared model runtime publication timed out",
|
|
);
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce();
|
|
|
|
finishTimedOutBuild?.();
|
|
await vi.waitFor(() => expect(mocks.discoverModels).toHaveBeenCalledOnce());
|
|
await expect(publishPreparedModelRuntimeSnapshot(input)).resolves.toMatchObject({
|
|
agentDir: input.agentDir,
|
|
});
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("rebuilds stale owners with the newly published config", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const agentDir = "/tmp/unused-agent";
|
|
const firstConfig = {};
|
|
const secondConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
|
const input = {
|
|
config: firstConfig,
|
|
agentDir,
|
|
inheritedAuthDir: agentDir,
|
|
workspaceDir: "/tmp/unused-workspace",
|
|
};
|
|
await publishPreparedModelRuntimeSnapshot(input, { provenance: "configured" });
|
|
|
|
await refreshPreparedModelRuntimeSnapshots(secondConfig);
|
|
const refreshed = await prepareModelRuntimeSnapshot({ ...input, config: secondConfig });
|
|
const fromStaleRequest = await prepareModelRuntimeSnapshot(input);
|
|
|
|
expect(refreshed.config).toBe(secondConfig);
|
|
expect(fromStaleRequest).toBe(refreshed);
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not serve the old snapshot after lifecycle refresh fails", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const agentDir = "/tmp/unused-agent";
|
|
const firstConfig = {};
|
|
const secondConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
|
const input = {
|
|
config: firstConfig,
|
|
agentDir,
|
|
inheritedAuthDir: agentDir,
|
|
workspaceDir: "/tmp/unused-workspace",
|
|
};
|
|
await publishPreparedModelRuntimeSnapshot(input, { provenance: "configured" });
|
|
const refreshError = new Error("catalog refresh failed");
|
|
mocks.ensureOpenClawModelsJson.mockRejectedValueOnce(refreshError);
|
|
|
|
await expect(refreshPreparedModelRuntimeSnapshots(secondConfig)).rejects.toBe(refreshError);
|
|
await expect(prepareModelRuntimeSnapshot({ ...input, config: secondConfig })).rejects.toBe(
|
|
refreshError,
|
|
);
|
|
});
|
|
|
|
it("does not serve a retired owner when another owner fails to refresh", async () => {
|
|
mocks.configuredAgentIds = ["default", "removed"];
|
|
const firstConfig = {};
|
|
await refreshPreparedModelRuntimeSnapshots(firstConfig);
|
|
mocks.configuredAgentIds = ["default"];
|
|
const refreshError = new Error("remaining owner refresh failed");
|
|
mocks.ensureOpenClawModelsJson.mockRejectedValueOnce(refreshError);
|
|
|
|
await expect(refreshPreparedModelRuntimeSnapshots({})).rejects.toBe(refreshError);
|
|
mocks.mutationListener?.({
|
|
agentDir: "/tmp/configured-removed",
|
|
affectsInheritedStores: false,
|
|
});
|
|
await expect(
|
|
prepareModelRuntimeSnapshot({
|
|
config: firstConfig,
|
|
agentDir: "/tmp/configured-removed",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/workspace-removed",
|
|
}),
|
|
).rejects.toThrow("owner was not published");
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("commits no configured owner when one sibling refresh fails", async () => {
|
|
mocks.configuredAgentIds = ["default", "secondary"];
|
|
const firstConfig = {};
|
|
await refreshPreparedModelRuntimeSnapshots(firstConfig);
|
|
const refreshError = new Error("secondary refresh failed");
|
|
mocks.ensureOpenClawModelsJson
|
|
.mockResolvedValueOnce({ agentDir: "/tmp/unused-agent", wrote: false })
|
|
.mockRejectedValueOnce(refreshError);
|
|
|
|
await expect(refreshPreparedModelRuntimeSnapshots({})).rejects.toBe(refreshError);
|
|
await expect(
|
|
prepareModelRuntimeSnapshot({
|
|
config: {},
|
|
agentDir: "/tmp/unused-agent",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/unused-workspace",
|
|
}),
|
|
).rejects.toBe(refreshError);
|
|
await expect(
|
|
prepareModelRuntimeSnapshot({
|
|
config: {},
|
|
agentDir: "/tmp/configured-secondary",
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir: "/tmp/workspace-secondary",
|
|
}),
|
|
).rejects.toBe(refreshError);
|
|
});
|
|
|
|
it("stales every owner when queued auth refresh fails after config publication", async () => {
|
|
mocks.configuredAgentIds = ["default", "secondary"];
|
|
await refreshPreparedModelRuntimeSnapshots({});
|
|
const refreshError = new Error("queued auth refresh failed");
|
|
let finishConfigRefresh!: () => void;
|
|
mocks.ensureOpenClawModelsJson
|
|
.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ agentDir: string; wrote: false }>((resolve) => {
|
|
finishConfigRefresh = () => resolve({ agentDir: "/tmp/unused-agent", wrote: false });
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce({ agentDir: "/tmp/configured-secondary", wrote: false })
|
|
.mockResolvedValueOnce({ agentDir: "/tmp/unused-agent", wrote: false })
|
|
.mockRejectedValueOnce(refreshError);
|
|
|
|
const refresh = refreshPreparedModelRuntimeSnapshots({});
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(4));
|
|
mocks.mutationListener?.({ affectsInheritedStores: true });
|
|
finishConfigRefresh();
|
|
|
|
await expect(refresh).rejects.toBe(refreshError);
|
|
for (const [agentDir, workspaceDir] of [
|
|
["/tmp/unused-agent", "/tmp/unused-workspace"],
|
|
["/tmp/configured-secondary", "/tmp/workspace-secondary"],
|
|
] as const) {
|
|
await expect(
|
|
prepareModelRuntimeSnapshot({
|
|
config: {},
|
|
agentDir,
|
|
inheritedAuthDir: "/tmp/unused-agent",
|
|
workspaceDir,
|
|
}),
|
|
).rejects.toBe(refreshError);
|
|
}
|
|
});
|
|
|
|
it("does not replay an auth mutation that occurs before the first owner is registered", async () => {
|
|
getPreparedModelRuntimeTestApi().setModelRuntimeBuildTimeoutMsForTest(100);
|
|
mocks.configuredAgentIds = ["default"];
|
|
mocks.prepareStaticCatalog.mockImplementationOnce(async () => {
|
|
mocks.mutationListener?.({ affectsInheritedStores: true });
|
|
return { entries: [] };
|
|
});
|
|
mocks.ensureOpenClawModelsJson
|
|
.mockResolvedValueOnce({ agentDir: "/tmp/unused-agent", wrote: false })
|
|
.mockImplementationOnce(async () => await new Promise<never>(() => {}));
|
|
|
|
await expect(
|
|
refreshPreparedModelRuntimeSnapshots({}, { gatewayLifecycle: true, catalogMode: "static" }),
|
|
).resolves.toBeUndefined();
|
|
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
|
|
expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledTimes(2);
|
|
expect(mocks.discoverAuthStorage).toHaveBeenCalledOnce();
|
|
expect(mocks.discoverModels).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("awaits auth invalidation queued during lifecycle publication", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
await refreshPreparedModelRuntimeSnapshots({});
|
|
let finishConfigRefresh: (() => void) | undefined;
|
|
let finishAuthRefresh: (() => void) | undefined;
|
|
mocks.ensureOpenClawModelsJson
|
|
.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ agentDir: string; wrote: false }>((resolve) => {
|
|
finishConfigRefresh = () => resolve({ agentDir: "/tmp/unused-agent", wrote: false });
|
|
}),
|
|
)
|
|
.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ agentDir: string; wrote: false }>((resolve) => {
|
|
finishAuthRefresh = () => resolve({ agentDir: "/tmp/unused-agent", wrote: false });
|
|
}),
|
|
);
|
|
|
|
const publication = refreshPreparedModelRuntimeSnapshots({});
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2));
|
|
mocks.mutationListener?.({ agentDir: "/tmp/unused-agent", affectsInheritedStores: false });
|
|
finishConfigRefresh?.();
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3));
|
|
let settled = false;
|
|
void publication.then(() => {
|
|
settled = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(settled).toBe(false);
|
|
|
|
finishAuthRefresh?.();
|
|
await publication;
|
|
expect(settled).toBe(true);
|
|
});
|
|
|
|
it("invalidates and refreshes the affected owner at auth publication", async () => {
|
|
const config = {};
|
|
const agentDir = "/tmp/prepared-model-runtime-auth";
|
|
const first = await publishPreparedModelRuntimeSnapshot({ config, agentDir });
|
|
|
|
mocks.mutationListener?.({ agentDir, affectsInheritedStores: false });
|
|
await expect(prepareModelRuntimeSnapshot({ config, agentDir })).rejects.toThrow(
|
|
"stale after auth mutation",
|
|
);
|
|
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2));
|
|
const refreshed = await prepareModelRuntimeSnapshot({ config, agentDir });
|
|
expect(refreshed).not.toBe(first);
|
|
expect(mocks.discoverAuthStorage).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("treats an auth refresh superseded by a newer mutation as control flow", async () => {
|
|
const config = {};
|
|
const agentDir = "/tmp/prepared-model-runtime-auth-superseded";
|
|
await publishPreparedModelRuntimeSnapshot({ config, agentDir });
|
|
let finishFirstRefresh: (() => void) | undefined;
|
|
mocks.ensureOpenClawModelsJson.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ agentDir: string; wrote: false }>((resolve) => {
|
|
finishFirstRefresh = () => resolve({ agentDir, wrote: false });
|
|
}),
|
|
);
|
|
|
|
mocks.mutationListener?.({ agentDir, affectsInheritedStores: false });
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2));
|
|
mocks.mutationListener?.({ agentDir, affectsInheritedStores: false });
|
|
finishFirstRefresh?.();
|
|
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3));
|
|
await expect(prepareModelRuntimeSnapshot({ config, agentDir })).resolves.toMatchObject({
|
|
agentDir,
|
|
});
|
|
expect(mocks.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a deduplicated caller when an auth refresh is superseded", async () => {
|
|
const config = {};
|
|
const agentDir = "/tmp/prepared-model-runtime-auth-pending-superseded";
|
|
await publishPreparedModelRuntimeSnapshot({ config, agentDir });
|
|
let finishFirstRefresh: (() => void) | undefined;
|
|
mocks.ensureOpenClawModelsJson.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ agentDir: string; wrote: false }>((resolve) => {
|
|
finishFirstRefresh = () => resolve({ agentDir, wrote: false });
|
|
}),
|
|
);
|
|
|
|
mocks.mutationListener?.({ agentDir, affectsInheritedStores: false });
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2));
|
|
const deduplicated = publishPreparedModelRuntimeSnapshot({ config, agentDir });
|
|
mocks.mutationListener?.({ agentDir, affectsInheritedStores: false });
|
|
finishFirstRefresh?.();
|
|
|
|
await expect(deduplicated).rejects.toThrow("superseded");
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3));
|
|
await expect(prepareModelRuntimeSnapshot({ config, agentDir })).resolves.toMatchObject({
|
|
agentDir,
|
|
});
|
|
});
|
|
|
|
it("does not let a superseded owner hide a genuine sibling refresh failure", async () => {
|
|
const config = {};
|
|
const supersededDir = "/tmp/prepared-model-runtime-auth-superseded-sibling";
|
|
const failingDir = "/tmp/prepared-model-runtime-auth-failing-sibling";
|
|
await publishPreparedModelRuntimeSnapshot({ config, agentDir: supersededDir });
|
|
await publishPreparedModelRuntimeSnapshot({ config, agentDir: failingDir });
|
|
let finishSupersededRefresh: (() => void) | undefined;
|
|
let failSiblingRefresh: (() => void) | undefined;
|
|
mocks.ensureOpenClawModelsJson
|
|
.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ agentDir: string; wrote: false }>((resolve) => {
|
|
finishSupersededRefresh = () => resolve({ agentDir: supersededDir, wrote: false });
|
|
}),
|
|
)
|
|
.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ agentDir: string; wrote: false }>((_resolve, reject) => {
|
|
failSiblingRefresh = () => reject(new Error("genuine sibling refresh failure"));
|
|
}),
|
|
);
|
|
|
|
mocks.mutationListener?.({ affectsInheritedStores: true });
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(4));
|
|
mocks.mutationListener?.({ agentDir: supersededDir, affectsInheritedStores: false });
|
|
finishSupersededRefresh?.();
|
|
failSiblingRefresh?.();
|
|
|
|
await vi.waitFor(() =>
|
|
expect(mocks.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining("genuine sibling refresh failure"),
|
|
),
|
|
);
|
|
expect(mocks.warn).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("refreshes owners that inherit the mutated auth directory", async () => {
|
|
const config = {};
|
|
const agentDir = "/tmp/prepared-model-runtime-custom-agent";
|
|
const inheritedAuthDir = "/tmp/prepared-model-runtime-main-agent";
|
|
await publishPreparedModelRuntimeSnapshot({ config, agentDir, inheritedAuthDir });
|
|
|
|
mocks.mutationListener?.({ agentDir: inheritedAuthDir, affectsInheritedStores: false });
|
|
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2));
|
|
expect(mocks.discoverAuthStorage).toHaveBeenLastCalledWith(
|
|
agentDir,
|
|
expect.objectContaining({ inheritedAuthDir }),
|
|
);
|
|
});
|
|
|
|
it("tracks default auth inheritance when the owner omits the directory", async () => {
|
|
const config = {};
|
|
const agentDir = "/tmp/prepared-model-runtime-implicit-inheritance";
|
|
await publishPreparedModelRuntimeSnapshot({ config, agentDir });
|
|
|
|
mocks.mutationListener?.({
|
|
agentDir: "/tmp/unused-agent",
|
|
affectsInheritedStores: false,
|
|
});
|
|
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2));
|
|
expect(mocks.discoverAuthStorage).toHaveBeenLastCalledWith(
|
|
agentDir,
|
|
expect.objectContaining({ inheritedAuthDir: "/tmp/unused-agent" }),
|
|
);
|
|
});
|
|
|
|
it("retains every owner until an explicit lifecycle invalidation", async () => {
|
|
const config = {};
|
|
const firstAgentDir = "/tmp/prepared-model-runtime-concurrent-0";
|
|
await Promise.all(
|
|
Array.from({ length: 70 }, async (_, index) =>
|
|
publishPreparedModelRuntimeSnapshot({
|
|
config,
|
|
agentDir: `/tmp/prepared-model-runtime-concurrent-${index}`,
|
|
}),
|
|
),
|
|
);
|
|
await prepareModelRuntimeSnapshot({ config, agentDir: firstAgentDir });
|
|
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(70);
|
|
expect(mocks.discoverAuthStorage).toHaveBeenCalledTimes(70);
|
|
expect(mocks.discoverModels).toHaveBeenCalledTimes(70);
|
|
});
|
|
|
|
it("serializes workspace replacements for one agent-owned catalog", async () => {
|
|
let finishFirst: (() => void) | undefined;
|
|
mocks.ensureOpenClawModelsJson.mockImplementationOnce(
|
|
async () =>
|
|
await new Promise<{ agentDir: string; wrote: false }>((resolve) => {
|
|
finishFirst = () => resolve({ agentDir: "/tmp/agent", wrote: false });
|
|
}),
|
|
);
|
|
const config = {};
|
|
const agentDir = "/tmp/prepared-model-runtime-workspace-replacement";
|
|
const first = publishPreparedModelRuntimeSnapshot({
|
|
config,
|
|
agentDir,
|
|
workspaceDir: "/tmp/workspace-old",
|
|
});
|
|
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce());
|
|
const requestDuringFirstGeneration = prepareModelRuntimeSnapshot({
|
|
config,
|
|
agentDir,
|
|
workspaceDir: "/tmp/workspace-old",
|
|
});
|
|
|
|
const replacement = publishPreparedModelRuntimeSnapshot({
|
|
config,
|
|
agentDir,
|
|
workspaceDir: "/tmp/workspace-new",
|
|
});
|
|
await Promise.resolve();
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce();
|
|
|
|
finishFirst?.();
|
|
const firstSnapshot = await first;
|
|
const replacementSnapshot = await replacement;
|
|
expect(await requestDuringFirstGeneration).toBe(firstSnapshot);
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2);
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenLastCalledWith(
|
|
config,
|
|
agentDir,
|
|
expect.objectContaining({ workspaceDir: "/tmp/workspace-new" }),
|
|
);
|
|
expect(
|
|
await prepareModelRuntimeSnapshot({
|
|
config,
|
|
agentDir,
|
|
workspaceDir: "/tmp/workspace-new",
|
|
}),
|
|
).toBe(replacementSnapshot);
|
|
});
|
|
|
|
it("preserves an authoritative workspace override across config refresh", async () => {
|
|
mocks.configuredAgentIds = ["default"];
|
|
const config = {};
|
|
const agentDir = "/tmp/unused-agent";
|
|
await publishPreparedModelRuntimeSnapshot(
|
|
{
|
|
agentId: "default",
|
|
config,
|
|
agentDir,
|
|
inheritedAuthDir: agentDir,
|
|
workspaceDir: "/tmp/explicit-workspace",
|
|
preserveWorkspaceDirOnRefresh: true,
|
|
},
|
|
{ provenance: "configured" },
|
|
);
|
|
|
|
await refreshPreparedModelRuntimeSnapshots({
|
|
agents: { defaults: { model: "openai/gpt-5.5" } },
|
|
});
|
|
const snapshot = await prepareModelRuntimeSnapshot({
|
|
agentId: "default",
|
|
config,
|
|
agentDir,
|
|
inheritedAuthDir: agentDir,
|
|
workspaceDir: "/tmp/explicit-workspace",
|
|
});
|
|
|
|
expect(snapshot.workspaceDir).toBe("/tmp/explicit-workspace");
|
|
expect(mocks.ensureOpenClawModelsJson).toHaveBeenLastCalledWith(
|
|
expect.any(Object),
|
|
agentDir,
|
|
expect.objectContaining({ workspaceDir: "/tmp/explicit-workspace" }),
|
|
);
|
|
});
|
|
});
|