From a15cffb7deb7ed9ab64a07198acac967b546d99f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 20:37:57 +0100 Subject: [PATCH] fix: stream tool summaries early and tool output --- CHANGELOG.md | 2 + docs/agent.md | 6 + docs/control-ui.md | 1 + docs/web.md | 1 + src/agents/pi-embedded-runner.ts | 12 +- src/agents/pi-embedded-subscribe.test.ts | 103 +++++++++++++++ src/agents/pi-embedded-subscribe.ts | 69 +++++----- src/auto-reply/tool-meta.test.ts | 35 ----- src/auto-reply/tool-meta.ts | 33 ----- src/gateway/server-chat.ts | 13 +- src/gateway/server.agent.test.ts | 39 ++++++ src/infra/agent-events.ts | 1 + ui/src/ui/app-render.ts | 11 +- ui/src/ui/app.ts | 157 +++++++++++++++++++++++ ui/src/ui/views/chat.ts | 18 ++- 15 files changed, 379 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f7911e0..761012ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,11 @@ - Block streaming: drop final payloads after soft chunking to keep Discord order intact. - Gmail hooks: resolve gcloud Python to a real executable when PATH uses mise shims — thanks @joargp. - Control UI: generate UUIDs when `crypto.randomUUID()` is unavailable over HTTP — thanks @ratulsarna. +- Control UI: stream live tool output cards in Chat (agent events include sessionKey). - Agent: add soft block-stream chunking (800–1200 chars default) with paragraph/newline preference. - Agent tools: scope the Discord tool to Discord surface runs. - Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style. +- Agent tools: emit verbose tool summaries at tool start (no debounce). - Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log. - Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability. - Onboarding: fix Control UI basePath usage when showing/opening gateway URLs. diff --git a/docs/agent.md b/docs/agent.md index 32a1a7733..70863ebc4 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -14,6 +14,10 @@ You must set an agent home directory via `agent.workspace`. CLAWDIS uses this as Recommended: use `clawdis setup` to create `~/.clawdis/clawdis.json` if missing and initialize the workspace files. +If `agent.sandbox` is enabled, non-main sessions can override this with +per-session workspaces under `agent.sandbox.workspaceRoot` (see +`docs/configuration.md`). + ## Bootstrap files (injected) Inside `agent.workspace`, CLAWDIS expects these user-editable files: @@ -85,6 +89,8 @@ via `agent.blockStreamingDefault: "off"` if you only want the final response. Tune the boundary via `agent.blockStreamingBreak` (`text_end` vs `message_end`; defaults to text_end). Control soft block chunking with `agent.blockStreamingChunk` (defaults to 800–1200 chars; prefers paragraph breaks, then newlines; sentences last). +Verbose tool summaries are emitted at tool start (no debounce); Control UI +streams tool output via agent events when available. ## Configuration (minimal) diff --git a/docs/control-ui.md b/docs/control-ui.md index b748766a2..b00699ccc 100644 --- a/docs/control-ui.md +++ b/docs/control-ui.md @@ -20,6 +20,7 @@ The dashboard settings panel lets you store a token; passwords are not persisted ## What it can do (today) - Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`) +- Stream tool calls + live tool output cards in Chat (agent events) - Connections: WhatsApp/Telegram status + QR login + Telegram config (`providers.status`, `web.login.*`, `config.set`) - Instances: presence list + refresh (`system-presence`) - Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) diff --git a/docs/web.md b/docs/web.md index acc8b3699..e923c5bb2 100644 --- a/docs/web.md +++ b/docs/web.md @@ -13,6 +13,7 @@ The Gateway serves a small **browser Control UI** (Vite + Lit) from the same por The UI talks directly to the Gateway WS and supports: - Chat (`chat.history`, `chat.send`, `chat.abort`) +- Chat tool cards (agent tool events) - Connections (provider status, WhatsApp QR, Telegram config) - Instances (`system-presence`) - Sessions (`sessions.list`, `sessions.patch`) diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 509d62c56..5801f4b61 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -48,6 +48,7 @@ import { } from "./pi-embedded-subscribe.js"; import { extractAssistantText } from "./pi-embedded-utils.js"; import { createClawdisCodingTools } from "./pi-tools.js"; +import { resolveSandboxContext } from "./sandbox.js"; import { applySkillEnvOverrides, applySkillEnvOverridesFromSnapshot, @@ -362,7 +363,13 @@ export async function runEmbeddedPiAgent(params: { return enqueueCommandInLane(sessionLane, () => enqueueGlobal(async () => { const started = Date.now(); - const resolvedWorkspace = resolveUserPath(params.workspaceDir); + const sandbox = await resolveSandboxContext({ + config: params.config, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + }); + const workspaceDir = sandbox?.workspaceDir ?? params.workspaceDir; + const resolvedWorkspace = resolveUserPath(workspaceDir); const prevCwd = process.cwd(); const provider = @@ -425,6 +432,7 @@ export async function runEmbeddedPiAgent(params: { const tools = createClawdisCodingTools({ bash: params.config?.agent?.bash, surface: params.surface, + sandbox, }); const machineName = await getMachineDisplayName(); const runtimeInfo = { @@ -497,7 +505,6 @@ export async function runEmbeddedPiAgent(params: { assistantTexts, toolMetas, unsubscribe, - flush: flushToolDebouncer, waitForCompactionRetry, } = subscribeEmbeddedPiSession({ session, @@ -571,7 +578,6 @@ export async function runEmbeddedPiAgent(params: { abortWarnTimer = undefined; } unsubscribe(); - flushToolDebouncer(); if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) { ACTIVE_EMBEDDED_RUNS.delete(params.sessionId); } diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts index f81d347e5..dc9598b98 100644 --- a/src/agents/pi-embedded-subscribe.test.ts +++ b/src/agents/pi-embedded-subscribe.test.ts @@ -630,4 +630,107 @@ describe("subscribeEmbeddedPiSession", () => { await waitPromise; expect(resolved).toBe(true); }); + + it("emits tool summaries at tool start when verbose is on", () => { + let handler: ((evt: unknown) => void) | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const onToolResult = vi.fn(); + + subscribeEmbeddedPiSession({ + session: session as unknown as Parameters< + typeof subscribeEmbeddedPiSession + >[0]["session"], + runId: "run-tool", + verboseLevel: "on", + onToolResult, + }); + + handler?.({ + type: "tool_execution_start", + toolName: "read", + toolCallId: "tool-1", + args: { path: "/tmp/a.txt" }, + }); + + expect(onToolResult).toHaveBeenCalledTimes(1); + const payload = onToolResult.mock.calls[0][0]; + expect(payload.text).toContain("/tmp/a.txt"); + + handler?.({ + type: "tool_execution_end", + toolName: "read", + toolCallId: "tool-1", + isError: false, + result: "ok", + }); + + expect(onToolResult).toHaveBeenCalledTimes(1); + }); + + it("skips tool summaries when shouldEmitToolResult is false", () => { + let handler: ((evt: unknown) => void) | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const onToolResult = vi.fn(); + + subscribeEmbeddedPiSession({ + session: session as unknown as Parameters< + typeof subscribeEmbeddedPiSession + >[0]["session"], + runId: "run-tool-off", + shouldEmitToolResult: () => false, + onToolResult, + }); + + handler?.({ + type: "tool_execution_start", + toolName: "read", + toolCallId: "tool-2", + args: { path: "/tmp/b.txt" }, + }); + + expect(onToolResult).not.toHaveBeenCalled(); + }); + + it("emits tool summaries when shouldEmitToolResult overrides verbose", () => { + let handler: ((evt: unknown) => void) | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const onToolResult = vi.fn(); + + subscribeEmbeddedPiSession({ + session: session as unknown as Parameters< + typeof subscribeEmbeddedPiSession + >[0]["session"], + runId: "run-tool-override", + verboseLevel: "off", + shouldEmitToolResult: () => true, + onToolResult, + }); + + handler?.({ + type: "tool_execution_start", + toolName: "read", + toolCallId: "tool-3", + args: { path: "/tmp/c.txt" }, + }); + + expect(onToolResult).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index c5a341622..b23378668 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -2,10 +2,7 @@ import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { AgentSession } from "@mariozechner/pi-coding-agent"; -import { - createToolDebouncer, - formatToolAggregate, -} from "../auto-reply/tool-meta.js"; +import { formatToolAggregate } from "../auto-reply/tool-meta.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import { splitMediaFromOutput } from "../media/parse.js"; import { defaultRuntime } from "../runtime.js"; @@ -113,6 +110,7 @@ export function subscribeEmbeddedPiSession(params: { const assistantTexts: string[] = []; const toolMetas: Array<{ toolName?: string; meta?: string }> = []; const toolMetaById = new Map(); + const toolSummaryById = new Set(); const blockReplyBreak = params.blockReplyBreak ?? "text_end"; let deltaBuffer = ""; let blockBuffer = ""; @@ -176,17 +174,25 @@ export function subscribeEmbeddedPiSession(params: { return afterStart.slice(0, endIndex); }; - const toolDebouncer = createToolDebouncer((toolName, metas) => { - if (!params.onPartialReply) return; - const text = formatToolAggregate(toolName, metas); - const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text); - void params.onPartialReply({ - text: cleanedText, - mediaUrls: mediaUrls?.length ? mediaUrls : undefined, - }); - }); - const blockChunking = params.blockReplyChunking; + const shouldEmitToolResult = () => + typeof params.shouldEmitToolResult === "function" + ? params.shouldEmitToolResult() + : params.verboseLevel === "on"; + const emitToolSummary = (toolName?: string, meta?: string) => { + if (!params.onToolResult) return; + const agg = formatToolAggregate(toolName, meta ? [meta] : undefined); + const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg); + if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) return; + try { + void params.onToolResult({ + text: cleanedText, + mediaUrls: mediaUrls?.length ? mediaUrls : undefined, + }); + } catch { + // ignore tool result delivery failures + } + }; const findSentenceBreak = (window: string, minChars: number): number => { if (!window) return -1; @@ -298,12 +304,12 @@ export function subscribeEmbeddedPiSession(params: { assistantTexts.length = 0; toolMetas.length = 0; toolMetaById.clear(); + toolSummaryById.clear(); deltaBuffer = ""; blockBuffer = ""; lastStreamedAssistant = undefined; lastBlockReplyText = undefined; assistantTextBaseline = 0; - toolDebouncer.flush(); }; const unsubscribe = params.session.subscribe( @@ -336,6 +342,15 @@ export function subscribeEmbeddedPiSession(params: { stream: "tool", data: { phase: "start", name: toolName, toolCallId }, }); + + if ( + params.onToolResult && + shouldEmitToolResult() && + !toolSummaryById.has(toolCallId) + ) { + toolSummaryById.add(toolCallId); + emitToolSummary(toolName, meta); + } } if (evt.type === "tool_execution_update") { @@ -382,7 +397,8 @@ export function subscribeEmbeddedPiSession(params: { const sanitizedResult = sanitizeToolResult(result); const meta = toolMetaById.get(toolCallId); toolMetas.push({ toolName, meta }); - toolDebouncer.push(toolName, meta); + toolMetaById.delete(toolCallId); + toolSummaryById.delete(toolCallId); emitAgentEvent({ runId: params.runId, @@ -406,25 +422,6 @@ export function subscribeEmbeddedPiSession(params: { isError, }, }); - - const emitToolResult = - typeof params.shouldEmitToolResult === "function" - ? params.shouldEmitToolResult() - : params.verboseLevel === "on"; - if (emitToolResult && params.onToolResult) { - const agg = formatToolAggregate(toolName, meta ? [meta] : undefined); - const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg); - if (cleanedText || (mediaUrls && mediaUrls.length > 0)) { - try { - void params.onToolResult({ - text: cleanedText, - mediaUrls: mediaUrls?.length ? mediaUrls : undefined, - }); - } catch { - // ignore tool result delivery failures - } - } - } } if (evt.type === "message_update") { @@ -626,7 +623,6 @@ export function subscribeEmbeddedPiSession(params: { if (evt.type === "agent_end") { defaultRuntime.log?.(`embedded run agent end: runId=${params.runId}`); - toolDebouncer.flush(); if (pendingCompactionRetry > 0) { resolveCompactionRetry(); } else { @@ -640,7 +636,6 @@ export function subscribeEmbeddedPiSession(params: { assistantTexts, toolMetas, unsubscribe, - flush: () => toolDebouncer.flush(), waitForCompactionRetry: () => { if (compactionInFlight || pendingCompactionRetry > 0) { ensureCompactionPromise(); diff --git a/src/auto-reply/tool-meta.test.ts b/src/auto-reply/tool-meta.test.ts index 01aa83ea5..b8da3af33 100644 --- a/src/auto-reply/tool-meta.test.ts +++ b/src/auto-reply/tool-meta.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { - createToolDebouncer, formatToolAggregate, formatToolPrefix, shortenMeta, @@ -48,37 +47,3 @@ describe("tool meta formatting", () => { expect(formatToolPrefix("x", "/Users/test/a.txt")).toBe("🧩 x: ~/a.txt"); }); }); - -describe("tool meta debouncer", () => { - it("flushes on timer and when tool changes", () => { - vi.useFakeTimers(); - try { - const calls: Array<{ tool: string | undefined; metas: string[] }> = []; - const d = createToolDebouncer((tool, metas) => { - calls.push({ tool, metas }); - }, 50); - - d.push("a", "/tmp/1"); - d.push("a", "/tmp/2"); - expect(calls).toHaveLength(0); - - vi.advanceTimersByTime(60); - expect(calls).toHaveLength(1); - expect(calls[0]).toMatchObject({ - tool: "a", - metas: ["/tmp/1", "/tmp/2"], - }); - - d.push("a", "x"); - d.push("b", "y"); // tool change flushes immediately - expect(calls).toHaveLength(2); - expect(calls[1]).toMatchObject({ tool: "a", metas: ["x"] }); - - vi.advanceTimersByTime(60); - expect(calls).toHaveLength(3); - expect(calls[2]).toMatchObject({ tool: "b", metas: ["y"] }); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/src/auto-reply/tool-meta.ts b/src/auto-reply/tool-meta.ts index 7e4f6222e..da8d50a6a 100644 --- a/src/auto-reply/tool-meta.ts +++ b/src/auto-reply/tool-meta.ts @@ -4,9 +4,6 @@ import { } from "../agents/tool-display.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; -export const TOOL_RESULT_DEBOUNCE_MS = 500; -export const TOOL_RESULT_FLUSH_COUNT = 5; - export function shortenPath(p: string): string { return shortenHomePath(p); } @@ -77,33 +74,3 @@ function isPathLike(value: string): boolean { if (value.includes("&&") || value.includes("||")) return false; return /^~?(\/[^\s]+)+$/.test(value); } - -export function createToolDebouncer( - onFlush: (toolName: string | undefined, metas: string[]) => void, - windowMs = TOOL_RESULT_DEBOUNCE_MS, -) { - let pendingTool: string | undefined; - let pendingMetas: string[] = []; - let timer: NodeJS.Timeout | null = null; - - const flush = () => { - if (!pendingTool && pendingMetas.length === 0) return; - onFlush(pendingTool, pendingMetas); - pendingTool = undefined; - pendingMetas = []; - if (timer) { - clearTimeout(timer); - timer = null; - } - }; - - const push = (toolName?: string, meta?: string) => { - if (pendingTool && toolName && pendingTool !== toolName) flush(); - if (!pendingTool) pendingTool = toolName; - if (meta) pendingMetas.push(meta); - if (timer) clearTimeout(timer); - timer = setTimeout(flush, windowMs); - }; - - return { push, flush }; -} diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 2d0687e47..60a2aec48 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -186,12 +186,18 @@ export function createAgentEventHandler({ }; return (evt: AgentEventPayload) => { + const chatLink = chatRunState.registry.peek(evt.runId); + const sessionKey = + chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId); + // Include sessionKey so Control UI can filter tool streams per session. + const agentPayload = sessionKey ? { ...evt, sessionKey } : evt; const last = agentRunSeq.get(evt.runId) ?? 0; if (evt.seq !== last + 1) { broadcast("agent", { runId: evt.runId, stream: "error", ts: Date.now(), + sessionKey, data: { reason: "seq gap", expected: last + 1, @@ -200,18 +206,15 @@ export function createAgentEventHandler({ }); } agentRunSeq.set(evt.runId, evt.seq); - broadcast("agent", evt); + broadcast("agent", agentPayload); - const chatLink = chatRunState.registry.peek(evt.runId); - const sessionKey = - chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId); const jobState = evt.stream === "job" && typeof evt.data?.state === "string" ? (evt.data.state as "done" | "error" | string) : null; if (sessionKey) { - bridgeSendToSession(sessionKey, "agent", evt); + bridgeSendToSession(sessionKey, "agent", agentPayload); if (evt.stream === "assistant" && typeof evt.data?.text === "string") { const clientRunId = chatLink?.clientRunId ?? evt.runId; emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text); diff --git a/src/gateway/server.agent.test.ts b/src/gateway/server.agent.test.ts index f0fc2cf93..a7cbdc17f 100644 --- a/src/gateway/server.agent.test.ts +++ b/src/gateway/server.agent.test.ts @@ -478,4 +478,43 @@ describe("gateway server agent", () => { ws.close(); await server.close(); }); + + test("agent events include sessionKey in agent payloads", async () => { + const { server, ws } = await startServerWithClient(); + await connectOk(ws, { + client: { + name: "webchat", + version: "1.0.0", + platform: "test", + mode: "webchat", + }, + }); + + registerAgentRunContext("run-tool-1", { sessionKey: "main" }); + + const agentEvtP = onceMessage( + ws, + (o) => + o.type === "event" && + o.event === "agent" && + o.payload?.runId === "run-tool-1", + 8000, + ); + + emitAgentEvent({ + runId: "run-tool-1", + stream: "tool", + data: { phase: "start", name: "read", toolCallId: "tool-1" }, + }); + + const evt = await agentEvtP; + const payload = + evt.payload && typeof evt.payload === "object" + ? (evt.payload as Record) + : {}; + expect(payload.sessionKey).toBe("main"); + + ws.close(); + await server.close(); + }); }); diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index 136d9491c..1022d024d 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -11,6 +11,7 @@ export type AgentEventPayload = { stream: AgentEventStream; ts: number; data: Record; + sessionKey?: string; }; export type AgentRunContext = { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index eb2abc67e..2d7f1eca3 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -84,6 +84,7 @@ export type AppViewState = { chatSending: boolean; chatMessage: string; chatMessages: unknown[]; + chatToolMessages: unknown[]; chatStream: string | null; chatRunId: string | null; chatThinkingLevel: string | null; @@ -168,6 +169,7 @@ export type AppViewState = { handleWhatsAppLogout: () => Promise; handleTelegramSave: () => Promise; handleSendChat: () => Promise; + resetToolStream: () => void; }; export function renderApp(state: AppViewState) { @@ -241,6 +243,7 @@ export function renderApp(state: AppViewState) { onSessionKeyChange: (next) => { state.sessionKey = next; state.chatMessage = ""; + state.resetToolStream(); state.applySettings({ ...state.settings, sessionKey: next }); }, onRefresh: () => state.loadOverview(), @@ -370,20 +373,24 @@ export function renderApp(state: AppViewState) { state.chatMessage = ""; state.chatStream = null; state.chatRunId = null; + state.resetToolStream(); state.applySettings({ ...state.settings, sessionKey: next }); void loadChatHistory(state); }, thinkingLevel: state.chatThinkingLevel, loading: state.chatLoading, sending: state.chatSending, - messages: state.chatMessages, + messages: [...state.chatMessages, ...state.chatToolMessages], stream: state.chatStream, draft: state.chatMessage, connected: state.connected, canSend: state.connected && hasConnectedMobileNode, disabledReason: chatDisabledReason, sessions: state.sessionsResult, - onRefresh: () => loadChatHistory(state), + onRefresh: () => { + state.resetToolStream(); + return loadChatHistory(state); + }, onDraftChange: (next) => (state.chatMessage = next), onSend: () => state.handleSendChat(), }) diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index f89938f1a..3717edea9 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -81,6 +81,62 @@ type EventLogEntry = { payload?: unknown; }; +const TOOL_STREAM_LIMIT = 50; + +type AgentEventPayload = { + runId: string; + seq: number; + stream: string; + ts: number; + sessionKey?: string; + data: Record; +}; + +type ToolStreamEntry = { + toolCallId: string; + runId: string; + sessionKey?: string; + name: string; + args?: unknown; + output?: string; + startedAt: number; + updatedAt: number; + message: Record; +}; + +function extractToolOutputText(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + if (typeof record.text === "string") return record.text; + const content = record.content; + if (!Array.isArray(content)) return null; + const parts = content + .map((item) => { + if (!item || typeof item !== "object") return null; + const entry = item as Record; + if (entry.type === "text" && typeof entry.text === "string") return entry.text; + return null; + }) + .filter((part): part is string => Boolean(part)); + if (parts.length === 0) return null; + return parts.join("\n"); +} + +function formatToolOutput(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + const contentText = extractToolOutputText(value); + if (contentText) return contentText; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + declare global { interface Window { __CLAWDIS_CONTROL_UI_BASE_PATH__?: string; @@ -125,6 +181,7 @@ export class ClawdisApp extends LitElement { @state() chatSending = false; @state() chatMessage = ""; @state() chatMessages: unknown[] = []; + @state() chatToolMessages: unknown[] = []; @state() chatStream: string | null = null; @state() chatRunId: string | null = null; @state() chatThinkingLevel: string | null = null; @@ -260,6 +317,8 @@ export class ClawdisApp extends LitElement { private chatScrollFrame: number | null = null; private chatScrollTimeout: number | null = null; private nodesPollInterval: number | null = null; + private toolStreamById = new Map(); + private toolStreamOrder: string[] = []; basePath = ""; private popStateHandler = () => this.onPopState(); private themeMedia: MediaQueryList | null = null; @@ -292,6 +351,7 @@ export class ClawdisApp extends LitElement { if ( this.tab === "chat" && (changed.has("chatMessages") || + changed.has("chatToolMessages") || changed.has("chatStream") || changed.has("chatLoading") || changed.has("chatMessage") || @@ -377,12 +437,109 @@ export class ClawdisApp extends LitElement { }); } + resetToolStream() { + this.toolStreamById.clear(); + this.toolStreamOrder = []; + this.chatToolMessages = []; + } + + private trimToolStream() { + if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return; + const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT; + const removed = this.toolStreamOrder.splice(0, overflow); + for (const id of removed) this.toolStreamById.delete(id); + } + + private syncToolStreamMessages() { + this.chatToolMessages = this.toolStreamOrder + .map((id) => this.toolStreamById.get(id)?.message) + .filter((msg): msg is Record => Boolean(msg)); + } + + private buildToolStreamMessage(entry: ToolStreamEntry): Record { + const content: Array> = []; + content.push({ + type: "toolcall", + name: entry.name, + arguments: entry.args ?? {}, + }); + if (entry.output) { + content.push({ + type: "toolresult", + name: entry.name, + text: entry.output, + }); + } + return { + role: "assistant", + toolCallId: entry.toolCallId, + runId: entry.runId, + content, + timestamp: entry.startedAt, + }; + } + + private handleAgentEvent(payload?: AgentEventPayload) { + if (!payload || payload.stream !== "tool") return; + const sessionKey = + typeof payload.sessionKey === "string" ? payload.sessionKey : undefined; + if (sessionKey && sessionKey !== this.sessionKey) return; + // Fallback: only accept session-less events for the active run. + if (!sessionKey && this.chatRunId && payload.runId !== this.chatRunId) return; + + const data = payload.data ?? {}; + const toolCallId = + typeof data.toolCallId === "string" ? data.toolCallId : ""; + if (!toolCallId) return; + const name = typeof data.name === "string" ? data.name : "tool"; + const phase = typeof data.phase === "string" ? data.phase : ""; + const args = phase === "start" ? data.args : undefined; + const output = + phase === "update" + ? formatToolOutput(data.partialResult) + : phase === "result" + ? formatToolOutput(data.result) + : undefined; + + const now = Date.now(); + let entry = this.toolStreamById.get(toolCallId); + if (!entry) { + entry = { + toolCallId, + runId: payload.runId, + sessionKey, + name, + args, + output, + startedAt: typeof payload.ts === "number" ? payload.ts : now, + updatedAt: now, + message: {}, + }; + this.toolStreamById.set(toolCallId, entry); + this.toolStreamOrder.push(toolCallId); + } else { + entry.name = name; + if (args !== undefined) entry.args = args; + if (output !== undefined) entry.output = output; + entry.updatedAt = now; + } + + entry.message = this.buildToolStreamMessage(entry); + this.trimToolStream(); + this.syncToolStreamMessages(); + } + private onEvent(evt: GatewayEventFrame) { this.eventLog = [ { ts: Date.now(), event: evt.event, payload: evt.payload }, ...this.eventLog, ].slice(0, 250); + if (evt.event === "agent") { + this.handleAgentEvent(evt.payload as AgentEventPayload | undefined); + return; + } + if (evt.event === "chat") { const payload = evt.payload as ChatEventPayload | undefined; const state = handleChatEvent(this, payload); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index e2fbc1282..7a07638cd 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -170,14 +170,18 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) { const m = message as Record; const role = typeof m.role === "string" ? m.role : "unknown"; const toolCards = extractToolCards(message); + const hasToolCards = toolCards.length > 0; const isToolResult = isToolResultMessage(message); - const text = - !isToolResult - ? extractText(message) ?? - (typeof m.content === "string" - ? m.content - : JSON.stringify(message, null, 2)) - : null; + const extractedText = extractText(message); + const contentText = typeof m.content === "string" ? m.content : null; + const fallback = hasToolCards ? null : JSON.stringify(message, null, 2); + const text = !isToolResult + ? extractedText?.trim() + ? extractedText + : contentText?.trim() + ? contentText + : fallback + : null; const timestamp = typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";