Fix dashboard session create and model metadata

This commit is contained in:
Tyler Yust 2026-03-12 17:26:17 -07:00
parent fe074ec8e4
commit 6bc1f779df
6 changed files with 441 additions and 1 deletions

View File

@ -0,0 +1,169 @@
import os from "node:os";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
import { spawnSubagentDirect } from "./subagent-spawn.js";
const callGatewayMock = vi.fn();
const updateSessionStoreMock = vi.fn();
const pruneLegacyStoreKeysMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
defaults: {
workspace: os.tmpdir(),
},
},
}),
};
});
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
return {
...actual,
updateSessionStore: (...args: unknown[]) => updateSessionStoreMock(...args),
};
});
vi.mock("../gateway/session-utils.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../gateway/session-utils.js")>();
return {
...actual,
resolveGatewaySessionStoreTarget: (params: { key: string }) => ({
agentId: "main",
storePath: "/tmp/subagent-spawn-model-session.json",
canonicalKey: params.key,
storeKeys: [params.key],
}),
pruneLegacyStoreKeys: (...args: unknown[]) => pruneLegacyStoreKeysMock(...args),
};
});
vi.mock("./subagent-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./subagent-registry.js")>();
return {
...actual,
countActiveRunsForSession: () => 0,
registerSubagentRun: () => {},
};
});
vi.mock("./subagent-announce.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./subagent-announce.js")>();
return {
...actual,
buildSubagentSystemPrompt: () => "system-prompt",
};
});
vi.mock("./subagent-depth.js", () => ({
getSubagentDepthFromSessionStore: () => 0,
}));
vi.mock("../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => ({ hasHooks: () => false }),
}));
describe("spawnSubagentDirect runtime model persistence", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
updateSessionStoreMock.mockReset();
pruneLegacyStoreKeysMock.mockReset();
callGatewayMock.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "sessions.patch") {
return { ok: true };
}
if (opts.method === "sessions.delete") {
return { ok: true };
}
if (opts.method === "agent") {
return { runId: "run-1", status: "accepted", acceptedAt: 1000 };
}
return {};
});
updateSessionStoreMock.mockImplementation(
async (
_storePath: string,
mutator: (store: Record<string, Record<string, unknown>>) => unknown,
) => {
const store: Record<string, Record<string, unknown>> = {};
await mutator(store);
return store;
},
);
});
it("persists runtime model fields on the child session before starting the run", async () => {
const operations: string[] = [];
callGatewayMock.mockImplementation(async (opts: { method?: string }) => {
operations.push(`gateway:${opts.method ?? "unknown"}`);
if (opts.method === "sessions.patch") {
return { ok: true };
}
if (opts.method === "agent") {
return { runId: "run-1", status: "accepted", acceptedAt: 1000 };
}
if (opts.method === "sessions.delete") {
return { ok: true };
}
return {};
});
let persistedStore: Record<string, Record<string, unknown>> | undefined;
updateSessionStoreMock.mockImplementation(
async (
_storePath: string,
mutator: (store: Record<string, Record<string, unknown>>) => unknown,
) => {
operations.push("store:update");
const store: Record<string, Record<string, unknown>> = {};
await mutator(store);
persistedStore = store;
return store;
},
);
const result = await spawnSubagentDirect(
{
task: "test",
model: "openai-codex/gpt-5.4",
},
{
agentSessionKey: "agent:main:main",
agentChannel: "discord",
},
);
expect(result).toMatchObject({
status: "accepted",
modelApplied: true,
});
expect(updateSessionStoreMock).toHaveBeenCalledTimes(1);
const [persistedKey, persistedEntry] = Object.entries(persistedStore ?? {})[0] ?? [];
expect(persistedKey).toMatch(/^agent:main:subagent:/);
expect(persistedEntry).toMatchObject({
modelProvider: "openai-codex",
model: "gpt-5.4",
});
expect(pruneLegacyStoreKeysMock).toHaveBeenCalledTimes(1);
expect(operations.indexOf("gateway:sessions.patch")).toBeGreaterThan(-1);
expect(operations.indexOf("store:update")).toBeGreaterThan(
operations.indexOf("gateway:sessions.patch"),
);
expect(operations.indexOf("gateway:agent")).toBeGreaterThan(operations.indexOf("store:update"));
});
});

View File

