From ae48066d2877d489e289812c303bb0e924c5d663 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 06:59:02 +0000 Subject: [PATCH] fix: track TUI agent events for external runs (#1567) (thanks @vignesh07) --- CHANGELOG.md | 1 + src/tui/tui-event-handlers.test.ts | 65 +++++++++++++++++++++++++++++- src/tui/tui-event-handlers.ts | 56 ++++++++++++++++++------- 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e93b92ffa..37a2b1a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.clawd.bot - Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman. - MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero. - Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160) +- TUI: track active run ids from chat events so tool/lifecycle updates show for non-TUI runs. (#1567) Thanks @vignesh07. ## 2026.1.22 diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 767681c5e..226ca3229 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { createEventHandlers } from "./tui-event-handlers.js"; -import type { AgentEvent, TuiStateAccess } from "./tui-types.js"; +import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js"; type MockChatLog = { startTool: ReturnType; @@ -121,4 +121,67 @@ describe("tui-event-handlers: handleAgentEvent", () => { expect(setActivityStatus).toHaveBeenCalledWith("running"); expect(tui.requestRender).toHaveBeenCalledTimes(1); }); + + it("captures runId from chat events when activeChatRunId is unset", () => { + const state = makeState({ activeChatRunId: null }); + const { chatLog, tui, setActivityStatus } = makeContext(state); + const { handleChatEvent, handleAgentEvent } = createEventHandlers({ + chatLog: chatLog as any, + tui: tui as any, + state, + setActivityStatus, + }); + + const chatEvt: ChatEvent = { + runId: "run-42", + sessionKey: state.currentSessionKey, + state: "delta", + message: { content: "hello" }, + }; + + handleChatEvent(chatEvt); + + expect(state.activeChatRunId).toBe("run-42"); + + const agentEvt: AgentEvent = { + runId: "run-42", + stream: "tool", + data: { phase: "start", toolCallId: "tc1", name: "exec" }, + }; + + handleAgentEvent(agentEvt); + + expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", undefined); + }); + + it("clears run mapping when the session changes", () => { + const state = makeState({ activeChatRunId: null }); + const { chatLog, tui, setActivityStatus } = makeContext(state); + const { handleChatEvent, handleAgentEvent } = createEventHandlers({ + chatLog: chatLog as any, + tui: tui as any, + state, + setActivityStatus, + }); + + handleChatEvent({ + runId: "run-old", + sessionKey: state.currentSessionKey, + state: "delta", + message: { content: "hello" }, + }); + + state.currentSessionKey = "agent:main:other"; + state.activeChatRunId = null; + tui.requestRender.mockClear(); + + handleAgentEvent({ + runId: "run-old", + stream: "tool", + data: { phase: "start", toolCallId: "tc2", name: "exec" }, + }); + + expect(chatLog.startTool).not.toHaveBeenCalled(); + expect(tui.requestRender).not.toHaveBeenCalled(); + }); }); diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 66b8129e4..1d99c4414 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -15,33 +15,58 @@ type EventHandlerContext = { export function createEventHandlers(context: EventHandlerContext) { const { chatLog, tui, state, setActivityStatus, refreshSessionInfo } = context; const finalizedRuns = new Map(); - const streamAssembler = new TuiStreamAssembler(); + const sessionRuns = new Map(); + let streamAssembler = new TuiStreamAssembler(); + let lastSessionKey = state.currentSessionKey; + + const pruneRunMap = (runs: Map) => { + if (runs.size <= 200) return; + const keepUntil = Date.now() - 10 * 60 * 1000; + for (const [key, ts] of runs) { + if (runs.size <= 150) break; + if (ts < keepUntil) runs.delete(key); + } + if (runs.size > 200) { + for (const key of runs.keys()) { + runs.delete(key); + if (runs.size <= 150) break; + } + } + }; + + const syncSessionKey = () => { + if (state.currentSessionKey === lastSessionKey) return; + lastSessionKey = state.currentSessionKey; + finalizedRuns.clear(); + sessionRuns.clear(); + streamAssembler = new TuiStreamAssembler(); + }; + + const noteSessionRun = (runId: string) => { + sessionRuns.set(runId, Date.now()); + pruneRunMap(sessionRuns); + }; const noteFinalizedRun = (runId: string) => { finalizedRuns.set(runId, Date.now()); + sessionRuns.delete(runId); streamAssembler.drop(runId); - if (finalizedRuns.size <= 200) return; - const keepUntil = Date.now() - 10 * 60 * 1000; - for (const [key, ts] of finalizedRuns) { - if (finalizedRuns.size <= 150) break; - if (ts < keepUntil) finalizedRuns.delete(key); - } - if (finalizedRuns.size > 200) { - for (const key of finalizedRuns.keys()) { - finalizedRuns.delete(key); - if (finalizedRuns.size <= 150) break; - } - } + pruneRunMap(finalizedRuns); }; const handleChatEvent = (payload: unknown) => { if (!payload || typeof payload !== "object") return; const evt = payload as ChatEvent; + syncSessionKey(); if (evt.sessionKey !== state.currentSessionKey) return; if (finalizedRuns.has(evt.runId)) { if (evt.state === "delta") return; if (evt.state === "final") return; } + noteSessionRun(evt.runId); + if (!state.activeChatRunId) { + state.activeChatRunId = evt.runId; + } if (evt.state === "delta") { const displayText = streamAssembler.ingestDelta(evt.runId, evt.message, state.showThinking); if (!displayText) return; @@ -78,6 +103,7 @@ export function createEventHandlers(context: EventHandlerContext) { if (evt.state === "aborted") { chatLog.addSystem("run aborted"); streamAssembler.drop(evt.runId); + sessionRuns.delete(evt.runId); state.activeChatRunId = null; setActivityStatus("aborted"); void refreshSessionInfo?.(); @@ -85,6 +111,7 @@ export function createEventHandlers(context: EventHandlerContext) { if (evt.state === "error") { chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`); streamAssembler.drop(evt.runId); + sessionRuns.delete(evt.runId); state.activeChatRunId = null; setActivityStatus("error"); void refreshSessionInfo?.(); @@ -95,9 +122,10 @@ export function createEventHandlers(context: EventHandlerContext) { const handleAgentEvent = (payload: unknown) => { if (!payload || typeof payload !== "object") return; const evt = payload as AgentEvent; + syncSessionKey(); // Agent events (tool streaming, lifecycle) are emitted per-run. Filter against the // active chat run id, not the session id. - if (!state.activeChatRunId || evt.runId !== state.activeChatRunId) return; + if (evt.runId !== state.activeChatRunId && !sessionRuns.has(evt.runId)) return; if (evt.stream === "tool") { const data = evt.data ?? {}; const phase = asString(data.phase, "");