From 6bc1f779df9fbc7527c30ee09ff9debd0bd1edd3 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Thu, 12 Mar 2026 17:26:17 -0700 Subject: [PATCH] Fix dashboard session create and model metadata --- .../subagent-spawn.model-session.test.ts | 169 ++++++++++++++++++ src/agents/subagent-spawn.ts | 57 ++++++ .../server-methods/agent.create-event.test.ts | 69 +++++++ src/gateway/server-methods/agent.ts | 47 +++++ src/utils/usage-format.test.ts | 56 ++++++ src/utils/usage-format.ts | 44 ++++- 6 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 src/agents/subagent-spawn.model-session.test.ts create mode 100644 src/gateway/server-methods/agent.create-event.test.ts diff --git a/src/agents/subagent-spawn.model-session.test.ts b/src/agents/subagent-spawn.model-session.test.ts new file mode 100644 index 00000000000..bb0ec7040c7 --- /dev/null +++ b/src/agents/subagent-spawn.model-session.test.ts @@ -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(); + 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(); + return { + ...actual, + updateSessionStore: (...args: unknown[]) => updateSessionStoreMock(...args), + }; +}); + +vi.mock("../gateway/session-utils.js", async (importOriginal) => { + const actual = await importOriginal(); + 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(); + return { + ...actual, + countActiveRunsForSession: () => 0, + registerSubagentRun: () => {}, + }; +}); + +vi.mock("./subagent-announce.js", async (importOriginal) => { + const actual = await importOriginal(); + 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>) => unknown, + ) => { + const store: Record> = {}; + 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> | undefined; + updateSessionStoreMock.mockImplementation( + async ( + _storePath: string, + mutator: (store: Record>) => unknown, + ) => { + operations.push("store:update"); + const store: Record> = {}; + 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")); + }); +}); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 33b8ade03d0..6c36b9f32d7 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -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; + childSessionKey: string; + resolvedModel?: string; +}): Promise { + 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) { diff --git a/src/gateway/server-methods/agent.create-event.test.ts b/src/gateway/server-methods/agent.create-event.test.ts new file mode 100644 index 00000000000..e62ac2d5843 --- /dev/null +++ b/src/gateway/server-methods/agent.create-event.test.ts @@ -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 }, + ); + }); +}); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index ee08425b7fd..ec828a41c6b 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -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[0]; runId: string; @@ -312,6 +350,7 @@ export const agentHandlers: GatewayRequestHandlers = { let bestEffortDeliver = requestedBestEffortDeliver ?? false; let cfgForAgent: ReturnType | 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({ diff --git a/src/utils/usage-format.test.ts b/src/utils/usage-format.test.ts index 128e048001e..90b8d182a42 100644 --- a/src/utils/usage-format.test.ts +++ b/src/utils/usage-format.test.ts @@ -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, + }); + }); }); diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts index 1086163bf20..2839a034f36 100644 --- a/src/utils/usage-format.ts +++ b/src/utils/usage-format.ts @@ -48,6 +48,45 @@ export function formatUsd(value?: number): string | undefined { return `$${value.toFixed(4)}`; } +const BUILTIN_MODEL_COSTS: Record = { + "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 =>