@ -3,7 +3,12 @@ import { promises as fs } from "node:fs";
import { formatThinkingLevels, normalizeThinkLevel } from "../auto-reply/thinking.js";
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
import { loadConfig } from "../config/config.js";
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
import {
pruneLegacyStoreKeys,
resolveGatewaySessionStoreTarget,
} from "../gateway/session-utils.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import {
isValidAgentId,
@ -115,6 +120,37 @@ export function splitModelRef(ref?: string) {
return { provider: undefined, model: trimmed };
}
async function persistInitialChildSessionRuntimeModel(params: {
cfg: ReturnType<typeof loadConfig>;
childSessionKey: string;
resolvedModel?: string;
}): Promise<string | undefined> {
const { provider, model } = splitModelRef(params.resolvedModel);
if (!model) {
return undefined;
}
try {
const target = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: params.childSessionKey,
});
await updateSessionStore(target.storePath, (store) => {
pruneLegacyStoreKeys({
store,
canonicalKey: target.canonicalKey,
candidates: target.storeKeys,
});
store[target.canonicalKey] = mergeSessionEntry(store[target.canonicalKey], {
model,
...(provider ? { modelProvider: provider } : {}),
});
});
return undefined;
} catch (err) {
return err instanceof Error ? err.message : typeof err === "string" ? err : "error";
}
}
function sanitizeMountPathHint(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
@ -459,6 +495,27 @@ export async function spawnSubagentDirect(
};
}
if (resolvedModel) {
const runtimeModelPersistError = await persistInitialChildSessionRuntimeModel({
cfg,
childSessionKey,
resolvedModel,
});
if (runtimeModelPersistError) {
try {
await callGateway({
method: "sessions.delete",
params: { key: childSessionKey, emitLifecycleHooks: false },
timeoutMs: 10_000,
});
} catch {
// Best-effort cleanup only.
}
return {
status: "error",
error: runtimeModelPersistError,
childSessionKey,
};
}
modelApplied = true;
}
if (requestThreadBinding) {

View File

@ -0,0 +1,69 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { testState, writeSessionStore } from "../test-helpers.js";
import { agentHandlers } from "./agent.js";
describe("agent handler session create events", () => {
let tempDir: string;
let storePath: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-create-event-"));
storePath = path.join(tempDir, "sessions.json");
testState.sessionStorePath = storePath;
await writeSessionStore({ entries: {} });
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("emits sessions.changed with reason create for new agent sessions", async () => {
const broadcastToConnIds = vi.fn();
const respond = vi.fn();
await agentHandlers.agent({
params: {
message: "hi",
sessionKey: "agent:main:subagent:create-test",
idempotencyKey: "idem-agent-create-event",
},
respond,
context: {
dedupe: new Map(),
deps: {} as never,
logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as never,
chatAbortControllers: new Map(),
addChatRun: vi.fn(),
registerToolEventRecipient: vi.fn(),
getSessionEventSubscriberConnIds: () => new Set(["conn-1"]),
broadcastToConnIds,
} as never,
client: null,
isWebchatConnect: () => false,
req: { id: "req-agent-create-event" } as never,
});
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({
status: "accepted",
runId: "idem-agent-create-event",
}),
undefined,
{ runId: "idem-agent-create-event" },
);
expect(broadcastToConnIds).toHaveBeenCalledWith(
"sessions.changed",
expect.objectContaining({
sessionKey: "agent:main:subagent:create-test",
reason: "create",
}),
new Set(["conn-1"]),
{ dropIfSlow: true },
);
});
});

View File

