fix: UX improvement around reasoning text, fixup tool invocation card in Chat UI

This commit is contained in:
David Garson 2026-01-29 00:10:47 -07:00
parent b927361650
commit 45bff6a2e4
20 changed files with 510 additions and 99 deletions

View File

@ -76,24 +76,27 @@ Legacy Pi/Tau session folders are **not** read.
## Claude Agent SDK Runtime ## Claude Agent SDK Runtime
When using the Claude Agent SDK (`ccsdk`) runtime, Moltbot stores SDK-specific When using the Claude Agent SDK (`ccsdk`) runtime, authentication is handled
configuration and sessions in a per-agent `.claude` directory: automatically through the SDK's internal auth resolution mechanism. By default,
the SDK uses OS-managed credentials (the same credentials used by Claude Code CLI).
``` Alternatively, you can provide an explicit `ANTHROPIC_API_KEY` in your per-agent
~/.clawdbot/agents/<agentId>/.claude/ provider configuration in `moltbot.json`:
├── sessions/ # SDK native session files
├── settings.json # Per-agent SDK settings (optional) ```json5
├── CLAUDE.md # Per-agent memory/instructions (optional) {
├── skills/ # Per-agent skills (optional) "agents": {
└── commands/ # Per-agent slash commands (optional) "main": {
"provider": {
"env": { "ANTHROPIC_API_KEY": "sk-ant-..." }
}
}
}
}
``` ```
This separation ensures Moltbot agent sessions are stored separately from Session transcripts for SDK-based runs are stored in Moltbot's standard
standard Claude Code CLI sessions (`~/.claude/`), and allows per-agent session directory (`~/.clawdbot/agents/<agentId>/sessions/`).
customization of Claude Code features like settings, skills, and commands.
The SDK config directory is set via the `CLAUDE_CONFIG_DIR` environment variable
when launching the SDK process.
## Steering while streaming ## Steering while streaming

View File

@ -24,7 +24,6 @@ import type { SdkConversationTurn, SdkRunnerResult } from "./types.js";
import { createMoltbotCodingTools } from "../pi-tools.js"; import { createMoltbotCodingTools } from "../pi-tools.js";
import { resolveMoltbotAgentDir } from "../agent-paths.js"; import { resolveMoltbotAgentDir } from "../agent-paths.js";
import { resolveModelAuthMode } from "../model-auth.js"; import { resolveModelAuthMode } from "../model-auth.js";
import { resolveCcSdkConfigDirForAgent } from "../../config/sessions/paths.js";
import { convertClientToolsForSdk } from "./client-tool-bridge.js"; import { convertClientToolsForSdk } from "./client-tool-bridge.js";
const log = createSubsystemLogger("agents/ccsdk-runtime"); const log = createSubsystemLogger("agents/ccsdk-runtime");
@ -210,7 +209,7 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
const ccsdkOpts = params.ccsdkOptions ?? {}; const ccsdkOpts = params.ccsdkOptions ?? {};
// Resolve effective model (from params or provider config) // Resolve effective model (from params or provider config)
const effectiveModel = params.model const _effectiveModel = params.model
? `${params.provider ?? "anthropic"}/${params.model}` ? `${params.provider ?? "anthropic"}/${params.model}`
: (ccsdkOpts.modelTiers?.sonnet ?? context?.ccsdkConfig?.models?.sonnet ?? "default"); : (ccsdkOpts.modelTiers?.sonnet ?? context?.ccsdkConfig?.models?.sonnet ?? "default");
@ -266,10 +265,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
// Combine built-in tools with client tools // Combine built-in tools with client tools
const tools: AnyAgentTool[] = [...builtInTools, ...clientToolsConverted]; const tools: AnyAgentTool[] = [...builtInTools, ...clientToolsConverted];
// Resolve CCSDK config directory for this agent's session storage.
// This ensures Moltbot sessions are stored separately from CLI sessions.
const ccsdkConfigDir = resolveCcSdkConfigDirForAgent(agentId);
const sdkResult = await runSdkAgent({ const sdkResult = await runSdkAgent({
// ─── Core shared params (spread) ──────────────────────────────────────── // ─── Core shared params (spread) ────────────────────────────────────────
runId: params.runId, runId: params.runId,
@ -284,7 +279,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
timeoutMs: params.timeoutMs, timeoutMs: params.timeoutMs,
abortSignal: params.abortSignal, abortSignal: params.abortSignal,
extraSystemPrompt: params.extraSystemPrompt, extraSystemPrompt: params.extraSystemPrompt,
ccsdkConfigDir,
// ─── Messaging & sender context (shared) ──────────────────────────────── // ─── Messaging & sender context (shared) ────────────────────────────────
messageChannel: params.messageChannel, messageChannel: params.messageChannel,

View File

@ -938,7 +938,8 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
...(normalizedName.rawName ? { rawName: normalizedName.rawName } : {}), ...(normalizedName.rawName ? { rawName: normalizedName.rawName } : {}),
...(argsForEvent ? { args: argsForEvent } : {}), ...(argsForEvent ? { args: argsForEvent } : {}),
isError, isError,
...(toolText ? { resultText: toolText } : {}), // Use `result` to match Pi runtime event shape (UI expects `data.result`).
...(toolText ? { result: toolText } : {}),
}); });
// Emit tool result via callback (using toolText already extracted above). // Emit tool result via callback (using toolText already extracted above).

