Merge worktree-llm-dynamic-config (884, with latest master) into worktree-llm-web-config
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-question/README.md
|
||||
README.md: 0700375758774610fcd897b9a3e16484206a871d
|
||||
README.zh.md: d9e5eb22cef13e16ab1ce2cebba9e563bd9d08d9
|
||||
README.md: 5ebba2a1da6e6108b82e9deb235b84f987600345
|
||||
README.zh.md: 0aa6428a9b6472fc5b525c11b4716ebc50c378c3
|
||||
|
||||
@@ -6,6 +6,8 @@ Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user`
|
||||
|
||||
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable.
|
||||
|
||||
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
|
||||
|
||||
Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated.
|
||||
|
||||
@@ -6,6 +6,8 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧
|
||||
|
||||
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
|
||||
|
||||
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。
|
||||
|
||||
编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该配置项,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Plan-review takeover: the waiting-approval card language (amber strip on a
|
||||
floating capsule, right-aligned actions) applied to a reviewed plan. Kept as
|
||||
its own module rather than shared with ui-conversation's ApprovalPanel: the
|
||||
two takeovers agree on tokens and geometry, not on content — this one's body
|
||||
is scrollable markdown, that one's is a headline plus a command line. Warn
|
||||
semantics ride the alias state tokens; no hardcoded colors. */
|
||||
|
||||
/* Mirrors the question card's frame so the takeover is a content swap. */
|
||||
.frame {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 24px 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
/* Composer seat sits in a fixed-height conversation column (overflow
|
||||
hidden): cap the card against the viewport and scroll the plan, so the
|
||||
strip and the decision row stay reachable on a long plan. */
|
||||
max-height: min(60vh, 520px);
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
/* Elevated surface in dark: the plan body inside scrolls once the card hits
|
||||
the cap above, so the thumb takes the l2 pair (see ui-theme
|
||||
styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.card,
|
||||
.card * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Tinted full-width header band, as on the approval takeover. */
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
/* The plan is the panel's message: it takes the whole body and the scroll. */
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 12px 16px 4px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
padding: 8px 16px 12px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
min-height: 16px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.frame {
|
||||
padding: 6px 10px 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 10px 12px 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
align-items: flex-end;
|
||||
padding: 8px 12px 10px;
|
||||
}
|
||||
}
|
||||
100
packages/client/ui-question/src/client/PlanReviewPanel.tsx
Normal file
100
packages/client/ui-question/src/client/PlanReviewPanel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
// PlanReviewPanel: the composer takeover for a question carrying the
|
||||
// `plan-review` presentation intent. A plan under review is one decision over
|
||||
// one body of markdown, so it takes the waiting-approval card shape — tinted
|
||||
// strip, content, right-aligned action row — instead of the generic question
|
||||
// flow's pager, numbered options, skip and custom-answer affordances, which
|
||||
// read as a quiz the user is being graded on.
|
||||
//
|
||||
// The three actions are the whole decision surface: approve and decline answer
|
||||
// the question with the option labels the asker offered (localised copy on the
|
||||
// buttons, the asker's descriptions as their tooltips), while "discuss"
|
||||
// dismisses the request so the composer returns and the user can simply say
|
||||
// what they want. Dismissal is the generic flow's own cancel verb, promoted to
|
||||
// a labelled button because in a two-outcome decision it is the third real
|
||||
// answer, not an escape hatch.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts'
|
||||
import css from './PlanReviewPanel.module.css'
|
||||
|
||||
/** The panel's own props: the question domain face, the narrowed review, and the locale seat. */
|
||||
export type PlanReviewPanelProps =
|
||||
{ pending: PendingQuestion; review: PlanReview } & Pick<QuestionComposerProps, 't'>
|
||||
|
||||
/**
|
||||
* Optional-prop spread for a decision button's tooltip: `title` is optional on
|
||||
* the DOM props, and exactOptionalPropertyTypes rejects an explicit undefined.
|
||||
*
|
||||
* @param description - the asker's option description, when it carries one.
|
||||
* @returns The `title` prop to spread, or nothing.
|
||||
*/
|
||||
function tooltip(description: string | undefined): { title?: string } {
|
||||
return description === undefined ? {} : { title: description }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a plan review as a decision card.
|
||||
*
|
||||
* @param props - the question domain face, the narrowed plan review, and `t`.
|
||||
* @returns The plan-review takeover for this request.
|
||||
*/
|
||||
export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
|
||||
// One-shot latch shaped like the approval takeover's: the panel leaves only
|
||||
// when the host's resolved frame lands, so until then a second click must
|
||||
// not re-fire. A failed send (rejected receipt / transport) re-arms it and
|
||||
// shows why, since nothing else would tell the user the click was lost.
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const settle = (send: () => Promise<void>): void => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
void send().catch((cause: unknown) => {
|
||||
setBusy(false)
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
})
|
||||
}
|
||||
const decide = (label: string): void => {
|
||||
settle(() => pending.answer({ answers: [{ id: review.id, selected: [label] }] }))
|
||||
}
|
||||
const decline = review.decline
|
||||
|
||||
return (
|
||||
<div className={css.frame} data-plan-review-key={pending.key}>
|
||||
<section className={css.card} aria-label={review.question}>
|
||||
<div className={css.strip}>
|
||||
<span className={css.dot} />
|
||||
{t('plan.header')}
|
||||
</div>
|
||||
<div className={css.body} data-plan-review-scroll>
|
||||
<MarkdownText text={review.plan} />
|
||||
</div>
|
||||
<div className={css.footer}>
|
||||
<div className={css.feedback} role="status">{error}</div>
|
||||
<div className={css.actions}>
|
||||
<Button
|
||||
size="sm" variant="ghost" icon={<IconEditOutline16 />}
|
||||
disabled={busy} onClick={() => { settle(() => pending.cancel()) }}
|
||||
>
|
||||
{t('plan.discuss')}
|
||||
</Button>
|
||||
{decline !== undefined && (
|
||||
<Button
|
||||
size="sm" variant="outline" {...tooltip(decline.description)}
|
||||
disabled={busy} onClick={() => { decide(decline.label) }}
|
||||
>
|
||||
{t('plan.decline')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm" variant="primary" {...tooltip(review.approve.description)}
|
||||
disabled={busy} onClick={() => { decide(review.approve.label) }}
|
||||
>
|
||||
{t('plan.approve')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
IconCloseOutline16, IconEditOutline16, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
PendingQuestion,
|
||||
PendingQuestion, planReviewOf,
|
||||
type QuestionAnswer, type QuestionComposerProps,
|
||||
} from './contract/slots.ts'
|
||||
import { PlanReviewPanel } from './PlanReviewPanel.tsx'
|
||||
import css from './QuestionComposer.module.css'
|
||||
|
||||
interface DraftAnswer {
|
||||
@@ -46,14 +47,24 @@ function isComposing(event: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement
|
||||
/**
|
||||
* Composer takeover boundary; the carrier key keys local drafts, so a
|
||||
* same-request replay (same key, new carrier object) preserves them.
|
||||
*
|
||||
* One takeover, two shapes: a request that declares a presentation intent this
|
||||
* package renders takes that shape (a plan review is one decision over one
|
||||
* plan, not a question set), and every other request takes the generic flow.
|
||||
* The routing lives here, at the one entry that owns the composer seat, so
|
||||
* neither shape can claim a request the other is already rendering.
|
||||
*
|
||||
* @param props - the selector-matched pending question carrier plus the framework standard kit.
|
||||
* @returns The question flow for this request.
|
||||
* @returns The question flow, or the intent's own surface, for this request.
|
||||
*/
|
||||
export function QuestionComposer(props: QuestionComposerProps) {
|
||||
// Domain-face mint rides the carrier's stable identity (never minted in a
|
||||
// select/render dispatch — per-dispatch minting would churn memo identity).
|
||||
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
|
||||
return <QuestionFlow key={question.key} pending={question} t={props.t} />
|
||||
const review = useMemo(() => planReviewOf(question.questions), [question])
|
||||
return review === undefined
|
||||
? <QuestionFlow key={question.key} pending={question} t={props.t} />
|
||||
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
|
||||
}
|
||||
|
||||
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
|
||||
|
||||
@@ -19,6 +19,71 @@ export type QuestionWait = PendingWait<'question'>
|
||||
/** One structured answer batch covering every question of the request. */
|
||||
export type QuestionAnswer = QuestionResponsePayload['answer']
|
||||
|
||||
/** One question of the request, as the carrier payload carries it. */
|
||||
type QuestionItem = QuestionWait['payload']['questions'][number]
|
||||
|
||||
/** One option the asker offered on a question. */
|
||||
type QuestionOption = NonNullable<QuestionItem['options']>[number]
|
||||
|
||||
/**
|
||||
* A request narrowed to the `plan-review` presentation intent: everything the
|
||||
* decision card renders and answers with, so the panel never re-reads the
|
||||
* request shape. `approve` and `decline` are the asker's own options — an
|
||||
* answer must carry one of those labels verbatim — and `plan` is the markdown
|
||||
* body under review.
|
||||
*/
|
||||
export interface PlanReview {
|
||||
/** The reviewed question's id, echoed in the answer. */
|
||||
id: string
|
||||
/** The question text, kept as the card's accessible name. */
|
||||
question: string
|
||||
/** The plan markdown under review. */
|
||||
plan: string
|
||||
/** The option that approves the plan. */
|
||||
approve: QuestionOption
|
||||
/** The option that declines it; absent when the asker offered no other option. */
|
||||
decline?: QuestionOption
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a request to a renderable plan review, or return undefined to leave it
|
||||
* to the generic question flow.
|
||||
*
|
||||
* The card is one decision over one plan, and it claims a request only when it
|
||||
* can send every answer that request allows — an intent changes the layout,
|
||||
* never which answers are reachable. So the batch must be a single question
|
||||
* that declares the intent, carries the plan as its detail, offers the approve
|
||||
* label the intent names, and is a binary single choice: at most one option
|
||||
* besides approve, and not multi-select. A third option or a multi-select batch
|
||||
* has answers two buttons cannot express, so the generic flow keeps it — as it
|
||||
* keeps any request whose intent the asker's own service would have rejected,
|
||||
* because the client sits downstream of a wire boundary and every request must
|
||||
* stay answerable.
|
||||
*
|
||||
* @param questions - the request's whole question batch.
|
||||
* @returns The narrowed review, or undefined when the generic flow owns it.
|
||||
*/
|
||||
export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | undefined {
|
||||
if (questions.length !== 1) return undefined
|
||||
// Length-checked above; the index read is the narrowing tax, not a guess.
|
||||
const question = questions[0] as QuestionItem
|
||||
const intent = question.intent
|
||||
if (intent?.kind !== 'plan-review' || question.detail === undefined) return undefined
|
||||
if (question.multiSelect === true) return undefined
|
||||
const options = question.options ?? []
|
||||
if (options.length > 2) return undefined
|
||||
const approve = options.find(option => option.label === intent.approve)
|
||||
if (approve === undefined) return undefined
|
||||
const decline = options.find(option => option.label !== intent.approve)
|
||||
return {
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
plan: question.detail,
|
||||
approve,
|
||||
...(decline === undefined ? {} : { decline }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Question domain face over the carrier: render identity and questions
|
||||
* transparently forwarded; answer/cancel own the wire encoding (the ok value
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
* question carrier (matched prop), and the whole behavior surface rides the
|
||||
* carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
|
||||
* the standard locale seat. Export discipline: packages/client/AGENTS.md.
|
||||
*
|
||||
* One entry, two shapes: the composer renders a request that declares a
|
||||
* presentation intent as that intent's own surface (`plan-review` → the plan
|
||||
* decision card) and every other request as the generic question flow. A
|
||||
* separate chain entry per shape would race the same carrier, so the shape
|
||||
* choice lives inside this entry — see QuestionComposer.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -15,7 +21,9 @@ import { QuestionComposer } from './QuestionComposer.tsx'
|
||||
import { en, zh, type QuestionKey } from './locales.ts'
|
||||
|
||||
export { PendingQuestion } from './contract/slots.ts'
|
||||
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
|
||||
export type {
|
||||
PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait,
|
||||
} from './contract/slots.ts'
|
||||
export type { QuestionKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
@@ -11,6 +11,10 @@ export const zh = {
|
||||
'custom.placeholder': '输入你的答案',
|
||||
'action.skip': '跳过本题',
|
||||
'action.next': '下一题',
|
||||
'plan.header': '计划待审',
|
||||
'plan.approve': '确认执行',
|
||||
'plan.decline': '拒绝',
|
||||
'plan.discuss': '去聊天里说',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The question namespace key union. */
|
||||
@@ -27,4 +31,8 @@ export const en = {
|
||||
'custom.placeholder': 'Type your answer',
|
||||
'action.skip': 'Skip this question',
|
||||
'action.next': 'Next',
|
||||
'plan.header': 'Plan review',
|
||||
'plan.approve': 'Approve',
|
||||
'plan.decline': 'Refuse',
|
||||
'plan.discuss': 'Chat about it',
|
||||
} satisfies Record<QuestionKey, string>
|
||||
|
||||
228
packages/client/ui-question/tests/plan-review-panel.spec.tsx
Normal file
228
packages/client/ui-question/tests/plan-review-panel.spec.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
// @vitest-environment jsdom
|
||||
// The plan-review takeover, driven through the composer entry that routes to
|
||||
// it: a request carrying the intent must reach the decision card and answer
|
||||
// with the asker's own option labels, and a request that does not (or cannot)
|
||||
// must keep the generic question flow.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts'
|
||||
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** Seat stub over a dictionary pair mirroring the real lookup chain: package dictionary, then common vocabulary, then the key. */
|
||||
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
|
||||
(key => dict[key] ?? common[key] ?? key)
|
||||
|
||||
/** Framework standard-kit stubs: the panel consumes only the locale seat. */
|
||||
const kit = {
|
||||
sessionId: SID,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
|
||||
useProjection: (() => undefined) as never,
|
||||
useInput: (() => { throw new Error('unused') }) as never,
|
||||
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
|
||||
t: seatOver(zh, commonZh),
|
||||
}
|
||||
|
||||
const PLAN = '# Ship the picker\n\n- read the store\n- render the rows\n'
|
||||
|
||||
/** The plan-mode request shape: one question, the plan as detail, approve named. */
|
||||
const questions = (): QuestionWait['payload']['questions'] => [{
|
||||
id: 'plan-review',
|
||||
header: 'Plan review',
|
||||
question: 'Approve this plan and leave plan mode?',
|
||||
detail: PLAN,
|
||||
options: [
|
||||
{ label: 'Approve', description: 'Leave plan mode; the plan is carried out from the next step.' },
|
||||
{ label: 'Keep planning', description: 'Stay in plan mode; feedback goes back to the model.' },
|
||||
],
|
||||
intent: { kind: 'plan-review', approve: 'Approve' },
|
||||
}]
|
||||
|
||||
/** Carrier fixture over a scripted respond carrier. */
|
||||
function wait(
|
||||
payload: QuestionWait['payload'] = { questions: questions() },
|
||||
respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true })),
|
||||
) {
|
||||
return { carrier: new PendingWait('question', RpcId('q-1'), SID, payload, respond), respond }
|
||||
}
|
||||
|
||||
/** The client-response envelope respond must have received for a decision. */
|
||||
function decidedEnvelope(label: string) {
|
||||
return {
|
||||
type: 'client-response', rpcId: RpcId('q-1'),
|
||||
result: { ok: true, value: { sessionId: SID, answer: { answers: [{ id: 'plan-review', selected: [label] }] } } },
|
||||
}
|
||||
}
|
||||
|
||||
describe('planReviewOf', () => {
|
||||
it('narrows a plan-review request to its decision, options included', () => {
|
||||
expect(planReviewOf(questions())).toEqual({
|
||||
id: 'plan-review',
|
||||
question: 'Approve this plan and leave plan mode?',
|
||||
plan: PLAN,
|
||||
approve: { label: 'Approve', description: 'Leave plan mode; the plan is carried out from the next step.' },
|
||||
decline: { label: 'Keep planning', description: 'Stay in plan mode; feedback goes back to the model.' },
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the decline absent when the asker offered approve alone', () => {
|
||||
const [question] = questions()
|
||||
const review = planReviewOf([{ ...question as object, options: [{ label: 'Approve' }] } as never])
|
||||
expect(review?.approve).toEqual({ label: 'Approve' })
|
||||
expect(review === undefined ? true : 'decline' in review).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a batch of more than one question', () => [...questions(), ...questions()]],
|
||||
['no intent at all', () => [{ ...questions()[0] as object, intent: undefined }]],
|
||||
['an intent without the plan as detail', () => [{ ...questions()[0] as object, detail: undefined }]],
|
||||
['an intent whose approve names no option', () => [{
|
||||
...questions()[0] as object, intent: { kind: 'plan-review', approve: 'Ship it' },
|
||||
}]],
|
||||
['an intent with no options at all', () => [{ ...questions()[0] as object, options: undefined }]],
|
||||
// Two buttons cannot send a third label or a combination, and the generic
|
||||
// flow can: an intent never costs the user a reachable answer.
|
||||
['a third option the card could not offer', () => [{
|
||||
...questions()[0] as object,
|
||||
options: [{ label: 'Approve' }, { label: 'Keep planning' }, { label: 'Start over' }],
|
||||
}]],
|
||||
['a multi-select decision', () => [{ ...questions()[0] as object, multiSelect: true }]],
|
||||
])('declines %s, leaving the request to the generic flow', (_case, build) => {
|
||||
expect(planReviewOf(build() as never)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('declines an empty batch, which the generic flow reports as such', () => {
|
||||
expect(planReviewOf([])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlanReviewPanel', () => {
|
||||
it('renders the plan under a review strip, with none of the quiz affordances', () => {
|
||||
const { carrier } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
expect(document.querySelector('[data-plan-review-key="q:q-1"]')).toBeTruthy()
|
||||
expect(screen.getByText(zh['plan.header'])).toBeTruthy()
|
||||
// The plan renders as markdown, so its heading is a heading.
|
||||
expect(screen.getByRole('heading', { name: 'Ship the picker' })).toBeTruthy()
|
||||
expect(screen.getByText('render the rows')).toBeTruthy()
|
||||
// The question text stays as the card's accessible name rather than a title
|
||||
// that reads like a test item.
|
||||
expect(screen.getByLabelText('Approve this plan and leave plan mode?')).toBeTruthy()
|
||||
// No pager, no numbered options, no skip, no custom answer.
|
||||
expect(screen.queryByText('1 / 1')).toBeNull()
|
||||
expect(screen.queryByRole('radio')).toBeNull()
|
||||
expect(screen.queryByText(zh['action.skip'])).toBeNull()
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
})
|
||||
|
||||
it('answers with the asker\'s approve label and keeps its description as the tooltip', () => {
|
||||
const { carrier, respond } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
const approve = screen.getByRole('button', { name: zh['plan.approve'] })
|
||||
expect(approve.getAttribute('title')).toBe('Leave plan mode; the plan is carried out from the next step.')
|
||||
fireEvent.click(approve)
|
||||
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Approve'))
|
||||
// One-shot: every action locks until the host's resolved frame lands.
|
||||
expect(approve.hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(approve)
|
||||
expect(respond).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('answers with the asker\'s decline label', () => {
|
||||
const { carrier, respond } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] }))
|
||||
expect(respond).toHaveBeenCalledWith(decidedEnvelope('Keep planning'))
|
||||
})
|
||||
|
||||
it('dismisses the request so the composer returns for a plain message', () => {
|
||||
const { carrier, respond } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
type: 'client-response', rpcId: RpcId('q-1'),
|
||||
result: {
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the tooltip for an option carrying no description', () => {
|
||||
const { carrier } = wait({ questions: [{
|
||||
...questions()[0] as object,
|
||||
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
|
||||
}] as never })
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false)
|
||||
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the decline action when the asker offered approve alone', () => {
|
||||
const { carrier } = wait({ questions: [{
|
||||
...questions()[0] as object, options: [{ label: 'Approve' }],
|
||||
}] as never })
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: zh['plan.decline'] })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: zh['plan.approve'] })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('re-arms the actions and says why when the decision does not land', async () => {
|
||||
const { carrier, respond } = wait(
|
||||
{ questions: questions() },
|
||||
vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: false, reason: 'not-pending' })),
|
||||
)
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
|
||||
const failure = await screen.findByText('question response rejected: not-pending')
|
||||
expect(failure.getAttribute('role')).toBe('status')
|
||||
// Re-armed for the retry: a lost click must not leave a dead card.
|
||||
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['plan.approve'] }))
|
||||
expect(respond).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports a non-Error transport failure as its stringified value', async () => {
|
||||
// A non-Error rejection is the case under test: a carrier can reject with
|
||||
// anything, and the panel must still show the user something.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
const { carrier } = wait({ questions: questions() }, vi.fn(() => Promise.reject('socket gone')))
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
|
||||
expect(await screen.findByText('socket gone')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('carries the same decision surface in English', () => {
|
||||
const { carrier } = wait()
|
||||
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
|
||||
|
||||
expect(screen.getByText('Plan review')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Refuse' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Chat about it' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1595,9 +1595,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'AskUserQuestionAnswerItem',
|
||||
declaration: 'export interface AskUserQuestionAnswerItem {\n id: string;\n selected: string[];\n custom?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AskUserQuestionIntent',
|
||||
declaration: 'export type AskUserQuestionIntent = {\n kind: \'plan-review\';\n approve: string;\n};',
|
||||
},
|
||||
{
|
||||
name: 'AskUserQuestionItem',
|
||||
declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n detail?: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}',
|
||||
declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n detail?: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n intent?: AskUserQuestionIntent;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AskUserQuestionOption',
|
||||
|
||||
@@ -23,6 +23,11 @@ export const askUserQuestionItemSchema = z.object({
|
||||
detail: z.string().optional(),
|
||||
options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
|
||||
multiSelect: z.boolean().optional(),
|
||||
// Presentation intent: a tagged union on the wire, so an unknown tag is a
|
||||
// rejected frame rather than a silently generic render.
|
||||
intent: z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('plan-review'), approve: z.string() }),
|
||||
]).optional(),
|
||||
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
|
||||
|
||||
/** Unified message envelope carried by transient queue frames. */
|
||||
|
||||
@@ -399,6 +399,17 @@ describe('events frame schemas', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow()
|
||||
})
|
||||
|
||||
it('carries a question presentation intent through, and rejects an unknown one', () => {
|
||||
const intent = { kind: 'plan-review', approve: 'Approve' }
|
||||
expect(askUserQuestionItemSchema.parse({
|
||||
id: 'plan-review', question: 'Approve?', detail: '# Plan', options: [{ label: 'Approve' }], intent,
|
||||
}).intent).toEqual(intent)
|
||||
// An unrecognised tag is a rejected frame, not a silently generic render.
|
||||
for (const invalid of [{ kind: 'plan-review' }, { kind: 'poll', approve: 'Approve' }, { approve: 'Approve' }]) {
|
||||
expect(() => askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?', intent: invalid })).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a queue snapshot with malformed items', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow()
|
||||
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {} }] })).toThrow()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md
|
||||
README.md: d3c2c14fe616e1c9b4e33b716570b084db6474cf
|
||||
README.zh.md: 6d6878c4b0300a716ad16be60fd86bc79f1514ba
|
||||
README.md: 6f0a9ac477b49b96ddfc2ce667e3556dec727569
|
||||
README.zh.md: 922b153aa1b08e1a6003f63736ea402787bff1dd
|
||||
|
||||
@@ -14,6 +14,8 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc
|
||||
|
||||
While active, `plan:policy` renders the configured `section`. The plugin always registers `exit_plan_mode`, keeping tool schemas stable across the transition; its execute path accepts only active plan mode and leaves it only after an exact user approval through `ctx.userInteraction`.
|
||||
|
||||
The review question declares the `plan-review` presentation intent, naming `Approve` as the label that approves it, so a capable UI presents the plan as a decision instead of a generic question; the answer the tool reads is the same either way. A dismissed review — the user closing the request to speak instead — is reported to the model as such, telling it to stay in plan mode and wait for the message; every other review failure keeps the seam's own message.
|
||||
|
||||
When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request.
|
||||
|
||||
The TUI consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary.
|
||||
@@ -77,7 +79,7 @@ The user block is append-only conversation growth. Entering or leaving plan mode
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback.
|
||||
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback, and a dismissed review a failed call naming the user's takeover.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -92,4 +94,5 @@ Mode transitions do not change the tool catalog; plan arguments and review resul
|
||||
- Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls.
|
||||
- A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it.
|
||||
- Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option.
|
||||
- The `exit_plan_mode` review arc (submit → human review → approved flip or rejected feedback) is covered by package tests only; its assembled-application snapshot left with the retired ACP UI scenarios ([automation-only ACP](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)) and the TUI keyless scenarios exercise only `/plan` entry and `/plan off` exit.
|
||||
- The `exit_plan_mode` review arc has one assembled-application snapshot, the Web `plan-review` e2e lane (submit → decision card → approved flip). The rejected-feedback and dismissed branches are covered by package tests only, and the TUI keyless scenarios exercise only `/plan` entry and `/plan off` exit.
|
||||
- Only the Web UI renders the `plan-review` intent; the TUI presents the review through its generic question flow, which is answerable but does not read as a plan gate.
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
激活时,`plan:policy` 会渲染已配置的 `section`。插件始终注册 `exit_plan_mode`,使工具 schema 在转换期间保持稳定;其 execute 路径只接受已激活的 plan mode,且只有通过 `ctx.userInteraction` 获得精确用户批准后才退出。
|
||||
|
||||
评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅 —— 用户关掉请求改用说话 —— 会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。
|
||||
|
||||
组合 `ctx.commands` 时,该包(package)会注册 `/plan [message]`,并保留精确参数 `off` 用于直接退出。不带参数的 `/plan` 选择 plan mode;任何其他非空参数都会先选择 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 选择未激活状态,不发送模型输入;它还可以在 plan mode 进入选择到达请求之前取消该待生效选择。
|
||||
|
||||
TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。
|
||||
@@ -77,7 +79,7 @@ You are in plan mode. Explore and design before presenting the complete plan thr
|
||||
|
||||
#### 模型所见内容
|
||||
|
||||
[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范 `{ approved: true }` 值,并渲染现有确认文本。拒绝仍是携带评审反馈的失败调用。
|
||||
[`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范 `{ approved: true }` 值,并渲染现有确认文本。拒绝仍是携带评审反馈的失败调用,放弃审阅则是一次指明用户接手的失败调用。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -92,4 +94,5 @@ Mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩
|
||||
- Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。
|
||||
- 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。
|
||||
- Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。
|
||||
- `exit_plan_mode` 评审弧(提交 → 人类评审 → 已批准切换或已拒绝反馈)仅由包测试覆盖;其组装应用快照随已退役 ACP UI 场景一起离开([仅面向自动化的 ACP](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)),TUI 无密钥场景只演练 `/plan` 进入和 `/plan off` 退出。
|
||||
- `exit_plan_mode` 评审弧有一个组装应用快照,即 Web `plan-review` e2e 通道(提交 → 决定卡片 → 已批准切换)。已拒绝反馈与放弃审阅两个分支仅由包测试覆盖,TUI 无密钥场景只演练 `/plan` 进入和 `/plan off` 退出。
|
||||
- 只有 Web UI 渲染 `plan-review` 意图;TUI 通过其通用问题流程呈现该评审,可以回答,但读起来不像一个计划关口。
|
||||
|
||||
@@ -29,7 +29,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-user-interaction'
|
||||
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
|
||||
// Type-only edge: resolves `ctx.commands` for the optional command child.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
// Type-only: resolves ctx.sessionProjections for the optional unit child.
|
||||
@@ -70,6 +70,9 @@ export interface PlanModeConfig {
|
||||
section: string
|
||||
}
|
||||
|
||||
/** The review question's id, echoed in the answer this tool reads. */
|
||||
const REVIEW_ID = 'plan-review'
|
||||
|
||||
/** The review question's approve option label. */
|
||||
const APPROVE_LABEL = 'Approve'
|
||||
|
||||
@@ -317,7 +320,7 @@ export class PlanModeService extends Service {
|
||||
}
|
||||
const answer = await interaction.ask({
|
||||
questions: [{
|
||||
id: 'plan-review',
|
||||
id: REVIEW_ID,
|
||||
header: 'Plan review',
|
||||
question: 'Approve this plan and leave plan mode?',
|
||||
detail: args.plan,
|
||||
@@ -325,16 +328,31 @@ export class PlanModeService extends Service {
|
||||
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
|
||||
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
|
||||
],
|
||||
// Presentation only: a capable UI renders the plan as a review
|
||||
// decision instead of a generic question, and answers with one of
|
||||
// the labels above either way.
|
||||
intent: { kind: 'plan-review', approve: APPROVE_LABEL },
|
||||
}],
|
||||
agent,
|
||||
signal: exec.signal,
|
||||
}).catch((cause: unknown) => {
|
||||
// A dismissed review is not a failed one: the user took the turn back
|
||||
// to say something the two options do not cover. Say so, because the
|
||||
// generic channel message names ask_user_question, which the model
|
||||
// never called. An abort (turn cancel, provider teardown) keeps its
|
||||
// own message — there is no user to wait for.
|
||||
if (cause instanceof UserInteractionError && cause.code === 'ASK_CANCELLED') {
|
||||
throw new Error('The user dismissed the plan review to speak instead; '
|
||||
+ 'stay in plan mode, stop here, and wait for their message.')
|
||||
}
|
||||
throw cause
|
||||
})
|
||||
// A review may outlive this plugin fiber. Without boundary listeners,
|
||||
// an approved result could never land, so fail and keep planning.
|
||||
if (disposed) {
|
||||
throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again')
|
||||
}
|
||||
const reviewItems = answer.answers.filter(entry => entry.id === 'plan-review')
|
||||
const reviewItems = answer.answers.filter(entry => entry.id === REVIEW_ID)
|
||||
const item = reviewItems.length === 1 ? reviewItems[0] : undefined
|
||||
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
|
||||
const feedback = item?.custom ?? ''
|
||||
|
||||
@@ -6,7 +6,9 @@ import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
|
||||
import UserInteractionService, {
|
||||
UserInteractionError, type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import PlanModeService, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts'
|
||||
@@ -894,6 +896,40 @@ describe('exit_plan_mode', () => {
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }])
|
||||
})
|
||||
|
||||
it('declares the plan-review presentation intent naming its approve option', async () => {
|
||||
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
|
||||
await callExit(ctx, agent)
|
||||
const question = asked[0]?.questions[0]
|
||||
expect(question?.intent).toEqual({ kind: 'plan-review', approve: 'Approve' })
|
||||
// The named label is one this same question offers, so a UI honouring the
|
||||
// intent answers a choice this tool accepts.
|
||||
expect(question?.options?.map(option => option.label)).toContain(question?.intent?.approve)
|
||||
})
|
||||
|
||||
it('reads a dismissed review as the user taking the turn back, not as a failure', async () => {
|
||||
const { ctx, agent } = await setupWithReview()
|
||||
ctx.userInteraction.registerProvider({
|
||||
ask: () => Promise.reject(new UserInteractionError(
|
||||
'the user cancelled ask_user_question', 'ASK_CANCELLED')),
|
||||
})
|
||||
const result = await callExit(ctx, agent)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Error: The user dismissed the plan review to speak instead; stay in plan mode, stop here, and wait for their message.' }])
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves every other review failure its own message', async () => {
|
||||
const { ctx, agent } = await setupWithReview()
|
||||
ctx.userInteraction.registerProvider({
|
||||
ask: () => Promise.reject(new UserInteractionError(
|
||||
'ask_user_question was aborted before the user answered', 'ASK_ABORTED')),
|
||||
})
|
||||
const result = await callExit(ctx, agent)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Error: ask_user_question was aborted before the user answered' }])
|
||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||
})
|
||||
|
||||
it('forwards the execution abort signal to the review question', async () => {
|
||||
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md
|
||||
README.md: d234d6677bdd772f1bbd2c979c0d41f90aef5c32
|
||||
README.zh.md: c89210b6955a661313ca9e0e82e43da5a4d1db79
|
||||
README.md: d62e75d110b8be339c5f9449b0834320f695ac99
|
||||
README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816
|
||||
|
||||
@@ -13,14 +13,19 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
|
||||
|
||||
### Key Types
|
||||
|
||||
- `AskUserQuestionRequest` — `{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
|
||||
- `AskUserQuestionRequest` — `{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
|
||||
- `AskUserQuestionOption` — `{ label, description? }`.
|
||||
- `AskUserQuestionIntent` — `{ kind: 'plan-review', approve }`; the tagged presentation intent below.
|
||||
- `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`.
|
||||
- `UserInteractionProvider` — UI implementation with `ask(request)`.
|
||||
- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
|
||||
- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
|
||||
|
||||
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
|
||||
|
||||
### Presentation intent
|
||||
|
||||
`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of.
|
||||
|
||||
## Role
|
||||
|
||||
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; `dsh-tui` and the host runtime provide interactive implementations. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop.
|
||||
|
||||
@@ -13,14 +13,19 @@
|
||||
|
||||
### 关键类型
|
||||
|
||||
- `AskUserQuestionRequest`:`{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`;`detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。
|
||||
- `AskUserQuestionRequest`:`{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`;`detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。
|
||||
- `AskUserQuestionOption`:`{ label, description? }`。
|
||||
- `AskUserQuestionIntent`:`{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。
|
||||
- `AskUserQuestionAnswer`:`{ answers: [{ id, selected, custom? }] }`。
|
||||
- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。
|
||||
- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。
|
||||
- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。
|
||||
|
||||
当回答包含 `custom` 时,`selected` 为空;自定义文本是所选选项的替代,而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
|
||||
|
||||
### 呈现意图
|
||||
|
||||
`intent` 声明某个问题本身就是一次已知形状的决定,因此认识该标签的 UI 可以照此呈现 —— `plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上 —— 而 `detail` 正是它自称在审阅的东西。
|
||||
|
||||
## 职责
|
||||
|
||||
这是接口包(package)。`@deepseek-ai/dsh-tool-ask-user` 等面向模型的消费方依赖此 seam;`dsh-tui` 和宿主运行时提供交互式实现。循环保持不变:工具调用等待 Promise,工具结果随后恢复正常的 agent loop(智能体循环)。
|
||||
|
||||
@@ -20,7 +20,8 @@ declare module 'cordis' {
|
||||
import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts'
|
||||
|
||||
export type {
|
||||
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionOption,
|
||||
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
|
||||
AskUserQuestionOption,
|
||||
} from './types.ts'
|
||||
|
||||
/** Request for a human answer. */
|
||||
@@ -86,6 +87,28 @@ export class UserInteractionService extends Service {
|
||||
if (request.questions.length === 0) {
|
||||
throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
|
||||
}
|
||||
// A presentation intent asserts two things the types cannot: that the
|
||||
// named approve label is one of this question's own options, and that a
|
||||
// plan-review carries the plan it is a review of. A UI honouring the
|
||||
// intent answers with that label, and shows that detail as the plan, so
|
||||
// either gap would put a choice the asker never offered — or an approval of
|
||||
// something invisible — in front of the user. Caught at the asker, where
|
||||
// the mistake is, rather than in each UI.
|
||||
for (const question of request.questions) {
|
||||
const intent = question.intent
|
||||
if (intent === undefined) continue
|
||||
if (!(question.options ?? []).some(option => option.label === intent.approve)) {
|
||||
throw new UserInteractionError(
|
||||
`question ${question.id} declares intent ${intent.kind} whose approve label `
|
||||
+ `${JSON.stringify(intent.approve)} names none of its options`,
|
||||
'BAD_INTENT')
|
||||
}
|
||||
if (question.detail === undefined) {
|
||||
throw new UserInteractionError(
|
||||
`question ${question.id} declares intent ${intent.kind} without the detail it reviews`,
|
||||
'BAD_INTENT')
|
||||
}
|
||||
}
|
||||
if (this.provider === undefined) {
|
||||
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
|
||||
}
|
||||
|
||||
@@ -13,6 +13,24 @@ export interface AskUserQuestionOption {
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
|
||||
/** One question in a user-interaction request. */
|
||||
export interface AskUserQuestionItem {
|
||||
/** Stable caller-provided question id, echoed in the answer. */
|
||||
@@ -27,6 +45,8 @@ export interface AskUserQuestionItem {
|
||||
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
|
||||
}
|
||||
|
||||
/** Answer to one question. */
|
||||
|
||||
@@ -83,4 +83,63 @@ describe('UserInteractionService', () => {
|
||||
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' })
|
||||
expect(p.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an intent whose approve label names none of its own options', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = { ask: vi.fn(async () => ({ answers: [] })) }
|
||||
ctx.userInteraction.registerProvider(p)
|
||||
const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' }
|
||||
|
||||
// A wrong label among offered options, and no options offered at all.
|
||||
for (const options of [[{ label: 'Approve' }], undefined]) {
|
||||
await expect(ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
...question,
|
||||
...(options === undefined ? {} : { options }),
|
||||
intent: { kind: 'plan-review', approve: 'Ship it' },
|
||||
}],
|
||||
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
|
||||
}
|
||||
expect(p.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a plan-review intent on a question carrying no plan to review', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = { ask: vi.fn(async () => ({ answers: [] })) }
|
||||
ctx.userInteraction.registerProvider(p)
|
||||
|
||||
// Detail IS the plan for this intent, so a UI honouring it would ask the
|
||||
// user to approve something they cannot see.
|
||||
await expect(ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'plan-review', question: 'Approve?',
|
||||
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
|
||||
intent: { kind: 'plan-review', approve: 'Approve' },
|
||||
}],
|
||||
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
|
||||
expect(p.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes an intent through once its approve label names an offered option', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = provider('Approve')
|
||||
ctx.userInteraction.registerProvider(p)
|
||||
const intent = { kind: 'plan-review', approve: 'Approve' } as const
|
||||
|
||||
const result = await ctx.userInteraction.ask({
|
||||
questions: [
|
||||
{ id: 'plain', question: 'Proceed?' },
|
||||
{
|
||||
id: 'plan-review', question: 'Approve?', detail: '# Plan',
|
||||
options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.answers).toEqual([{ id: 'plain', selected: ['Approve'] }])
|
||||
expect(p.seen[0]?.questions[1]?.intent).toEqual(intent)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user