fix: UX improvement around reasoning text, fixup tool invocation card in Chat UI
This commit is contained in:
parent
b927361650
commit
45bff6a2e4
@ -76,24 +76,27 @@ Legacy Pi/Tau session folders are **not** read.
|
||||
|
||||
## Claude Agent SDK Runtime
|
||||
|
||||
When using the Claude Agent SDK (`ccsdk`) runtime, Moltbot stores SDK-specific
|
||||
configuration and sessions in a per-agent `.claude` directory:
|
||||
When using the Claude Agent SDK (`ccsdk`) runtime, authentication is handled
|
||||
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).
|
||||
|
||||
```
|
||||
~/.clawdbot/agents/<agentId>/.claude/
|
||||
├── sessions/ # SDK native session files
|
||||
├── settings.json # Per-agent SDK settings (optional)
|
||||
├── CLAUDE.md # Per-agent memory/instructions (optional)
|
||||
├── skills/ # Per-agent skills (optional)
|
||||
└── commands/ # Per-agent slash commands (optional)
|
||||
Alternatively, you can provide an explicit `ANTHROPIC_API_KEY` in your per-agent
|
||||
provider configuration in `moltbot.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
"agents": {
|
||||
"main": {
|
||||
"provider": {
|
||||
"env": { "ANTHROPIC_API_KEY": "sk-ant-..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This separation ensures Moltbot agent sessions are stored separately from
|
||||
standard Claude Code CLI sessions (`~/.claude/`), and allows per-agent
|
||||
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.
|
||||
Session transcripts for SDK-based runs are stored in Moltbot's standard
|
||||
session directory (`~/.clawdbot/agents/<agentId>/sessions/`).
|
||||
|
||||
## Steering while streaming
|
||||
|
||||
|
||||
@ -24,7 +24,6 @@ import type { SdkConversationTurn, SdkRunnerResult } from "./types.js";
|
||||
import { createMoltbotCodingTools } from "../pi-tools.js";
|
||||
import { resolveMoltbotAgentDir } from "../agent-paths.js";
|
||||
import { resolveModelAuthMode } from "../model-auth.js";
|
||||
import { resolveCcSdkConfigDirForAgent } from "../../config/sessions/paths.js";
|
||||
import { convertClientToolsForSdk } from "./client-tool-bridge.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/ccsdk-runtime");
|
||||
@ -210,7 +209,7 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
|
||||
const ccsdkOpts = params.ccsdkOptions ?? {};
|
||||
|
||||
// Resolve effective model (from params or provider config)
|
||||
const effectiveModel = params.model
|
||||
const _effectiveModel = params.model
|
||||
? `${params.provider ?? "anthropic"}/${params.model}`
|
||||
: (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
|
||||
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({
|
||||
// ─── Core shared params (spread) ────────────────────────────────────────
|
||||
runId: params.runId,
|
||||
@ -284,7 +279,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
|
||||
timeoutMs: params.timeoutMs,
|
||||
abortSignal: params.abortSignal,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ccsdkConfigDir,
|
||||
|
||||
// ─── Messaging & sender context (shared) ────────────────────────────────
|
||||
messageChannel: params.messageChannel,
|
||||
|
||||
@ -938,7 +938,8 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
...(normalizedName.rawName ? { rawName: normalizedName.rawName } : {}),
|
||||
...(argsForEvent ? { args: argsForEvent } : {}),
|
||||
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).
|
||||
|
||||
@ -19,7 +19,6 @@
|
||||
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
import { Value } from "@sinclair/typebox/value";
|
||||
import * as z from "zod/v4-mini";
|
||||
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
|
||||
@ -213,14 +213,6 @@ export type SdkRunnerParams = {
|
||||
*/
|
||||
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.
|
||||
* Since the SDK is stateless, prior turns are injected as context
|
||||
|
||||
@ -12,8 +12,8 @@ export function logToolEvent(evt: AgentEventData, runId?: string): void {
|
||||
if (evt.stream !== "tool") return;
|
||||
|
||||
const phase = evt.data.phase as string | undefined;
|
||||
const toolName = String(evt.data.name ?? "unknown");
|
||||
const toolCallId = String(evt.data.toolCallId ?? "");
|
||||
const toolName = (evt.data.name as string | undefined) ?? "unknown";
|
||||
const toolCallId = (evt.data.toolCallId as string | undefined) ?? "";
|
||||
|
||||
switch (phase) {
|
||||
case "start":
|
||||
|
||||
@ -71,25 +71,3 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
||||
if (store.startsWith("~")) return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir()));
|
||||
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");
|
||||
}
|
||||
|
||||
@ -28,6 +28,16 @@ export type ChatRunEntry = {
|
||||
clientRunId: string;
|
||||
};
|
||||
|
||||
export type StreamPhase = {
|
||||
type: "thinking" | "text";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type PhaseBuffer = {
|
||||
phases: StreamPhase[];
|
||||
lastPhaseType: "thinking" | "text" | null;
|
||||
};
|
||||
|
||||
export type ChatRunRegistry = {
|
||||
add: (sessionId: string, entry: ChatRunEntry) => void;
|
||||
peek: (sessionId: string) => ChatRunEntry | undefined;
|
||||
@ -80,27 +90,114 @@ export function createChatRunRegistry(): ChatRunRegistry {
|
||||
|
||||
export type ChatRunState = {
|
||||
registry: ChatRunRegistry;
|
||||
phaseBuffers: Map<string, PhaseBuffer>;
|
||||
/** @deprecated Use phaseBuffers for phase-aware rendering. This provides combined text for backward compatibility. */
|
||||
buffers: Map<string, string>;
|
||||
deltaSentAt: Map<string, number>;
|
||||
abortedRuns: Map<string, number>;
|
||||
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 {
|
||||
const registry = createChatRunRegistry();
|
||||
const buffers = new Map<string, string>();
|
||||
const phaseBuffers = new Map<string, PhaseBuffer>();
|
||||
const deltaSentAt = 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 = () => {
|
||||
registry.clear();
|
||||
buffers.clear();
|
||||
phaseBuffers.clear();
|
||||
deltaSentAt.clear();
|
||||
abortedRuns.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
registry,
|
||||
phaseBuffers,
|
||||
buffers,
|
||||
deltaSentAt,
|
||||
abortedRuns,
|
||||
@ -133,12 +230,30 @@ export function createAgentEventHandler({
|
||||
resolveSessionKeyForRun,
|
||||
clearAgentRunContext,
|
||||
}: 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) => {
|
||||
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 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,
|
||||
@ -146,7 +261,34 @@ export function createAgentEventHandler({
|
||||
state: "delta" as const,
|
||||
message: {
|
||||
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,
|
||||
},
|
||||
};
|
||||
@ -164,19 +306,40 @@ export function createAgentEventHandler({
|
||||
jobState: "done" | "error",
|
||||
error?: unknown,
|
||||
) => {
|
||||
const text = chatRunState.buffers.get(clientRunId)?.trim() ?? "";
|
||||
chatRunState.buffers.delete(clientRunId);
|
||||
const buffer = chatRunState.phaseBuffers.get(clientRunId);
|
||||
chatRunState.phaseBuffers.delete(clientRunId);
|
||||
chatRunState.deltaSentAt.delete(clientRunId);
|
||||
|
||||
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 = {
|
||||
runId: clientRunId,
|
||||
sessionKey,
|
||||
seq,
|
||||
state: "final" as const,
|
||||
message: text
|
||||
message: hasContent
|
||||
? {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
: undefined,
|
||||
@ -224,8 +387,12 @@ export function createAgentEventHandler({
|
||||
// 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.stream === "tool" && !shouldEmitToolEvents(evt.runId, sessionKey)) {
|
||||
const emitToolToNode = shouldEmitToolEvents(evt.runId, sessionKey);
|
||||
if (evt.stream === "tool" && !emitToolToNode) {
|
||||
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;
|
||||
}
|
||||
if (evt.seq !== last + 1) {
|
||||
@ -249,7 +416,17 @@ export function createAgentEventHandler({
|
||||
|
||||
if (sessionKey) {
|
||||
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);
|
||||
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
|
||||
if (chatLink) {
|
||||
@ -277,7 +454,7 @@ export function createAgentEventHandler({
|
||||
} else if (isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
|
||||
chatRunState.abortedRuns.delete(clientRunId);
|
||||
chatRunState.abortedRuns.delete(evt.runId);
|
||||
chatRunState.buffers.delete(clientRunId);
|
||||
chatRunState.phaseBuffers.delete(clientRunId);
|
||||
chatRunState.deltaSentAt.delete(clientRunId);
|
||||
if (chatLink) {
|
||||
chatRunState.registry.remove(evt.runId, clientRunId, sessionKey);
|
||||
|
||||
@ -122,3 +122,36 @@
|
||||
border-top: 1px solid var(--border);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -114,6 +114,36 @@
|
||||
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 {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
|
||||
@ -131,6 +131,8 @@ export function connectGateway(host: GatewayHost) {
|
||||
// Any in-flight run's final event was lost during the disconnect window.
|
||||
host.chatRunId = 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;
|
||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||
void loadAssistantIdentity(host as unknown as MoltbotApp);
|
||||
|
||||
@ -433,6 +433,8 @@ export function renderApp(state: AppViewState) {
|
||||
state.chatMessage = "";
|
||||
state.chatAttachments = [];
|
||||
state.chatStream = null;
|
||||
state.chatThinkingStream = null;
|
||||
state.chatStreamPhases = [];
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
state.chatQueue = [];
|
||||
@ -456,6 +458,8 @@ export function renderApp(state: AppViewState) {
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
stream: state.chatStream,
|
||||
thinkingStream: state.chatThinkingStream,
|
||||
streamPhases: state.chatStreamPhases,
|
||||
streamStartedAt: state.chatStreamStartedAt,
|
||||
draft: state.chatMessage,
|
||||
queue: state.chatQueue,
|
||||
|
||||
@ -20,6 +20,7 @@ export type ToolStreamEntry = {
|
||||
name: string;
|
||||
args?: unknown;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
message: Record<string, unknown>;
|
||||
@ -82,11 +83,12 @@ function buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown>
|
||||
name: entry.name,
|
||||
arguments: entry.args ?? {},
|
||||
});
|
||||
if (entry.output) {
|
||||
if (entry.output || entry.isError) {
|
||||
content.push({
|
||||
type: "toolresult",
|
||||
name: entry.name,
|
||||
text: entry.output,
|
||||
text: entry.output ?? (entry.isError ? "Error (no message)" : undefined),
|
||||
isError: entry.isError,
|
||||
});
|
||||
}
|
||||
return {
|
||||
@ -205,12 +207,18 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
|
||||
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 isError = data.isError === true;
|
||||
|
||||
// Extract output: check for error first (failed tools), then result/partialResult
|
||||
// Supports both `result` (Pi runtime) and `resultText` (CCSDK) field names.
|
||||
let output: string | null | 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();
|
||||
let entry = host.toolStreamById.get(toolCallId);
|
||||
@ -221,7 +229,8 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
|
||||
sessionKey,
|
||||
name,
|
||||
args,
|
||||
output,
|
||||
output: output ?? undefined,
|
||||
isError,
|
||||
startedAt: typeof payload.ts === "number" ? payload.ts : now,
|
||||
updatedAt: now,
|
||||
message: {},
|
||||
@ -231,7 +240,8 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
|
||||
} else {
|
||||
entry.name = name;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -53,6 +53,8 @@ export type AppViewState = {
|
||||
chatMessages: unknown[];
|
||||
chatToolMessages: unknown[];
|
||||
chatStream: string | null;
|
||||
chatThinkingStream: string | null;
|
||||
chatStreamPhases: Array<{ type: "thinking" | "text"; content: string }>;
|
||||
chatRunId: string | null;
|
||||
chatAvatarUrl: string | null;
|
||||
chatThinkingLevel: string | null;
|
||||
|
||||
@ -123,6 +123,8 @@ export class MoltbotApp extends LitElement {
|
||||
@state() chatMessages: unknown[] = [];
|
||||
@state() chatToolMessages: unknown[] = [];
|
||||
@state() chatStream: string | null = null;
|
||||
@state() chatThinkingStream: string | null = null;
|
||||
@state() chatStreamPhases: Array<{ type: "thinking" | "text"; content: string }> = [];
|
||||
@state() chatStreamStartedAt: number | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null;
|
||||
|
||||
@ -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(
|
||||
text: string,
|
||||
startedAt: number,
|
||||
opts: StreamingGroupOptions,
|
||||
onOpenSidebar?: (content: string) => void,
|
||||
assistant?: AssistantIdentity,
|
||||
) {
|
||||
const timestamp = new Date(startedAt).toLocaleTimeString([], {
|
||||
const timestamp = new Date(opts.startedAt).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
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`
|
||||
<div class="chat-group assistant">
|
||||
@ -89,10 +128,10 @@ export function renderStreamingGroup(
|
||||
${renderGroupedMessage(
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: startedAt,
|
||||
content,
|
||||
timestamp: opts.startedAt,
|
||||
},
|
||||
{ isStreaming: true, showReasoning: false },
|
||||
{ isStreaming: true, showReasoning },
|
||||
onOpenSidebar,
|
||||
)}
|
||||
<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(
|
||||
message: unknown,
|
||||
opts: { isStreaming: boolean; showReasoning: boolean },
|
||||
@ -243,6 +323,19 @@ function renderGroupedMessage(
|
||||
const images = extractImages(message);
|
||||
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 extractedThinking =
|
||||
opts.showReasoning && role === "assistant"
|
||||
@ -272,6 +365,19 @@ function renderGroupedMessage(
|
||||
|
||||
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`
|
||||
<div class="${bubbleClasses}">
|
||||
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
|
||||
|
||||
@ -35,7 +35,8 @@ export function extractToolCards(message: unknown): ToolCard[] {
|
||||
if (kind !== "toolresult" && kind !== "tool_result") continue;
|
||||
const text = extractToolText(item);
|
||||
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 (
|
||||
@ -60,17 +61,22 @@ export function renderToolCardSidebar(
|
||||
const display = resolveToolDisplay({ name: card.name, args: card.args });
|
||||
const detail = formatToolDetail(display);
|
||||
const hasText = Boolean(card.text?.trim());
|
||||
const isError = card.isError === true;
|
||||
|
||||
const canClick = Boolean(onOpenSidebar);
|
||||
const handleClick = canClick
|
||||
? () => {
|
||||
if (hasText) {
|
||||
onOpenSidebar!(formatToolOutputForSidebar(card.text!));
|
||||
const prefix = isError ? "## Error\n\n" : "";
|
||||
onOpenSidebar!(prefix + formatToolOutputForSidebar(card.text!));
|
||||
return;
|
||||
}
|
||||
const status = isError
|
||||
? "*Tool failed with no error message.*"
|
||||
: "*No output — tool completed successfully.*";
|
||||
const info = `## ${display.label}\n\n${
|
||||
detail ? `**Command:** \`${detail}\`\n\n` : ""
|
||||
}*No output — tool completed successfully.*`;
|
||||
}${status}`;
|
||||
onOpenSidebar!(info);
|
||||
}
|
||||
: undefined;
|
||||
@ -80,9 +86,12 @@ export function renderToolCardSidebar(
|
||||
const showInline = hasText && isShort;
|
||||
const isEmpty = !hasText;
|
||||
|
||||
const statusIcon = isError ? icons.x : icons.check;
|
||||
const statusClass = isError ? "chat-tool-card--error" : "";
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""}"
|
||||
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""} ${statusClass}"
|
||||
@click=${handleClick}
|
||||
role=${canClick ? "button" : nothing}
|
||||
tabindex=${canClick ? "0" : nothing}
|
||||
@ -100,21 +109,21 @@ export function renderToolCardSidebar(
|
||||
<span>${display.label}</span>
|
||||
</div>
|
||||
${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}
|
||||
${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>
|
||||
${detail
|
||||
? html`<div class="chat-tool-card__detail">${detail}</div>`
|
||||
: nothing}
|
||||
${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}
|
||||
${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}
|
||||
${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}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import { extractText } from "../chat/message-extract";
|
||||
import { extractText, extractThinking } from "../chat/message-extract";
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import { generateUUID } from "../uuid";
|
||||
import type { ChatAttachment } from "../ui-types";
|
||||
|
||||
export type StreamPhase = {
|
||||
type: "thinking" | "text";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ChatState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
@ -15,6 +20,8 @@ export type ChatState = {
|
||||
chatAttachments: ChatAttachment[];
|
||||
chatRunId: string | null;
|
||||
chatStream: string | null;
|
||||
chatThinkingStream: string | null;
|
||||
chatStreamPhases: StreamPhase[];
|
||||
chatStreamStartedAt: number | 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(
|
||||
state: ChatState,
|
||||
payload?: ChatEventPayload,
|
||||
@ -174,23 +198,57 @@ export function handleChatEvent(
|
||||
}
|
||||
|
||||
if (payload.state === "delta") {
|
||||
const next = extractText(payload.message);
|
||||
if (typeof next === "string") {
|
||||
const current = state.chatStream ?? "";
|
||||
if (!current || next.length >= current.length) {
|
||||
state.chatStream = next;
|
||||
// Try to parse phase-aware content first
|
||||
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 ?? "";
|
||||
if (!current || combinedText.length >= current.length) {
|
||||
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") {
|
||||
state.chatStream = null;
|
||||
state.chatThinkingStream = null;
|
||||
state.chatStreamPhases = [];
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
} else if (payload.state === "aborted") {
|
||||
state.chatStream = null;
|
||||
state.chatThinkingStream = null;
|
||||
state.chatStreamPhases = [];
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
} else if (payload.state === "error") {
|
||||
state.chatStream = null;
|
||||
state.chatThinkingStream = null;
|
||||
state.chatStreamPhases = [];
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.lastError = payload.errorMessage ?? "chat error";
|
||||
|
||||
@ -40,4 +40,5 @@ export type ToolCard = {
|
||||
name: string;
|
||||
args?: unknown;
|
||||
text?: string;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
renderMessageGroup,
|
||||
renderReadingIndicatorGroup,
|
||||
renderStreamingGroup,
|
||||
type StreamPhase,
|
||||
} from "../chat/grouped-render";
|
||||
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
||||
import "../components/resizable-divider";
|
||||
@ -35,6 +36,8 @@ export type ChatProps = {
|
||||
messages: unknown[];
|
||||
toolMessages: unknown[];
|
||||
stream: string | null;
|
||||
thinkingStream: string | null;
|
||||
streamPhases?: StreamPhase[];
|
||||
streamStartedAt: number | null;
|
||||
assistantAvatarUrl?: string | null;
|
||||
draft: string;
|
||||
@ -219,7 +222,12 @@ export function renderChat(props: ChatProps) {
|
||||
if (item.kind === "stream") {
|
||||
return renderStreamingGroup(
|
||||
item.text,
|
||||
item.startedAt,
|
||||
{
|
||||
phases: props.streamPhases,
|
||||
thinking: props.thinkingStream,
|
||||
showReasoning,
|
||||
startedAt: item.startedAt,
|
||||
},
|
||||
props.onOpenSidebar,
|
||||
assistantIdentity,
|
||||
);
|
||||
@ -456,7 +464,9 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
|
||||
|
||||
if (props.stream !== null) {
|
||||
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({
|
||||
kind: "stream",
|
||||
key,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user