Files
deepseek-harness/docs/core-data-structures/user-interaction.md
creatixchu 6d7bd7e703 fix(web): keep the plan card to decisions it can actually answer
Review follow-ups on the presentation intent.

The card claimed any single-question request declaring the intent, then sent one
of two labels — so a producer offering a third option, or a multi-select batch,
lost answers the generic flow would have shown. That contradicts the intent's
own contract, so `planReviewOf` now claims only a binary single choice and
leaves everything else to the flow that can express it.

`ask()` also rejects a plan-review intent on a question with no `detail`: the
intent declares detail IS the plan, and without one a honouring UI asks the
user to approve something invisible. The client keeps its own fallback — it sits
downstream of a wire boundary — but the misconfiguration now fails at the asker.

`planReviewOf` stops being a value export of the client contract face (client
export discipline: pure helpers stay internal; the tests already import it
relatively), and the ui-question README fallback list, both languages, now
states every condition the code enforces.
2026-07-30 19:38:18 +08:00

5.6 KiB

User Interaction

English | 中文

The user-interaction seam of dsh-user-interaction. It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active UserInteractionProvider; dsh-tui uses keyboard-driven overlays and the host runtime relays requests to its connected client.

Source: packages/ui/user-interaction/src/index.ts

Question options

AskUserQuestionOption is the selectable-choice shape. label is the user-facing option text and also the model-facing selected value; description is optional UI help text.

/** One selectable answer offered to the user. */
interface AskUserQuestionOption {
  /** User-facing label. */
  label: string
  /** Optional extra context rendered by capable UIs. */
  description?: string
}

Presentation intent

AskUserQuestionIntent is the optional declaration that a question IS a decision of a known shape. It is tagged on kind so intents can be added; a UI that does not recognise a tag renders the generic option list. An intent shapes presentation only — a UI honouring it answers with the same option labels a generic UI would send, so the caller reads one answer shape either way. approve names the affirmative option instead of relying on option order. ask() rejects the two assertions no type can carry: an approve naming none of its own question's options, and an intent on a question with no detail.

/**
 * A caller-declared presentation intent: the question IS a decision of this
 * shape, so a UI that recognises the tag may present it as such instead of as a
 * generic option list. Tagged so further intents can be added; a UI that does
 * not know a tag renders the generic flow, and the answer encoding is identical
 * either way — an intent shapes presentation only, never the protocol.
 */
type AskUserQuestionIntent = {
  /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */
  kind: 'plan-review'
  /**
   * The option label that approves the plan; every other option declines it.
   * Named rather than positional so no UI infers the verdict from option order.
   * An `approve` naming no option of its own question is rejected at `ask()`.
   */
  approve: string
}

Question item

AskUserQuestionItem is one question in a request. The caller supplies a stable id, which is echoed back with the answer so batched questions remain routable. Optional detail carries supporting text that providers render with the question but keep out of selectable option labels.

/** One question in a user-interaction request. */
interface AskUserQuestionItem {
  /** Stable caller-provided question id, echoed in the answer. */
  id: string
  /** The question to display. */
  question: string
  /** Optional supporting detail rendered with the question but kept out of option labels. */
  detail?: string
  /** Optional short heading/group label. */
  header?: string
  /** Optional choices the UI can render as a menu. */
  options?: AskUserQuestionOption[]
  /** Whether more than one option may be selected. Defaults to single-select. */
  multiSelect?: boolean
  /** Optional presentation intent for capable UIs; absent asks for the generic option list. */
  intent?: AskUserQuestionIntent
}

Ask request

AskUserQuestionRequest is the cross-package request. questions is an array so a UI can present related prompts in one flow while preserving a stable id per answer.

/** Request for a human answer. */
interface AskUserQuestionRequest {
  /** Questions to display. */
  questions: AskUserQuestionItem[]
  /** Calling agent, when the request came from an agent tool call. */
  agent?: Agent
  /** Abort signal for the owning tool/step. */
  signal?: AbortSignal
}

Answer

Providers return one answer item per question id. selected contains selected option labels, and custom carries a free-form "Other" answer when the user typed one. When custom is present, selected is empty; custom text is an answer override, not a supplement to selected choices. A UI may also use an item with empty selected and no custom to preserve a skipped question in an otherwise completed batch.

/** Answer to one question. */
interface AskUserQuestionAnswerItem {
  /** The answered question id. */
  id: string
  /** Selected option labels. Empty for custom or unanswered choices. */
  selected: string[]
  /** Optional free-text "Other" answer. */
  custom?: string
}
/** The human's answer. */
interface AskUserQuestionAnswer {
  /** Structured answers keyed by question id. */
  answers: AskUserQuestionAnswerItem[]
}

Provider

Only one provider may be active in a context. Provider registration is effect-bound so HMR/disposal removes the active UI.

/** UI-side provider for user questions. */
interface UserInteractionProvider {
  ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
}

Errors

UserInteractionError extends HarnessError, so ctx.tools.execute() preserves { name, code } for model-facing tool failures such as EMPTY_QUESTIONS, NO_PROVIDER, ASK_ABORTED, or UI-side cancellation.

/** Stable error taxonomy for user-interaction failures. */
class UserInteractionError extends HarnessError {
  constructor(message: string, code: string, options?: ErrorOptions) {
    super(message, code, options)
    this.name = 'UserInteractionError'
  }
}