@ -49,6 +49,7 @@ import {
import { performGatewaySessionReset } from "../session-reset-service.js";
import {
canonicalizeSpawnedByForAgent,
loadGatewaySessionRow,
loadSessionEntry,
pruneLegacyStoreKeys,
resolveGatewaySessionStoreTarget,
@ -94,6 +95,43 @@ async function runSessionResetFromAgent(params: {
};
}
function emitSessionsChanged(
context: Pick<
GatewayRequestHandlerOptions["context"],
"broadcastToConnIds" | "getSessionEventSubscriberConnIds"
>,
payload: { sessionKey?: string; reason: string },
) {
const connIds = context.getSessionEventSubscriberConnIds();
if (connIds.size === 0) {
return;
}
const sessionRow = payload.sessionKey ? loadGatewaySessionRow(payload.sessionKey) : null;
context.broadcastToConnIds(
"sessions.changed",
{
...payload,
ts: Date.now(),
...(sessionRow
? {
totalTokens: sessionRow.totalTokens,
totalTokensFresh: sessionRow.totalTokensFresh,
contextTokens: sessionRow.contextTokens,
estimatedCostUsd: sessionRow.estimatedCostUsd,
modelProvider: sessionRow.modelProvider,
model: sessionRow.model,
status: sessionRow.status,
startedAt: sessionRow.startedAt,
endedAt: sessionRow.endedAt,
runtimeMs: sessionRow.runtimeMs,
}
: {}),
},
connIds,
{ dropIfSlow: true },
);
}
function dispatchAgentRunFromGateway(params: {
ingressOpts: Parameters<typeof agentCommandFromIngress>[0];
runId: string;
@ -312,6 +350,7 @@ export const agentHandlers: GatewayRequestHandlers = {
let bestEffortDeliver = requestedBestEffortDeliver ?? false;
let cfgForAgent: ReturnType<typeof loadConfig> | undefined;
let resolvedSessionKey = requestedSessionKey;
let isNewSession = false;
let skipTimestampInjection = false;
const resetCommandMatch = message.match(RESET_COMMAND_RE);
@ -351,6 +390,7 @@ export const agentHandlers: GatewayRequestHandlers = {
if (requestedSessionKey) {
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(requestedSessionKey);
cfgForAgent = cfg;
isNewSession = !entry;
const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID();
const labelValue = request.label?.trim() || entry?.label;
@ -584,6 +624,13 @@ export const agentHandlers: GatewayRequestHandlers = {
});
respond(true, accepted, undefined, { runId });
if (requestedSessionKey && resolvedSessionKey && isNewSession) {
emitSessionsChanged(context, {
sessionKey: resolvedSessionKey,
reason: "create",
});
}
const resolvedThreadId = explicitThreadId ?? deliveryPlan.resolvedThreadId;
dispatchAgentRunFromGateway({

View File

@ -59,4 +59,60 @@ describe("usage-format", () => {
expect(total).toBeCloseTo(0.003);
});
it("falls back to built in pricing for common models", () => {
expect(
resolveModelCostConfig({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
).toEqual({
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
});
expect(
resolveModelCostConfig({
provider: "openai-codex",
model: "gpt-5.4",
}),
).toEqual({
input: 2,
output: 8,
cacheRead: 0,
cacheWrite: 0,
});
});
it("prefers configured pricing over built in defaults", () => {
const config = {
models: {
providers: {
anthropic: {
models: [
{
id: "claude-sonnet-4-6",
cost: { input: 9, output: 19, cacheRead: 0.9, cacheWrite: 1.9 },
},
],
},
},
},
} as unknown as OpenClawConfig;
expect(
resolveModelCostConfig({
provider: "anthropic",
model: "claude-sonnet-4-6",
config,
}),
).toEqual({
input: 9,
output: 19,
cacheRead: 0.9,
cacheWrite: 1.9,
});
});
});

View File

@ -48,6 +48,45 @@ export function formatUsd(value?: number): string | undefined {
return `$${value.toFixed(4)}`;
}
const BUILTIN_MODEL_COSTS: Record<string, ModelCostConfig> = {
"anthropic/claude-opus-4-6": {
input: 15,
output: 75,
cacheRead: 1.5,
cacheWrite: 18.75,
},
"anthropic/claude-sonnet-4-6": {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
},
"anthropic/claude-haiku-4-5": {
input: 0.8,
output: 4,
cacheRead: 0.08,
cacheWrite: 1,
},
"openai-codex/gpt-5.4": {
input: 2,
output: 8,
cacheRead: 0,
cacheWrite: 0,
},
"openai-codex/gpt-5.3-codex": {
input: 1,
output: 4,
cacheRead: 0,
cacheWrite: 0,
},
"openai-codex/gpt-5.3-codex-spark": {
input: 0.5,
output: 2,
cacheRead: 0,
cacheWrite: 0,
},
};
export function resolveModelCostConfig(params: {
provider?: string;
model?: string;
@ -60,7 +99,10 @@ export function resolveModelCostConfig(params: {
}
const providers = params.config?.models?.providers ?? {};
const entry = providers[provider]?.models?.find((item) => item.id === model);
return entry?.cost;
if (entry?.cost) {
return entry.cost;
}
return BUILTIN_MODEL_COSTS[`${provider.toLowerCase()}/${model.toLowerCase()}`];
}
const toNumber = (value: number | undefined): number =>