mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
a24cf632c7
* fix(anthropic): declare GA 1M context window for opus-4-7/sonnet-4-6/opus-4-6 (issue #108152) * test(anthropic): cover corrected context resolution * fix(anthropic): align exact Claude model limits * fix(anthropic): scope Claude CLI output limits * fix(anthropic): preserve plan-safe Claude CLI context * test(anthropic): align status context expectations * test(anthropic): align gateway context expectations * test(anthropic): align lookup context expectations * test(anthropic): align CLI status context * test(anthropic): align CLI reseed context cap --------- Co-authored-by: Lavya Tandel <lavya@loom.local> Co-authored-by: Peter Steinberger <steipete@gmail.com>
946 lines
28 KiB
TypeScript
946 lines
28 KiB
TypeScript
// Session search tests cover gateway session rows, transcript usage summaries,
|
|
// subagent state, model context limits, and cost/token display metadata.
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { afterEach, beforeAll, describe, expect, test } from "vitest";
|
|
import { ANTHROPIC_CONTEXT_1M_TOKENS } from "../agents/context-resolution.js";
|
|
import {
|
|
addSubagentRunForTests,
|
|
resetSubagentRegistryForTests,
|
|
} from "../agents/subagent-registry.test-helpers.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import type { SessionEntry } from "../config/sessions.js";
|
|
import {
|
|
appendTranscriptMessageSync,
|
|
replaceSessionEntry,
|
|
} from "../config/sessions/session-accessor.js";
|
|
import { registerAgentRunContext, resetAgentEventsForTest } from "../infra/agent-events.js";
|
|
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
|
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
|
import {
|
|
buildGatewaySessionInfo,
|
|
filterAndSortSessionEntries,
|
|
listSessionsFromStore,
|
|
} from "./session-utils.js";
|
|
|
|
const MAIN_SESSION_KEY = "agent:main:main";
|
|
const MAIN_SESSION_ID = "sess-main";
|
|
const TRANSCRIPT_TOTAL_TOKENS = 3_200;
|
|
const TRANSCRIPT_COST_USD = 0.007725;
|
|
const ANTHROPIC_MODEL = "claude-sonnet-4-6";
|
|
const FREE_OPENAI_MODEL = "gpt-5.3-codex-spark";
|
|
|
|
type TranscriptUsageFixture = {
|
|
provider: string;
|
|
model: string;
|
|
input: number;
|
|
output: number;
|
|
cacheRead: number;
|
|
costTotal: number;
|
|
};
|
|
|
|
const ANTHROPIC_USAGE: TranscriptUsageFixture = {
|
|
provider: "anthropic",
|
|
model: ANTHROPIC_MODEL,
|
|
input: 2_000,
|
|
output: 500,
|
|
cacheRead: 1_200,
|
|
costTotal: TRANSCRIPT_COST_USD,
|
|
};
|
|
|
|
const FREE_OPENAI_USAGE: TranscriptUsageFixture = {
|
|
provider: "openai",
|
|
model: FREE_OPENAI_MODEL,
|
|
input: 5_107,
|
|
output: 1_827,
|
|
cacheRead: 1_536,
|
|
costTotal: 0,
|
|
};
|
|
|
|
function createModelDefaultsConfig(params: {
|
|
primary: string;
|
|
models?: Record<string, Record<string, never>>;
|
|
}): OpenClawConfig {
|
|
return {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: params.primary },
|
|
models: params.models,
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
}
|
|
|
|
function closeSessionSqliteDatabasesForTest(): void {
|
|
closeOpenClawAgentDatabasesForTest();
|
|
closeOpenClawStateDatabaseForTest();
|
|
}
|
|
|
|
function createLegacyRuntimeListConfig(
|
|
models?: Record<string, Record<string, never>>,
|
|
): OpenClawConfig {
|
|
return createModelDefaultsConfig({
|
|
primary: "google-gemini-cli/gemini-3.1-pro-preview",
|
|
...(models ? { models } : {}),
|
|
});
|
|
}
|
|
|
|
function createLegacyRuntimeStore(model: string): Record<string, SessionEntry> {
|
|
return {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: Date.now(),
|
|
model,
|
|
} as SessionEntry,
|
|
};
|
|
}
|
|
|
|
function buildLegacyRuntimeRow(cfg: OpenClawConfig, model: string) {
|
|
const store = createLegacyRuntimeStore(model);
|
|
return buildGatewaySessionInfo({
|
|
cfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store,
|
|
key: MAIN_SESSION_KEY,
|
|
entry: store[MAIN_SESSION_KEY],
|
|
});
|
|
}
|
|
|
|
function createOpenAiPricingConfig(params: {
|
|
id: string;
|
|
label: string;
|
|
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
}): OpenClawConfig {
|
|
return {
|
|
session: { mainKey: "main" },
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
models: [
|
|
{
|
|
id: params.id,
|
|
label: params.label,
|
|
baseUrl: "https://api.openai.com/v1",
|
|
cost: params.cost,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
}
|
|
|
|
type DefaultTranscriptFixtureParams<T> = {
|
|
prefix: string;
|
|
transcriptId?: string;
|
|
run: (fixture: { storePath: string; now: number }) => Promise<T> | T;
|
|
};
|
|
|
|
function appendUsageTranscriptMessage(params: {
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
storePath: string;
|
|
usage: TranscriptUsageFixture;
|
|
}) {
|
|
appendTranscriptMessageSync(
|
|
{
|
|
agentId: "main",
|
|
sessionId: params.sessionId,
|
|
sessionKey: params.sessionKey,
|
|
storePath: params.storePath,
|
|
},
|
|
{
|
|
message: {
|
|
role: "assistant",
|
|
provider: params.usage.provider,
|
|
model: params.usage.model,
|
|
usage: {
|
|
input: params.usage.input,
|
|
output: params.usage.output,
|
|
cacheRead: params.usage.cacheRead,
|
|
cost: { total: params.usage.costTotal },
|
|
},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
async function withTranscriptFixture<T>(
|
|
usage: TranscriptUsageFixture,
|
|
params: DefaultTranscriptFixtureParams<T>,
|
|
): Promise<T> {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), params.prefix));
|
|
const storePath = path.join(tmpDir, "sessions.json");
|
|
const transcriptId = params.transcriptId ?? MAIN_SESSION_ID;
|
|
const now = Date.now();
|
|
|
|
try {
|
|
await replaceSessionEntry(
|
|
{
|
|
agentId: "main",
|
|
sessionKey: MAIN_SESSION_KEY,
|
|
storePath,
|
|
},
|
|
{ sessionId: transcriptId, updatedAt: now },
|
|
);
|
|
appendUsageTranscriptMessage({
|
|
sessionId: transcriptId,
|
|
sessionKey: MAIN_SESSION_KEY,
|
|
storePath,
|
|
usage,
|
|
});
|
|
return await params.run({ storePath, now });
|
|
} finally {
|
|
closeSessionSqliteDatabasesForTest();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const withAnthropicTranscriptFixture = <T>(params: DefaultTranscriptFixtureParams<T>) =>
|
|
withTranscriptFixture(ANTHROPIC_USAGE, params);
|
|
|
|
const withFreeOpenAiTranscriptFixture = <T>(params: DefaultTranscriptFixtureParams<T>) =>
|
|
withTranscriptFixture(FREE_OPENAI_USAGE, params);
|
|
|
|
function createAnthropicContext1mConfig(): OpenClawConfig {
|
|
return {
|
|
session: { mainKey: "main" },
|
|
agents: {
|
|
list: [{ id: "main", default: true }],
|
|
defaults: {
|
|
models: {
|
|
[`anthropic/${ANTHROPIC_MODEL}`]: { params: { context1m: true } },
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
}
|
|
|
|
function listSingleSession(params: {
|
|
cfg: OpenClawConfig;
|
|
storePath: string;
|
|
key: string;
|
|
entry: SessionEntry;
|
|
}) {
|
|
return listSessionsFromStore({
|
|
cfg: params.cfg,
|
|
storePath: params.storePath,
|
|
store: {
|
|
[params.key]: params.entry,
|
|
},
|
|
opts: {},
|
|
});
|
|
}
|
|
|
|
function listMainSession(params: { cfg: OpenClawConfig; storePath: string; entry: SessionEntry }) {
|
|
return listSingleSession({
|
|
cfg: params.cfg,
|
|
storePath: params.storePath,
|
|
key: MAIN_SESSION_KEY,
|
|
entry: params.entry,
|
|
});
|
|
}
|
|
|
|
function registerRunningSubagent(params: {
|
|
runId: string;
|
|
childSessionKey: string;
|
|
model: string;
|
|
now: number;
|
|
}) {
|
|
addSubagentRunForTests({
|
|
runId: params.runId,
|
|
childSessionKey: params.childSessionKey,
|
|
controllerSessionKey: MAIN_SESSION_KEY,
|
|
requesterSessionKey: MAIN_SESSION_KEY,
|
|
requesterDisplayKey: "main",
|
|
task: "child task",
|
|
cleanup: "keep",
|
|
createdAt: params.now - 5_000,
|
|
startedAt: params.now - 4_000,
|
|
model: params.model,
|
|
});
|
|
registerAgentRunContext(params.runId, {
|
|
sessionKey: params.childSessionKey,
|
|
});
|
|
}
|
|
|
|
type ListedSession = ReturnType<typeof listSessionsFromStore>["sessions"][number];
|
|
|
|
function expectSessionModel(
|
|
session: ListedSession | undefined,
|
|
expected: { key: string; provider: string; model: string },
|
|
) {
|
|
expect(session?.key).toBe(expected.key);
|
|
expect(session?.modelProvider).toBe(expected.provider);
|
|
expect(session?.model).toBe(expected.model);
|
|
}
|
|
|
|
function expectTranscriptBackfill(
|
|
session: ListedSession | undefined,
|
|
expected?: { contextTokens?: number; estimatedCostUsd?: number },
|
|
) {
|
|
expect(session?.totalTokens).toBe(TRANSCRIPT_TOTAL_TOKENS);
|
|
expect(session?.totalTokensFresh).toBe(true);
|
|
if (expected?.contextTokens !== undefined) {
|
|
expect(session?.contextTokens).toBe(expected.contextTokens);
|
|
}
|
|
if (expected?.estimatedCostUsd !== undefined) {
|
|
expect(session?.estimatedCostUsd).toBeCloseTo(expected.estimatedCostUsd, 8);
|
|
}
|
|
}
|
|
|
|
function sessionEntry(overrides: Partial<SessionEntry> = {}, updatedAt = Date.now()): SessionEntry {
|
|
return {
|
|
sessionId: MAIN_SESSION_ID,
|
|
updatedAt,
|
|
...overrides,
|
|
} as SessionEntry;
|
|
}
|
|
|
|
function mainSessionStore(entry: SessionEntry): Record<string, SessionEntry> {
|
|
return { [MAIN_SESSION_KEY]: entry };
|
|
}
|
|
|
|
function transcriptFallbackEntry(now: number, overrides: Partial<SessionEntry> = {}): SessionEntry {
|
|
return sessionEntry(
|
|
{
|
|
totalTokens: 0,
|
|
totalTokensFresh: false,
|
|
...overrides,
|
|
},
|
|
now,
|
|
);
|
|
}
|
|
|
|
function expectAnthropicBackfill(session: ListedSession | undefined) {
|
|
expectTranscriptBackfill(session, {
|
|
contextTokens: ANTHROPIC_CONTEXT_1M_TOKENS,
|
|
estimatedCostUsd: TRANSCRIPT_COST_USD,
|
|
});
|
|
}
|
|
|
|
function expectOpenAiGpt54Backfill(session: ListedSession | undefined) {
|
|
expectSessionModel(session, {
|
|
key: MAIN_SESSION_KEY,
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
});
|
|
expectTranscriptBackfill(session);
|
|
}
|
|
|
|
function freeOpenAiUsageEntry(): SessionEntry {
|
|
return sessionEntry({
|
|
modelProvider: "openai",
|
|
model: FREE_OPENAI_MODEL,
|
|
inputTokens: FREE_OPENAI_USAGE.input,
|
|
outputTokens: FREE_OPENAI_USAGE.output,
|
|
cacheRead: FREE_OPENAI_USAGE.cacheRead,
|
|
cacheWrite: 0,
|
|
});
|
|
}
|
|
|
|
function anthropicUsageEntry(now: number, overrides: Partial<SessionEntry> = {}): SessionEntry {
|
|
return {
|
|
sessionId: MAIN_SESSION_ID,
|
|
updatedAt: now,
|
|
totalTokens: 0,
|
|
totalTokensFresh: false,
|
|
inputTokens: ANTHROPIC_USAGE.input,
|
|
outputTokens: ANTHROPIC_USAGE.output,
|
|
cacheRead: ANTHROPIC_USAGE.cacheRead,
|
|
...overrides,
|
|
} as SessionEntry;
|
|
}
|
|
|
|
function zeroUsageTranscriptEntry(
|
|
now: number,
|
|
overrides: Partial<SessionEntry> = {},
|
|
): SessionEntry {
|
|
return transcriptFallbackEntry(now, {
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function childTranscriptEntry(sessionId: string, now: number): SessionEntry {
|
|
return transcriptFallbackEntry(now, {
|
|
sessionId,
|
|
spawnedBy: MAIN_SESSION_KEY,
|
|
});
|
|
}
|
|
|
|
describe("listSessionsFromStore search", () => {
|
|
beforeAll(() => {
|
|
listSessionsFromStore({
|
|
cfg: createModelDefaultsConfig({ primary: "anthropic/claude-sonnet-4-6" }),
|
|
store: {
|
|
"agent:main:warm-runtime": {
|
|
sessionId: "sess-warm-runtime",
|
|
updatedAt: Date.now(),
|
|
} as SessionEntry,
|
|
},
|
|
storePath: "/tmp/openclaw-session-search-warm.json",
|
|
opts: { search: "anthropic" },
|
|
});
|
|
});
|
|
|
|
beforeAll(() => {
|
|
listSessionsFromStore({
|
|
cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }),
|
|
storePath: "/tmp/sessions.json",
|
|
store: {
|
|
"agent:main:main": {
|
|
sessionId: "sess-main",
|
|
updatedAt: 1,
|
|
modelProvider: "openai",
|
|
model: "gpt-5.4",
|
|
},
|
|
},
|
|
opts: { search: "openai" },
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetAgentEventsForTest({ preserveListeners: true });
|
|
resetSubagentRegistryForTests();
|
|
closeSessionSqliteDatabasesForTest();
|
|
});
|
|
|
|
const baseCfg = {
|
|
session: { mainKey: "main" },
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
} as OpenClawConfig;
|
|
|
|
const makeStore = (): Record<string, SessionEntry> => ({
|
|
"agent:main:work-project": {
|
|
sessionId: "sess-work-1",
|
|
updatedAt: Date.now(),
|
|
displayName: "Work Project Alpha",
|
|
label: "work",
|
|
} as SessionEntry,
|
|
"agent:main:personal-chat": {
|
|
sessionId: "sess-personal-1",
|
|
updatedAt: Date.now() - 1000,
|
|
displayName: "Personal Chat",
|
|
subject: "Family Reunion Planning",
|
|
} as SessionEntry,
|
|
"agent:main:discord:group:dev-team": {
|
|
sessionId: "sess-discord-1",
|
|
updatedAt: Date.now() - 2000,
|
|
label: "discord",
|
|
subject: "Dev Team Discussion",
|
|
} as SessionEntry,
|
|
});
|
|
|
|
function listSearchSessions(params: {
|
|
opts: Parameters<typeof listSessionsFromStore>[0]["opts"];
|
|
cfg?: OpenClawConfig;
|
|
store?: Record<string, SessionEntry>;
|
|
}) {
|
|
return listSessionsFromStore({
|
|
cfg: params.cfg ?? baseCfg,
|
|
storePath: "/tmp/sessions.json",
|
|
store: params.store ?? makeStore(),
|
|
opts: params.opts,
|
|
});
|
|
}
|
|
|
|
function listConfiguredMainSession(cfg: OpenClawConfig, entry: SessionEntry) {
|
|
return listSearchSessions({
|
|
cfg,
|
|
store: mainSessionStore(entry),
|
|
opts: {},
|
|
});
|
|
}
|
|
|
|
test("returns all sessions when search is empty or missing", () => {
|
|
const cases = [{ opts: { search: "" } }, { opts: {} }] as const;
|
|
for (const testCase of cases) {
|
|
const result = listSearchSessions({ opts: testCase.opts });
|
|
expect(result.sessions).toHaveLength(3);
|
|
}
|
|
});
|
|
|
|
test("filters sessions across display metadata and key fields", () => {
|
|
const cases = [
|
|
{ search: "WORK PROJECT", expectedKey: "agent:main:work-project" },
|
|
{ search: "reunion", expectedKey: "agent:main:personal-chat" },
|
|
{ search: "discord", expectedKey: "agent:main:discord:group:dev-team" },
|
|
{ search: "sess-personal", expectedKey: "agent:main:personal-chat" },
|
|
{ search: "dev-team", expectedKey: "agent:main:discord:group:dev-team" },
|
|
{ search: "alpha", expectedKey: "agent:main:work-project" },
|
|
{ search: " personal ", expectedKey: "agent:main:personal-chat" },
|
|
{ search: "nonexistent-term", expectedKey: undefined },
|
|
] as const;
|
|
|
|
for (const testCase of cases) {
|
|
const result = listSearchSessions({ opts: { search: testCase.search } });
|
|
if (!testCase.expectedKey) {
|
|
expect(result.sessions).toHaveLength(0);
|
|
continue;
|
|
}
|
|
expect(result.sessions).toHaveLength(1);
|
|
expect(expectDefined(result.sessions[0], "result.sessions[0] test invariant").key).toBe(
|
|
testCase.expectedKey,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("filters sessions by the displayed provider and model identity", () => {
|
|
const now = Date.now();
|
|
const cfg = createModelDefaultsConfig({
|
|
primary: "anthropic/claude-sonnet-4-6",
|
|
});
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:inherited-default": {
|
|
sessionId: "sess-inherited-default",
|
|
updatedAt: now,
|
|
label: "Inherited default",
|
|
} as SessionEntry,
|
|
"agent:main:override": {
|
|
sessionId: "sess-override",
|
|
updatedAt: now - 1_000,
|
|
label: "Override",
|
|
providerOverride: "openai",
|
|
modelOverride: "gpt-5.5",
|
|
} as SessionEntry,
|
|
"agent:main:runtime": {
|
|
sessionId: "sess-runtime",
|
|
updatedAt: now - 2_000,
|
|
label: "Runtime",
|
|
modelProvider: "google",
|
|
model: "gemini-3.1-pro-preview",
|
|
} as SessionEntry,
|
|
};
|
|
const cases = [
|
|
{ search: "anthropic", expectedKey: "agent:main:inherited-default" },
|
|
{ search: "claude-sonnet", expectedKey: "agent:main:inherited-default" },
|
|
{ search: "anthropic/claude-sonnet", expectedKey: "agent:main:inherited-default" },
|
|
{ search: "openai/gpt-5.5", expectedKey: "agent:main:override" },
|
|
{ search: "gemini-3.1", expectedKey: "agent:main:runtime" },
|
|
{ search: "google/gemini", expectedKey: "agent:main:runtime" },
|
|
] as const;
|
|
|
|
for (const testCase of cases) {
|
|
const entries = filterAndSortSessionEntries({
|
|
cfg,
|
|
store,
|
|
opts: { search: testCase.search },
|
|
now,
|
|
});
|
|
|
|
expect(entries.map(([key]) => key)).toEqual([testCase.expectedKey]);
|
|
}
|
|
});
|
|
|
|
test("keeps derived model search for colon model ids", () => {
|
|
const now = Date.now();
|
|
const cfg = createModelDefaultsConfig({
|
|
primary: "ollama/qwen3:0.6b",
|
|
});
|
|
const result = listSearchSessions({
|
|
cfg,
|
|
store: {
|
|
"agent:main:inherited-local-model": {
|
|
sessionId: "sess-inherited-local-model",
|
|
updatedAt: now,
|
|
label: "Inherited local model",
|
|
} as SessionEntry,
|
|
},
|
|
opts: { search: "qwen3:0.6b" },
|
|
});
|
|
|
|
expect(result.sessions.map((session) => session.key)).toEqual([
|
|
"agent:main:inherited-local-model",
|
|
]);
|
|
expect(result.totalCount).toBe(1);
|
|
});
|
|
|
|
test("hides cron run alias session keys from sessions list", () => {
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:cron:job-1": {
|
|
sessionId: "run-abc",
|
|
updatedAt: now,
|
|
label: "Cron: job-1",
|
|
} as SessionEntry,
|
|
"agent:main:cron:job-1:run:run-abc": {
|
|
sessionId: "run-abc",
|
|
updatedAt: now,
|
|
label: "Cron: job-1",
|
|
} as SessionEntry,
|
|
};
|
|
|
|
const result = listSearchSessions({
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]);
|
|
});
|
|
|
|
test("ranks sessions by real interaction without heartbeat or cron noise", () => {
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:main": {
|
|
sessionId: "main",
|
|
updatedAt: now - 10_000,
|
|
lastInteractionAt: now - 1_000,
|
|
} as SessionEntry,
|
|
"agent:main:heartbeat-noise": {
|
|
sessionId: "heartbeat-noise",
|
|
updatedAt: now,
|
|
lastInteractionAt: now - 5_000,
|
|
pinnedAt: now,
|
|
} as SessionEntry,
|
|
"agent:main:background-only": {
|
|
sessionId: "background-only",
|
|
updatedAt: now + 1_000,
|
|
} as SessionEntry,
|
|
"agent:main:main:heartbeat": {
|
|
sessionId: "isolated-heartbeat",
|
|
updatedAt: now + 3_000,
|
|
lastInteractionAt: now + 3_000,
|
|
heartbeatIsolatedBaseSessionKey: "agent:main:main",
|
|
} as SessionEntry,
|
|
"agent:main:cron:job-1:run:run-abc": {
|
|
sessionId: "run-abc",
|
|
updatedAt: now + 2_000,
|
|
lastInteractionAt: now + 2_000,
|
|
} as SessionEntry,
|
|
};
|
|
|
|
const result = listSearchSessions({
|
|
store,
|
|
opts: {
|
|
requireLastInteraction: true,
|
|
sortBy: "lastInteractionAt",
|
|
},
|
|
});
|
|
|
|
expect(result.sessions.map((session) => session.key)).toEqual([
|
|
"agent:main:main",
|
|
"agent:main:heartbeat-noise",
|
|
]);
|
|
expect(result.sessions[0]?.lastInteractionAt).toBe(now - 1_000);
|
|
});
|
|
|
|
test.each([
|
|
{
|
|
name: "does not guess provider for legacy runtime model without modelProvider",
|
|
cfg: createLegacyRuntimeListConfig(),
|
|
runtimeModel: "claude-sonnet-4-6",
|
|
expectedProvider: undefined,
|
|
},
|
|
{
|
|
name: "infers provider for legacy runtime model when allowlist match is unique",
|
|
cfg: createLegacyRuntimeListConfig({ "anthropic/claude-sonnet-4-6": {} }),
|
|
runtimeModel: "claude-sonnet-4-6",
|
|
expectedProvider: "anthropic",
|
|
},
|
|
{
|
|
name: "infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique",
|
|
cfg: createLegacyRuntimeListConfig({
|
|
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
|
|
}),
|
|
runtimeModel: "anthropic/claude-sonnet-4-6",
|
|
expectedProvider: "vercel-ai-gateway",
|
|
},
|
|
])("$name", ({ cfg, runtimeModel, expectedProvider }) => {
|
|
const row = buildLegacyRuntimeRow(cfg, runtimeModel);
|
|
|
|
expect(row.modelProvider).toBe(expectedProvider);
|
|
expect(row.model).toBe(runtimeModel);
|
|
});
|
|
|
|
test("exposes unknown totals when freshness is stale or missing", () => {
|
|
const now = Date.now();
|
|
const store: Record<string, SessionEntry> = {
|
|
"agent:main:fresh": {
|
|
sessionId: "sess-fresh",
|
|
updatedAt: now,
|
|
totalTokens: 1200,
|
|
totalTokensFresh: true,
|
|
} as SessionEntry,
|
|
"agent:main:stale": {
|
|
sessionId: "sess-stale",
|
|
updatedAt: now - 1000,
|
|
totalTokens: 2200,
|
|
totalTokensFresh: false,
|
|
} as SessionEntry,
|
|
"agent:main:missing": {
|
|
sessionId: "sess-missing",
|
|
updatedAt: now - 2000,
|
|
inputTokens: 100,
|
|
outputTokens: 200,
|
|
} as SessionEntry,
|
|
};
|
|
|
|
const result = listSearchSessions({
|
|
store,
|
|
opts: {},
|
|
});
|
|
|
|
const fresh = result.sessions.find((row) => row.key === "agent:main:fresh");
|
|
const stale = result.sessions.find((row) => row.key === "agent:main:stale");
|
|
const missing = result.sessions.find((row) => row.key === "agent:main:missing");
|
|
expect(fresh?.totalTokens).toBe(1200);
|
|
expect(fresh?.totalTokensFresh).toBe(true);
|
|
expect(stale?.totalTokens).toBeUndefined();
|
|
expect(stale?.totalTokensFresh).toBe(false);
|
|
expect(missing?.totalTokens).toBeUndefined();
|
|
expect(missing?.totalTokensFresh).toBe(false);
|
|
});
|
|
|
|
test("includes estimated session cost when model pricing is configured", () => {
|
|
const cfg = createOpenAiPricingConfig({
|
|
id: "gpt-5.4",
|
|
label: "GPT 5.4",
|
|
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0.5 },
|
|
});
|
|
const result = listConfiguredMainSession(
|
|
cfg,
|
|
sessionEntry({
|
|
modelProvider: "openai",
|
|
model: "gpt-5.4",
|
|
inputTokens: 2_000,
|
|
outputTokens: 500,
|
|
cacheRead: 1_000,
|
|
cacheWrite: 200,
|
|
}),
|
|
);
|
|
|
|
expect(result.sessions[0]?.estimatedCostUsd).toBeCloseTo(TRANSCRIPT_COST_USD, 8);
|
|
});
|
|
|
|
test("prefers persisted estimated session cost from the store", async () => {
|
|
await withAnthropicTranscriptFixture({
|
|
prefix: "openclaw-session-utils-store-cost-",
|
|
run: ({ storePath, now }) => {
|
|
const result = listMainSession({
|
|
cfg: baseCfg,
|
|
storePath,
|
|
entry: transcriptFallbackEntry(now, {
|
|
modelProvider: "anthropic",
|
|
model: ANTHROPIC_MODEL,
|
|
estimatedCostUsd: 0.1234,
|
|
}),
|
|
});
|
|
|
|
expect(result.sessions[0]?.estimatedCostUsd).toBe(0.1234);
|
|
expect(result.sessions[0]?.totalTokens).toBe(TRANSCRIPT_TOTAL_TOKENS);
|
|
},
|
|
});
|
|
});
|
|
|
|
test("keeps zero estimated session cost when configured model pricing resolves to free", () => {
|
|
const cfg = createOpenAiPricingConfig({
|
|
id: FREE_OPENAI_MODEL,
|
|
label: "GPT 5.3 Codex Spark",
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
});
|
|
const result = listConfiguredMainSession(cfg, freeOpenAiUsageEntry());
|
|
|
|
expect(result.sessions[0]?.estimatedCostUsd).toBe(0);
|
|
});
|
|
|
|
test("falls back to transcript usage for totalTokens and zero estimatedCostUsd", async () => {
|
|
await withFreeOpenAiTranscriptFixture({
|
|
prefix: "openclaw-session-utils-zero-cost-",
|
|
run: ({ storePath, now }) => {
|
|
const result = listMainSession({
|
|
cfg: baseCfg,
|
|
storePath,
|
|
entry: zeroUsageTranscriptEntry(now, {
|
|
modelProvider: "openai",
|
|
model: FREE_OPENAI_MODEL,
|
|
}),
|
|
});
|
|
|
|
expect(result.sessions[0]?.totalTokens).toBe(6_643);
|
|
expect(result.sessions[0]?.totalTokensFresh).toBe(true);
|
|
expect(result.sessions[0]?.estimatedCostUsd).toBe(0);
|
|
},
|
|
});
|
|
});
|
|
|
|
test("falls back to transcript usage for totalTokens and estimatedCostUsd, and derives contextTokens from the resolved model", async () => {
|
|
await withAnthropicTranscriptFixture({
|
|
prefix: "openclaw-session-utils-",
|
|
run: ({ storePath, now }) => {
|
|
const result = listMainSession({
|
|
cfg: createAnthropicContext1mConfig(),
|
|
storePath,
|
|
entry: zeroUsageTranscriptEntry(now, {
|
|
modelProvider: "anthropic",
|
|
model: ANTHROPIC_MODEL,
|
|
}),
|
|
});
|
|
|
|
expectAnthropicBackfill(result.sessions[0]);
|
|
},
|
|
});
|
|
});
|
|
|
|
test("chat history session metadata keeps model context and projects a catalog-pinned harness", async () => {
|
|
await withAnthropicTranscriptFixture({
|
|
prefix: "openclaw-session-info-context-",
|
|
run: ({ storePath, now }) => {
|
|
const entry: SessionEntry = {
|
|
sessionId: MAIN_SESSION_ID,
|
|
updatedAt: now,
|
|
modelProvider: "local-test",
|
|
model: "test-model",
|
|
agentHarnessId: "codex",
|
|
modelSelectionLocked: true,
|
|
pluginExtensions: {
|
|
codex: {
|
|
supervision: {
|
|
sourceThreadId: "019f-codex-thread",
|
|
modelLocked: true,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const row = buildGatewaySessionInfo({
|
|
cfg: {
|
|
models: {
|
|
providers: {
|
|
"local-test": {
|
|
models: [{ id: "test-model", contextTokens: 123_456 }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig,
|
|
storePath,
|
|
key: MAIN_SESSION_KEY,
|
|
entry,
|
|
store: { [MAIN_SESSION_KEY]: entry },
|
|
});
|
|
|
|
expect(row.totalTokens).toBeUndefined();
|
|
expect(row.totalTokensFresh).toBe(false);
|
|
expect(row.estimatedCostUsd).toBeUndefined();
|
|
expect(row.contextTokens).toBe(123_456);
|
|
expect(row.modelSelectionLocked).toBe(true);
|
|
expect(row.agentRuntime).toEqual({ id: "codex", source: "session" });
|
|
},
|
|
});
|
|
});
|
|
|
|
test("uses subagent run model immediately for child sessions while transcript usage fills live totals", async () => {
|
|
await withAnthropicTranscriptFixture({
|
|
prefix: "openclaw-session-utils-subagent-",
|
|
transcriptId: "sess-child",
|
|
run: ({ storePath, now }) => {
|
|
registerRunningSubagent({
|
|
runId: "run-child-live",
|
|
childSessionKey: "agent:main:subagent:child-live",
|
|
model: `anthropic/${ANTHROPIC_MODEL}`,
|
|
now,
|
|
});
|
|
|
|
const result = listSingleSession({
|
|
cfg: createAnthropicContext1mConfig(),
|
|
storePath,
|
|
key: "agent:main:subagent:child-live",
|
|
entry: childTranscriptEntry("sess-child", now),
|
|
});
|
|
|
|
expectSessionModel(result.sessions[0], {
|
|
key: "agent:main:subagent:child-live",
|
|
provider: "anthropic",
|
|
model: ANTHROPIC_MODEL,
|
|
});
|
|
expect(result.sessions[0]?.status).toBe("running");
|
|
expectAnthropicBackfill(result.sessions[0]);
|
|
},
|
|
});
|
|
});
|
|
|
|
test("keeps a running subagent model when transcript fallback still reflects an older run", async () => {
|
|
await withAnthropicTranscriptFixture({
|
|
prefix: "openclaw-session-utils-subagent-stale-model-",
|
|
transcriptId: "sess-child-stale",
|
|
run: ({ storePath, now }) => {
|
|
registerRunningSubagent({
|
|
runId: "run-child-live-new-model",
|
|
childSessionKey: "agent:main:subagent:child-live-stale-transcript",
|
|
model: "openai/gpt-5.4",
|
|
now,
|
|
});
|
|
|
|
const result = listSingleSession({
|
|
cfg: createAnthropicContext1mConfig(),
|
|
storePath,
|
|
key: "agent:main:subagent:child-live-stale-transcript",
|
|
entry: childTranscriptEntry("sess-child-stale", now),
|
|
});
|
|
|
|
expectSessionModel(result.sessions[0], {
|
|
key: "agent:main:subagent:child-live-stale-transcript",
|
|
provider: "openai",
|
|
model: "gpt-5.4",
|
|
});
|
|
expect(result.sessions[0]?.status).toBe("running");
|
|
expectTranscriptBackfill(result.sessions[0]);
|
|
},
|
|
});
|
|
});
|
|
|
|
test("keeps the selected override model when runtime identity was intentionally cleared", async () => {
|
|
await withAnthropicTranscriptFixture({
|
|
prefix: "openclaw-session-utils-cleared-runtime-model-",
|
|
transcriptId: "sess-override",
|
|
run: ({ storePath, now }) => {
|
|
const result = listMainSession({
|
|
cfg: createAnthropicContext1mConfig(),
|
|
storePath,
|
|
entry: transcriptFallbackEntry(now, {
|
|
sessionId: "sess-override",
|
|
providerOverride: "openai",
|
|
modelOverride: "gpt-5.4",
|
|
}),
|
|
});
|
|
|
|
expectOpenAiGpt54Backfill(result.sessions[0]);
|
|
},
|
|
});
|
|
});
|
|
|
|
test("does not replace the current runtime model when transcript fallback is only for missing pricing", async () => {
|
|
await withAnthropicTranscriptFixture({
|
|
prefix: "openclaw-session-utils-pricing-",
|
|
transcriptId: "sess-pricing",
|
|
run: ({ storePath, now }) => {
|
|
const result = listMainSession({
|
|
cfg: {
|
|
session: { mainKey: "main" },
|
|
agents: {
|
|
list: [{ id: "main", default: true }],
|
|
},
|
|
} as unknown as OpenClawConfig,
|
|
storePath,
|
|
entry: anthropicUsageEntry(now, {
|
|
sessionId: "sess-pricing",
|
|
modelProvider: "openai",
|
|
model: "gpt-5.4",
|
|
contextTokens: 200_000,
|
|
totalTokens: TRANSCRIPT_TOTAL_TOKENS,
|
|
totalTokensFresh: true,
|
|
}),
|
|
});
|
|
|
|
expectOpenAiGpt54Backfill(result.sessions[0]);
|
|
expect(result.sessions[0]?.contextTokens).toBe(200_000);
|
|
},
|
|
});
|
|
});
|
|
});
|