tool execution output

This commit is contained in:
Davit
2026-05-03 16:37:37 +04:00
parent 3b3850a6a2
commit f610a44b95
28 changed files with 1045 additions and 67 deletions
+41 -1
View File
@@ -4,12 +4,29 @@ export interface SseEmitter {
error: (msg: string) => void;
}
export interface ToolStepOutput {
text: string;
isError: boolean;
status?: string | null;
exitCode?: number | null;
durationMs?: number | null;
truncated?: boolean;
}
export interface ToolStep {
id: string;
name: string;
input: Record<string, unknown> | null;
output: ToolStepOutput | null;
}
export interface OpenClawMessage {
externalId: string;
role: string;
text: string;
thinking: string | null;
timestamp: string | null;
toolSteps: ToolStep[] | null;
}
export interface OpenClawSession {
@@ -31,6 +48,18 @@ export interface SessionEntry {
fastMode?: boolean | null;
verboseLevel?: string | null;
reasoningLevel?: string | null;
status?: string;
abortedLastRun?: boolean;
abortReason?: string;
lastInteractionAt?: number;
endedAt?: number;
}
export interface SessionRunStatus {
aborted: boolean;
status: string | null;
reason: string | null;
endedAt: number | null;
}
export type SessionsFile = Record<string, SessionEntry>;
@@ -47,12 +76,23 @@ export interface JsonlThinkingPart {
thinking: string;
}
export interface JsonlToolCallPart {
type: 'toolCall';
id?: string;
name?: string;
arguments?: unknown;
}
export interface JsonlOtherPart {
type: string;
[key: string]: unknown;
}
export type JsonlContentPart = JsonlTextPart | JsonlThinkingPart | JsonlOtherPart;
export type JsonlContentPart =
| JsonlTextPart
| JsonlThinkingPart
| JsonlToolCallPart
| JsonlOtherPart;
export interface JsonlMessageEntry {
type: 'message';
+4
View File
@@ -1,4 +1,5 @@
import { Entity, PrimaryGeneratedColumn, Column, DeleteDateColumn, Index } from 'typeorm';
import { ToolStep } from '../@types/openclaw';
@Entity('messages')
@Index(['conversationId', 'externalId'], { unique: true, where: 'externalId IS NOT NULL' })
@@ -21,6 +22,9 @@ export default class Message {
@Column({ type: 'simple-json', default: '[]' })
files: { filename: string; originalName: string; mimetype: string; size: number; url: string }[];
@Column({ type: 'simple-json', nullable: true, default: null })
toolSteps: ToolStep[] | null;
@Column({ type: 'text', default: 'user' })
role: 'user' | 'assistant';
+1
View File
@@ -267,6 +267,7 @@ const sync: RequestHandler = async (req, res, next) => {
externalId: m.externalId,
text: m.text,
thinking: m.thinking || null,
toolSteps: m.toolSteps && m.toolSteps.length > 0 ? m.toolSteps : null,
files: [],
role: m.role as MessageRole,
createdBy: req.user!._id,
+25
View File
@@ -0,0 +1,25 @@
/* The other route modules in this folder export multiple handlers, so
* `import * as controller` reads naturally there. This one just exposes
* a single GET — keeping the same pattern for consistency rather than
* switching to a default export. */
/* eslint-disable import/prefer-default-export */
import { RequestHandler } from 'express';
import { gateway } from '../../services/openclawGateway';
/**
* GET /api/gateway/status — cheap, unauthenticated snapshot of the
* WebSocket connection to the OpenClaw daemon. Polled by the chat UI
* (5 s cadence) to render the green/yellow/red dot in the message input.
*
* Returning unauthenticated because the response carries no PII (just a
* connection state enum + counter) and the chat shell needs it before
* the user is necessarily logged in to render a useful "gateway down"
* cue. If we ever add detailed timing/credentials in here, gate it.
*/
export const status: RequestHandler = (_req, res, next) => {
try {
return res.json(gateway.getStatus());
} catch (error) {
return next(error);
}
};
+8
View File
@@ -0,0 +1,8 @@
import Router from 'express';
import * as controller from './controller';
const router = Router();
router.route('/gateway/status').get(controller.status);
export default router;
+2
View File
@@ -9,6 +9,7 @@ import plugin from './plugin';
import skill from './skill';
import cron from './cron';
import update from './update';
import gateway from './gateway';
const router = Router();
@@ -22,5 +23,6 @@ router.use(skill);
router.use(cron);
router.use(auth);
router.use(update);
router.use(gateway);
export default router;
+90 -9
View File
@@ -1,7 +1,7 @@
import fs from 'fs';
import path from 'path';
import { RequestHandler } from 'express';
import { LessThan, IsNull, MoreThan, FindOptionsWhere } from 'typeorm';
import { LessThan, IsNull, In, MoreThan, FindOptionsWhere } from 'typeorm';
import AppDataSource from '../../data-source';
import { Message, Conversation, Agent } from '../../entities';
import {
@@ -217,13 +217,18 @@ const chat: Chat = async (req, res, next) => {
const assistantThinking = lastAssistantJsonl.thinking
? stripWrapperTags(lastAssistantJsonl.thinking).trim()
: null;
const assistantToolSteps = lastAssistantJsonl.toolSteps ?? null;
if (assistantText || assistantThinking) {
/* Persist if we got any signal: real text, thinking, or tool calls
* — the last produces a compact tool-stub bubble in the UI. */
if (assistantText || assistantThinking || (assistantToolSteps && assistantToolSteps.length > 0)) {
const assistantMessage = msgRepo.create({
conversationId: Number(conversationId),
externalId: lastAssistantJsonl.externalId || null,
text: assistantText || '...',
text: assistantText,
thinking: assistantThinking || null,
toolSteps:
assistantToolSteps && assistantToolSteps.length > 0 ? assistantToolSteps : null,
role: 'assistant' as const,
createdBy: req.user!._id,
createdAt: new Date(),
@@ -283,14 +288,18 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?:
const conv = await convRepo.findOneBy({ _id: convId });
if (!conv?.sessionKey) {
return res.json({ items: [], synced: 0 });
return res.json({ items: [], synced: 0, runStatus: null });
}
const agent = await agentRepo.findOneBy({ _id: conv.agentId });
if (!agent?.openclawAgentId) {
return res.json({ items: [], synced: 0 });
return res.json({ items: [], synced: 0, runStatus: null });
}
/* Run state surfaced to the UI so it can show a banner when the daemon
* aborted the last run (model idle timeout, error, user cancel). */
const runStatus = ocService.getSessionRunStatus(agent.openclawAgentId, conv.sessionKey);
let synced = 0;
const jsonlMessages = ocService
.getSessionMessages(agent.openclawAgentId, conv.sessionKey)
@@ -356,20 +365,47 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?:
});
const toInsert: typeof candidates = [];
const updates: Array<{ id: number; externalId: string }> = [];
const updates: Array<{
id: number;
externalId: string;
thinking: string | null;
toolSteps: NonNullable<(typeof candidates)[number]['toolSteps']> | null;
}> = [];
candidates.forEach((m) => {
const pool = unlinkedByRole.get(m.role);
if (pool && pool.length > 0) {
const match = pool.shift()!;
updates.push({ id: match._id, externalId: m.externalId! });
/* The chat handler may have saved this row pre-stream-completion,
* before any toolResult had landed in JSONL. We carry the live
* toolSteps + thinking through the link so the row picks up
* whatever the JSONL has now (including populated tool outputs). */
updates.push({
id: match._id,
externalId: m.externalId!,
thinking: m.thinking || null,
toolSteps: m.toolSteps && m.toolSteps.length > 0 ? m.toolSteps : null,
});
} else {
toInsert.push(m);
}
});
if (updates.length) {
await Promise.all(updates.map((u) => msgRepo.update(u.id, { externalId: u.externalId })));
await Promise.all(
updates.map((u) => {
/* TypeORM's update() type widens to _QueryDeepPartialEntity,
* which doesn't model arbitrary `Record<string, unknown>` shapes
* inside ToolStep.input. The runtime contract is just
* "JSON-serialisable patch", so the cast is safe. */
const patch = {
externalId: u.externalId,
thinking: u.thinking,
toolSteps: u.toolSteps,
} as unknown as Parameters<typeof msgRepo.update>[1];
return msgRepo.update(u.id, patch);
})
);
synced += updates.length;
}
@@ -381,6 +417,7 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?:
externalId: m.externalId,
text: m.text,
thinking: m.thinking || null,
toolSteps: m.toolSteps && m.toolSteps.length > 0 ? m.toolSteps : null,
files: [],
role: m.role as 'user' | 'assistant',
createdBy: req.user!._id,
@@ -390,6 +427,50 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?:
);
synced += toInsert.length;
}
/* Refresh tool steps on already-linked assistant rows. A long-running
* tool finishes AFTER its parent assistant turn was first synced, so
* the toolResult lands in JSONL on a later poll. Without this pass,
* the row keeps `output: null` forever and the UI shows "(no result
* captured)". We compare canonical JSON to skip no-op writes. */
const liveAssistantSteps = jsonlMessages.filter(
(m) => m.role === 'assistant' && m.toolSteps && m.toolSteps.length > 0
);
if (liveAssistantSteps.length) {
const liveIds = liveAssistantSteps.map((m) => m.externalId!).filter(Boolean);
if (liveIds.length) {
const existing = await msgRepo.find({
where: {
conversationId: convId,
role: 'assistant',
externalId: In(liveIds),
},
select: ['_id', 'externalId', 'toolSteps'],
});
const dbByExt = new Map(existing.map((m) => [m.externalId!, m]));
const refreshes: Array<{ id: number; toolSteps: typeof liveAssistantSteps[number]['toolSteps'] }> = [];
liveAssistantSteps.forEach((m) => {
const row = dbByExt.get(m.externalId!);
if (!row) return;
const liveJson = JSON.stringify(m.toolSteps ?? null);
const dbJson = JSON.stringify(row.toolSteps ?? null);
if (liveJson !== dbJson) {
refreshes.push({ id: row._id, toolSteps: m.toolSteps });
}
});
if (refreshes.length) {
await Promise.all(
refreshes.map((r) => {
const patch = { toolSteps: r.toolSteps } as unknown as Parameters<
typeof msgRepo.update
>[1];
return msgRepo.update(r.id, patch);
})
);
synced += refreshes.length;
}
}
}
}
const where: FindOptionsWhere<Message> = { conversationId: convId };
@@ -403,7 +484,7 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?:
take: 200,
});
return res.json({ items, synced });
return res.json({ items, synced, runStatus });
} catch (error) {
return next(error);
}
+1
View File
@@ -14,6 +14,7 @@ export {
deleteSession,
deleteSessionMessage,
extractThinkingFromJsonl,
getSessionRunStatus,
getSessionSettingsInternal,
} from './sessions';
export { runChat } from './chat';
+139 -10
View File
@@ -4,9 +4,132 @@ import {
JsonlEntry,
JsonlTextPart,
JsonlThinkingPart,
JsonlToolCallPart,
OpenClawMessage,
ToolStep,
ToolStepOutput,
} from '../../@types/openclaw';
/** Hard limits to keep tool step JSON small enough to live alongside chat
* messages in SQLite without blowing up message payloads. Real tool I/O
* (e.g. file dumps, large model outputs) routinely runs past these caps;
* the UI shows a "(truncated)" hint when that happens. */
const MAX_TOOL_INPUT_VALUE_CHARS = 8_000;
const MAX_TOOL_OUTPUT_TEXT_CHARS = 16_000;
/** Provider-specific noise we never want to surface to the UI. */
const HIDDEN_TOOL_INPUT_KEYS = new Set(['thoughtSignature']);
function isTextPart(p: JsonlContentPart): p is JsonlTextPart {
return p.type === 'text' && typeof (p as JsonlTextPart).text === 'string';
}
function isThinkingPart(p: JsonlContentPart): p is JsonlThinkingPart {
return p.type === 'thinking' && typeof (p as JsonlThinkingPart).thinking === 'string';
}
function truncateString(value: string, max: number): { value: string; truncated: boolean } {
if (value.length <= max) return { value, truncated: false };
return { value: `${value.slice(0, max)}\n…[truncated ${value.length - max} chars]`, truncated: true };
}
function sanitizeToolInput(args: unknown): Record<string, unknown> | null {
if (!args || typeof args !== 'object' || Array.isArray(args)) return null;
const out: Record<string, unknown> = {};
Object.entries(args as Record<string, unknown>).forEach(([k, v]) => {
if (HIDDEN_TOOL_INPUT_KEYS.has(k)) return;
if (typeof v === 'string') {
out[k] = truncateString(v, MAX_TOOL_INPUT_VALUE_CHARS).value;
} else {
/* For nested objects we serialize once + truncate, avoiding deep
* recursion through arbitrary tool schemas. */
try {
const json = JSON.stringify(v);
if (json && json.length > MAX_TOOL_INPUT_VALUE_CHARS) {
out[k] = `${json.slice(0, MAX_TOOL_INPUT_VALUE_CHARS)}…[truncated]`;
} else {
out[k] = v;
}
} catch {
out[k] = '[unserialisable]';
}
}
});
return Object.keys(out).length === 0 ? null : out;
}
function isToolCallPart(p: JsonlContentPart): p is JsonlToolCallPart {
return (
Boolean(p) && typeof p === 'object' && (p as { type?: unknown }).type === 'toolCall'
);
}
/**
* Walk all JSONL entries once and index `toolResult` rows by their
* `toolCallId`. The map gives us O(1) lookup when assembling assistant
* messages so the parser stays O(N) overall.
*/
function indexToolResults(entries: JsonlEntry[]): Map<string, ToolStepOutput> {
const out = new Map<string, ToolStepOutput>();
entries.forEach((entry) => {
if (entry.type !== 'message') return;
const msg = entry.message as
| {
role?: string;
toolCallId?: string;
content?: JsonlContentPart[] | string;
isError?: boolean;
details?: Record<string, unknown>;
}
| undefined;
if (!msg || msg.role !== 'toolResult' || !msg.toolCallId) return;
/* The result's primary text lives either as a single string in
* `content` or as a list of text parts. Joining is the safe default. */
let text = '';
if (typeof msg.content === 'string') {
text = msg.content;
} else if (Array.isArray(msg.content)) {
text = msg.content
.filter(isTextPart)
.map((c) => c.text)
.join('\n');
}
const { value: clipped, truncated } = truncateString(text, MAX_TOOL_OUTPUT_TEXT_CHARS);
const details = (msg.details ?? {}) as {
status?: unknown;
exitCode?: unknown;
durationMs?: unknown;
};
out.set(msg.toolCallId, {
text: clipped,
isError: msg.isError === true,
status: typeof details.status === 'string' ? details.status : null,
exitCode: typeof details.exitCode === 'number' ? details.exitCode : null,
durationMs: typeof details.durationMs === 'number' ? details.durationMs : null,
truncated,
});
});
return out;
}
function extractToolSteps(
content: JsonlContentPart[] | string,
results: Map<string, ToolStepOutput>
): ToolStep[] {
if (!Array.isArray(content)) return [];
return content.filter(isToolCallPart).map((part) => {
const id = typeof part.id === 'string' ? part.id : '';
const name = typeof part.name === 'string' ? part.name : 'tool';
return {
id,
name,
input: sanitizeToolInput(part.arguments),
output: id ? results.get(id) ?? null : null,
};
});
}
export function extractUserText(raw: string): string {
const trimmed = raw.trim();
// Preserve scheduled-task headers so the frontend can render them specially
@@ -29,14 +152,6 @@ export function extractAssistantText(raw: string): string {
.trim();
}
function isTextPart(p: JsonlContentPart): p is JsonlTextPart {
return p.type === 'text' && typeof (p as JsonlTextPart).text === 'string';
}
function isThinkingPart(p: JsonlContentPart): p is JsonlThinkingPart {
return p.type === 'thinking' && typeof (p as JsonlThinkingPart).thinking === 'string';
}
function readJsonlLines(jsonlPath: string): JsonlEntry[] {
if (!jsonlPath || !fs.existsSync(jsonlPath)) return [];
return fs
@@ -71,7 +186,13 @@ export function readFirstUserMessage(jsonlPath: string): string | null {
export function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] {
try {
const raw = readJsonlLines(jsonlPath)
const entries = readJsonlLines(jsonlPath);
/* One pre-pass to index toolResult rows by toolCallId — assistant entries
* reference these by id rather than positionally, so a Map is the only
* reliable way to pair them. */
const resultsByCallId = indexToolResults(entries);
const raw = entries
.filter((entry) => {
if (entry.type !== 'message') return false;
const role = entry.message?.role;
@@ -97,13 +218,18 @@ export function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] {
.join('\n')
.trim();
const thinking = [structuredThink, inlineThink].filter(Boolean).join('\n').trim() || null;
if (!text) return null;
const toolSteps = role === 'assistant' ? extractToolSteps(message.content, resultsByCallId) : [];
/* Keep the entry if it has ANY meaningful signal: text, thinking, or
* tool calls. Tool-only assistant turns used to be dropped here,
* which made tool-using runs look like silent gaps in the chat. */
if (!text && !thinking && toolSteps.length === 0) return null;
return {
externalId: entry.id || '',
role,
text,
thinking,
timestamp: entry.timestamp || null,
toolSteps: toolSteps.length > 0 ? toolSteps : null,
};
})
.filter((m): m is OpenClawMessage => m !== null);
@@ -113,6 +239,9 @@ export function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] {
if (msg.role === 'assistant' && prev?.role === 'assistant') {
prev.text += msg.text;
if (msg.thinking) prev.thinking = (prev.thinking || '') + msg.thinking;
if (msg.toolSteps && msg.toolSteps.length > 0) {
prev.toolSteps = [...(prev.toolSteps ?? []), ...msg.toolSteps];
}
prev.externalId = msg.externalId;
prev.timestamp = msg.timestamp || prev.timestamp;
} else {
+34
View File
@@ -6,6 +6,7 @@ import {
OpenClawMessage,
OpenClawSession,
SessionEntry,
SessionRunStatus,
SessionSettings,
SessionSettingsPatchBody,
SessionsFile,
@@ -77,6 +78,39 @@ export function getSessionSettingsInternal(
}
}
/**
* Inspect `sessions.json` for the run state of one session.
*
* The OpenClaw daemon writes `status`, `abortedLastRun`, `abortReason`, and
* `endedAt` after each run. When `status === 'timeout'` (model idle timeout)
* or `abortedLastRun === true` (any other abnormal end) the assistant's
* partial reply was streamed to whatever client was connected but never
* committed to the JSONL — so we surface this so the chat UI can explain
* the gap to the user instead of silently showing a missing message.
*/
export function getSessionRunStatus(
agentId: string,
sessionKey: string
): SessionRunStatus {
const empty: SessionRunStatus = { aborted: false, status: null, reason: null, endedAt: null };
try {
const sessions = readSessions(agentId);
if (!sessions) return empty;
const entry = findSessionEntry(sessions, agentId, sessionKey);
if (!entry) return empty;
const status = typeof entry.status === 'string' ? entry.status : null;
const aborted = entry.abortedLastRun === true || status === 'timeout' || status === 'error';
const reason =
(typeof entry.abortReason === 'string' && entry.abortReason) ||
(status && status !== 'ok' ? status : null) ||
null;
const endedAt = typeof entry.endedAt === 'number' ? entry.endedAt : null;
return { aborted, status, reason, endedAt };
} catch {
return empty;
}
}
export function extractThinkingFromJsonl(agentId: string, sessionKey: string): string | null {
try {
const sessions = readSessions(agentId);
+33
View File
@@ -400,6 +400,39 @@ export class GatewayClient {
offEvent(key: string): void {
this.eventListeners.delete(key);
}
/**
* Cheap snapshot of the connection's current health, surfaced through
* `/api/gateway/status` for the chat UI's status dot. `connected` means
* the socket is OPEN and post-auth; `connecting` covers both initial
* handshake and reconnect attempts; `disconnected` is everything else.
*/
getStatus(): {
state: 'connected' | 'connecting' | 'disconnected';
lastSeenAt: number;
reconnectAttempts: number;
hasCredentials: boolean;
} {
const hasCredentials = Boolean(this.credentials || loadGatewayCredentials());
if (this.ws?.readyState === WsWebSocket.OPEN && this.authenticated) {
return {
state: 'connected',
lastSeenAt: this.lastSeenAt,
reconnectAttempts: 0,
hasCredentials,
};
}
const connecting =
this.ws?.readyState === WsWebSocket.CONNECTING ||
Boolean(this.connectPromise) ||
(this.ws?.readyState === WsWebSocket.OPEN && !this.authenticated);
return {
state: connecting ? 'connecting' : 'disconnected',
lastSeenAt: this.lastSeenAt,
reconnectAttempts: this.reconnectAttempts,
hasCredentials,
};
}
}
export const gateway = new GatewayClient();
+26
View File
@@ -0,0 +1,26 @@
import { baseApi } from '../../shared/api/baseApi';
export type GatewayState = 'connected' | 'connecting' | 'disconnected';
export interface GatewayStatus {
state: GatewayState;
/** Wall-clock ms of the last byte received on the current socket. 0
* when never connected. */
lastSeenAt: number;
/** Successive reconnect attempts since the last successful auth.
* Surfaced for diagnostics, not currently rendered. */
reconnectAttempts: number;
/** False when device-pairing files are missing — the dot turns into a
* red "needs pairing" cue regardless of socket state. */
hasCredentials: boolean;
}
const gatewayApi = baseApi.injectEndpoints({
endpoints: (build) => ({
getGatewayStatus: build.query<GatewayStatus, void>({
query: () => '/gateway/status',
}),
}),
});
export const { useGetGatewayStatusQuery } = gatewayApi;
+2
View File
@@ -0,0 +1,2 @@
export * from './api';
export { default as GatewayStatusDot } from './ui/GatewayStatusDot';
@@ -0,0 +1,57 @@
import { Box, Tooltip } from '@mui/material';
import { useGetGatewayStatusQuery } from '../api';
const POLL_MS = 5_000;
const COPY: Record<string, { color: string; tooltip: string }> = {
connected: {
color: 'success.main',
tooltip: 'Gateway connected',
},
connecting: {
color: 'warning.main',
tooltip: 'Connecting to gateway…',
},
disconnected: {
color: 'error.main',
tooltip:
'Gateway disconnected — agent traffic is paused. Reconnect attempts are running in the background.',
},
unpaired: {
color: 'error.main',
tooltip: 'Device not paired with the gateway. Run the pairing flow before sending messages.',
},
};
export default function GatewayStatusDot({ size = 5 }: { size?: number }) {
const { data } = useGetGatewayStatusQuery(undefined, {
pollingInterval: POLL_MS,
refetchOnMountOrArgChange: true,
});
const key = data?.hasCredentials === false ? 'unpaired' : (data?.state ?? 'disconnected');
const meta = COPY[key];
return (
<Tooltip title={meta.tooltip} arrow placement="top">
<Box
aria-label={meta.tooltip}
sx={{
width: size,
height: size,
borderRadius: '50%',
bgcolor: meta.color,
flexShrink: 0,
/* Pulse while connecting so the user sees something is in
* progress and the UI isn't just frozen on a stale state. */
animation:
data?.state === 'connecting' ? 'gatewayPulse 1.4s ease-in-out infinite' : 'none',
'@keyframes gatewayPulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.35 },
},
}}
/>
</Tooltip>
);
}
+39
View File
@@ -8,11 +8,35 @@ export interface MessageFile {
url: string;
}
/** Provider-reported result of a single tool invocation. */
export interface ToolStepOutput {
text: string;
isError: boolean;
status?: string | null;
exitCode?: number | null;
durationMs?: number | null;
truncated?: boolean;
}
/**
* One tool invocation captured from the assistant's JSONL turn — the
* `toolCall` content part paired with its matching `toolResult` row. The
* UI renders these as collapsible "Tool call / Tool output" blocks beneath
* the thinking section so users can audit what the agent did.
*/
export interface ToolStep {
id: string;
name: string;
input: Record<string, unknown> | null;
output: ToolStepOutput | null;
}
export interface Message {
_id: string;
conversationId: string;
text: string;
thinking: string | null;
toolSteps?: ToolStep[] | null;
files: MessageFile[];
role: 'user' | 'assistant';
createdAt: string;
@@ -29,9 +53,24 @@ export interface MessagesQueryArg {
before?: string;
}
/**
* Run-state surfaced from `sessions.json` on each poll. When `aborted` is
* true the OpenClaw daemon ended the last run abnormally (idle timeout,
* error, manual cancel) and the agent's reply was streamed only — never
* committed to the JSONL. The UI shows a banner explaining this so the
* apparent gap doesn't look like a sync failure.
*/
export interface SessionRunStatus {
aborted: boolean;
status: string | null;
reason: string | null;
endedAt: number | null;
}
export interface PollResponse {
items: Message[];
synced: number;
runStatus: SessionRunStatus | null;
}
export interface PollQueryArg {
@@ -0,0 +1,42 @@
import type { ToolStep } from '../api';
/**
* Pull the most informative one-liner out of a tool call's arguments so
* the collapsed header has context without forcing the user to expand.
* Special-cases the common shapes (exec/read/write/edit) and falls back
* to the first string field for anything else.
*/
export function summarizeInput(name: string, input: Record<string, unknown> | null): string {
if (!input) return '';
const candidate =
(typeof input.command === 'string' && input.command) ||
(typeof input.path === 'string' && input.path) ||
(typeof input.file_path === 'string' && input.file_path) ||
(typeof input.url === 'string' && input.url) ||
(typeof input.query === 'string' && input.query) ||
(typeof input.pattern === 'string' && input.pattern) ||
'';
if (candidate) return candidate;
/* Generic fallback: first string-valued arg, prefixed with its key so
* the header stays self-describing for unknown tools. */
const first = Object.entries(input).find(([, v]) => typeof v === 'string' && v);
if (first) return `${first[0]}: ${first[1]}`;
return name;
}
export function formatDuration(ms: number | null | undefined): string | null {
if (ms == null || !Number.isFinite(ms)) return null;
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s`;
}
/** Roll up per-step statuses into one summary state for the outer header. */
export function aggregateStatus(steps: ToolStep[]): 'ok' | 'error' | 'pending' {
const hasPending = steps.some((s) => !s.output);
const hasError = steps.some(
(s) => s.output?.isError || (typeof s.output?.exitCode === 'number' && s.output.exitCode !== 0)
);
if (hasError) return 'error';
if (hasPending) return 'pending';
return 'ok';
}
@@ -1,6 +1,22 @@
import { useState, memo, useCallback } from 'react';
import { Box, Paper, Typography, IconButton, Chip, alpha, useTheme } from '@mui/material';
import { DeleteOutline, ContentCopy, Done, ScheduleOutlined } from '@mui/icons-material';
import {
Box,
Paper,
Stack,
Tooltip,
Typography,
IconButton,
Chip,
alpha,
useTheme,
} from '@mui/material';
import {
DeleteOutline,
ContentCopy,
Done,
ScheduleOutlined,
ErrorOutline,
} from '@mui/icons-material';
import { DeleteButton, MarkdownContent } from '../../../shared/ui';
import { useDeleteMessageMutation, type Message } from '../api';
import type { ParsedCronMessage } from '../lib/parseCronMessage';
@@ -9,12 +25,14 @@ interface CronMessageBubbleProps {
message: Message | { text: string; role: string };
messageId?: string;
parsed: ParsedCronMessage;
deliveryError?: string | null;
}
const CronMessageBubble = memo(function CronMessageBubble({
message,
messageId,
parsed,
deliveryError,
}: CronMessageBubbleProps) {
const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
@@ -121,13 +139,19 @@ const CronMessageBubble = memo(function CronMessageBubble({
</Box>
)}
{createdAt && (
<Typography
variant="caption"
sx={{ opacity: 0.6, display: 'block', mt: 0.5, fontSize: 10.5 }}
>
{new Date(createdAt).toLocaleTimeString()}
</Typography>
{(createdAt || deliveryError) && (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ mt: 0.5 }}>
{deliveryError && (
<Tooltip title={deliveryError} arrow placement="right">
<ErrorOutline sx={{ fontSize: 13, color: 'warning.main', cursor: 'help' }} />
</Tooltip>
)}
{createdAt && (
<Typography variant="caption" sx={{ opacity: 0.6, fontSize: 10.5 }}>
{new Date(createdAt).toLocaleTimeString()}
</Typography>
)}
</Stack>
)}
</Paper>
</Box>
@@ -1,24 +1,32 @@
import { useState, memo, useCallback } from 'react';
import { Box, Paper, Typography, IconButton, useTheme } from '@mui/material';
import { DeleteOutline, ContentCopy, Done } from '@mui/icons-material';
import { Box, Paper, Stack, Tooltip, Typography, IconButton, useTheme } from '@mui/material';
import { DeleteOutline, ContentCopy, Done, ErrorOutline } from '@mui/icons-material';
import { DeleteButton, MarkdownContent } from '../../../shared/ui';
import ThinkingBlock from './ThinkingBlock';
import ToolStepsBlock from './ToolStepsBlock';
import FileAttachments from './FileAttachments';
import CronMessageBubble from './CronMessageBubble';
import ChannelMetadataHeader from './ChannelMetadataHeader';
import { parseCronMessage } from '../lib/parseCronMessage';
import { parseChannelMetadata } from '../lib/parseChannelMetadata';
import { useDeleteMessageMutation, type Message, type MessageFile } from '../api';
import { useDeleteMessageMutation, type Message, type MessageFile, type ToolStep } from '../api';
export type MessageLike =
| Message
| { text: string; role: string; thinking?: string | null; files?: MessageFile[] };
| {
text: string;
role: string;
thinking?: string | null;
files?: MessageFile[];
toolSteps?: ToolStep[] | null;
};
interface MessageBubbleProps {
message: MessageLike;
isStreaming?: boolean;
thinkingText?: string;
messageId?: string;
deliveryError?: string | null;
}
const MessageBubble = memo(function MessageBubble({
@@ -26,6 +34,7 @@ const MessageBubble = memo(function MessageBubble({
isStreaming,
thinkingText,
messageId,
deliveryError,
}: MessageBubbleProps) {
const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
@@ -35,6 +44,10 @@ const MessageBubble = memo(function MessageBubble({
const isUser = message.role === 'user';
const thinking = thinkingText || ('thinking' in message ? message.thinking : null);
const files = ('files' in message ? message.files : undefined) ?? [];
const toolSteps =
!isUser && 'toolSteps' in message && Array.isArray(message.toolSteps)
? (message.toolSteps as ToolStep[])
: [];
const parsedCron = isUser && !isStreaming ? parseCronMessage(message.text) : null;
const parsedChannel =
isUser && !isStreaming && !parsedCron ? parseChannelMetadata(message.text) : null;
@@ -55,7 +68,38 @@ const MessageBubble = memo(function MessageBubble({
if (parsedCron) {
return (
<CronMessageBubble message={message as Message} messageId={messageId} parsed={parsedCron} />
<CronMessageBubble
message={message as Message}
messageId={messageId}
parsed={parsedCron}
deliveryError={deliveryError}
/>
);
}
if (!isUser && !hasTextContent && !thinking && toolSteps.length > 0 && !isStreaming) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
mb: 1.5,
width: '100%',
maxWidth: { xs: '90%', sm: '80%', md: 'min(70%, 100%)' },
}}
>
<ToolStepsBlock steps={toolSteps} asStandalone />
{'createdAt' in message && (
<Typography
variant="caption"
color="text.secondary"
sx={{ opacity: 0.6, mt: 0.5, fontSize: '0.65rem' }}
>
{new Date((message as Message).createdAt).toLocaleTimeString()}
</Typography>
)}
</Box>
);
}
@@ -133,6 +177,7 @@ const MessageBubble = memo(function MessageBubble({
) : (
<MarkdownContent isStreaming={isStreaming}>{displayText}</MarkdownContent>
))}
{!isUser && toolSteps.length > 0 && <ToolStepsBlock steps={toolSteps} />}
{isStreaming && !hasTextContent && (
<Box
component="span"
@@ -146,10 +191,31 @@ const MessageBubble = memo(function MessageBubble({
}}
/>
)}
{'createdAt' in message && (
<Typography variant="caption" sx={{ opacity: 0.7 }}>
{new Date(message.createdAt).toLocaleTimeString()}
</Typography>
{('createdAt' in message || deliveryError) && (
<Stack
direction="row"
spacing={0.5}
alignItems="center"
justifyContent={isUser ? 'flex-end' : 'flex-start'}
sx={{ mt: 0.25 }}
>
{deliveryError && (
<Tooltip title={deliveryError} arrow placement={isUser ? 'left' : 'right'}>
<ErrorOutline
sx={{
fontSize: 14,
color: 'warning.main',
cursor: 'help',
}}
/>
</Tooltip>
)}
{'createdAt' in message && (
<Typography variant="caption" sx={{ opacity: 0.7 }}>
{new Date(message.createdAt).toLocaleTimeString()}
</Typography>
)}
</Stack>
)}
</Paper>
</Box>
@@ -0,0 +1,25 @@
import { Box } from '@mui/material';
export default function ToolStepCodeFrame({ children }: { children: string }) {
return (
<Box
component="pre"
sx={{
m: 0,
mt: 0.5,
p: 1,
bgcolor: 'action.hover',
borderRadius: 1,
fontSize: '0.72rem',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 320,
overflow: 'auto',
color: 'text.secondary',
}}
>
{children}
</Box>
);
}
@@ -0,0 +1,78 @@
import { Box, Chip, Typography } from '@mui/material';
import { ExpandMore } from '@mui/icons-material';
import type { ReactNode } from 'react';
interface ToolStepHeaderProps {
expanded: boolean;
onToggle: () => void;
label: string;
toolName: string;
summary?: string;
trailing?: ReactNode;
}
export default function ToolStepHeader({
expanded,
onToggle,
label,
toolName,
summary,
trailing,
}: ToolStepHeaderProps) {
return (
<Box
onClick={onToggle}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
opacity: 0.7,
'&:hover': { opacity: 1 },
minWidth: 0,
}}
>
<ExpandMore
sx={{
fontSize: 14,
transition: 'transform 0.2s',
transform: expanded ? 'rotate(0deg)' : 'rotate(-90deg)',
flexShrink: 0,
}}
/>
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: '0.7rem', flexShrink: 0 }}>
{label}
</Typography>
<Chip
label={toolName}
size="small"
variant="outlined"
sx={{
height: 16,
fontSize: '0.62rem',
fontFamily: 'monospace',
'& .MuiChip-label': { px: 0.5 },
flexShrink: 0,
}}
/>
{summary && (
<Typography
variant="caption"
color="text.secondary"
sx={{
fontSize: '0.7rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
opacity: 0.8,
minWidth: 0,
flex: 1,
}}
>
{summary}
</Typography>
)}
{trailing && <Box sx={{ flexShrink: 0, ml: 'auto' }}>{trailing}</Box>}
</Box>
);
}
@@ -0,0 +1,105 @@
import { useMemo, useState } from 'react';
import { Box, Collapse, Typography } from '@mui/material';
import type { ToolStep } from '../api';
import { summarizeInput } from '../lib/toolStepFormatting';
import ToolStepCodeFrame from './ToolStepCodeFrame';
import ToolStepHeader from './ToolStepHeader';
import ToolStepTrailing from './ToolStepTrailing';
interface ToolStepPairProps {
step: ToolStep;
idx: number;
}
export default function ToolStepPair({ step, idx }: ToolStepPairProps) {
const [callOpen, setCallOpen] = useState(false);
const [outputOpen, setOutputOpen] = useState(false);
const inputJson = useMemo(() => {
if (!step.input) return '';
try {
return JSON.stringify(step.input, null, 2);
} catch {
return String(step.input);
}
}, [step.input]);
const summary = summarizeInput(step.name, step.input);
const out = step.output;
return (
<Box sx={{ mt: idx === 0 ? 0 : 0.75 }}>
<ToolStepHeader
expanded={callOpen}
onToggle={() => setCallOpen((v) => !v)}
label="Tool call"
toolName={step.name}
summary={summary}
trailing={<ToolStepTrailing step={step} />}
/>
<Collapse in={callOpen}>
{step.input ? (
<ToolStepCodeFrame>{inputJson}</ToolStepCodeFrame>
) : (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', pl: 2.5, fontStyle: 'italic', opacity: 0.7 }}
>
(no arguments)
</Typography>
)}
</Collapse>
<Box sx={{ mt: 0.25 }}>
<ToolStepHeader
expanded={outputOpen}
onToggle={() => setOutputOpen((v) => !v)}
label="Tool output"
toolName={step.name}
/>
<Collapse in={outputOpen}>
{out ? (
<>
{out.text ? (
<ToolStepCodeFrame>{out.text}</ToolStepCodeFrame>
) : (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', pl: 2.5, fontStyle: 'italic', opacity: 0.7 }}
>
(no output)
</Typography>
)}
{out.truncated && (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: 'block',
pl: 2.5,
mt: 0.25,
fontStyle: 'italic',
opacity: 0.7,
}}
>
output truncated for storage
</Typography>
)}
</>
) : (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', pl: 2.5, fontStyle: 'italic', opacity: 0.7 }}
>
{/* In-flight call (e.g. live stream) or aborted run — no result row exists yet. */}
(no result captured)
</Typography>
)}
</Collapse>
</Box>
</Box>
);
}
@@ -0,0 +1,42 @@
import { Stack, Typography } from '@mui/material';
import { ErrorOutline, CheckCircleOutline } from '@mui/icons-material';
import type { ToolStep } from '../api';
import { formatDuration } from '../lib/toolStepFormatting';
export default function ToolStepTrailing({ step }: { step: ToolStep }) {
const out = step.output;
if (!out) {
return (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.65rem', opacity: 0.6 }}
>
pending
</Typography>
);
}
const duration = formatDuration(out.durationMs);
const errored = out.isError || (typeof out.exitCode === 'number' && out.exitCode !== 0);
return (
<Stack direction="row" spacing={0.5} alignItems="center">
{errored ? (
<ErrorOutline sx={{ fontSize: 13, color: 'error.main' }} />
) : (
<CheckCircleOutline sx={{ fontSize: 13, color: 'success.main', opacity: 0.7 }} />
)}
{duration && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>
{duration}
</Typography>
)}
{typeof out.exitCode === 'number' && out.exitCode !== 0 && (
<Typography variant="caption" color="error.main" sx={{ fontSize: '0.65rem' }}>
exit {out.exitCode}
</Typography>
)}
</Stack>
);
}
@@ -0,0 +1,68 @@
import { useState } from 'react';
import { Box, Collapse, Stack, Typography } from '@mui/material';
import { ExpandMore, Build, ErrorOutline, CheckCircleOutline } from '@mui/icons-material';
import type { ToolStep } from '../api';
import { aggregateStatus } from '../lib/toolStepFormatting';
import ToolStepPair from './ToolStepPair';
interface ToolStepsBlockProps {
steps: ToolStep[];
asStandalone?: boolean;
}
export default function ToolStepsBlock({ steps, asStandalone }: ToolStepsBlockProps) {
const [open, setOpen] = useState(false);
if (!steps.length) return null;
const status = aggregateStatus(steps);
const trailing = (
<Stack direction="row" spacing={0.5} alignItems="center">
{status === 'ok' && (
<CheckCircleOutline sx={{ fontSize: 13, color: 'success.main', opacity: 0.7 }} />
)}
{status === 'error' && <ErrorOutline sx={{ fontSize: 13, color: 'error.main' }} />}
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>
{steps.length === 1 ? '1 call' : `${steps.length} calls`}
</Typography>
</Stack>
);
return (
<Box sx={{ mt: asStandalone ? 0 : 0.5 }}>
<Box
onClick={() => setOpen((v) => !v)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
opacity: 0.75,
'&:hover': { opacity: 1 },
minWidth: 0,
}}
>
<ExpandMore
sx={{
fontSize: 14,
transition: 'transform 0.2s',
transform: open ? 'rotate(0deg)' : 'rotate(-90deg)',
flexShrink: 0,
}}
/>
{asStandalone && <Build sx={{ fontSize: 13, color: 'text.secondary', flexShrink: 0 }} />}
<Typography variant="caption" sx={{ fontWeight: 600, fontSize: '0.72rem', flexShrink: 0 }}>
Tool execution
</Typography>
<Box sx={{ flex: 1, minWidth: 0 }} />
<Box sx={{ flexShrink: 0 }}>{trailing}</Box>
</Box>
<Collapse in={open}>
<Box sx={{ pl: 2, mt: 0.25 }}>
{steps.map((step, idx) => (
<ToolStepPair key={`${step.id || step.name}-${idx}`} step={step} idx={idx} />
))}
</Box>
</Collapse>
</Box>
);
}
+5 -1
View File
@@ -1,5 +1,5 @@
import type { RefObject } from 'react';
import type { Message, MessageFile } from '../../../entities/message';
import type { Message, MessageFile, SessionRunStatus } from '../../../entities/message';
export interface ChatState {
messages: Message[];
@@ -15,6 +15,10 @@ export interface ChatState {
pendingUserText: string;
pendingFilesPreviews: MessageFile[];
/** Last-run state from the gateway daemon. `null` while unknown. */
runStatus: SessionRunStatus | null;
/** Hide the timeout/abort banner for the current chat session. */
send: (text: string, files: File[]) => Promise<void>;
loadMore: () => void;
handleScroll: () => void;
+11
View File
@@ -6,6 +6,7 @@ import {
usePollMessagesQuery,
type Message,
type MessagesResponse,
type SessionRunStatus,
} from '../../../entities/message';
import { useSendMessage } from '../../../features/message/send';
import type { ChatState } from './types';
@@ -146,6 +147,15 @@ export function useChat(conversationId: string | undefined): ChatState {
if (loadMoreCursor !== undefined) setLoadMoreCursor(undefined);
}
/* Run state is derived directly from the latest poll. Polling is paused
* while streaming, so by definition this only updates between runs.
* The MessageList consumes it to flag the last "stuck" message inline
* (exclamation icon + tooltip) — no banner state, no dismissal needed,
* because the indicator clears naturally when a new assistant reply
* arrives after the affected message. */
const runStatus: SessionRunStatus | null =
pollData?.runStatus?.aborted ? pollData.runStatus : null;
useEffect(() => {
return () => {
abort();
@@ -182,6 +192,7 @@ export function useChat(conversationId: string | undefined): ChatState {
streamError,
pendingUserText,
pendingFilesPreviews,
runStatus,
send,
loadMore,
handleScroll,
+8
View File
@@ -7,6 +7,7 @@ import {
InsertDriveFileOutlined,
ImageOutlined,
} from '@mui/icons-material';
import { GatewayStatusDot } from '../../../entities/gateway';
interface ChatInputProps {
onSend: (text: string, files: File[]) => Promise<void>;
@@ -96,6 +97,13 @@ export default function ChatInput({ onSend, isStreaming }: ChatInputProps) {
}}
>
<input ref={fileInputRef} type="file" multiple hidden onChange={handleFileChange} />
{/* Gateway status dot mirrors WebSocket health so the user sees
* immediately whether agent traffic can flow. Sits to the left
* of the paperclip so it stays visible without crowding the
* send-side controls. */}
<Box sx={{ display: 'flex', alignItems: 'center', mr: 0.75, ml: 0.25 }}>
<GatewayStatusDot />
</Box>
<IconButton
onClick={() => fileInputRef.current?.click()}
disabled={isStreaming || pendingFiles.length >= 5}
+50 -27
View File
@@ -1,7 +1,23 @@
import { Alert, Box, Typography, CircularProgress } from '@mui/material';
import { Box, Typography, CircularProgress } from '@mui/material';
import { MessageBubble } from '../../../entities/message';
import type { ChatState } from '../model/types';
/** Short, single-line copy shown inside the per-bubble error tooltip. */
function describeDeliveryError(status: string | null, reason: string | null): string {
if (status === 'timeout') {
return 'The model went idle past its timeout — no reply was committed. Try resending.';
}
if (status === 'error') {
return reason ? `Run failed: ${reason}.` : 'The daemon ended the run with an error.';
}
if (status === 'cancelled') {
return 'The run was cancelled before a reply was committed.';
}
return reason
? `The daemon aborted the run: ${reason}.`
: 'The daemon aborted the run before a reply was committed.';
}
interface MessageListProps {
chat: ChatState;
}
@@ -18,14 +34,39 @@ export default function MessageList({ chat }: MessageListProps) {
streamError,
pendingUserText,
pendingFilesPreviews,
runStatus,
loadMore,
loadMoreCursor,
scrollContainerRef,
messagesEndRef,
handleScroll,
clearError,
} = chat;
/* Two error sources can flag a stuck send:
* 1. `streamError` the SSE pipeline itself failed (network drop,
* gateway 5xx, model timeout surfaced as an error event). Most
* immediate; clears automatically when the next send starts.
* 2. `runStatus.aborted` the daemon's run ended abnormally on the
* previous turn (idle timeout, error, cancelled). Surfaces on the
* next poll between runs.
* Both render as the same inline marker (warning icon + tooltip) on
* whichever bubble is currently waiting for a reply. The marker clears
* naturally once an assistant reply arrives after that bubble. */
const errorTooltip = streamError
? streamError
: runStatus
? describeDeliveryError(runStatus.status, runStatus.reason)
: null;
const lastInputIdx =
errorTooltip && !isStreaming
? (() => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i].role !== 'assistant') return i;
}
return -1;
})()
: -1;
return (
<Box
ref={scrollContainerRef}
@@ -39,29 +80,6 @@ export default function MessageList({ chat }: MessageListProps) {
py: 2,
}}
>
{streamError && (
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 2,
mb: 1.5,
}}
>
<Alert
severity="error"
variant="filled"
onClose={clearError}
sx={{
alignItems: 'flex-start',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{streamError}
</Alert>
</Box>
)}
{isLoading && !loadMoreCursor ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
@@ -90,8 +108,13 @@ export default function MessageList({ chat }: MessageListProps) {
)}
</Box>
)}
{messages.map((msg) => (
<MessageBubble key={msg._id} message={msg} messageId={msg._id} />
{messages.map((msg, idx) => (
<MessageBubble
key={msg._id}
message={msg}
messageId={msg._id}
deliveryError={idx === lastInputIdx ? errorTooltip : null}
/>
))}
{isStreaming && (pendingUserText || pendingFilesPreviews.length > 0) && (
<MessageBubble
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw-client",
"version": "2.5.1",
"version": "2.5.2",
"description": "Web-based chat interface for OpenClaw AI agents",
"private": true,
"type": "module",