View File

@ -19,7 +19,6 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { TSchema } from "@sinclair/typebox"; import type { TSchema } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import * as z from "zod/v4-mini"; import * as z from "zod/v4-mini";
import { createSubsystemLogger } from "../../logging/subsystem.js"; import { createSubsystemLogger } from "../../logging/subsystem.js";

View File

@ -213,14 +213,6 @@ export type SdkRunnerParams = {
*/ */
mcpServerName?: string; mcpServerName?: string;
/**
* Custom Claude Code config directory for session storage.
* When provided, sets CLAUDE_CONFIG_DIR environment variable to ensure
* Moltbot agent sessions are stored separately from standard Claude Code
* CLI sessions. Typically: ~/.clawdbot/agents/{agentId}/ccsdk/
*/
ccsdkConfigDir?: string;
/** /**
* Prior conversation history to serialize into the SDK prompt. * Prior conversation history to serialize into the SDK prompt.
* Since the SDK is stateless, prior turns are injected as context * Since the SDK is stateless, prior turns are injected as context

View File

@ -12,8 +12,8 @@ export function logToolEvent(evt: AgentEventData, runId?: string): void {
if (evt.stream !== "tool") return; if (evt.stream !== "tool") return;
const phase = evt.data.phase as string | undefined; const phase = evt.data.phase as string | undefined;
const toolName = String(evt.data.name ?? "unknown"); const toolName = (evt.data.name as string | undefined) ?? "unknown";
const toolCallId = String(evt.data.toolCallId ?? ""); const toolCallId = (evt.data.toolCallId as string | undefined) ?? "";
switch (phase) { switch (phase) {
case "start": case "start":

View File

@ -71,25 +71,3 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
if (store.startsWith("~")) return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir())); if (store.startsWith("~")) return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir()));
return path.resolve(store); return path.resolve(store);
} }
/**
* Resolve the Claude Code SDK config directory for a given agent.
*
* This directory is passed to the SDK via the CLAUDE_CONFIG_DIR environment
* variable to ensure Moltbot agent sessions are stored separately from
* standard Claude Code CLI sessions.
*
* Using `.claude/` allows per-agent customization of other Claude Code
* features like settings.json, CLAUDE.md, skills, commands, etc.
*
* Structure: ~/.clawdbot/agents/{agentId}/.claude/
*/
export function resolveCcSdkConfigDirForAgent(
agentId?: string,
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const root = resolveStateDir(env, homedir);
const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID);
return path.join(root, "agents", id, ".claude");
}

View File

