Merge remote-tracking branch 'origin/master' into worktree/unify-workspace-add
This commit is contained in:
@@ -54,6 +54,7 @@ const PRIVILEGED_METHODS = new Set([
|
||||
'settings.describe',
|
||||
'settings.update',
|
||||
'settings.replace',
|
||||
'settings.mutate',
|
||||
'credentials.describe',
|
||||
'credentials.set',
|
||||
'credentials.unset',
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('connection node half', () => {
|
||||
// passed), but each privileged method stays loopback-only and 403s.
|
||||
for (const method of [
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
'settings.describe', 'settings.update', 'settings.replace',
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
@@ -191,7 +191,7 @@ describe('connection node half over a real HTTP server', () => {
|
||||
// Reads are as privileged as writes: describe returns the exposed
|
||||
// configuration, and credentials.describe probes arbitrary env-var names.
|
||||
for (const method of [
|
||||
'settings.describe', 'settings.update', 'settings.replace',
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
]) {
|
||||
|
||||
@@ -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-models/README.md
|
||||
README.md: 4edb34ccbe8f628c04e410a6dd2f002e247623f3
|
||||
README.zh.md: 68a1e0ee205d3ba764620bcfeba7c11a88ee8736
|
||||
README.md: 937b8e6bf9b41049f359d702eb3ac2dc11bf0767
|
||||
README.zh.md: 37d8642e8d6d52a2d95e86207649b7a6ce3e8246
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
|
||||
|
||||
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface.
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。
|
||||
|
||||
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
|
||||
@@ -1,7 +1,99 @@
|
||||
.dialog {
|
||||
width: min(420px, 100%);
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(640px, calc(100vw - 64px));
|
||||
max-height: 100vh;
|
||||
padding: clamp(104px, 18vh, 156px) 0 40px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 42px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 16px 0 0;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
width: 100%;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.title,
|
||||
.description,
|
||||
.actions {
|
||||
animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
}
|
||||
|
||||
.title { animation-delay: 40ms; }
|
||||
.description { animation-delay: 80ms; }
|
||||
.actions { animation-delay: 120ms; }
|
||||
|
||||
@keyframes credential-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand,
|
||||
.title,
|
||||
.description,
|
||||
.actions {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page {
|
||||
width: calc(100vw - 40px);
|
||||
padding-top: 64px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column-reverse;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.later {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Official-DeepSeek first-run dialog. Readiness comes from the same
|
||||
* Official-DeepSeek first-run step. Readiness comes from the same
|
||||
* provider/settings/credential join as the Models page; the prompt only
|
||||
* routes the user to that page's single credential editor.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
|
||||
import { deepSeekReadiness } from './store.ts'
|
||||
@@ -37,29 +37,35 @@ function assertNever(_value: never): never {
|
||||
* Prompt a first-run user to open Models while the official adapter exists
|
||||
* and its effective credential is not configured.
|
||||
* @param props - settings-shell owner state and Models feature dependencies.
|
||||
* @returns the controlled modal or null when onboarding needs no intervention.
|
||||
* @returns the onboarding page or null when onboarding needs no intervention.
|
||||
*/
|
||||
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
|
||||
const { active, openSection, controller, useSnapshot, t } = props
|
||||
const { complete, openSection, controller, useSnapshot, t } = props
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
const readiness = deepSeekReadiness(state)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !dismissed && state.status === 'idle') void controller.load()
|
||||
}, [active, controller, dismissed, state.status])
|
||||
if (state.status === 'idle') void controller.load()
|
||||
}, [controller, state.status])
|
||||
|
||||
const close = (): void => {
|
||||
setDismissed(true)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (
|
||||
readiness.kind === 'adapter-absent'
|
||||
|| readiness.kind === 'configured'
|
||||
|| readiness.kind === 'unavailable'
|
||||
) complete()
|
||||
}, [complete, readiness.kind])
|
||||
|
||||
useEffect(() => {
|
||||
if (readiness.kind === 'credential-missing') titleRef.current?.focus()
|
||||
}, [readiness.kind])
|
||||
|
||||
const openModels = (): void => {
|
||||
close()
|
||||
complete()
|
||||
openSection('models')
|
||||
}
|
||||
|
||||
if (!active || dismissed) return null
|
||||
|
||||
switch (readiness.kind) {
|
||||
case 'loading':
|
||||
case 'adapter-absent':
|
||||
@@ -74,23 +80,25 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={close}
|
||||
title={t('onboardingTitle')}
|
||||
closeLabel={t('onboardingLater')}
|
||||
description={t('onboardingDescription')}
|
||||
className={styles['dialog'] as string}
|
||||
footer={(
|
||||
<Button
|
||||
variant="primary"
|
||||
className={styles['primary']}
|
||||
autoFocus
|
||||
onClick={openModels}
|
||||
>
|
||||
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
|
||||
<div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
|
||||
<h2
|
||||
ref={titleRef}
|
||||
id="deepseek-onboarding-title"
|
||||
className={styles['title']}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('onboardingTitle')}
|
||||
</h2>
|
||||
<p className={styles['description']}>{t('onboardingDescription')}</p>
|
||||
<div className={styles['actions']}>
|
||||
<Button variant="ghost" className={styles['later']} onClick={complete}>
|
||||
{t('onboardingLater')}
|
||||
</Button>
|
||||
<Button variant="primary" className={styles['primary']} onClick={openModels}>
|
||||
{t('onboardingGoToSettings')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ function fail<T>(message: string): RpcResponse<T> {
|
||||
|
||||
function harness(options: {
|
||||
provider?: boolean
|
||||
providerActive?: boolean
|
||||
providerSettingsNs?: string
|
||||
providerActive?: boolean
|
||||
settingsNamespace?: boolean
|
||||
apiKeyEnv?: string | null
|
||||
literal?: boolean
|
||||
@@ -40,9 +40,7 @@ function harness(options: {
|
||||
const face = {
|
||||
llm: {
|
||||
providers: () => {
|
||||
if (options.providersReject === true) {
|
||||
return Promise.reject(new Error('provider transport unavailable'))
|
||||
}
|
||||
if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable'))
|
||||
return Promise.resolve(ok({
|
||||
providers: options.provider === false
|
||||
? []
|
||||
@@ -91,9 +89,11 @@ function harness(options: {
|
||||
}
|
||||
const controller = new ModelsSettingsStore(face as never)
|
||||
const openSection = vi.fn()
|
||||
const complete = vi.fn()
|
||||
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
|
||||
const props: DeepSeekOnboardingDialogProps = {
|
||||
active: true,
|
||||
stepId: 'deepseek-official',
|
||||
complete,
|
||||
openSection,
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
@@ -101,36 +101,36 @@ function harness(options: {
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
t: key => en[key],
|
||||
}
|
||||
return { controller, openSection, props, configure: () => { fileConfigured = true } }
|
||||
return { controller, complete, openSection, props, configure: () => { fileConfigured = true } }
|
||||
}
|
||||
|
||||
describe('DeepSeekOnboardingDialog', () => {
|
||||
it('loads on first entry and presents one accessible route to Models', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
|
||||
expect(await screen.findByRole('region', { name: en.onboardingTitle })).toBeTruthy()
|
||||
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
|
||||
const action = screen.getByRole('button', { name: en.onboardingGoToSettings })
|
||||
expect(action).toBeTruthy()
|
||||
expect(document.activeElement).toBe(action)
|
||||
expect(document.activeElement).toBe(screen.getByRole('heading', { name: en.onboardingTitle }))
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the Models section and dismisses the prompt', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
await screen.findByRole('region')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
expect(h.openSection).toHaveBeenCalledWith('models')
|
||||
expect(screen.queryByRole('dialog', { name: en.onboardingTitle })).toBeNull()
|
||||
})
|
||||
|
||||
it('allows configure-later dismissal without opening settings', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
await screen.findByRole('region')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
expect(h.openSection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -146,7 +146,8 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
]) {
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
|
||||
expect(h.openSection).not.toHaveBeenCalled()
|
||||
view.unmount()
|
||||
}
|
||||
@@ -161,7 +162,8 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
]) {
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
@@ -169,18 +171,10 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
it('closes when an external credential invalidation refreshes the shared join', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
await screen.findByRole('region')
|
||||
h.configure()
|
||||
await act(async () => { await h.controller.load() })
|
||||
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
|
||||
})
|
||||
|
||||
it('stays hidden while the onboarding owner is inactive', async () => {
|
||||
const h = harness()
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} active={false} />)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
view.rerender(<DeepSeekOnboardingDialog {...h.props} active />)
|
||||
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
|
||||
await waitFor(() => { expect(screen.queryByRole('region')).toBeNull() })
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-settings-general/README.md
|
||||
README.md: 241678567c4dbc7411ab9e76f595f2f696cc02d6
|
||||
README.zh.md: da4568d109c20bf1860fb8841942d42078b9443a
|
||||
README.md: 3e191b501e69062b671df0f237f2128a4ad086d1
|
||||
README.zh.md: 44ba3eba8bfc756a7d68e43a3d34056349f7eaaa
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages.
|
||||
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
|
||||
|
||||
`src/onboarding-copy.ts` is the single editable owner of the complete Chinese and English notice plus `WELCOME_NOTICE_VERSION`. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。
|
||||
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
|
||||
|
||||
`src/onboarding-copy.ts` 是完整中英文通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-general",
|
||||
"description": "Settings ownerless-copy plugin: the General section, shell trigger/header chrome content, and settings dictionaries",
|
||||
"description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -26,7 +26,8 @@
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
@@ -35,22 +36,30 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(640px, calc(100vw - 64px));
|
||||
max-height: 100vh;
|
||||
padding: clamp(64px, 9vh, 104px) 0 40px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
--welcome-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 42px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.opening,
|
||||
.status,
|
||||
.reflection,
|
||||
.feedback,
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.opening {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.reflection {
|
||||
margin-top: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.opening,
|
||||
.status,
|
||||
.reflection,
|
||||
.feedback {
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.feedback strong {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.primary {
|
||||
min-width: 120px;
|
||||
transition: transform 140ms var(--welcome-ease-out);
|
||||
}
|
||||
|
||||
.primary:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.brand,
|
||||
.title,
|
||||
.opening,
|
||||
.status,
|
||||
.reflection,
|
||||
.feedback,
|
||||
.footer {
|
||||
animation: welcome-enter 280ms var(--welcome-ease-out) both;
|
||||
}
|
||||
|
||||
.title { animation-delay: 40ms; }
|
||||
.opening { animation-delay: 80ms; }
|
||||
.status { animation-delay: 120ms; }
|
||||
.reflection { animation-delay: 160ms; }
|
||||
.feedback { animation-delay: 200ms; }
|
||||
.footer { animation-delay: 240ms; }
|
||||
|
||||
@keyframes welcome-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand,
|
||||
.title,
|
||||
.opening,
|
||||
.status,
|
||||
.reflection,
|
||||
.feedback,
|
||||
.footer {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.primary {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page {
|
||||
width: calc(100vw - 40px);
|
||||
padding-top: 38px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.opening {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.reflection {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Product-wide, versioned first-run welcome step. */
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import css from './WelcomeNotice.module.css'
|
||||
|
||||
function emphasizedFeedback(paragraph: string, emphasis: string): ReactNode {
|
||||
const index = paragraph.indexOf(emphasis)
|
||||
/* v8 ignore next -- both locale values derive from one owner object that contains the emphasis */
|
||||
if (index < 0) return paragraph
|
||||
return (
|
||||
<>
|
||||
{paragraph.slice(0, index)}
|
||||
<strong>{emphasis}</strong>
|
||||
{paragraph.slice(index + emphasis.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Registrant-owned dependencies of {@link WelcomeNotice}. */
|
||||
export interface WelcomeNoticeInjected {
|
||||
controller: WelcomeNoticeStore
|
||||
useSnapshot: SnapshotSelectorHook<WelcomeNoticeState>
|
||||
}
|
||||
|
||||
/** Coordinator owner props plus the welcome step's injected face. */
|
||||
export type WelcomeNoticeProps =
|
||||
PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected
|
||||
|
||||
/** Render the mandatory notice until its current version commits durably. */
|
||||
export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
|
||||
const { complete, controller, useSnapshot, t } = props
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
const finished = useRef(false)
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
||||
const finish = useCallback((): void => {
|
||||
if (finished.current) return
|
||||
finished.current = true
|
||||
complete()
|
||||
}, [complete])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'idle') void controller.load()
|
||||
}, [controller, state.status])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.acknowledged) finish()
|
||||
}, [finish, state.acknowledged])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'ready' && !state.acknowledged) titleRef.current?.focus()
|
||||
}, [state.acknowledged, state.status])
|
||||
|
||||
if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
|
||||
|
||||
const acknowledge = async (): Promise<void> => {
|
||||
if (await controller.acknowledge()) finish()
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={css.page} role="region" aria-labelledby="welcome-notice-title">
|
||||
<div className={css.brand} aria-hidden="true"><BrandWordmark size={24} /></div>
|
||||
<h2 ref={titleRef} id="welcome-notice-title" className={css.title} tabIndex={-1}>{t('welcome.title')}</h2>
|
||||
<p className={css.opening}>{t('welcome.paragraph.0')}</p>
|
||||
<p className={css.status}>{t('welcome.paragraph.1')}</p>
|
||||
<blockquote className={css.reflection}>{t('welcome.paragraph.2')}</blockquote>
|
||||
<p className={css.feedback}>
|
||||
{emphasizedFeedback(t('welcome.paragraph.3'), t('welcome.feedbackEmphasis'))}
|
||||
</p>
|
||||
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
|
||||
<div className={css.footer}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.primary}
|
||||
disabled={state.status === 'saving'}
|
||||
onClick={() => { void acknowledge() }}
|
||||
>
|
||||
{t('welcome.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -7,12 +7,18 @@
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
|
||||
import { WelcomeNotice } from './WelcomeNotice.tsx'
|
||||
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
|
||||
import { en, zh, type SettingsKey } from './locales.ts'
|
||||
|
||||
export type {
|
||||
@@ -21,6 +27,8 @@ export type {
|
||||
export type {
|
||||
GeneralSectionComponentProps,
|
||||
} from './GeneralSection.tsx'
|
||||
export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx'
|
||||
export type { WelcomeNoticeState } from './welcome-store.ts'
|
||||
export type { SettingsKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -38,7 +46,7 @@ const NS = 'settings'
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registration goes through declaration-aware deferral.
|
||||
*/
|
||||
export const inject = ['slots', 'locale']
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
|
||||
/**
|
||||
* Register the `settings` dictionaries, the chrome content, and the General
|
||||
@@ -52,6 +60,25 @@ export function apply(ctx: ClientContext): void {
|
||||
// seat, and the nav label is a thunk the owner resolves per render — no
|
||||
// locale/change re-registration wiring.
|
||||
const t = ctx.locale.bind(NS)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const welcomeController = new WelcomeNoticeStore(connection.api)
|
||||
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
|
||||
const welcomeInjected = (): WelcomeNoticeInjected => ({
|
||||
controller: welcomeController,
|
||||
useSnapshot: useWelcomeSnapshot,
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
|
||||
refreshWelcomeIfLoaded(welcomeController)
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings-general: welcome invalidations')
|
||||
ctx.effect(() => {
|
||||
const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
|
||||
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
|
||||
@@ -68,11 +95,20 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: NS,
|
||||
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
||||
}, GeneralSection))
|
||||
const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
id: 'welcome-notice',
|
||||
order: -100,
|
||||
locale: NS,
|
||||
inject: welcomeInjected,
|
||||
}, WelcomeNotice))
|
||||
return () => {
|
||||
trigger.dispose()
|
||||
header.dispose()
|
||||
close.dispose()
|
||||
general.dispose()
|
||||
welcome.dispose()
|
||||
}
|
||||
}, 'ui-settings-general: chrome and section registrations')
|
||||
}, 'ui-settings-general: chrome, section, and onboarding registrations')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Shell chrome and General-nav dictionaries; feature rows own their copy. */
|
||||
/** Shell chrome, General-nav, and welcome-notice dictionaries; feature rows own their copy. */
|
||||
import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
@@ -6,6 +7,14 @@ export const zh = {
|
||||
'title': '设置',
|
||||
'close': '关闭',
|
||||
'general.nav': '通用设置',
|
||||
'welcome.title': WELCOME_NOTICE_COPY.zh.title,
|
||||
'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0],
|
||||
'welcome.paragraph.1': WELCOME_NOTICE_COPY.zh.paragraphs[1],
|
||||
'welcome.paragraph.2': WELCOME_NOTICE_COPY.zh.paragraphs[2],
|
||||
'welcome.paragraph.3': WELCOME_NOTICE_COPY.zh.paragraphs[3],
|
||||
'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.zh.feedbackEmphasis,
|
||||
'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel,
|
||||
'welcome.error': '暂时无法保存确认状态,请重试。',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The settings namespace key union. */
|
||||
@@ -17,4 +26,12 @@ export const en = {
|
||||
'title': 'Settings',
|
||||
'close': 'Close',
|
||||
'general.nav': 'General',
|
||||
'welcome.title': WELCOME_NOTICE_COPY.en.title,
|
||||
'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0],
|
||||
'welcome.paragraph.1': WELCOME_NOTICE_COPY.en.paragraphs[1],
|
||||
'welcome.paragraph.2': WELCOME_NOTICE_COPY.en.paragraphs[2],
|
||||
'welcome.paragraph.3': WELCOME_NOTICE_COPY.en.paragraphs[3],
|
||||
'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.en.feedbackEmphasis,
|
||||
'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel,
|
||||
'welcome.error': 'The acknowledgement could not be saved. Please try again.',
|
||||
} satisfies Record<SettingsKey, string>
|
||||
|
||||
108
packages/client/ui-settings-general/src/client/welcome-store.ts
Normal file
108
packages/client/ui-settings-general/src/client/welcome-store.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/** Durable welcome-notice state over the Host settings document. */
|
||||
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../onboarding-copy.ts'
|
||||
|
||||
/** State rendered by the welcome step. */
|
||||
export interface WelcomeNoticeState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'saving' | 'error'
|
||||
acknowledged: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
|
||||
if (typeof view.value !== 'object' || view.value === null) return undefined
|
||||
const value = (view.value as Record<string, unknown>)[WELCOME_NOTICE_ACK_FIELD]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/** Coordinates welcome acknowledgement reads and the sole durable write. */
|
||||
export class WelcomeNoticeStore {
|
||||
/** uSES-safe state source shared by the registered welcome step. */
|
||||
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
|
||||
status: 'idle', acknowledged: false, error: null,
|
||||
})
|
||||
|
||||
private generation = 0
|
||||
|
||||
/** @param api - settings wire face used for durable reads and writes. */
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
|
||||
|
||||
/** Load the current acknowledgement from the Host settings document. */
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
this.store.update((state) => { state.status = 'loading'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.describe({})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
const view = response.result.value.namespaces.find(
|
||||
candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
)
|
||||
if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable')
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION
|
||||
state.error = null
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist this copy version. The path mutation is idempotent across tabs and
|
||||
* preserves every sibling setting; failure leaves the step unacknowledged.
|
||||
* @returns true only when the Host committed the acknowledgement.
|
||||
*/
|
||||
async acknowledge(): Promise<boolean> {
|
||||
const generation = ++this.generation
|
||||
this.store.update((state) => { state.status = 'saving'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.mutate({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
if (generation === this.generation) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = true
|
||||
state.error = null
|
||||
})
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (generation === this.generation) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh only after the welcome step has begun reading durable state.
|
||||
* @param controller - welcome state owner whose current status decides whether to load.
|
||||
*/
|
||||
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
|
||||
if (controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
}
|
||||
@@ -1,4 +1,31 @@
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the general settings plugin. */
|
||||
export function apply(): void {}
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
} from './onboarding-copy.ts'
|
||||
|
||||
export {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
WELCOME_NOTICE_VERSION,
|
||||
} from './onboarding-copy.ts'
|
||||
|
||||
interface OnboardingSettings {
|
||||
welcomeNoticeVersion?: string
|
||||
}
|
||||
|
||||
const OnboardingSettingsSchema: z<OnboardingSettings> = z.object({
|
||||
[WELCOME_NOTICE_ACK_FIELD]: z.string(),
|
||||
})
|
||||
|
||||
/** Register the durable GUI-onboarding section when a settings provider exists. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.register(
|
||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
||||
OnboardingSettingsSchema,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,10 +15,9 @@ export const name = 'client-ui-settings-general-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a copy-owning registrant contributing chrome content
|
||||
* and the General section into shell-declared slots — it emits no cordis
|
||||
* events and owns no cross-plugin mutable relation; slot conflicts already
|
||||
* fail loud in the slot core at load time.
|
||||
* No runtime invariant: the settings seam validates and publishes the durable
|
||||
* welcome section, while slot conflicts fail loud in the slot core; this
|
||||
* package owns no additional event/data relationship between those systems.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
37
packages/client/ui-settings-general/src/onboarding-copy.ts
Normal file
37
packages/client/ui-settings-general/src/onboarding-copy.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** Durable settings namespace for product-wide GUI onboarding facts. */
|
||||
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
|
||||
|
||||
/** Field storing the last welcome notice version the user acknowledged. */
|
||||
export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
|
||||
|
||||
/**
|
||||
* Bump only when the notice changes materially and every user should see it
|
||||
* again. The acknowledgement is compared for exact equality.
|
||||
*/
|
||||
export const WELCOME_NOTICE_VERSION = '2026-07-30.5'
|
||||
|
||||
/** The complete editable welcome notice in both supported GUI locales. */
|
||||
export const WELCOME_NOTICE_COPY = {
|
||||
zh: {
|
||||
title: '内测声明',
|
||||
paragraphs: [
|
||||
'感谢您愿意拨冗试用 DeepSeek Harness。',
|
||||
'目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
|
||||
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
|
||||
'我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
|
||||
],
|
||||
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
|
||||
continueLabel: '继续',
|
||||
},
|
||||
en: {
|
||||
title: 'Internal Testing Notice',
|
||||
paragraphs: [
|
||||
'Thank you for taking the time to try DeepSeek Harness.',
|
||||
'This version is still in internal testing. Its functionality still needs improvement, and the experience may feel a little rough.',
|
||||
'“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you discover in real use may prompt us to reconsider—or even overturn—our existing designs.',
|
||||
'We especially want to hear about failures, confusion, and friction. If you have any feedback or suggestions, please leave us a message in the company WeChat group. Every piece of feedback helps us refine it.',
|
||||
],
|
||||
feedbackEmphasis: 'If you have any feedback or suggestions, please leave us a message in the company WeChat group',
|
||||
continueLabel: 'Continue',
|
||||
},
|
||||
} as const
|
||||
@@ -7,13 +7,17 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
|
||||
|
||||
/** The four seats this plugin fills (slot name → expected component). */
|
||||
/** The five seats this plugin fills (slot name → expected component). */
|
||||
const SEATS = [
|
||||
['settings.trigger', TriggerContent],
|
||||
['settings.header', HeaderContent],
|
||||
['settings.close', CloseLabel],
|
||||
['settings.section', GeneralSection],
|
||||
['settings.onboarding', WelcomeNotice],
|
||||
] as const
|
||||
|
||||
async function bench() {
|
||||
@@ -21,7 +25,25 @@ async function bench() {
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
|
||||
const settingsDescribe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'settings-general' as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: {
|
||||
writable: true,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: {},
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
},
|
||||
},
|
||||
}))
|
||||
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
|
||||
}
|
||||
|
||||
/** Declare the shell's four child slots the way ui-settings' entry does. */
|
||||
@@ -34,6 +56,7 @@ function declare(slots: SlotsService): () => void {
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
} as never,
|
||||
() => null,
|
||||
@@ -46,10 +69,10 @@ function generalEntry(slots: SlotsService) {
|
||||
|
||||
describe('ui-settings-general apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
})
|
||||
|
||||
it('fills all four seats for declarations before or after apply', async () => {
|
||||
it('fills all five seats for declarations before or after apply', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots)
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -62,6 +85,8 @@ describe('ui-settings-general apply', () => {
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
|
||||
expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
|
||||
expect(before.slots.entries('settings.general.item')).toEqual([])
|
||||
const welcome = before.slots.entries('settings.onboarding')[0]!
|
||||
expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 })
|
||||
// Copy rides the standard locale seat: every seat declares the namespace.
|
||||
for (const [name] of SEATS) {
|
||||
expect(before.slots.entries(name)[0]!.locale).toBe('settings')
|
||||
@@ -113,6 +138,22 @@ describe('ui-settings-general apply', () => {
|
||||
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
|
||||
})
|
||||
|
||||
it('refreshes loaded welcome state only for its settings namespace or a reconnect', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.onboarding')[0]!
|
||||
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
|
||||
await controller.load()
|
||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('settings/changed', 'unrelated')
|
||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE)
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
|
||||
b.ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
|
||||
})
|
||||
|
||||
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
|
||||
const b = await bench()
|
||||
const redeclare = declare(b.slots)
|
||||
|
||||
29
packages/client/ui-settings-general/tests/host.spec.ts
Normal file
29
packages/client/ui-settings-general/tests/host.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { apply } from '../src/index.ts'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
|
||||
|
||||
class MemorySettings extends Settings {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-settings-general host', () => {
|
||||
it('registers and disposes the durable onboarding namespace with its fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).toContain(
|
||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
||||
)
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(
|
||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -9,10 +9,4 @@ describe('invariant companion', () => {
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', async () => {
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general')
|
||||
apply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeProps } from '../src/client/WelcomeNotice.tsx'
|
||||
import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
WELCOME_NOTICE_VERSION,
|
||||
} from '../src/onboarding-copy.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function response<T>(value: T) {
|
||||
return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } }
|
||||
}
|
||||
|
||||
function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Promise.resolve(response({}))) {
|
||||
const mutate = vi.fn(mutateImpl)
|
||||
const api = {
|
||||
settings: {
|
||||
describe: () => Promise.resolve(response({
|
||||
writable: true,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
})),
|
||||
mutate,
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
const complete = vi.fn()
|
||||
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
|
||||
const props: WelcomeNoticeProps = {
|
||||
stepId: 'welcome-notice',
|
||||
complete,
|
||||
openSection: vi.fn(),
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
t: key => key in zh ? zh[key as keyof typeof zh] : key,
|
||||
}
|
||||
return { ...render(<WelcomeNotice {...props} />), complete, controller, mutate }
|
||||
}
|
||||
|
||||
describe('WelcomeNotice', () => {
|
||||
it('renders the owner copy with one primary action and no dismissal control', async () => {
|
||||
const h = mount()
|
||||
const page = await screen.findByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
||||
expect(screen.getByText(WELCOME_NOTICE_COPY.zh.title)).toBeTruthy()
|
||||
for (const text of WELCOME_NOTICE_COPY.zh.paragraphs) expect(page.textContent).toContain(text)
|
||||
expect(page.textContent?.match(/感谢您愿意拨冗试用 DeepSeek Harness/g) ?? []).toHaveLength(1)
|
||||
const buttons = page.querySelectorAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })).toBeTruthy()
|
||||
expect(document.activeElement).toBe(screen.getByRole('heading', { name: WELCOME_NOTICE_COPY.zh.title }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('region')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('completes only after the acknowledgement write commits', async () => {
|
||||
const h = mount()
|
||||
await screen.findByRole('region')
|
||||
fireEvent.click(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(h.mutate).toHaveBeenCalledOnce()
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('skips itself when this exact version was already acknowledged', async () => {
|
||||
const h = mount(WELCOME_NOTICE_VERSION)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the sole action disabled while saving and reports a refused write', async () => {
|
||||
let resolveWrite!: (value: unknown) => void
|
||||
const write = new Promise<unknown>((resolve) => { resolveWrite = resolve })
|
||||
const h = mount(undefined, () => write)
|
||||
await screen.findByRole('region')
|
||||
const action = screen.getByRole<HTMLButtonElement>('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })
|
||||
fireEvent.click(action)
|
||||
expect(action.disabled).toBe(true)
|
||||
resolveWrite({
|
||||
rpcId: 'welcome-refused' as never,
|
||||
result: { ok: false, error: { code: 'settings-rejected', message: 'read only', details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE } } },
|
||||
})
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('暂时无法保存确认状态,请重试。')
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
166
packages/client/ui-settings-general/tests/welcome-store.spec.ts
Normal file
166
packages/client/ui-settings-general/tests/welcome-store.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import { refreshWelcomeIfLoaded } from '../src/client/welcome-store.ts'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../src/onboarding-copy.ts'
|
||||
|
||||
let rpc = 0
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function namespace(version?: string) {
|
||||
return {
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('WelcomeNoticeStore', () => {
|
||||
it('acknowledges only the exact current copy version', async () => {
|
||||
for (const [version, acknowledged] of [
|
||||
[undefined, false],
|
||||
['older-copy', false],
|
||||
[WELCOME_NOTICE_VERSION, true],
|
||||
] as const) {
|
||||
const api = {
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(version)] }))),
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged })
|
||||
}
|
||||
})
|
||||
|
||||
it('persists the owner version through one idempotent path mutation', async () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION))))
|
||||
const controller = new WelcomeNoticeStore({ settings: { mutate } } as never)
|
||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
})
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
})
|
||||
|
||||
it('keeps the notice pending when loading or persistence fails', async () => {
|
||||
const load = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.reject(new Error('offline')) },
|
||||
} as never)
|
||||
await load.load()
|
||||
expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' })
|
||||
|
||||
const save = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => Promise.reject(new Error('disk full')) },
|
||||
} as never)
|
||||
await expect(save.acknowledge()).resolves.toBe(false)
|
||||
expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' })
|
||||
|
||||
const nonError = new WelcomeNoticeStore({
|
||||
// Durable/wire failures are unknown; exercise containment of a non-Error rejection.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
settings: { describe: () => Promise.reject('offline string') },
|
||||
} as never)
|
||||
await nonError.load()
|
||||
expect(nonError.store.getSnapshot().error).toBe('offline string')
|
||||
})
|
||||
|
||||
it('reports business failures, missing namespaces, and malformed durable values', async () => {
|
||||
for (const describe of [
|
||||
() => Promise.resolve({
|
||||
rpcId: 'failed' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
|
||||
}),
|
||||
() => Promise.resolve(ok({ writable: true, namespaces: [] })),
|
||||
]) {
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot().status).toBe('error')
|
||||
}
|
||||
|
||||
for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) {
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
namespaces: [{ ...namespace(), value }],
|
||||
})) },
|
||||
} as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
|
||||
}
|
||||
|
||||
const save = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => Promise.resolve({
|
||||
rpcId: 'failed-save' as never,
|
||||
result: { ok: false, error: { code: 'settings-rejected', message: 'denied', details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE } } },
|
||||
}) },
|
||||
} as never)
|
||||
await expect(save.acknowledge()).resolves.toBe(false)
|
||||
expect(save.store.getSnapshot().error).toBe('denied')
|
||||
})
|
||||
|
||||
it('lets the latest load win over stale success and failure', async () => {
|
||||
const first = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
const stale = controller.load()
|
||||
await controller.load()
|
||||
first.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
|
||||
await stale
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
|
||||
const failed = deferred<ReturnType<typeof ok>>()
|
||||
describe
|
||||
.mockImplementationOnce(() => failed.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] })))
|
||||
const staleFailure = controller.load()
|
||||
await controller.load()
|
||||
failed.reject('stale failure')
|
||||
await staleFailure
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null })
|
||||
})
|
||||
|
||||
it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
|
||||
const write = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => write.promise, describe },
|
||||
} as never)
|
||||
refreshWelcomeIfLoaded(controller)
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
const staleWrite = controller.acknowledge()
|
||||
await controller.load()
|
||||
write.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))
|
||||
await expect(staleWrite).resolves.toBe(true)
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
refreshWelcomeIfLoaded(controller)
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) })
|
||||
|
||||
const failedWrite = deferred<ReturnType<typeof ok>>()
|
||||
const staleFailure = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => failedWrite.promise, describe },
|
||||
} as never)
|
||||
const pending = staleFailure.acknowledge()
|
||||
await staleFailure.load()
|
||||
failedWrite.reject('late failure')
|
||||
await expect(pending).resolves.toBe(false)
|
||||
expect(staleFailure.store.getSnapshot().status).toBe('ready')
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
@@ -23,9 +26,15 @@
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -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-settings/README.md
|
||||
README.md: eaa588489bbb6369a9fe073f0a9a37efb3af7d9f
|
||||
README.zh.md: 4330ea90e487270c8da31dace3d531d94f61b8de
|
||||
README.md: 14c78c83467313a6efa7033c31fb9c9b1cd94e0c
|
||||
README.zh.md: 8831842d03db4572f1dca5547b84c0d8a3be8f2d
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (feature-owned overlays on the empty Hero). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections, rows, and onboarding overlays). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
|
||||
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
|
||||
|
||||
The shell supplies onboarding registrants only two navigation facts: whether the session surface is the empty Hero and an `openSection(id)` callback that opens the panel on a registered section. Registrants own capability readiness, dismissal, copy, and mutations; the shell therefore does not become a second configuration fact source.
|
||||
The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、覆盖在空白 Hero 之上的浮层)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架和「通用」分区;各功能拥有各自的分区、行和首次使用浮层)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
|
||||
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
|
||||
|
||||
外壳只向首次使用注册方提供两个导航事实:当前会话界面是否为空白 Hero,以及一个 `openSection(id)` 回调;后者会打开设置面板并切换到已注册的指定分区。能力就绪状态、浮层关闭、文案和变更操作均由注册方持有,因此外壳不会成为第二个配置事实来源。
|
||||
外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings",
|
||||
"description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and root-scoped onboarding overlays",
|
||||
"description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and an ordered full-page onboarding stage",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -43,7 +43,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
@@ -52,9 +53,11 @@
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -209,3 +209,33 @@
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* First-run stage: keep the product top bar visible, then let onboarding own
|
||||
the complete workspace instead of presenting another settings modal. */
|
||||
.onboardingOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
/* Mask */
|
||||
.onboardingMask {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
top: 80px;
|
||||
bottom: 0px;
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
/* Mask-blur */
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.onboardingStage {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
* names resolve to that content (trigger: its own text; dialog:
|
||||
* aria-labelledby the title node; close: visually-hidden slot text). Modal
|
||||
* open state and the active section id are component-local viewing state;
|
||||
* the onboarding slot receives the sessions-derived empty-Hero fact and a
|
||||
* private callback that opens one registered section.
|
||||
* the onboarding coordinator mounts exactly one ordered registrant while the
|
||||
* sessions-derived empty-Hero fact is active.
|
||||
*/
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
|
||||
@@ -95,9 +96,10 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
|
||||
* @returns the settings shell element tree.
|
||||
*/
|
||||
export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
const { wide, useSections, useSessions, renderSlot } = props
|
||||
const { wide, useSections, useOnboardingSteps, useSessions, renderSlot } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
||||
const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const close = useCallback(() => {
|
||||
setOpen(false)
|
||||
setActiveId(undefined)
|
||||
@@ -111,9 +113,33 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
// freshly localized text on locale change, and the trigger/header/close
|
||||
// seats re-render through their own outlets' subscriptions.
|
||||
const rows = useSections(s => s)
|
||||
const onboardingSteps = useOnboardingSteps(s => s)
|
||||
const onboardingActive = useSessions(state =>
|
||||
state.phase === 'ready'
|
||||
&& (state.current === undefined || state.byId[state.current]?.blank === true))
|
||||
const onboardingStep = onboardingActive
|
||||
? onboardingSteps.find(step => !completedOnboarding.has(step.id))
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingActive) return
|
||||
setCompletedOnboarding(new Set())
|
||||
}, [onboardingActive])
|
||||
|
||||
const completeOnboardingStep = useCallback((id: string) => {
|
||||
setCompletedOnboarding((previous) => {
|
||||
if (previous.has(id)) return previous
|
||||
return new Set([...previous, id])
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingStep === undefined) return
|
||||
const appRoot = document.getElementById('root')
|
||||
if (appRoot === null) return
|
||||
appRoot.inert = true
|
||||
return () => { appRoot.inert = false }
|
||||
}, [onboardingStep])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -135,7 +161,18 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{renderSlot('settings.onboarding', { active: onboardingActive, openSection })}
|
||||
{onboardingStep !== undefined && createPortal((
|
||||
<div className={css.onboardingOverlay} role="presentation">
|
||||
<div className={css.onboardingMask} aria-hidden="true" />
|
||||
<div className={css.onboardingStage}>
|
||||
{renderSlot('settings.onboarding', {
|
||||
stepId: onboardingStep.id,
|
||||
complete: () => { completeOnboardingStep(onboardingStep.id) },
|
||||
openSection,
|
||||
}, { only: onboardingStep.id })}
|
||||
</div>
|
||||
</div>
|
||||
), document.body)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,10 +48,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
*/
|
||||
'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps }
|
||||
/**
|
||||
* Root-scoped onboarding overlays contributed by settings features. The
|
||||
* shell supplies whether the current navigation state is the empty Hero
|
||||
* and a private callback that opens one settings section; registrants own
|
||||
* readiness, copy, and dialog behavior.
|
||||
* Root-scoped onboarding steps contributed by settings features. The
|
||||
* shell mounts one ordered step at a time; the active registrant either
|
||||
* completes itself or keeps ownership until the user completes its sole
|
||||
* path. Registrants own readiness, copy, and dialog behavior.
|
||||
*/
|
||||
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
|
||||
}
|
||||
@@ -79,10 +79,12 @@ export interface SettingsSectionOwnerProps {
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** Owner share of a settings-backed onboarding overlay. */
|
||||
/** Owner share of the currently active settings-backed onboarding step. */
|
||||
export interface SettingsOnboardingOwnerProps {
|
||||
/** Whether the current UI is in its empty Hero/onboarding state. */
|
||||
active: boolean
|
||||
/** Stable id of the step currently selected by the coordinator. */
|
||||
stepId: string
|
||||
/** Complete or skip this step and transfer ownership to the next entry. */
|
||||
complete: () => void
|
||||
/** Open the settings panel directly on one registered section. */
|
||||
openSection: (id: string) => void
|
||||
}
|
||||
@@ -94,6 +96,12 @@ export interface SettingsSectionRow {
|
||||
label: string
|
||||
}
|
||||
|
||||
/** One ordered onboarding step projected from a slot registration. */
|
||||
export interface SettingsOnboardingStep {
|
||||
id: string
|
||||
order: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrant-private injected share of the settings shell (assembled in
|
||||
* apply): the ledger's nav-row projection as a hooks-compartment source —
|
||||
@@ -103,6 +111,8 @@ export type SettingsRootInjected = {
|
||||
hooks: {
|
||||
/** settings.section ledger projected into ordered nav rows. */
|
||||
sections: HostObservable<readonly SettingsSectionRow[]>
|
||||
/** settings.onboarding ledger projected into coordinator order. */
|
||||
onboardingSteps: HostObservable<readonly SettingsOnboardingStep[]>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* Settings shell plugin, browser half. A pure composition face: occupies the
|
||||
* sidebar-owned `sidebar.settings` hole with the trigger chrome + modal
|
||||
* panel, declares its chrome, section, and onboarding slots, and projects the
|
||||
* section ledger into panel navigation. The shell ships no copy and reads no
|
||||
* locale state — all text arrives from registrants (ui-settings-general owns
|
||||
* the chrome and General content; features own their rows, sections, and
|
||||
* onboarding overlays). Export discipline: packages/client/AGENTS.md.
|
||||
* section ledger into panel navigation. The shell ships no copy; it reads the
|
||||
* optional locale revision only to resolve registrant-owned nav-label thunks.
|
||||
* ui-settings-general owns the chrome and General content; features own their
|
||||
* rows, sections, and onboarding pages. Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the ctx.locale Context merge for the optional ctx.get('locale')
|
||||
@@ -13,12 +13,15 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// copy of its own and takes no hard locale dependency).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { deferRegistration, resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
|
||||
import type {
|
||||
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
|
||||
} from './contract/slots.ts'
|
||||
import { SettingsRoot } from './SettingsRoot.tsx'
|
||||
|
||||
export type {
|
||||
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
|
||||
SettingsOnboardingOwnerProps, SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
|
||||
SettingsOnboardingOwnerProps, SettingsOnboardingStep, SettingsSectionOwnerProps,
|
||||
SettingsSectionRow, SettingsTriggerOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
@@ -42,6 +45,8 @@ export function apply(ctx: ClientContext): void {
|
||||
let rowsVersion = -1
|
||||
let rowsRevision = -1
|
||||
let rows: readonly SettingsSectionRow[] = []
|
||||
let onboardingVersion = -1
|
||||
let onboardingSteps: readonly SettingsOnboardingStep[] = []
|
||||
const localeRevision = (): number => ctx.get('locale')?.getSnapshot().revision ?? 0
|
||||
const injected = (): SettingsRootInjected => ({
|
||||
hooks: {
|
||||
@@ -72,6 +77,23 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
},
|
||||
},
|
||||
onboardingSteps: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.onboarding')
|
||||
if (version !== onboardingVersion) {
|
||||
onboardingVersion = version
|
||||
onboardingSteps = ctx.slots.entries('settings.onboarding')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return onboardingSteps
|
||||
},
|
||||
subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener),
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx.effect(() => {
|
||||
|
||||
@@ -83,6 +83,29 @@ describe('ui-settings apply', () => {
|
||||
off()
|
||||
})
|
||||
|
||||
it('projects onboarding entries into stable coordinator order', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const { onboardingSteps } = injectedOf(b.slots).hooks
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
|
||||
const steps = onboardingSteps.getSnapshot()
|
||||
expect(steps).toEqual([
|
||||
{ id: 'welcome', order: -100 },
|
||||
{ id: 'credential', order: 0 },
|
||||
{ id: 'default-order', order: 0 },
|
||||
])
|
||||
expect(onboardingSteps.getSnapshot()).toBe(steps)
|
||||
const listener = vi.fn()
|
||||
const off = onboardingSteps.subscribe(listener)
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
|
||||
await Promise.resolve()
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
off()
|
||||
})
|
||||
|
||||
it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => {
|
||||
const b = await bench()
|
||||
const redeclare = declare(b.slots)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
||||
afterEach(cleanup)
|
||||
|
||||
type Row = { id: string; order: number; label: string }
|
||||
type Step = { id: string; order: number }
|
||||
|
||||
/** Slot-content stand-ins: the shell renders whatever the seats contribute. */
|
||||
const SEAT_CONTENT: Record<string, string> = {
|
||||
@@ -23,7 +24,11 @@ function mount({
|
||||
{ id: 'general', order: 0, label: 'General' },
|
||||
{ id: 'models', order: 10, label: 'Models' },
|
||||
],
|
||||
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[] } = {}) {
|
||||
steps = [
|
||||
{ id: 'welcome', order: -100 },
|
||||
{ id: 'credential', order: 0 },
|
||||
],
|
||||
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) {
|
||||
// Mutable row source standing in for the bound useSections hook; bump()
|
||||
// plays a ledger change through the same observable contract.
|
||||
let current = rows
|
||||
@@ -46,6 +51,7 @@ function mount({
|
||||
useSessions,
|
||||
useWorkspaces: unusedHook,
|
||||
wide,
|
||||
useOnboardingSteps: select => select(steps),
|
||||
useSections: (select) => {
|
||||
const [, force] = useState(0)
|
||||
useEffect(() => {
|
||||
@@ -164,20 +170,41 @@ describe('SettingsPanel navigation', () => {
|
||||
expect(screen.queryByTestId('section-general')).toBeNull()
|
||||
})
|
||||
|
||||
it('hands Hero readiness and a direct section opener to onboarding registrants', () => {
|
||||
it('mounts onboarding steps in order and transfers ownership only on completion', () => {
|
||||
const { renderSlot } = mount()
|
||||
const onboardingCall = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
|
||||
expect(onboardingCall?.[1]).toMatchObject({ active: true })
|
||||
const first = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
|
||||
expect(first?.[1]).toMatchObject({ stepId: 'welcome' })
|
||||
expect(first?.[2]).toEqual({ only: 'welcome' })
|
||||
act(() => {
|
||||
(onboardingCall?.[1] as { openSection: (id: string) => void }).openSection('models')
|
||||
(first?.[1] as { complete: () => void }).complete()
|
||||
;(first?.[1] as { complete: () => void }).complete()
|
||||
})
|
||||
const onboardingCalls = renderSlot.mock.calls.filter(call => call[0] === 'settings.onboarding')
|
||||
const second = onboardingCalls.at(-1)
|
||||
expect(second?.[1]).toMatchObject({ stepId: 'credential' })
|
||||
expect(second?.[2]).toEqual({ only: 'credential' })
|
||||
|
||||
act(() => {
|
||||
(second?.[1] as { openSection: (id: string) => void }).openSection('models')
|
||||
})
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
expect(screen.getByTestId('section-models')).toBeTruthy()
|
||||
|
||||
cleanup()
|
||||
const active = mount({ onboardingActive: false }).renderSlot.mock.calls
|
||||
.find(call => call[0] === 'settings.onboarding')
|
||||
expect(active?.[1]).toMatchObject({ active: false })
|
||||
const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls
|
||||
.filter(call => call[0] === 'settings.onboarding')
|
||||
expect(inactive).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('makes the underlying application inert while onboarding owns the viewport', () => {
|
||||
const appRoot = document.createElement('div')
|
||||
appRoot.id = 'root'
|
||||
document.body.append(appRoot)
|
||||
const { view } = mount()
|
||||
expect(appRoot.inert).toBe(true)
|
||||
view.unmount()
|
||||
expect(appRoot.inert).toBe(false)
|
||||
appRoot.remove()
|
||||
})
|
||||
|
||||
it('falls back to the first row when the active entry unregisters', () => {
|
||||
|
||||
@@ -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/compact/compact-basic/README.md
|
||||
README.md: 775355f1ac1a7c79c16f66a5b2489d73df7b960d
|
||||
README.zh.md: bfa139596b5ef61c23d29575bdea5534fa82b158
|
||||
README.md: b35e5dc110e908047338054337b309a77b7e0f68
|
||||
README.zh.md: 9c5f83d987b584d58ffacadce4e469bb6ba81aa2
|
||||
|
||||
@@ -136,7 +136,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
|
||||
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
|
||||
|
||||
Rules:
|
||||
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
|
||||
- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.
|
||||
- Capture user feedback and explicit instructions faithfully, especially corrections.
|
||||
- Do NOT mention this summarization request or that the context was compacted.
|
||||
- Output only the checkpoint text: do not call any tool or take any other action.
|
||||
|
||||
@@ -136,7 +136,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
|
||||
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
|
||||
|
||||
Rules:
|
||||
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
|
||||
- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.
|
||||
- Capture user feedback and explicit instructions faithfully, especially corrections.
|
||||
- Do NOT mention this summarization request or that the context was compacted.
|
||||
- Output only the checkpoint text: do not call any tool or take any other action.
|
||||
|
||||
@@ -58,7 +58,7 @@ const COMPACTION_INSTRUCTION = [
|
||||
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
|
||||
'',
|
||||
'Rules:',
|
||||
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
|
||||
'- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.',
|
||||
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
|
||||
'- Do NOT mention this summarization request or that the context was compacted.',
|
||||
'- Output only the checkpoint text: do not call any tool or take any other action.',
|
||||
|
||||
@@ -1224,7 +1224,8 @@ describe('default one-shot summarizer', () => {
|
||||
expect(messages[0]).toEqual(prefix)
|
||||
const last = messages.at(-1)?.content[0]
|
||||
const lastText = last?.type === 'text' ? last.text : ''
|
||||
expect(lastText).toContain('Condense the conversation ABOVE')
|
||||
expect(lastText).toContain('Write concise English engineering prose.')
|
||||
expect(lastText).toContain('numeric values, function signatures, and syntax fragments.')
|
||||
expect(lastText).toContain('## Primary Request and Intent')
|
||||
})
|
||||
|
||||
|
||||
@@ -342,6 +342,11 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
|
||||
expect(adapter.conversationRequests).toHaveLength(2)
|
||||
expect(adapter.summaryRequests).toHaveLength(1)
|
||||
const instruction = adapter.summaryRequests[0]!.messages.at(-1)?.content
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('') ?? ''
|
||||
expect(instruction).toContain('Write concise English engineering prose.')
|
||||
expect(instruction).toContain('numeric values, function signatures, and syntax fragments.')
|
||||
expect(JSON.stringify(adapter.conversationRequests[0]!.messages)).toContain('OLD HISTORY SENTINEL')
|
||||
const retry = JSON.stringify(adapter.conversationRequests[1]!.messages)
|
||||
expect(retry).toContain('RECOVERY CHECKPOINT')
|
||||
|
||||
@@ -2287,6 +2287,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ScopeKey',
|
||||
declaration: 'export type ScopeKey = object;',
|
||||
},
|
||||
{
|
||||
name: 'SearchFileMatches',
|
||||
declaration: 'export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'SearchLineMatch',
|
||||
declaration: 'export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SearchMatchesResultView',
|
||||
declaration: 'export interface SearchMatchesResultView {\n card: \'search\';\n shape: \'matches\';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SearchPathsResultView',
|
||||
declaration: 'export interface SearchPathsResultView {\n card: \'search\';\n shape: \'paths\';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SearchResultView',
|
||||
declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;',
|
||||
},
|
||||
{
|
||||
name: 'SendOptions',
|
||||
declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}',
|
||||
@@ -2857,7 +2877,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ToolResultView',
|
||||
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView;',
|
||||
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;',
|
||||
},
|
||||
{
|
||||
name: 'ToolRunContext',
|
||||
|
||||
@@ -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: dcce455f9551318f3871e3df84c29789078fef7c
|
||||
README.zh.md: 63eaa2e0b66797c74a1d4845c29ca970b63f5f99
|
||||
README.md: 15fc5839a3b0e3fa2d20c5a9cc50577e9807ffda
|
||||
README.zh.md: 8547ee4a796dcd93945dfa40373c14c10d7d0c8a
|
||||
|
||||
@@ -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? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), 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).
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search — grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob), with `truncated`/`total` so a UI never presents a capped result as complete; the view carries no result text and a search has no `card: 'search'` call-time analogue), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), 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: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
|
||||
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索——`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表,配 `truncated`/`total` 使 UI 永不把被截断的结果当作完整结果呈现;该视图不携带结果文本,且搜索没有 `card: 'search'` 的调用时对应视图)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)或 `{ 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) 规定卡片词汇。
|
||||
|
||||
|
||||
@@ -83,6 +83,11 @@ export type {
|
||||
GenericResultView,
|
||||
TerminalResultView,
|
||||
DiffResultView,
|
||||
SearchResultView,
|
||||
SearchMatchesResultView,
|
||||
SearchPathsResultView,
|
||||
SearchFileMatches,
|
||||
SearchLineMatch,
|
||||
ReadResultView,
|
||||
WebResultView,
|
||||
WebSearchResultView,
|
||||
|
||||
@@ -137,7 +137,7 @@ export interface ReadFileLine {
|
||||
* `ToolDefinition.presentResult`; omitting the method keeps the pending
|
||||
* title and renders the raw result content.
|
||||
*/
|
||||
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView
|
||||
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView
|
||||
|
||||
/**
|
||||
* The default completed card: an optional replacement title and reformatted
|
||||
@@ -189,6 +189,83 @@ export interface DiffResultView {
|
||||
diffs: FileDiff[]
|
||||
}
|
||||
|
||||
/** One matched line inside a {@link SearchFileMatches} group: its 1-based line number and text. */
|
||||
export interface SearchLineMatch {
|
||||
/** 1-based line number of the match within its file. */
|
||||
lineNumber: number
|
||||
/** The matched line text, as the tool surfaced it (the per-line preview budget already applied). */
|
||||
line: string
|
||||
}
|
||||
|
||||
/** One file's grouped content matches for a {@link SearchMatchesResultView}, in first-seen file order. */
|
||||
export interface SearchFileMatches {
|
||||
/** The file the matches belong to (the model-facing display path). */
|
||||
path: string
|
||||
/** The file's matched lines, in output order. */
|
||||
matches: SearchLineMatch[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed content search (`grep`) rendered as a search card whose matches are
|
||||
* grouped by file, so a capable UI can list each file as an expandable group of
|
||||
* its matched lines. `shape: 'matches'` discriminates this variant from the path
|
||||
* variant ({@link SearchPathsResultView}) within {@link SearchResultView}. The
|
||||
* discriminant is `shape`, not `kind`, so it never collides with the
|
||||
* {@link ToolCallKind} `kind` an icon-picking bridge reads off a call view.
|
||||
*/
|
||||
export interface SearchMatchesResultView {
|
||||
card: 'search'
|
||||
shape: 'matches'
|
||||
/** Replacement title for the completed call. Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/** Matched lines grouped by file, in first-seen file order. */
|
||||
files: SearchFileMatches[]
|
||||
/**
|
||||
* Whether the tool capped the inline result: `files` carries only the retained
|
||||
* matches, not every match the search found. A UI shows a capped indicator so it
|
||||
* never presents a partial group as complete.
|
||||
*/
|
||||
truncated: boolean
|
||||
/** Total matches the search found before capping (equals the retained count when not `truncated`). */
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed path search (`glob`) rendered as a search card whose result is a flat
|
||||
* path list. `shape: 'paths'` discriminates this variant from the grouped-matches
|
||||
* variant ({@link SearchMatchesResultView}) within {@link SearchResultView}.
|
||||
*/
|
||||
export interface SearchPathsResultView {
|
||||
card: 'search'
|
||||
shape: 'paths'
|
||||
/** Replacement title for the completed call. Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/** The discovered paths, in the tool's result order (the retained page when `truncated`). */
|
||||
paths: string[]
|
||||
/**
|
||||
* Whether the tool capped the inline result: `paths` carries only the retained
|
||||
* page, not every path the search found. A UI shows a capped indicator so it
|
||||
* never presents a partial list as complete.
|
||||
*/
|
||||
truncated: boolean
|
||||
/** Total paths the search found before capping (equals `paths.length` when not `truncated`). */
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed search rendered as a search card, the result-time view a discovery
|
||||
* tool (`grep`, `glob`) returns from `presentResult`. One `card: 'search'` view
|
||||
* with two `shape`-discriminated variants: grouped-by-file content matches
|
||||
* ({@link SearchMatchesResultView}) and a flat path list
|
||||
* ({@link SearchPathsResultView}). Both carry a `truncated`/`total` signal so a UI
|
||||
* never presents a capped result as complete. The view carries no result text: a
|
||||
* UI without a search card falls back to the raw `tool/result` content. There is
|
||||
* no call-time analogue: a search call stays a {@link GenericCallView}
|
||||
* (`kind: 'search'`) because the pending state has no matches or paths to show —
|
||||
* the structured shape exists only after `execute`.
|
||||
*/
|
||||
export type SearchResultView = SearchMatchesResultView | SearchPathsResultView
|
||||
|
||||
/**
|
||||
* A completed file read rendered as a line-numbered, optionally syntax-highlighted
|
||||
* code view by a capable UI. Set by a tool whose call reads file text (e.g.
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
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 type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
import { globSearchMeta, searchViewFromMeta } from './presentation.ts'
|
||||
import { singleQuote } from './shell-quote.ts'
|
||||
import { acceptedSurfaceValue } from './surface.ts'
|
||||
|
||||
@@ -43,6 +44,8 @@ export interface GlobToolCaps {
|
||||
sampleOverCapGlobResults: boolean
|
||||
/** Max paths retained inline; later paths go to the formatted spill file. */
|
||||
maxResults: number
|
||||
/** Max bytes of serialized `presentationMeta`; trailing paths drop past it. */
|
||||
maxMetaBytes: number
|
||||
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
||||
rawOutputMaxBytes: number
|
||||
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
|
||||
@@ -231,6 +234,24 @@ function renderGlobPaths(paths: string[], caps: GlobToolCaps, root: string, spil
|
||||
return formatGlobOutput(sampleAcrossTopLevel(paths, caps.maxResults, root), paths.length, spillRef)
|
||||
}
|
||||
|
||||
/**
|
||||
* The inline page of paths a completed `glob` card shows, computed the SAME way
|
||||
* {@link renderGlobPaths} computes its model-facing page so the card and the text
|
||||
* agree on which paths survived the cap. A result within the cap is shown whole;
|
||||
* an over-cap result is either the modification-time head or the top-level sample,
|
||||
* matching the deployment's `sampleOverCapGlobResults`.
|
||||
*
|
||||
* @param paths - the complete discovered path list, in modification-time order.
|
||||
* @param caps - the resolved glob caps (the inline cap and the sampling switch).
|
||||
* @param root - the search root in the same display-path space as `paths`.
|
||||
* @returns the inline page and whether the complete result was capped.
|
||||
*/
|
||||
function globCardPage(paths: string[], caps: GlobToolCaps, root: string): { items: string[]; truncated: boolean } {
|
||||
if (paths.length <= caps.maxResults) return { items: paths, truncated: false }
|
||||
if (!caps.sampleOverCapGlobResults) return { items: paths.slice(0, caps.maxResults), truncated: true }
|
||||
return { items: sampleAcrossTopLevel(paths, caps.maxResults, root).items, truncated: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending-call presentation: a search card titled by the pattern (and root).
|
||||
*
|
||||
@@ -242,6 +263,24 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener
|
||||
return { card: 'generic', title: `Glob ${args.pattern}${where}`, kind: 'search', rawInput: args.pattern }
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed-call presentation: the search card projected from the result's
|
||||
* `presentationMeta` (the discovered path list, with the truncation signal). A UI
|
||||
* without a search card falls back to the raw `tool/result` content, so the view
|
||||
* carries no result text of its own. Malformed or absent metadata (an obsolete or
|
||||
* hand-edited replayed log) falls back to the generic card.
|
||||
*
|
||||
* @param _args - the raw tool arguments; unused, the view derives from the result.
|
||||
* @param result - the final model-facing tool result carrying the projected metadata.
|
||||
* @returns the search card view, or `undefined` for the generic fallback.
|
||||
*/
|
||||
export function presentGlobResult(_args: { pattern: string; path?: string }, result: ToolResult): SearchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const view = searchViewFromMeta(result.meta)
|
||||
if (view === undefined || view.shape !== 'paths') return undefined
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `glob` tool and its system-prompt guidance.
|
||||
*
|
||||
@@ -289,6 +328,10 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps, value.root) }],
|
||||
presentationMeta: (_args, value) => {
|
||||
const page = globCardPage(value.paths, caps, value.root)
|
||||
return globSearchMeta({ items: page.items, truncated: page.truncated, seen: value.paths.length }, caps.maxMetaBytes)
|
||||
},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const input = parseGlobArgs(args)
|
||||
@@ -305,6 +348,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
return { root, paths: all }
|
||||
},
|
||||
presentCall: presentGlobCall,
|
||||
presentResult: presentGlobResult,
|
||||
})
|
||||
ctx.tools.register(tool)
|
||||
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
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'
|
||||
import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
import type { GrepMatch } from './search-core.ts'
|
||||
import { SearchError, previewLine, retainGrepMatches, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
import { grepSearchMeta, searchViewFromMeta } from './presentation.ts'
|
||||
import { singleQuote } from './shell-quote.ts'
|
||||
import { acceptedSurfaceValue } from './surface.ts'
|
||||
|
||||
@@ -41,6 +42,8 @@ export interface GrepToolCaps {
|
||||
maxMatches: number
|
||||
/** Max bytes retained per matched-line preview. */
|
||||
maxLineBytes: number
|
||||
/** Max bytes of serialized `presentationMeta`; trailing file groups drop past it. */
|
||||
maxMetaBytes: number
|
||||
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
||||
rawOutputMaxBytes: number
|
||||
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
|
||||
@@ -54,13 +57,6 @@ export interface GrepInput {
|
||||
include?: string
|
||||
}
|
||||
|
||||
/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */
|
||||
export interface GrepMatch {
|
||||
path: string
|
||||
lineNumber: number
|
||||
line: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an `include` that is not ONE positive glob filter: blank strings,
|
||||
* negated patterns (`!…`), and comma-separated lists. A comma inside a brace
|
||||
@@ -177,22 +173,6 @@ export function parseGrepMatches(stdout: string): GrepMatch[] {
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and
|
||||
* mark the cut. The cap is a per-line budget fact; the complete line stays in
|
||||
* the searched file for `read`.
|
||||
*
|
||||
* @param line - the matched line text (trailing newline already stripped).
|
||||
* @param maxBytes - the preview budget in bytes.
|
||||
* @returns the preview, suffixed with ` (line truncated)` when bytes were cut.
|
||||
*/
|
||||
export function previewLine(line: string, maxBytes: number): string {
|
||||
const retainer = new TextRetainer({ kind: 'head', maxBytes })
|
||||
retainer.push(line)
|
||||
const kept = retainer.finish()
|
||||
return kept.truncated ? `${kept.text} (line truncated)` : kept.text
|
||||
}
|
||||
|
||||
/** `match` / `matches` for a count. */
|
||||
function matchNoun(count: number): string {
|
||||
return count === 1 ? 'match' : 'matches'
|
||||
@@ -241,18 +221,10 @@ export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: S
|
||||
return `${header}\n\n${body}\n\n(${recovery})`
|
||||
}
|
||||
|
||||
/** Apply the Native per-line preview budget without changing the canonical matches. */
|
||||
function previewGrepMatches(matches: GrepMatch[], maxLineBytes: number): GrepMatch[] {
|
||||
return matches.map(match => ({ ...match, line: previewLine(match.line, maxLineBytes) }))
|
||||
}
|
||||
|
||||
/** Retain and format one canonical match list for the Native surface. */
|
||||
function renderGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number, spillRef?: SpillRef): string {
|
||||
if (matches.length === 0) return 'No matches found'
|
||||
const previewed = previewGrepMatches(matches, maxLineBytes)
|
||||
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
|
||||
for (const match of previewed) retainer.push(match)
|
||||
return formatGrepOutput(retainer.finish(), spillRef)
|
||||
/** Format one already-retained match list for the Native surface. */
|
||||
function formatRetainedGrep(retained: RetainedItems<GrepMatch>, spillRef?: SpillRef): string {
|
||||
if (retained.seen === 0) return 'No matches found'
|
||||
return formatGrepOutput(retained, spillRef)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,6 +240,27 @@ export function presentGrepCall(args: { pattern: string; path?: string; include?
|
||||
return { card: 'generic', title: `Grep ${args.pattern}${where}${filter}`, kind: 'search', rawInput: args.pattern }
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed-call presentation: the search card projected from the result's
|
||||
* `presentationMeta` (matches grouped by file, with the truncation signal). A UI
|
||||
* without a search card falls back to the raw `tool/result` content, so the view
|
||||
* carries no result text of its own. Malformed or absent metadata (an obsolete or
|
||||
* hand-edited replayed log) falls back to the generic card.
|
||||
*
|
||||
* @param _args - the raw tool arguments; unused, the view derives from the result.
|
||||
* @param result - the final model-facing tool result carrying the projected metadata.
|
||||
* @returns the search card view, or `undefined` for the generic fallback.
|
||||
*/
|
||||
export function presentGrepResult(
|
||||
_args: { pattern: string; path?: string; include?: string },
|
||||
result: ToolResult,
|
||||
): SearchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const view = searchViewFromMeta(result.meta)
|
||||
if (view === undefined || view.shape !== 'matches') return undefined
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `grep` tool and its system-prompt guidance.
|
||||
*
|
||||
@@ -315,8 +308,10 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes),
|
||||
text: formatRetainedGrep(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes)),
|
||||
}],
|
||||
presentationMeta: (_args, value) =>
|
||||
grepSearchMeta(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), caps.maxMetaBytes),
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const input = parseGrepArgs(args)
|
||||
@@ -335,6 +330,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
return { matches: all }
|
||||
},
|
||||
presentCall: presentGrepCall,
|
||||
presentResult: presentGrepResult,
|
||||
})
|
||||
ctx.tools.register(tool)
|
||||
|
||||
@@ -344,17 +340,20 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
if (value === undefined) return decision
|
||||
const matches = value.matches
|
||||
if (matches.length <= caps.maxMatches) return decision
|
||||
// The spill artifact holds the COMPLETE result: preview each line, but keep
|
||||
// every match (no inline cap), so the recovery file is the full search.
|
||||
const previewedAll = matches.map(match => ({ ...match, line: previewLine(match.line, caps.maxLineBytes) }))
|
||||
const spillRef = await trySaveFormattedResult(
|
||||
ctx,
|
||||
exec,
|
||||
'grep-results.txt',
|
||||
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewGrepMatches(matches, caps.maxLineBytes))}`,
|
||||
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewedAll)}`,
|
||||
)
|
||||
return {
|
||||
kind: 'accept',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: renderGrepMatches(matches, caps.maxMatches, caps.maxLineBytes, spillRef),
|
||||
text: formatRetainedGrep(retainGrepMatches(matches, caps.maxMatches, caps.maxLineBytes), spillRef),
|
||||
}],
|
||||
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
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'
|
||||
import { RAW_OUTPUT_MAX_BYTES, SEARCH_META_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
|
||||
|
||||
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, sampleAcrossTopLevel } from './glob.ts'
|
||||
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult, sampleAcrossTopLevel } from './glob.ts'
|
||||
export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts'
|
||||
export {
|
||||
GREP_MAX_LINE_BYTES,
|
||||
@@ -45,11 +45,20 @@ export {
|
||||
parseGrepArgs,
|
||||
parseGrepMatches,
|
||||
presentGrepCall,
|
||||
previewLine,
|
||||
presentGrepResult,
|
||||
} from './grep.ts'
|
||||
export type { GrepInput, GrepMatch, GrepToolCaps } from './grep.ts'
|
||||
export { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
export type { RipgrepRun, SearchErrorCode } from './search-core.ts'
|
||||
export type { GrepInput, GrepToolCaps } from './grep.ts'
|
||||
export {
|
||||
RAW_OUTPUT_MAX_BYTES,
|
||||
SEARCH_META_MAX_BYTES,
|
||||
SEARCH_TIMEOUT_MS,
|
||||
SearchError,
|
||||
previewLine,
|
||||
runRipgrep,
|
||||
toWorkdirRelative,
|
||||
trySaveFormattedResult,
|
||||
} from './search-core.ts'
|
||||
export type { GrepMatch, RipgrepRun, SearchErrorCode } from './search-core.ts'
|
||||
export { singleQuote } from './shell-quote.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
@@ -68,6 +77,8 @@ export interface Config {
|
||||
grepMaxMatches?: number
|
||||
/** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */
|
||||
grepMaxLineBytes?: number
|
||||
/** Max bytes of one search's serialized `presentationMeta`; trailing groups/paths drop past it so the persisted card stays bounded. */
|
||||
searchMetaMaxBytes?: number
|
||||
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
|
||||
rawOutputMaxBytes?: number
|
||||
/** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */
|
||||
@@ -79,6 +90,7 @@ export const Config: z<Config> = z.object({
|
||||
globMaxResults: z.number().default(GLOB_MAX_RESULTS),
|
||||
grepMaxMatches: z.number().default(GREP_MAX_MATCHES),
|
||||
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
|
||||
searchMetaMaxBytes: z.number().default(SEARCH_META_MAX_BYTES),
|
||||
rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES),
|
||||
timeoutMs: z.number().default(SEARCH_TIMEOUT_MS),
|
||||
})
|
||||
@@ -133,6 +145,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
assertPositiveInteger('globMaxResults', resolved.globMaxResults)
|
||||
assertPositiveInteger('grepMaxMatches', resolved.grepMaxMatches)
|
||||
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
|
||||
assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes)
|
||||
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
|
||||
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
|
||||
if (!await ripgrepAvailable(ctx)) {
|
||||
@@ -142,12 +155,14 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
applyGlobTool(ctx, {
|
||||
sampleOverCapGlobResults: resolved.sampleOverCapGlobResults,
|
||||
maxResults: resolved.globMaxResults,
|
||||
maxMetaBytes: resolved.searchMetaMaxBytes,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
})
|
||||
applyGrepTool(ctx, {
|
||||
maxMatches: resolved.grepMaxMatches,
|
||||
maxLineBytes: resolved.grepMaxLineBytes,
|
||||
maxMetaBytes: resolved.searchMetaMaxBytes,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
})
|
||||
|
||||
205
packages/fs/tool-fs-search/src/presentation.ts
Normal file
205
packages/fs/tool-fs-search/src/presentation.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Result-time search-card presentation for `grep` and `glob`. Both tools land on
|
||||
* one `card: 'search'` render intent ({@link SearchResultView}) with two
|
||||
* `shape`-discriminated variants: `grep` projects its matches grouped by file
|
||||
* ({@link SearchMatchesResultView}), `glob` projects a flat path list
|
||||
* ({@link SearchPathsResultView}). This module owns the value→`presentationMeta`
|
||||
* projection each tool declares and the defensive `meta`→view narrowing each
|
||||
* tool's `presentResult` reads back on replay.
|
||||
*
|
||||
* The canonical value never crosses the wire — only the model-facing render text
|
||||
* and this JSON `meta` do — so the structured shape a UI renders MUST ride in
|
||||
* `meta`. Each projection consumes the SAME retained matches/paths the
|
||||
* model-facing render consumes ({@link module:@deepseek-ai/dsh-tool-fs-search/search-core}
|
||||
* `retainGrepMatches`/`retainGlobPaths`), so text and card agree about which
|
||||
* results survived the inline cap, and reports `total` (every result found) and
|
||||
* `truncated`, so a UI never presents a capped result as complete.
|
||||
*
|
||||
* A second, independent cap bounds the JSON `meta` itself: the retained matches
|
||||
* of a broad search (hundreds of long lines) can still serialize to hundreds of
|
||||
* kilobytes, and `meta` is persisted with the session log and re-sent on every
|
||||
* request. {@link capMetaBytes} drops trailing groups/paths until the serialized
|
||||
* `meta` fits `maxMetaBytes` and marks the result `truncated`; a deployment's
|
||||
* final output budget (`dsh-spill-policy`) only shrinks `content`, never `meta`,
|
||||
* so this projection owns keeping `meta` bounded.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/presentation
|
||||
*/
|
||||
|
||||
import type {
|
||||
SearchFileMatches,
|
||||
SearchLineMatch,
|
||||
SearchResultView,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { GrepMatch } from './search-core.ts'
|
||||
|
||||
/**
|
||||
* The retention fields a meta projection reads: the retained page, whether the
|
||||
* complete result was capped, and the pre-cap total. Both a full
|
||||
* {@link RetainedItems} (from `retainGrepMatches`) and `glob`'s sampled page
|
||||
* satisfy this structural subset, so a projection consumes either without a fake
|
||||
* `kept`/`omitted`.
|
||||
*/
|
||||
type RetainedPage<T> = Pick<RetainedItems<T>, 'items' | 'truncated' | 'seen'>
|
||||
|
||||
/**
|
||||
* The `grep`/`glob` tools' private `tool/result` `meta` payload: the capped,
|
||||
* structured search result. Attached opaquely (as `JsonValue`) on the tool result
|
||||
* and persisted with the session log, so `presentResult` reproduces the search
|
||||
* card on replay. The `matches` shape carries the by-file groups; the `paths`
|
||||
* shape carries the flat list. Both carry the pre-cap `total` and the `truncated`
|
||||
* flag. The producing tool owns and narrows this opaque shape.
|
||||
*
|
||||
* The member shapes use object-literal `type` aliases rather than the
|
||||
* {@link SearchFileMatches}/{@link SearchLineMatch} interfaces because only a type
|
||||
* alias is assignable to the `JsonValue` index signature `presentationMeta`
|
||||
* returns; the two are structurally identical, so the projected value still reads
|
||||
* back as a {@link SearchResultView}.
|
||||
*/
|
||||
export type SearchMeta =
|
||||
| { shape: 'matches'; files: MetaFileMatches[]; truncated: boolean; total: number }
|
||||
| { shape: 'paths'; paths: string[]; truncated: boolean; total: number }
|
||||
|
||||
/** One matched line in {@link SearchMeta} (the JSON-assignable form of {@link SearchLineMatch}). */
|
||||
type MetaLineMatch = { lineNumber: number; line: string }
|
||||
|
||||
/** One file's grouped matches in {@link SearchMeta} (the JSON-assignable form of {@link SearchFileMatches}). */
|
||||
type MetaFileMatches = { path: string; matches: MetaLineMatch[] }
|
||||
|
||||
/**
|
||||
* Group flat matches by file (first-seen order) into the structured by-file shape
|
||||
* a UI renders as expandable per-file groups. The grouping matches the
|
||||
* model-facing text grouping
|
||||
* ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `formatGrepMatches`), so
|
||||
* card and text agree about file order and membership.
|
||||
*
|
||||
* @param matches - the retained matches to group, in output order.
|
||||
* @returns one entry per file, in first-seen order.
|
||||
*/
|
||||
export function groupMatchesByFile(matches: GrepMatch[]): MetaFileMatches[] {
|
||||
const byFile = new Map<string, MetaLineMatch[]>()
|
||||
for (const match of matches) {
|
||||
const entry: MetaLineMatch = { lineNumber: match.lineNumber, line: match.line }
|
||||
const group = byFile.get(match.path)
|
||||
if (group !== undefined) group.push(entry)
|
||||
else byFile.set(match.path, [entry])
|
||||
}
|
||||
return Array.from(byFile, ([path, fileMatches]) => ({ path, matches: fileMatches }))
|
||||
}
|
||||
|
||||
/** The serialized UTF-8 byte size of one meta payload (the size persisted and re-sent). */
|
||||
function metaBytes(meta: SearchMeta): number {
|
||||
return Buffer.byteLength(JSON.stringify(meta), 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop trailing top-level items (file groups or paths) until the serialized meta
|
||||
* fits `maxMetaBytes`, marking the result `truncated` when anything was dropped.
|
||||
* `total` is preserved (it counts what the search found, not what meta retains).
|
||||
* A single item too large to fit on its own is kept: the invariant is a bounded
|
||||
* payload wherever droppable, never an empty card that hides a real result.
|
||||
*
|
||||
* @param meta - the projected meta, already capped to the inline item count.
|
||||
* @param maxMetaBytes - the serialized-meta byte budget.
|
||||
* @returns the same meta when it fits, else a byte-bounded copy marked `truncated`.
|
||||
*/
|
||||
function capMetaBytes(meta: SearchMeta, maxMetaBytes: number): SearchMeta {
|
||||
if (metaBytes(meta) <= maxMetaBytes) return meta
|
||||
if (meta.shape === 'matches') {
|
||||
const files = [...meta.files]
|
||||
while (files.length > 1 && metaBytes({ ...meta, files, truncated: true }) > maxMetaBytes) files.pop()
|
||||
return { ...meta, files, truncated: true }
|
||||
}
|
||||
const paths = [...meta.paths]
|
||||
while (paths.length > 1 && metaBytes({ ...meta, paths, truncated: true }) > maxMetaBytes) paths.pop()
|
||||
return { ...meta, paths, truncated: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the retained `grep` matches into {@link SearchMeta} for the search
|
||||
* card. Consumes the same {@link RetainedItems} the model-facing render consumes
|
||||
* (preview budget and inline match cap already applied), groups the retained
|
||||
* matches by file, reports `total` (every parsed match) and `truncated`, then
|
||||
* bounds the serialized meta to `maxMetaBytes`.
|
||||
*
|
||||
* @param retained - the retention outcome over every parsed match (previewed, capped).
|
||||
* @param maxMetaBytes - the serialized-meta byte budget.
|
||||
* @returns the `matches`-shaped search metadata.
|
||||
*/
|
||||
export function grepSearchMeta(retained: RetainedPage<GrepMatch>, maxMetaBytes: number): SearchMeta {
|
||||
const meta: SearchMeta = {
|
||||
shape: 'matches',
|
||||
files: groupMatchesByFile(retained.items),
|
||||
truncated: retained.truncated,
|
||||
total: retained.seen,
|
||||
}
|
||||
return capMetaBytes(meta, maxMetaBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the retained `glob` paths into {@link SearchMeta} for the search card.
|
||||
* Consumes the same {@link RetainedItems} the model-facing render consumes (inline
|
||||
* path cap already applied), reports `total` (every discovered path) and
|
||||
* `truncated`, then bounds the serialized meta to `maxMetaBytes`.
|
||||
*
|
||||
* @param retained - the retention outcome over every discovered path (capped).
|
||||
* @param maxMetaBytes - the serialized-meta byte budget.
|
||||
* @returns the `paths`-shaped search metadata.
|
||||
*/
|
||||
export function globSearchMeta(retained: RetainedPage<string>, maxMetaBytes: number): SearchMeta {
|
||||
const meta: SearchMeta = {
|
||||
shape: 'paths',
|
||||
paths: retained.items,
|
||||
truncated: retained.truncated,
|
||||
total: retained.seen,
|
||||
}
|
||||
return capMetaBytes(meta, maxMetaBytes)
|
||||
}
|
||||
|
||||
/** Whether `value` is a valid {@link SearchLineMatch} (defensive narrowing from opaque `meta`). */
|
||||
function isSearchLineMatch(value: unknown): value is SearchLineMatch {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const { lineNumber, line } = value as Record<string, unknown>
|
||||
return typeof lineNumber === 'number' && typeof line === 'string'
|
||||
}
|
||||
|
||||
/** Whether `value` is a valid {@link SearchFileMatches} (defensive narrowing from opaque `meta`). */
|
||||
function isSearchFileMatches(value: unknown): value is SearchFileMatches {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const { path, matches } = value as Record<string, unknown>
|
||||
return typeof path === 'string' && Array.isArray(matches) && matches.every(isSearchLineMatch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow opaque live or replayed result metadata to a {@link SearchResultView}.
|
||||
* Malformed metadata returns `undefined` so `presentResult` can fall back to the
|
||||
* generic card instead of throwing during replay of an older or hand-edited log.
|
||||
* The view carries no result text: a UI without a search card falls back to the
|
||||
* raw `tool/result` content.
|
||||
*
|
||||
* A zero-result meta (`files: []` / `paths: []`) narrows to a valid empty card —
|
||||
* unlike the mirrored `diffsFromMeta`, which rejects empty diffs, because a
|
||||
* zero-match grep is a legitimate result a UI shows as "no matches", not an
|
||||
* absent projection.
|
||||
*
|
||||
* @param meta - result metadata (the {@link SearchMeta} the tool projected).
|
||||
* @returns the search view, or `undefined` for absent or malformed metadata.
|
||||
*/
|
||||
export function searchViewFromMeta(meta: unknown): SearchResultView | undefined {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
|
||||
const record = meta as Record<string, unknown>
|
||||
const { truncated, total } = record
|
||||
if (typeof truncated !== 'boolean' || typeof total !== 'number') return undefined
|
||||
if (record.shape === 'matches') {
|
||||
const { files } = record
|
||||
if (!Array.isArray(files) || !files.every(isSearchFileMatches)) return undefined
|
||||
return { card: 'search', shape: 'matches', files: files, truncated, total }
|
||||
}
|
||||
if (record.shape === 'paths') {
|
||||
const { paths } = record
|
||||
if (!Array.isArray(paths) || !paths.every((path): path is string => typeof path === 'string')) return undefined
|
||||
return { card: 'search', shape: 'paths', paths, truncated, total }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
import { isAbsolute, relative, sep } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
@@ -36,6 +38,18 @@ export const RAW_OUTPUT_MAX_BYTES = 20_000_000
|
||||
*/
|
||||
export const SEARCH_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* Default cap in bytes on one search's serialized `presentationMeta` (the
|
||||
* `searchMetaMaxBytes` config). The inline match/path caps already bound the item
|
||||
* COUNT, but retained matches of a broad search (many long lines) can still
|
||||
* serialize to hundreds of kilobytes, and `meta` is persisted with the session
|
||||
* log and re-sent on every request. A deployment's final output budget
|
||||
* (`dsh-spill-policy`) only shrinks a result's `content`, never its `meta`, so the
|
||||
* projection owns this cap. 64 KiB holds the full default-capped result of a
|
||||
* typical search while bounding the pathological one.
|
||||
*/
|
||||
export const SEARCH_META_MAX_BYTES = 65_536
|
||||
|
||||
/**
|
||||
* Stable, machine-routable codes for search failures. Package-owned (not
|
||||
* `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs`
|
||||
@@ -212,6 +226,63 @@ export function toWorkdirRelative(path: string, workdir: string): string {
|
||||
return rel
|
||||
}
|
||||
|
||||
/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */
|
||||
export interface GrepMatch {
|
||||
path: string
|
||||
lineNumber: number
|
||||
line: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and
|
||||
* mark the cut. The cap is a per-line budget fact; the complete line stays in
|
||||
* the searched file for `read`.
|
||||
*
|
||||
* @param line - the matched line text (trailing newline already stripped).
|
||||
* @param maxBytes - the preview budget in bytes.
|
||||
* @returns the preview, suffixed with ` (line truncated)` when bytes were cut.
|
||||
*/
|
||||
export function previewLine(line: string, maxBytes: number): string {
|
||||
const retainer = new TextRetainer({ kind: 'head', maxBytes })
|
||||
retainer.push(line)
|
||||
const kept = retainer.finish()
|
||||
return kept.truncated ? `${kept.text} (line truncated)` : kept.text
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the shared inline cap to a canonical `grep` match list: preview each
|
||||
* retained line to `maxLineBytes` and keep the first `maxMatches`. The single
|
||||
* retention pass both the model-facing render ({@link module:@deepseek-ai/dsh-tool-fs-search/grep}
|
||||
* `formatGrepOutput`) and the search-card projection
|
||||
* ({@link module:@deepseek-ai/dsh-tool-fs-search/presentation} `grepSearchMeta`)
|
||||
* consume, so text and card never disagree about which matches survived.
|
||||
*
|
||||
* @param matches - every match the search parsed (the canonical value's matches).
|
||||
* @param maxMatches - the inline match cap (the `grepMaxMatches` config).
|
||||
* @param maxLineBytes - the per-matched-line preview budget in bytes.
|
||||
* @returns the retention outcome over the previewed matches.
|
||||
*/
|
||||
export function retainGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number): RetainedItems<GrepMatch> {
|
||||
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
|
||||
for (const match of matches) retainer.push({ ...match, line: previewLine(match.line, maxLineBytes) })
|
||||
return retainer.finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the shared inline cap to a canonical `glob` path list: keep the first
|
||||
* `maxResults`. The single retention pass both the model-facing render and the
|
||||
* search-card projection consume.
|
||||
*
|
||||
* @param paths - every path the search discovered (the canonical value's paths).
|
||||
* @param maxResults - the inline path cap (the `globMaxResults` config).
|
||||
* @returns the retention outcome over the paths.
|
||||
*/
|
||||
export function retainGlobPaths(paths: string[], maxResults: number): RetainedItems<string> {
|
||||
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
|
||||
for (const path of paths) retainer.push(path)
|
||||
return retainer.finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort save of one COMPLETE formatted search result through
|
||||
* `ctx.spillStore.saveText()` — the model-facing recovery path for a capped
|
||||
|
||||
176
packages/fs/tool-fs-search/tests/presentation.spec.ts
Normal file
176
packages/fs/tool-fs-search/tests/presentation.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Unit tests for the search-card presentation layer (`src/presentation.ts`): the
|
||||
* canonical value → `presentationMeta` projections (`grepSearchMeta`,
|
||||
* `globSearchMeta`, `groupMatchesByFile`) and the defensive `meta` → view
|
||||
* narrowing (`searchViewFromMeta`). These pin the by-file grouping, the
|
||||
* `truncated`/`total` honesty over already-retained input, the serialized-meta
|
||||
* byte cap, and the malformed-metadata fallback a replayed or hand-edited log can
|
||||
* deliver.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
globSearchMeta,
|
||||
grepSearchMeta,
|
||||
groupMatchesByFile,
|
||||
searchViewFromMeta,
|
||||
} from '../src/presentation.ts'
|
||||
import type { GrepMatch } from '../src/search-core.ts'
|
||||
import { retainGlobPaths, retainGrepMatches } from '../src/search-core.ts'
|
||||
|
||||
const match = (path: string, lineNumber: number, line: string): GrepMatch => ({ path, lineNumber, line })
|
||||
|
||||
/** A byte cap large enough that no test payload here is meta-capped. */
|
||||
const WIDE = 1_000_000
|
||||
|
||||
describe('groupMatchesByFile', () => {
|
||||
it('groups matches by first-seen file order, keeping line/lineNumber only', () => {
|
||||
expect(groupMatchesByFile([
|
||||
match('b.ts', 2, 'x'),
|
||||
match('a.ts', 1, 'y'),
|
||||
match('b.ts', 5, 'z'),
|
||||
])).toEqual([
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 2, line: 'x' }, { lineNumber: 5, line: 'z' }] },
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'y' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty list for no matches', () => {
|
||||
expect(groupMatchesByFile([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('grepSearchMeta', () => {
|
||||
it('projects grouped matches with total and a false truncation flag within the cap', () => {
|
||||
const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'one'), match('a.ts', 2, 'two')], 10, 2000), WIDE)
|
||||
expect(meta).toEqual({
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
|
||||
truncated: false,
|
||||
total: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the pre-cap total and truncation from the shared retention pass', () => {
|
||||
const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'one'), match('a.ts', 2, 'two'), match('b.ts', 3, 'three')], 2, 2000), WIDE)
|
||||
expect(meta).toEqual({
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
|
||||
truncated: true,
|
||||
total: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the per-line preview budget (UTF-8 boundary) the retention pass applied', () => {
|
||||
const meta = grepSearchMeta(retainGrepMatches([match('a.txt', 1, 'aéaéaéaé')], 10, 7), WIDE)
|
||||
expect(meta).toMatchObject({ shape: 'matches', files: [{ path: 'a.txt', matches: [{ lineNumber: 1, line: 'aéaéa (line truncated)' }] }] })
|
||||
})
|
||||
|
||||
it('drops trailing file groups until the serialized meta fits the byte cap, marking it truncated', () => {
|
||||
const retained = retainGrepMatches(
|
||||
[match('a.ts', 1, 'x'.repeat(60)), match('b.ts', 2, 'y'.repeat(60)), match('c.ts', 3, 'z'.repeat(60))],
|
||||
10,
|
||||
2000,
|
||||
)
|
||||
// One 60-byte group serializes to ~110 bytes; a 260-byte cap holds two, not three.
|
||||
const meta = grepSearchMeta(retained, 260)
|
||||
expect(meta.shape).toBe('matches')
|
||||
if (meta.shape !== 'matches') throw new Error('unreachable')
|
||||
expect(meta.truncated).toBe(true)
|
||||
expect(meta.total).toBe(3)
|
||||
expect(meta.files.length).toBeLessThan(3)
|
||||
expect(Buffer.byteLength(JSON.stringify(meta), 'utf8')).toBeLessThanOrEqual(260)
|
||||
})
|
||||
|
||||
it('keeps a single oversized group rather than emit an empty card', () => {
|
||||
const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'x'.repeat(500))], 10, 2000), 50)
|
||||
expect(meta.shape).toBe('matches')
|
||||
if (meta.shape !== 'matches') throw new Error('unreachable')
|
||||
expect(meta.files).toHaveLength(1)
|
||||
expect(meta.truncated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('globSearchMeta', () => {
|
||||
it('projects the path list with total and a false truncation flag within the cap', () => {
|
||||
expect(globSearchMeta(retainGlobPaths(['a.ts', 'b.ts'], 10), WIDE)).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 })
|
||||
})
|
||||
|
||||
it('reports the pre-cap total and truncation from the shared retention pass', () => {
|
||||
expect(globSearchMeta(retainGlobPaths(['a.ts', 'b.ts', 'c.ts'], 2), WIDE)).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
|
||||
})
|
||||
|
||||
it('drops trailing paths until the serialized meta fits the byte cap, marking it truncated', () => {
|
||||
const retained = retainGlobPaths([`${'a'.repeat(100)}.ts`, `${'b'.repeat(100)}.ts`, `${'c'.repeat(100)}.ts`], 10)
|
||||
const meta = globSearchMeta(retained, 180)
|
||||
expect(meta.shape).toBe('paths')
|
||||
if (meta.shape !== 'paths') throw new Error('unreachable')
|
||||
expect(meta.truncated).toBe(true)
|
||||
expect(meta.total).toBe(3)
|
||||
expect(meta.paths.length).toBeLessThan(3)
|
||||
expect(Buffer.byteLength(JSON.stringify(meta), 'utf8')).toBeLessThanOrEqual(180)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchViewFromMeta (defensive narrowing)', () => {
|
||||
// The narrowing accepts an opaque JsonValue; a malformed payload is not a
|
||||
// statically-valid JsonValue, so route every case through one cast helper that
|
||||
// mirrors how a hand-edited/older session log delivers arbitrary shapes.
|
||||
const m = (value: unknown): JsonValue | undefined => value as JsonValue | undefined
|
||||
|
||||
it('narrows a well-formed matches payload into a matches view', () => {
|
||||
const meta = { shape: 'matches', files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] }], truncated: true, total: 5 }
|
||||
expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta })
|
||||
})
|
||||
|
||||
it('narrows a well-formed paths payload into a paths view', () => {
|
||||
const meta = { shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 }
|
||||
expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta })
|
||||
})
|
||||
|
||||
it('narrows a zero-result payload into a valid empty card (not a rejected projection)', () => {
|
||||
expect(searchViewFromMeta(m({ shape: 'matches', files: [], truncated: false, total: 0 })))
|
||||
.toEqual({ card: 'search', shape: 'matches', files: [], truncated: false, total: 0 })
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false, total: 0 })))
|
||||
.toEqual({ card: 'search', shape: 'paths', paths: [], truncated: false, total: 0 })
|
||||
})
|
||||
|
||||
it('rejects undefined / non-object / array meta', () => {
|
||||
expect(searchViewFromMeta(undefined)).toBeUndefined()
|
||||
expect(searchViewFromMeta(null)).toBeUndefined()
|
||||
expect(searchViewFromMeta(m('nope'))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m([]))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a payload with a missing / mistyped truncated or total field', () => {
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], total: 0 }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: 'no', total: 0 }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false, total: '0' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an unknown or missing shape discriminant', () => {
|
||||
expect(searchViewFromMeta(m({ shape: 'other', truncated: false, total: 0 }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ truncated: false, total: 0 }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a matches payload with a malformed files array', () => {
|
||||
const base = { shape: 'matches', truncated: false, total: 1 }
|
||||
expect(searchViewFromMeta(m({ ...base, files: 'x' }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [null] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: ['x'] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [[]] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 1, matches: [] }] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: 'x' }] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [null] }] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [{ lineNumber: '1', line: 'x' }] }] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [{ lineNumber: 1, line: 2 }] }] }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a paths payload with a non-array or non-string-element paths field', () => {
|
||||
const base = { shape: 'paths', truncated: false, total: 1 }
|
||||
expect(searchViewFromMeta(m({ ...base, paths: 'x' }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, paths: [1] }))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
formatGrepMatches,
|
||||
parseGrepMatches,
|
||||
presentGlobCall,
|
||||
presentGlobResult,
|
||||
presentGrepCall,
|
||||
presentGrepResult,
|
||||
previewLine,
|
||||
sampleAcrossTopLevel,
|
||||
toWorkdirRelative,
|
||||
@@ -987,6 +989,73 @@ describe('presentation', () => {
|
||||
expect(presentGrepCall({ pattern: 'todo' })).toMatchObject({ card: 'generic', title: 'Grep todo', kind: 'search' })
|
||||
expect(presentGrepCall({ pattern: 'todo', path: 'src', include: '*.ts' }).title).toBe('Grep todo in src (*.ts)')
|
||||
})
|
||||
|
||||
it('grep projects a search card from a real execute, grouped by file with total and truncation', async () => {
|
||||
const { ctx, bash } = await setup({ config: { grepMaxMatches: 2 } })
|
||||
bash.handler = () => runResult([
|
||||
matchLine('a.ts', 1, 'one'),
|
||||
matchLine('a.ts', 2, 'two'),
|
||||
matchLine('b.ts', 3, 'three'),
|
||||
'',
|
||||
].join('\n'))
|
||||
const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') })
|
||||
if (result.isError) throw new Error('expected grep success')
|
||||
// The presentationMeta projection rides the result meta (a surface call).
|
||||
expect(result.meta).toEqual({
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
|
||||
truncated: true,
|
||||
total: 3,
|
||||
})
|
||||
const view = presentGrepResult({ pattern: 'e' }, result)
|
||||
expect(view).toEqual({
|
||||
card: 'search',
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
|
||||
truncated: true,
|
||||
total: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('glob projects a search card from a real execute, a flat path list with total and truncation', async () => {
|
||||
const { ctx, bash } = await setup({ config: { globMaxResults: 2 } })
|
||||
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
|
||||
if (result.isError) throw new Error('expected glob success')
|
||||
expect(result.meta).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
|
||||
const view = presentGlobResult({ pattern: '*.ts' }, result)
|
||||
expect(view).toEqual({ card: 'search', shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
|
||||
})
|
||||
|
||||
it('nested Code dispatch computes no meta, so presentResult falls back to the generic card', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'o' }, {
|
||||
agent: agent('/w'),
|
||||
parent: Symbol('run_code') as ToolExecutionToken,
|
||||
})
|
||||
if (result.isError) throw new Error('expected grep success')
|
||||
expect(result.meta).toBeUndefined()
|
||||
expect(presentGrepResult({ pattern: 'o' }, result)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('presentResult returns undefined for a failed result and for the other tool’s meta shape', () => {
|
||||
const errorResult = { content: [{ type: 'text' as const, text: 'boom' }], isError: true }
|
||||
expect(presentGrepResult({ pattern: 'x' }, errorResult)).toBeUndefined()
|
||||
expect(presentGlobResult({ pattern: '*' }, errorResult)).toBeUndefined()
|
||||
// A grep result carrying a paths-shaped meta (and vice versa) is not this
|
||||
// tool's shape: each presenter narrows to its own shape and otherwise falls back.
|
||||
const pathsResult = { content: [], isError: false, meta: { shape: 'paths', paths: ['a.ts'], truncated: false, total: 1 } }
|
||||
const matchesResult = { content: [], isError: false, meta: { shape: 'matches', files: [], truncated: false, total: 0 } }
|
||||
expect(presentGrepResult({ pattern: 'x' }, pathsResult)).toBeUndefined()
|
||||
expect(presentGlobResult({ pattern: '*' }, matchesResult)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('presentResult falls back to the generic card on malformed replayed meta', () => {
|
||||
const malformed = { content: [], isError: false, meta: { shape: 'matches', files: 'nope', truncated: false, total: 0 } }
|
||||
expect(presentGrepResult({ pattern: 'x' }, malformed)).toBeUndefined()
|
||||
expect(presentGlobResult({ pattern: '*' }, { content: [], isError: false, meta: 42 })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpers', () => {
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 8f08d90f8a91afc2ff022761d2d83de055df18a0
|
||||
README.zh.md: febeba0e601d75cbc69490d29135c206189efe29
|
||||
README.md: 94698d02f599e10b1bbe69e2f9b1b058eb3e1647
|
||||
README.zh.md: da63ff09d7ca52bdc10c8a8bce81be4c2a0abf0d
|
||||
|
||||
@@ -34,7 +34,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit Web-preference allowlist, currently only `permission`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 Web 偏好 allowlist,目前仅包含 `permission`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
|
||||
@@ -84,6 +84,9 @@ const COLD_SUMMARY_BATCH_SIZE = 16
|
||||
/** Conversation message event types (the pagination counting unit). */
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
|
||||
|
||||
/** Product settings intentionally exposed beside model-provider namespaces. */
|
||||
const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding'])
|
||||
|
||||
/** Read live abort state across awaits without treating it as synchronously immutable. */
|
||||
function isAborted(signal: AbortSignal): boolean {
|
||||
return signal.aborted
|
||||
@@ -1108,13 +1111,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
|
||||
/**
|
||||
* The settings namespaces this proxy serves: configurable model providers
|
||||
* plus the small explicit Web preference allowlist. The settings seam
|
||||
* remains general; a future registration does not become remotely readable
|
||||
* or writable by default.
|
||||
* plus the small explicit Web preference and product-owned allowlists. The
|
||||
* settings seam remains general; a future registration does not become
|
||||
* remotely readable or writable by default.
|
||||
*/
|
||||
function exposedNamespaces(): Set<string> {
|
||||
const exposed = modelProviderNamespaces()
|
||||
for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns)
|
||||
for (const ns of PRODUCT_SETTINGS_NAMESPACES) exposed.add(ns)
|
||||
return exposed
|
||||
}
|
||||
|
||||
|
||||
@@ -160,8 +160,8 @@ async function harness(options?: {
|
||||
await ctx.plugin(LlmService)
|
||||
if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings)
|
||||
if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials)
|
||||
// Model-provider namespaces and the explicit Web preference allowlist are
|
||||
// the proxy's complete settings surface.
|
||||
// Model-provider namespaces plus the explicit Web preference and product
|
||||
// onboarding allowlists are the proxy's complete settings surface.
|
||||
if (options?.configurableProviders !== false) {
|
||||
ctx.llm.registerConfigurableProviders([
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
|
||||
@@ -226,8 +226,8 @@ describe('settings domain', () => {
|
||||
// The settings seam is general: any plugin may register a namespace for
|
||||
// its own configuration. The Web configuration plane remains opt-in, so a
|
||||
// future internal plugin cannot become remotely configurable just by
|
||||
// registering; permission is the one non-model namespace intentionally
|
||||
// admitted by this surface.
|
||||
// registering; permission and the product onboarding namespace are the
|
||||
// non-model namespaces intentionally admitted by this surface.
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
|
||||
@@ -258,6 +258,21 @@ describe('settings domain', () => {
|
||||
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({})
|
||||
})
|
||||
|
||||
it('serves the product onboarding namespace without invalidating the model catalog', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() }))
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
|
||||
.toEqual(['ui-onboarding'])
|
||||
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
|
||||
expectOk(await api.settings.mutate(request({
|
||||
ns: 'ui-onboarding',
|
||||
ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
|
||||
})))
|
||||
})
|
||||
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }])
|
||||
})
|
||||
|
||||
it('refuses even a model-provider namespace once its directory entry is gone', async () => {
|
||||
const ctx = await harness({ configurableProviders: false })
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain, LlmError, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import { displayText } from '../components/text.ts'
|
||||
import {
|
||||
@@ -37,6 +37,8 @@ export interface ModelController {
|
||||
resetContextResolution(): void
|
||||
/** Forget the tracked selector overlay (shutdown). */
|
||||
clearOverlay(): void
|
||||
/** Remove the adapter-registration listener (channel detach). */
|
||||
detach(): void
|
||||
}
|
||||
|
||||
type ContextResolution =
|
||||
@@ -55,8 +57,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
let modelOverlay: TuiOverlaySession | undefined
|
||||
let modelCommands = Promise.resolve()
|
||||
|
||||
// A route whose adapter has not registered yet. Loader activation order is
|
||||
// service-driven, so the TUI can mount before a configured adapter plugin
|
||||
// activates; that transient NO_ADAPTER is not an error — the resolution
|
||||
// waits for the next `llm/adapters-updated` commit instead of surfacing it.
|
||||
let awaitingAdapter = false
|
||||
|
||||
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
|
||||
contextWindow = undefined
|
||||
awaitingAdapter = false
|
||||
const resolution: Promise<ContextResolution> = selected === undefined
|
||||
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
|
||||
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
|
||||
@@ -67,6 +76,10 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
void resolution.then((result) => {
|
||||
if (contextResolution !== resolution) return
|
||||
if (result.kind === 'error') {
|
||||
if (selected !== undefined && result.error instanceof LlmError && result.error.code === 'NO_ADAPTER') {
|
||||
awaitingAdapter = true
|
||||
return
|
||||
}
|
||||
deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
|
||||
return
|
||||
}
|
||||
@@ -74,6 +87,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
deps.requestRender()
|
||||
})
|
||||
}
|
||||
// The wait cannot go stale against `target.current`: every target change
|
||||
// re-enters resolveContextWindow, which clears it. A commit that still
|
||||
// lacks the route parks the resolution again rather than erroring, so
|
||||
// unrelated topology changes stay silent. The disposer rides the channel's
|
||||
// detachListeners() through detach(), matching the sibling listeners.
|
||||
const disposeAdapterListener = ctx.on('llm/adapters-updated', () => {
|
||||
if (deps.isDisposed() || !awaitingAdapter) return
|
||||
resolveContextWindow(target.current)
|
||||
})
|
||||
resolveContextWindow(target.current)
|
||||
|
||||
const selectModel = (
|
||||
@@ -187,5 +209,8 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
|
||||
clearOverlay(): void {
|
||||
modelOverlay = undefined
|
||||
},
|
||||
detach(): void {
|
||||
disposeAdapterListener()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,23 +402,26 @@ export class ToolCardComponent implements Component {
|
||||
const glyph = this.result === undefined ? '○' : '●'
|
||||
const rawBody = this.renderBody()
|
||||
const view = this.resultView ?? this.callView
|
||||
// A generic card's own content, or a read card's `content` fallback (the
|
||||
// A generic card's own content, a read card's `content` fallback (the
|
||||
// envelope-stripped file text — the TUI has no dedicated read rendering, so a
|
||||
// read renders exactly as before the read card existed), or a web card's
|
||||
// fallback to the raw result content (the `web` view carries no `content`
|
||||
// copy), all 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).
|
||||
// read renders exactly as before the read card existed), or a search/web
|
||||
// card's fallback to the raw result content (neither the `search` nor the
|
||||
// `web` view carries a `content` copy), all render as one dim Markdown block
|
||||
// below, so links/lists/headings keep the unified dim styling rather than
|
||||
// reading as bare text. A search card thus stays byte-identical to the
|
||||
// pre-search-card generic fallback. 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.card === 'read'
|
||||
? 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 */
|
||||
: view.card === 'search'
|
||||
? this.result?.content
|
||||
: undefined
|
||||
: 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(markdownContent)),
|
||||
@@ -535,11 +538,12 @@ export class ToolCardComponent implements Component {
|
||||
// rather than under the dim result-output color.
|
||||
return { prelude: [...hunks, footer], lines: [] }
|
||||
}
|
||||
// A generic or read card carries its own envelope-stripped `content`; a `web`
|
||||
// card carries no `content` copy and falls back to the raw result content
|
||||
// here. (Mirrors the `markdownContent` selection in render(); a read card has
|
||||
// no dedicated TUI rendering, so its `content` takes the same body path,
|
||||
// keeping read output as it was before the read card existed.)
|
||||
// A generic or read card carries its own envelope-stripped `content`; a
|
||||
// search or web card carries no `content` copy and falls back to the raw
|
||||
// result content here. (Mirrors the `markdownContent` selection in render();
|
||||
// a read card has no dedicated TUI rendering, so its `content` takes the same
|
||||
// body path, keeping read output as it was before the read card existed, and
|
||||
// a search card stays byte-identical to the pre-search-card fallback.)
|
||||
const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
|
||||
const prelude: string[] = []
|
||||
const lines: string[] = []
|
||||
|
||||
@@ -1563,6 +1563,7 @@ export function createTuiChat(
|
||||
disposeAgent()
|
||||
disposeSchemeListener()
|
||||
disposeTargetListeners()
|
||||
modelController.detach()
|
||||
}
|
||||
|
||||
// Sweep reveal of the whole banner: the header wipes in left-to-right over
|
||||
|
||||
@@ -10,6 +10,7 @@ import AgentRegistry, {
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage,
|
||||
createToolResultMessage,
|
||||
LlmError,
|
||||
ReasoningEffortId,
|
||||
type LlmCallConfig,
|
||||
type LlmModelReasoningInfo,
|
||||
@@ -3630,6 +3631,96 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await dispose(reasoningFailed)
|
||||
})
|
||||
|
||||
it('defers a NO_ADAPTER context resolution until the provider registers instead of surfacing an error', async () => {
|
||||
// Loader activation order is service-driven: the TUI can mount before a
|
||||
// configured adapter plugin activates, so the initial resolveModelInfo
|
||||
// fails with NO_ADAPTER. That transient state must not print an error;
|
||||
// the resolution retries on llm/adapters-updated.
|
||||
const adapters = new Set<string>()
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
|
||||
contextTokens: 50_000,
|
||||
catalog: {
|
||||
providers: [],
|
||||
models: [],
|
||||
resolveModelInfo: () => adapters.has('openai-codex')
|
||||
? Promise.resolve({ context: { contextWindow: 100_000 } })
|
||||
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
|
||||
// A topology commit that still lacks the route parks the wait again.
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('% context')
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
|
||||
adapters.add('openai-codex')
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('% context')
|
||||
})
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
|
||||
// A commit after satisfaction is a no-op for the resolved value.
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('stops listening for adapter registrations after channel detach', async () => {
|
||||
// The listener disposer rides detachListeners() through the controller's
|
||||
// detach(): after dispose, a registry commit must not re-enter resolution
|
||||
// at all (the isDisposed() guard is a fallback, not the removal).
|
||||
const calls: string[] = []
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
|
||||
catalog: {
|
||||
providers: [],
|
||||
models: [],
|
||||
resolveModelInfo: (provider) => {
|
||||
calls.push(provider)
|
||||
return Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER'))
|
||||
},
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
const callsAtDetach = calls.length
|
||||
await result.controller.dispose()
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(calls.length).toBe(callsAtDetach)
|
||||
await result.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => {
|
||||
const result = await setup({
|
||||
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }],
|
||||
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
|
||||
resolveModelInfo: provider => provider === 'alpha'
|
||||
? Promise.resolve({ context: { contextWindow: 64_000 } })
|
||||
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
|
||||
},
|
||||
})
|
||||
await tick()
|
||||
// Switching the model re-resolves and clears the deferred wait, so the
|
||||
// stale route's adapter arriving afterwards must be a no-op.
|
||||
result.terminal.send('/model alpha/a1')
|
||||
result.terminal.send('\r')
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('Model selected: alpha/a1')
|
||||
})
|
||||
result.ctx.emit('llm/adapters-updated')
|
||||
await tick()
|
||||
expect(result.terminal.output).not.toContain('Could not resolve model context')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('does not render a model catalog that resolves after TUI disposal', async () => {
|
||||
const deferred = Promise.withResolvers<never[]>()
|
||||
const result = await setup({
|
||||
@@ -4387,6 +4478,20 @@ describe('tool cards and surface replay', () => {
|
||||
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
|
||||
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
|
||||
},
|
||||
// A search card carries no result text of its own; the TUI has no dedicated
|
||||
// search arm and falls back to the raw result content, rendered as the same
|
||||
// dim generic body a pre-search-card grep/glob result showed.
|
||||
search: {
|
||||
name: 'search', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Grep todo', kind: 'search' }),
|
||||
presentResult: () => ({
|
||||
card: 'search',
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'todo one' }] }],
|
||||
truncated: false,
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
symbolic: {
|
||||
name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
|
||||
@@ -4424,6 +4529,7 @@ describe('tool cards and surface replay', () => {
|
||||
['c12', 'symbolic', '{}'],
|
||||
['c13', 'knownXml', '{}'],
|
||||
['c16', 'webCard', '{}'],
|
||||
['c17', 'search', '{"pattern":"todo"}'],
|
||||
] as const
|
||||
appendAssistant(result.session, [
|
||||
{ type: 'text', text: 'Calling tools' },
|
||||
@@ -4525,6 +4631,14 @@ describe('tool cards and surface replay', () => {
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'c17' as never,
|
||||
content: [{ type: 'text', text: 'Found 1 match\n\na.ts\nLine 1: todo one' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -4556,6 +4670,11 @@ describe('tool cards and surface replay', () => {
|
||||
expect(output).toContain('$ blank desc command')
|
||||
// A card whose title only repeats the name renders header-only (empty body).
|
||||
expect(output).toContain('Tool / emptyBody')
|
||||
// A search result view carries no `content` of its own, so the card renders
|
||||
// the raw model-facing result text through the same dim generic body — the
|
||||
// TUI has no dedicated search arm.
|
||||
expect(output).toContain('Tool / search')
|
||||
expect(output).toContain('Line 1: todo one')
|
||||
// A diff card drops its title (the paths + change footer carry the meaning).
|
||||
// The first file's path is head-visible; the second file and the change
|
||||
// footer sit past this card's 4-line budget and appear only when expanded.
|
||||
|
||||
@@ -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: 7bee0d2d30fbbcf582fd7b60eb5d9130b6bdf888
|
||||
README.zh.md: 3d708839c9ffbdd89df08678fd6997fc6c45ee07
|
||||
README.md: 12f5c806db66b2109888c1ec642d117f3432d0df
|
||||
README.zh.md: cfbf47219f160af706912ca53f85cff535c381ec
|
||||
|
||||
@@ -4,7 +4,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 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 }`).
|
||||
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`). Search guidance mentions `web_fetch` only when fetch is also config-enabled; a search-only composition instead tells the model to use returned snippets and cite their URLs.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -47,14 +47,20 @@ The tool never calls a provider's `available()` and never enumerates providers
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Search and fetch contribute the web-search and web-fetch guidance below. A scoped tool restriction does not remove these independently registered sections.
|
||||
Search and fetch contribute the web-search and web-fetch guidance below. Search chooses its fetch-enabled or search-only text from config at registration time. A scoped tool restriction does not remove these independently registered sections.
|
||||
|
||||
##### Web search guidance
|
||||
##### Web search guidance with fetch enabled
|
||||
|
||||
```markdown
|
||||
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.
|
||||
```
|
||||
|
||||
##### Web search-only guidance
|
||||
|
||||
```markdown
|
||||
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
|
||||
```
|
||||
|
||||
##### Web fetch guidance
|
||||
|
||||
```markdown
|
||||
@@ -63,11 +69,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema.
|
||||
Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema. Toggling fetch changes the search guidance as well as registering or removing the fetch section.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it.
|
||||
Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement—including toggling fetch's search-guidance branch—or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it.
|
||||
|
||||
### Tool schemas
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
面向模型的 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 }`)。
|
||||
每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。仅当抓取也通过配置启用时,搜索指引才会提及 `web_fetch`;仅启用搜索的组合则会要求模型使用返回的 snippet 并引用其 URL。
|
||||
|
||||
## 工具
|
||||
|
||||
@@ -47,14 +47,20 @@
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
搜索与抓取分别贡献以下 web-search 和 web-fetch 指引。scope 工具限制不会移除这些独立注册的区段。
|
||||
搜索与抓取分别贡献以下 web-search 和 web-fetch 指引。搜索会在注册时根据配置选用启用抓取或仅搜索的文本。scope 工具限制不会移除这些独立注册的区段。
|
||||
|
||||
##### Web 搜索指引
|
||||
##### 启用抓取时的 Web 搜索指引
|
||||
|
||||
```markdown
|
||||
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.
|
||||
```
|
||||
|
||||
##### 仅搜索时的 Web 搜索指引
|
||||
|
||||
```markdown
|
||||
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
|
||||
```
|
||||
|
||||
##### Web 抓取指引
|
||||
|
||||
```markdown
|
||||
@@ -63,11 +69,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。
|
||||
每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。切换抓取状态不仅会注册或移除抓取区段,也会更改搜索指引。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。
|
||||
只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态(包括因切换抓取状态而改变搜索指引分支)或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。
|
||||
|
||||
### 工具 schema
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs)
|
||||
assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs)
|
||||
assertPositiveInteger('fetchMaxOutputChars', resolved.fetchMaxOutputChars)
|
||||
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs)
|
||||
if (resolved.search) {
|
||||
applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs, resolved.fetch)
|
||||
}
|
||||
if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs, resolved.fetchMaxOutputChars)
|
||||
}
|
||||
|
||||
@@ -204,12 +204,21 @@ export function presentSearchResult(args: { query: string }, result: ToolResult)
|
||||
* request's `maxResults`.
|
||||
* @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
|
||||
* `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
|
||||
* @param fetchEnabled - whether the same composition exposes `web_fetch`, which
|
||||
* controls whether search guidance may recommend that follow-up tool.
|
||||
*/
|
||||
export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: number): void {
|
||||
export function applyWebSearchTool(
|
||||
ctx: Context,
|
||||
maxResults: number,
|
||||
timeoutMs: number,
|
||||
fetchEnabled: boolean,
|
||||
): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:web_search',
|
||||
order: 110,
|
||||
text: 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.',
|
||||
text: fetchEnabled
|
||||
? 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.'
|
||||
: 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
|
||||
@@ -483,8 +483,17 @@ describe('tool-web registration', () => {
|
||||
const { fiber, ctx } = await mountTools()
|
||||
const prompt = await ctx.systemPrompt.assemble()
|
||||
const text = prompt.sections.map(s => s.text).join('\n')
|
||||
expect(text).toContain('web_search')
|
||||
expect(text).toContain('web_fetch')
|
||||
expect(text).toContain('Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.')
|
||||
expect(text).toContain('Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not advertise web_fetch in search-only prompt guidance', async () => {
|
||||
const { fiber, ctx } = await mountTools({ config: { search: true, fetch: false } })
|
||||
const prompt = await ctx.systemPrompt.assemble()
|
||||
const text = prompt.sections.map(s => s.text).join('\n')
|
||||
expect(text).toContain('Use the returned source snippets when available')
|
||||
expect(text).not.toContain('web_fetch')
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/web-search-deepseek/README.md
|
||||
README.md: 54eb7561b9d81a9e2da565e3870abe094dbe984d
|
||||
README.zh.md: 8862b8a9c1ba69d247942bb6e41289214826c682
|
||||
README.md: 9046934de209ed0787efa50332e5be16bfdf55c6
|
||||
README.zh.md: 94e01daba69cecd2f5c3c6680979ee5fd66d7cdd
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
A [DeepSeek](https://deepseek.com)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls DeepSeek's **Anthropic-compatible Messages API** (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool enabled, and maps the structured `web_search_tool_result` blocks DeepSeek returns into the seam's normalized `WebSearchResult`.
|
||||
|
||||
This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`.
|
||||
This is an **implementation** package: it registers a provider into `ctx.web`, resolves its credential for each search through the optional `ctx.credentials` seam, records the auxiliary request in the initiating Agent session when one exists, and does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`.
|
||||
|
||||
## How it differs from a dedicated search endpoint
|
||||
|
||||
@@ -12,13 +12,14 @@ Exa and Perplexity expose dedicated search endpoints; DeepSeek does not. Instead
|
||||
|
||||
**Strict mode**: if the response carries no `web_search_tool_result` block (native search did not trigger), the provider throws `WebError` `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping — honest and debuggable.
|
||||
|
||||
It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses.
|
||||
It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses. A mounted credentials service is authoritative; without one, the provider falls back to the launching process environment. The reference is resolved for each search, so a key stored or rotated by the Web Models page reaches the next call without a restart.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent makes the provider unavailable. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). |
|
||||
| `apiKey` | omitted | Literal DeepSeek API key. Prefer `apiKeyEnv` so no secret enters configuration; a non-empty literal wins. |
|
||||
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved for each search through `ctx.credentials`, or from the process environment when that seam is absent. A missing value fails the call as `WEB_PROVIDER_CREDENTIAL_MISSING`. |
|
||||
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. |
|
||||
| `model` | `deepseek-v4-flash` | Anthropic-format model name. |
|
||||
| `apiVersion` | `2023-06-01` | `anthropic-version` header value. |
|
||||
@@ -29,7 +30,7 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`:
|
||||
- id: web-search-deepseek
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
|
||||
```
|
||||
|
||||
@@ -41,6 +42,10 @@ Results are deduplicated by URL because one request may surface the same page ac
|
||||
|
||||
Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`.
|
||||
|
||||
## Request logging
|
||||
|
||||
Immediately before dispatch, a search running under an initiating Agent appends the log-only `web/deepseek-search-llm-request` session event. It contains the resolved endpoint, API version, and exact secret-free JSON body sent to DeepSeek; headers and credentials are excluded. Credential failures and cancellations before dispatch create no event, while later HTTP or response failures leave the attempted request durable. Direct programmatic provider calls outside an Agent have no initiating session to log.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Auxiliary DeepSeek search request
|
||||
@@ -61,7 +66,7 @@ Independent of the conversation request cache. The auxiliary instruction and nat
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures are `DeepSeek search aborted`, `DeepSeek search request failed: <error>`, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: <error>`; HTTP failures preserve the provider message. The consumer owns the error wrapper.
|
||||
Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures include the actionable missing-credential message, `DeepSeek search credential resolution failed: <error>`, `DeepSeek search aborted`, `DeepSeek search request failed: <error>`, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: <error>`; HTTP failures preserve the provider message. The consumer owns the error wrapper.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -74,6 +79,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One search costs a full Messages model turn** — latency plus generated tokens, with up to `maxUses` server-side searches; DeepSeek exposes no dedicated retrieval endpoint.
|
||||
- **Dynamic credential availability resolves inside the operation** — the synchronous `available()` contract can establish that a resolver exists but cannot query an asynchronous credential store. A selected keyless provider therefore fails the search with `WEB_PROVIDER_CREDENTIAL_MISSING`; the stable `web_search` schema remains registered. Caller cancellation races this preflight locally, but cannot force an arbitrary credential backend itself to stop work.
|
||||
- **Over-returned sources still cost tokens** — with no result-count knob on the wire, `maxResults` is enforced only post-hoc by seam truncation.
|
||||
- **Uncited results carry no `snippet`** — a source gains one only when a `text` block citation (`cited_text`) matches its URL.
|
||||
- **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (e.g. `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
由 [DeepSeek](https://deepseek.com) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 DeepSeek 的 **Anthropic 兼容 Messages API**(`POST {baseURL}/messages`),启用原生 `web_search_20250305` 服务器工具,并把 DeepSeek 返回的结构化 `web_search_tool_result` 块映射为 seam 规范化的 `WebSearchResult`。
|
||||
|
||||
这是一个**实现**包(package):它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。
|
||||
这是一个**实现**包(package):它向 `ctx.web` 注册提供方,通过可选的 `ctx.credentials` seam 为每次搜索解析凭据,若存在发起请求的 agent(智能体)会话,还会在其中记录该辅助请求,且不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。
|
||||
|
||||
## 与专用搜索端点的区别
|
||||
|
||||
@@ -12,13 +12,14 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方
|
||||
|
||||
**严格模式**:如果响应不含 `web_search_tool_result` 块(未触发原生搜索),提供方会抛出 `WebError` `WEB_PROVIDER_ERROR`,而非降级为文本抓取;这种行为诚实且可诊断。
|
||||
|
||||
它复用 `$DEEPSEEK_API_KEY`(不增加密钥),但**不会**复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`),不同于大语言模型(LLM)适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。
|
||||
它复用 `DEEPSEEK_API_KEY` 凭据引用(不增加密钥),但**不会**复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`),不同于大语言模型(LLM)适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。已挂载的凭据服务具有权威性;没有该服务时,提供方会回退到启动进程的环境变量。每次搜索都会解析该引用,因此在 Web 的 Models 页中存储或轮换的密钥无需重启,即可用于下一次调用。
|
||||
|
||||
## 配置
|
||||
|
||||
| 配置键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API 密钥。为空或缺失时提供方不可用。同时通过 `x-api-key` 和 `Authorization: Bearer` 发送(分别用于官方接口与 Anthropic 兼容代理)。 |
|
||||
| `apiKey` | 未设置 | DeepSeek API 密钥字面值。优先使用 `apiKeyEnv`,避免密钥进入配置;非空字面值优先。 |
|
||||
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次搜索都会通过 `ctx.credentials` 解析该凭据引用;没有该 seam 时则从进程环境解析。值缺失时,调用以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 |
|
||||
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。覆盖时使用 `$DEEPSEEK_SEARCH_BASE_URL` 等独立环境变量;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 |
|
||||
| `model` | `deepseek-v4-flash` | Anthropic 格式模型名称。 |
|
||||
| `apiVersion` | `2023-06-01` | `anthropic-version` 标头值。 |
|
||||
@@ -29,7 +30,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方
|
||||
- id: web-search-deepseek
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
|
||||
```
|
||||
|
||||
@@ -41,6 +42,10 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
|
||||
|
||||
提供方失败变为 `WEB_PROVIDER_ERROR`;调用方取消变为 `WEB_ABORTED`。HTTP 重定向会在接触 `Location` 目标前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。
|
||||
|
||||
## 请求日志
|
||||
|
||||
由 agent 发起的搜索会在发出请求前一刻,向相应会话追加仅用于日志的 `web/deepseek-search-llm-request` 会话事件。其中包含已解析端点、API 版本,以及发送给 DeepSeek 且不含密钥的精确 JSON 请求体;不包含标头和凭据。发出请求前发生凭据处理失败或取消时不会创建事件;发出请求后才发生 HTTP 或响应失败时,本次请求尝试仍保留持久记录。在 agent 之外通过程序直接调用提供方时,没有发起会话可供记录。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 辅助 DeepSeek 搜索请求
|
||||
@@ -61,7 +66,7 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体错误消息为 `DeepSeek search aborted`、`DeepSeek search request failed: <error>`、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: <error>`;HTTP 失败保留提供方消息。错误包装属于消费方。
|
||||
通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体错误消息包括带有处理指引的凭据缺失消息、`DeepSeek search credential resolution failed: <error>`、`DeepSeek search aborted`、`DeepSeek search request failed: <error>`、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: <error>`;HTTP 失败保留提供方消息。错误包装属于消费方。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -74,6 +79,6 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **一次搜索需要完整的 Messages 模型轮次**:会产生延迟与生成 token,并且最多执行 `maxUses` 次服务器侧搜索;DeepSeek 不公开专用检索端点。
|
||||
- **动态凭据的可用性在操作内部解析**:同步的 `available()` 契约可以确认解析器存在,但无法查询异步凭据存储。因此,选中的无密钥提供方会使搜索以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败;稳定的 `web_search` schema 仍保持注册。调用方取消在本地与该预检存在竞态,但无法强制任意凭据后端自行停止工作。
|
||||
- **超量返回的源仍消耗 token**:协议没有结果数量旋钮,`maxResults` 只能由 seam 在事后截断。
|
||||
- **未引用的结果没有 `snippet`**:只有 `text` 块中的引用(`cited_text`)匹配其 URL 时,源才会获得 snippet。
|
||||
- **中止分类基于错误结构**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。
|
||||
|
||||
@@ -27,7 +27,10 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-credentials": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-web": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -35,7 +38,11 @@
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-web": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type {} from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-web'
|
||||
import {
|
||||
DeepSeekSearchProvider,
|
||||
@@ -26,7 +29,7 @@ export {
|
||||
DEEPSEEK_DEFAULT_MODEL,
|
||||
DEEPSEEK_PROVIDER_ID,
|
||||
} from './provider.ts'
|
||||
export type { DeepSeekSearchProviderOptions } from './provider.ts'
|
||||
export type { DeepSeekSearchLlmRequest, DeepSeekSearchProviderOptions } from './provider.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'web-search-deepseek'
|
||||
@@ -34,10 +37,14 @@ export const name = 'web-search-deepseek'
|
||||
/** The web seam this provider registers into. */
|
||||
export const inject = ['web']
|
||||
|
||||
const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
|
||||
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */
|
||||
/** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
|
||||
apiKey?: string
|
||||
/** Credential reference resolved for each search; defaults to `DEEPSEEK_API_KEY`. */
|
||||
apiKeyEnv?: string
|
||||
/** Anthropic-compatible endpoint base; `/messages` is appended. */
|
||||
baseURL?: string
|
||||
/** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */
|
||||
@@ -51,7 +58,8 @@ export interface Config {
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
apiKey: z.string(),
|
||||
apiKey: z.string().role('secret'),
|
||||
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
|
||||
baseURL: z.string(),
|
||||
model: z.string(),
|
||||
apiVersion: z.string(),
|
||||
@@ -63,12 +71,29 @@ export const Config: z<Config> = z.object({
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS
|
||||
const maxUses = config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES
|
||||
const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV)
|
||||
const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0
|
||||
? config.apiKey
|
||||
: undefined
|
||||
ctx.web.registerSearchProvider(new DeepSeekSearchProvider({
|
||||
apiKey: config.apiKey ?? process.env.DEEPSEEK_API_KEY ?? '',
|
||||
...literalApiKey === undefined ? {} : { apiKey: literalApiKey },
|
||||
resolveApiKey: async () => {
|
||||
const credentials = ctx.get('credentials')
|
||||
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value
|
||||
const ambient = process.env[apiKeyEnv]
|
||||
return ambient !== undefined && ambient.length > 0 ? ambient : undefined
|
||||
},
|
||||
apiKeyEnv,
|
||||
baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
|
||||
model: config.model ?? DEEPSEEK_DEFAULT_MODEL,
|
||||
apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,
|
||||
maxTokens,
|
||||
maxUses,
|
||||
recordRequest: (request) => {
|
||||
ctx.get('agents')?.currentInitiator()?.session.append(
|
||||
'web/deepseek-search-llm-request',
|
||||
request,
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ export const name = 'web-search-deepseek-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
* No runtime invariant: the package emits a pre-dispatch log event but owns no
|
||||
* later authoritative dispatch event to relate it to. Exact envelope equality
|
||||
* is pinned at the provider boundary instead.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
WebSearchResult,
|
||||
WebSearchSource,
|
||||
} from '@deepseek-ai/dsh-web'
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type {} from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
AnthropicError,
|
||||
AnthropicResponse,
|
||||
@@ -47,10 +49,49 @@ export const DEEPSEEK_DEFAULT_MAX_USES = 5
|
||||
/** Attribution header sent on every request. Bump with the package version. */
|
||||
const USER_AGENT = 'deepseek-harness/0.0.1'
|
||||
|
||||
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
|
||||
/**
|
||||
* Exact secret-free DeepSeek Messages request recorded immediately before one
|
||||
* auxiliary search dispatch.
|
||||
*/
|
||||
export interface DeepSeekSearchLlmRequest {
|
||||
/** Fully resolved Messages endpoint. */
|
||||
readonly endpoint: string
|
||||
/** `anthropic-version` header value. */
|
||||
readonly apiVersion: string
|
||||
/** Exact JSON body sent to the provider. */
|
||||
readonly body: {
|
||||
readonly model: string
|
||||
readonly max_tokens: number
|
||||
readonly messages: readonly [{
|
||||
readonly role: 'user'
|
||||
readonly content: readonly [{
|
||||
readonly type: 'text'
|
||||
readonly text: string
|
||||
}]
|
||||
}]
|
||||
readonly tools: readonly [{
|
||||
readonly type: 'web_search_20250305'
|
||||
readonly name: 'web_search'
|
||||
readonly max_uses: number
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/** Secret-free auxiliary DeepSeek search request recorded before dispatch. */
|
||||
'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved provider options (the plugin's `apply` supplies credential and constant defaults). */
|
||||
export interface DeepSeekSearchProviderOptions {
|
||||
/** DeepSeek API key. Empty/absent makes the provider unavailable. */
|
||||
apiKey: string
|
||||
/** Literal DeepSeek API key; when present it wins over {@link resolveApiKey}. */
|
||||
apiKey?: string
|
||||
/** Resolve the current DeepSeek API key for one search operation. */
|
||||
resolveApiKey?: () => Promise<string | undefined>
|
||||
/** Credential reference named by missing-credential diagnostics. */
|
||||
apiKeyEnv?: CredentialRef
|
||||
/** Endpoint base; `/messages` is appended. */
|
||||
baseURL: string
|
||||
/** Anthropic-format model name. */
|
||||
@@ -61,6 +102,11 @@ export interface DeepSeekSearchProviderOptions {
|
||||
maxTokens: number
|
||||
/** Maximum `web_search` server-tool uses per request. */
|
||||
maxUses: number
|
||||
/**
|
||||
* Record the exact secret-free request immediately before dispatch. A throw
|
||||
* prevents dispatch so model-visible auxiliary input cannot escape logging.
|
||||
*/
|
||||
recordRequest?: (request: DeepSeekSearchLlmRequest) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,41 +180,51 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
constructor(private readonly options: DeepSeekSearchProviderOptions) {}
|
||||
|
||||
available(): boolean {
|
||||
return this.options.apiKey.length > 0
|
||||
return ((this.options.apiKey?.length ?? 0) > 0 || this.options.resolveApiKey !== undefined)
|
||||
&& URL.canParse(this.options.baseURL)
|
||||
&& isPositiveInteger(this.options.maxTokens)
|
||||
&& isPositiveInteger(this.options.maxUses)
|
||||
}
|
||||
|
||||
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
|
||||
const apiKey = await this.apiKey(signal)
|
||||
throwIfSearchAborted(signal)
|
||||
const endpoint = `${this.options.baseURL}/messages`
|
||||
const body: DeepSeekSearchLlmRequest['body'] = {
|
||||
model: this.options.model,
|
||||
max_tokens: this.options.maxTokens,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }],
|
||||
}],
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
|
||||
}
|
||||
this.options.recordRequest?.({
|
||||
endpoint,
|
||||
apiVersion: this.options.apiVersion,
|
||||
body,
|
||||
})
|
||||
throwIfSearchAborted(signal)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${this.options.baseURL}/messages`, {
|
||||
response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
// Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy
|
||||
// may expect `Authorization: Bearer` — send both so either resolves.
|
||||
'x-api-key': this.options.apiKey,
|
||||
'authorization': `Bearer ${this.options.apiKey}`,
|
||||
'x-api-key': apiKey,
|
||||
'authorization': `Bearer ${apiKey}`,
|
||||
'anthropic-version': this.options.apiVersion,
|
||||
'content-type': 'application/json',
|
||||
'accept': 'application/json',
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.options.model,
|
||||
max_tokens: this.options.maxTokens,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }],
|
||||
}],
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
...signal !== undefined ? { signal } : {},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
|
||||
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
|
||||
throw new WebError(`DeepSeek search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
||||
}
|
||||
|
||||
@@ -183,7 +239,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
// An abort fired mid-body must surface as WEB_ABORTED, not be swallowed
|
||||
// into a generic HTTP-error message — cancellation is not a provider
|
||||
// error (the seam's cancellation contract).
|
||||
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
|
||||
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
|
||||
// Otherwise: the HTTP status is already captured in `message` above; a
|
||||
// malformed/non-JSON error body (normal for gateway 5xx/429s) can only
|
||||
// cost a richer provider message, never the real error.
|
||||
@@ -195,11 +251,72 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
|
||||
const payload = await response.json() as AnthropicResponse
|
||||
return mapAnthropicResponse(payload)
|
||||
} catch (error: unknown) {
|
||||
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
|
||||
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
|
||||
if (error instanceof WebError) throw error
|
||||
throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve one operation's credential without retaining it on the provider. */
|
||||
private async apiKey(signal?: AbortSignal): Promise<string> {
|
||||
throwIfSearchAborted(signal)
|
||||
if (this.options.apiKey !== undefined && this.options.apiKey.length > 0) return this.options.apiKey
|
||||
let resolved: string | undefined
|
||||
try {
|
||||
resolved = await abortable(this.options.resolveApiKey?.() ?? Promise.resolve(undefined), signal)
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
|
||||
throw new WebError(
|
||||
`DeepSeek search credential resolution failed: ${String(error)}`,
|
||||
'WEB_PROVIDER_ERROR',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
if (resolved !== undefined && resolved.length > 0) return resolved
|
||||
const ref = this.options.apiKeyEnv ?? 'DEEPSEEK_API_KEY'
|
||||
throw new WebError(
|
||||
`DeepSeek search has no API key for "${ref}"; store it through the credentials service`
|
||||
+ ' (the web Models page writes it), export it in the launching environment, or set a literal'
|
||||
+ ' "apiKey" in the web-search-deepseek config',
|
||||
'WEB_PROVIDER_CREDENTIAL_MISSING',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Race a same-process asynchronous preflight against caller cancellation. The
|
||||
* attached settlement handlers keep observing an uncooperative operation after
|
||||
* abort so a later rejection cannot become unhandled.
|
||||
*/
|
||||
function abortable<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (signal === undefined) return operation
|
||||
if (signal.aborted) return Promise.reject(searchAborted(signal))
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => { reject(searchAborted(signal)) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(new Error(String(error).replace(/^Error: /u, ''), { cause: error }))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Throw the provider's stable cancellation error when the caller already aborted. */
|
||||
function throwIfSearchAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted === true) throw searchAborted(signal)
|
||||
}
|
||||
|
||||
/** Build the provider's stable cancellation error while retaining the caller's reason. */
|
||||
function searchAborted(signal?: AbortSignal, fallback?: unknown): WebError {
|
||||
return new WebError('DeepSeek search aborted', 'WEB_ABORTED', {
|
||||
cause: signal?.aborted === true ? signal.reason : fallback,
|
||||
})
|
||||
}
|
||||
|
||||
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import CredentialsLocal from '@deepseek-ai/dsh-credentials-local'
|
||||
import WebService from '@deepseek-ai/dsh-web'
|
||||
import {
|
||||
DeepSeekSearchProvider,
|
||||
@@ -156,10 +161,11 @@ describe('DeepSeekSearchProvider availability', () => {
|
||||
})
|
||||
|
||||
describe('DeepSeekSearchProvider request mapping', () => {
|
||||
it('posts an Anthropic Messages request enabling the web_search server tool', async () => {
|
||||
it('records and posts the same Anthropic Messages request with the web_search server tool', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
|
||||
const recordRequest = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await new DeepSeekSearchProvider(options).search({ query: 'hello' })
|
||||
await new DeepSeekSearchProvider({ ...options, recordRequest }).search({ query: 'hello' })
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages')
|
||||
expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
|
||||
@@ -167,12 +173,20 @@ describe('DeepSeekSearchProvider request mapping', () => {
|
||||
expect(headers['x-api-key']).toBe('ds-key')
|
||||
expect(headers['authorization']).toBe('Bearer ds-key')
|
||||
expect(headers['anthropic-version']).toBe('2023-06-01')
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
const body = {
|
||||
model: 'deepseek-chat',
|
||||
max_tokens: 4096,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'Perform a web search for the query: hello' }] }],
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }],
|
||||
}
|
||||
expect(JSON.parse(init.body as string)).toEqual(body)
|
||||
expect(recordRequest).toHaveBeenCalledOnce()
|
||||
expect(recordRequest).toHaveBeenCalledWith({
|
||||
endpoint: url,
|
||||
apiVersion: '2023-06-01',
|
||||
body,
|
||||
})
|
||||
expect(recordRequest.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder[0] ?? 0)
|
||||
})
|
||||
|
||||
it('forwards the abort signal', async () => {
|
||||
@@ -186,6 +200,91 @@ describe('DeepSeekSearchProvider request mapping', () => {
|
||||
})
|
||||
|
||||
describe('DeepSeekSearchProvider error handling', () => {
|
||||
it('does not start credential resolution or dispatch for a pre-aborted call', async () => {
|
||||
const resolveApiKey = vi.fn(async () => 'late-key')
|
||||
const recordRequest = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('caller stopped'))
|
||||
await expect(new DeepSeekSearchProvider({
|
||||
...options,
|
||||
apiKey: '',
|
||||
resolveApiKey,
|
||||
recordRequest,
|
||||
}).search({ query: 'q' }, controller.signal))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
||||
expect(resolveApiKey).not.toHaveBeenCalled()
|
||||
expect(recordRequest).not.toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts while an uncooperative credential resolver remains pending', async () => {
|
||||
const resolveApiKey = vi.fn(() => new Promise<string>(() => {}))
|
||||
const recordRequest = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const controller = new AbortController()
|
||||
const search = new DeepSeekSearchProvider({
|
||||
...options,
|
||||
apiKey: '',
|
||||
resolveApiKey,
|
||||
recordRequest,
|
||||
}).search({ query: 'q' }, controller.signal)
|
||||
controller.abort(new Error('deadline'))
|
||||
await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
||||
expect(resolveApiKey).toHaveBeenCalledOnce()
|
||||
expect(recordRequest).not.toHaveBeenCalled()
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves credentials under an active cancellation signal', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const controller = new AbortController()
|
||||
await expect(new DeepSeekSearchProvider({
|
||||
...options,
|
||||
apiKey: '',
|
||||
resolveApiKey: async () => 'resolved-key',
|
||||
}).search({ query: 'q' }, controller.signal)).resolves.toMatchObject({ truncated: false })
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect((init.headers as Record<string, string>)['x-api-key']).toBe('resolved-key')
|
||||
})
|
||||
|
||||
it('maps a credential resolver rejection under an active signal to WEB_PROVIDER_ERROR', async () => {
|
||||
const controller = new AbortController()
|
||||
await expect(new DeepSeekSearchProvider({
|
||||
...options,
|
||||
apiKey: '',
|
||||
resolveApiKey: () => Promise.reject(new Error('credential backend failed')),
|
||||
}).search({ query: 'q' }, controller.signal))
|
||||
.rejects.toThrow(expect.objectContaining({
|
||||
code: 'WEB_PROVIDER_ERROR',
|
||||
message: 'DeepSeek search credential resolution failed: Error: credential backend failed',
|
||||
}))
|
||||
})
|
||||
|
||||
it('uses the default credential reference when no resolver is configured', async () => {
|
||||
await expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).search({ query: 'q' }))
|
||||
.rejects.toThrow('DeepSeek search has no API key for "DEEPSEEK_API_KEY"')
|
||||
})
|
||||
|
||||
it('observes cancellation triggered synchronously by credential resolution', async () => {
|
||||
const controller = new AbortController()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(new DeepSeekSearchProvider({
|
||||
...options,
|
||||
apiKey: '',
|
||||
resolveApiKey: () => {
|
||||
controller.abort(new Error('resolver cancelled caller'))
|
||||
return Promise.resolve('unused-key')
|
||||
},
|
||||
}).search({ query: 'q' }, controller.signal))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'rate limited' } }, { status: 429 })))
|
||||
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
|
||||
@@ -216,6 +315,17 @@ describe('DeepSeekSearchProvider error handling', () => {
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
||||
})
|
||||
|
||||
it('maps a custom abort reason to WEB_ABORTED', async () => {
|
||||
const controller = new AbortController()
|
||||
vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => { reject(new Error('custom abort reason')) }, { once: true })
|
||||
})))
|
||||
const search = new DeepSeekSearchProvider(options).search({ query: 'q' }, controller.signal)
|
||||
controller.abort(new Error('timeout reason'))
|
||||
await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
||||
})
|
||||
|
||||
it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 })))
|
||||
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
|
||||
@@ -323,28 +433,66 @@ describe('web-search-deepseek plugin registration', () => {
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(deepseekPlugin, {})
|
||||
deepseekPlugin.apply(ctx, {})
|
||||
await ctx.web.search({ query: 'q' })
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages')
|
||||
expect((init.headers as Record<string, string>)['x-api-key']).toBe('env-key')
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({ model: 'deepseek-v4-flash' })
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.DEEPSEEK_API_KEY
|
||||
else process.env.DEEPSEEK_API_KEY = prev
|
||||
}
|
||||
})
|
||||
|
||||
it('is unavailable when neither config nor env supplies a key', async () => {
|
||||
it('resolves the credential for each search so a stored or rotated key needs no restart', async () => {
|
||||
const previous = process.env.DEEPSEEK_API_KEY
|
||||
delete process.env.DEEPSEEK_API_KEY
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-web-search-credentials-'))
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse(searchResponse()))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
||||
await ctx.plugin(deepseekPlugin, { baseURL: 'https://api.deepseek.test/anthropic/v1' })
|
||||
|
||||
await expect(ctx.web.search({ query: 'missing' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' }))
|
||||
|
||||
const ref = credentialRef('DEEPSEEK_API_KEY')
|
||||
await ctx.credentials.set(ref, 'stored-key')
|
||||
await ctx.web.search({ query: 'stored' })
|
||||
await ctx.credentials.set(ref, 'rotated-key')
|
||||
await ctx.web.search({ query: 'rotated' })
|
||||
|
||||
const headers = fetchMock.mock.calls.map(([, init]) => (init as RequestInit).headers as Record<string, string>)
|
||||
expect(headers.map(value => value['x-api-key'])).toEqual(['stored-key', 'rotated-key'])
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
if (previous === undefined) delete process.env.DEEPSEEK_API_KEY
|
||||
else process.env.DEEPSEEK_API_KEY = previous
|
||||
}
|
||||
})
|
||||
|
||||
it('reports an actionable credential error when neither config nor env supplies a key', async () => {
|
||||
const prev = process.env.DEEPSEEK_API_KEY
|
||||
delete process.env.DEEPSEEK_API_KEY
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
await ctx.plugin(deepseekPlugin, {})
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
|
||||
let caught: unknown
|
||||
try {
|
||||
await ctx.web.search({ query: 'q' })
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toMatchObject({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' })
|
||||
if (!(caught instanceof Error)) throw new Error('search did not throw an Error')
|
||||
expect(caught.message).toMatch(/store it through the credentials service.*Models page/s)
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev
|
||||
}
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
{
|
||||
"path": "../web"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user