fix(tui): require a fresh agent roster (#116715)

This commit is contained in:
Peter Steinberger
2026-07-31 01:05:32 -07:00
committed by GitHub
parent 623a015928
commit 5fc976571e
5 changed files with 163 additions and 11 deletions

View 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);
}
}

View File

@@ -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: [

View File

@@ -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");

View File

@@ -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;

View File

@@ -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);