/** * Pure conversion helpers for the Factory Droid backend. * * Everything here is side-effect free so tests can exercise the exact * serialization/parsing logic without HTTP or a live droid. */ import type { FactoryContentBlock, FactoryMessage, FactoryTokenUsage, MessageLike, } from "./types.ts" export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } export function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined } export function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined } // --------------------------------------------------------------------------- // User message extraction // --------------------------------------------------------------------------- function textPartToText(value: unknown): string { if (typeof value === "string") return value if (!isRecord(value)) return "" const text = stringValue(value.text) if (text) return text const content = value.content if (typeof content === "string") return content if (Array.isArray(content)) return content .map((part) => textPartToText(part)) .filter(Boolean) .join("\n") return "" } function messageText(message: MessageLike): string { const content = message.content if (typeof content === "string") return content if (Array.isArray(content)) return content .map((part) => textPartToText(part)) .filter(Boolean) .join("\n") return "" } /** * Extract the LAST user message's text from a pi conversation context. * * Factory sessions are stateful: the agent loop retains the full conversation * server-side, so per turn we send only the newest user message instead of * replaying the entire history (which would duplicate the context). */ export function lastUserMessageText( messages: readonly MessageLike[] | undefined, ): string { if (!messages) return "" for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (message?.role === "user") { const text = messageText(message).trim() if (text) return text } } // Fallback: no explicit user message — flatten whatever we have. return (messages ?? []) .map((message) => messageText(message)) .filter(Boolean) .join("\n") } // --------------------------------------------------------------------------- // Assistant content extraction // --------------------------------------------------------------------------- /** Whether an assistant message's content is a text block. */ export function isTextBlock( block: FactoryContentBlock, ): block is { type: "text"; text: string } { return block.type === "text" } export function isThinkingBlock( block: FactoryContentBlock, ): block is { type: "thinking"; thinking: string } { return block.type === "thinking" } /** * Extract the plain assistant text from a list of Factory messages. * Only `text` blocks are included — tool_use/tool_result blocks are the * DROID's own internal agent actions and must NOT be surfaced as pi tool * calls (they ran inside the Factory computer, not on pi's side). */ export function assistantTextFromMessages(messages: readonly FactoryMessage[]): string { const parts: string[] = [] for (const message of messages) { if (message.role !== "assistant") continue for (const block of message.content ?? []) { if (isTextBlock(block)) parts.push(block.text) } } return parts.join("\n") } /** Extract thinking text blocks (shown as pi thinking events). */ export function assistantThinkingFromMessages( messages: readonly FactoryMessage[], ): string { const parts: string[] = [] for (const message of messages) { if (message.role !== "assistant") continue for (const block of message.content ?? []) { if (isThinkingBlock(block)) parts.push(block.thinking) } } return parts.join("\n") } // --------------------------------------------------------------------------- // Usage deltas // --------------------------------------------------------------------------- /** * Compute the per-turn token usage delta: current cumulative session usage * minus the last-seen cumulative usage for that session. Falls back to the * full cumulative values when we have no baseline (first turn). */ export function usageDelta( current: FactoryTokenUsage | undefined, last: FactoryTokenUsage | undefined, ): FactoryTokenUsage { const get = ( value: FactoryTokenUsage | undefined, key: keyof FactoryTokenUsage, ): number => (typeof value?.[key] === "number" ? (value[key] as number) : 0) return { inputTokens: Math.max(0, get(current, "inputTokens") - get(last, "inputTokens")), outputTokens: Math.max( 0, get(current, "outputTokens") - get(last, "outputTokens"), ), cacheCreationTokens: Math.max( 0, get(current, "cacheCreationTokens") - get(last, "cacheCreationTokens"), ), cacheReadTokens: Math.max( 0, get(current, "cacheReadTokens") - get(last, "cacheReadTokens"), ), thinkingTokens: Math.max( 0, get(current, "thinkingTokens") - get(last, "thinkingTokens"), ), factoryCredits: Math.max( 0, get(current, "factoryCredits") - get(last, "factoryCredits"), ), } } /** Factory REST error body → human message. */ export function errorMessageFromBody(body: unknown, status: number): string { if (isRecord(body)) { const detail = stringValue(body.detail) if (detail) return detail const message = stringValue(body.message) if (message) return message } return `Factory API error (status ${status})` } /** * Map a Factory session status to a pi stop reason. * Factory turns end "normally" (agent loop finished); we surface stop. */ export function mapFinishStatus(_status: unknown): "stop" { return "stop" }