mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 14:01:40 +00:00
fix(tui): require a fresh agent roster (#116715)
This commit is contained in:
committed by
GitHub
parent
623a015928
commit
5fc976571e
19
src/tui/tui-agent-list-refresh.ts
Normal file
19
src/tui/tui-agent-list-refresh.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { err, ok, type Result } from "@openclaw/normalization-core/result";
|
||||
import type { TuiAgentsList } from "./tui-backend.js";
|
||||
import { formatTuiErrorMessage } from "./tui-formatters.js";
|
||||
|
||||
/** Refresh an authoritative agent roster without discarding the last good snapshot on failure. */
|
||||
export async function refreshTuiAgentList(params: {
|
||||
load: () => Promise<TuiAgentsList>;
|
||||
apply: (result: TuiAgentsList) => void;
|
||||
reportError: (message: string) => void;
|
||||
}): Promise<Result<void, string>> {
|
||||
try {
|
||||
params.apply(await params.load());
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
const message = formatTuiErrorMessage(error);
|
||||
params.reportError(message);
|
||||
return err(message);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { OverlayHandle } from "@earendil-works/pi-tui";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import type { Result } from "@openclaw/normalization-core/result";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createSessionProjection,
|
||||
@@ -35,6 +36,7 @@ type SetActivityStatusMock = ReturnType<typeof vi.fn> & ((text: string) => void)
|
||||
type SetSessionMock = ReturnType<typeof vi.fn> & ((key: string) => Promise<void>);
|
||||
type ConsumeCompletedRunMock = ReturnType<typeof vi.fn> & ((runId: string) => boolean);
|
||||
type FlushPendingHistoryRefreshMock = ReturnType<typeof vi.fn> & (() => void);
|
||||
type RefreshAgentsMock = ReturnType<typeof vi.fn> & (() => Promise<Result<void, string>>);
|
||||
|
||||
function createOverlayHandle(): OverlayHandle {
|
||||
return {
|
||||
@@ -131,6 +133,9 @@ function createHarness(params?: {
|
||||
consumeCompletedRunForPendingSend?: ConsumeCompletedRunMock;
|
||||
isRunObserved?: (runId: string) => boolean;
|
||||
flushPendingHistoryRefreshIfIdle?: FlushPendingHistoryRefreshMock;
|
||||
refreshAgents?: RefreshAgentsMock;
|
||||
agentDefaultId?: string;
|
||||
agents?: Array<{ id: string; kind?: "agent" | "system"; name?: string }>;
|
||||
}) {
|
||||
const sendChat =
|
||||
params?.sendChat ??
|
||||
@@ -172,12 +177,17 @@ function createHarness(params?: {
|
||||
const requestExit = vi.fn();
|
||||
const abortActive =
|
||||
params?.abortActive ?? (vi.fn().mockResolvedValue(undefined) as AbortActiveMock);
|
||||
const refreshAgents =
|
||||
params?.refreshAgents ??
|
||||
(vi.fn().mockResolvedValue({ ok: true, value: undefined }) as RefreshAgentsMock);
|
||||
const runAuthFlow: RunAuthFlow | undefined =
|
||||
params?.runAuthFlow ??
|
||||
(params?.opts?.local
|
||||
? (vi.fn().mockResolvedValue({ exitCode: 0, signal: null }) as unknown as RunAuthFlow)
|
||||
: undefined);
|
||||
const state = {
|
||||
agentDefaultId: params?.agentDefaultId ?? "main",
|
||||
agents: params?.agents ?? [],
|
||||
currentAgentId: params?.currentAgentId ?? "main",
|
||||
currentSessionKey: params?.currentSessionKey ?? "agent:main:main",
|
||||
currentSessionId: params?.currentSessionId ?? null,
|
||||
@@ -219,7 +229,7 @@ function createHarness(params?: {
|
||||
refreshSessionInfo: refreshSessionInfo as never,
|
||||
loadHistory,
|
||||
setSession,
|
||||
refreshAgents: vi.fn(),
|
||||
refreshAgents,
|
||||
abortActive,
|
||||
setActivityStatus,
|
||||
formatSessionKey: vi.fn(),
|
||||
@@ -272,11 +282,55 @@ function createHarness(params?: {
|
||||
forgetLocalBtwRunId,
|
||||
requestExit,
|
||||
abortActive,
|
||||
refreshAgents,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
describe("tui command handlers", () => {
|
||||
it("does not open the agent picker from a cached roster after refresh failure", async () => {
|
||||
const refreshAgents = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, error: "gateway unavailable" }) as RefreshAgentsMock;
|
||||
const { handleCommand, openOverlay, requestRender } = createHarness({
|
||||
refreshAgents,
|
||||
agents: [{ id: "cached", name: "Cached Agent" }],
|
||||
});
|
||||
|
||||
await handleCommand("/agents");
|
||||
|
||||
expect(refreshAgents).toHaveBeenCalledTimes(1);
|
||||
expect(openOverlay).not.toHaveBeenCalled();
|
||||
expect(requestRender).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the agent picker only after a successful refresh", async () => {
|
||||
const refreshAgents = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, value: undefined }) as RefreshAgentsMock;
|
||||
const { handleCommand, openOverlay } = createHarness({
|
||||
refreshAgents,
|
||||
agentDefaultId: "team-lead",
|
||||
agents: [
|
||||
{ id: "team-lead", name: "Lead Agent" },
|
||||
{ id: "system-agent", kind: "system", name: "System Agent" },
|
||||
],
|
||||
});
|
||||
|
||||
await handleCommand("/agents");
|
||||
|
||||
expect(refreshAgents).toHaveBeenCalledTimes(1);
|
||||
expect(openOverlay).toHaveBeenCalledTimes(1);
|
||||
const selector = firstMockArg(openOverlay, "openOverlay") as SelectableOverlay;
|
||||
expect(selector.items).toEqual([
|
||||
{
|
||||
value: "team-lead",
|
||||
label: "team-lead (Lead Agent)",
|
||||
description: "default",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds session picker hydration to recent TUI sessions", async () => {
|
||||
const listSessions = vi.fn().mockResolvedValue({
|
||||
sessions: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Implements TUI slash command handlers and backend action dispatch.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Component, OverlayHandle, SelectItem, TUI } from "@earendil-works/pi-tui";
|
||||
import type { Result } from "@openclaw/normalization-core/result";
|
||||
import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/index.js";
|
||||
import { modelKey } from "../agents/model-ref-shared.js";
|
||||
import { shouldForwardModelCommandToServer } from "../auto-reply/commands-registry.shared.js";
|
||||
@@ -74,7 +75,7 @@ type CommandHandlerContext = {
|
||||
refreshSessionInfo: () => Promise<void>;
|
||||
loadHistory: () => Promise<unknown>;
|
||||
setSession: (key: string) => Promise<void>;
|
||||
refreshAgents: () => Promise<void>;
|
||||
refreshAgents: () => Promise<Result<void, string>>;
|
||||
abortActive: (params?: { preferActive?: boolean }) => Promise<void>;
|
||||
setActivityStatus: (text: string) => void;
|
||||
formatSessionKey: (key: string) => string;
|
||||
@@ -289,7 +290,11 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
||||
};
|
||||
|
||||
const openAgentSelector = async () => {
|
||||
await refreshAgents();
|
||||
const refreshResult = await refreshAgents();
|
||||
if (!refreshResult.ok) {
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
const selectableAgents = state.agents.filter((agent) => agent.kind !== "system");
|
||||
if (selectableAgents.length === 0) {
|
||||
chatLog.addSystem("no agents found");
|
||||
|
||||
@@ -134,6 +134,81 @@ describe("tui session actions", () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("keeps the cached agent roster when a refresh fails", async () => {
|
||||
const cachedAgents = [{ id: "cached", name: "Cached Agent" }];
|
||||
const state = createBaseState({
|
||||
agentDefaultId: "cached",
|
||||
sessionMainKey: "cached-main",
|
||||
sessionScope: "per-sender",
|
||||
agents: cachedAgents,
|
||||
currentAgentId: "cached",
|
||||
});
|
||||
const agentNames = new Map([["cached", "Cached Agent"]]);
|
||||
const addSystem = vi.fn();
|
||||
const { refreshAgents } = createTestSessionActions({
|
||||
client: {
|
||||
listAgents: vi.fn().mockRejectedValue(new Error("gateway unavailable")),
|
||||
} as unknown as TuiBackend,
|
||||
chatLog: { addSystem } as unknown as import("./components/chat-log.js").ChatLog,
|
||||
state,
|
||||
agentNames,
|
||||
});
|
||||
|
||||
await expect(refreshAgents()).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "gateway unavailable",
|
||||
});
|
||||
expect(state.agents).toBe(cachedAgents);
|
||||
expect(state.agentDefaultId).toBe("cached");
|
||||
expect(state.sessionMainKey).toBe("cached-main");
|
||||
expect(state.sessionScope).toBe("per-sender");
|
||||
expect([...agentNames]).toEqual([["cached", "Cached Agent"]]);
|
||||
expect(addSystem).toHaveBeenCalledWith("agents list failed: gateway unavailable");
|
||||
});
|
||||
|
||||
it("returns success after applying a normalized fresh agent roster", async () => {
|
||||
const state = createBaseState({
|
||||
agents: [{ id: "cached", name: "Cached Agent" }],
|
||||
currentAgentId: "cached",
|
||||
});
|
||||
const agentNames = new Map([["cached", "Cached Agent"]]);
|
||||
const updateHeader = vi.fn();
|
||||
const updateFooter = vi.fn();
|
||||
const { refreshAgents } = createTestSessionActions({
|
||||
client: {
|
||||
listAgents: vi.fn().mockResolvedValue({
|
||||
defaultId: " Team Lead ",
|
||||
mainKey: " Primary ",
|
||||
scope: "per-sender",
|
||||
agents: [
|
||||
{ id: " Team Lead ", name: " Lead Agent " },
|
||||
{ id: " System Agent ", kind: "system", name: " System Agent " },
|
||||
],
|
||||
}),
|
||||
} as unknown as TuiBackend,
|
||||
state,
|
||||
agentNames,
|
||||
updateHeader,
|
||||
updateFooter,
|
||||
});
|
||||
|
||||
await expect(refreshAgents()).resolves.toEqual({ ok: true, value: undefined });
|
||||
expect(state.agentDefaultId).toBe("team-lead");
|
||||
expect(state.sessionMainKey).toBe("primary");
|
||||
expect(state.sessionScope).toBe("per-sender");
|
||||
expect(state.agents).toEqual([
|
||||
{ id: "team-lead", kind: undefined, name: "Lead Agent" },
|
||||
{ id: "system-agent", kind: "system", name: "System Agent" },
|
||||
]);
|
||||
expect(state.currentAgentId).toBe("team-lead");
|
||||
expect([...agentNames]).toEqual([
|
||||
["team-lead", "Lead Agent"],
|
||||
["system-agent", "System Agent"],
|
||||
]);
|
||||
expect(updateHeader).toHaveBeenCalledTimes(1);
|
||||
expect(updateFooter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("queues session refreshes and applies the latest result", async () => {
|
||||
let resolveFirst: ((value: unknown) => void) | undefined;
|
||||
let resolveSecond: ((value: unknown) => void) | undefined;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import type { ChatLog } from "./components/chat-log.js";
|
||||
import { refreshTuiAgentList } from "./tui-agent-list-refresh.js";
|
||||
import type { TuiAgentsList, TuiBackend, TuiSessionMutationResult } from "./tui-backend.js";
|
||||
import {
|
||||
asString,
|
||||
@@ -172,14 +173,12 @@ export function createSessionActions(context: SessionActionContext) {
|
||||
updateFooter();
|
||||
};
|
||||
|
||||
const refreshAgents = async () => {
|
||||
try {
|
||||
const result = await client.listAgents();
|
||||
applyAgentsResult(result);
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`agents list failed: ${formatTuiErrorMessage(err)}`);
|
||||
}
|
||||
};
|
||||
const refreshAgents = () =>
|
||||
refreshTuiAgentList({
|
||||
load: () => client.listAgents(),
|
||||
apply: applyAgentsResult,
|
||||
reportError: (message) => chatLog.addSystem(`agents list failed: ${message}`),
|
||||
});
|
||||
|
||||
const updateAgentFromSessionKey = (key: string) => {
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
|
||||
Reference in New Issue
Block a user