From 5fc976571e5b307aaa9be92170ed09108cd5eec1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 01:05:32 -0700 Subject: [PATCH] fix(tui): require a fresh agent roster (#116715) --- src/tui/tui-agent-list-refresh.ts | 19 +++++++ src/tui/tui-command-handlers.test.ts | 56 ++++++++++++++++++++- src/tui/tui-command-handlers.ts | 9 +++- src/tui/tui-session-actions.test.ts | 75 ++++++++++++++++++++++++++++ src/tui/tui-session-actions.ts | 15 +++--- 5 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 src/tui/tui-agent-list-refresh.ts diff --git a/src/tui/tui-agent-list-refresh.ts b/src/tui/tui-agent-list-refresh.ts new file mode 100644 index 000000000000..09548abefa34 --- /dev/null +++ b/src/tui/tui-agent-list-refresh.ts @@ -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; + apply: (result: TuiAgentsList) => void; + reportError: (message: string) => void; +}): Promise> { + try { + params.apply(await params.load()); + return ok(undefined); + } catch (error) { + const message = formatTuiErrorMessage(error); + params.reportError(message); + return err(message); + } +} diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index e09bb52f7ccb..bd0952856433 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -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 & ((text: string) => void) type SetSessionMock = ReturnType & ((key: string) => Promise); type ConsumeCompletedRunMock = ReturnType & ((runId: string) => boolean); type FlushPendingHistoryRefreshMock = ReturnType & (() => void); +type RefreshAgentsMock = ReturnType & (() => Promise>); 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: [ diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 323d5f86ec49..d35cfcdf7624 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -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; loadHistory: () => Promise; setSession: (key: string) => Promise; - refreshAgents: () => Promise; + refreshAgents: () => Promise>; abortActive: (params?: { preferActive?: boolean }) => Promise; 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"); diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 9afa4cfa433e..d523a2dbb655 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -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; diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index c3536aa1dff7..c27e38d7ba14 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -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);