diff --git a/src/agents/agent-command.ingress-diagnostics.test.ts b/src/agents/agent-command.ingress-diagnostics.test.ts index 134de3713424..78d6e318fb48 100644 --- a/src/agents/agent-command.ingress-diagnostics.test.ts +++ b/src/agents/agent-command.ingress-diagnostics.test.ts @@ -9,7 +9,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { emitIngressModelUsageDiagnostic } from "./command/ingress-diagnostics.js"; +import { emitIngressModelUsageDiagnostic as emitIngressModelUsageDiagnosticBase } from "./command/ingress-diagnostics.js"; const mocks = vi.hoisted(() => ({ emitTrustedDiagnosticEvent: vi.fn(), @@ -97,12 +97,20 @@ function makeOpts(overrides?: Record) { }; } +function emitIngressModelUsageDiagnostic( + result: Parameters[0], + opts: Parameters[1], + agentDir = "/state/agents/main/agent", +) { + emitIngressModelUsageDiagnosticBase(result, opts, agentDir); +} + describe("emitIngressModelUsageDiagnostic", () => { it("emits model.usage when diagnostics are enabled and result has usage", () => { const result = makeResult(); const opts = makeOpts(); - emitIngressModelUsageDiagnostic(result, opts); + emitIngressModelUsageDiagnostic(result, opts, "/state/agents/main/agent"); expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1); const event = mocks.emitTrustedDiagnosticEvent.mock.calls[0]?.[0]; @@ -139,7 +147,14 @@ describe("emitIngressModelUsageDiagnostic", () => { }, }); - emitIngressModelUsageDiagnostic(result, makeOpts()); + emitIngressModelUsageDiagnostic(result, makeOpts(), "/state/agents/marie/agent"); + + expect(mocks.resolveModelCostConfig).toHaveBeenCalledWith({ + provider: "openai", + model: "gpt-5.5", + config: {}, + agentDir: "/state/agents/marie/agent", + }); expect(mocks.estimateUsageCost).toHaveBeenCalledWith({ usage: { @@ -247,6 +262,7 @@ describe("emitIngressModelUsageDiagnostic", () => { provider: "openai", model: "gpt-5.5", config: expect.any(Object) as unknown, + agentDir: "/state/agents/main/agent", }); expect(mocks.estimateUsageCost).toHaveBeenCalled(); expect(mocks.emitTrustedDiagnosticEvent).toHaveBeenCalledTimes(1); diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 6f59cbc2ccea..27c2ffab9d6f 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -629,6 +629,7 @@ async function agentCommandFromIngressInternal( const lifecycleGeneration = opts.lifecycleGeneration ?? captureAgentRunLifecycleGeneration(opts.runId ?? ""); return await withAgentRunLifecycleGeneration(lifecycleGeneration, async () => { + let preparedAgentDir: string | undefined; const result = await runWithAgentCommandRecoveryOwner({ lifecycleGeneration, mode: "claim", @@ -639,8 +640,9 @@ async function agentCommandFromIngressInternal( }, prepare: async (preparedOpts) => await prepareAgentCommandExecution(preparedOpts, runtime), restoreAdmittedRecovery: recovery?.restoreAdmittedRecovery, - run: async (prepared) => - await withAgentPluginRegistry({ + run: async (prepared) => { + preparedAgentDir = prepared.agentDir; + return await withAgentPluginRegistry({ config: prepared.cfg, workspaceDir: prepared.workspaceDir, run: async () => @@ -651,11 +653,12 @@ async function agentCommandFromIngressInternal( runtime, deps, ), - }), + }); + }, }); - if (result) { - emitIngressModelUsageDiagnostic(result, opts); + if (result && preparedAgentDir) { + emitIngressModelUsageDiagnostic(result, opts, preparedAgentDir); } return result; diff --git a/src/agents/command/ingress-diagnostics.ts b/src/agents/command/ingress-diagnostics.ts index abc51f376031..73b8c5a8d037 100644 --- a/src/agents/command/ingress-diagnostics.ts +++ b/src/agents/command/ingress-diagnostics.ts @@ -37,6 +37,7 @@ function ingressDiagnosticChannel(opts: AgentCommandIngressOpts): string { export function emitIngressModelUsageDiagnostic( result: AgentCommandResult, opts: AgentCommandIngressOpts, + agentDir: string, ): void { const cfg = getRuntimeConfig(); if (!isDiagnosticsEnabled(cfg)) { @@ -65,6 +66,7 @@ export function emitIngressModelUsageDiagnostic( provider: providerUsed, model: modelUsed, config: cfg, + agentDir, }); const costUsd = hasBillableUsageBuckets ? estimateUsageCost({ usage, cost: costConfig }) diff --git a/src/agents/command/post-run.ts b/src/agents/command/post-run.ts index 2b1e8ff0e6b6..1141605518a0 100644 --- a/src/agents/command/post-run.ts +++ b/src/agents/command/post-run.ts @@ -146,6 +146,7 @@ export async function finalizeEmbeddedAgentCommand(params: { const { updateSessionStoreAfterAgentRun } = await loadSessionStoreRuntime(); await updateSessionStoreAfterAgentRun({ cfg, + agentDir, contextTokensOverride: agentCfg?.contextTokens, sessionId: effectiveSessionId, sessionKey, diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 61c9bd64819d..fbb341557fde 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -19,7 +19,7 @@ import { persistCliSessionForkSuccessorInStore, restoreCliSessionForkInStore, recordCliCompactionInStore, - updateSessionStoreAfterAgentRun, + updateSessionStoreAfterAgentRun as updateSessionStoreAfterAgentRunBase, } from "./session-store.js"; import { resolveSession } from "./session.js"; @@ -60,7 +60,16 @@ vi.mock("../../utils/usage-format.js", () => ({ } return total / 1e6; }, - resolveModelCostConfig: (params: { provider?: string; model?: string; config?: unknown }) => { + resolveModelCostConfig: (params: { + provider?: string; + model?: string; + config?: unknown; + agentDir?: string; + }) => { + const agents = (params.config as OpenClawConfig | undefined)?.agents?.list ?? []; + if (agents.length > 1 && !params.agentDir) { + throw new Error("multi-agent cost resolution requires an explicit agent directory"); + } const providers = (params.config as MockUsageFormatConfig | undefined)?.models?.providers; if (!providers) { return undefined; @@ -126,7 +135,70 @@ afterEach(() => { closeOpenClawAgentDatabasesForTest(); }); +type SessionStoreUpdateParams = Parameters[0]; + +async function updateSessionStoreAfterAgentRun( + params: Omit & { agentDir?: string }, +) { + await updateSessionStoreAfterAgentRunBase({ + ...params, + agentDir: params.agentDir ?? "/tmp/openclaw-session-store-test-agent", + }); +} + describe("updateSessionStoreAfterAgentRun", () => { + it("uses the prepared agent directory for multi-agent cost accounting", async () => { + await withTempSessionStore(async ({ dir, storePath }) => { + const sessionKey = "agent:marie:dashboard:cost-accounting"; + const sessionId = "cost-accounting-session"; + const sessionStore: Record = {}; + + await updateSessionStoreAfterAgentRun({ + cfg: { + agents: { list: [{ id: "main" }, { id: "marie" }] }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + input: ["text"], + cost: { input: 2, output: 4, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + ], + }, + }, + }, + } satisfies OpenClawConfig, + agentDir: path.join(dir, "agents", "marie", "agent"), + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "openai", + defaultModel: "gpt-5.5", + result: { + meta: { + durationMs: 1, + agentMeta: { + sessionId, + provider: "openai", + model: "gpt-5.5", + usage: { input: 1_000_000, output: 1_000_000 }, + }, + }, + }, + }); + + expect(sessionStore[sessionKey]?.estimatedCostUsd).toBe(6); + }); + }); + it("clears the durable replay-safe recovery guard after the recovery run terminates", async () => { await withTempSessionStore(async ({ storePath }) => { const sessionKey = "agent:main:explicit:restart-recovery"; diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 121eb2fe1b66..9f27a266d1ad 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -47,6 +47,7 @@ function resolvePositiveInteger(value: number | undefined): number | undefined { /** Applies run result metadata, usage, and CLI bindings to a session entry. */ export async function updateSessionStoreAfterAgentRun(params: { cfg: OpenClawConfig; + agentDir: string; contextTokensOverride?: number; sessionId: string; sessionKey: string; @@ -218,6 +219,7 @@ export async function updateSessionStoreAfterAgentRun(params: { provider: providerUsed, model: modelUsed, config: cfg, + agentDir: params.agentDir, }), }), ); diff --git a/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts b/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts index 6f1d7e034c72..aeac206de0e1 100644 --- a/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/embedded-agent-runner.run-embedded-agent.auth-profile-rotation.e2e.test.ts @@ -659,6 +659,42 @@ async function runTurnWithCooldownSeed(params: { } describe("runEmbeddedAgent auth profile rotation", () => { + it("runs an agent-scoped session without an ambient default owner", async () => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + runEmbeddedAttemptMock.mockResolvedValueOnce({ + ...makeAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildAssistant({ + provider: "openai", + model: "mock-1", + stopReason: "stop", + content: [{ type: "text", text: "ok" }], + }), + }), + }); + + await runEmbeddedAgentInline({ + sessionId: "session:work", + sessionKey: "agent:work:dashboard:scoped-run", + workspaceDir, + agentDir, + config: { + ...makeConfig(), + agents: { entries: { main: {}, work: {} } }, + }, + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileId: "openai:p1", + authProfileIdSource: "auto", + timeoutMs: 5_000, + runId: "run:work", + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + }); + }); + it("does not persist auth profile bookkeeping for read-only probes", async () => { await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { await writeAuthStore(agentDir); diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index 98519a1e6ddb..79ed3811fb30 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -26,9 +26,9 @@ import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js" import { resolveAgentDir, resolveAgentWorkspaceDir, - resolveDefaultAgentDir, resolveRunModelFallbacksOverride, } from "../agent-scope.js"; +import { resolveLegacyInheritedAuthDir } from "../legacy-inherited-auth-dir.js"; import { resolveModelCandidateChain } from "../model-fallback-candidates.js"; import { acquireAgentRunPreparedModelRuntime, @@ -250,7 +250,9 @@ async function runEmbeddedAgentInternal( config, agentId: requestedWorkspaceResolution.agentId, agentDir: requestedAgentDir, - inheritedAuthDir: resolveDefaultAgentDir(config), + // Shared credential inheritance stays anchored to its compatibility owner; + // the selected session agent already owns this prepared runtime. + inheritedAuthDir: resolveLegacyInheritedAuthDir(config), workspaceDir: requestedWorkspaceResolution.workspaceDir, preserveWorkspaceDirOnRefresh: !requestedWorkspaceResolution.isCanonicalWorkspace, ...(params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}), diff --git a/ui/src/components/sidebar-attention.test.ts b/ui/src/components/sidebar-attention.test.ts index c8e151f7e9da..691cb0395e6f 100644 --- a/ui/src/components/sidebar-attention.test.ts +++ b/ui/src/components/sidebar-attention.test.ts @@ -179,7 +179,7 @@ describe("sidebar attention refresh ownership", () => { "cron.list": [firstCron, secondCron], "models.authStatus": [firstAuth, secondAuth], }; - const request = vi.fn((method: keyof typeof responses) => { + const request = vi.fn((method: keyof typeof responses, _params?: unknown) => { const response = responses[method].shift(); if (!response) { throw new Error(`Unexpected request: ${method}`); @@ -226,6 +226,9 @@ describe("sidebar attention refresh ownership", () => { provider.append(element); document.body.append(provider); await waitForFast(() => expect(request).toHaveBeenCalledTimes(2)); + expect(request.mock.calls.find(([method]) => method === "models.authStatus")?.[1]).toEqual({ + agentId: "main", + }); document.dispatchEvent(new Event("visibilitychange")); await waitForFast(() => expect(request).toHaveBeenCalledTimes(4)); diff --git a/ui/src/components/sidebar-attention.ts b/ui/src/components/sidebar-attention.ts index 8f849cf64cf9..2a91545adc8e 100644 --- a/ui/src/components/sidebar-attention.ts +++ b/ui/src/components/sidebar-attention.ts @@ -77,7 +77,12 @@ class SidebarAttention extends OpenClawLightDomContentsElement { ]; if (refreshModelAuth) { loads.push( - loadModelAuthStatus(client, { signal }) + loadModelAuthStatus(client, { + signal, + ...(gateway.snapshot.assistantAgentId + ? { agentId: gateway.snapshot.assistantAgentId } + : {}), + }) .catch(() => null) .then((modelAuthStatus) => { if (!signal.aborted) { diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 687d88fdb397..62cdd4f4cea6 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -55,6 +55,7 @@ import { } from "./chat-session-companion.ts"; import { ChatStateController } from "./chat-state-controller.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; +import { resolveChatAgentId } from "./chat-state-route.ts"; import type { ChatPaneHeaderAction } from "./components/chat-pane-header.ts"; import type { SessionRailCommand, SessionRailMode } from "./components/chat-session-rail.ts"; import type { ChatSessionSharingState } from "./components/chat-session-sharing.ts"; @@ -254,26 +255,28 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { return; } const sessionKey = state.sessionKey; + const agentId = resolveChatAgentId(state); this.requestSessionRail("open"); if (!state.connected || !state.client) { - this.sessionCompanionThreads.setDraft(sessionKey, question, state.assistantAgentId); + this.sessionCompanionThreads.setDraft(sessionKey, question, agentId); return; } const client = state.client; await this.sessionCompanionThreads.submit( sessionKey, question, - (key, value) => requestSessionCompanionAnswer(client, key, value, state.assistantAgentId), - state.assistantAgentId, + (key, value) => requestSessionCompanionAnswer(client, key, value, agentId), + agentId, ); }; protected readonly prefillSessionCompanionQuestion = (question: string) => { - const sessionKey = this.state?.sessionKey; + const state = this.state; + const sessionKey = state?.sessionKey; if (!sessionKey) { return; } - this.sessionCompanionThreads.setDraft(sessionKey, question, this.state?.assistantAgentId); + this.sessionCompanionThreads.setDraft(sessionKey, question, resolveChatAgentId(state)); this.requestSessionRail("open"); }; @@ -282,7 +285,8 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { if (!state?.connected || !state.client || !sessionKey || parseCatalogSessionKey(sessionKey)) { return; } - const hydrationKey = `${this.connectionGeneration}\0${state.assistantAgentId ?? ""}\0${sessionKey}`; + const agentId = resolveChatAgentId(state); + const hydrationKey = `${this.connectionGeneration}\0${agentId}\0${sessionKey}`; if (this.sessionCompanionHydrationKey === hydrationKey) { return; } @@ -290,8 +294,8 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { this.ensureSessionRail(); void this.sessionCompanionThreads.hydrate( sessionKey, - (key) => requestSessionCompanionState(state.client!, key, state.assistantAgentId), - state.assistantAgentId, + (key) => requestSessionCompanionState(state.client!, key, agentId), + agentId, ); } @@ -300,12 +304,9 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { if (!state?.connected || !state.client || !state.sessionKey) { return; } + const agentId = resolveChatAgentId(state); await this.sessionCompanionThreads - .reset( - state.sessionKey, - (key) => resetSessionCompanion(state.client!, key, state.assistantAgentId), - state.assistantAgentId, - ) + .reset(state.sessionKey, (key) => resetSessionCompanion(state.client!, key, agentId), agentId) .catch(() => undefined); }; protected resetConfirmation: diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 85b34698e6e6..8e87625bb650 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -302,13 +302,13 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { observerLastReadAt: selectedSession?.lastReadAt, sessionRailCompanion: catalogKey ? undefined - : this.sessionCompanionThreads.view(state.sessionKey, state.assistantAgentId), + : this.sessionCompanionThreads.view(state.sessionKey, currentAgentId), ...this.sessionRailCommandProps(state.sessionKey), sessionRailMode: this.selectedSessionRailMode(state.sessionKey), sessionRailDocked: !catalogKey && chatMainWidth >= SESSION_RAIL_SIDE_MIN_PANE_WIDTH, onSessionRailSubmit: (question) => void this.submitSessionCompanionQuestion(question), onSessionRailDraftChange: (draft) => - this.sessionCompanionThreads.setDraft(state.sessionKey, draft, state.assistantAgentId), + this.sessionCompanionThreads.setDraft(state.sessionKey, draft, currentAgentId), onSessionRailClear: () => void this.clearSessionCompanion(), onSessionRailModeChange: (mode) => { if (state.sessionKey !== this.sessionRailModeSessionKey || mode !== this.sessionRailMode) { diff --git a/ui/src/pages/chat/chat-pane-session-hydration.test.ts b/ui/src/pages/chat/chat-pane-session-hydration.test.ts index 9ed104b10240..a31f9e74079e 100644 --- a/ui/src/pages/chat/chat-pane-session-hydration.test.ts +++ b/ui/src/pages/chat/chat-pane-session-hydration.test.ts @@ -27,6 +27,8 @@ describe("chat pane session hydration", () => { } as unknown as SessionCapability; const client = { request } as unknown as GatewayBrowserClient; const { pane, state } = createTestChatPane({ client, sessions }); + state.assistantAgentId = "main"; + state.sessionKey = "agent:work:current"; pane.context.gateway.snapshot.hello = { features: { methods: [SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD, "session.discussion.info"], @@ -65,6 +67,9 @@ describe("chat pane session hydration", () => { "sessions.companion.state", SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD, ]); + expect( + request.mock.calls.find(([method]) => method === "sessions.companion.state")?.[1], + ).toEqual({ sessionKey: state.sessionKey, agentId: "work" }); expect(complete).toHaveBeenCalledOnce(); }); diff --git a/ui/src/pages/chat/chat-state-refresh.ts b/ui/src/pages/chat/chat-state-refresh.ts index 780d8370fb7d..5fe21df1a314 100644 --- a/ui/src/pages/chat/chat-state-refresh.ts +++ b/ui/src/pages/chat/chat-state-refresh.ts @@ -280,7 +280,10 @@ export async function refreshChatModelAuthStatus(host: ChatPageHost, opts?: { re const client = host.client; const connectionEpoch = host.connectionEpoch; try { - const result = await loadModelAuthStatus(client, opts); + const result = await loadModelAuthStatus(client, { + ...opts, + agentId: resolveChatAgentId(host), + }); if (host.client !== client || !host.connected || host.connectionEpoch !== connectionEpoch) { return; } diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index e24698d73b0b..caca45505c6f 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -1754,6 +1754,23 @@ describe("refreshChatMetadata", () => { }); describe("refreshChatModelAuthStatus", () => { + it("scopes auth status to the selected session agent", async () => { + const request = vi.fn(async () => ({ ts: 1, providers: [] })); + const state = { + client: { request }, + connected: true, + connectionEpoch: 1, + sessionKey: "agent:work:dashboard:current", + assistantAgentId: "main", + modelAuthStatusResult: null, + modelAuthStatusError: null, + } as unknown as ChatPageHost; + + await refreshChatModelAuthStatus(state); + + expect(request).toHaveBeenCalledWith("models.authStatus", { agentId: "work" }); + }); + it.each(["success", "failure"] as const)( "ignores a stale auth status %s after reconnecting the same client", async (outcome) => {