fix(auto-reply): persist heartbeat cli session bindings

Fixes #98895
This commit is contained in:
Ayaan Zaidi
2026-07-01 21:16:17 -07:00
parent 8939c10d77
commit b60803dfaa
2 changed files with 101 additions and 7 deletions

View File

@@ -234,7 +234,7 @@ export async function persistSessionUsageUpdate(params: {
) {
patch.totalTokensFresh = false;
}
return preserveSessionModelState
return preserveUserFacingRunState
? patch
: applyCliSessionIdToSessionPatch(params, entry, patch);
},
@@ -285,7 +285,7 @@ export async function persistSessionUsageUpdate(params: {
// zero persisted for the previously empty session.
patch.totalTokensFresh = false;
}
return preserveSessionModelState
return preserveUserFacingRunState
? patch
: applyCliSessionIdToSessionPatch(params, entry, patch);
},

View File

@@ -30,10 +30,10 @@ import {
} from "../../test-utils/channel-plugins.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js";
import { replyRunRegistry } from "./reply-run-registry.js";
import { drainFormattedSystemEvents } from "./session-updates.js";
import { persistSessionUsageUpdate } from "./session-usage.js";
import { initSessionState } from "./session.js";
import { replyRunRegistry } from "./reply-run-registry.js";
const sessionForkMocks = vi.hoisted(() => ({
forkSessionFromParent: vi.fn(),
@@ -4313,7 +4313,7 @@ describe("persistSessionUsageUpdate", () => {
expect(stored[sessionKey].totalTokensFresh).toBe(true);
});
it("accounts exhausted-run usage without committing its model", async () => {
it("accounts exhausted-run usage without committing its model and persists CLI binding", async () => {
const storePath = await createStorePath("openclaw-usage-exhausted-");
const sessionKey = "main";
await seedSessionStore({
@@ -4357,12 +4357,12 @@ describe("persistSessionUsageUpdate", () => {
totalTokens: 100,
totalTokensFresh: true,
cliSessionBindings: {
"claude-cli": { sessionId: "existing-cli-session" },
"claude-cli": { sessionId: "exhausted-cli-session" },
},
cliSessionIds: {
"claude-cli": "existing-cli-session",
"claude-cli": "exhausted-cli-session",
},
claudeCliSessionId: "existing-cli-session",
claudeCliSessionId: "exhausted-cli-session",
});
});
@@ -4908,6 +4908,100 @@ describe("persistSessionUsageUpdate", () => {
expect(stored[sessionKey].totalTokens).toBe(1_105);
});
it("persists heartbeat CLI binding while preserving displayed session model", async () => {
const storePath = await createStorePath("openclaw-usage-heartbeat-cli-binding-");
const sessionKey = "main";
await seedSessionStore({
storePath,
sessionKey,
entry: {
sessionId: "s1",
updatedAt: Date.now(),
modelProvider: "openai",
model: "gpt-5.4",
cliSessionBindings: {
"claude-cli": { sessionId: "old-heartbeat-cli-session" },
},
cliSessionIds: {
"claude-cli": "old-heartbeat-cli-session",
},
claudeCliSessionId: "old-heartbeat-cli-session",
},
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
isHeartbeat: true,
usage: { input: 1_200, output: 100 },
usageIsContextSnapshot: true,
providerUsed: "claude-cli",
modelUsed: "claude-sonnet-4-6",
cliSessionBinding: {
sessionId: "new-heartbeat-cli-session",
authProfileId: "anthropic:heartbeat",
},
contextTokensUsed: 128_000,
});
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].modelProvider).toBe("openai");
expect(stored[sessionKey].model).toBe("gpt-5.4");
expect(stored[sessionKey].cliSessionIds?.["claude-cli"]).toBe("new-heartbeat-cli-session");
expect(stored[sessionKey].cliSessionBindings?.["claude-cli"]).toEqual({
sessionId: "new-heartbeat-cli-session",
authProfileId: "anthropic:heartbeat",
});
expect(stored[sessionKey].claudeCliSessionId).toBe("new-heartbeat-cli-session");
});
it("honors heartbeat CLI binding clears while preserving displayed session model", async () => {
const storePath = await createStorePath("openclaw-usage-heartbeat-cli-clear-");
const sessionKey = "main";
await seedSessionStore({
storePath,
sessionKey,
entry: {
sessionId: "s1",
updatedAt: Date.now(),
modelProvider: "openai",
model: "gpt-5.4",
cliSessionIds: {
"claude-cli": "old-heartbeat-cli-session",
"codex-cli": "codex-cli-session",
},
cliSessionBindings: {
"claude-cli": { sessionId: "old-heartbeat-cli-session" },
"codex-cli": { sessionId: "codex-cli-session" },
},
claudeCliSessionId: "old-heartbeat-cli-session",
},
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
isHeartbeat: true,
usage: { input: 1_200, output: 100 },
usageIsContextSnapshot: true,
providerUsed: "claude-cli",
modelUsed: "claude-sonnet-4-6",
clearCliSessionBinding: true,
contextTokensUsed: 128_000,
});
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].modelProvider).toBe("openai");
expect(stored[sessionKey].model).toBe("gpt-5.4");
expect(stored[sessionKey].cliSessionIds?.["claude-cli"]).toBeUndefined();
expect(stored[sessionKey].cliSessionIds?.["codex-cli"]).toBe("codex-cli-session");
expect(stored[sessionKey].cliSessionBindings?.["claude-cli"]).toBeUndefined();
expect(stored[sessionKey].cliSessionBindings?.["codex-cli"]).toEqual({
sessionId: "codex-cli-session",
});
expect(stored[sessionKey].claudeCliSessionId).toBeUndefined();
});
it("preserves the displayed session model when an internal announce uses fallback", async () => {
const storePath = await createStorePath("openclaw-usage-internal-announce-model-");
const sessionKey = "agent:main:telegram:group:-1003871627242:topic:6823";