Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/lib/actions/inference-set-gateway-restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ interface InferenceResultForGateway {
model: string;
primaryModelRef: string;
inSandboxConfigSynced: boolean;
/**
* Hermes only: whether the isolated Web Dashboard profile converged onto the
* switched model (#6893). `undefined` for agents/switches with no Dashboard to
* converge (treated as converged). When explicitly `false` the "Inference route
* synced" line is withheld so the command does not claim a route it did not
* fully apply.
*/
dashboardConverged?: boolean;
}

export interface InferenceMutation<T extends InferenceResultForGateway> {
Expand Down Expand Up @@ -101,7 +109,10 @@ export function finalizeInferenceMutation<T extends InferenceResultForGateway>(
deps.appendAuditEntry(auditEntry);
}

if (result.inSandboxConfigSynced && !openClawGatewayRestartRequired) {
// A Hermes switch whose Web Dashboard profile did not converge is not fully
// applied, so withhold the success line (the caller already warned) (#6893).
const hermesDashboardStale = agentName === "hermes" && result.dashboardConverged === false;
if (result.inSandboxConfigSynced && !openClawGatewayRestartRequired && !hermesDashboardStale) {
deps.log(
agentName === "hermes"
? ` Inference route synced for '${result.sandboxName}': ${result.model}`
Expand Down
191 changes: 191 additions & 0 deletions src/lib/actions/inference-set-hermes-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,197 @@ describe("runInferenceSet Hermes routing", () => {
expect(deps.calls.restartSandboxGateway).not.toHaveBeenCalled();
});

it("re-seeds the isolated Hermes dashboard config after an in-place switch (#6893)", async () => {
const config: ConfigObject = {
model: {
default: "moonshotai/kimi-k2.6",
provider: "custom",
base_url: "https://inference.local/v1",
},
};
const deps = createDeps({
config,
entry: {
name: "hermes",
agent: "hermes",
provider: "hermes-provider",
model: "moonshotai/kimi-k2.6",
},
defaultSandbox: "hermes",
target: HERMES_TARGET,
session: baseSession({ agent: "hermes", sandboxName: "hermes" }),
});

await runInferenceSet(
{
provider: "hermes-provider",
model: "openai/gpt-5.4-mini",
sandboxName: "hermes",
noVerify: true,
},
deps,
);

// The dashboard-home config only re-mirrors the gateway model routing at
// startup, so the in-place switch must re-seed it or Dashboard Chat stays on
// the previous model. It must run after the gateway config was written.
expect(deps.calls.seedHermesDashboardConfig).toHaveBeenCalledWith("hermes", HERMES_TARGET);
const writeOrder = deps.calls.writeSandboxConfig.mock.invocationCallOrder[0];
const seedOrder = deps.calls.seedHermesDashboardConfig.mock.invocationCallOrder[0];
expect(seedOrder).toBeGreaterThan(writeOrder);
});

it("does not re-seed the dashboard when the in-sandbox config write fails (#6893)", async () => {
const config: ConfigObject = {
model: { default: "moonshotai/kimi-k2.6", provider: "custom" },
};
const deps = createDeps({
config,
entry: {
name: "hermes",
agent: "hermes",
provider: "hermes-provider",
model: "moonshotai/kimi-k2.6",
},
defaultSandbox: "hermes",
target: HERMES_TARGET,
session: baseSession({ agent: "hermes", sandboxName: "hermes" }),
});
deps.calls.writeSandboxConfig.mockImplementation(() => {
throw new Error("write failed");
});

await runInferenceSet(
{
provider: "hermes-provider",
model: "openai/gpt-5.4-mini",
sandboxName: "hermes",
noVerify: true,
},
deps,
);

// A failed gateway-config write leaves the old config in place; re-seeding the
// dashboard from it would be pointless (and the guidance is to rebuild).
expect(deps.calls.seedHermesDashboardConfig).not.toHaveBeenCalled();
});

it("does not re-seed or report synced when the config hash refresh fails (#6893)", async () => {
const config: ConfigObject = {
model: { default: "moonshotai/kimi-k2.6", provider: "custom" },
};
const deps = createDeps({
config,
entry: {
name: "hermes",
agent: "hermes",
provider: "hermes-provider",
model: "moonshotai/kimi-k2.6",
},
defaultSandbox: "hermes",
target: HERMES_TARGET,
session: baseSession({ agent: "hermes", sandboxName: "hermes" }),
});
deps.calls.recomputeSandboxConfigHash.mockImplementation(() => {
throw new Error("hash refresh failed");
});

await runInferenceSet(
{
provider: "hermes-provider",
model: "openai/gpt-5.4-mini",
sandboxName: "hermes",
noVerify: true,
},
deps,
);

expect(deps.calls.writeSandboxConfig).toHaveBeenCalledOnce();
expect(deps.calls.seedHermesDashboardConfig).not.toHaveBeenCalled();
const logs = deps.calls.log.mock.calls.map((call) => String(call[0]));
expect(logs.some((line) => line.includes("failed to refresh its integrity hash"))).toBe(true);
expect(logs.some((line) => line.includes("rebuild"))).toBe(true);
expect(logs.some((line) => line.includes("Inference route synced"))).toBe(false);
});

it("withholds the synced line when the dashboard fails to converge (#6893)", async () => {
const config: ConfigObject = {
model: {
default: "moonshotai/kimi-k2.6",
provider: "custom",
base_url: "https://inference.local/v1",
},
};
const deps = createDeps({
config,
entry: {
name: "hermes",
agent: "hermes",
provider: "hermes-provider",
model: "moonshotai/kimi-k2.6",
},
defaultSandbox: "hermes",
target: HERMES_TARGET,
session: baseSession({ agent: "hermes", sandboxName: "hermes" }),
seedHermesDashboardConfigResult: "failed",
});

await runInferenceSet(
{
provider: "hermes-provider",
model: "openai/gpt-5.4-mini",
sandboxName: "hermes",
noVerify: true,
},
deps,
);

// Not fully applied — the command must not claim `Inference route synced`, and
// it must tell the user why (the reporter's "converge or fail without synced").
const logs = deps.calls.log.mock.calls.map((c) => String(c[0]));
expect(logs.some((l) => l.includes("Inference route synced"))).toBe(false);
expect(logs.some((l) => l.includes("could not refresh the dashboard"))).toBe(true);
});

it("still reports synced when the dashboard profile is absent (Dashboard disabled) (#6893)", async () => {
const config: ConfigObject = {
model: {
default: "moonshotai/kimi-k2.6",
provider: "custom",
base_url: "https://inference.local/v1",
},
};
const deps = createDeps({
config,
entry: {
name: "hermes",
agent: "hermes",
provider: "hermes-provider",
model: "moonshotai/kimi-k2.6",
},
defaultSandbox: "hermes",
target: HERMES_TARGET,
session: baseSession({ agent: "hermes", sandboxName: "hermes" }),
seedHermesDashboardConfigResult: "absent",
});

await runInferenceSet(
{
provider: "hermes-provider",
model: "openai/gpt-5.4-mini",
sandboxName: "hermes",
noVerify: true,
},
deps,
);

// Nothing to converge — the switch is fully applied, so it still reports synced
// and does not warn.
const logs = deps.calls.log.mock.calls.map((c) => String(c[0]));
expect(logs.some((l) => l.includes("Inference route synced"))).toBe(true);
expect(logs.some((l) => l.includes("could not refresh the dashboard"))).toBe(false);
});

it("keeps Hermes custom Anthropic switches off the managed Anthropic SSE frontend (#6289)", async () => {
const config: ConfigObject = {
model: {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/actions/inference-set-openclaw-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ describe("runInferenceSet OpenClaw routing", () => {
});
expect(deps.calls.writeSandboxConfig).toHaveBeenCalledWith("alpha", OPENCLAW_TARGET, config);
expect(deps.calls.recomputeSandboxConfigHash).toHaveBeenCalledWith("alpha", OPENCLAW_TARGET);
// The dashboard re-seed is Hermes-only; OpenClaw has no isolated dashboard config. (#6893)
expect(deps.calls.seedHermesDashboardConfig).not.toHaveBeenCalled();
expect(deps.calls.updateSandbox).toHaveBeenCalledWith(
"alpha",
expect.objectContaining({
Expand Down
4 changes: 4 additions & 0 deletions src/lib/actions/inference-set.test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,14 @@ export function createDeps(options: {
prepareRunOpenshell?: () => void;
rewriteConfigUrlsWithDnsPinning?: (value: ConfigValue) => Promise<ConfigValue>;
restartSandboxGateway?: InferenceSetDeps["restartSandboxGateway"];
seedHermesDashboardConfigResult?: "converged" | "absent" | "failed";
withGatewayRouteMutationLock?: InferenceSetDeps["withGatewayRouteMutationLock"];
}): InferenceSetDeps & {
calls: {
captureOpenshell: ReturnType<typeof vi.fn>;
writeSandboxConfig: ReturnType<typeof vi.fn>;
recomputeSandboxConfigHash: ReturnType<typeof vi.fn>;
seedHermesDashboardConfig: ReturnType<typeof vi.fn>;
updateSandbox: ReturnType<typeof vi.fn>;
readSandboxConfig: ReturnType<typeof vi.fn>;
updateSession: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -124,6 +126,7 @@ export function createDeps(options: {
})),
writeSandboxConfig: vi.fn(),
recomputeSandboxConfigHash: vi.fn(),
seedHermesDashboardConfig: vi.fn(() => options.seedHermesDashboardConfigResult ?? "converged"),
updateSandbox: vi.fn(() => true),
readSandboxConfig: vi.fn(() => options.config),
updateSession: vi.fn((mutator: (value: Session) => Session | void) => {
Expand Down Expand Up @@ -169,6 +172,7 @@ export function createDeps(options: {
readSandboxConfig: calls.readSandboxConfig,
writeSandboxConfig: calls.writeSandboxConfig,
recomputeSandboxConfigHash: calls.recomputeSandboxConfigHash,
seedHermesDashboardConfig: calls.seedHermesDashboardConfig,
prepareRunOpenshell: calls.prepareRunOpenshell,
captureOpenshell: calls.captureOpenshell,
appendAuditEntry: calls.appendAuditEntry,
Expand Down
27 changes: 27 additions & 0 deletions src/lib/actions/inference-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ import {
} from "../openshell-gateway-endpoint-guard";
import {
type AgentConfigTarget,
type HermesDashboardReseedResult,
readSandboxConfig,
recomputeSandboxConfigHash,
resolveAgentConfig,
rewriteConfigUrlsWithDnsPinning,
seedHermesDashboardConfig,
writeSandboxConfig,
} from "../sandbox/config";
import type { ConfigObject, ConfigValue } from "../security/credential-filter";
Expand Down Expand Up @@ -110,6 +112,10 @@ export interface InferenceSetDeps extends InferenceGatewayRestartDeps {
config: ConfigObject,
) => void;
recomputeSandboxConfigHash: (sandboxName: string, target: AgentConfigTarget) => void;
seedHermesDashboardConfig: (
sandboxName: string,
target: AgentConfigTarget,
) => HermesDashboardReseedResult;
prepareRunOpenshell: () => void;
captureOpenshell: (
args: string[],
Expand Down Expand Up @@ -212,6 +218,7 @@ function defaultDeps(): InferenceSetDeps {
readSandboxConfig,
writeSandboxConfig,
recomputeSandboxConfigHash,
seedHermesDashboardConfig,
prepareRunOpenshell: () => {
getOpenshellBinary();
},
Expand Down Expand Up @@ -866,6 +873,25 @@ async function runInferenceSetWithoutHostLock(
` Run '${CLI_NAME} ${sandboxName} rebuild' to finish applying the model inside the sandbox.`,
);
}
// Hermes keeps an isolated dashboard-home config that only mirrors the gateway
// config's model routing at sandbox startup. Re-seed it after an in-place
// switch so Dashboard Chat (and /api/model/info) converge on the new model
// instead of silently staying on the previous one (#6893).
// - "converged": dashboard now matches the switch.
// - "absent": Dashboard disabled — nothing to converge, still a success.
// - "failed": warn and, below, withhold the "synced" success line so the
// command does not claim a route it did not fully apply.
let dashboardConverged: boolean | undefined;
if (agentName === "hermes" && inSandboxConfigSynced) {
const reseed = deps.seedHermesDashboardConfig(sandboxName, target);
dashboardConverged = reseed !== "failed";
if (reseed === "failed") {
deps.log(
` Warning: updated the Hermes model route but could not refresh the dashboard ` +
`config for '${sandboxName}'. Restart the sandbox to converge Dashboard Chat.`,
);
}
}
const sessionUpdated = updateMatchingOnboardSession(
sandboxName,
provider,
Expand All @@ -890,6 +916,7 @@ async function runInferenceSetWithoutHostLock(
configChanged: patched.changed,
sessionUpdated,
inSandboxConfigSynced,
dashboardConverged,
},
},
deps,
Expand Down
Loading
Loading