fix: guard estimateMessageCharsCached against null/undefined messages

After upgrading to v2026.3.7, orphaned null/undefined entries can appear
in the session message array (likely from the new compaction lifecycle).
These cause a TypeError in estimateMessageCharsCached when WeakMap.get()
is called on a non-object, crashing active sessions.

Changes:
- Add null/typeof guard at the top of estimateMessageCharsCached()
- Add .filter() in estimateContextChars() to skip null entries before reduce
- Add test coverage for null, undefined, and primitive inputs

Regression since v2026.3.7. Related to #35146 and #35143 (null guards
for thinking/text blocks by @Sid-Qin), but this addresses null entries
in the message array itself rather than within message content blocks.
This commit is contained in:
Claudia 2026-03-09 16:20:59 +01:00
parent fbf5d56366
commit 20f3017241
2 changed files with 98 additions and 1 deletions

View File

@ -0,0 +1,92 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js";
import {
createMessageCharEstimateCache,
estimateContextChars,
estimateMessageCharsCached,
} from "./tool-result-char-estimator.js";
function makeUser(text: string): AgentMessage {
return castAgentMessage({
role: "user",
content: text,
timestamp: Date.now(),
});
}
describe("estimateMessageCharsCached", () => {
it("returns a positive estimate for a valid message", () => {
const cache = createMessageCharEstimateCache();
const msg = makeUser("hello world");
expect(estimateMessageCharsCached(msg, cache)).toBeGreaterThan(0);
});
it("returns 0 for null", () => {
const cache = createMessageCharEstimateCache();
expect(estimateMessageCharsCached(null as unknown as AgentMessage, cache)).toBe(0);
});
it("returns 0 for undefined", () => {
const cache = createMessageCharEstimateCache();
expect(estimateMessageCharsCached(undefined as unknown as AgentMessage, cache)).toBe(0);
});
it("returns 0 for a non-object primitive", () => {
const cache = createMessageCharEstimateCache();
expect(estimateMessageCharsCached(42 as unknown as AgentMessage, cache)).toBe(0);
});
it("caches the estimate on second call", () => {
const cache = createMessageCharEstimateCache();
const msg = makeUser("cached test");
const first = estimateMessageCharsCached(msg, cache);
const second = estimateMessageCharsCached(msg, cache);
expect(first).toBe(second);
expect(first).toBeGreaterThan(0);
});
});
describe("estimateContextChars", () => {
it("sums estimates for valid messages", () => {
const cache = createMessageCharEstimateCache();
const messages = [makeUser("one"), makeUser("two")];
const total = estimateContextChars(messages, cache);
expect(total).toBeGreaterThan(0);
});
it("skips null entries without crashing", () => {
const cache = createMessageCharEstimateCache();
const messages = [
makeUser("valid"),
null as unknown as AgentMessage,
makeUser("also valid"),
];
const total = estimateContextChars(messages, cache);
expect(total).toBeGreaterThan(0);
});
it("skips undefined entries without crashing", () => {
const cache = createMessageCharEstimateCache();
const messages = [
undefined as unknown as AgentMessage,
makeUser("valid"),
];
const total = estimateContextChars(messages, cache);
expect(total).toBeGreaterThan(0);
});
it("handles an entirely null/undefined array", () => {
const cache = createMessageCharEstimateCache();
const messages = [
null as unknown as AgentMessage,
undefined as unknown as AgentMessage,
];
expect(estimateContextChars(messages, cache)).toBe(0);
});
it("handles an empty array", () => {
const cache = createMessageCharEstimateCache();
expect(estimateContextChars([], cache)).toBe(0);
});
});

View File

@ -145,6 +145,9 @@ export function estimateMessageCharsCached(
msg: AgentMessage,
cache: MessageCharEstimateCache,
): number {
if (msg == null || typeof msg !== "object") {
return 0;
}
const hit = cache.get(msg);
if (hit !== undefined) {
return hit;
@ -158,7 +161,9 @@ export function estimateContextChars(
messages: AgentMessage[],
cache: MessageCharEstimateCache,
): number {
return messages.reduce((sum, msg) => sum + estimateMessageCharsCached(msg, cache), 0);
return messages
.filter((m): m is AgentMessage => m != null)
.reduce((sum, msg) => sum + estimateMessageCharsCached(msg, cache), 0);
}
export function invalidateMessageCharsCacheEntry(