Merge origin/master into feature/tui-first-run-welcome

This commit is contained in:
NI0317
2026-07-31 00:29:05 +08:00
109 changed files with 3273 additions and 188 deletions

View File

@@ -389,10 +389,23 @@ export class ToolCardComponent implements Component {
const glyph = this.result === undefined ? '○' : '●'
const rawBody = this.renderBody()
const view = this.resultView ?? this.callView
const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined
const unknownXml = this.definition === undefined && genericContent !== undefined
// A generic card's own content, or a web card's fallback to the raw result
// content (the `web` view carries no `content` copy), both render as one dim
// Markdown block below, so links/lists/headings keep the unified dim styling
// rather than reading as bare text. Terminal and diff cards own their body
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
const markdownContent = view.card === 'generic'
? view.content ?? this.result?.content
: view.card === 'web'
// A web resultView is only assigned alongside this.result (the result
// handler sets both) and the pending callView is never a web card, so
// the optional-chain undefined side is unreachable here.
/* v8 ignore next */
? this.result?.content
: undefined
const unknownXml = this.definition === undefined && markdownContent !== undefined
? renderUnknownXml(
displayText(contentText(genericContent)),
displayText(contentText(markdownContent)),
this.maxOutputLines,
this.visibility === 'expanded',
displayText,
@@ -405,7 +418,7 @@ export class ToolCardComponent implements Component {
// A generic card renders title and result as one Markdown document, so the
// document's own block spacing is preserved, then dims every row — the whole
// card body reads as one dim block under the status-colored header.
const body = unknownXml ?? (genericContent !== undefined && rawBody.lines.length > 0
const body = unknownXml ?? (markdownContent !== undefined && rawBody.lines.length > 0
? this.dimBody(rawBody, width)
: [...rawBody.prelude, ...rawBody.lines])
const visibleBody = unknownXml !== undefined || this.visibility === 'expanded'
@@ -502,7 +515,11 @@ export class ToolCardComponent implements Component {
// rather than under the dim result-output color.
return { prelude: [...hunks, footer], lines: [] }
}
const content = view.content ?? this.result?.content
// The web card carries no `content` copy, so a `web` result view falls back
// to the raw result content here (`view.card === 'generic'` narrows the
// generic union arm; a `web` card takes the same fallback, mirroring the
// `markdownContent` selection in render()).
const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content
const prelude: string[] = []
const lines: string[] = []
// The presenter title headlines the body now that the header is a fixed

View File

@@ -4376,6 +4376,14 @@ describe('tool cards and surface replay', () => {
name: 'knownXml', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Known XML' }),
},
// A web card carries no `content` copy, so it falls back to the raw result
// content, which must still render through the dim Markdown path (bold
// markers stripped) rather than as bare text.
webCard: {
name: 'webCard', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Fetch page', kind: 'fetch' }),
presentResult: () => ({ card: 'web', kind: 'fetch', title: 'https://a.test', url: 'https://a.test', statusCode: 200, truncated: false }),
},
}
it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
@@ -4396,6 +4404,7 @@ describe('tool cards and surface replay', () => {
['c11', 'terminalResult', '{}'],
['c12', 'symbolic', '{}'],
['c13', 'knownXml', '{}'],
['c16', 'webCard', '{}'],
] as const
appendAssistant(result.session, [
{ type: 'text', text: 'Calling tools' },
@@ -4489,6 +4498,14 @@ describe('tool cards and surface replay', () => {
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c16' as never,
content: [{ type: 'text', text: 'Fetched **body** text' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1,
step: 1,
@@ -4538,6 +4555,11 @@ describe('tool cards and surface replay', () => {
expect(output).toContain('Empty card')
expect(output).toContain('converted terminal')
expect(output).toContain('<known><value>literal</value></known>')
// A web card carries no `content` copy, so it falls back to the raw result
// content, which still renders through the dim Markdown path: the bold
// markers are stripped rather than shown literally.
expect(output).toContain('Fetched body text')
expect(output).not.toContain('Fetched **body** text')
expect(output).toContain('path: /tmp/a.txt')
expect(output).toContain('line (number="1"): hello')
expect(output).not.toContain('<result>')

View File

@@ -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

View File

@@ -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.

View File

@@ -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智能体循环

View File

@@ -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')
}

View File

@@ -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. */

View File

@@ -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)
})
})