Fix dashboard session create and model metadata
This commit is contained in:
parent
fe074ec8e4
commit
6bc1f779df
169
src/agents/subagent-spawn.model-session.test.ts
Normal file
169
src/agents/subagent-spawn.model-session.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
69
src/gateway/server-methods/agent.create-event.test.ts
Normal file
69
src/gateway/server-methods/agent.create-event.test.ts
Normal 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user