agent-orchestrator/ui-electrobun/src/bun/message-adapter.ts
Hibryda 29a3370e79 fix(electrobun): address all 20 Codex review findings
CRITICAL:
- PTY leak: Terminal.svelte now calls pty.close on destroy, not just unsubscribe
- Agent session cleanup: clearSession() removes done/error sessions, backend
  deletes after 60s grace period

HIGH:
- Clone branch passthrough: user's branch name flows through callback
- Circular imports: extracted rpc.ts singleton, broke main.ts ↔ App.svelte cycle
- Settings wired to runtime: Terminal reads cursor/scrollback from settings
- Security disclaimer: added "prototype — not system keyring" notice
- ThemeEditor: fixed basePalette → initialPalette reference

MEDIUM:
- Clone race: UUID suffix instead of count-based index
- Silent failures: structured error returns from PTY handlers
- WebKitGTK mount: only current + previous group mounted
- Debug listeners: gated behind DEBUG, cleanup on destroy
- NDJSON residual buffer parsed on process exit
- Codex adapter: deduplicated tool_call/tool_result
- extraEnv: rejects CLAUDE*/CODEX*/OLLAMA* keys
- settings-db: runMigrations() with version tracking
- active_group: persisted via settings.set

LOW:
- Removed dead demo code, unused variables
- color-mix() fallbacks added
2026-03-22 01:20:23 +01:00

499 lines
13 KiB
TypeScript

