fix(agents): honor heartbeat.model override instead of session model (#14181)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: f19b789057
Co-authored-by: 0xRaini <190923101+0xRaini@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
0xRain
2026-02-12 01:46:51 +08:00
committed by GitHub
parent 8c963dc5a6
commit 6d723c9f8a
5 changed files with 122 additions and 1 deletions

View File

@@ -127,6 +127,18 @@ describe("trigger handling", () => {
});
const cfg = makeCfg(home);
await fs.writeFile(
join(home, "sessions.json"),
JSON.stringify({
[_MAIN_SESSION_KEY]: {
sessionId: "main",
updatedAt: Date.now(),
providerOverride: "openai",
modelOverride: "gpt-5.2",
},
}),
"utf-8",
);
cfg.agents = {
...cfg.agents,
defaults: {
@@ -150,6 +162,44 @@ describe("trigger handling", () => {
expect(call?.model).toBe("claude-haiku-4-5-20251001");
});
});
it("keeps stored model override for heartbeat runs when heartbeat model is not configured", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
await fs.writeFile(
join(home, "sessions.json"),
JSON.stringify({
[_MAIN_SESSION_KEY]: {
sessionId: "main",
updatedAt: Date.now(),
providerOverride: "openai",
modelOverride: "gpt-5.2",
},
}),
"utf-8",
);
await getReplyFromConfig(
{
Body: "hello",
From: "+1002",
To: "+2000",
},
{ isHeartbeat: true },
makeCfg(home),
);
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(call?.provider).toBe("openai");
expect(call?.model).toBe("gpt-5.2");
});
});
it("suppresses HEARTBEAT_OK replies outside heartbeat runs", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({

View File

@@ -106,6 +106,7 @@ export async function resolveReplyDirectives(params: {
aliasIndex: ModelAliasIndex;
provider: string;
model: string;
hasResolvedHeartbeatModelOverride: boolean;
typing: TypingController;
opts?: GetReplyOptions;
skillFilter?: string[];
@@ -131,6 +132,7 @@ export async function resolveReplyDirectives(params: {
defaultModel,
provider: initialProvider,
model: initialModel,
hasResolvedHeartbeatModelOverride,
typing,
opts,
skillFilter,
@@ -391,6 +393,7 @@ export async function resolveReplyDirectives(params: {
provider,
model,
hasModelDirective: directives.hasModelDirective,
hasResolvedHeartbeatModelOverride,
});
provider = modelState.provider;
model = modelState.model;

View File

@@ -78,6 +78,7 @@ export async function getReplyFromConfig(
});
let provider = defaultProvider;
let model = defaultModel;
let hasResolvedHeartbeatModelOverride = false;
if (opts?.isHeartbeat) {
const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? "";
const heartbeatRef = heartbeatRaw
@@ -90,6 +91,7 @@ export async function getReplyFromConfig(
if (heartbeatRef) {
provider = heartbeatRef.ref.provider;
model = heartbeatRef.ref.model;
hasResolvedHeartbeatModelOverride = true;
}
}
@@ -196,6 +198,7 @@ export async function getReplyFromConfig(
aliasIndex,
provider,
model,
hasResolvedHeartbeatModelOverride,
typing,
opts: resolvedOpts,
skillFilter: mergedSkillFilter,

View File

@@ -153,4 +153,62 @@ describe("createModelSelectionState parent inheritance", () => {
expect(state.provider).toBe(defaultProvider);
expect(state.model).toBe(defaultModel);
});
it("applies stored override when heartbeat override was not resolved", async () => {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:discord:channel:c1";
const sessionEntry = makeEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
});
const sessionStore = {
[sessionKey]: sessionEntry,
};
const state = await createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry,
sessionStore,
sessionKey,
defaultProvider,
defaultModel,
provider: "anthropic",
model: "claude-opus-4-5",
hasModelDirective: false,
hasResolvedHeartbeatModelOverride: false,
});
expect(state.provider).toBe("openai");
expect(state.model).toBe("gpt-4o");
});
it("skips stored override when heartbeat override was resolved", async () => {
const cfg = {} as OpenClawConfig;
const sessionKey = "agent:main:discord:channel:c1";
const sessionEntry = makeEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
});
const sessionStore = {
[sessionKey]: sessionEntry,
};
const state = await createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry,
sessionStore,
sessionKey,
defaultProvider,
defaultModel,
provider: "anthropic",
model: "claude-opus-4-5",
hasModelDirective: false,
hasResolvedHeartbeatModelOverride: true,
});
expect(state.provider).toBe("anthropic");
expect(state.model).toBe("claude-opus-4-5");
});
});

View File

@@ -271,6 +271,9 @@ export async function createModelSelectionState(params: {
provider: string;
model: string;
hasModelDirective: boolean;
/** True when heartbeat.model was explicitly resolved for this run.
* In that case, skip session-stored overrides so the heartbeat selection wins. */
hasResolvedHeartbeatModelOverride?: boolean;
}): Promise<ModelSelectionState> {
const {
cfg,
@@ -343,7 +346,11 @@ export async function createModelSelectionState(params: {
sessionKey,
parentSessionKey,
});
if (storedOverride?.model) {
// Skip stored session model override only when an explicit heartbeat.model
// was resolved. Heartbeat runs without heartbeat.model should still inherit
// the regular session/parent model override behavior.
const skipStoredOverride = params.hasResolvedHeartbeatModelOverride === true;
if (storedOverride?.model && !skipStoredOverride) {
const candidateProvider = storedOverride.provider || defaultProvider;
const key = modelKey(candidateProvider, storedOverride.model);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {