fix: stream tool summaries early and tool output

This commit is contained in:
Peter Steinberger
2026-01-03 20:37:57 +01:00
parent 03c1599544
commit a15cffb7de
15 changed files with 379 additions and 122 deletions

View File

@@ -24,9 +24,11 @@
- Block streaming: drop final payloads after soft chunking to keep Discord order intact. - 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. - 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: 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 (8001200 chars default) with paragraph/newline preference. - Agent: add soft block-stream chunking (8001200 chars default) with paragraph/newline preference.
- Agent tools: scope the Discord tool to Discord surface runs. - 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: 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: 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. - 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. - Onboarding: fix Control UI basePath usage when showing/opening gateway URLs.

View File

@@ -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. 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) ## Bootstrap files (injected)
Inside `agent.workspace`, CLAWDIS expects these user-editable files: 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). Tune the boundary via `agent.blockStreamingBreak` (`text_end` vs `message_end`; defaults to text_end).
Control soft block chunking with `agent.blockStreamingChunk` (defaults to Control soft block chunking with `agent.blockStreamingChunk` (defaults to
8001200 chars; prefers paragraph breaks, then newlines; sentences last). 8001200 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) ## Configuration (minimal)

View File

@@ -20,6 +20,7 @@ The dashboard settings panel lets you store a token; passwords are not persisted
## What it can do (today) ## What it can do (today)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`) - 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`) - Connections: WhatsApp/Telegram status + QR login + Telegram config (`providers.status`, `web.login.*`, `config.set`)
- Instances: presence list + refresh (`system-presence`) - Instances: presence list + refresh (`system-presence`)
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) - Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)

View File

@@ -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: The UI talks directly to the Gateway WS and supports:
- Chat (`chat.history`, `chat.send`, `chat.abort`) - Chat (`chat.history`, `chat.send`, `chat.abort`)
- Chat tool cards (agent tool events)
- Connections (provider status, WhatsApp QR, Telegram config) - Connections (provider status, WhatsApp QR, Telegram config)
- Instances (`system-presence`) - Instances (`system-presence`)
- Sessions (`sessions.list`, `sessions.patch`) - Sessions (`sessions.list`, `sessions.patch`)

View File