// Message Adapter — parses provider-specific NDJSON events into common AgentMessage format
// Standalone for Bun process (no Svelte/Tauri deps). Mirrors the Tauri adapter layer.
// ── Type guards ──────────────────────────────────────────────────────────────
function str(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
}
function num(v: unknown, fallback = 0): number {
return typeof v === "number" ? v : fallback;
}
// ── Types ────────────────────────────────────────────────────────────────────
export type AgentMessageType =
| "init"
| "text"
| "thinking"
| "tool_call"
| "tool_result"
| "status"
| "compaction"
| "cost"
| "error"
| "unknown";
export interface AgentMessage {
id: string;
type: AgentMessageType;
parentId?: string;
content: unknown;
timestamp: number;
}
export type ProviderId = "claude" | "codex" | "ollama";
// ── Public API ───────────────────────────────────────────────────────────────
/** Parse a raw NDJSON event from a sidecar runner into AgentMessage[] */
export function parseMessage(
provider: ProviderId,
raw: Record<string, unknown>,
): AgentMessage[] {
switch (provider) {
case "claude":
return adaptClaudeMessage(raw);
case "codex":
return adaptCodexMessage(raw);
case "ollama":
return adaptOllamaMessage(raw);
default:
return adaptClaudeMessage(raw);
}
}
// ── Claude adapter ───────────────────────────────────────────────────────────
function adaptClaudeMessage(raw: Record<string, unknown>): AgentMessage[] {
const uuid = str(raw.uuid) || crypto.randomUUID();
const ts = Date.now();
const parentId =
typeof raw.parent_tool_use_id === "string"
? raw.parent_tool_use_id
: undefined;
switch (raw.type) {
case "system":
return adaptClaudeSystem(raw, uuid, ts);
case "assistant":
return adaptClaudeAssistant(raw, uuid, ts, parentId);
case "user":
return adaptClaudeUser(raw, uuid, ts, parentId);
case "result":
return adaptClaudeResult(raw, uuid, ts);
default:
return [{ id: uuid, type: "unknown", content: raw, timestamp: ts }];
}
}
function adaptClaudeSystem(
raw: Record<string, unknown>,
uuid: string,
ts: number,
): AgentMessage[] {
const subtype = str(raw.subtype);
if (subtype === "init") {
return [
{
id: uuid,
type: "init",
content: {
sessionId: str(raw.session_id),
model: str(raw.model),
cwd: str(raw.cwd),
tools: Array.isArray(raw.tools)
? raw.tools.filter((t): t is string => typeof t === "string")
: [],
},
timestamp: ts,
},
];
}
if (subtype === "compact_boundary") {
const meta =
typeof raw.compact_metadata === "object" && raw.compact_metadata !== null
? (raw.compact_metadata as Record<string, unknown>)
: {};
return [
{
id: uuid,
type: "compaction",
content: {
trigger: str(meta.trigger, "auto"),
preTokens: num(meta.pre_tokens),
},
timestamp: ts,
},
];
}
return [
{
id: uuid,
type: "status",
content: {
subtype,
message: typeof raw.status === "string" ? raw.status : undefined,
},
timestamp: ts,
},
];
}
function adaptClaudeAssistant(
raw: Record<string, unknown>,
uuid: string,
ts: number,
parentId?: string,
): AgentMessage[] {
const msg =
typeof raw.message === "object" && raw.message !== null
? (raw.message as Record<string, unknown>)
: undefined;
if (!msg) return [];
const content = Array.isArray(msg.content)
? (msg.content as Array<Record<string, unknown>>)
: undefined;
if (!content) return [];
const messages: AgentMessage[] = [];
for (const block of content) {
switch (block.type) {
case "text":
messages.push({
id: `${uuid}-text-${messages.length}`,
type: "text",
parentId,
content: { text: str(block.text) },
timestamp: ts,
});
break;
case "thinking":
messages.push({
id: `${uuid}-think-${messages.length}`,
type: "thinking",
parentId,
content: { text: str(block.thinking ?? block.text) },
timestamp: ts,
});
break;
case "tool_use":
messages.push({
id: `${uuid}-tool-${messages.length}`,
type: "tool_call",
parentId,
content: {
toolUseId: str(block.id),
name: str(block.name),
input: block.input,
},
timestamp: ts,
});
break;
}
}
return messages;
}
function adaptClaudeUser(
raw: Record<string, unknown>,
uuid: string,
ts: number,
parentId?: string,
): AgentMessage[] {
const msg =
typeof raw.message === "object" && raw.message !== null
? (raw.message as Record<string, unknown>)
: undefined;
if (!msg) return [];
const content = Array.isArray(msg.content)
? (msg.content as Array<Record<string, unknown>>)
: undefined;
if (!content) return [];
const messages: AgentMessage[] = [];
for (const block of content) {
if (block.type === "tool_result") {
messages.push({
id: `${uuid}-result-${messages.length}`,
type: "tool_result",
parentId,
content: {
toolUseId: str(block.tool_use_id),
output: block.content ?? raw.tool_use_result,
},
timestamp: ts,
});
}
}
return messages;
}
function adaptClaudeResult(
raw: Record<string, unknown>,
uuid: string,
ts: number,
): AgentMessage[] {
const usage =
typeof raw.usage === "object" && raw.usage !== null
? (raw.usage as Record<string, unknown>)
: undefined;
return [
{
id: uuid,
type: "cost",
content: {
totalCostUsd: num(raw.total_cost_usd),
durationMs: num(raw.duration_ms),
inputTokens: num(usage?.input_tokens),
outputTokens: num(usage?.output_tokens),
numTurns: num(raw.num_turns),
isError: raw.is_error === true,
result: typeof raw.result === "string" ? raw.result : undefined,
},
timestamp: ts,
},
];
}
// ── Codex adapter ────────────────────────────────────────────────────────────
function adaptCodexMessage(raw: Record<string, unknown>): AgentMessage[] {
const ts = Date.now();
const uuid = crypto.randomUUID();
switch (raw.type) {
case "thread.started":
return [
{
id: uuid,
type: "init",
content: {
sessionId: str(raw.thread_id),
model: "",
cwd: "",
tools: [],
},
timestamp: ts,
},
];
case "turn.started":
return [
{
id: uuid,
type: "status",
content: { subtype: "turn_started" },
timestamp: ts,
},
];
case "turn.completed": {
const usage =
typeof raw.usage === "object" && raw.usage !== null
? (raw.usage as Record<string, unknown>)
: {};
return [
{
id: uuid,
type: "cost",
content: {
totalCostUsd: 0,
durationMs: 0,
inputTokens: num(usage.input_tokens),
outputTokens: num(usage.output_tokens),
numTurns: 1,
isError: false,
},
timestamp: ts,
},
];
}
case "turn.failed":
return [
{
id: uuid,
type: "error",
content: {
message: str(
(raw.error as Record<string, unknown>)?.message,
"Turn failed",
),
},
timestamp: ts,
},
];
case "item.started":
case "item.updated":
case "item.completed":
return adaptCodexItem(raw, uuid, ts);
case "error":
return [
{
id: uuid,
type: "error",
content: { message: str(raw.message, "Unknown error") },
timestamp: ts,
},
];
default:
return [{ id: uuid, type: "unknown", content: raw, timestamp: ts }];
}
}
function adaptCodexItem(
raw: Record<string, unknown>,
uuid: string,
ts: number,
): AgentMessage[] {
const item =
typeof raw.item === "object" && raw.item !== null
? (raw.item as Record<string, unknown>)
: {};
const itemType = str(item.type);
const eventType = str(raw.type);
switch (itemType) {
case "agent_message":
if (eventType !== "item.completed") return [];
return [
{
id: uuid,
type: "text",
content: { text: str(item.text) },
timestamp: ts,
},
];
case "reasoning":
if (eventType !== "item.completed") return [];
return [
{
id: uuid,
type: "thinking",
content: { text: str(item.text) },
timestamp: ts,
},
];
case "command_execution": {
// Fix #13: Only emit tool_call on item.started, tool_result on item.completed
// Prevents duplicate tool_call messages.
const messages: AgentMessage[] = [];
const toolUseId = str(item.id, uuid);
if (eventType === "item.started") {
messages.push({
id: `${uuid}-call`,
type: "tool_call",
content: {
toolUseId,
name: "Bash",
input: { command: str(item.command) },
},
timestamp: ts,
});
}
if (eventType === "item.completed") {
messages.push({
id: `${uuid}-result`,
type: "tool_result",
content: {
toolUseId,
output: str(item.aggregated_output),
},
timestamp: ts,
});
}
return messages;
}
default:
return [];
}
}
// ── Ollama adapter ───────────────────────────────────────────────────────────
function adaptOllamaMessage(raw: Record<string, unknown>): AgentMessage[] {
const ts = Date.now();
const uuid = crypto.randomUUID();
switch (raw.type) {
case "system": {
const subtype = str(raw.subtype);
if (subtype === "init") {
return [
{
id: uuid,
type: "init",
content: {
sessionId: str(raw.session_id),
model: str(raw.model),
cwd: str(raw.cwd),
tools: [],
},
timestamp: ts,
},
];
}
return [
{
id: uuid,
type: "status",
content: {
subtype,
message: typeof raw.status === "string" ? raw.status : undefined,
},
timestamp: ts,
},
];
}
case "chunk": {
const messages: AgentMessage[] = [];
const msg =
typeof raw.message === "object" && raw.message !== null
? (raw.message as Record<string, unknown>)
: {};
const done = raw.done === true;
const thinking = str(msg.thinking);
if (thinking) {
messages.push({
id: `${uuid}-think`,
type: "thinking",
content: { text: thinking },
timestamp: ts,
});
}
const text = str(msg.content);
if (text) {
messages.push({
id: `${uuid}-text`,
type: "text",
content: { text },
timestamp: ts,
});
}
if (done) {
const evalDuration = num(raw.eval_duration);
const durationMs =
evalDuration > 0 ? Math.round(evalDuration / 1_000_000) : 0;
messages.push({
id: `${uuid}-cost`,
type: "cost",
content: {
totalCostUsd: 0,
durationMs,
inputTokens: num(raw.prompt_eval_count),
outputTokens: num(raw.eval_count),
numTurns: 1,
isError: str(raw.done_reason) === "error",
},
timestamp: ts,
});
}
return messages;
}
case "error":
return [
{
id: uuid,
type: "error",
content: { message: str(raw.message, "Ollama error") },
timestamp: ts,
},
];
default:
return [{ id: uuid, type: "unknown", content: raw, timestamp: ts }];
}
}