BTerminal/v2/src/lib/adapters/sdk-messages.ts

265 lines
6.6 KiB
TypeScript

// SDK Message Adapter — insulates UI from Claude Agent SDK wire format changes
// This is the ONLY place that knows SDK internals.
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 interface InitContent {
sessionId: string;
model: string;
cwd: string;
tools: string[];
}
export interface TextContent {
text: string;
}
export interface ThinkingContent {
text: string;
}
export interface ToolCallContent {
toolUseId: string;
name: string;
input: unknown;
}
export interface ToolResultContent {
toolUseId: string;
output: unknown;
}
export interface StatusContent {
subtype: string;
message?: string;
}
export interface CostContent {
totalCostUsd: number;
durationMs: number;
inputTokens: number;
outputTokens: number;
numTurns: number;
isError: boolean;
result?: string;
errors?: string[];
}
export interface CompactionContent {
trigger: 'manual' | 'auto';
preTokens: number;
}
export interface ErrorContent {
message: string;
}
/** Runtime guard — returns value if it's a string, fallback otherwise */
function str(v: unknown, fallback = ''): string {
return typeof v === 'string' ? v : fallback;
}
/** Runtime guard — returns value if it's a number, fallback otherwise */
function num(v: unknown, fallback = 0): number {
return typeof v === 'number' ? v : fallback;
}
/**
* Adapt a raw SDK stream-json message to our internal format.
* When SDK changes wire format, only this function needs updating.
*/
export function adaptSDKMessage(raw: Record<string, unknown>): AgentMessage[] {
const uuid = str(raw.uuid) || crypto.randomUUID();
const timestamp = Date.now();
const parentId = typeof raw.parent_tool_use_id === 'string' ? raw.parent_tool_use_id : undefined;
switch (raw.type) {
case 'system':
return adaptSystemMessage(raw, uuid, timestamp);
case 'assistant':
return adaptAssistantMessage(raw, uuid, timestamp, parentId);
case 'user':
return adaptUserMessage(raw, uuid, timestamp, parentId);
case 'result':
return adaptResultMessage(raw, uuid, timestamp);
default:
return [{
id: uuid,
type: 'unknown',
content: raw,
timestamp,
}];
}
}
function adaptSystemMessage(
raw: Record<string, unknown>,
uuid: string,
timestamp: 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') : [],
} satisfies InitContent,
timestamp,
}];
}
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') as 'manual' | 'auto',
preTokens: num(meta.pre_tokens),
} satisfies CompactionContent,
timestamp,
}];
}
return [{
id: uuid,
type: 'status',
content: {
subtype,
message: typeof raw.status === 'string' ? raw.status : undefined,
} satisfies StatusContent,
timestamp,
}];
}
function adaptAssistantMessage(
raw: Record<string, unknown>,
uuid: string,
timestamp: number,
parentId?: string,
): AgentMessage[] {
const messages: AgentMessage[] = [];
const msg = typeof raw.message === 'object' && raw.message !== null ? raw.message as Record<string, unknown> : undefined;
if (!msg) return messages;
const content = Array.isArray(msg.content) ? msg.content as Array<Record<string, unknown>> : undefined;
if (!content) return messages;
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) } satisfies TextContent,
timestamp,
});
break;
case 'thinking':
messages.push({
id: `${uuid}-think-${messages.length}`,
type: 'thinking',
parentId,
content: { text: str(block.thinking ?? block.text) } satisfies ThinkingContent,
timestamp,
});
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,
} satisfies ToolCallContent,
timestamp,
});
break;
}
}
return messages;
}
function adaptUserMessage(
raw: Record<string, unknown>,
uuid: string,
timestamp: number,
parentId?: string,
): AgentMessage[] {
const messages: AgentMessage[] = [];
const msg = typeof raw.message === 'object' && raw.message !== null ? raw.message as Record<string, unknown> : undefined;
if (!msg) return messages;
const content = Array.isArray(msg.content) ? msg.content as Array<Record<string, unknown>> : undefined;
if (!content) return messages;
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,
} satisfies ToolResultContent,
timestamp,
});
}
}
return messages;
}
function adaptResultMessage(
raw: Record<string, unknown>,
uuid: string,
timestamp: 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,
errors: Array.isArray(raw.errors) ? raw.errors.filter((e): e is string => typeof e === 'string') : undefined,
} satisfies CostContent,
timestamp,
}];
}