mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
oc-e35: restore explicit multi-agent UI ownership (#122889)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -146,6 +146,7 @@ export async function finalizeEmbeddedAgentCommand(params: {
|
||||
const { updateSessionStoreAfterAgentRun } = await loadSessionStoreRuntime();
|
||||
await updateSessionStoreAfterAgentRun({
|
||||
cfg,
|
||||
agentDir,
|
||||
contextTokensOverride: agentCfg?.contextTokens,
|
||||
sessionId: effectiveSessionId,
|
||||
sessionKey,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user