@@ -48,6 +48,7 @@ import {
} from "./pi-embedded-subscribe.js"; } from "./pi-embedded-subscribe.js";
import { extractAssistantText } from "./pi-embedded-utils.js"; import { extractAssistantText } from "./pi-embedded-utils.js";
import { createClawdisCodingTools } from "./pi-tools.js"; import { createClawdisCodingTools } from "./pi-tools.js";
import { resolveSandboxContext } from "./sandbox.js";
import { import {
applySkillEnvOverrides, applySkillEnvOverrides,
applySkillEnvOverridesFromSnapshot, applySkillEnvOverridesFromSnapshot,
@@ -362,7 +363,13 @@ export async function runEmbeddedPiAgent(params: {
return enqueueCommandInLane(sessionLane, () => return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => { enqueueGlobal(async () => {
const started = Date.now(); 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 prevCwd = process.cwd();
const provider = const provider =
@@ -425,6 +432,7 @@ export async function runEmbeddedPiAgent(params: {
const tools = createClawdisCodingTools({ const tools = createClawdisCodingTools({
bash: params.config?.agent?.bash, bash: params.config?.agent?.bash,
surface: params.surface, surface: params.surface,
sandbox,
}); });
const machineName = await getMachineDisplayName(); const machineName = await getMachineDisplayName();
const runtimeInfo = { const runtimeInfo = {
@@ -497,7 +505,6 @@ export async function runEmbeddedPiAgent(params: {
assistantTexts, assistantTexts,
toolMetas, toolMetas,
unsubscribe, unsubscribe,
flush: flushToolDebouncer,
waitForCompactionRetry, waitForCompactionRetry,
} = subscribeEmbeddedPiSession({ } = subscribeEmbeddedPiSession({
session, session,
@@ -571,7 +578,6 @@ export async function runEmbeddedPiAgent(params: {
abortWarnTimer = undefined; abortWarnTimer = undefined;
} }
unsubscribe(); unsubscribe();
flushToolDebouncer();
if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) { if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) {
ACTIVE_EMBEDDED_RUNS.delete(params.sessionId); ACTIVE_EMBEDDED_RUNS.delete(params.sessionId);
} }

View File

@@ -630,4 +630,107 @@ describe("subscribeEmbeddedPiSession", () => {
await waitPromise; await waitPromise;
expect(resolved).toBe(true); 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);
});
}); });

View File

@@ -2,10 +2,7 @@ import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { AgentSession } from "@mariozechner/pi-coding-agent"; import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { import { formatToolAggregate } from "../auto-reply/tool-meta.js";
createToolDebouncer,
formatToolAggregate,
} from "../auto-reply/tool-meta.js";
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
import { splitMediaFromOutput } from "../media/parse.js"; import { splitMediaFromOutput } from "../media/parse.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@@ -113,6 +110,7 @@ export function subscribeEmbeddedPiSession(params: {
const assistantTexts: string[] = []; const assistantTexts: string[] = [];
const toolMetas: Array<{ toolName?: string; meta?: string }> = []; const toolMetas: Array<{ toolName?: string; meta?: string }> = [];
const toolMetaById = new Map<string, string | undefined>(); const toolMetaById = new Map<string, string | undefined>();
const toolSummaryById = new Set<string>();
const blockReplyBreak = params.blockReplyBreak ?? "text_end"; const blockReplyBreak = params.blockReplyBreak ?? "text_end";
let deltaBuffer = ""; let deltaBuffer = "";
let blockBuffer = ""; let blockBuffer = "";
@@ -176,17 +174,25 @@ export function subscribeEmbeddedPiSession(params: {
return afterStart.slice(0, endIndex); 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 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 => { const findSentenceBreak = (window: string, minChars: number): number => {
if (!window) return -1; if (!window) return -1;
@@ -298,12 +304,12 @@ export function subscribeEmbeddedPiSession(params: {
assistantTexts.length = 0; assistantTexts.length = 0;
toolMetas.length = 0; toolMetas.length = 0;
toolMetaById.clear(); toolMetaById.clear();
toolSummaryById.clear();
deltaBuffer = ""; deltaBuffer = "";
blockBuffer = ""; blockBuffer = "";
lastStreamedAssistant = undefined; lastStreamedAssistant = undefined;
lastBlockReplyText = undefined; lastBlockReplyText = undefined;
assistantTextBaseline = 0; assistantTextBaseline = 0;
toolDebouncer.flush();
}; };
const unsubscribe = params.session.subscribe( const unsubscribe = params.session.subscribe(
@@ -336,6 +342,15 @@ export function subscribeEmbeddedPiSession(params: {
stream: "tool", stream: "tool",
data: { phase: "start", name: toolName, toolCallId }, 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") { if (evt.type === "tool_execution_update") {
@@ -382,7 +397,8 @@ export function subscribeEmbeddedPiSession(params: {
const sanitizedResult = sanitizeToolResult(result); const sanitizedResult = sanitizeToolResult(result);
const meta = toolMetaById.get(toolCallId); const meta = toolMetaById.get(toolCallId);
toolMetas.push({ toolName, meta }); toolMetas.push({ toolName, meta });
toolDebouncer.push(toolName, meta); toolMetaById.delete(toolCallId);
toolSummaryById.delete(toolCallId);
emitAgentEvent({ emitAgentEvent({
runId: params.runId, runId: params.runId,
@@ -406,25 +422,6 @@ export function subscribeEmbeddedPiSession(params: {
isError, 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") { if (evt.type === "message_update") {
@@ -626,7 +623,6 @@ export function subscribeEmbeddedPiSession(params: {
if (evt.type === "agent_end") { if (evt.type === "agent_end") {
defaultRuntime.log?.(`embedded run agent end: runId=${params.runId}`); defaultRuntime.log?.(`embedded run agent end: runId=${params.runId}`);
toolDebouncer.flush();
if (pendingCompactionRetry > 0) { if (pendingCompactionRetry > 0) {
resolveCompactionRetry(); resolveCompactionRetry();
} else { } else {
@@ -640,7 +636,6 @@ export function subscribeEmbeddedPiSession(params: {
assistantTexts, assistantTexts,
toolMetas, toolMetas,
unsubscribe, unsubscribe,
flush: () => toolDebouncer.flush(),
waitForCompactionRetry: () => { waitForCompactionRetry: () => {
if (compactionInFlight || pendingCompactionRetry > 0) { if (compactionInFlight || pendingCompactionRetry > 0) {
ensureCompactionPromise(); ensureCompactionPromise();

View File

@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
createToolDebouncer,
formatToolAggregate, formatToolAggregate,
formatToolPrefix, formatToolPrefix,
shortenMeta, shortenMeta,
@@ -48,37 +47,3 @@ describe("tool meta formatting", () => {
expect(formatToolPrefix("x", "/Users/test/a.txt")).toBe("🧩 x: ~/a.txt"); 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();
}
});
});

View File

@@ -4,9 +4,6 @@ import {
} from "../agents/tool-display.js"; } from "../agents/tool-display.js";
import { shortenHomeInString, shortenHomePath } from "../utils.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 { export function shortenPath(p: string): string {
return shortenHomePath(p); return shortenHomePath(p);
} }
@@ -77,33 +74,3 @@ function isPathLike(value: string): boolean {
if (value.includes("&&") || value.includes("||")) return false; if (value.includes("&&") || value.includes("||")) return false;
return /^~?(\/[^\s]+)+$/.test(value); 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 };
}

View File

@@ -186,12 +186,18 @@ export function createAgentEventHandler({
}; };
return (evt: AgentEventPayload) => { 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; const last = agentRunSeq.get(evt.runId) ?? 0;
if (evt.seq !== last + 1) { if (evt.seq !== last + 1) {
broadcast("agent", { broadcast("agent", {
runId: evt.runId, runId: evt.runId,
stream: "error", stream: "error",
ts: Date.now(), ts: Date.now(),
sessionKey,
data: { data: {
reason: "seq gap", reason: "seq gap",
expected: last + 1, expected: last + 1,
@@ -200,18 +206,15 @@ export function createAgentEventHandler({
}); });
} }
agentRunSeq.set(evt.runId, evt.seq); 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 = const jobState =
evt.stream === "job" && typeof evt.data?.state === "string" evt.stream === "job" && typeof evt.data?.state === "string"
? (evt.data.state as "done" | "error" | string) ? (evt.data.state as "done" | "error" | string)
: null; : null;
if (sessionKey) { if (sessionKey) {
bridgeSendToSession(sessionKey, "agent", evt); bridgeSendToSession(sessionKey, "agent", agentPayload);
if (evt.stream === "assistant" && typeof evt.data?.text === "string") { if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
const clientRunId = chatLink?.clientRunId ?? evt.runId; const clientRunId = chatLink?.clientRunId ?? evt.runId;
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text); emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);

View File

@@ -478,4 +478,43 @@ describe("gateway server agent", () => {
ws.close(); ws.close();
await server.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<string, unknown>)
: {};
expect(payload.sessionKey).toBe("main");
ws.close();
await server.close();
});
}); });

View File

@@ -11,6 +11,7 @@ export type AgentEventPayload = {
stream: AgentEventStream; stream: AgentEventStream;
ts: number; ts: number;
data: Record<string, unknown>; data: Record<string, unknown>;
sessionKey?: string;
}; };
export type AgentRunContext = { export type AgentRunContext = {

View File

@@ -84,6 +84,7 @@ export type AppViewState = {
chatSending: boolean; chatSending: boolean;
chatMessage: string; chatMessage: string;
chatMessages: unknown[]; chatMessages: unknown[];
chatToolMessages: unknown[];
chatStream: string | null; chatStream: string | null;
chatRunId: string | null; chatRunId: string | null;
chatThinkingLevel: string | null; chatThinkingLevel: string | null;
@@ -168,6 +169,7 @@ export type AppViewState = {
handleWhatsAppLogout: () => Promise<void>; handleWhatsAppLogout: () => Promise<void>;
handleTelegramSave: () => Promise<void>; handleTelegramSave: () => Promise<void>;
handleSendChat: () => Promise<void>; handleSendChat: () => Promise<void>;
resetToolStream: () => void;
}; };
export function renderApp(state: AppViewState) { export function renderApp(state: AppViewState) {
@@ -241,6 +243,7 @@ export function renderApp(state: AppViewState) {
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
state.sessionKey = next; state.sessionKey = next;
state.chatMessage = ""; state.chatMessage = "";
state.resetToolStream();
state.applySettings({ ...state.settings, sessionKey: next }); state.applySettings({ ...state.settings, sessionKey: next });
}, },
onRefresh: () => state.loadOverview(), onRefresh: () => state.loadOverview(),
@@ -370,20 +373,24 @@ export function renderApp(state: AppViewState) {
state.chatMessage = ""; state.chatMessage = "";
state.chatStream = null; state.chatStream = null;
state.chatRunId = null; state.chatRunId = null;
state.resetToolStream();
state.applySettings({ ...state.settings, sessionKey: next }); state.applySettings({ ...state.settings, sessionKey: next });
void loadChatHistory(state); void loadChatHistory(state);
}, },
thinkingLevel: state.chatThinkingLevel, thinkingLevel: state.chatThinkingLevel,
loading: state.chatLoading, loading: state.chatLoading,
sending: state.chatSending, sending: state.chatSending,
messages: state.chatMessages, messages: [...state.chatMessages, ...state.chatToolMessages],
stream: state.chatStream, stream: state.chatStream,
draft: state.chatMessage, draft: state.chatMessage,
connected: state.connected, connected: state.connected,
canSend: state.connected && hasConnectedMobileNode, canSend: state.connected && hasConnectedMobileNode,
disabledReason: chatDisabledReason, disabledReason: chatDisabledReason,
sessions: state.sessionsResult, sessions: state.sessionsResult,
onRefresh: () => loadChatHistory(state), onRefresh: () => {
state.resetToolStream();
return loadChatHistory(state);
},
onDraftChange: (next) => (state.chatMessage = next), onDraftChange: (next) => (state.chatMessage = next),
onSend: () => state.handleSendChat(), onSend: () => state.handleSendChat(),
}) })

View File

@@ -81,6 +81,62 @@ type EventLogEntry = {
payload?: unknown; payload?: unknown;
}; };
const TOOL_STREAM_LIMIT = 50;
type AgentEventPayload = {
runId: string;
seq: number;
stream: string;
ts: number;
sessionKey?: string;
data: Record<string, unknown>;
};
type ToolStreamEntry = {
toolCallId: string;
runId: string;
sessionKey?: string;
name: string;
args?: unknown;
output?: string;
startedAt: number;
updatedAt: number;
message: Record<string, unknown>;
};
function extractToolOutputText(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
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<string, unknown>;
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 { declare global {
interface Window { interface Window {
__CLAWDIS_CONTROL_UI_BASE_PATH__?: string; __CLAWDIS_CONTROL_UI_BASE_PATH__?: string;
@@ -125,6 +181,7 @@ export class ClawdisApp extends LitElement {
@state() chatSending = false; @state() chatSending = false;
@state() chatMessage = ""; @state() chatMessage = "";
@state() chatMessages: unknown[] = []; @state() chatMessages: unknown[] = [];
@state() chatToolMessages: unknown[] = [];
@state() chatStream: string | null = null; @state() chatStream: string | null = null;
@state() chatRunId: string | null = null; @state() chatRunId: string | null = null;
@state() chatThinkingLevel: string | null = null; @state() chatThinkingLevel: string | null = null;
@@ -260,6 +317,8 @@ export class ClawdisApp extends LitElement {
private chatScrollFrame: number | null = null; private chatScrollFrame: number | null = null;
private chatScrollTimeout: number | null = null; private chatScrollTimeout: number | null = null;
private nodesPollInterval: number | null = null; private nodesPollInterval: number | null = null;
private toolStreamById = new Map<string, ToolStreamEntry>();
private toolStreamOrder: string[] = [];
basePath = ""; basePath = "";
private popStateHandler = () => this.onPopState(); private popStateHandler = () => this.onPopState();
private themeMedia: MediaQueryList | null = null; private themeMedia: MediaQueryList | null = null;
@@ -292,6 +351,7 @@ export class ClawdisApp extends LitElement {
if ( if (
this.tab === "chat" && this.tab === "chat" &&
(changed.has("chatMessages") || (changed.has("chatMessages") ||
changed.has("chatToolMessages") ||
changed.has("chatStream") || changed.has("chatStream") ||
changed.has("chatLoading") || changed.has("chatLoading") ||
changed.has("chatMessage") || 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<string, unknown> => Boolean(msg));
}
private buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown> {
const content: Array<Record<string, unknown>> = [];
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) { private onEvent(evt: GatewayEventFrame) {
this.eventLog = [ this.eventLog = [
{ ts: Date.now(), event: evt.event, payload: evt.payload }, { ts: Date.now(), event: evt.event, payload: evt.payload },
...this.eventLog, ...this.eventLog,
].slice(0, 250); ].slice(0, 250);
if (evt.event === "agent") {
this.handleAgentEvent(evt.payload as AgentEventPayload | undefined);
return;
}
if (evt.event === "chat") { if (evt.event === "chat") {
const payload = evt.payload as ChatEventPayload | undefined; const payload = evt.payload as ChatEventPayload | undefined;
const state = handleChatEvent(this, payload); const state = handleChatEvent(this, payload);

View File

@@ -170,14 +170,18 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown"; const role = typeof m.role === "string" ? m.role : "unknown";
const toolCards = extractToolCards(message); const toolCards = extractToolCards(message);
const hasToolCards = toolCards.length > 0;
const isToolResult = isToolResultMessage(message); const isToolResult = isToolResultMessage(message);
const text = const extractedText = extractText(message);
!isToolResult const contentText = typeof m.content === "string" ? m.content : null;
? extractText(message) ?? const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
(typeof m.content === "string" const text = !isToolResult
? m.content ? extractedText?.trim()
: JSON.stringify(message, null, 2)) ? extractedText
: null; : contentText?.trim()
? contentText
: fallback
: null;
const timestamp = const timestamp =
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : ""; typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";