Improve OpenClaw SSE transcript parsing and stream readability

This commit is contained in:
Dotta
2026-03-05 17:26:00 -06:00
parent 5134cac993
commit 81bc8c7313
5 changed files with 206 additions and 4 deletions

View File

@@ -135,7 +135,7 @@ export interface ServerAdapterModule {
// ---------------------------------------------------------------------------
export type TranscriptEntry =
| { kind: "assistant"; ts: string; text: string }
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
| { kind: "user"; ts: string; text: string }
| { kind: "tool_call"; ts: string; name: string; input: unknown }

View File

@@ -1,5 +1,119 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function extractResponseOutputText(response: Record<string, unknown> | null): string {
if (!response) return "";
const output = Array.isArray(response.output) ? response.output : [];
const parts: string[] = [];
for (const itemRaw of output) {
const item = asRecord(itemRaw);
if (!item) continue;
const content = Array.isArray(item.content) ? item.content : [];
for (const partRaw of content) {
const part = asRecord(partRaw);
if (!part) continue;
const type = asString(part.type).trim().toLowerCase();
if (type !== "output_text" && type !== "text" && type !== "refusal") continue;
const text = asString(part.text).trim();
if (text) parts.push(text);
}
}
return parts.join("\n\n").trim();
}
function parseOpenClawSseLine(line: string, ts: string): TranscriptEntry[] {
const match = line.match(/^\[openclaw:sse\]\s+event=([^\s]+)\s+data=(.*)$/s);
if (!match) return [{ kind: "stdout", ts, text: line }];
const eventType = (match[1] ?? "").trim();
const dataText = (match[2] ?? "").trim();
const parsed = asRecord(safeJsonParse(dataText));
const normalizedEventType = eventType.toLowerCase();
if (dataText === "[DONE]") {
return [];
}
const delta = asString(parsed?.delta);
if (normalizedEventType.endsWith(".delta") && delta) {
return [{ kind: "assistant", ts, text: delta, delta: true }];
}
if (
normalizedEventType.includes("error") ||
normalizedEventType.includes("failed") ||
normalizedEventType.includes("cancel")
) {
const message =
asString(parsed?.error).trim() ||
asString(parsed?.message).trim() ||
dataText;
return message ? [{ kind: "stderr", ts, text: message }] : [];
}
if (normalizedEventType === "response.completed" || normalizedEventType.endsWith(".completed")) {
const response = asRecord(parsed?.response);
const usage = asRecord(response?.usage);
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();
const isError =
statusLower === "failed" ||
statusLower === "error" ||
statusLower === "cancelled";
return [{
kind: "result",
ts,
text: extractResponseOutputText(response),
inputTokens: asNumber(usage?.input_tokens),
outputTokens: asNumber(usage?.output_tokens),
cachedTokens: asNumber(usage?.cached_input_tokens),
costUsd: asNumber(usage?.cost_usd, asNumber(usage?.total_cost_usd)),
subtype: status || eventType,
isError,
errors: errorText ? [errorText] : [],
}];
}
return [];
}
export function parseOpenClawStdoutLine(line: string, ts: string): TranscriptEntry[] {
const trimmed = line.trim();
if (!trimmed) return [];
if (trimmed.startsWith("[openclaw:sse]")) {
return parseOpenClawSseLine(trimmed, ts);
}
if (trimmed.startsWith("[openclaw]")) {
return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw\]\s*/, "") }];
}
return [{ kind: "stdout", ts, text: line }];
}