diff --git a/src/auto-reply/reply/session-usage.ts b/src/auto-reply/reply/session-usage.ts index 3d61607d64ac..b36754584a1d 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -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); }, diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index c8b08a042b4c..ccdbb5c3ba68 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -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";