/** * Pure translation between harness vocabulary and ACP wire types. No I/O, no * Cordis context — every function here is total and unit-testable in isolation. * Keeping the mapping pure is deliberate: the SDK rejects an unknown * `stopReason`, so the {@link turnEndToStopReason} total function (with its * exhaustive test over every `TurnEndReason` kind) is the guard that a turn * always settles to a legal wire value. * * @module @deepseek-ai/dsh-acp/codec */ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk' /** * Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum. * * The mapping is total over the kinds the loop actually produces today * (`completed`/`aborted`/`error`/`disposed`/`max-tokens`/`rejected`). * `TurnEndReason` is * merge-extensible, so an unknown future kind falls through to `end_turn` — * the safest default (the turn DID end; we just lack a more specific wire * reason) — rather than throwing into the SDK, which would reject an unknown * `stopReason` and break the prompt RPC. When a new kind gains a dedicated ACP * reason (e.g. a future `refusal` → `refusal`), add an explicit case here. * * - `completed` → `end_turn` (the model chose to stop) * - `max-tokens` → `max_tokens` (cut off at the output-token ceiling) * - `aborted` → `cancelled` (a step abort or a queue-aware `agent.cancel()`, e.g. from `session/cancel`) * - `error` → `end_turn` (defensive fallback only: the bridge REJECTS the * `session/prompt` RPC on an error turn BEFORE calling this, so * a client sees a JSON-RPC error, not a stop reason — see * `rejectPrompt` in index.ts. This case keeps the function total * for any non-bridge caller / property test.) * - `disposed` → `cancelled` (the agent was torn down mid-turn — closest to a * cancellation from the client's perspective) * - `rejected` → `cancelled` (the prompt was blocked by an `agent/prompt-submit` * hook before any step ran — ACP has no "rejected" reason, and a * blocked prompt is, from the client's view, the prompt not being * carried out; `cancelled` is the closest legal wire reason) * @param reason - the harness turn-end reason to translate. * @returns the legal ACP wire value per the mapping above. */ export function turnEndToStopReason(reason: TurnEndReason): StopReason { switch (reason.kind) { case 'completed': return 'end_turn' case 'max-tokens': return 'max_tokens' case 'aborted': return 'cancelled' case 'disposed': return 'cancelled' case 'rejected': return 'cancelled' case 'error': return 'end_turn' // Merge-extensible: an unknown future TurnEndReason kind still has to // produce a legal wire value (the SDK rejects unknown stopReason), so // default to end_turn rather than assertNever. Add an explicit case when a // new kind gains a dedicated ACP reason. default: return 'end_turn' } } /** * Translate a harness {@link ContentBlock} from a prompt into ACP content for * replay, or `undefined` for block kinds the bridge does not surface to the * client as message content. Today only `text` maps; `resource_link` is an * ACP prompt-only input rendered into text by {@link acpPromptToText}; * `reasoning` is surfaced via `agent_thought_chunk` * streaming rather than as a message block, and `tool-call`/`tool-result` * are handled by the tool-call update path. * @param block - the harness content block to translate. * @returns the ACP block, or `undefined` for a kind with no message-content mapping. */ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined { switch (block.type) { case 'text': return { type: 'text', text: block.text } // reasoning → streamed as agent_thought_chunk, not a message block // tool-call / tool-result → the tool_call / tool_call_update path // plugin-added block types → not surfaced default: return undefined } } /** * Extract plain text from an ACP prompt's content blocks. Text blocks are * concatenated verbatim; resource links become explicit textual references so * baseline ACP clients can point at files without the bridge silently dropping * that context. * @param prompt - the ACP prompt blocks to flatten. * @returns the concatenated text, with resource links rendered as bracketed references. */ export function acpPromptToText(prompt: readonly AcpContentBlock[]): string { return prompt .flatMap((block): string[] => { switch (block.type) { case 'text': return [block.text] case 'resource_link': return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`] default: return [] } }) .join('') } /** * Whether an ACP prompt contains content the bridge cannot accept. Baseline ACP * requires `text` and `resource_link`; richer inline payloads (`resource`, * image, audio, …) are rejected rather than silently dropped. * @param prompt - the ACP prompt blocks to inspect. * @returns `true` when any block is neither `text` nor `resource_link`. */ export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean { return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link') }