seqcolyte / studio /src /lib /claudeStream.ts
seqmachines's picture
Deploy Seqcolyte Studio
ff34739 verified
Raw
History Blame Contribute Delete
5 kB
import readline from "node:readline";
import type { UIMessageStreamWriter } from "ai";
export interface ClaudeStreamMeta {
sessionId?: string;
costUsd?: number;
resultText?: string;
isError?: boolean;
}
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n) + `\n… (${s.length - n} more chars)` : s;
}
function stringifyToolResult(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((c) =>
c && typeof c === "object" && "text" in c
? String((c as { text: unknown }).text)
: JSON.stringify(c),
)
.join("\n");
}
return JSON.stringify(content);
}
/**
* Bridge a `claude -p --output-format stream-json --verbose --include-partial-messages`
* NDJSON stream into AI SDK UI-message chunks.
*
* Mapping (verified against claude 2.1.x):
* - system/init -> capture session_id (for --resume)
* - stream_event text_delta on a text block -> text-start / text-delta / text-end
* - assistant tool_use blocks -> tool-input-available (dynamic)
* - user tool_result blocks -> tool-output-available
* - result -> capture cost + session + final text
* thinking blocks and non-text deltas are dropped.
*/
export async function bridgeClaudeStream(
stdout: NodeJS.ReadableStream,
writer: UIMessageStreamWriter,
): Promise<ClaudeStreamMeta> {
const rl = readline.createInterface({ input: stdout, crlfDelay: Infinity });
const meta: ClaudeStreamMeta = {};
const openText = new Map<number, string>(); // block index -> text part id
const emittedTools = new Set<string>();
let textCounter = 0;
const closeAllText = () => {
for (const id of openText.values()) writer.write({ type: "text-end", id });
openText.clear();
};
for await (const raw of rl) {
const line = raw.trim();
if (!line) continue;
let ev: Record<string, unknown>;
try {
ev = JSON.parse(line) as Record<string, unknown>;
} catch {
continue;
}
switch (ev.type) {
case "system": {
if (ev.subtype === "init" && typeof ev.session_id === "string") {
meta.sessionId = ev.session_id;
}
break;
}
case "stream_event": {
const e = ev.event as
| { type: string; index?: number; content_block?: { type?: string }; delta?: { type?: string; text?: string } }
| undefined;
if (!e) break;
const idx = e.index ?? 0;
if (e.type === "content_block_start") {
if (e.content_block?.type === "text") {
const id = `t${textCounter++}`;
openText.set(idx, id);
writer.write({ type: "text-start", id });
}
} else if (e.type === "content_block_delta") {
if (e.delta?.type === "text_delta" && openText.has(idx)) {
writer.write({ type: "text-delta", id: openText.get(idx)!, delta: e.delta.text ?? "" });
}
} else if (e.type === "content_block_stop") {
const id = openText.get(idx);
if (id) {
writer.write({ type: "text-end", id });
openText.delete(idx);
}
} else if (e.type === "message_stop") {
closeAllText();
}
break;
}
case "assistant": {
const content = (ev.message as { content?: Array<Record<string, unknown>> } | undefined)?.content ?? [];
for (const block of content) {
if (block.type === "tool_use" && typeof block.id === "string" && !emittedTools.has(block.id)) {
emittedTools.add(block.id);
writer.write({
type: "tool-input-available",
toolCallId: block.id,
toolName: String(block.name ?? "tool"),
input: block.input ?? {},
dynamic: true,
});
}
}
break;
}
case "user": {
const content =
(ev.message as { content?: Array<Record<string, unknown>> } | undefined)?.content ??
(ev.content as Array<Record<string, unknown>> | undefined) ??
[];
for (const block of content) {
if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
writer.write({
type: "tool-output-available",
toolCallId: block.tool_use_id,
output: truncate(stringifyToolResult(block.content), 4000),
dynamic: true,
});
}
}
break;
}
case "result": {
if (typeof ev.session_id === "string") meta.sessionId = ev.session_id;
if (typeof ev.total_cost_usd === "number") meta.costUsd = ev.total_cost_usd;
if (typeof ev.result === "string") meta.resultText = ev.result;
if (ev.is_error === true || ev.subtype === "error_max_turns") meta.isError = true;
break;
}
}
}
closeAllText();
return meta;
}