@ -28,6 +28,16 @@ export type ChatRunEntry = {
clientRunId: string; clientRunId: string;
}; };
export type StreamPhase = {
type: "thinking" | "text";
content: string;
};
export type PhaseBuffer = {
phases: StreamPhase[];
lastPhaseType: "thinking" | "text" | null;
};
export type ChatRunRegistry = { export type ChatRunRegistry = {
add: (sessionId: string, entry: ChatRunEntry) => void; add: (sessionId: string, entry: ChatRunEntry) => void;
peek: (sessionId: string) => ChatRunEntry | undefined; peek: (sessionId: string) => ChatRunEntry | undefined;
@ -80,27 +90,114 @@ export function createChatRunRegistry(): ChatRunRegistry {
export type ChatRunState = { export type ChatRunState = {
registry: ChatRunRegistry; registry: ChatRunRegistry;
phaseBuffers: Map<string, PhaseBuffer>;
/** @deprecated Use phaseBuffers for phase-aware rendering. This provides combined text for backward compatibility. */
buffers: Map<string, string>; buffers: Map<string, string>;
deltaSentAt: Map<string, number>; deltaSentAt: Map<string, number>;
abortedRuns: Map<string, number>; abortedRuns: Map<string, number>;
clear: () => void; clear: () => void;
}; };
function createPhaseBuffer(): PhaseBuffer {
return { phases: [], lastPhaseType: null };
}
function appendToPhaseBuffer(
buffer: PhaseBuffer,
type: "thinking" | "text",
content: string,
): void {
if (buffer.lastPhaseType === type && buffer.phases.length > 0) {
// Same phase - append
buffer.phases[buffer.phases.length - 1].content += content;
} else {
// New phase
buffer.phases.push({ type, content });
buffer.lastPhaseType = type;
}
}
function getPhaseBufferText(buffer: PhaseBuffer): string {
return buffer.phases
.filter((p) => p.type === "text")
.map((p) => p.content)
.join("");
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- reserved for future use
function _getPhaseBufferThinking(buffer: PhaseBuffer): string {
return buffer.phases
.filter((p) => p.type === "thinking")
.map((p) => p.content)
.join("");
}
export function createChatRunState(): ChatRunState { export function createChatRunState(): ChatRunState {
const registry = createChatRunRegistry(); const registry = createChatRunRegistry();
const buffers = new Map<string, string>(); const phaseBuffers = new Map<string, PhaseBuffer>();
const deltaSentAt = new Map<string, number>(); const deltaSentAt = new Map<string, number>();
const abortedRuns = new Map<string, number>(); const abortedRuns = new Map<string, number>();
// Compatibility layer: buffers provides combined text from phaseBuffers
// This allows existing code that expects Map<string, string> to continue working.
const buffers = new Proxy(new Map<string, string>(), {
get(target, prop, receiver) {
if (prop === "get") {
return (key: string) => {
const buffer = phaseBuffers.get(key);
return buffer ? getPhaseBufferText(buffer) : undefined;
};
}
if (prop === "has") {
return (key: string) => phaseBuffers.has(key);
}
if (prop === "delete") {
return (key: string) => phaseBuffers.delete(key);
}
if (prop === "clear") {
return () => phaseBuffers.clear();
}
if (prop === "size") {
return phaseBuffers.size;
}
if (prop === "keys") {
return () => phaseBuffers.keys();
}
if (prop === "values") {
return function* () {
for (const buffer of phaseBuffers.values()) {
yield getPhaseBufferText(buffer);
}
};
}
if (prop === "entries") {
return function* () {
for (const [key, buffer] of phaseBuffers.entries()) {
yield [key, getPhaseBufferText(buffer)] as [string, string];
}
};
}
if (prop === Symbol.iterator) {
return function* () {
for (const [key, buffer] of phaseBuffers.entries()) {
yield [key, getPhaseBufferText(buffer)] as [string, string];
}
};
}
return Reflect.get(target, prop, receiver);
},
});
const clear = () => { const clear = () => {
registry.clear(); registry.clear();
buffers.clear(); phaseBuffers.clear();
deltaSentAt.clear(); deltaSentAt.clear();
abortedRuns.clear(); abortedRuns.clear();
}; };
return { return {
registry, registry,
phaseBuffers,
buffers, buffers,
deltaSentAt, deltaSentAt,
abortedRuns, abortedRuns,
@ -133,12 +230,30 @@ export function createAgentEventHandler({
resolveSessionKeyForRun, resolveSessionKeyForRun,
clearAgentRunContext, clearAgentRunContext,
}: AgentEventHandlerOptions) { }: AgentEventHandlerOptions) {
const buildPhaseContent = (
buffer: PhaseBuffer,
): Array<{ type: string; thinking?: string; text?: string; phaseIndex?: number }> => {
return buffer.phases.map((phase, idx) => ({
type: phase.type,
[phase.type === "thinking" ? "thinking" : "text"]: phase.content,
phaseIndex: idx,
}));
};
const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => { const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => {
chatRunState.buffers.set(clientRunId, text); let buffer = chatRunState.phaseBuffers.get(clientRunId);
if (!buffer) {
buffer = createPhaseBuffer();
chatRunState.phaseBuffers.set(clientRunId, buffer);
}
appendToPhaseBuffer(buffer, "text", text);
const now = Date.now(); const now = Date.now();
const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0; const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0;
if (now - last < 150) return; if (now - last < 150) return;
chatRunState.deltaSentAt.set(clientRunId, now); chatRunState.deltaSentAt.set(clientRunId, now);
const content = buildPhaseContent(buffer);
const payload = { const payload = {
runId: clientRunId, runId: clientRunId,
sessionKey, sessionKey,
@ -146,7 +261,34 @@ export function createAgentEventHandler({
state: "delta" as const, state: "delta" as const,
message: { message: {
role: "assistant", role: "assistant",
content: [{ type: "text", text }], content,
timestamp: now,
},
};
// Suppress webchat broadcast for heartbeat runs when showOk is false
if (!shouldSuppressHeartbeatBroadcast(clientRunId)) {
broadcast("chat", payload, { dropIfSlow: true });
}
nodeSendToSession(sessionKey, "chat", payload);
};
const emitThinkingDelta = (sessionKey: string, clientRunId: string, seq: number) => {
const buffer = chatRunState.phaseBuffers.get(clientRunId);
if (!buffer || buffer.phases.length === 0) return;
const now = Date.now();
const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0;
if (now - last < 150) return;
chatRunState.deltaSentAt.set(clientRunId, now);
const content = buildPhaseContent(buffer);
const payload = {
runId: clientRunId,
sessionKey,
seq,
state: "delta" as const,
message: {
role: "assistant",
content,
timestamp: now, timestamp: now,
}, },
}; };
@ -164,19 +306,40 @@ export function createAgentEventHandler({
jobState: "done" | "error", jobState: "done" | "error",
error?: unknown, error?: unknown,
) => { ) => {
const text = chatRunState.buffers.get(clientRunId)?.trim() ?? ""; const buffer = chatRunState.phaseBuffers.get(clientRunId);
chatRunState.buffers.delete(clientRunId); chatRunState.phaseBuffers.delete(clientRunId);
chatRunState.deltaSentAt.delete(clientRunId); chatRunState.deltaSentAt.delete(clientRunId);
if (jobState === "done") { if (jobState === "done") {
// Build final content from phases, trimming each phase's content
const content: Array<{
type: string;
thinking?: string;
text?: string;
phaseIndex?: number;
}> = [];
if (buffer) {
for (let idx = 0; idx < buffer.phases.length; idx++) {
const phase = buffer.phases[idx];
const trimmed = phase.content.trim();
if (!trimmed) continue;
content.push({
type: phase.type,
[phase.type === "thinking" ? "thinking" : "text"]: trimmed,
phaseIndex: idx,
});
}
}
const hasContent = content.length > 0;
const payload = { const payload = {
runId: clientRunId, runId: clientRunId,
sessionKey, sessionKey,
seq, seq,
state: "final" as const, state: "final" as const,
message: text message: hasContent
? { ? {
role: "assistant", role: "assistant",
content: [{ type: "text", text }], content,
timestamp: Date.now(), timestamp: Date.now(),
} }
: undefined, : undefined,
@ -224,8 +387,12 @@ export function createAgentEventHandler({
// Include sessionKey so Control UI can filter tool streams per session. // Include sessionKey so Control UI can filter tool streams per session.
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt; const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
const last = agentRunSeq.get(evt.runId) ?? 0; const last = agentRunSeq.get(evt.runId) ?? 0;
if (evt.stream === "tool" && !shouldEmitToolEvents(evt.runId, sessionKey)) { const emitToolToNode = shouldEmitToolEvents(evt.runId, sessionKey);
if (evt.stream === "tool" && !emitToolToNode) {
agentRunSeq.set(evt.runId, evt.seq); agentRunSeq.set(evt.runId, evt.seq);
// Always broadcast tool events to webchat clients - let UI decide visibility.
// Only skip nodeSendToSession for external channels when verbose is off.
broadcast("agent", agentPayload);
return; return;
} }
if (evt.seq !== last + 1) { if (evt.seq !== last + 1) {
@ -249,7 +416,17 @@ export function createAgentEventHandler({
if (sessionKey) { if (sessionKey) {
nodeSendToSession(sessionKey, "agent", agentPayload); nodeSendToSession(sessionKey, "agent", agentPayload);
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") { // Accumulate thinking events and emit thinking-only delta when no text yet
if (!isAborted && evt.stream === "thinking" && typeof evt.data?.text === "string") {
let buffer = chatRunState.phaseBuffers.get(clientRunId);
if (!buffer) {
buffer = createPhaseBuffer();
chatRunState.phaseBuffers.set(clientRunId, buffer);
}
appendToPhaseBuffer(buffer, "thinking", evt.data.text);
// Emit thinking delta
emitThinkingDelta(sessionKey, clientRunId, evt.seq);
} else if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text); emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) { } else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
if (chatLink) { if (chatLink) {
@ -277,7 +454,7 @@ export function createAgentEventHandler({
} else if (isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) { } else if (isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
chatRunState.abortedRuns.delete(clientRunId); chatRunState.abortedRuns.delete(clientRunId);
chatRunState.abortedRuns.delete(evt.runId); chatRunState.abortedRuns.delete(evt.runId);
chatRunState.buffers.delete(clientRunId); chatRunState.phaseBuffers.delete(clientRunId);
chatRunState.deltaSentAt.delete(clientRunId); chatRunState.deltaSentAt.delete(clientRunId);
if (chatLink) { if (chatLink) {
chatRunState.registry.remove(evt.runId, clientRunId, sessionKey); chatRunState.registry.remove(evt.runId, clientRunId, sessionKey);

View File

@ -122,3 +122,36 @@
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
margin: 1em 0; margin: 1em 0;
} }
/* =============================================
PHASE SEPARATORS
Visual separation between thinking and text phases
============================================= */
.chat-thinking-phase {
border-left: 2px solid var(--accent);
padding-left: 0.75rem;
margin-bottom: 0.5rem;
opacity: 0.85;
}
.chat-text-phase {
margin-bottom: 0.5rem;
}
/* Add visual separator between phases of different types */
.chat-thinking-phase + .chat-text-phase,
.chat-text-phase + .chat-thinking-phase {
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
}
:root[data-theme="light"] .chat-thinking-phase {
border-left-color: var(--accent);
}
:root[data-theme="light"] .chat-thinking-phase + .chat-text-phase,
:root[data-theme="light"] .chat-text-phase + .chat-thinking-phase {
border-top-color: var(--border);
}

View File

@ -114,6 +114,36 @@
margin-top: 4px; margin-top: 4px;
} }
.chat-tool-card__status-text.error {
color: var(--danger);
}
/* Error state for tool cards */
.chat-tool-card--error {
border-color: var(--danger);
background: color-mix(in srgb, var(--danger) 5%, var(--card));
}
.chat-tool-card--error:hover {
border-color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, var(--card));
}
.chat-tool-card--error .chat-tool-card__status {
color: var(--danger);
}
.chat-tool-card--error .chat-tool-card__action {
color: var(--danger);
}
.chat-tool-card--error .chat-tool-card__preview.error,
.chat-tool-card--error .chat-tool-card__inline.error {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 8%, var(--secondary));
border-color: color-mix(in srgb, var(--danger) 30%, var(--border));
}
.chat-tool-card__detail { .chat-tool-card__detail {
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);

View File

@ -131,6 +131,8 @@ export function connectGateway(host: GatewayHost) {
// Any in-flight run's final event was lost during the disconnect window. // Any in-flight run's final event was lost during the disconnect window.
host.chatRunId = null; host.chatRunId = null;
(host as unknown as { chatStream: string | null }).chatStream = null; (host as unknown as { chatStream: string | null }).chatStream = null;
(host as unknown as { chatThinkingStream: string | null }).chatThinkingStream = null;
(host as unknown as { chatStreamPhases: Array<{ type: "thinking" | "text"; content: string }> }).chatStreamPhases = [];
(host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null; (host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null;
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]); resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void loadAssistantIdentity(host as unknown as MoltbotApp); void loadAssistantIdentity(host as unknown as MoltbotApp);

View File

@ -433,6 +433,8 @@ export function renderApp(state: AppViewState) {
state.chatMessage = ""; state.chatMessage = "";
state.chatAttachments = []; state.chatAttachments = [];
state.chatStream = null; state.chatStream = null;
state.chatThinkingStream = null;
state.chatStreamPhases = [];
state.chatStreamStartedAt = null; state.chatStreamStartedAt = null;
state.chatRunId = null; state.chatRunId = null;
state.chatQueue = []; state.chatQueue = [];
@ -456,6 +458,8 @@ export function renderApp(state: AppViewState) {
messages: state.chatMessages, messages: state.chatMessages,
toolMessages: state.chatToolMessages, toolMessages: state.chatToolMessages,
stream: state.chatStream, stream: state.chatStream,
thinkingStream: state.chatThinkingStream,
streamPhases: state.chatStreamPhases,
streamStartedAt: state.chatStreamStartedAt, streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage, draft: state.chatMessage,
queue: state.chatQueue, queue: state.chatQueue,

View File

@ -20,6 +20,7 @@ export type ToolStreamEntry = {
name: string; name: string;
args?: unknown; args?: unknown;
output?: string; output?: string;
isError?: boolean;
startedAt: number; startedAt: number;
updatedAt: number; updatedAt: number;
message: Record<string, unknown>; message: Record<string, unknown>;
@ -82,11 +83,12 @@ function buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown>
name: entry.name, name: entry.name,
arguments: entry.args ?? {}, arguments: entry.args ?? {},
}); });
if (entry.output) { if (entry.output || entry.isError) {
content.push({ content.push({
type: "toolresult", type: "toolresult",
name: entry.name, name: entry.name,
text: entry.output, text: entry.output ?? (entry.isError ? "Error (no message)" : undefined),
isError: entry.isError,
}); });
} }
return { return {
@ -205,12 +207,18 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
const name = typeof data.name === "string" ? data.name : "tool"; const name = typeof data.name === "string" ? data.name : "tool";
const phase = typeof data.phase === "string" ? data.phase : ""; const phase = typeof data.phase === "string" ? data.phase : "";
const args = phase === "start" ? data.args : undefined; const args = phase === "start" ? data.args : undefined;
const output = const isError = data.isError === true;
phase === "update"
? formatToolOutput(data.partialResult) // Extract output: check for error first (failed tools), then result/partialResult
: phase === "result" // Supports both `result` (Pi runtime) and `resultText` (CCSDK) field names.
? formatToolOutput(data.result) let output: string | null | undefined;
: undefined; if (phase === "result" && isError && typeof data.error === "string") {
output = data.error;
} else if (phase === "update") {
output = formatToolOutput(data.partialResult);
} else if (phase === "result") {
output = formatToolOutput(data.result ?? data.resultText);
}
const now = Date.now(); const now = Date.now();
let entry = host.toolStreamById.get(toolCallId); let entry = host.toolStreamById.get(toolCallId);
@ -221,7 +229,8 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
sessionKey, sessionKey,
name, name,
args, args,
output, output: output ?? undefined,
isError,
startedAt: typeof payload.ts === "number" ? payload.ts : now, startedAt: typeof payload.ts === "number" ? payload.ts : now,
updatedAt: now, updatedAt: now,
message: {}, message: {},
@ -231,7 +240,8 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
} else { } else {
entry.name = name; entry.name = name;
if (args !== undefined) entry.args = args; if (args !== undefined) entry.args = args;
if (output !== undefined) entry.output = output; if (output !== undefined) entry.output = output ?? undefined;
if (isError) entry.isError = true;
entry.updatedAt = now; entry.updatedAt = now;
} }

View File

@ -53,6 +53,8 @@ export type AppViewState = {
chatMessages: unknown[]; chatMessages: unknown[];
chatToolMessages: unknown[]; chatToolMessages: unknown[];
chatStream: string | null; chatStream: string | null;
chatThinkingStream: string | null;
chatStreamPhases: Array<{ type: "thinking" | "text"; content: string }>;
chatRunId: string | null; chatRunId: string | null;
chatAvatarUrl: string | null; chatAvatarUrl: string | null;
chatThinkingLevel: string | null; chatThinkingLevel: string | null;

View File

@ -123,6 +123,8 @@ export class MoltbotApp extends LitElement {
@state() chatMessages: unknown[] = []; @state() chatMessages: unknown[] = [];
@state() chatToolMessages: unknown[] = []; @state() chatToolMessages: unknown[] = [];
@state() chatStream: string | null = null; @state() chatStream: string | null = null;
@state() chatThinkingStream: string | null = null;
@state() chatStreamPhases: Array<{ type: "thinking" | "text"; content: string }> = [];
@state() chatStreamStartedAt: number | null = null; @state() chatStreamStartedAt: number | null = null;
@state() chatRunId: string | null = null; @state() chatRunId: string | null = null;
@state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null; @state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null;

View File

@ -70,17 +70,56 @@ export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
`; `;
} }
export type StreamPhase = {
type: "thinking" | "text";
content: string;
};
export type StreamingGroupOptions = {
phases?: StreamPhase[];
thinking?: string | null;
showReasoning?: boolean;
startedAt: number;
};
export function renderStreamingGroup( export function renderStreamingGroup(
text: string, text: string,
startedAt: number, opts: StreamingGroupOptions,
onOpenSidebar?: (content: string) => void, onOpenSidebar?: (content: string) => void,
assistant?: AssistantIdentity, assistant?: AssistantIdentity,
) { ) {
const timestamp = new Date(startedAt).toLocaleTimeString([], { const timestamp = new Date(opts.startedAt).toLocaleTimeString([], {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
}); });
const name = assistant?.name ?? "Assistant"; const name = assistant?.name ?? "Assistant";
const showReasoning = opts.showReasoning ?? false;
// Build content array - prefer phases if available for proper visual separation
const content: Array<{ type: string; thinking?: string; text?: string }> = [];
if (opts.phases && opts.phases.length > 0) {
// Use phase-aware rendering
for (const phase of opts.phases) {
if (phase.type === "thinking" && showReasoning) {
content.push({ type: "thinking", thinking: phase.content });
} else if (phase.type === "text") {
content.push({ type: "text", text: phase.content });
}
}
} else {
// Fallback to legacy behavior
if (opts.thinking && showReasoning) {
content.push({ type: "thinking", thinking: opts.thinking });
}
if (text) {
content.push({ type: "text", text });
}
}
// If we have no content at all, show reading indicator
if (content.length === 0) {
return renderReadingIndicatorGroup(assistant);
}
return html` return html`
<div class="chat-group assistant"> <div class="chat-group assistant">
@ -89,10 +128,10 @@ export function renderStreamingGroup(
${renderGroupedMessage( ${renderGroupedMessage(
{ {
role: "assistant", role: "assistant",
content: [{ type: "text", text }], content,
timestamp: startedAt, timestamp: opts.startedAt,
}, },
{ isStreaming: true, showReasoning: false }, { isStreaming: true, showReasoning },
onOpenSidebar, onOpenSidebar,
)} )}
<div class="chat-group-footer"> <div class="chat-group-footer">
@ -224,6 +263,47 @@ function renderMessageImages(images: ImageBlock[]) {
`; `;
} }
function hasMultiplePhaseTypes(content: unknown[]): boolean {
let hasThinking = false;
let hasText = false;
for (const item of content) {
if (typeof item !== "object" || item === null) continue;
const c = item as { type?: string };
if (c.type === "thinking") hasThinking = true;
if (c.type === "text") hasText = true;
if (hasThinking && hasText) return true;
}
return false;
}
function renderPhaseContent(
content: unknown[],
opts: { showReasoning: boolean },
) {
// Check if we have multiple phase types for visual separation
const usePhaseClasses = hasMultiplePhaseTypes(content);
return content.map((item) => {
if (typeof item !== "object" || item === null) return nothing;
const c = item as { type?: string; thinking?: string; text?: string };
if (c.type === "thinking" && typeof c.thinking === "string" && opts.showReasoning) {
const reasoningMarkdown = formatReasoningMarkdown(c.thinking);
const className = usePhaseClasses ? "chat-thinking-phase" : "chat-thinking";
return html`<div class="${className}">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`;
}
if (c.type === "text" && typeof c.text === "string") {
const className = usePhaseClasses ? "chat-text-phase" : "chat-text";
return html`<div class="${className}">${unsafeHTML(toSanitizedMarkdownHtml(c.text))}</div>`;
}
return nothing;
});
}
function renderGroupedMessage( function renderGroupedMessage(
message: unknown, message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean }, opts: { isStreaming: boolean; showReasoning: boolean },
@ -243,6 +323,19 @@ function renderGroupedMessage(
const images = extractImages(message); const images = extractImages(message);
const hasImages = images.length > 0; const hasImages = images.length > 0;
// Check if content is already phase-structured (for streaming)
const content = m.content;
const isPhaseStructured =
Array.isArray(content) &&
content.length > 0 &&
content.some(
(item) =>
typeof item === "object" &&
item !== null &&
((item as { type?: string }).type === "thinking" ||
(item as { type?: string }).type === "text"),
);
const extractedText = extractTextCached(message); const extractedText = extractTextCached(message);
const extractedThinking = const extractedThinking =
opts.showReasoning && role === "assistant" opts.showReasoning && role === "assistant"
@ -272,6 +365,19 @@ function renderGroupedMessage(
if (!markdown && !hasToolCards && !hasImages) return nothing; if (!markdown && !hasToolCards && !hasImages) return nothing;
// Use phase-aware rendering when content is structured with phases
if (isPhaseStructured && Array.isArray(content)) {
return html`
<div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
${renderMessageImages(images)}
${renderPhaseContent(content, { showReasoning: opts.showReasoning })}
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div>
`;
}
// Fallback to legacy rendering
return html` return html`
<div class="${bubbleClasses}"> <div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing} ${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}

View File

@ -35,7 +35,8 @@ export function extractToolCards(message: unknown): ToolCard[] {
if (kind !== "toolresult" && kind !== "tool_result") continue; if (kind !== "toolresult" && kind !== "tool_result") continue;
const text = extractToolText(item); const text = extractToolText(item);
const name = typeof item.name === "string" ? item.name : "tool"; const name = typeof item.name === "string" ? item.name : "tool";
cards.push({ kind: "result", name, text }); const isError = item.isError === true;
cards.push({ kind: "result", name, text, isError });
} }
if ( if (
@ -60,17 +61,22 @@ export function renderToolCardSidebar(
const display = resolveToolDisplay({ name: card.name, args: card.args }); const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display); const detail = formatToolDetail(display);
const hasText = Boolean(card.text?.trim()); const hasText = Boolean(card.text?.trim());
const isError = card.isError === true;
const canClick = Boolean(onOpenSidebar); const canClick = Boolean(onOpenSidebar);
const handleClick = canClick const handleClick = canClick
? () => { ? () => {
if (hasText) { if (hasText) {
onOpenSidebar!(formatToolOutputForSidebar(card.text!)); const prefix = isError ? "## Error\n\n" : "";
onOpenSidebar!(prefix + formatToolOutputForSidebar(card.text!));
return; return;
} }
const status = isError
? "*Tool failed with no error message.*"
: "*No output — tool completed successfully.*";
const info = `## ${display.label}\n\n${ const info = `## ${display.label}\n\n${
detail ? `**Command:** \`${detail}\`\n\n` : "" detail ? `**Command:** \`${detail}\`\n\n` : ""
}*No output tool completed successfully.*`; }${status}`;
onOpenSidebar!(info); onOpenSidebar!(info);
} }
: undefined; : undefined;
@ -80,9 +86,12 @@ export function renderToolCardSidebar(
const showInline = hasText && isShort; const showInline = hasText && isShort;
const isEmpty = !hasText; const isEmpty = !hasText;
const statusIcon = isError ? icons.x : icons.check;
const statusClass = isError ? "chat-tool-card--error" : "";
return html` return html`
<div <div
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""}" class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""} ${statusClass}"
@click=${handleClick} @click=${handleClick}
role=${canClick ? "button" : nothing} role=${canClick ? "button" : nothing}
tabindex=${canClick ? "0" : nothing} tabindex=${canClick ? "0" : nothing}
@ -100,21 +109,21 @@ export function renderToolCardSidebar(
<span>${display.label}</span> <span>${display.label}</span>
</div> </div>
${canClick ${canClick
? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${icons.check}</span>` ? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${statusIcon}</span>`
: nothing} : nothing}
${isEmpty && !canClick ? html`<span class="chat-tool-card__status">${icons.check}</span>` : nothing} ${isEmpty && !canClick ? html`<span class="chat-tool-card__status">${statusIcon}</span>` : nothing}
</div> </div>
${detail ${detail
? html`<div class="chat-tool-card__detail">${detail}</div>` ? html`<div class="chat-tool-card__detail">${detail}</div>`
: nothing} : nothing}
${isEmpty ${isEmpty
? html`<div class="chat-tool-card__status-text muted">Completed</div>` ? html`<div class="chat-tool-card__status-text ${isError ? "error" : "muted"}">${isError ? "Failed" : "Completed"}</div>`
: nothing} : nothing}
${showCollapsed ${showCollapsed
? html`<div class="chat-tool-card__preview mono">${getTruncatedPreview(card.text!)}</div>` ? html`<div class="chat-tool-card__preview mono ${isError ? "error" : ""}">${getTruncatedPreview(card.text!)}</div>`
: nothing} : nothing}
${showInline ${showInline
? html`<div class="chat-tool-card__inline mono">${card.text}</div>` ? html`<div class="chat-tool-card__inline mono ${isError ? "error" : ""}">${card.text}</div>`
: nothing} : nothing}
</div> </div>
`; `;

View File

@ -1,8 +1,13 @@
import { extractText } from "../chat/message-extract"; import { extractText, extractThinking } from "../chat/message-extract";
import type { GatewayBrowserClient } from "../gateway"; import type { GatewayBrowserClient } from "../gateway";
import { generateUUID } from "../uuid"; import { generateUUID } from "../uuid";
import type { ChatAttachment } from "../ui-types"; import type { ChatAttachment } from "../ui-types";
export type StreamPhase = {
type: "thinking" | "text";
content: string;
};
export type ChatState = { export type ChatState = {
client: GatewayBrowserClient | null; client: GatewayBrowserClient | null;
connected: boolean; connected: boolean;
@ -15,6 +20,8 @@ export type ChatState = {
chatAttachments: ChatAttachment[]; chatAttachments: ChatAttachment[];
chatRunId: string | null; chatRunId: string | null;
chatStream: string | null; chatStream: string | null;
chatThinkingStream: string | null;
chatStreamPhases: StreamPhase[];
chatStreamStartedAt: number | null; chatStreamStartedAt: number | null;
lastError: string | null; lastError: string | null;
}; };
@ -155,6 +162,23 @@ export async function abortChatRun(state: ChatState): Promise<boolean> {
} }
} }
function parseStreamPhases(message: unknown): StreamPhase[] | null {
const m = message as { content?: unknown[] } | undefined;
if (!m?.content || !Array.isArray(m.content)) return null;
const phases: StreamPhase[] = [];
for (const block of m.content) {
if (typeof block !== "object" || block === null) continue;
const b = block as { type?: string; thinking?: string; text?: string };
if (b.type === "thinking" && typeof b.thinking === "string") {
phases.push({ type: "thinking", content: b.thinking });
} else if (b.type === "text" && typeof b.text === "string") {
phases.push({ type: "text", content: b.text });
}
}
return phases.length > 0 ? phases : null;
}
export function handleChatEvent( export function handleChatEvent(
state: ChatState, state: ChatState,
payload?: ChatEventPayload, payload?: ChatEventPayload,
@ -174,23 +198,57 @@ export function handleChatEvent(
} }
if (payload.state === "delta") { if (payload.state === "delta") {
const next = extractText(payload.message); // Try to parse phase-aware content first
if (typeof next === "string") { const phases = parseStreamPhases(payload.message);
if (phases) {
state.chatStreamPhases = phases;
// Also update legacy fields for backward compat
const textPhases = phases.filter((p) => p.type === "text");
const thinkingPhases = phases.filter((p) => p.type === "thinking");
if (textPhases.length > 0) {
const combinedText = textPhases.map((p) => p.content).join("");
const current = state.chatStream ?? ""; const current = state.chatStream ?? "";
if (!current || next.length >= current.length) { if (!current || combinedText.length >= current.length) {
state.chatStream = next; state.chatStream = combinedText;
}
}
if (thinkingPhases.length > 0) {
state.chatThinkingStream = thinkingPhases.map((p) => p.content).join("");
}
} else {
// Fallback to legacy extraction
const nextText = extractText(payload.message);
const nextThinking = extractThinking(payload.message);
// Update text if present and longer
if (typeof nextText === "string") {
const current = state.chatStream ?? "";
if (!current || nextText.length >= current.length) {
state.chatStream = nextText;
}
}
// Update thinking if present
if (typeof nextThinking === "string") {
state.chatThinkingStream = nextThinking;
} }
} }
} else if (payload.state === "final") { } else if (payload.state === "final") {
state.chatStream = null; state.chatStream = null;
state.chatThinkingStream = null;
state.chatStreamPhases = [];
state.chatRunId = null; state.chatRunId = null;
state.chatStreamStartedAt = null; state.chatStreamStartedAt = null;
} else if (payload.state === "aborted") { } else if (payload.state === "aborted") {
state.chatStream = null; state.chatStream = null;
state.chatThinkingStream = null;
state.chatStreamPhases = [];
state.chatRunId = null; state.chatRunId = null;
state.chatStreamStartedAt = null; state.chatStreamStartedAt = null;
} else if (payload.state === "error") { } else if (payload.state === "error") {
state.chatStream = null; state.chatStream = null;
state.chatThinkingStream = null;
state.chatStreamPhases = [];
state.chatRunId = null; state.chatRunId = null;
state.chatStreamStartedAt = null; state.chatStreamStartedAt = null;
state.lastError = payload.errorMessage ?? "chat error"; state.lastError = payload.errorMessage ?? "chat error";

View File

@ -40,4 +40,5 @@ export type ToolCard = {
name: string; name: string;
args?: unknown; args?: unknown;
text?: string; text?: string;
isError?: boolean;
}; };

View File

@ -13,6 +13,7 @@ import {
renderMessageGroup, renderMessageGroup,
renderReadingIndicatorGroup, renderReadingIndicatorGroup,
renderStreamingGroup, renderStreamingGroup,
type StreamPhase,
} from "../chat/grouped-render"; } from "../chat/grouped-render";
import { renderMarkdownSidebar } from "./markdown-sidebar"; import { renderMarkdownSidebar } from "./markdown-sidebar";
import "../components/resizable-divider"; import "../components/resizable-divider";
@ -35,6 +36,8 @@ export type ChatProps = {
messages: unknown[]; messages: unknown[];
toolMessages: unknown[]; toolMessages: unknown[];
stream: string | null; stream: string | null;
thinkingStream: string | null;
streamPhases?: StreamPhase[];
streamStartedAt: number | null; streamStartedAt: number | null;
assistantAvatarUrl?: string | null; assistantAvatarUrl?: string | null;
draft: string; draft: string;
@ -219,7 +222,12 @@ export function renderChat(props: ChatProps) {
if (item.kind === "stream") { if (item.kind === "stream") {
return renderStreamingGroup( return renderStreamingGroup(
item.text, item.text,
item.startedAt, {
phases: props.streamPhases,
thinking: props.thinkingStream,
showReasoning,
startedAt: item.startedAt,
},
props.onOpenSidebar, props.onOpenSidebar,
assistantIdentity, assistantIdentity,
); );
@ -456,7 +464,9 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
if (props.stream !== null) { if (props.stream !== null) {
const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`; const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`;
if (props.stream.trim().length > 0) { const hasTextContent = props.stream.trim().length > 0;
const hasPhaseContent = props.streamPhases && props.streamPhases.length > 0;
if (hasTextContent || hasPhaseContent) {
items.push({ items.push({
kind: "stream", kind: "stream",
key, key,