Files
deepseek-harness/packages/ui/acp/src/codec.ts
T
Tianyi Cui cd9737d569 Gate JSDoc completeness on every package export
New doc-sync gate verify-export-jsdoc walks every module-level exported
name under packages/*/*/src and requires description prose everywhere,
plus @param per parameter and @returns on non-void annotated returns for
function-like exports, public class methods, properties, and accessors.
The parsing + check helpers move out of gen-cordis-catalog.ts into a
shared scripts/jsdoc.ts so 'documented' means one thing on both gated
surfaces.

Deliberate exemptions (documented in the RFC): heritage-declared class
members (the seam declaration is the doc's one home — the one checker
query in an otherwise pure-AST walk), cordis plugin-protocol slots
(name/inject/reusable/Config/apply, top-level and static), constructors,
overload implementations, declare-module augmentation bodies, and
re-export statements (checked at the defining module).

The 203 under-documented exports the gate found at adoption are filled
in this change, so the gate lands green; generated catalogs/graphs are
regenerated for the shifted line pointers.

RFC: docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md
2026-07-06 22:09:30 +08:00

124 lines
5.5 KiB
TypeScript

/**
* 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')
}