Skip to content

Commit 4029fbd

Browse files
fix(status): avoid stale session context windows (#93220)
Summary: - The PR filters stale session/live context-token values when rendering `/status`, threads existing per-agent/default context caps into status rendering, and adds regression tests for status message and summary output. - PR surface: Source +107, Tests +155. Total +262 across 7 files. - Reproducibility: yes. Source inspection shows current main forwards stale live and persisted context-token v ... atus`, and the PR comments include live gateway output validating the Kimi/DeepSeek mismatch after the fix. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(status): avoid stale session context windows Validation: - ClawSweeper review passed for head 4a8e929. - Required merge gates passed before the squash merge. Prepared head SHA: 4a8e929 Review: #93220 (comment) Co-authored-by: masonxhuang <[email protected]> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: hxy91819 Co-authored-by: hxy91819 <[email protected]>
1 parent e7ee1c5 commit 4029fbd

7 files changed

Lines changed: 275 additions & 13 deletions

File tree

src/agents/agent-scope-config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type ResolvedAgentConfig = {
2424
verboseDefault?: AgentDefaultsConfig["verboseDefault"];
2525
reasoningDefault?: AgentEntry["reasoningDefault"];
2626
fastModeDefault?: AgentEntry["fastModeDefault"];
27+
contextTokens?: AgentEntry["contextTokens"];
2728
contextInjection?: AgentEntry["contextInjection"];
2829
bootstrapMaxChars?: AgentEntry["bootstrapMaxChars"];
2930
bootstrapTotalMaxChars?: AgentEntry["bootstrapTotalMaxChars"];
@@ -131,6 +132,7 @@ export function resolveAgentConfig(
131132
verboseDefault: entry.verboseDefault ?? agentDefaults?.verboseDefault,
132133
reasoningDefault: entry.reasoningDefault,
133134
fastModeDefault: entry.fastModeDefault,
135+
contextTokens: entry.contextTokens ?? agentDefaults?.contextTokens,
134136
contextInjection: entry.contextInjection,
135137
bootstrapMaxChars: entry.bootstrapMaxChars,
136138
bootstrapTotalMaxChars: entry.bootstrapTotalMaxChars,

src/auto-reply/reply/commands-status.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,71 @@ describe("buildStatusReply subagent summary", () => {
604604
});
605605
});
606606

607+
it("ignores stale live contextTokens when /status displays the current default model", async () => {
608+
const text = await buildStatusText({
609+
cfg: {
610+
...baseCfg,
611+
models: {
612+
providers: {
613+
"ollama-cloud": {
614+
baseUrl: "https://ollama.com",
615+
models: [
616+
{
617+
id: "deepseek-v4-pro",
618+
name: "DeepSeek V4 Pro",
619+
reasoning: true,
620+
input: ["text", "image"],
621+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
622+
contextWindow: 1_000_000,
623+
maxTokens: 128_000,
624+
},
625+
{
626+
id: "kimi-k2.7-code",
627+
name: "Kimi K2.7 Code",
628+
reasoning: true,
629+
input: ["text", "image"],
630+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
631+
contextWindow: 262_144,
632+
maxTokens: 128_000,
633+
},
634+
],
635+
},
636+
},
637+
},
638+
},
639+
sessionEntry: {
640+
sessionId: "sess-status-stale-live-context",
641+
updatedAt: 0,
642+
modelProvider: "ollama-cloud",
643+
model: "kimi-k2.7-code",
644+
totalTokens: 0,
645+
totalTokensFresh: true,
646+
},
647+
sessionKey: "agent:main:main",
648+
parentSessionKey: "agent:main:main",
649+
sessionScope: "per-sender",
650+
statusChannel: "mobilechat",
651+
provider: "ollama-cloud",
652+
model: "deepseek-v4-pro",
653+
contextTokens: 262_144,
654+
resolvedFastMode: false,
655+
resolvedVerboseLevel: "off",
656+
resolvedReasoningLevel: "off",
657+
resolveDefaultThinkingLevel: async () => undefined,
658+
isGroup: false,
659+
defaultGroupActivation: () => "mention",
660+
modelAuthOverride: "api-key",
661+
activeModelAuthOverride: "api-key",
662+
});
663+
664+
const normalized = normalizeTestText(text);
665+
expect(normalized).toContain("Model: ollama-cloud/deepseek-v4-pro");
666+
expect(normalized).toContain("Context: 0/1.0m");
667+
expect(normalized).not.toContain("kimi-k2.7-code");
668+
expect(normalized).not.toContain("Context: 0/262k");
669+
expect(normalized).not.toContain("/262k");
670+
});
671+
607672
it("shows gateway and system uptime in /status output", async () => {
608673
vi.spyOn(process, "uptime").mockReturnValue(2 * 60 * 60 + 5 * 60);
609674
vi.spyOn(os, "uptime").mockReturnValue(4 * 24 * 60 * 60 + 3 * 60 * 60);

src/auto-reply/status.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -987,6 +987,74 @@ describe("buildStatusMessage", () => {
987987
expect(normalizeTestText(text)).toContain("Context: 1.0k/66k");
988988
});
989989

990+
it("ignores stale session contextTokens after the default model changes", () => {
991+
const text = buildStatusMessage({
992+
agent: {
993+
model: "ollama-cloud/kimi-k2.7-code",
994+
contextTokens: 262_144,
995+
},
996+
explicitConfiguredContextTokens: 262_144,
997+
sessionEntry: {
998+
sessionId: "default-model-context-window",
999+
updatedAt: 0,
1000+
modelProvider: "ollama-cloud",
1001+
model: "deepseek-v4-pro",
1002+
totalTokens: 501,
1003+
totalTokensFresh: true,
1004+
contextTokens: 1_000_000,
1005+
},
1006+
sessionKey: "agent:main:main",
1007+
sessionScope: "per-sender",
1008+
queue: { mode: "collect", depth: 0 },
1009+
modelAuth: "api-key",
1010+
});
1011+
1012+
const normalized = normalizeTestText(text);
1013+
expect(normalized).toContain("Model: ollama-cloud/kimi-k2.7-code");
1014+
expect(normalized).toContain("Context: 501/262k");
1015+
expect(normalized).not.toContain("Context: 501/1.0m");
1016+
});
1017+
1018+
it("uses the selected model window when a stale runtime snapshot is smaller", () => {
1019+
const text = buildStatusMessage({
1020+
config: {
1021+
models: {
1022+
providers: {
1023+
"ollama-cloud": {
1024+
models: [
1025+
{ id: "deepseek-v4-pro", contextWindow: 1_000_000 },
1026+
{ id: "kimi-k2.7-code", contextWindow: 262_144 },
1027+
],
1028+
},
1029+
},
1030+
},
1031+
} as unknown as OpenClawConfig,
1032+
agent: {
1033+
model: "ollama-cloud/deepseek-v4-pro",
1034+
contextTokens: 1_000_000,
1035+
},
1036+
explicitConfiguredContextTokens: 1_000_000,
1037+
sessionEntry: {
1038+
sessionId: "default-model-context-window-larger",
1039+
updatedAt: 0,
1040+
modelProvider: "ollama-cloud",
1041+
model: "kimi-k2.7-code",
1042+
totalTokens: 0,
1043+
totalTokensFresh: true,
1044+
contextTokens: 262_144,
1045+
},
1046+
sessionKey: "agent:main:main",
1047+
sessionScope: "per-sender",
1048+
queue: { mode: "collect", depth: 0 },
1049+
modelAuth: "api-key",
1050+
});
1051+
1052+
const normalized = normalizeTestText(text);
1053+
expect(normalized).toContain("Model: ollama-cloud/deepseek-v4-pro");
1054+
expect(normalized).toContain("Context: 0/1.0m");
1055+
expect(normalized).not.toContain("Context: 0/262k");
1056+
});
1057+
9901058
it("recomputes context window from the active fallback model when session contextTokens are stale", () => {
9911059
const text = buildStatusMessage({
9921060
config: {

src/commands/status.summary.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,28 @@ describe("getStatusSummary", () => {
345345
});
346346
});
347347

348+
it("does not pass stale session contextTokens as status row overrides", async () => {
349+
statusSummaryMocks.listSessionEntries.mockReturnValue(
350+
toSessionEntrySummaries({
351+
"agent:main:main": {
352+
sessionId: "stale-context",
353+
updatedAt: Date.now(),
354+
modelProvider: "openai",
355+
model: "gpt-5.4",
356+
contextTokens: 1_000_000,
357+
},
358+
}),
359+
);
360+
361+
await getStatusSummary();
362+
363+
expect(
364+
vi
365+
.mocked(statusSummaryRuntime.resolveContextTokensForModel)
366+
.mock.calls.some((call) => call[0]?.contextTokensOverride === 1_000_000),
367+
).toBe(false);
368+
});
369+
348370
it("uses bundled provider static catalogs for cold status context", async () => {
349371
vi.mocked(statusSummaryRuntime.resolveConfiguredStatusModelRef).mockReturnValue({
350372
provider: "google",

src/commands/status.summary.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,38 @@ function hasUserPinnedModelSelection(entry: SessionEntry | undefined): boolean {
136136
return !hasSessionAutoModelFallbackProvenance(entry);
137137
}
138138

139+
function normalizeStatusModelPart(value: unknown): string {
140+
return typeof value === "string" ? value.trim().toLowerCase() : "";
141+
}
142+
143+
function resolveTrustedSessionContextTokens(params: {
144+
entry: SessionEntry | undefined;
145+
provider: string | undefined;
146+
model: string | null;
147+
}): number | undefined {
148+
const contextTokens =
149+
typeof params.entry?.contextTokens === "number" && params.entry.contextTokens > 0
150+
? params.entry.contextTokens
151+
: undefined;
152+
if (contextTokens === undefined) {
153+
return undefined;
154+
}
155+
if (hasSessionAutoModelFallbackProvenance(params.entry)) {
156+
return contextTokens;
157+
}
158+
const entryProvider = normalizeStatusModelPart(params.entry?.modelProvider);
159+
const entryModel = normalizeStatusModelPart(params.entry?.model);
160+
const resolvedProvider = normalizeStatusModelPart(params.provider);
161+
const resolvedModel = normalizeStatusModelPart(params.model);
162+
if (!entryModel || !resolvedModel || entryModel !== resolvedModel) {
163+
return undefined;
164+
}
165+
if (entryProvider && resolvedProvider && entryProvider !== resolvedProvider) {
166+
return undefined;
167+
}
168+
return contextTokens;
169+
}
170+
139171
type SessionCandidate = {
140172
key: string;
141173
entry: SessionEntry;
@@ -361,7 +393,11 @@ export async function getStatusSummary(
361393
provider: resolvedModel.provider,
362394
model,
363395
...modelContext,
364-
contextTokensOverride: entry?.contextTokens,
396+
contextTokensOverride: resolveTrustedSessionContextTokens({
397+
entry,
398+
provider: resolvedModel.provider,
399+
model,
400+
}),
365401
fallbackContextTokens: configContextTokens ?? undefined,
366402
allowAsyncLoad: false,
367403
}) ?? null;

src/status/status-message.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -735,10 +735,28 @@ export function buildStatusMessage(args: StatusArgs): string {
735735
typeof entry?.contextTokens === "number" && entry.contextTokens > 0
736736
? entry.contextTokens
737737
: undefined;
738+
const persistedContextMatchesActiveModel = (() => {
739+
if (persistedContextTokens === undefined) {
740+
return false;
741+
}
742+
const entryProvider = normalizeLowercaseStringOrEmpty(entry?.modelProvider);
743+
const entryModel = normalizeLowercaseStringOrEmpty(entry?.model);
744+
const lookupProvider = normalizeLowercaseStringOrEmpty(contextLookupProvider);
745+
const lookupModel = normalizeLowercaseStringOrEmpty(contextLookupModel);
746+
if (!entryModel || !lookupModel || entryModel !== lookupModel) {
747+
return false;
748+
}
749+
if (entryProvider && lookupProvider && entryProvider !== lookupProvider) {
750+
return false;
751+
}
752+
return !runtimeDiffersFromSelected || initialFallbackState.active;
753+
})();
738754
const cappedPersistedContextTokens =
739755
typeof persistedContextTokens === "number" && typeof activeContextTokens === "number"
740756
? Math.min(persistedContextTokens, activeContextTokens)
741-
: persistedContextTokens;
757+
: persistedContextMatchesActiveModel
758+
? persistedContextTokens
759+
: undefined;
742760
const agentContextTokens =
743761
typeof args.agent?.contextTokens === "number" && args.agent.contextTokens > 0
744762
? args.agent.contextTokens
@@ -778,17 +796,40 @@ export function buildStatusMessage(args: StatusArgs): string {
778796
const contextTokens = runtimeDiffersFromSelected
779797
? (explicitRuntimeContextTokens ??
780798
(() => {
781-
if (persistedContextTokens !== undefined) {
799+
const runtimeSnapshotHasFallbackProvenance =
800+
initialFallbackState.active ||
801+
hasSessionAutoModelFallbackProvenance(entry) ||
802+
areRuntimeModelRefsEquivalent(activeModelLabel, modelRefs.selected.label || "unknown");
803+
if (!runtimeSnapshotHasFallbackProvenance) {
804+
if (typeof selectedContextTokens === "number") {
805+
if (explicitConfiguredContextTokens !== undefined) {
806+
return Math.min(explicitConfiguredContextTokens, selectedContextTokens);
807+
}
808+
if (agentContextTokens !== undefined) {
809+
return Math.min(agentContextTokens, selectedContextTokens);
810+
}
811+
return selectedContextTokens;
812+
}
813+
if (explicitConfiguredContextTokens !== undefined) {
814+
return explicitConfiguredContextTokens;
815+
}
816+
if (agentContextTokens !== undefined) {
817+
return agentContextTokens;
818+
}
819+
return DEFAULT_CONTEXT_TOKENS;
820+
}
821+
if (cappedPersistedContextTokens !== undefined) {
822+
const trustedPersistedContextTokens = cappedPersistedContextTokens;
782823
const persistedLooksSelectedWindow =
783824
typeof selectedContextTokens === "number" &&
784-
persistedContextTokens === selectedContextTokens;
825+
trustedPersistedContextTokens === selectedContextTokens;
785826
const activeWindowDiffersFromSelected =
786827
typeof selectedContextTokens === "number" &&
787828
typeof activeContextTokens === "number" &&
788829
activeContextTokens !== selectedContextTokens;
789830
const explicitConfiguredMatchesPersisted =
790831
typeof explicitConfiguredContextTokens === "number" &&
791-
explicitConfiguredContextTokens === persistedContextTokens;
832+
explicitConfiguredContextTokens === trustedPersistedContextTokens;
792833
if (
793834
persistedLooksSelectedWindow &&
794835
activeWindowDiffersFromSelected &&
@@ -797,9 +838,9 @@ export function buildStatusMessage(args: StatusArgs): string {
797838
return activeContextTokens;
798839
}
799840
if (typeof activeContextTokens === "number") {
800-
return Math.min(persistedContextTokens, activeContextTokens);
841+
return Math.min(trustedPersistedContextTokens, activeContextTokens);
801842
}
802-
return persistedContextTokens;
843+
return trustedPersistedContextTokens;
803844
}
804845
if (cappedConfiguredContextTokens !== undefined) {
805846
return cappedConfiguredContextTokens;

0 commit comments

Comments
 (0)