diff --git a/packages/adapters/openclaw/src/shared/stream.ts b/packages/adapters/openclaw/src/shared/stream.ts new file mode 100644 index 00000000..a2e84357 --- /dev/null +++ b/packages/adapters/openclaw/src/shared/stream.ts @@ -0,0 +1,16 @@ +export function normalizeOpenClawStreamLine(rawLine: string): { + stream: "stdout" | "stderr" | null; + line: string; +} { + const trimmed = rawLine.trim(); + if (!trimmed) return { stream: null, line: "" }; + + const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*(.*)$/i); + if (!prefixed) { + return { stream: null, line: trimmed }; + } + + const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout"; + const line = (prefixed[2] ?? "").trim(); + return { stream, line }; +} diff --git a/packages/adapters/openclaw/src/ui/parse-stdout.ts b/packages/adapters/openclaw/src/ui/parse-stdout.ts index c462027b..55c7f3fe 100644 --- a/packages/adapters/openclaw/src/ui/parse-stdout.ts +++ b/packages/adapters/openclaw/src/ui/parse-stdout.ts @@ -1,4 +1,5 @@ import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { normalizeOpenClawStreamLine } from "../shared/stream.js"; function safeJsonParse(text: string): unknown { try { @@ -21,6 +22,51 @@ function asNumber(value: unknown, fallback = 0): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function readErrorText(value: unknown): string { + if (typeof value === "string") return value; + const obj = asRecord(value); + if (!obj) return stringifyUnknown(value); + return ( + asString(obj.message).trim() || + asString(obj.error).trim() || + asString(obj.code).trim() || + stringifyUnknown(obj) + ); +} + +function readDeltaText(payload: Record | null): string { + if (!payload) return ""; + + if (typeof payload.delta === "string") return payload.delta; + + const deltaObj = asRecord(payload.delta); + if (deltaObj) { + const nestedDelta = + asString(deltaObj.text) || + asString(deltaObj.value) || + asString(deltaObj.delta); + if (nestedDelta.length > 0) return nestedDelta; + } + + const part = asRecord(payload.part); + if (part) { + const partText = asString(part.text); + if (partText.length > 0) return partText; + } + + return ""; +} + function extractResponseOutputText(response: Record | null): string { if (!response) return ""; @@ -55,8 +101,8 @@ function parseOpenClawSseLine(line: string, ts: string): TranscriptEntry[] { return []; } - const delta = asString(parsed?.delta); - if (normalizedEventType.endsWith(".delta") && delta) { + const delta = readDeltaText(parsed); + if (normalizedEventType.endsWith(".delta") && delta.length > 0) { return [{ kind: "assistant", ts, text: delta, delta: true }]; } @@ -65,10 +111,7 @@ function parseOpenClawSseLine(line: string, ts: string): TranscriptEntry[] { normalizedEventType.includes("failed") || normalizedEventType.includes("cancel") ) { - const message = - asString(parsed?.error).trim() || - asString(parsed?.message).trim() || - dataText; + const message = readErrorText(parsed?.error) || readErrorText(parsed?.message) || dataText; return message ? [{ kind: "stderr", ts, text: message }] : []; } @@ -78,9 +121,9 @@ function parseOpenClawSseLine(line: string, ts: string): TranscriptEntry[] { const status = asString(response?.status, asString(parsed?.status, eventType)); const statusLower = status.trim().toLowerCase(); const errorText = - asString(response?.error).trim() || - asString(parsed?.error).trim() || - asString(parsed?.message).trim(); + readErrorText(response?.error).trim() || + readErrorText(parsed?.error).trim() || + readErrorText(parsed?.message).trim(); const isError = statusLower === "failed" || statusLower === "error" || @@ -104,7 +147,12 @@ function parseOpenClawSseLine(line: string, ts: string): TranscriptEntry[] { } export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEntry[] { - const trimmed = line.trim(); + const normalized = normalizeOpenClawStreamLine(line); + if (normalized.stream === "stderr") { + return [{ kind: "stderr", ts, text: normalized.line }]; + } + + const trimmed = normalized.line.trim(); if (!trimmed) return []; if (trimmed.startsWith("[openclaw:sse]")) { @@ -115,5 +163,5 @@ export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEnt return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw\]\s*/, "") }]; } - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: normalized.line }]; } diff --git a/server/src/__tests__/openclaw-adapter.test.ts b/server/src/__tests__/openclaw-adapter.test.ts index 523e6b89..d55942f3 100644 --- a/server/src/__tests__/openclaw-adapter.test.ts +++ b/server/src/__tests__/openclaw-adapter.test.ts @@ -74,6 +74,21 @@ describe("openclaw ui stdout parser", () => { ]); }); + it("parses stdout-prefixed SSE deltas and preserves spacing", () => { + const ts = "2026-03-05T23:07:16.296Z"; + const line = + 'stdout[openclaw:sse] event=response.output_text.delta data={"type":"response.output_text.delta","delta":" can"}'; + + expect(parseOpenClawStdoutLine(line, ts)).toEqual([ + { + kind: "assistant", + ts, + text: " can", + delta: true, + }, + ]); + }); + it("parses response.completed into usage-aware result entries", () => { const ts = "2026-03-05T23:07:20.269Z"; const line = JSON.stringify({ @@ -128,6 +143,19 @@ describe("openclaw ui stdout parser", () => { }, ]); }); + + it("maps stderr-prefixed lines to stderr transcript entries", () => { + const ts = "2026-03-05T23:07:20.269Z"; + const line = "stderr OpenClaw transport error"; + + expect(parseOpenClawStdoutLine(line, ts)).toEqual([ + { + kind: "stderr", + ts, + text: "OpenClaw transport error", + }, + ]); + }); }); describe("openclaw adapter execute", () => { diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index d9db179d..190713b3 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -97,8 +97,20 @@ function parseStdoutChunk( pendingByRun.set(pendingKey, split.pop() ?? ""); const adapter = getUIAdapter(run.adapterType); - const summarized: Array<{ text: string; tone: FeedTone; thinkingDelta?: boolean }> = []; + const summarized: Array<{ text: string; tone: FeedTone; thinkingDelta?: boolean; assistantDelta?: boolean }> = []; const appendSummary = (entry: TranscriptEntry) => { + if (entry.kind === "assistant" && entry.delta) { + const text = entry.text; + if (!text.trim()) return; + const last = summarized[summarized.length - 1]; + if (last && last.assistantDelta) { + last.text += text; + } else { + summarized.push({ text, tone: "assistant", assistantDelta: true }); + } + return; + } + if (entry.kind === "thinking" && entry.delta) { const text = entry.text; if (!text.trim()) return;