oc-e35: restore explicit multi-agent UI ownership (#122889)

This commit is contained in:
Josh Lehman
2026-08-12 20:17:13 -07:00
committed by GitHub
parent aba94bbe0b
commit edb941a508
15 changed files with 198 additions and 30 deletions
@@ -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<string, unknown>) {
};
}
function emitIngressModelUsageDiagnostic(
result: Parameters<typeof emitIngressModelUsageDiagnosticBase>[0],
opts: Parameters<typeof emitIngressModelUsageDiagnosticBase>[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);
+8 -5
View File
@@ -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;
@@ -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 })
+1
View File
@@ -146,6 +146,7 @@ export async function finalizeEmbeddedAgentCommand(params: {
const { updateSessionStoreAfterAgentRun } = await loadSessionStoreRuntime();
await updateSessionStoreAfterAgentRun({
cfg,
agentDir,
contextTokensOverride: agentCfg?.contextTokens,
sessionId: effectiveSessionId,
sessionKey,
+74 -2
View File
@@ -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<typeof updateSessionStoreAfterAgentRunBase>[0];
async function updateSessionStoreAfterAgentRun(
params: Omit<SessionStoreUpdateParams, "agentDir"> & { 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<string, SessionEntry> = {};
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";
+2
View File
@@ -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,
}),
}),
);
@@ -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);
@@ -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 } : {}),
+4 -1
View File
@@ -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));
+6 -1
View File
@@ -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) {
+14 -13
View File
@@ -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:
+2 -2
View File
@@ -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) {
@@ -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();
});
+4 -1
View File
@@ -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;
}
+17
View File
@@ -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) => {