Merge origin/master into feature/tui-first-run-welcome
This commit is contained in:
@@ -19,6 +19,13 @@
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
/* Elevated surface in dark, same as the menus: `.body` inside scrolls once
|
||||
the justification or command passes the cap, so the thumb takes the l2
|
||||
pair. Declared on the card because the elevation belongs to the surface,
|
||||
and the custom properties inherit down to the region that actually
|
||||
scrolls (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);
|
||||
}
|
||||
|
||||
/* Tinted full-width header band. */
|
||||
@@ -40,11 +47,22 @@
|
||||
background: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
/* Scroll region: an agent's justification and its command are unbounded model
|
||||
text (a one-line `cd` or a 40-line heredoc), and the seat sits in a
|
||||
fixed-height column — uncapped, a long command pushed the action row past
|
||||
the viewport and the approval could not be answered at all. The strip and
|
||||
the action row stay outside, so the buttons are always on screen. */
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 16px 14px;
|
||||
/* border-box so the cap is the region's OUTER height: the composer's draft
|
||||
area counts its padding inside the same number, and the two seats are
|
||||
only interchangeable if they occupy the same box. */
|
||||
box-sizing: border-box;
|
||||
max-height: var(--dsh-composer-text-max-height);
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
/* The model's justification is the panel's message, not a footnote. */
|
||||
@@ -63,11 +81,15 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Card-level row, not body content. Its padding reproduces the metrics the row
|
||||
had inside the body: 14px above (the flex gap of 6 plus the row's 8px top
|
||||
margin, neither of which reaches it out here) and the body's former 14px
|
||||
bottom pad below, so the resting card is unchanged. */
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 14px 16px 14px;
|
||||
}
|
||||
|
||||
.allow,
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
// pending, this panel occupies the composer slot in place of the InputBar:
|
||||
// an amber "Waiting for approval" strip on the card top, the model's
|
||||
// justification as the headline, the paired command in muted code text, and
|
||||
// a right-aligned refuse/allow action row. One-shot: the buttons disable
|
||||
// a right-aligned refuse/allow action row. Justification and command are
|
||||
// unbounded model text, so they scroll inside the card at the shared composer
|
||||
// cap (`data-approval-scroll`) and the action row stays outside it — the
|
||||
// buttons must be reachable no matter how long the command is.
|
||||
// One-shot: the buttons disable
|
||||
// after a click and the panel leaves (the InputBar returns) on the broadcast
|
||||
// resolved frame. The draft's "Always allow this type" is deferred with
|
||||
// grant storage.
|
||||
@@ -53,17 +57,20 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
|
||||
<div className={css.root} data-approval-key={pending.key}>
|
||||
<div className={css.card}>
|
||||
<div className={css.strip}><span className={css.dot} />等待审批</div>
|
||||
<div className={css.body}>
|
||||
{/* Tab stop: the region scrolls once the command passes the cap and
|
||||
holds nothing focusable of its own, so without one a keyboard-only
|
||||
user cannot reach the command's tail before answering. */}
|
||||
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label="审批详情">
|
||||
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
|
||||
{command !== undefined && <div className={css.command}>{command}</div>}
|
||||
<div className={css.actionRow}>
|
||||
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
允许一次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.actionRow}>
|
||||
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
允许一次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,6 +143,14 @@
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
/* One cap for every scrolling text region a composer seat can hold: the
|
||||
InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the
|
||||
takeover panels' bodies top out at the same height, so electing a
|
||||
takeover never grows the footer past the card it replaces. Declared on
|
||||
the seat because it is the chain's only shared ancestor — fallback and
|
||||
elected overlay are siblings — and custom properties inherit down to
|
||||
whichever entry is mounted. */
|
||||
--dsh-composer-text-max-height: 336px;
|
||||
}
|
||||
|
||||
/* Active phase: header is ordinary column chrome above the scrollport (not
|
||||
|
||||
@@ -209,7 +209,9 @@
|
||||
.mirror {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
max-height: 336px;
|
||||
/* 14-line cap, shared with the composer takeovers (declared on
|
||||
ConversationRoot .composerSeat). */
|
||||
max-height: var(--dsh-composer-text-max-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -1536,9 +1536,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',
|
||||
@@ -2750,7 +2754,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ToolResultView',
|
||||
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;',
|
||||
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;',
|
||||
},
|
||||
{
|
||||
name: 'ToolRunContext',
|
||||
@@ -2856,6 +2860,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'WebFetchResult',
|
||||
declaration: 'export interface WebFetchResult {\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebFetchResultView',
|
||||
declaration: 'export interface WebFetchResultView {\n card: \'web\';\n kind: \'fetch\';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebResultView',
|
||||
declaration: 'export type WebResultView = WebSearchResultView | WebFetchResultView;',
|
||||
},
|
||||
{
|
||||
name: 'WebRoute',
|
||||
declaration: 'export interface WebRoute {\n kind: WebRouteKind;\n path: string;\n handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;\n}',
|
||||
@@ -2876,10 +2888,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'WebSearchResult',
|
||||
declaration: 'export interface WebSearchResult {\n readonly content?: string;\n readonly sources: readonly WebSearchSource[];\n readonly truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebSearchResultView',
|
||||
declaration: 'export interface WebSearchResultView {\n card: \'web\';\n kind: \'search\';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebSearchSource',
|
||||
declaration: 'export interface WebSearchSource {\n readonly url: string;\n readonly title?: string;\n readonly snippet?: string;\n readonly publishedAt?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebSource',
|
||||
declaration: 'export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WorkflowMeta',
|
||||
declaration: 'export interface WorkflowMeta {\n name: string;\n description: string;\n whenToUse?: string;\n phases?: WorkflowPhase[];\n}',
|
||||
|
||||
@@ -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/core/tools/README.md
|
||||
README.md: e5adb153e77d7a2d8c4068b016194ab6abb6473e
|
||||
README.zh.md: c67a2f2ee4ac2a9d587c6efbf2b5c60d14fc58c2
|
||||
README.md: e7f395f8c1d6417db856e590f5267cf6887e4d12
|
||||
README.zh.md: acb4c047bf86e36c828882ff751d4be1f627f99e
|
||||
|
||||
@@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E
|
||||
Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names:
|
||||
|
||||
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`.
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
|
||||
|
||||
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ ctx.tools.register(defineTool({
|
||||
工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称:
|
||||
|
||||
- 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。
|
||||
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }` 或 `{ card: 'diff', title?, diffs }`。
|
||||
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }` 或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
|
||||
|
||||
返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。
|
||||
|
||||
|
||||
@@ -82,6 +82,10 @@ export type {
|
||||
GenericResultView,
|
||||
TerminalResultView,
|
||||
DiffResultView,
|
||||
WebResultView,
|
||||
WebSearchResultView,
|
||||
WebFetchResultView,
|
||||
WebSource,
|
||||
} from './presentation.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
|
||||
@@ -125,7 +125,7 @@ export interface DiffCallView {
|
||||
* `ToolDefinition.presentResult`; omitting the method keeps the pending
|
||||
* title and renders the raw result content.
|
||||
*/
|
||||
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView
|
||||
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView
|
||||
|
||||
/**
|
||||
* The default completed card: an optional replacement title and reformatted
|
||||
@@ -176,3 +176,84 @@ export interface DiffResultView {
|
||||
/** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */
|
||||
diffs: FileDiff[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One citeable source in a completed {@link WebSearchResultView}, the faithful
|
||||
* projection of one web-search source. The presentation projection of `dsh-web`'s
|
||||
* `WebSearchSource`: that seam type is the authoritative shape (core cannot depend
|
||||
* on the web seam, so the two are declared separately and MUST evolve together).
|
||||
* A web tool projects this shape through `output.presentationMeta` because the
|
||||
* render text cannot losslessly carry it (see the web-result-card Agent Note); its
|
||||
* `presentResult` reads it back.
|
||||
*/
|
||||
export interface WebSource {
|
||||
/** The source URL. */
|
||||
url: string
|
||||
/** The source title, when the provider returned one. */
|
||||
title?: string
|
||||
/** A short excerpt or summary, when the provider returned one. */
|
||||
snippet?: string
|
||||
/** Publication/crawl timestamp as a provider-supplied ISO-8601 string, when present. */
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed web retrieval rendered as a structured card by a capable UI. Set
|
||||
* by a web tool whose call retrieves from the web (`web_search`, `web_fetch`).
|
||||
* One `kind`-tagged union carries both shapes because both are web retrieval and
|
||||
* a UI renders them with one component family; a UI switches on `kind`. An
|
||||
* incapable UI falls back to the raw `tool/result` content (this view carries no
|
||||
* `content` copy — see the web-result-card Agent Note). This is the result-time
|
||||
* analogue of the `web_search`/`web_fetch` calls' generic call views
|
||||
* (`kind: 'search'`/`'fetch'`); those tools keep their generic pending card and
|
||||
* add only this completed card.
|
||||
*
|
||||
* The `kind` field here is this union's own discriminant, NOT a
|
||||
* {@link ToolCallKind}: the two values deliberately match the tools' pending
|
||||
* `ToolCallKind` (`'search'`/`'fetch'`) so a call and its result read as one
|
||||
* category, but a new arm is a union edit plus a consumer branch, not any
|
||||
* arbitrary `ToolCallKind` value.
|
||||
*/
|
||||
export type WebResultView = WebSearchResultView | WebFetchResultView
|
||||
|
||||
/**
|
||||
* The completed state of a `web_search` call: the structured sources the model
|
||||
* cited, an optional provider answer, and whether the source list was cut to the
|
||||
* result cap. A capable UI renders the sources as a citation list; a UI without
|
||||
* the `web` capability falls back to the raw `tool/result` content.
|
||||
*/
|
||||
export interface WebSearchResultView {
|
||||
card: 'web'
|
||||
kind: 'search'
|
||||
/** Replacement title for the completed call. Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/** The faithful, structured sources — the field render text cannot losslessly carry. */
|
||||
sources: WebSource[]
|
||||
/** The provider-generated answer text, when any. */
|
||||
answer?: string
|
||||
/** True when the seam cut the source list to honor the result cap. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The completed state of a `web_fetch` call: the fetched URL, its HTTP status,
|
||||
* and whether the content was cut. The body itself is already markdown in the
|
||||
* raw `tool/result` content, so this card carries only the retrieval summary and
|
||||
* a UI without the `web` capability falls back to that content.
|
||||
*/
|
||||
export interface WebFetchResultView {
|
||||
card: 'web'
|
||||
kind: 'fetch'
|
||||
/** Replacement title for the completed call. Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/** The final URL after allowed redirects. */
|
||||
url: string
|
||||
/** HTTP status code of the fetched response. */
|
||||
statusCode: number
|
||||
/**
|
||||
* True when the provider capped the decoded body, or the output cap or a
|
||||
* pre-conversion source cut trimmed the rendered text (the effective
|
||||
* truncation the model-facing text also reflects).
|
||||
*/
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
@@ -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/fs/tool-fs-search/README.md
|
||||
README.md: 88a80fb51d7161e6940a3460b7f506575592f9cb
|
||||
README.zh.md: 9767c545ddc87d323c9fefa43a3145d1a2df5914
|
||||
README.md: b12ffda9869c7d6bef5ea5b54594781ecf555ff4
|
||||
README.zh.md: 7dd6cdf9a209f2fe357b4ffe48d20d574266ce60
|
||||
|
||||
@@ -5,9 +5,9 @@ English | [中文](README.zh.md)
|
||||
The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
|
||||
// A deployment chooses how over-cap glob pages are selected.
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
|
||||
await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep
|
||||
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false })
|
||||
// Optional: a spill backend makes capped results fully recoverable.
|
||||
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
|
||||
```
|
||||
@@ -20,11 +20,12 @@ The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin
|
||||
|
||||
## Config
|
||||
|
||||
All keys are optional; the defaults are the shipped search caps.
|
||||
`sampleOverCapGlobResults` is required and has no fallback; deployments choose the over-cap ordering contract explicitly. The remaining keys are optional search caps with the defaults below.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `globMaxResults` | `100` | Max paths one `glob` call retains inline (matches Claude Code's `GlobTool` limit); later paths go to the formatted spill artifact. |
|
||||
| `sampleOverCapGlobResults` | none (required) | `true` samples an over-cap `glob` page across top-level entries; `false` keeps the modification-time-ordered head. When formatted spill succeeds, both modes preserve the complete sorted list in that artifact. |
|
||||
| `globMaxResults` | `100` | Max paths one `glob` call shows inline (matches Claude Code's `GlobTool` limit). A result within the cap remains complete and modification-time ordered. |
|
||||
| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. |
|
||||
| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. |
|
||||
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. |
|
||||
@@ -34,14 +35,14 @@ All keys are optional; the defaults are the shipped search caps.
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|---|---|---|
|
||||
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one path per line, modification-time ordered. |
|
||||
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one FILE path per line; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. Complete results stay modification-time ordered; over-cap presentation follows `sampleOverCapGlobResults`. |
|
||||
| `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: <preview>`. |
|
||||
|
||||
Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint.
|
||||
|
||||
## Two budgets, two artifacts
|
||||
|
||||
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps every acquired path in `{ paths }`; `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with a head page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
|
||||
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
|
||||
|
||||
## Errors
|
||||
|
||||
@@ -55,10 +56,16 @@ Search failures carry the package-owned `SearchError` (a `HarnessError` subclass
|
||||
|
||||
After the load-time `rg` probe succeeds, every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
|
||||
|
||||
##### Glob guidance
|
||||
##### Glob guidance with `sampleOverCapGlobResults: true`
|
||||
|
||||
```markdown
|
||||
Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.
|
||||
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.
|
||||
```
|
||||
|
||||
##### Glob guidance with `sampleOverCapGlobResults: false`
|
||||
|
||||
```markdown
|
||||
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head.
|
||||
```
|
||||
|
||||
##### Grep guidance
|
||||
@@ -69,17 +76,17 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed guidance cost per request while the tools are registered.
|
||||
Fixed guidance cost per request while the tools are registered; the required sampling choice selects one glob variant.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section.
|
||||
Prefix-stable while the plugin scope, sampling choice, and guidance text are unchanged. Activation, disposal, or changing the choice may invalidate reuse from this prompt section.
|
||||
|
||||
### Tool schemas
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) after the load-time `rg` probe succeeds and while this surface is visible.
|
||||
The glob description states the configured over-cap ordering. The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) use `sampleOverCapGlobResults: true`; schemas are visible only after the load-time `rg` probe succeeds.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -93,7 +100,7 @@ Prefix-stable while tool visibility and definitions are unchanged. Registration
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved.
|
||||
`glob` returns one path per line; `grep` groups `Line <line>: <preview>` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. With `sampleOverCapGlobResults: true`, an over-cap `glob` page takes paths round-robin across entries immediately beneath the actual search root, and the footer states the sampled basis and how many top-level entries it reached; when it cannot reach them all, the footer tells the model to narrow `path`. With `false`, the page is the modification-time-ordered head and keeps the plain capped-result footer. A result that fits inline is untouched, and a flat sampled result also keeps the plain footer because its sample equals the modification-time head. The spill artifact always holds the complete list in modification-time order.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -122,3 +129,4 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
|
||||
- **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer.
|
||||
- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend.
|
||||
- **Sampling, when enabled, groups by first path segment beneath the search root only** — an over-cap `glob` page balances across those top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred.
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
**面向模型的文件系统发现工具**(`glob`、`grep`)由 **bash 执行器 seam** 支持,而不是由 `ctx.fs` 提供方方法支持。加载时,本包(package)探测 `command -v rg`,探测通过 `ctx.bash` 进行;如果执行器无法在其 `PATH` 上找到 ripgrep,就记录警告,并且不注册工具或提示词段。每次调用都会组装固定的 ripgrep 命令(所有模型控制的值都经过同一个包私有 shell 引用辅助函数),通过 `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` 作为普通前台工具调用运行,解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `bash`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a bash executor whose PATH includes rg, then the discovery tools.
|
||||
// A deployment chooses how over-cap glob pages are selected.
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
|
||||
await ctx.plugin(ToolFsSearch) // this package — conditionally registers glob/grep
|
||||
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false })
|
||||
// Optional: a spill backend makes capped results fully recoverable.
|
||||
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
|
||||
```
|
||||
@@ -20,11 +20,12 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
|
||||
|
||||
## 配置
|
||||
|
||||
所有键均为可选;默认值是随产品交付的搜索上限。
|
||||
`sampleOverCapGlobResults` 是必填项且没有回退值;部署必须显式选择超过上限时的排序契约。其余键是可选的搜索上限,默认值如下。
|
||||
|
||||
| 键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `globMaxResults` | `100` | 一次 `glob` 调用内联保留的最大路径数(与 Claude Code 的 `GlobTool` 上限相同);后续路径写入格式化 spill 产物。 |
|
||||
| `sampleOverCapGlobResults` | 无(必填) | `true` 会在顶层条目之间对超过上限的 `glob` 页面采样;`false` 保留按修改时间排序的前部。格式化 spill 成功时,两种模式都会在该产物中保留完整排序列表。 |
|
||||
| `globMaxResults` | `100` | 一次 `glob` 调用内联展示的最大路径数(与 Claude Code 的 `GlobTool` 上限相同)。未超过上限的结果保持完整,并按修改时间排序。 |
|
||||
| `grepMaxMatches` | `250` | 一次 `grep` 调用内联保留的最大平铺匹配数(与 Claude Code 的 `GrepTool` `head_limit` 相同);后续匹配写入格式化 spill 产物。 |
|
||||
| `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 |
|
||||
| `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 |
|
||||
@@ -34,14 +35,14 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
|
||||
|
||||
| 工具 | 参数 | 行为 |
|
||||
|---|---|---|
|
||||
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的 bash 工作目录。每行返回一个路径,按修改时间排序。 |
|
||||
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件**路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。完整结果保持按修改时间排序;超过上限时的呈现方式遵循 `sampleOverCapGlobResults`。 |
|
||||
| `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录**目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: <preview>` 的匹配。 |
|
||||
|
||||
常规预算不进入面向模型的 schema(没有 `head_limit`/`offset`/`case_insensitive`/输出模式):模型需要周边上下文时,用 `read` 读取匹配文件;需要后续结果时,遵循返回的 spill locator 检索提示。
|
||||
|
||||
## 两类预算、两类产物
|
||||
|
||||
原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout;如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ paths }` 中保留所有已取得路径;`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为头部页面加 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
|
||||
原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout;如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
|
||||
|
||||
## 错误
|
||||
|
||||
@@ -55,10 +56,16 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
|
||||
|
||||
加载时 `rg` 探测成功后,该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema,而不移除其提示词段。
|
||||
|
||||
##### Glob 指导
|
||||
##### 启用 `sampleOverCapGlobResults: true` 时的 Glob 指导
|
||||
|
||||
```markdown
|
||||
Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.
|
||||
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.
|
||||
```
|
||||
|
||||
##### 启用 `sampleOverCapGlobResults: false` 时的 Glob 指导
|
||||
|
||||
```markdown
|
||||
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head.
|
||||
```
|
||||
|
||||
##### Grep 指导
|
||||
@@ -69,17 +76,17 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
|
||||
|
||||
#### Token 影响
|
||||
|
||||
工具注册期间,每个请求支付固定指导成本。
|
||||
工具注册期间,每个请求支付固定指导成本;必填的采样选项决定采用哪个 glob 变体。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要插件作用域和指导文本不变,前缀就保持稳定。启用或 dispose(资源释放)可能从该提示词段开始使复用失效。
|
||||
只要插件作用域、采样选项和指导文本不变,前缀就保持稳定。启用、dispose(资源释放)或更改该选项,可能从该提示词段开始使复用失效。
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
当前接口可见时,公开已生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search);前提是加载时 `rg` 探测成功。
|
||||
glob 描述会说明配置所指定的超限结果排序方式。已生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) 使用 `sampleOverCapGlobResults: true`;只有加载时 `rg` 探测成功后,这些 schema 才可见。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -93,7 +100,7 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line <line>: <preview>` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。
|
||||
`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line <line>: <preview>` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。`sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面会在实际搜索根正下方的条目之间按轮转方式取路径,footer 会说明采样依据和触达的顶层条目数;若无法触达全部条目,footer 会要求模型缩小 `path`。设为 `false` 时,页面保留按修改时间排序的前部,并沿用通常用于达到上限结果的 footer。未超过上限的结果原样不动;扁平的采样结果也沿用普通 footer,因为其样本等同于按修改时间排序的前部。spill 产物始终保存按修改时间排序的完整列表。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -122,3 +129,4 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
|
||||
- **搜索和文件访问没有共享工作区证明**:只有 bash 工作目录和文件系统根目录表示同一工作区时,返回路径才能继续读取;本包不执行运行时跨服务校验。
|
||||
- **Ripgrep 是部署依赖**:缺失 `rg` 可执行文件时,本包不注册工具或指导;可执行文件不兼容或注册后消失时,调用以 `SEARCH_FAILED` 失败。远程或虚拟文件系统需要共置执行器或其他搜索消费方。
|
||||
- **schema 只公开一个有界页面**:offset 分页、大小写模式开关、其他输出模式和提供方支持的发现均不在本包内;达到上限的完整输出需要 spill 后端。
|
||||
- **启用采样时,只按搜索根下的路径首段分组**:超过上限的 `glob` 页面在这些顶层条目之间做均衡,因此集中在更深层的结果(一棵总体均匀的树里某个特别庞大的子目录)在该层级以下仍然分布不均;递归均衡已延期。
|
||||
|
||||
@@ -3,17 +3,15 @@
|
||||
* pattern, sorted by modification time. Execution goes through the bash seam
|
||||
* (`ctx.bash`) with a fixed `rg --files` command — this module owns the
|
||||
* model-facing schema, argument validation, shell-safe command construction,
|
||||
* result parsing, retention, and formatting; process concerns (defaulting,
|
||||
* result parsing, inline sampling, and formatting; process concerns (defaulting,
|
||||
* scrubbing, kill, backend substitution) stay behind `ctx.bash`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/glob
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { sep } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -41,6 +39,8 @@ export const GLOB_VCS_EXCLUDES: readonly string[] = ['.git', '.svn', '.hg', '.bz
|
||||
|
||||
/** Resolved glob-tool caps — plugin config after defaulting (see `Config` in index.ts). */
|
||||
export interface GlobToolCaps {
|
||||
/** Whether over-cap pages are sampled across top-level entries instead of taking the modification-time head. */
|
||||
sampleOverCapGlobResults: boolean
|
||||
/** Max paths retained inline; later paths go to the formatted spill file. */
|
||||
maxResults: number
|
||||
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
||||
@@ -99,30 +99,136 @@ export function buildGlobCommand(input: GlobInput): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the model-facing `glob` result: the retained paths, then — when the
|
||||
* result was capped — a footer carrying either the formatted-spill recovery
|
||||
* locator or the could-not-save explanation. The omitted count is a budget fact:
|
||||
* the search itself completed.
|
||||
* The inline page of a capped `glob` result, plus how much of the complete
|
||||
* result's top level it reaches.
|
||||
*/
|
||||
export interface GlobSample {
|
||||
/** Paths to show inline: grouped by top-level entry, modification-time ordered within each group. */
|
||||
items: string[]
|
||||
/** Distinct top-level entries the shown paths reach. */
|
||||
shown: number
|
||||
/** Distinct top-level entries across the complete result. */
|
||||
total: number
|
||||
}
|
||||
|
||||
/** Remove the displayed search-root prefix before choosing a top-level group. */
|
||||
function relativeToSearchRoot(path: string, root: string): string {
|
||||
if (root === '.') return path.startsWith(`.${sep}`) ? path.slice(2) : path
|
||||
let rootEnd = root.length
|
||||
while (rootEnd > 0 && root[rootEnd - 1] === sep) rootEnd -= 1
|
||||
const trimmedRoot = root.slice(0, rootEnd)
|
||||
if (trimmedRoot.length === 0) return stripLeadingSeparators(path)
|
||||
if (path === trimmedRoot) return ''
|
||||
if (path.startsWith(`${trimmedRoot}${sep}`)) {
|
||||
return path.slice(trimmedRoot.length + 1)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/** Strip only separators recognized by the execution platform. */
|
||||
function stripLeadingSeparators(path: string): string {
|
||||
let start = 0
|
||||
while (path[start] === sep) start += 1
|
||||
return path.slice(start)
|
||||
}
|
||||
|
||||
/**
|
||||
* The leading path segment of one display path — the top-level entry, relative
|
||||
* to the search root, that the path sits under. A path with no separator is its
|
||||
* own top-level entry. Leading separators are stripped first so an absolute path
|
||||
* (one outside the workdir, which {@link toWorkdirRelative} leaves untouched)
|
||||
* groups by its first real name instead of collapsing every such path into one
|
||||
* empty group.
|
||||
*/
|
||||
function topLevelSegment(path: string): string {
|
||||
const trimmed = stripLeadingSeparators(path)
|
||||
const cut = trimmed.indexOf(sep)
|
||||
return cut === -1 ? trimmed : trimmed.slice(0, cut)
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the inline page of an over-cap result by round-robin across the
|
||||
* complete result's top-level entries, instead of taking its head.
|
||||
*
|
||||
* @param retained - the retention outcome over every discovered path.
|
||||
* Every top-level entry receives a slot before any receives a second; exhausted
|
||||
* groups drop out. Group order and order within each group follow `paths`, so a
|
||||
* flat result reproduces the modification-time head.
|
||||
*
|
||||
* @param paths - the complete result, in ripgrep's modification-time order.
|
||||
* @param maxItems - how many paths the page may hold; the caller has already established it is smaller than `paths`.
|
||||
* @param root - the search root in the same display-path space as `paths`.
|
||||
* @returns the page grouped by top-level entry, with the shown/total top-level spread.
|
||||
*/
|
||||
export function sampleAcrossTopLevel(paths: readonly string[], maxItems: number, root = '.'): GlobSample {
|
||||
type ActiveGroup = { key: string; items: string[]; index: number; current: string }
|
||||
const groups = new Map<string, string[]>()
|
||||
let active: ActiveGroup[] = []
|
||||
for (const path of paths) {
|
||||
const key = topLevelSegment(relativeToSearchRoot(path, root))
|
||||
const group = groups.get(key)
|
||||
if (group === undefined) {
|
||||
const items = [path]
|
||||
groups.set(key, items)
|
||||
active.push({ key, items, index: 0, current: path })
|
||||
} else {
|
||||
group.push(path)
|
||||
}
|
||||
}
|
||||
const taken = new Map<string, string[]>()
|
||||
let count = 0
|
||||
while (active.length > 0 && count < maxItems) {
|
||||
const nextActive: ActiveGroup[] = []
|
||||
for (const { key, items, index, current } of active) {
|
||||
if (count >= maxItems) break
|
||||
count += 1
|
||||
const bucket = taken.get(key)
|
||||
if (bucket === undefined) taken.set(key, [current])
|
||||
else bucket.push(current)
|
||||
const nextIndex = index + 1
|
||||
const nextPath = items[nextIndex]
|
||||
if (nextPath !== undefined) nextActive.push({ key, items, index: nextIndex, current: nextPath })
|
||||
}
|
||||
active = nextActive
|
||||
}
|
||||
return { items: [...taken.values()].flat(), shown: taken.size, total: groups.size }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a capped sampled page and its complete-result recovery path. A flat
|
||||
* result keeps the plain footer because its sample is the modification-time head.
|
||||
*
|
||||
* @param sample - the inline page and its top-level spread.
|
||||
* @param seen - how many paths the complete result holds; always more than the page.
|
||||
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
|
||||
* @returns the model-facing text.
|
||||
*/
|
||||
export function formatGlobOutput(retained: RetainedItems<string>, spillRef: SpillRef | undefined): string {
|
||||
const body = retained.items.join('\n')
|
||||
if (!retained.truncated) return body
|
||||
export function formatGlobOutput(sample: GlobSample, seen: number, spillRef: SpillRef | undefined): string {
|
||||
const basis = sample.total === seen
|
||||
? '.'
|
||||
: `, sampled across ${sample.shown} of the ${sample.total} top-level entries this pattern matched instead of taken in modification-time order.`
|
||||
+ (sample.shown < sample.total ? ' Narrow path to inspect a specific subtree.' : '')
|
||||
return formatGlobPage(sample.items, seen, spillRef, basis)
|
||||
}
|
||||
|
||||
/** Format one bounded page and the recovery path for its complete sorted result. */
|
||||
function formatGlobPage(items: readonly string[], seen: number, spillRef: SpillRef | undefined, basis: string): string {
|
||||
const body = items.join('\n')
|
||||
const recovery = spillRef !== undefined
|
||||
? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
|
||||
: 'The complete result could not be saved; narrow pattern or path to see more.'
|
||||
return `${body}\n\n(Showing ${retained.kept} of ${retained.seen} paths. ${recovery})`
|
||||
return `${body}\n\n(Showing ${items.length} of ${seen} paths${basis} ${recovery})`
|
||||
}
|
||||
|
||||
/** Retain and format one canonical path list for the Native surface. */
|
||||
function renderGlobPaths(paths: string[], maxResults: number, spillRef?: SpillRef): string {
|
||||
/** Bound and format one canonical path list for the Native surface relative to its search root. */
|
||||
function renderGlobPaths(paths: string[], caps: GlobToolCaps, root: string, spillRef?: SpillRef): string {
|
||||
if (paths.length === 0) return 'No files found'
|
||||
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
|
||||
for (const path of paths) retainer.push(path)
|
||||
return formatGlobOutput(retainer.finish(), spillRef)
|
||||
// A result that fits is shown whole, untouched: modification-time order is the
|
||||
// tool's contract, and over a complete result it is what answers age questions.
|
||||
if (paths.length <= caps.maxResults) return paths.join('\n')
|
||||
if (!caps.sampleOverCapGlobResults) {
|
||||
return formatGlobPage(paths.slice(0, caps.maxResults), paths.length, spillRef, '.')
|
||||
}
|
||||
return formatGlobOutput(sampleAcrossTopLevel(paths, caps.maxResults, root), paths.length, spillRef)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,19 +250,32 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener
|
||||
* @param caps - the deployment's resolved glob caps (plugin config after defaulting).
|
||||
*/
|
||||
export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
const overCapGuidance = caps.sampleOverCapGlobResults
|
||||
? 'while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree.'
|
||||
: 'while a larger one keeps the modification-time-ordered head.'
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:glob',
|
||||
order: 103,
|
||||
text: 'Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files.',
|
||||
text: 'Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. '
|
||||
+ `Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, ${overCapGuidance}`,
|
||||
})
|
||||
|
||||
const overCapDescription = caps.sampleOverCapGlobResults
|
||||
? `a larger result instead returns ${caps.maxResults} paths sampled across top-level entries`
|
||||
: `a larger result returns the first ${caps.maxResults} paths in modification-time order`
|
||||
const tool = defineTool({
|
||||
name: 'glob',
|
||||
description: 'Find files whose paths match a glob pattern. Returns matching paths sorted by modification time, '
|
||||
description: 'Find files whose paths match a glob pattern. Returns matching file paths — never directories — '
|
||||
+ 'including hidden and ignored files (VCS metadata directories are excluded). '
|
||||
+ `Returns the first ${caps.maxResults} paths inline; a capped result reports where the complete list was saved.`,
|
||||
+ `Up to ${caps.maxResults} paths come back in modification-time order; ${overCapDescription}, `
|
||||
+ 'says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.',
|
||||
parameters: {
|
||||
pattern: { type: 'string', required: true, description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js").' },
|
||||
pattern: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js"). '
|
||||
+ 'A pattern with no "/" matches the basename at any depth, so "*" and "*.ts" both search the whole tree; include a separator to anchor the depth.',
|
||||
},
|
||||
path: { type: 'string', description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.' },
|
||||
},
|
||||
timeoutMs: caps.timeoutMs,
|
||||
@@ -165,15 +284,17 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
root: { type: 'string', required: true },
|
||||
paths: { type: 'array', required: true, items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps.maxResults) }],
|
||||
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps, value.root) }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const input = parseGlobArgs(args)
|
||||
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
|
||||
if (run.noMatches) return { paths: [] }
|
||||
const root = input.path === undefined ? '.' : toWorkdirRelative(input.path, run.workdir)
|
||||
if (run.noMatches) return { root, paths: [] }
|
||||
|
||||
const all: string[] = []
|
||||
for (const line of run.stdout.split('\n')) {
|
||||
@@ -181,7 +302,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
const displayPath = toWorkdirRelative(line, run.workdir)
|
||||
all.push(displayPath)
|
||||
}
|
||||
return { paths: all }
|
||||
return { root, paths: all }
|
||||
},
|
||||
presentCall: presentGlobCall,
|
||||
})
|
||||
@@ -189,14 +310,14 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
|
||||
ctx.on('tools/post-execute', async (exec, result, next) => {
|
||||
const decision = await next()
|
||||
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { paths: string[] } | undefined
|
||||
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { root: string; paths: string[] } | undefined
|
||||
if (value === undefined) return decision
|
||||
const paths = value.paths
|
||||
if (paths.length <= caps.maxResults) return decision
|
||||
const spillRef = await trySaveFormattedResult(ctx, exec, 'glob-results.txt', paths.join('\n'))
|
||||
return {
|
||||
kind: 'accept',
|
||||
content: [{ type: 'text', text: renderGlobPaths(paths, caps.maxResults, spillRef) }],
|
||||
content: [{ type: 'text', text: renderGlobPaths(paths, caps, value.root, spillRef) }],
|
||||
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -33,8 +33,8 @@ import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
|
||||
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
|
||||
import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
|
||||
|
||||
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall } from './glob.ts'
|
||||
export type { GlobInput, GlobToolCaps } from './glob.ts'
|
||||
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, sampleAcrossTopLevel } from './glob.ts'
|
||||
export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts'
|
||||
export {
|
||||
GREP_MAX_LINE_BYTES,
|
||||
GREP_MAX_MATCHES,
|
||||
@@ -58,8 +58,10 @@ export const name = 'tool-fs-search'
|
||||
/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
|
||||
export const inject = ['tools', 'systemPrompt', 'bash']
|
||||
|
||||
/** Plugin config (all optional — `Config` supplies the defaults). */
|
||||
/** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */
|
||||
export interface Config {
|
||||
/** Whether an over-cap `glob` page is sampled across top-level entries instead of taking the modification-time head. */
|
||||
sampleOverCapGlobResults: boolean
|
||||
/** Max paths one `glob` call retains inline; later paths go to the formatted spill file. */
|
||||
globMaxResults?: number
|
||||
/** Max flat matches one `grep` call retains inline; later matches go to the formatted spill file. */
|
||||
@@ -73,6 +75,7 @@ export interface Config {
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
sampleOverCapGlobResults: z.boolean().required(),
|
||||
globMaxResults: z.number().default(GLOB_MAX_RESULTS),
|
||||
grepMaxMatches: z.number().default(GREP_MAX_MATCHES),
|
||||
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
|
||||
@@ -137,6 +140,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
return
|
||||
}
|
||||
applyGlobTool(ctx, {
|
||||
sampleOverCapGlobResults: resolved.sampleOverCapGlobResults,
|
||||
maxResults: resolved.globMaxResults,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
|
||||
@@ -64,7 +64,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
|
||||
await ctx.plugin(ToolFsSearch)
|
||||
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('dsh-tool-fs-search real-load-path guard', () => {
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters<Context['plugin']>[0]
|
||||
// A collapsed export shape (dropped inject) would throw "without inject" here.
|
||||
const fiber = await ctx.plugin(unwrapped)
|
||||
const fiber = await ctx.plugin(unwrapped, { sampleOverCapGlobResults: true })
|
||||
expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['glob', 'grep']))
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { join } from 'node:path'
|
||||
import { join, sep } from 'node:path'
|
||||
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
presentGlobCall,
|
||||
presentGrepCall,
|
||||
previewLine,
|
||||
sampleAcrossTopLevel,
|
||||
toWorkdirRelative,
|
||||
} from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
@@ -110,12 +111,14 @@ class FakeSpill extends SpillStore {
|
||||
}
|
||||
|
||||
interface SetupOptions {
|
||||
config?: ToolFsSearch.Config
|
||||
config?: Partial<ToolFsSearch.Config>
|
||||
spill?: boolean
|
||||
probeError?: Error
|
||||
probeResult?: BashRunResult
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG = { sampleOverCapGlobResults: true } satisfies ToolFsSearch.Config
|
||||
|
||||
async function setup(options: SetupOptions = {}) {
|
||||
const ctx = new Context()
|
||||
const warnings: string[] = []
|
||||
@@ -127,7 +130,7 @@ async function setup(options: SetupOptions = {}) {
|
||||
if (options.probeResult) bash.probeResult = options.probeResult
|
||||
if (options.probeError) bash.probeError = options.probeError
|
||||
if (options.spill === true) await ctx.plugin(FakeSpill)
|
||||
const fiber = await ctx.plugin(ToolFsSearch, options.config)
|
||||
const fiber = await ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...options.config })
|
||||
const spill = options.spill === true ? ctx.get('spillStore') as FakeSpill : undefined
|
||||
return { ctx, bash, spill, fiber, warnings }
|
||||
}
|
||||
@@ -184,6 +187,10 @@ describe('registration', () => {
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('Use the glob tool')
|
||||
expect(prompt).toContain('Use the grep tool')
|
||||
expect(prompt).toContain('sampled across top-level entries')
|
||||
expect(prompt).not.toContain('sampled across top-level directories')
|
||||
const glob = ctx.tools.schemas().find(schema => schema.name === 'glob')
|
||||
expect(glob?.description).toContain('sampled across top-level entries')
|
||||
})
|
||||
|
||||
it('does not register glob or grep when the bash executor cannot find rg', async () => {
|
||||
@@ -211,7 +218,7 @@ describe('registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolFsSearch) // no bash executor
|
||||
await ctx.plugin(ToolFsSearch, DEFAULT_CONFIG) // no bash executor
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -236,9 +243,27 @@ describe('registration', () => {
|
||||
expect(ctx.tools.get('glob')?.timeoutMs).toBe(30_000)
|
||||
expect(ctx.tools.get('grep')?.timeoutMs).toBe(30_000)
|
||||
})
|
||||
|
||||
it('describes the modification-time head when over-cap sampling is disabled', async () => {
|
||||
const { ctx } = await setup({ config: { sampleOverCapGlobResults: false } })
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('a larger one keeps the modification-time-ordered head')
|
||||
expect(prompt).not.toContain('sampled across top-level entries')
|
||||
const glob = ctx.tools.schemas().find(schema => schema.name === 'glob')
|
||||
expect(glob?.description).toContain('a larger result returns the first 100 paths in modification-time order')
|
||||
expect(glob?.description).not.toContain('sampled across top-level entries')
|
||||
})
|
||||
})
|
||||
|
||||
describe('config validation', () => {
|
||||
it('requires an explicit over-cap glob sampling choice', () => {
|
||||
expect(() => new ToolFsSearch.Config()).toThrow(/sampleOverCapGlobResults/)
|
||||
expect(new ToolFsSearch.Config({ sampleOverCapGlobResults: false })).toMatchObject({
|
||||
sampleOverCapGlobResults: false,
|
||||
globMaxResults: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['globMaxResults', { globMaxResults: 0 }],
|
||||
['grepMaxMatches', { grepMaxMatches: -1 }],
|
||||
@@ -250,7 +275,7 @@ describe('config validation', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeBash)
|
||||
await expect(ctx.plugin(ToolFsSearch, config)).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`))
|
||||
await expect(ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...config })).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -498,13 +523,100 @@ describe('raw output acquisition', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('cross-directory sampling', () => {
|
||||
it('gives every top-level entry a slot before any entry gets a second', () => {
|
||||
const paths = ['v/a', 'v/b', 'v/c', 'v/d', 'src/e', 'guide/f']
|
||||
// The head of 3 would be all `v/`; the sample reaches all three entries.
|
||||
expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['v/a', 'src/e', 'guide/f'], shown: 3, total: 3 })
|
||||
// Extra slots go round again — to the only entry with paths left — and the
|
||||
// page stays grouped by entry rather than interleaved.
|
||||
expect(sampleAcrossTopLevel(paths, 5)).toEqual({ items: ['v/a', 'v/b', 'v/c', 'src/e', 'guide/f'], shown: 3, total: 3 })
|
||||
})
|
||||
|
||||
it('hands an exhausted entry the remaining slots go to entries that still have paths', () => {
|
||||
const paths = ['solo/a', 'many/b', 'many/c', 'many/d']
|
||||
expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['solo/a', 'many/b', 'many/c'], shown: 2, total: 2 })
|
||||
})
|
||||
|
||||
it('does not rescan exhausted entries while filling a skewed page', () => {
|
||||
const singletonCount = 12_500
|
||||
const paths = [
|
||||
...Array.from({ length: singletonCount }, (_, index) => `group-${index}/only`),
|
||||
...Array.from({ length: singletonCount }, (_, index) => `late/${index}`),
|
||||
]
|
||||
expect(sampleAcrossTopLevel(paths, paths.length - 1)).toMatchObject({
|
||||
shown: singletonCount + 1,
|
||||
total: singletonCount + 1,
|
||||
items: { length: paths.length - 1 },
|
||||
})
|
||||
}, 500)
|
||||
|
||||
it('reports the entries it could not reach when the page is smaller than the top level', () => {
|
||||
const paths = ['a/1', 'b/1', 'c/1', 'd/1']
|
||||
expect(sampleAcrossTopLevel(paths, 2)).toEqual({ items: ['a/1', 'b/1'], shown: 2, total: 4 })
|
||||
})
|
||||
|
||||
it('groups an absolute path by its first real name, not by its empty root segment', () => {
|
||||
// Paths outside the workdir stay absolute; without stripping the leading
|
||||
// separator every one of them would collapse into a single empty group.
|
||||
expect(sampleAcrossTopLevel(['/out/a', '/out/b', '/away/c', '/away/d'], 2))
|
||||
.toEqual({ items: ['/out/a', '/away/c'], shown: 2, total: 2 })
|
||||
})
|
||||
|
||||
it('reproduces the modification-time-ordered head for a flat result', () => {
|
||||
expect(sampleAcrossTopLevel(['a.ts', 'b.ts', 'c.ts'], 2)).toEqual({ items: ['a.ts', 'b.ts'], shown: 2, total: 3 })
|
||||
})
|
||||
|
||||
it('groups paths relative to an explicit search root', () => {
|
||||
expect(sampleAcrossTopLevel([
|
||||
'workspace/vendor/a.ts',
|
||||
'workspace/vendor/b.ts',
|
||||
'workspace/source/c.ts',
|
||||
'workspace/guides/d.md',
|
||||
], 3, 'workspace')).toEqual({
|
||||
items: ['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'],
|
||||
shown: 3,
|
||||
total: 3,
|
||||
})
|
||||
expect(sampleAcrossTopLevel(['./vendor/a.ts', './src/b.ts'], 2, '.'))
|
||||
.toEqual({ items: ['./vendor/a.ts', './src/b.ts'], shown: 2, total: 2 })
|
||||
expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'], 2, '/'))
|
||||
.toEqual({ items: ['/vendor/a.ts', '/src/b.ts'], shown: 2, total: 2 })
|
||||
const rooted = [
|
||||
['root', 'a', 'one'].join(sep),
|
||||
['root', 'a', 'two'].join(sep),
|
||||
['root', 'b', 'three'].join(sep),
|
||||
]
|
||||
expect(sampleAcrossTopLevel(rooted, 2, 'root'))
|
||||
.toEqual({ items: [rooted[0], rooted[2]], shown: 2, total: 2 })
|
||||
expect(sampleAcrossTopLevel(['other/a.ts'], 1, 'src'))
|
||||
.toEqual({ items: ['other/a.ts'], shown: 1, total: 1 })
|
||||
expect(sampleAcrossTopLevel(['src'], 1, 'src'))
|
||||
.toEqual({ items: ['src'], shown: 1, total: 1 })
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('treats POSIX backslashes as filename characters', () => {
|
||||
const paths = ['old\\one', 'old\\two', 'src/a']
|
||||
expect(sampleAcrossTopLevel(paths, 2)).toEqual({
|
||||
items: ['old\\one', 'old\\two'],
|
||||
shown: 2,
|
||||
total: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles more top-level groups than the JavaScript argument limit', () => {
|
||||
const paths = Array.from({ length: 125_000 }, (_, index) => `dir-${index}/file.txt`)
|
||||
expect(sampleAcrossTopLevel(paths, 100)).toMatchObject({ shown: 100, total: 125_000 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('glob results', () => {
|
||||
it('lists workdir-relative paths (absolute output under the workdir is relativized)', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
|
||||
if (result.isError) throw new Error('expected glob success')
|
||||
expect(result.value).toEqual({ paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] })
|
||||
expect(result.value).toEqual({ root: '.', paths: [join('src', 'a.ts'), '/elsewhere/b.ts', 'rel/c.ts'] })
|
||||
expect(text(result)).toBe(`${join('src', 'a.ts')}\n/elsewhere/b.ts\nrel/c.ts`)
|
||||
})
|
||||
|
||||
@@ -534,7 +646,7 @@ describe('glob results', () => {
|
||||
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected glob success')
|
||||
expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
|
||||
expect(result.value).toEqual({ root: '.', paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
|
||||
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. Full sorted result stored at: /spill/glob-results.txt. Use the fake retrieval hint.)')
|
||||
expect(spill?.saves).toHaveLength(1)
|
||||
expect(spill?.saves[0]).toMatchObject({
|
||||
@@ -547,6 +659,79 @@ describe('glob results', () => {
|
||||
expect(result.additionalContexts?.[0]?.content).toEqual([{ type: 'text', text: 'glob context' }])
|
||||
})
|
||||
|
||||
it('samples an over-cap result across top-level entries instead of taking its head', async () => {
|
||||
// The shipped failure: `*` matches the whole tree, mtime order puts one
|
||||
// freshly-unpacked subtree first, and a head-of-3 reads like the entire
|
||||
// workspace. The sample reaches every top-level entry instead.
|
||||
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
|
||||
bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].join('\n'))
|
||||
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })
|
||||
expect(text(result)).toBe('vendor/a.ts\nsrc/d.ts\nguide/e.md\n\n'
|
||||
+ '(Showing 3 of 6 paths, sampled across 3 of the 4 top-level entries this pattern matched '
|
||||
+ 'instead of taken in modification-time order. Narrow path to inspect a specific subtree. '
|
||||
+ 'The complete result could not be saved; narrow pattern or path to see more.)')
|
||||
})
|
||||
|
||||
it('keeps the modification-time head when over-cap sampling is disabled', async () => {
|
||||
const { ctx, bash } = await setup({
|
||||
config: { globMaxResults: 3, sampleOverCapGlobResults: false },
|
||||
})
|
||||
bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md'].join('\n'))
|
||||
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
|
||||
.toBe('vendor/a.ts\nvendor/b.ts\nvendor/c.ts\n\n'
|
||||
+ '(Showing 3 of 5 paths. The complete result could not be saved; narrow pattern or path to see more.)')
|
||||
})
|
||||
|
||||
it('samples relative to the explicit search root instead of its workdir prefix', async () => {
|
||||
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
|
||||
bash.handler = () => runResult([
|
||||
'workspace/vendor/a.ts',
|
||||
'workspace/vendor/b.ts',
|
||||
'workspace/source/c.ts',
|
||||
'workspace/guides/d.md',
|
||||
].join('\n'))
|
||||
const result = await call(ctx, 'glob', { pattern: '*', path: 'workspace' }, { agent: agent('/w') })
|
||||
expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md')
|
||||
expect(text(result)).toContain('sampled across 3 of the 3 top-level entries')
|
||||
})
|
||||
|
||||
it('samples relative to an absolute search root after workdir display conversion', async () => {
|
||||
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
|
||||
bash.handler = () => runResult([
|
||||
'/w/workspace/vendor/a.ts',
|
||||
'/w/workspace/vendor/b.ts',
|
||||
'/w/workspace/source/c.ts',
|
||||
'/w/workspace/guides/d.md',
|
||||
].join('\n'))
|
||||
const result = await call(ctx, 'glob', { pattern: '*', path: '/w/workspace' }, { agent: agent('/w') })
|
||||
expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md')
|
||||
expect(text(result)).toContain('sampled across 3 of the 3 top-level entries')
|
||||
})
|
||||
|
||||
it('drops the narrowing hint when the sample reaches every top-level entry', async () => {
|
||||
const { ctx, bash } = await setup({ config: { globMaxResults: 3 } })
|
||||
bash.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n'))
|
||||
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
|
||||
.toBe('vendor/a.ts\nvendor/b.ts\nsrc/d.ts\n\n'
|
||||
+ '(Showing 3 of 4 paths, sampled across 2 of the 2 top-level entries this pattern matched '
|
||||
+ 'instead of taken in modification-time order. '
|
||||
+ 'The complete result could not be saved; narrow pattern or path to see more.)')
|
||||
})
|
||||
|
||||
it('keeps modification-time order untouched when the whole result fits', async () => {
|
||||
const { ctx, bash } = await setup({ config: { globMaxResults: 4 } })
|
||||
bash.handler = () => runResult('vendor/a.ts\nvendor/b.ts\nsrc/c.ts\n')
|
||||
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
|
||||
.toBe('vendor/a.ts\nvendor/b.ts\nsrc/c.ts')
|
||||
})
|
||||
|
||||
it('keeps the plain footer for a flat result, where the sample is the modification-time head', async () => {
|
||||
const { ctx, bash } = await setup({ config: { globMaxResults: 2 } })
|
||||
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n')
|
||||
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
|
||||
.toBe('a.ts\nb.ts\n\n(Showing 2 of 3 paths. The complete result could not be saved; narrow pattern or path to see more.)')
|
||||
})
|
||||
|
||||
it('does not create a spill file when the result fits inline', async () => {
|
||||
const { ctx, bash, spill } = await setup({ spill: true })
|
||||
bash.handler = () => runResult('a.ts\nb.ts\n')
|
||||
@@ -559,14 +744,14 @@ describe('glob results', () => {
|
||||
const { ctx, bash, spill } = await setup({ config: { globMaxResults: 1 }, spill: true })
|
||||
ctx.on('tools/post-execute', async () => ({
|
||||
kind: 'accept' as const,
|
||||
value: { paths: ['replacement-a.ts', 'replacement-b.ts'] },
|
||||
value: { root: '.', paths: ['replacement-a.ts', 'replacement-b.ts'] },
|
||||
}))
|
||||
bash.handler = () => runResult('old-a.ts\nold-b.ts\n')
|
||||
|
||||
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
|
||||
|
||||
if (result.isError) throw new Error('expected glob replacement success')
|
||||
expect(result.value).toEqual({ paths: ['replacement-a.ts', 'replacement-b.ts'] })
|
||||
expect(result.value).toEqual({ root: '.', paths: ['replacement-a.ts', 'replacement-b.ts'] })
|
||||
expect(text(result)).toContain('replacement-a.ts')
|
||||
expect(text(result)).not.toContain('old-a.ts')
|
||||
expect(spill?.saves).toHaveLength(0)
|
||||
@@ -580,7 +765,7 @@ describe('glob results', () => {
|
||||
parent: Symbol('run_code') as ToolExecutionToken,
|
||||
})
|
||||
if (result.isError) throw new Error('expected glob success')
|
||||
expect(result.value).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
|
||||
expect(result.value).toEqual({ root: '.', paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] })
|
||||
expect(text(result)).toBe('a.ts\nb.ts\n\n(Showing 2 of 4 paths. The complete result could not be saved; narrow pattern or path to see more.)')
|
||||
expect(spill?.saves).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/web/tool-web/README.md
|
||||
README.md: 9b78920b1b6c611118294421dec1e75e381ed5d6
|
||||
README.zh.md: d36258d3a5bd8af6716e1fd9c3384389e8395e23
|
||||
README.md: 7bee0d2d30fbbcf582fd7b60eb5d9130b6bdf888
|
||||
README.zh.md: 3d708839c9ffbdd89df08678fd6997fc6c45ee07
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
|
||||
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
|
||||
|
||||
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`).
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 `presentCall`。所有 web 访问都通过 `ctx.web`;该包(package)绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。
|
||||
面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包(package)绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。
|
||||
|
||||
每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { Context } from 'cordis'
|
||||
import TurndownService from 'turndown'
|
||||
import { gfm } from '@joplin/turndown-plugin-gfm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, JsonValue, ToolResult, WebFetchResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -246,26 +246,87 @@ function renderBody(body: WebFetchBody, maxInputChars: number): RenderedBody {
|
||||
/** The truncation notice appended when the provider or the output cap cut content. */
|
||||
const TRUNCATION_FOOTER = '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)'
|
||||
|
||||
/** A rendered fetch output: the model-facing text and its effective truncation. */
|
||||
interface RenderedFetch {
|
||||
/** The complete bounded output — header, rendered body, and truncation footer. */
|
||||
text: string
|
||||
/**
|
||||
* True when the provider capped the body, a pre-conversion source cut applied,
|
||||
* or the complete output exceeded `maxOutputChars`. This is the effective
|
||||
* truncation the returned text reflects (its footer), wider than the
|
||||
* provider-only `WebFetchResult.truncated`.
|
||||
*/
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a fetch result as one model-facing text block, bounded as a whole.
|
||||
* The same cap limits the source prefix processed synchronously, then applies
|
||||
* again where the complete output — header, rendered body, and footer — is known.
|
||||
* Render a fetch result to its bounded model-facing text and effective
|
||||
* truncation. The single source of both the `render` text and the fetch card's
|
||||
* `truncated`, so the card never disagrees with the text the model saw. The cap
|
||||
* limits the source prefix processed synchronously, then applies again where the
|
||||
* complete output — header, rendered body, and footer — is known.
|
||||
*
|
||||
* Package-internal: the only callers are {@link formatFetchOutput} and
|
||||
* {@link fetchMetaFromValue}, both reached through the tool registry, which
|
||||
* deep-freezes the result value before calling `output.render` and
|
||||
* `output.presentationMeta`. The conversion is memoized per
|
||||
* `(result, maxOutputChars)` so the synchronous DOM parse and turndown walk run
|
||||
* once, not twice, on that same frozen value. Keeping it unexported means no
|
||||
* caller can mutate a cached input or the returned {@link RenderedFetch}, so the
|
||||
* memo needs no defensive copy.
|
||||
*
|
||||
* @param result - the seam's fetch outcome.
|
||||
* @param maxOutputChars - cap on the complete returned string; a cut body gets
|
||||
* the same fetch-something-narrower notice as provider-side truncation.
|
||||
* @returns a `Fetched <url> (HTTP <status>)` header, the rendered body, and a
|
||||
* truncation notice when the provider or the cap cut the content.
|
||||
* @returns the complete `Fetched <url> (HTTP <status>)`-headed text and whether
|
||||
* the provider, a source cut, or the cap trimmed the content.
|
||||
*/
|
||||
export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string {
|
||||
function renderFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch {
|
||||
const byCap = renderCache.get(result) ?? new Map<number, RenderedFetch>()
|
||||
const cached = byCap.get(maxOutputChars)
|
||||
if (cached !== undefined) return cached
|
||||
const computed = computeFetchOutput(result, maxOutputChars)
|
||||
byCap.set(maxOutputChars, computed)
|
||||
renderCache.set(result, byCap)
|
||||
return computed
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-result memo for {@link renderFetchOutput}, keyed first on the frozen
|
||||
* result value so a garbage-collected result drops its entry, then on the output
|
||||
* cap (a deployment constant per registration). Collapses the registry's twin
|
||||
* `render`/`presentationMeta` calls into one HTML→markdown conversion.
|
||||
*/
|
||||
const renderCache = new WeakMap<WebFetchResult, Map<number, RenderedFetch>>()
|
||||
|
||||
/**
|
||||
* The uncached conversion behind {@link renderFetchOutput}. Separated so the
|
||||
* memo wraps exactly one call site and the conversion logic stays pure.
|
||||
*
|
||||
* @param result - the seam's fetch outcome.
|
||||
* @param maxOutputChars - cap on the complete returned string.
|
||||
* @returns the bounded text and effective truncation.
|
||||
*/
|
||||
function computeFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch {
|
||||
const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n`
|
||||
const rendered = renderBody(result.body, maxOutputChars)
|
||||
const prefix = `${header}${rendered.text}`
|
||||
const truncated = result.truncated || rendered.sourceTruncated || prefix.length > maxOutputChars
|
||||
const full = `${prefix}${truncated ? TRUNCATION_FOOTER : ''}`
|
||||
if (full.length <= maxOutputChars) return full
|
||||
if (maxOutputChars < TRUNCATION_FOOTER.length) return full.slice(0, maxOutputChars)
|
||||
return `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}`
|
||||
if (full.length <= maxOutputChars) return { text: full, truncated }
|
||||
if (maxOutputChars < TRUNCATION_FOOTER.length) return { text: full.slice(0, maxOutputChars), truncated }
|
||||
return { text: `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}`, truncated }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a fetch result as one model-facing text block, bounded as a whole.
|
||||
*
|
||||
* @param result - the seam's fetch outcome.
|
||||
* @param maxOutputChars - cap on the complete returned string.
|
||||
* @returns the complete text from {@link renderFetchOutput}.
|
||||
*/
|
||||
export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string {
|
||||
return renderFetchOutput(result, maxOutputChars).text
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,6 +339,83 @@ export function presentFetchCall(args: { url: string }): GenericCallView {
|
||||
return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `web_fetch` tool's private `tool/result` `meta` payload: the fetch summary
|
||||
* a UI cannot recover from the model-facing render text without reparsing its
|
||||
* header line. Attached opaquely (as `JsonValue`) on the tool result and
|
||||
* persisted with the session log, so `presentResult` reproduces the fetch card
|
||||
* on replay. The body itself is already markdown in the result content, so it is
|
||||
* not duplicated here. `truncated` is the effective truncation the render text
|
||||
* reflects, which a client cannot recompute (it does not know the deployment's
|
||||
* `fetchMaxOutputChars`); this is why fetch meta is carried, not derived from the
|
||||
* header line (see the web-result-card Agent Note).
|
||||
*/
|
||||
export interface WebFetchMeta {
|
||||
/** The final URL after allowed redirects. */
|
||||
url: string
|
||||
/** HTTP status code of the fetched response. */
|
||||
statusCode: number
|
||||
/** True when the provider, a source cut, or the output cap trimmed the content. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a validated `web_fetch` output value into its replayable presentation
|
||||
* meta ({@link WebFetchMeta} as opaque JSON). `truncated` is the effective
|
||||
* truncation the model-facing text reflects (via {@link renderFetchOutput}), not
|
||||
* the provider-only `WebFetchResult.truncated`, so the fetch card never disagrees
|
||||
* with the returned text.
|
||||
*
|
||||
* @param value - the canonical `web_fetch` output value (the seam's result shape).
|
||||
* @param maxOutputChars - the deployment's output cap, the same one
|
||||
* {@link formatFetchOutput} applies to the render text.
|
||||
* @returns the URL, status code, and effective truncation flag.
|
||||
*/
|
||||
export function fetchMetaFromValue(value: WebFetchResult, maxOutputChars: number): JsonValue {
|
||||
return { url: value.url, statusCode: value.statusCode, truncated: renderFetchOutput(value, maxOutputChars).truncated }
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow opaque live or replayed result metadata to a {@link WebFetchMeta}.
|
||||
* Malformed metadata returns `undefined` so presentation can fall back to the
|
||||
* generic card instead of throwing during replay.
|
||||
*
|
||||
* @param meta - result metadata.
|
||||
* @returns the validated fetch meta, or `undefined` for absent or malformed data.
|
||||
*/
|
||||
export function fetchMetaFromResult(meta: unknown): WebFetchMeta | undefined {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
|
||||
const { url, statusCode, truncated } = meta as Record<string, unknown>
|
||||
if (typeof url !== 'string' || typeof statusCode !== 'number' || typeof truncated !== 'boolean') return undefined
|
||||
return { url, statusCode, truncated }
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed-call presentation: a `web` fetch card carrying the retrieval summary
|
||||
* from `meta`. It sets no `content` copy — a UI without the `web` capability
|
||||
* falls back to the raw `tool/result` content, the already-markdown body (see the
|
||||
* web-result-card Agent Note).
|
||||
*
|
||||
* @param args - the raw tool arguments; `url` becomes the result-state title so a
|
||||
* window-truncated replay that dropped the call head still has one.
|
||||
* @param result - the final model-facing tool result; `meta` carries the summary.
|
||||
* @returns the fetch result view, or `undefined` (generic card) on failure or
|
||||
* malformed meta.
|
||||
*/
|
||||
export function presentFetchResult(args: { url: string }, result: ToolResult): WebFetchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const meta = fetchMetaFromResult(result.meta)
|
||||
if (meta === undefined) return undefined
|
||||
return {
|
||||
card: 'web',
|
||||
kind: 'fetch',
|
||||
title: args.url,
|
||||
url: meta.url,
|
||||
statusCode: meta.statusCode,
|
||||
truncated: meta.truncated,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `web_fetch` tool and its system-prompt guidance.
|
||||
*
|
||||
@@ -333,6 +471,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value, maxOutputChars) }],
|
||||
presentationMeta: (_args, value) => fetchMetaFromValue(value, maxOutputChars),
|
||||
},
|
||||
timeoutMs,
|
||||
// Provider reads do not mutate parent-agent state.
|
||||
@@ -351,5 +490,6 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar
|
||||
}
|
||||
},
|
||||
presentCall: presentFetchCall,
|
||||
presentResult: (args, result) => presentFetchResult(args, result),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import type {} from '@deepseek-ai/dsh-web'
|
||||
import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts'
|
||||
import { applyWebFetchTool } from './fetch.ts'
|
||||
|
||||
export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts'
|
||||
export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall } from './fetch.ts'
|
||||
export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall, presentSearchResult, searchMetaFromValue, searchMetaFromResult } from './search.ts'
|
||||
export type { WebSearchMeta } from './search.ts'
|
||||
export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts'
|
||||
export type { WebFetchMeta } from './fetch.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-web'
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { WebSearchResult } from '@deepseek-ai/dsh-web'
|
||||
import type { GenericCallView, JsonValue, ToolResult, WebSearchResultView, WebSource } from '@deepseek-ai/dsh-tools'
|
||||
import type { WebSearchResult, WebSearchSource } from '@deepseek-ai/dsh-web'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/**
|
||||
@@ -84,6 +84,117 @@ export function presentSearchCall(args: { query: string }): GenericCallView {
|
||||
return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `web_search` tool's private `tool/result` `meta` payload: the structured
|
||||
* sources, the optional provider answer, and the truncation flag. Attached
|
||||
* opaquely (as `JsonValue`) on the tool result and persisted with the session
|
||||
* log, so `presentResult` reproduces the search card on replay. This projection
|
||||
* is the only faithful route to the per-source fields, which the lossy render
|
||||
* text cannot carry (the owning rationale is the web-result-card Agent Note).
|
||||
*/
|
||||
export interface WebSearchMeta {
|
||||
/** The faithful structured sources, in result order. */
|
||||
sources: WebSource[]
|
||||
/** True when the seam cut the source list to honor the result cap. */
|
||||
truncated: boolean
|
||||
/** The provider-generated answer text, when any. */
|
||||
answer?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one seam source into a plain object that omits every absent optional
|
||||
* field. Shared by the canonical `execute` result and its replayable
|
||||
* presentation meta so both carry byte-identical source shapes.
|
||||
*
|
||||
* @param source - one source from the `ctx.web` search outcome.
|
||||
* @returns `{ url }` plus each present optional field.
|
||||
*/
|
||||
function projectSource(source: WebSearchSource): {
|
||||
url: string
|
||||
title?: string
|
||||
snippet?: string
|
||||
publishedAt?: string
|
||||
} {
|
||||
return {
|
||||
url: source.url,
|
||||
...source.title !== undefined ? { title: source.title } : {},
|
||||
...source.snippet !== undefined ? { snippet: source.snippet } : {},
|
||||
...source.publishedAt !== undefined ? { publishedAt: source.publishedAt } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a validated `web_search` output value into its replayable
|
||||
* presentation meta ({@link WebSearchMeta} as opaque JSON).
|
||||
*
|
||||
* @param value - the canonical `web_search` output value (the seam's result shape).
|
||||
* @returns the structured sources, the truncation flag, and the answer when present.
|
||||
*/
|
||||
export function searchMetaFromValue(value: WebSearchResult): JsonValue {
|
||||
return {
|
||||
sources: value.sources.map(projectSource),
|
||||
truncated: value.truncated,
|
||||
...value.content !== undefined ? { answer: value.content } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether `value` is a valid {@link WebSource} (defensive narrowing from opaque `meta`). */
|
||||
function isWebSource(value: unknown): value is WebSource {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const { url, title, snippet, publishedAt } = value as Record<string, unknown>
|
||||
return typeof url === 'string'
|
||||
&& (title === undefined || typeof title === 'string')
|
||||
&& (snippet === undefined || typeof snippet === 'string')
|
||||
&& (publishedAt === undefined || typeof publishedAt === 'string')
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow opaque live or replayed result metadata to a {@link WebSearchMeta}.
|
||||
* Malformed metadata returns `undefined` so presentation can fall back to the
|
||||
* generic card instead of throwing during replay.
|
||||
*
|
||||
* @param meta - result metadata.
|
||||
* @returns the validated search meta, or `undefined` for absent or malformed data.
|
||||
*/
|
||||
export function searchMetaFromResult(meta: unknown): WebSearchMeta | undefined {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
|
||||
const { sources, truncated, answer } = meta as Record<string, unknown>
|
||||
if (!Array.isArray(sources) || !sources.every(isWebSource)) return undefined
|
||||
if (typeof truncated !== 'boolean') return undefined
|
||||
if (answer !== undefined && typeof answer !== 'string') return undefined
|
||||
return {
|
||||
sources,
|
||||
truncated,
|
||||
...answer !== undefined ? { answer } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed-call presentation: a `web` search card carrying the faithful
|
||||
* structured sources from `meta`. It sets no `content` copy — a UI without the
|
||||
* `web` capability falls back to the raw `tool/result` content, which is the
|
||||
* same text (see the web-result-card Agent Note).
|
||||
*
|
||||
* @param args - the raw tool arguments; `query` becomes the result-state title so
|
||||
* a window-truncated replay that dropped the call head still has one.
|
||||
* @param result - the final model-facing tool result; `meta` carries the sources.
|
||||
* @returns the search result view, or `undefined` (generic card) on failure or
|
||||
* malformed meta.
|
||||
*/
|
||||
export function presentSearchResult(args: { query: string }, result: ToolResult): WebSearchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const meta = searchMetaFromResult(result.meta)
|
||||
if (meta === undefined) return undefined
|
||||
return {
|
||||
card: 'web',
|
||||
kind: 'search',
|
||||
title: args.query,
|
||||
sources: meta.sources,
|
||||
truncated: meta.truncated,
|
||||
...meta.answer !== undefined ? { answer: meta.answer } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `web_search` tool and its system-prompt guidance.
|
||||
*
|
||||
@@ -131,6 +242,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: formatSearchOutput(value) }],
|
||||
presentationMeta: (_args, value) => searchMetaFromValue(value),
|
||||
},
|
||||
timeoutMs,
|
||||
// Provider reads do not mutate parent-agent state.
|
||||
@@ -143,15 +255,11 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
|
||||
)
|
||||
return {
|
||||
...result.content !== undefined ? { content: result.content } : {},
|
||||
sources: result.sources.map(source => ({
|
||||
url: source.url,
|
||||
...source.title !== undefined ? { title: source.title } : {},
|
||||
...source.snippet !== undefined ? { snippet: source.snippet } : {},
|
||||
...source.publishedAt !== undefined ? { publishedAt: source.publishedAt } : {},
|
||||
})),
|
||||
sources: result.sources.map(projectSource),
|
||||
truncated: result.truncated,
|
||||
}
|
||||
},
|
||||
presentCall: presentSearchCall,
|
||||
presentResult: (args, result) => presentSearchResult(args, result),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -14,8 +14,16 @@ import {
|
||||
parseFetchArgs,
|
||||
presentSearchCall,
|
||||
presentFetchCall,
|
||||
presentSearchResult,
|
||||
presentFetchResult,
|
||||
searchMetaFromValue,
|
||||
searchMetaFromResult,
|
||||
fetchMetaFromValue,
|
||||
fetchMetaFromResult,
|
||||
WEB_SEARCH_MAX_RESULTS,
|
||||
} from '@deepseek-ai/dsh-tool-web'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
@@ -91,6 +99,97 @@ describe('search formatting', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** Build a completed non-error tool result with the given meta and text content. */
|
||||
function toolResult(meta: unknown, text = 'body', isError = false): ToolResult {
|
||||
const content: ContentBlock[] = [{ type: 'text', text }]
|
||||
return { content, isError, ...meta !== undefined ? { meta: meta as never } : {} }
|
||||
}
|
||||
|
||||
describe('web_search presentation meta and result view', () => {
|
||||
it('projects sources, answer, and truncation into meta, omitting absent optional fields', () => {
|
||||
const meta = searchMetaFromValue({
|
||||
content: 'an answer', truncated: true,
|
||||
sources: [
|
||||
{ url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
|
||||
{ url: 'https://b.test/y' },
|
||||
],
|
||||
})
|
||||
expect(meta).toEqual({
|
||||
answer: 'an answer',
|
||||
truncated: true,
|
||||
sources: [
|
||||
{ url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
|
||||
{ url: 'https://b.test/y' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits answer from meta when the provider returned none', () => {
|
||||
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
expect(meta).toEqual({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
})
|
||||
|
||||
it('round-trips projected meta back to a typed search meta', () => {
|
||||
const value = {
|
||||
content: 'ans', truncated: false,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 's', publishedAt: '2026-01-01' }],
|
||||
}
|
||||
expect(searchMetaFromResult(searchMetaFromValue(value))).toEqual({
|
||||
answer: 'ans', truncated: false,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 's', publishedAt: '2026-01-01' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('presents a completed search as a web/search card carrying the structured sources, titled by the query', () => {
|
||||
const meta = searchMetaFromValue({
|
||||
content: 'an answer', truncated: true,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
||||
})
|
||||
expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'rendered'))).toEqual({
|
||||
card: 'web',
|
||||
kind: 'search',
|
||||
title: 'q',
|
||||
answer: 'an answer',
|
||||
truncated: true,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the answer from the view when meta carries none', () => {
|
||||
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
const view = presentSearchResult({ query: 'q' }, toolResult(meta))
|
||||
expect(view).toBeDefined()
|
||||
expect(view && 'answer' in view).toBe(false)
|
||||
expect(view && 'content' in view).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the generic card on an error result', () => {
|
||||
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'body', true))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the generic card on absent or malformed meta', () => {
|
||||
expect(presentSearchResult({ query: 'q' }, toolResult(undefined))).toBeUndefined()
|
||||
expect(searchMetaFromResult(undefined)).toBeUndefined()
|
||||
expect(searchMetaFromResult(null)).toBeUndefined()
|
||||
expect(searchMetaFromResult('nope')).toBeUndefined()
|
||||
expect(searchMetaFromResult([])).toBeUndefined()
|
||||
expect(searchMetaFromResult({})).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: 'x', truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [], truncated: 'no' })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [], truncated: false, answer: 1 })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [null], truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [{ url: 1 }], truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [{ url: 'u', title: 2 }], truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [{ url: 'u', snippet: 2 }], truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [{ url: 'u', publishedAt: 2 }], truncated: false })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts an empty source list as valid meta', () => {
|
||||
expect(searchMetaFromResult({ sources: [], truncated: false })).toEqual({ sources: [], truncated: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetch formatting', () => {
|
||||
const NO_CAP = 1_000_000
|
||||
const HEADER = 'Fetched https://a.test (HTTP 200)\n\n'
|
||||
@@ -259,6 +358,87 @@ describe('fetch formatting', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('web_fetch presentation meta and result view', () => {
|
||||
const NO_CAP = 1_000_000
|
||||
|
||||
it('projects url, status, and the provider truncation into meta', () => {
|
||||
expect(fetchMetaFromValue({ url: 'https://a.test', statusCode: 404, truncated: true, body: { kind: 'text', content: 'x' } }, NO_CAP))
|
||||
.toEqual({ url: 'https://a.test', statusCode: 404, truncated: true })
|
||||
})
|
||||
|
||||
it('projects truncated: true when the output cap cut a body the provider did not, matching the render footer', () => {
|
||||
// The provider reports truncated: false, but conversion outgrows the cap, so
|
||||
// the render text carries the truncation footer. The meta must agree.
|
||||
const value = {
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html' as const, content: `<p>${'_'.repeat(1000)}</p>` },
|
||||
}
|
||||
const meta = fetchMetaFromValue(value, 500) as { truncated: boolean }
|
||||
expect(meta.truncated).toBe(true)
|
||||
expect(formatFetchOutput(value, 500)).toContain('Content truncated')
|
||||
})
|
||||
|
||||
it('projects truncated: false when neither the provider nor the cap cut the body', () => {
|
||||
const value = {
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'text' as const, content: 'short' },
|
||||
}
|
||||
const meta = fetchMetaFromValue(value, NO_CAP) as { truncated: boolean }
|
||||
expect(meta.truncated).toBe(false)
|
||||
expect(formatFetchOutput(value, NO_CAP)).not.toContain('Content truncated')
|
||||
})
|
||||
|
||||
it('converts one HTML body once across the render and meta projections of the same result', () => {
|
||||
// The registry calls output.render and output.presentationMeta with the same
|
||||
// frozen result value; the memo must collapse them into one turndown walk so
|
||||
// a large or deeply nested page is not parsed and converted twice. A second
|
||||
// cap on the same result is a distinct entry, so it converts again.
|
||||
const spy = vi.spyOn(TurndownService.prototype, 'turndown')
|
||||
const value = {
|
||||
url: 'https://a.test', statusCode: 200, truncated: false,
|
||||
body: { kind: 'html' as const, content: '<p>hello</p>' },
|
||||
}
|
||||
try {
|
||||
formatFetchOutput(value, NO_CAP)
|
||||
fetchMetaFromValue(value, NO_CAP)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
formatFetchOutput(value, NO_CAP - 1)
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('presents a completed fetch as a web/fetch card carrying the summary, titled by the url, without content', () => {
|
||||
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: '# Title' } }, NO_CAP)
|
||||
expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, '# Title'))).toEqual({
|
||||
card: 'web',
|
||||
kind: 'fetch',
|
||||
title: 'https://a.test',
|
||||
url: 'https://a.test',
|
||||
statusCode: 200,
|
||||
truncated: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the generic card on an error result', () => {
|
||||
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: 'ok' } }, NO_CAP)
|
||||
expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, 'body', true))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the generic card on absent or malformed meta', () => {
|
||||
expect(presentFetchResult({ url: 'https://a.test' }, toolResult(undefined))).toBeUndefined()
|
||||
expect(fetchMetaFromResult(undefined)).toBeUndefined()
|
||||
expect(fetchMetaFromResult(null)).toBeUndefined()
|
||||
expect(fetchMetaFromResult('nope')).toBeUndefined()
|
||||
expect(fetchMetaFromResult([])).toBeUndefined()
|
||||
expect(fetchMetaFromResult({})).toBeUndefined()
|
||||
expect(fetchMetaFromResult({ url: 1, statusCode: 200, truncated: false })).toBeUndefined()
|
||||
expect(fetchMetaFromResult({ url: 'u', statusCode: 'x', truncated: false })).toBeUndefined()
|
||||
expect(fetchMetaFromResult({ url: 'u', statusCode: 200, truncated: 'no' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-web registration', () => {
|
||||
it('registers both tools by default', async () => {
|
||||
const { fiber, ctx } = await mountTools()
|
||||
@@ -323,6 +503,38 @@ describe('tool-web execution through the real registry', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('projects the search sources into the tool result meta and derives its web/search view', async () => {
|
||||
const result: WebSearchResult = {
|
||||
content: 'answer', truncated: true,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
||||
}
|
||||
const { ctx, fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
|
||||
const out = await call('web_search', { query: 'q' })
|
||||
expect(out.meta).toEqual({
|
||||
answer: 'answer', truncated: true,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
||||
})
|
||||
const view = ctx.tools.get('web_search')?.presentResult?.({ query: 'q' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} })
|
||||
expect(view).toMatchObject({ card: 'web', kind: 'search', truncated: true, answer: 'answer' })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('projects the fetch summary into the tool result meta and derives its web/fetch view', async () => {
|
||||
const fetchProvider = {
|
||||
id: 'stub-fetch',
|
||||
available: () => available,
|
||||
fetch: (request: { url: string }) => Promise.resolve({
|
||||
url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: true,
|
||||
}),
|
||||
}
|
||||
const { ctx, fiber, call } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
|
||||
const out = await call('web_fetch', { url: 'https://a.test' })
|
||||
expect(out.meta).toEqual({ url: 'https://a.test', statusCode: 200, truncated: true })
|
||||
const view = ctx.tools.get('web_fetch')?.presentResult?.({ url: 'https://a.test' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} })
|
||||
expect(view).toMatchObject({ card: 'web', kind: 'fetch', url: 'https://a.test', statusCode: 200, truncated: true })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('surfaces a structured WebError when no provider is available', async () => {
|
||||
const { fiber, call } = await mountTools()
|
||||
const out = await call('web_search', { query: 'q' })
|
||||
|
||||
Reference in New Issue
Block a user