diff --git a/src/tui/components/chat-log.test.ts b/src/tui/components/chat-log.test.ts index 0b8cb9a9d938..4f54cb20c999 100644 --- a/src/tui/components/chat-log.test.ts +++ b/src/tui/components/chat-log.test.ts @@ -97,6 +97,22 @@ describe("ChatLog", () => { expect(chatLog.children.length).toBe(20); }); + it("clears visible tool entries and stale tool references", () => { + const chatLog = new ChatLog(20); + chatLog.startTool("tool-1", "read_file", { path: "a.txt" }); + chatLog.updateToolResult("tool-1", { content: [{ type: "text", text: "done" }] }); + + let rendered = normalizeTestText(chatLog.render(120).join("\n")); + expect(rendered).toContain("Read File"); + + chatLog.clearTools(); + chatLog.updateToolResult("tool-1", { content: [{ type: "text", text: "stale" }] }); + + rendered = normalizeTestText(chatLog.render(120).join("\n")); + expect(rendered).not.toContain("Read File"); + expect(rendered).not.toContain("stale"); + }); + it("prunes system messages atomically when a non-system entry overflows the log", () => { const chatLog = new ChatLog(20); for (let i = 1; i <= 20; i++) { diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index 444dbb5f07b1..0caefb5fd63b 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -99,6 +99,13 @@ export class ChatLog extends Container { } } + clearTools() { + for (const tool of this.toolById.values()) { + this.removeChild(tool); + } + this.toolById.clear(); + } + restorePendingUsers() { for (const entry of this.pendingUsers.values()) { if (this.children.includes(entry.component)) { diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 82403b4d69b6..847a963db1e9 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -101,6 +101,7 @@ function createHarness(params?: { params?.setEmptySession ?? (vi.fn().mockResolvedValue(undefined) as SetEmptySessionMock); const addUser = vi.fn(); const addSystem = vi.fn(); + const clearTools = vi.fn(); const reserveAssistantSlot = vi.fn(); const requestRender = vi.fn(); const noteLocalRunId = vi.fn(); @@ -144,7 +145,7 @@ function createHarness(params?: { resetSession, runGoalCommand, } as never, - chatLog: { addUser, addSystem, reserveAssistantSlot } as never, + chatLog: { addUser, addSystem, clearTools, reserveAssistantSlot } as never, tui: { requestRender } as never, opts: params?.opts ?? {}, state: state as never, @@ -187,6 +188,7 @@ function createHarness(params?: { setEmptySession, addUser, addSystem, + clearTools, reserveAssistantSlot, requestRender, loadHistory, @@ -732,6 +734,60 @@ describe("tui command handlers", () => { }); }); + it("hides tools locally for /verbose off without reloading history", async () => { + const patchResult = { entry: { verboseLevel: "off" } }; + const patchSession = vi.fn().mockResolvedValue(patchResult); + const applySessionInfoFromPatch = vi.fn(); + const loadHistory = vi.fn().mockResolvedValue(undefined); + const refreshSessionInfo = vi.fn().mockResolvedValue(undefined); + const { handleCommand, clearTools } = createHarness({ + patchSession, + applySessionInfoFromPatch, + loadHistory, + refreshSessionInfo, + }); + + await handleCommand("/verbose off"); + + expect(patchSession).toHaveBeenCalledWith({ + key: "agent:main:main", + verboseLevel: "off", + }); + expect(applySessionInfoFromPatch).toHaveBeenCalledWith(patchResult); + expect(clearTools).toHaveBeenCalledTimes(1); + expect(refreshSessionInfo).toHaveBeenCalledTimes(1); + expect(loadHistory).not.toHaveBeenCalled(); + }); + + it("reloads history for /verbose on so prior tool output becomes visible", async () => { + const loadHistory = vi.fn().mockResolvedValue(undefined); + const refreshSessionInfo = vi.fn().mockResolvedValue(undefined); + const { handleCommand, clearTools } = createHarness({ + loadHistory, + refreshSessionInfo, + }); + + await handleCommand("/verbose on"); + + expect(loadHistory).toHaveBeenCalledTimes(1); + expect(refreshSessionInfo).not.toHaveBeenCalled(); + expect(clearTools).not.toHaveBeenCalled(); + }); + + it("refreshes session info for /trace without reloading history", async () => { + const loadHistory = vi.fn().mockResolvedValue(undefined); + const refreshSessionInfo = vi.fn().mockResolvedValue(undefined); + const { handleCommand } = createHarness({ + loadHistory, + refreshSessionInfo, + }); + + await handleCommand("/trace on"); + + expect(refreshSessionInfo).toHaveBeenCalledTimes(1); + expect(loadHistory).not.toHaveBeenCalled(); + }); + it("reports send failures and marks activity status as error", async () => { const setActivityStatus = vi.fn(); const { handleCommand, addSystem, state } = createHarness({ diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 0ea738df4546..248154f1b276 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -507,7 +507,12 @@ export function createCommandHandlers(context: CommandHandlerContext) { }); chatLog.addSystem(`verbose set to ${args}`); applySessionInfoFromPatch(result); - await loadHistory(); + if (args === "off") { + chatLog.clearTools(); + await refreshSessionInfo(); + } else { + await loadHistory(); + } } catch (err) { chatLog.addSystem(`verbose failed: ${String(err)}`); } @@ -524,7 +529,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { }); chatLog.addSystem(`trace set to ${args}`); applySessionInfoFromPatch(result); - await loadHistory(); + await refreshSessionInfo(); } catch (err) { chatLog.addSystem(`trace failed: ${String(err)}`); } diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 63ee73ea7f9a..c563b63718ef 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -95,7 +95,7 @@ describe("tui session actions", () => { const first = refreshSessionInfo(); const second = refreshSessionInfo(); - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); expect(listSessions).toHaveBeenCalledTimes(1); expect(listSessions).toHaveBeenNthCalledWith(1, { limit: TUI_SESSION_LOOKUP_LIMIT, @@ -119,8 +119,7 @@ describe("tui session actions", () => { ], }); - await first; - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); expect(listSessions).toHaveBeenCalledTimes(2); @@ -138,7 +137,7 @@ describe("tui session actions", () => { ], }); - await second; + await Promise.all([first, second]); expect(state.sessionInfo.model).toBe("Minimax-M2.7"); expect(updateAutocompleteProvider).toHaveBeenCalledTimes(2); @@ -146,6 +145,51 @@ describe("tui session actions", () => { expect(requestRender).toHaveBeenCalledTimes(2); }); + it("coalesces refresh bursts into a single follow-up lookup", async () => { + let resolveFirst: ((value: unknown) => void) | undefined; + let resolveSecond: ((value: unknown) => void) | undefined; + + const listSessions = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + const { refreshSessionInfo } = createTestSessionActions({ + client: { listSessions } as unknown as TuiBackend, + }); + + const first = refreshSessionInfo(); + const second = refreshSessionInfo(); + const third = refreshSessionInfo(); + + await new Promise((resolve) => setImmediate(resolve)); + expect(listSessions).toHaveBeenCalledTimes(1); + + resolveFirst?.({ + defaults: {}, + sessions: [{ key: "agent:main:main", updatedAt: 1 }], + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(listSessions).toHaveBeenCalledTimes(2); + + resolveSecond?.({ + defaults: {}, + sessions: [{ key: "agent:main:main", updatedAt: 2 }], + }); + await Promise.all([first, second, third]); + + expect(listSessions).toHaveBeenCalledTimes(2); + }); + it("keeps patched model selection when a refresh returns an older snapshot", async () => { const listSessions = vi.fn().mockResolvedValue({ ts: Date.now(), diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 94e70780c056..fceeafc6c360 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -69,7 +69,8 @@ export function createSessionActions(context: SessionActionContext) { rememberSessionKey, emptySessionInfoDefaults, } = context; - let refreshSessionInfoPromise: Promise = Promise.resolve(); + let refreshSessionInfoInFlight: Promise | null = null; + let refreshSessionInfoQueued = false; let lastSessionDefaults: SessionInfoDefaults | null = null; const applyAgentsResult = (result: TuiAgentsList) => { @@ -274,12 +275,25 @@ export function createSessionActions(context: SessionActionContext) { } }; + const drainRefreshSessionInfo = async () => { + do { + // Many TUI paths ask for the same session snapshot at once; keep one in-flight + // lookup and at most one follow-up so bursts do not queue stale backend calls. + refreshSessionInfoQueued = false; + await runRefreshSessionInfo(); + } while (refreshSessionInfoQueued); + }; + const refreshSessionInfo = async () => { - refreshSessionInfoPromise = refreshSessionInfoPromise.then( - runRefreshSessionInfo, - runRefreshSessionInfo, - ); - await refreshSessionInfoPromise; + if (refreshSessionInfoInFlight) { + refreshSessionInfoQueued = true; + await refreshSessionInfoInFlight; + return; + } + refreshSessionInfoInFlight = drainRefreshSessionInfo().finally(() => { + refreshSessionInfoInFlight = null; + }); + await refreshSessionInfoInFlight; }; const applySessionInfoFromPatch = (