


























@@ -877,6 +877,248 @@ describe("updateSessionStoreAfterAgentRun", () => {
877877expect(sessionStore[sessionKey]?.lastInteractionAt).toBeGreaterThan(lastInteractionAt);
878878});
879879});
880+881+it("preserves runtime model and contextTokens when preserveRuntimeModel is true (heartbeat bleed fix)", async () => {
882+await withTempSessionStore(async ({ storePath }) => {
883+const cfg = {} as OpenClawConfig;
884+const sessionKey = "agent:main:explicit:test-heartbeat-bleed";
885+const sessionId = "test-heartbeat-bleed-session";
886+const sessionStore: Record<string, SessionEntry> = {
887+[sessionKey]: {
888+ sessionId,
889+updatedAt: 1,
890+modelProvider: "anthropic",
891+model: "claude-opus-4-6",
892+contextTokens: 1_000_000,
893+},
894+};
895+await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
896+897+// Heartbeat turn uses a different model
898+const result: EmbeddedPiRunResult = {
899+meta: {
900+durationMs: 500,
901+agentMeta: {
902+ sessionId,
903+provider: "ollama",
904+model: "llama3.2:1b",
905+contextTokens: 128_000,
906+},
907+},
908+};
909+910+await updateSessionStoreAfterAgentRun({
911+ cfg,
912+ sessionId,
913+ sessionKey,
914+ storePath,
915+ sessionStore,
916+defaultProvider: "anthropic",
917+defaultModel: "claude-opus-4-6",
918+ result,
919+preserveRuntimeModel: true,
920+});
921+922+// Runtime model and contextTokens should be preserved from the original entry
923+expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6");
924+expect(sessionStore[sessionKey]?.modelProvider).toBe("anthropic");
925+expect(sessionStore[sessionKey]?.contextTokens).toBe(1_000_000);
926+927+const persisted = loadSessionStore(storePath);
928+expect(persisted[sessionKey]?.model).toBe("claude-opus-4-6");
929+expect(persisted[sessionKey]?.modelProvider).toBe("anthropic");
930+expect(persisted[sessionKey]?.contextTokens).toBe(1_000_000);
931+});
932+});
933+934+it("leaves contextTokens unset when entry has prior model but no contextTokens (heartbeat bleed guard)", async () => {
935+await withTempSessionStore(async ({ storePath }) => {
936+const cfg = {} as OpenClawConfig;
937+const sessionKey = "agent:main:explicit:test-heartbeat-no-context-tokens";
938+const sessionId = "test-heartbeat-no-context-tokens-session";
939+const sessionStore: Record<string, SessionEntry> = {
940+[sessionKey]: {
941+ sessionId,
942+updatedAt: 1,
943+modelProvider: "anthropic",
944+model: "claude-opus-4-6",
945+// contextTokens intentionally missing — older session without cached context
946+},
947+};
948+await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
949+950+// Heartbeat turn uses a different, smaller model
951+const result: EmbeddedPiRunResult = {
952+meta: {
953+durationMs: 500,
954+agentMeta: {
955+ sessionId,
956+provider: "ollama",
957+model: "llama3.2:1b",
958+contextTokens: 128_000,
959+},
960+},
961+};
962+963+await updateSessionStoreAfterAgentRun({
964+ cfg,
965+ sessionId,
966+ sessionKey,
967+ storePath,
968+ sessionStore,
969+defaultProvider: "anthropic",
970+defaultModel: "claude-opus-4-6",
971+ result,
972+preserveRuntimeModel: true,
973+});
974+975+// Runtime model should be preserved
976+expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6");
977+expect(sessionStore[sessionKey]?.modelProvider).toBe("anthropic");
978+// contextTokens should NOT bleed from the heartbeat run's smaller window
979+expect(sessionStore[sessionKey]?.contextTokens).toBeUndefined();
980+});
981+});
982+983+it("does not set runtime model when preserveRuntimeModel is true and entry has no prior runtime model", async () => {
984+await withTempSessionStore(async ({ storePath }) => {
985+const cfg = {} as OpenClawConfig;
986+const sessionKey = "agent:main:explicit:test-heartbeat-new-session";
987+const sessionId = "test-heartbeat-new-session-id";
988+const sessionStore: Record<string, SessionEntry> = {
989+[sessionKey]: {
990+ sessionId,
991+updatedAt: 1,
992+},
993+};
994+await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
995+996+const result: EmbeddedPiRunResult = {
997+meta: {
998+durationMs: 500,
999+agentMeta: {
1000+ sessionId,
1001+provider: "ollama",
1002+model: "llama3.2:1b",
1003+contextTokens: 128_000,
1004+},
1005+},
1006+};
1007+1008+await updateSessionStoreAfterAgentRun({
1009+ cfg,
1010+ sessionId,
1011+ sessionKey,
1012+ storePath,
1013+ sessionStore,
1014+defaultProvider: "ollama",
1015+defaultModel: "llama3.2:1b",
1016+ result,
1017+preserveRuntimeModel: true,
1018+});
1019+1020+// Heartbeat should NOT establish initial model state on an empty session
1021+expect(sessionStore[sessionKey]?.model).toBeUndefined();
1022+expect(sessionStore[sessionKey]?.modelProvider).toBeUndefined();
1023+expect(sessionStore[sessionKey]?.contextTokens).toBeUndefined();
1024+});
1025+});
1026+1027+it("preserves model without borrowing heartbeat provider when entry has model but no modelProvider", async () => {
1028+await withTempSessionStore(async ({ storePath }) => {
1029+const cfg = {} as OpenClawConfig;
1030+const sessionKey = "agent:main:explicit:test-heartbeat-model-no-provider";
1031+const sessionId = "test-heartbeat-model-no-provider-session";
1032+const sessionStore: Record<string, SessionEntry> = {
1033+[sessionKey]: {
1034+ sessionId,
1035+updatedAt: 1,
1036+model: "claude-opus-4-6",
1037+// modelProvider intentionally missing
1038+},
1039+};
1040+await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
1041+1042+// Heartbeat turn uses a different provider
1043+const result: EmbeddedPiRunResult = {
1044+meta: {
1045+durationMs: 500,
1046+agentMeta: {
1047+ sessionId,
1048+provider: "ollama",
1049+model: "llama3.2:1b",
1050+contextTokens: 128_000,
1051+},
1052+},
1053+};
1054+1055+await updateSessionStoreAfterAgentRun({
1056+ cfg,
1057+ sessionId,
1058+ sessionKey,
1059+ storePath,
1060+ sessionStore,
1061+defaultProvider: "anthropic",
1062+defaultModel: "claude-opus-4-6",
1063+ result,
1064+preserveRuntimeModel: true,
1065+});
1066+1067+// Model preserved, provider NOT borrowed from heartbeat
1068+expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6");
1069+expect(sessionStore[sessionKey]?.modelProvider).toBeUndefined();
1070+1071+const persisted = loadSessionStore(storePath);
1072+expect(persisted[sessionKey]?.model).toBe("claude-opus-4-6");
1073+expect(persisted[sessionKey]?.modelProvider).toBeUndefined();
1074+});
1075+});
1076+1077+it("overwrites runtime model when preserveRuntimeModel is false (default behavior)", async () => {
1078+await withTempSessionStore(async ({ storePath }) => {
1079+const cfg = {} as OpenClawConfig;
1080+const sessionKey = "agent:main:explicit:test-normal-overwrite";
1081+const sessionId = "test-normal-overwrite-session";
1082+const sessionStore: Record<string, SessionEntry> = {
1083+[sessionKey]: {
1084+ sessionId,
1085+updatedAt: 1,
1086+modelProvider: "anthropic",
1087+model: "claude-opus-4-6",
1088+contextTokens: 1_000_000,
1089+},
1090+};
1091+await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
1092+1093+const result: EmbeddedPiRunResult = {
1094+meta: {
1095+durationMs: 500,
1096+agentMeta: {
1097+ sessionId,
1098+provider: "openai",
1099+model: "gpt-5.4",
1100+contextTokens: 400_000,
1101+},
1102+},
1103+};
1104+1105+await updateSessionStoreAfterAgentRun({
1106+ cfg,
1107+ sessionId,
1108+ sessionKey,
1109+ storePath,
1110+ sessionStore,
1111+defaultProvider: "openai",
1112+defaultModel: "gpt-5.4",
1113+ result,
1114+});
1115+1116+// Normal turn: runtime model is updated
1117+expect(sessionStore[sessionKey]?.model).toBe("gpt-5.4");
1118+expect(sessionStore[sessionKey]?.modelProvider).toBe("openai");
1119+expect(sessionStore[sessionKey]?.contextTokens).toBe(400_000);
1120+});
1121+});
8801122});
88111238821124describe("clearCliSessionInStore", () => {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。