Merge remote-tracking branch 'origin/master' into worktree/fix-1463-rich-content-bridge

This commit is contained in:
Tianyi Cui
2026-08-17 13:56:17 +08:00
106 changed files with 2593 additions and 559 deletions

View File

@@ -216,7 +216,7 @@ export class AgentPresetSettingsController {
// The roster says what may be chosen; `settings.describe` says whether
// this browser may write the choice down. A non-loopback browser reaches
// neither method, so a refused describe leaves the row read-only rather
// than offering a control whose write answers `settings-not-exposed`.
// than offering a control whose write the Host would refuse.
const described = await this.api.settings.describe({})
this.set({
status: 'ready',

View File

@@ -67,8 +67,8 @@ describe('the agent-preset settings controller', () => {
await controller.load()
// `settings.describe` is loopback-only and reports a read-only provider;
// offering a control whose write answers `settings-not-exposed` would
// promise a switch the host refuses.
// offering a control whose write answers `settings-rejected` would promise
// a switch the host refuses.
expect(controller.store.getSnapshot().writable).toBe(false)
expect(controller.store.getSnapshot().currentValue).toBe('standard')
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: ecf463da619662fe158511079e7773ee3c627ac8
README.zh.md: f8922b160cdc6feca94dea998163c25d803a5535
README.md: d1a265b5789d9f1d9b5e630e0548ae5f619eebbf
README.zh.md: 3f303391d39bc040b4a6a5a2d1f6a34fe8891919

View File

@@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with a loaded `compaction/summary` event shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when the cited `compaction/summary` event is outside the loaded window, the checkpoint remains visible but non-expandable.
The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Safari alone receives a pre-paint recovery when a native edit shortens the draft and leaves stale soft-wrap overflow; draft growth, programmatic updates, and other browsers never read layout for that recovery ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)).
Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model-selection, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite.

View File

@@ -6,7 +6,7 @@
压缩compaction在检查点自身的消息流位置渲染为一行折叠标记不替换其上方的 transcript文本记录。自动压缩使用「上下文已压缩」标题。每个已加载对应 `compaction/summary` 事件的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩context compaction图标仅在悬停或键盘聚焦时将其替换为收起展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染被引用的 `compaction/summary` 事件位于已加载窗口之外时,检查点仍然可见但不可展开。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口textarea 保持只读且支持键盘操作。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero编辑器子树首个会话到达时彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏作为普通列 chrome仅显示当前会话标题和视图标签fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。该滚动容器无条件预留自己的滚动条槽选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口textarea 保持只读且支持键盘操作。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero编辑器子树首个会话到达时彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏作为普通列 chrome仅显示当前会话标题和视图标签fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。该滚动容器无条件预留自己的滚动条槽选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。只有 Safari 会在原生编辑缩短草稿并留下陈旧软换行溢出时执行绘制前恢复;草稿增长、程序化更新与其他浏览器都不会为这项恢复读取布局([决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md))。
别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block输入栏就渲染同一个禁用的 textarea并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好知道某会话发不出消息的插件ui-model-selection在没有适配器服务其路由时本就依赖本包因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份约定里的每个 block 都靠选模型来解除把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它无法路由的提示词。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。

View File

@@ -6,7 +6,7 @@
* region-slot content) ride the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import {
@@ -31,6 +31,7 @@ import {
} from '../image-labels.ts'
import { ContextMeter } from './ContextMeter.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts'
import css from './InputBar.module.css'
/** Decoration product of the no-session state (no machine, empty draft). */
@@ -106,6 +107,8 @@ export function InputBar({
const dragDepthRef = useRef(0)
const scrollRef = useRef<HTMLDivElement | null>(null)
const mirrorRef = useRef<HTMLDivElement | null>(null)
const safari = useMemo(() => isSafariBrowser(navigator), [])
const safariNativeShrinkRef = useRef(false)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
// clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
const composingRef = useRef(false)
@@ -154,6 +157,18 @@ export function InputBar({
}
}, [attachments, input?.imageIds, inputActions])
// A native Safari edit that shortens the draft may leave the previous
// soft-wrap layout behind after the mirror shrinks. The native-change signal
// keeps ordinary typing and programmatic draft updates from reading layout;
// the helper then repairs only measured overflow before paint while
// preserving native editing state. See
// .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md.
useLayoutEffect(() => {
const nativeShrink = safariNativeShrinkRef.current
safariNativeShrinkRef.current = false
if (safari && nativeShrink) repairSafariTextareaLayout(inputRef.current)
}, [draft, safari])
useEffect(() => {
if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null)
}, [attachments, preview])
@@ -343,6 +358,7 @@ export function InputBar({
if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
const next = e.target.value
safariNativeShrinkRef.current = safari && next.length < draft.length
keyboard.setDraft(next)
// selectionStart is number|null in lib.dom; the type-aware lint program narrows it.
// oxlint-disable-next-line typescript/no-unnecessary-condition

View File

@@ -0,0 +1,42 @@
/** Safari-specific textarea layout recovery for the conversation composer. */
/** Browser identity fields needed to distinguish Safari from other WebKit-based browsers. */
export interface BrowserIdentity {
readonly userAgent: string
readonly vendor: string
}
const ALTERNATE_IOS_BROWSER = /\b(?:CriOS|FxiOS|EdgiOS|OPiOS|OPT|DuckDuckGo|Brave)(?:\/|\b)/
/**
* Detect Safari's `Version/... Safari/...` form while excluding known alternate iOS browser tokens.
* @param identity - Browser user-agent and vendor values.
* @returns Whether the identity should use the Safari-specific recovery.
*/
export function isSafariBrowser(identity: BrowserIdentity): boolean {
return identity.vendor === 'Apple Computer, Inc.'
&& /\bVersion\/[\d.]+.*\bSafari\/[\d.]+/.test(identity.userAgent)
&& !ALTERNATE_IOS_BROWSER.test(identity.userAgent)
}
/**
* Repair Safari's stale native textarea layout and the scrollport auto height it can contaminate.
* @param input - Composer textarea whose own scrollable overflow must stay zero.
*/
export function repairSafariTextareaLayout(input: HTMLTextAreaElement | null): void {
if (input === null || input.scrollHeight <= input.clientHeight) return
const scrollport = input.closest<HTMLElement>('[data-input-scroll]')
if (scrollport === null) return
const inputHeight = input.style.height
input.style.height = `${String(input.clientHeight + 1)}px`
void input.offsetHeight
input.style.height = inputHeight
void input.offsetHeight
const scrollportHeight = scrollport.style.height
scrollport.style.height = `${String(scrollport.clientHeight + 1)}px`
void scrollport.offsetHeight
scrollport.style.height = scrollportHeight
void scrollport.offsetHeight
}

View File

@@ -824,6 +824,116 @@ describe('running and lock semantics', () => {
expect(backdrop.textContent).toBe('line\n'.repeat(40))
})
it('repairs Safari native overflow after the mirror shrinks the draft', () => {
const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
)
onTestFinished(() => {
vendor.mockRestore()
userAgent.mockRestore()
})
const { textarea } = bench({ draft: 'two wrapped lines' })
const scrollport = textarea.closest<HTMLElement>('[data-input-scroll]')!
let inputRepaired = false
let scrollportRepaired = false
const inputLayouts: string[] = []
const scrollportLayouts: string[] = []
Object.defineProperty(textarea, 'clientHeight', {
configurable: true,
get: () => textarea.style.height === '29px' ? 29 : 28,
})
Object.defineProperty(textarea, 'scrollHeight', {
configurable: true,
get: () => inputRepaired ? 28 : 52,
})
Object.defineProperty(textarea, 'offsetHeight', {
configurable: true,
get: () => {
inputLayouts.push(textarea.style.height)
if (textarea.style.height === '') inputRepaired = true
return textarea.clientHeight
},
})
Object.defineProperty(scrollport, 'clientHeight', {
configurable: true,
get: () => {
if (scrollport.style.height === '53px') return 53
if (inputRepaired && !scrollportRepaired) return 52
return 28
},
})
Object.defineProperty(scrollport, 'offsetHeight', {
configurable: true,
get: () => {
scrollportLayouts.push(scrollport.style.height)
if (scrollport.style.height === '') scrollportRepaired = true
return scrollport.clientHeight
},
})
textarea.setSelectionRange(5, 5)
fireEvent.change(textarea, { target: { value: 'one line' } })
expect(inputLayouts).toEqual(['29px', ''])
expect(scrollportLayouts).toEqual(['53px', ''])
expect(textarea.style.height).toBe('')
expect(scrollport.style.height).toBe('')
expect(textarea.scrollHeight).toBe(textarea.clientHeight)
expect(scrollport.clientHeight).toBe(28)
})
it('does not force the Safari recovery for another iOS browser', () => {
const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1',
)
onTestFinished(() => {
vendor.mockRestore()
userAgent.mockRestore()
})
const { textarea } = bench({ draft: 'two wrapped lines' })
const scrollport = textarea.closest<HTMLElement>('[data-input-scroll]')!
Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 28 })
Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 52 })
Object.defineProperty(textarea, 'offsetHeight', {
configurable: true,
get: () => { throw new Error('non-Safari browser must not force textarea layout') },
})
Object.defineProperty(scrollport, 'offsetHeight', {
configurable: true,
get: () => { throw new Error('non-Safari browser must not force scrollport layout') },
})
fireEvent.change(textarea, { target: { value: 'one line' } })
expect(scrollport.style.height).toBe('')
})
it('does not read Safari layout while a native edit grows the draft', () => {
const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.')
const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
)
onTestFinished(() => {
vendor.mockRestore()
userAgent.mockRestore()
})
const { textarea, shell } = bench({ draft: 'one line' })
Object.defineProperty(textarea, 'clientHeight', {
configurable: true,
get: () => { throw new Error('growing Safari input must not read layout') },
})
Object.defineProperty(textarea, 'scrollHeight', {
configurable: true,
get: () => { throw new Error('growing Safari input must not read layout') },
})
fireEvent.change(textarea, { target: { value: 'one line grows' } })
expect(shell.snapshot.draft).toBe('one line grows')
})
it('an edit the composer performs itself scrolls the caret back into view', async () => {
// Paste and cut suppress the native edit, so no engine reveals the caret
// for them. jsdom has no layout: the rects are stubbed,

View File

@@ -0,0 +1,131 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { isSafariBrowser, repairSafariTextareaLayout } from '../src/client/skeleton/safari.ts'
describe('Safari browser detection', () => {
it.each([
{
name: 'desktop Safari',
vendor: 'Apple Computer, Inc.',
userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15',
expected: true,
},
{
name: 'mobile Safari',
vendor: 'Apple Computer, Inc.',
userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1',
expected: true,
},
{
name: 'desktop Chromium',
vendor: 'Google Inc.',
userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
expected: false,
},
{
name: 'Chrome on iOS',
vendor: 'Apple Computer, Inc.',
userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1',
expected: false,
},
{
name: 'Edge on iOS with Safari tokens',
vendor: 'Apple Computer, Inc.',
userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 EdgiOS/140.0 Mobile/15E148 Safari/604.1',
expected: false,
},
{
name: 'Opera on iOS with Safari tokens',
vendor: 'Apple Computer, Inc.',
userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 OPiOS/6.0 Mobile/15E148 Safari/604.1',
expected: false,
},
{
name: 'Apple web view',
vendor: 'Apple Computer, Inc.',
userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148',
expected: false,
},
])('identifies $name', ({ vendor, userAgent, expected }) => {
expect(isSafariBrowser({ vendor, userAgent })).toBe(expected)
})
})
describe('Safari textarea layout recovery', () => {
it('does nothing while the textarea owns no scrollable overflow', () => {
const input = document.createElement('textarea')
Object.defineProperty(input, 'clientHeight', { value: 28 })
Object.defineProperty(input, 'scrollHeight', { value: 28 })
repairSafariTextareaLayout(input)
expect(input.style.height).toBe('')
})
it('invalidates a stale native layout and restores the owned height', () => {
const input = document.createElement('textarea')
const scrollport = document.createElement('div')
scrollport.setAttribute('data-input-scroll', '')
scrollport.appendChild(input)
input.value = 'abcdef'
input.setSelectionRange(3, 3)
input.style.height = '100%'
scrollport.style.height = '100%'
let inputRepaired = false
let scrollportRepaired = false
const inputLayouts: string[] = []
const scrollportLayouts: string[] = []
Object.defineProperty(input, 'clientHeight', {
get: () => input.style.height === '29px' ? 29 : 28,
})
Object.defineProperty(input, 'scrollHeight', {
get: () => inputRepaired ? 28 : 52,
})
Object.defineProperty(input, 'offsetHeight', {
get: () => {
inputLayouts.push(input.style.height)
if (input.style.height === '100%') inputRepaired = true
return input.clientHeight
},
})
Object.defineProperty(scrollport, 'clientHeight', {
get: () => {
if (scrollport.style.height === '53px') return 53
if (inputRepaired && !scrollportRepaired) return 52
return 28
},
})
Object.defineProperty(scrollport, 'offsetHeight', {
get: () => {
scrollportLayouts.push(scrollport.style.height)
if (scrollport.style.height === '100%') scrollportRepaired = true
return scrollport.clientHeight
},
})
repairSafariTextareaLayout(input)
expect(inputLayouts).toEqual(['29px', '100%'])
expect(scrollportLayouts).toEqual(['53px', '100%'])
expect(input.style.height).toBe('100%')
expect(scrollport.style.height).toBe('100%')
expect(input.scrollHeight).toBe(input.clientHeight)
expect(scrollport.clientHeight).toBe(28)
expect([input.selectionStart, input.selectionEnd]).toEqual([3, 3])
})
it('does nothing outside the composer scrollport', () => {
const input = document.createElement('textarea')
Object.defineProperty(input, 'clientHeight', { value: 28 })
Object.defineProperty(input, 'scrollHeight', { value: 52 })
repairSafariTextareaLayout(input)
expect(input.style.height).toBe('')
})
it('accepts an absent textarea during teardown', () => {
expect(() => { repairSafariTextareaLayout(null) }).not.toThrow()
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md
README.md: 53418f8939649bae4413625a37630e46e7060d15
README.zh.md: 8d4a2bf8d1af662e9f8e05ccc607cc8b66731731
README.md: 92ba426b6bee3ae73e184dfcfd418e889bd47086
README.zh.md: dc319ea9f128016b6df821575cb4d75740f04579

View File

@@ -6,13 +6,13 @@ The **Plugins** settings section and its **Plugin configuration** tab. The secti
## What appears here
A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the configurable tab reflects what this deployment actually runs.
The configurable tab reads which settings namespaces the Host serves and dispatches one slot key per namespace, so what renders is the intersection of two ledgers: the namespaces a live Host plugin registered, and the cards registered under those keys. A served namespace no card claims renders nothing — another surface owns it, or this deployment ships no browser half for it — and a card whose namespace this deployment does not serve is never dispatched, so an uncomposed plugin leaves no trace and does not hold the tab back from its empty line. The empty line waits for the Host's first answer, so an unanswered read never reads as "this deployment configures no plugin". Cards appear in the order they registered, which is stable for the cards one package installs together and not stable across plugins: apply order between packages is unconstrained.
The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
The cards this package ships cover the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
## Extension point
The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs. It keeps a tab mounted after its first selection, so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` list slot. A plugin that ships a browser half registers its own card into that nested slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Both levels follow the contribution's `order`.
The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs. It keeps a tab mounted after its first selection, so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` slot — keyed on the settings namespace a card edits. A plugin that ships a browser half registers its own card under its own namespace and owns every part of it: chrome, controls, and copy. Keying on the namespace is what lets a plugin distributed outside this repository appear here — it registers the namespace on the Host and the card in the browser, and the tab pairs the two without learning what the namespace means. Tabs follow the contribution's `order`; cards follow registration order.
## Writes
@@ -35,6 +35,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all (a second session mounting the same preset would fail on a duplicate registration), so this section lists nothing for it. Editing those values remains the preset editor's job.
- **Exposure is a Host allowlist, not a plugin declaration** — a namespace absent from the api-proxy's allowlist answers `settings-not-exposed` even when its owner registered it, so a plugin distributed outside this repository cannot surface its own configuration here without a change in `packages/host/apiproxy`.
- **A card still needs a browser bundle** — the browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `packages/client/tsdown.client.ts` rather than a published package, so a plugin outside this repository has to reproduce that build itself. The bundle-purity gate also forbids importing this package's card chrome or form model as values, so such a card owns its own staging and revision fencing.
- **The served namespaces re-read on two signals only** — the wire announces settings-document commits and connection resets, not registrations, so a namespace whose owner registers after the tab's read joins the list on the next document commit or reconnect.
- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the card edits the same two fields on both, and a deployment composing neither shows no card.
- **The empty line counts registered cards, not visible ones** — a card whose namespace this deployment does not expose renders nothing, but still counts, so a deployment that exposes none shows an empty list rather than the empty line. The count is also read once, because the renderer caches a root entry's inject face; a card registered later does not raise it.

View File

@@ -6,13 +6,13 @@
## 这里会出现什么
只有当某个命名空间既被存活 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此“插件配置”标签页反映的是该部署实际运行的东西
“插件配置”标签页读取 Host 服务了哪些 settings 命名空间,并为每个命名空间派发一个 slot 键,因此渲染出来的是两份账本的交集:存活 Host 插件注册的命名空间,以及注册在这些键上的卡片。被服务却无人认领的命名空间什么都不渲染——它归别的界面所有,或本部署没有为它提供浏览器半侧;而命名空间未被本部署服务的卡片根本不会被派发,因此未组装插件不留任何痕迹,也不会挡住那行空态文案。空态文案要等 Host 的第一次答复,因此一次尚未答复的读取绝不会被读成“本部署没有可配置的插件”。卡片按自身注册的顺序出现;对同一个包一起安装的卡片这是稳定的,对跨插件的卡片则不稳定:包与包之间的 apply 顺序是无约束的
第一批覆盖 shell 执行器(`bash`、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
本包自带的卡片覆盖 shell 执行器(`bash`、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
## 扩展点
本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页。某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` 列表 slot。带浏览器半侧的插件把自己的卡片注册进这个嵌套 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。两层排序都遵循贡献的 `order`
本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页。某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` slot——以卡片所编辑的 settings 命名空间为键。带浏览器半侧的插件把自己的卡片注册在自己的命名空间上,并拥有它的全部:外观、控件与文案。以命名空间为键,正是在本仓库之外分发的插件能出现在这里的原因——它在 Host 上注册命名空间、在浏览器里注册卡片,由标签页把两者配对,而无需知道命名空间意味着什么。标签页遵循贡献的 `order`;卡片遵循注册顺序
## 写入
@@ -35,6 +35,6 @@
## 已知限制与暂缓事项
- **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。
- **暴露是 Host 的白名单,而非插件的声明**——不在 api-proxy 白名单中的命名空间,即便其拥有方已注册,也只会得到 `settings-not-exposed`,因此在本仓库之外分发的插件无法在不改动 `packages/host/apiproxy` 的前提下让自己的配置出现在这里
- **卡片仍然需要一份浏览器 bundle**——浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `packages/client/tsdown.client.ts`并非已发布的包因此本仓库之外的插件得自行复刻该构建。bundle 纯净度门禁同时禁止以值的形式导入本包的卡片外观与表单模型,所以这样的卡片要自行拥有暂存与 revision 设栅
- **被服务的命名空间只在两种信号上重读**——协议通告的是 settings 文档提交与连接重置,而非注册行为,因此在标签页读取之后才被其拥有方注册的命名空间,要等下一次文档提交或重连才会加入列表。
- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同PowerShell 多出 `pwshPath`),尽管卡片在两者下编辑的都是同样两个字段;而两者都不组装的部署不会显示这张卡片。
- **空态数的是已注册卡片,不是可见卡片**——命名空间未被本部署暴露的卡片什么都不渲染,但仍计入数量,因此一个都不暴露的部署看到的是空列表而非那行空态文案。该计数还只读取一次,因为渲染器会缓存根级 entry 的 inject face之后注册的卡片不会让它变大。

View File

@@ -1,25 +1,43 @@
/** Configurable Host plugins contributed to the shared Plugins section. */
/**
* Configurable Host plugins contributed to the shared Plugins section.
*
* The tab enumerates settings namespaces but never interprets one — a card
* arrives through `settings.plugin.item` keyed by the namespace it edits, so a
* plugin that ships a browser half owns its own card and this tab only decides
* which keys to dispatch.
*/
import { Fragment } from 'react'
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from './slot-contract.ts'
import type { ConfigurablePluginsTabFace } from './tab-store.ts'
import css from './PluginsSettingsSection.module.css'
/** Registration-side business face for the configurable tab. */
export interface ConfigurablePluginsTabInjected {
/** How many cards the slot ledger held when the tab registration mounted. */
cardCount: number
}
/** Props the renderer binds for the configurable tab. */
export type ConfigurablePluginsTabProps =
PropsRuntime<'settings.plugins.tab'>
& PropsLocale<'settings.plugins'>
& PropsRenderSlots<'settings.plugin.item'>
& InjectFace<ConfigurablePluginsTabInjected>
& InjectFace<ConfigurablePluginsTabFace>
/** Render cards registered by plugins that expose editable settings. */
export function ConfigurablePluginsTab({ t, renderSlot, cardCount }: ConfigurablePluginsTabProps) {
return cardCount === 0
? <p className={css.empty}>{t('empty')}</p>
: <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>
/**
* Render cards registered by plugins that expose editable settings.
* @param props - locale copy, slot rendering, and the namespaces to dispatch.
* @returns the card list, or the empty line once the Host has answered.
*/
export function ConfigurablePluginsTab(props: ConfigurablePluginsTabProps) {
const { t, renderSlot } = props
const { loaded, namespaces } = props.useConfigurablePlugins(snapshot => snapshot)
if (namespaces.length > 0) {
return (
<ul className={css.cards}>
{namespaces.map(ns => (
// One dispatch per namespace, so the list identity is the namespace
// rather than a position that shifts as cards arrive.
<Fragment key={ns}>{renderSlot('settings.plugin.item', {}, { entryKey: ns })}</Fragment>
))}
</ul>
)
}
return loaded ? <p className={css.empty}>{t('empty')}</p> : null
}

View File

@@ -23,17 +23,18 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { AgentLoopCard } from './AgentLoopCard.tsx'
import { BashCard } from './BashCard.tsx'
import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
import type { ConfigurablePluginsTabInjected } from './ConfigurablePluginsTab.tsx'
import { PluginsSettingsSection } from './PluginsSettingsSection.tsx'
import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx'
import { WebSearchCard } from './WebSearchCard.tsx'
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts'
import { SHELL_NS, BashCardController } from './bash-card-controller.ts'
import { ConfigurablePluginsTabController } from './tab-store.ts'
import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts'
import { en, zh } from './locales.ts'
export type { PluginsSettingsSectionInjected, PluginsSettingsSectionProps } from './PluginsSettingsSection.tsx'
export type { ConfigurablePluginsTabInjected, ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx'
export type { ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx'
export type { ConfigurablePluginsTabFace, ConfigurablePluginsTabState } from './tab-store.ts'
export type { PluginCardProps } from './PluginCard.tsx'
export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
export type { FieldProps } from './fields.tsx'
@@ -71,6 +72,27 @@ export function apply(ctx: ClientContext): void {
'ui-settings-plugins: credential invalidations',
)
// Which namespaces the Host serves is a registration fact the wire does not
// announce, so the directory re-reads on the two signals that can carry a
// changed composition: a settings document commit and a reconnect.
const configurable = new ConfigurablePluginsTabController(
api, () => ctx.slots.entries('settings.plugin.item'))
ctx.effect(() => () => { configurable.dispose() }, 'ui-settings-plugins: tab directory')
ctx.effect(
() => ctx.remote.$on('settings/document-updated', () => { void configurable.load() }),
'ui-settings-plugins: served-namespace invalidations',
)
ctx.effect(
() => ctx.on('connection/reset', () => { void configurable.load() }),
'ui-settings-plugins: served-namespace reconnect',
)
// A card registered after the first read joins the list without a wire call.
ctx.effect(
() => ctx.slots.subscribe('settings.plugin.item', () => { configurable.refresh() }),
'ui-settings-plugins: card ledger',
)
void configurable.load()
let tabsVersion = -1
let tabsRevision = -1
let tabs: readonly PluginsSettingsTabEntry[] = []
@@ -126,31 +148,26 @@ export function apply(ctx: ClientContext): void {
order: 0,
label: () => t('configurableTab'),
locale: NS,
inject: (): ConfigurablePluginsTabInjected => ({
cardCount: ctx.slots.entries('settings.plugin.item').length,
}),
children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } },
inject: () => configurable.inject(),
children: { 'settings.plugin.item': { kind: 'keyed', scope: 'root' } },
}, ConfigurablePluginsTab))
ctx.slots.inject('settings.plugin.item', function* () {
yield ctx.slots.register({
name: 'settings.plugin.item',
id: 'bash',
order: 0,
key: SHELL_NS,
locale: NS,
inject: () => bash.inject(),
}, BashCard)
yield ctx.slots.register({
name: 'settings.plugin.item',
id: 'agent-loop',
order: 10,
key: AGENT_LOOP_NS,
locale: NS,
inject: () => agentLoop.inject(),
}, AgentLoopCard)
yield ctx.slots.register({
name: 'settings.plugin.item',
id: 'web-search',
order: 20,
key: WEB_SEARCH_NS,
locale: NS,
inject: () => webSearch.inject(),
}, WebSearchCard)

View File

@@ -1,19 +1,22 @@
/**
* The `settings.plugin.item` slot type — one plugin's card inside the plugin
* configuration section. Options: `id` (card key), `order` (card position).
* A card draws its own internals; the section only stacks them and reports
* how many there are.
* The `settings.plugin.item` slot type — one plugin's card inside the
* configurable-plugins tab, keyed by the settings namespace the card edits.
* Options: `key` (the namespace). A card draws its own internals; the tab only
* decides which namespaces to dispatch and stacks what comes back.
*
* TYPE HOME RATIONALE: unlike `settings.general.item`, whose registrants span
* packages that cannot reference its declarer, every current registrant of
* this slot ships in this package, and a plugin registering its own card
* already depends on this package for the card chrome. The type therefore
* lives with the section that declares it at runtime.
* Keying on the namespace is what lets a plugin distributed outside this
* repository contribute a card: it registers its own settings namespace on the
* Host and its own card under that key in the browser, and the tab pairs the
* two without ever learning what the namespace means.
*
* TYPE HOME RATIONALE: the tab declares this slot at runtime, and a plugin
* registering its own card already depends on this package for the slot's
* declaration. The type therefore lives with its declarer.
*/
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** One plugin's card inside the plugin configuration section (see module JSDoc). */
'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
}
}

View File

@@ -0,0 +1,123 @@
/**
* The configurable-plugins tab's card list.
*
* The tab dispatches its slot by settings namespace, so what it renders is
* the intersection of two ledgers: the namespaces the Host serves and the
* cards registered into `settings.plugin.item`. A served namespace no card
* claims renders nothing — another surface owns it, or this deployment ships
* no browser half for it — and a card whose namespace the Host does not serve
* is never dispatched, so a plugin this deployment did not compose leaves no
* trace and does not count toward the empty line.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** What the section renders. */
export interface ConfigurablePluginsTabState {
/**
* Whether the Host has answered once. The empty line waits for it: an
* unanswered read is not the same statement as "this deployment configures
* no plugin", and saying the second while the first is true would flash a
* wrong answer on every open.
*/
loaded: boolean
/**
* Namespaces to dispatch, in the order their cards registered, narrowed to
* those the Host serves. Card registration order rather than the Host's
* description order: the latter follows plugin activation, which async
* settings injection can reorder between boots, and a settings page whose
* cards move between visits is worse than one whose order a registrant
* chose.
*/
namespaces: string[]
}
/** The registration-side face the tab's slot entry injects. */
export interface ConfigurablePluginsTabFace {
hooks: {
/** Section snapshot bound by the renderer as usePluginConfigSection. */
configurablePlugins: SnapshotStore<ConfigurablePluginsTabState>
}
}
/** Reads the served namespaces and pairs them with the cards that claim them. */
export class ConfigurablePluginsTabController {
private readonly store = createSnapshotStore<ConfigurablePluginsTabState>({ loaded: false, namespaces: [] })
/** Last Host answer; kept so a slot mutation republishes without a wire read. */
private served: readonly string[] = []
private loaded = false
private generation = 0
private disposed = false
/**
* @param api - settings wire face.
* @param entries - reads the cards currently registered into the section's slot.
*/
constructor(
private readonly api: Pick<IApiClient, 'settings'>,
private readonly entries: () => readonly StoredEntry[],
) {}
/** Opaque read of {@link disposed}: control flow cannot narrow it across awaits. */
private isDisposed(): boolean {
return this.disposed
}
/**
* Re-read the served namespaces from the Host and republish.
* @returns settlement after the read, or immediately once disposed.
*/
async load(): Promise<void> {
if (this.isDisposed()) return
const generation = ++this.generation
let response: Awaited<ReturnType<IApiClient['settings']['describe']>>
try {
response = await this.api.settings.describe({})
} catch (_settingsReadFailure) {
// The tab keeps the namespaces it last knew; the next invalidation
// or reconnect reads again.
return
}
if (this.isDisposed() || generation !== this.generation || !response.result.ok) return
this.served = response.result.value.namespaces.map(view => view.ns)
this.loaded = true
this.publish()
}
/** Republish after the slot ledger changed; a card registered late joins here. */
refresh(): void {
if (this.disposed) return
this.publish()
}
/** Stop publishing; an in-flight read settles without touching the store. */
dispose(): void {
this.disposed = true
this.generation += 1
}
/**
* Build the face the tab's slot registration injects.
* @returns the tab's snapshot source.
*/
inject(): ConfigurablePluginsTabFace {
return { hooks: { configurablePlugins: this.store } }
}
private publish(): void {
const served = new Set(this.served)
const namespaces = this.entries().flatMap(entry =>
entry.options.key !== undefined && served.has(entry.options.key) ? [entry.options.key] : [])
const previous = this.store.getSnapshot()
// Every settings-document commit re-reads, and most of them change nothing
// this section shows. An observable source must keep its snapshot
// reference until the fact moves, or each unrelated save re-renders the
// whole card list (packages/client/AGENTS.md reactive rule 5).
if (previous.loaded === this.loaded
&& previous.namespaces.length === namespaces.length
&& previous.namespaces.every((ns, index) => ns === namespaces[index])) return
this.store.set({ loaded: this.loaded, namespaces })
}
}

View File

@@ -16,8 +16,8 @@ export const inject = ['invariants']
/**
* No runtime invariant: this is a browser-side settings surface whose node half owns no event
* stream or mutable runtime data; the layering, write refusals, and exposure boundary are Host
* contracts covered by the owning plugins and the api-proxy.
* stream or mutable runtime data; the layering and write refusals are Host contracts covered by
* the owning plugins and the api-proxy.
*/
const install: InvariantInstaller = () => {}

View File

@@ -9,19 +9,38 @@ import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-t
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
import type {
ConfigurablePluginsTabInjected, PluginsSettingsSectionInjected,
ConfigurablePluginsTabFace, PluginsSettingsSectionInjected,
} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
async function bench() {
/**
* @param served - namespaces the Host describes; omitted answers a failed read,
* which is what most of these specs want (no card has anything to render).
*/
async function bench(served?: string[]) {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
const describeSettings = vi.fn(() => Promise.resolve(served === undefined
? { rpcId: 's', result: { ok: false, error: {} } }
: {
rpcId: 's',
result: {
ok: true,
value: {
writable: true,
hasDocument: true,
namespaces: served.map(ns => ({
ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0,
})),
},
},
}))
// The section binds its scopes through the Settings surface's service, and
// forwarded Host events reach it through the same `$dispatch` handoff the
// connection sink makes.
@@ -29,12 +48,12 @@ async function bench() {
ctx.provide('connection', {
isLoopback: true,
api: {
settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) },
settings: { describe: describeSettings },
credentials: { describe: describeCredentials },
},
} as never)
await ctx.plugin(SettingsScopeBinder).await()
return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials }
return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings }
}
function declareRoot(slots: SlotRegistry): () => void {
@@ -63,20 +82,11 @@ describe('ui-settings-plugins apply', () => {
const tab = slots.entries('settings.plugins.tab')[0]!
expect(tab.options).toMatchObject({ id: 'configurable', order: 0 })
expect(resolveSlotLabel(tab.options.label)).toBe('插件配置')
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' })
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' })
})
it('registers one card per host-plane section it ships, in a stable order', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
expect(slots.entries('settings.plugin.item').map(entry => entry.options.id))
.toEqual(['bash', 'agent-loop', 'web-search'])
})
it('injects a live tab projection, a card count, and one business face per card', async () => {
it('injects a live tab projection, the card directory, and one business face per card', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
@@ -99,7 +109,8 @@ describe('ui-settings-plugins apply', () => {
unsubscribe()
const tab = slots.entries('settings.plugins.tab')[0]!
expect((tab.inject as unknown as () => ConfigurablePluginsTabInjected)()).toEqual({ cardCount: 3 })
const tabFace = (tab.inject as unknown as () => ConfigurablePluginsTabFace)()
expect(Object.keys(tabFace.hooks)).toEqual(['configurablePlugins'])
for (const entry of slots.entries('settings.plugin.item')) {
const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
// Each card injects exactly one snapshot store plus its own actions.
@@ -107,6 +118,58 @@ describe('ui-settings-plugins apply', () => {
}
})
it('keys each card it ships on the settings namespace that card edits', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
expect(slots.entries('settings.plugin.item').map(entry => entry.options.key))
.toEqual(['shell', 'agent-loop', 'web-search-deepseek'])
})
it('dispatches the served namespaces its cards claim, and no others', async () => {
// ui-theme is served but belongs to another surface, and a deployment
// composing no PowerShell/POSIX executor serves no `bash` at all.
const { ctx, slots } = await bench(['agent-loop', 'ui-theme', 'web-search-deepseek'])
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const tab = slots.entries('settings.plugins.tab')[0]!
const face = (tab.inject as unknown as () => ConfigurablePluginsTabFace)()
await vi.waitFor(() => {
expect(face.hooks.configurablePlugins.getSnapshot().namespaces)
.toEqual(['agent-loop', 'web-search-deepseek'])
})
})
it('re-reads the served namespaces when the Host commits a settings document', async () => {
// Which namespaces the Host serves is a registration fact the wire never
// announces on its own, so the tab rides the invalidation that can
// accompany a changed composition.
const { ctx, slots, describeSettings } = await bench(['bash'])
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
describeSettings.mockClear()
ctx.remote.$dispatch('settings/document-updated', ['bash', 1])
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
})
it('re-reads the served namespaces after a reconnect', async () => {
const { ctx, slots, describeSettings } = await bench(['bash'])
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
describeSettings.mockClear()
ctx.emit('connection/reset')
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
})
it('re-reads the credential when the Host reports the watched reference changed', async () => {
const { ctx, slots, describeCredentials } = await bench()
declareRoot(slots)

View File

@@ -22,6 +22,7 @@ import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts'
import type { BashCardState } from '../src/client/bash-card-controller.ts'
import type { CardFieldState, CardShell } from '../src/client/card-form.ts'
import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts'
import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts'
import { en } from '../src/client/locales.ts'
@@ -59,11 +60,20 @@ function renderSection(rows: readonly PluginsSettingsTabEntry[]) {
render(<PluginsSettingsSection {...props} />)
}
function renderConfigurable(cardCount: number, cards = 'cards') {
/**
* Render the tab over the namespaces it was told to dispatch, with `cards`
* standing in for the slot ledger: a key it names renders that text, and one
* it does not renders nothing, exactly as an unclaimed key does.
*/
function renderConfigurable(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
const store = createSnapshotStore<ConfigurablePluginsTabState>({ loaded, namespaces })
const props = {
t,
cardCount,
renderSlot: () => <li>{cards}</li>,
useConfigurablePlugins: bindSnapshotSelector(store),
renderSlot: (_name: string, _owner: object, opts?: { entryKey?: string }) => {
const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey]
return card === undefined ? null : <li>{card}</li>
},
} as unknown as ConfigurablePluginsTabProps
render(<ConfigurablePluginsTab {...props} />)
}
@@ -153,16 +163,24 @@ describe('PluginsSettingsSection', () => {
describe('ConfigurablePluginsTab', () => {
it('says so when no plugin contributed a card', () => {
renderConfigurable(0)
renderConfigurable([], { bash: 'shell' })
expect(screen.getByText(en.empty)).toBeTruthy()
expect(screen.queryByText('cards')).toBeNull()
expect(screen.queryByText('shell')).toBeNull()
})
it('renders the card list once a plugin contributed one', () => {
renderConfigurable(1)
it('withholds the empty line until the Host has answered once', () => {
// An unanswered read is not the statement that this deployment configures
// no plugin; saying it anyway would flash a wrong answer on every open.
renderConfigurable([], { bash: 'shell' }, false)
expect(screen.getByText('cards')).toBeTruthy()
expect(screen.queryByText(en.empty)).toBeNull()
})
it('dispatches one card per namespace, keyed by it', () => {
renderConfigurable(['bash', 'agent-loop'], { bash: 'shell', 'agent-loop': 'loop' })
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['shell', 'loop'])
expect(screen.queryByText(en.empty)).toBeNull()
})
})

View File

@@ -8,6 +8,7 @@ import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-clie
import { CardForm, numberField, textField } from '../src/client/card-form.ts'
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts'
import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts'
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts'
/** Make the stub behave like a Host that accepts every write. */
@@ -380,7 +381,7 @@ describe('AgentLoopCardController', () => {
})
describe('WebSearchCardController', () => {
it('reads the credential state for the reference the section names', async () => {
it('reads the credential state for the reference the tab names', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const credentials = credentialsApi(true)
const controller = new WebSearchCardController(host.scope, credentials.api)
@@ -460,7 +461,7 @@ describe('WebSearchCardController', () => {
})
})
it('addresses the reference the section declares rather than the default', async () => {
it('addresses the reference the tab declares rather than the default', async () => {
const host = stubSettingsScope<WebSearchSettings>()
const credentials = credentialsApi(false)
const controller = new WebSearchCardController(host.scope, credentials.api)
@@ -538,3 +539,131 @@ describe('WebSearchCardController', () => {
expect(credentials.set).not.toHaveBeenCalled()
})
})
describe('ConfigurablePluginsTabController', () => {
function settingsApi(namespaces: string[]) {
const describe = vi.fn(() => Promise.resolve({
rpcId: 's-1' as never,
result: {
ok: true as const,
value: {
writable: true,
hasDocument: true,
namespaces: namespaces.map(ns => ({
ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0,
})),
},
},
}))
return { api: { settings: { describe } } as never, describe }
}
/** Slot ledger stand-in: one stored entry per registered card key. */
function ledger(...keys: string[]) {
return keys.map(key => ({ component: null, options: { key } }))
}
it('dispatches the served namespaces a card claims, in card registration order', async () => {
const settings = settingsApi(['bash', 'ui-theme', 'agent-loop'])
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('agent-loop', 'bash'))
await controller.load()
// ui-theme is served but claimed by no card here — another surface owns
// it. The order is the cards', not the Host's: plugin activation can
// reorder the description between boots.
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces)
.toEqual(['agent-loop', 'bash'])
})
it('never dispatches a card whose namespace this deployment does not serve', async () => {
const settings = settingsApi(['bash'])
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'web-search-deepseek'))
await controller.load()
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
})
it('takes a card registered after the read without asking the Host again', async () => {
const settings = settingsApi(['bash'])
let entries = ledger()
const controller = new ConfigurablePluginsTabController(settings.api, () => entries)
await controller.load()
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
entries = ledger('bash')
controller.refresh()
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
expect(settings.describe).toHaveBeenCalledOnce()
})
it('keeps the namespaces it knew when a read fails', async () => {
const settings = settingsApi(['bash'])
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash'))
await controller.load()
settings.describe.mockRejectedValueOnce(new Error('offline'))
await controller.load()
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
})
it('publishes nothing once disposed, and never claims it was answered', async () => {
const settings = settingsApi(['bash'])
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash'))
controller.dispose()
await controller.load()
expect(controller.inject().hooks.configurablePlugins.getSnapshot())
.toEqual({ loaded: false, namespaces: [] })
expect(settings.describe).not.toHaveBeenCalled()
})
it('ignores a slot-ledger change that arrives after disposal', async () => {
const settings = settingsApi(['bash'])
let entries = ledger()
const controller = new ConfigurablePluginsTabController(settings.api, () => entries)
await controller.load()
controller.dispose()
entries = ledger('bash')
controller.refresh()
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
})
it('drops a read a newer one superseded', async () => {
// The section re-reads on every settings-document invalidation, so a slow
// first answer must not overwrite the newer one that already landed.
const settings = settingsApi(['bash'])
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash', 'agent-loop'))
const slow = Promise.withResolvers<unknown>()
settings.describe.mockReturnValueOnce(slow.promise as never)
const stale = controller.load()
await controller.load()
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
slow.resolve({
rpcId: 's-0',
result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [
{ ns: 'agent-loop', schema: {}, value: {}, applies: 'live', secrets: [], revision: 0 },
] } },
})
await stale
expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
})
it('reports the Host answered even when it serves nothing this tab shows', async () => {
const settings = settingsApi(['ui-theme'])
const controller = new ConfigurablePluginsTabController(settings.api, () => ledger('bash'))
await controller.load()
expect(controller.inject().hooks.configurablePlugins.getSnapshot())
.toEqual({ loaded: true, namespaces: [] })
})
})

View File

@@ -39,6 +39,28 @@
box-sizing: border-box;
}
/* Collapsed to the header strip: drop the height cap and the inner scroll
seat so the card hugs the title row, freeing the viewport for the
conversation above while the question stays pending. */
.cardMinimized {
max-height: none;
}
/* The header strip is the whole card when collapsed: the title row needs
bottom padding once the body that normally carries it is hidden. */
.cardMinimized .header {
padding-bottom: 14px;
}
/* Header button group: minimize sits next to the close action, both on the
same 24px icon-button grid. */
.headerActions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Figma 1019:36938 header, user-tuned: heading block left, close right; the
pager sits in the footer to balance the card. */
.header {

View File

@@ -1,8 +1,9 @@
import { useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import clsx from 'clsx'
import {
Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16, MarkdownText,
Button, IconCheckOutline14, IconChevronDownOutline14, IconChevronLeftOutline14,
IconChevronRightOutline14, IconChevronUpOutline14, IconCloseOutline16,
IconEditOutline16, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
PendingQuestion, planReviewOf,
@@ -75,6 +76,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<Feedback | null>(null)
// Collapsed to the header strip so the conversation above stays readable
// while the user decides; the drafts survive because the state lives here.
const [minimized, setMinimized] = useState(false)
// The free-form textarea autofocuses on first presentation; re-expanding a
// collapsed question must not steal focus from the expand toggle back into
// the input, so focus is granted once per question index.
const focusedQuestions = useRef(new Set<number>())
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// oxlint-disable-next-line typescript/no-non-null-assertion
const question = questions[index]!
@@ -191,7 +199,10 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
return (
<div className={css.frame} data-question-key={pending.key}>
<section className={css.card} aria-labelledby={`question-${pending.key}-${String(index)}`}>
<section
className={clsx(css.card, minimized && css.cardMinimized)}
aria-labelledby={`question-${pending.key}-${String(index)}`}
>
<header className={css.header}>
<div className={css.headingBlock}>
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
@@ -199,138 +210,155 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
{question.question}
</h2>
</div>
<button
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
title={t('nav.cancel')}
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
</button>
<div className={css.headerActions}>
<button
type="button" className={css.iconButton}
aria-label={t(minimized ? 'nav.maximize' : 'nav.minimize')}
title={t(minimized ? 'nav.maximize' : 'nav.minimize')}
aria-expanded={!minimized}
disabled={busy !== null}
onClick={() => { setMinimized(current => !current) }}
>
{minimized ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
</button>
<button
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
title={t('nav.cancel')}
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
</button>
</div>
</header>
<div className={css.body} data-question-scroll>
{question.detail !== undefined && (
<div className={css.detail}><MarkdownText text={question.detail} /></div>
)}
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
{(question.options ?? []).map((option, optionIndex) => {
const selected = draft.selected.includes(option.label)
const display = parseRecommendedLabel(option.label)
return (
<button
type="button" key={`${option.label}-${String(optionIndex)}`}
className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
disabled={busy !== null}
onClick={() => { choose(option.label) }}
onKeyDown={(event) => {
if (event.key !== 'Enter' || !drafts.every(completed)) return
event.preventDefault()
submitDrafts(drafts)
}}
>
{question.multiSelect === true
? (
<span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
{selected && <IconCheckOutline14 size={12} />}
</span>
)
: <span className={css.number}>{optionIndex + 1}</span>}
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && (
<span className={css.badge}>{t('option.recommended')}</span>
)}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
</span>
</span>
</button>
)
})}
{hasOptions
? (
<div className={clsx(css.customRow, draft.custom !== '' && css.customRowActive)}>
{question.multiSelect === true
? (
<span
className={clsx(css.checkbox, draft.custom !== '' && css.checkboxChecked)}
aria-hidden="true"
>
{draft.custom !== '' && <IconCheckOutline14 size={12} />}
</span>
)
: (
<span className={css.number} aria-hidden="true">
<IconEditOutline16 size={12} />
</span>
)}
<input
type="text"
className={css.customInput}
value={draft.custom}
disabled={busy !== null}
placeholder={t('custom.placeholder')}
onChange={draftCustom}
onKeyDown={continueFromCustom}
/>
</div>
)
: (
<textarea
autoFocus
className={css.customTextarea}
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder={t('custom.placeholder')}
onChange={draftCustom}
onKeyDown={continueFromCustom}
/>
{!minimized && (
<>
<div className={css.body} data-question-scroll>
{question.detail !== undefined && (
<div className={css.detail}><MarkdownText text={question.detail} /></div>
)}
</div>
</div>
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
{(question.options ?? []).map((option, optionIndex) => {
const selected = draft.selected.includes(option.label)
const display = parseRecommendedLabel(option.label)
return (
<button
type="button" key={`${option.label}-${String(optionIndex)}`}
className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
disabled={busy !== null}
onClick={() => { choose(option.label) }}
onKeyDown={(event) => {
if (event.key !== 'Enter' || !drafts.every(completed)) return
event.preventDefault()
submitDrafts(drafts)
}}
>
{question.multiSelect === true
? (
<span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
{selected && <IconCheckOutline14 size={12} />}
</span>
)
: <span className={css.number}>{optionIndex + 1}</span>}
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && (
<span className={css.badge}>{t('option.recommended')}</span>
)}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
</span>
</span>
</button>
)
})}
<footer className={css.footer}>
<div className={css.pager}>
<button
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
</div>
<div className={css.feedback} role="status">
{error === null ? null : 'key' in error ? t(error.key) : error.text}
</div>
<div className={css.footerActions}>
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
{t('action.skip')}
</Button>
<Button
variant="primary"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? t('submitting')
: index === questions.length - 1 ? t('submit') : t('action.next')}
</Button>
</div>
</footer>
{hasOptions
? (
<div className={clsx(css.customRow, draft.custom !== '' && css.customRowActive)}>
{question.multiSelect === true
? (
<span
className={clsx(css.checkbox, draft.custom !== '' && css.checkboxChecked)}
aria-hidden="true"
>
{draft.custom !== '' && <IconCheckOutline14 size={12} />}
</span>
)
: (
<span className={css.number} aria-hidden="true">
<IconEditOutline16 size={12} />
</span>
)}
<input
type="text"
className={css.customInput}
value={draft.custom}
disabled={busy !== null}
placeholder={t('custom.placeholder')}
onChange={draftCustom}
onKeyDown={continueFromCustom}
/>
</div>
)
: (
<textarea
autoFocus={!focusedQuestions.current.has(index)}
className={css.customTextarea}
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder={t('custom.placeholder')}
onFocus={() => { focusedQuestions.current.add(index) }}
onChange={draftCustom}
onKeyDown={continueFromCustom}
/>
)}
</div>
</div>
<footer className={css.footer}>
<div className={css.pager}>
<button
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
</div>
<div className={css.feedback} role="status">
{error === null ? null : 'key' in error ? t(error.key) : error.text}
</div>
<div className={css.footerActions}>
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
{t('action.skip')}
</Button>
<Button
variant="primary"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? t('submitting')
: index === questions.length - 1 ? t('submit') : t('action.next')}
</Button>
</div>
</footer>
</>
)}
</section>
</div>
)

View File

@@ -6,6 +6,8 @@ export const zh = {
'error.unanswered': '请选择一个选项或填写自定义答案。',
'nav.prev': '上一题',
'nav.next': '下一题',
'nav.minimize': '收起问题卡片',
'nav.maximize': '展开问题卡片',
'nav.cancel': '放弃整组问题',
'option.recommended': '推荐',
'custom.placeholder': '输入你的答案',
@@ -26,6 +28,8 @@ export const en = {
'error.unanswered': 'Please select an option or enter a custom answer.',
'nav.prev': 'Previous question',
'nav.next': 'Next question',
'nav.minimize': 'Collapse the question card',
'nav.maximize': 'Expand the question card',
'nav.cancel': 'Dismiss all questions',
'option.recommended': 'Recommended',
'custom.placeholder': 'Type your answer',

View File

@@ -306,6 +306,45 @@ describe('PendingQuestion domain face', () => {
expect(question.key).toBe('q:rk')
expect(question.questions).toBe(wait('rk').carrier.payload.questions)
})
it('collapses the card to the header strip and expands it back', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
// Expanded: the option list is visible.
expect(screen.getByRole('radiogroup')).toBeTruthy()
// Collapse: options leave the tree; the title and minimize toggle stay.
fireEvent.click(screen.getByLabelText(zh['nav.minimize']))
expect(screen.queryByRole('radiogroup')).toBeNull()
expect(screen.getByText('选择候选人类型')).toBeTruthy()
// Expand: the options return (the toggle label flips while collapsed).
fireEvent.click(screen.getByLabelText(zh['nav.maximize']))
expect(screen.getByRole('radiogroup')).toBeTruthy()
// Expanded again: the toggle reports expanded and the option list is back.
expect(screen.getByLabelText(zh['nav.minimize']).getAttribute('aria-expanded')).toBe('true')
})
it('keeps the collapse toggle out of the cancel path and preserves drafts across collapse', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ }))
// Single-select auto-advances to the second question; collapse and expand
// must not lose either the picked option or the current position.
fireEvent.click(screen.getByLabelText(zh['nav.minimize']))
fireEvent.click(screen.getByLabelText(zh['nav.maximize']))
const custom = screen.getByPlaceholderText(zh['custom.placeholder'])
fireEvent.change(custom, { target: { value: '要能独立排查线上问题' } })
// Re-expanding must not steal focus back into the textarea: it was
// autofocused on first presentation, so focus stays on the expand toggle.
expect(document.activeElement).not.toBe(custom)
fireEvent.click(screen.getByLabelText('下一题'))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
{ id: 'detail', custom: '要能独立排查线上问题', selected: [] },
{ id: 'signals', selected: ['系统设计'] },
]))
})
})
describe('parseRecommendedLabel', () => {

View File

@@ -62,7 +62,7 @@ function inboxText(message: UserMessage): string {
describe('assistant replay provider and model fields', () => {
it('records adapter replay state with the assembled assistant content', async () => {
const response = textResponse('unchanged')
const replayState = { private: 'state' }
const replayState = { response: { private: 'state' }, blocks: ['block-meta'] }
response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState }
const adapter = new MockAdapter([response])
const ctx = await harness(adapter)

View File

@@ -1192,15 +1192,29 @@ describe('agent loop', () => {
{ type: 'block-end', index: 0, block: { type: 'text', text: 'partial text' } },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 1, id: callId, name: 'echo', argumentsDelta: '{"text"' },
{ type: 'finish', reason: { kind: 'max-tokens' } },
]])
{
type: 'finish',
reason: { kind: 'max-tokens' },
replayState: { response: { responseId: 'resp-1' }, blocks: ['text-meta', 'tool-meta'] },
},
], textResponse('continued')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
send(agent, 'continue')
await waitForIdle(ctx, agent)
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
// The follow-up request replays the truncated message with its replay
// metadata pruned in step with the dropped tool call.
expect(adapter.requests[1]?.messages[1]?.source).toEqual({
kind: 'model',
provider: 'mock',
model: 'mock',
replayState: { response: { responseId: 'resp-1' }, blocks: ['text-meta'] },
})
expect(agent.session.deriveMessages()).toEqual([
{
id: expect.any(String) as unknown,
@@ -1212,6 +1226,23 @@ describe('agent loop', () => {
id: expect.any(String) as unknown,
role: 'assistant',
content: [{ type: 'text', text: 'partial text' }],
source: {
kind: 'model',
provider: 'mock',
model: 'mock',
replayState: { response: { responseId: 'resp-1' }, blocks: ['text-meta'] },
},
},
{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'continue' }],
source: { kind: 'user' },
},
{
id: expect.any(String) as unknown,
role: 'assistant',
content: [{ type: 'text', text: 'continued' }],
source: { kind: 'model', provider: 'mock', model: 'mock' },
},
])

View File

@@ -1268,28 +1268,16 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
},
{
key: 'settings.plugin.item',
kind: 'list',
kind: 'keyed',
scope: 'root',
summary: 'One plugin\'s card inside the plugin configuration section (see module JSDoc).',
doc: 'One plugin\'s card inside the plugin configuration section (see module JSDoc).',
registerOptions: [
{
name: 'id',
name: 'key',
requirement: 'required',
type: 'string',
doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.',
},
{
name: 'order',
requirement: 'optional',
type: 'number',
doc: 'Position among the entries, ascending (default 0).',
},
{
name: 'label',
requirement: 'optional',
type: 'string | (() => string)',
doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.',
},
],
ownerProps: [
@@ -1300,18 +1288,18 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'useSessions: SnapshotSelectorHook<SessionListState>',
'useWorkspaces: SnapshotSelectorHook<import(\'./workspaces/service.ts\').WorkspaceListState>',
],
keyDomain: '',
keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet',
hookContext: '',
slotInject: '',
declaredBy: 'an entry in \'settings.plugins.tab\' (client-ui-settings-plugins), so it exists while that entry is mounted',
occupants: [
'client-ui-settings-plugins BashCard id \'bash\'',
'client-ui-settings-plugins AgentLoopCard id \'agent-loop\'',
'client-ui-settings-plugins WebSearchCard id \'web-search\'',
'client-ui-settings-plugins BashCard',
'client-ui-settings-plugins AgentLoopCard',
'client-ui-settings-plugins WebSearchCard',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.plugin.item\', () => ctx.slots.register(\n { name: \'settings.plugin.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-settings-plugins/src/client/slot-contract.ts:16',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.plugin.item\', () => ctx.slots.register(\n { name: \'settings.plugin.item\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-settings-plugins/src/client/slot-contract.ts:19',
},
{
key: 'settings.plugins.tab',

View File

@@ -3583,6 +3583,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'RedactedSecret',
declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}',
},
{
name: 'ReplayEnvelope',
declaration: 'export interface ReplayEnvelope {\n response: unknown;\n blocks?: readonly unknown[];\n}',
},
{
name: 'RequestContext',
declaration: 'export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n}',
@@ -4097,7 +4101,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'StreamChunk',
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};',
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: ReplayEnvelope;\n};',
},
{
name: 'SubagentCapabilities',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: b7dee9488c1b123172c225d4d4235bdb3c76911d
README.zh.md: 46835a84254c4bbc45300a18d29aae5a6ac66f14
README.md: 607cd4e4176631b64daf4a298b5d86a75ccdce68
README.zh.md: cdfb5aa65b3ad5b00596487aae6c99e2f9d4e433

View File

@@ -58,7 +58,7 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse
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 composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `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. `commands/change` rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch `command.list` instead of diffing. Forwarded `agent-preset/selected` is its per-session counterpart, emitted from the logged selection commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it.
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 preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, the host-plane plugin sections `agent-loop`, `bash`, and `web-search-deepseek` that the plugin configuration page edits, 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, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `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. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`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 every registered namespace: a plugin distributed outside this repository becomes browser-configurable by registering its section, with no change here, and this proxy adds no boundary of its own — a name no registration answers folds into the seam's own `settings-rejected`. Which surface renders a namespace is the browser's decision (the plugin configuration page keys its cards on the namespace), never this proxy's. `settings.describe` returns each 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, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `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. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`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)

View File

@@ -58,7 +58,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和 skill技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt``dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token并以注入的 `<skill_content>` 上下文作答因此所有入口Web、TUI 与 ACPAgent Client Protocol共享同一条确定性路径手动键入的文本也走该路径且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。转发的 `agent-preset/selected` 是它按会话粒度的对应物,由落账的选择提交点发出:重组空会话的 agent 只是重新挂接其 scope不产生任何注册因此该会话组成所决定的两份目录`command.list``skill.list`)都会失效,却没有任何注册表变化来宣告它。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale``permission``ui-conversation``ui-theme`、插件配置页所编辑的宿主平面插件分节 `agent-loop``bash``web-search-deepseek`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`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` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate``credentials.set`且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据都折叠为 `model-discovery-failed`其消息是适配器自己的文本details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated``credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated``settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据提供方的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于每一个已注册 namespace在本仓库之外分发的插件只要注册自己的分节即可变得可从浏览器配置无需改动这里本代理也不再自设边界——没有任何注册应答的名字会折叠为 seam 自己的 `settings-rejected`。由哪个界面渲染某个 namespace 是浏览器的决定(插件配置页按 namespace 为其卡片编键),从不由本代理决定`settings.describe` 为每个 namespace 提供其序列化 schemastery schema、脱敏后的分层值resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`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` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate``credentials.set`且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据都折叠为 `model-discovery-failed`其消息是适配器自己的文本details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated``credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated``settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据提供方的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
## 载体层(`/client` + 根路径)

View File

@@ -30,8 +30,7 @@ import {
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import {
InvalidPresetIdError, PresetExistsError, PresetMountError,
PresetNotWritableError, resolveSessionPreset,
SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError,
PresetNotWritableError, resolveSessionPreset, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets'
import type {} from '@deepseek-ai/dsh-tools'
@@ -113,20 +112,6 @@ import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/**
* Non-model settings namespaces intentionally served to the Web client. The
* plugin-owned entries (`agent-loop`, `bash`, `web-search-deepseek`) are the
* host-plane sections the plugin configuration page edits; a namespace absent
* here answers `settings-not-exposed` even when its owner registered it, so
* adding a section to that page is a decision made here rather than by the
* registering plugin. Moving that declaration to `settings.register()`, so a
* plugin can expose its own configuration without a change in this package,
* is deferred work.
*/
const WEB_SETTINGS_NAMESPACES = [
'agent-loop', 'shell', 'locale', 'permission', 'ui-conversation', 'ui-theme', 'web-search-deepseek',
] as const
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
@@ -234,16 +219,6 @@ function referencedImage(events: readonly SessionEvent[], attachmentId: string):
return undefined
}
/**
* Product settings intentionally exposed beside model-provider namespaces.
*
* The agent-preset namespace carries one field — which preset a session with
* no explicit choice is composed from — and both browser surfaces that offer
* that choice write it through `settings.update`, so it has to cross the
* configuration boundary or the pickers silently fail to persist.
*/
const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding', AGENT_PRESET_SETTINGS_NAMESPACE])
/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */
const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
@@ -291,7 +266,12 @@ function paginate(
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
count++
const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
let groupStart = event.seq
if (sources !== undefined) {
for (const source of sources) {
if (source < groupStart) groupStart = source
}
}
if (count >= maxMessages) {
cut = groupStart
break
@@ -1928,39 +1908,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
}
/** Settings namespaces whose changes can invalidate the model catalog. */
function modelProviderNamespaces(): Set<string> {
return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs))
}
/**
* The settings namespaces this proxy serves: configurable model providers
* 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
}
/** Refuse a namespace outside the explicit configuration-client boundary. */
function notExposed(request: RpcRequest<unknown>, ns: string): RpcResponse<SettingsNamespaceView> {
return err(request, {
code: 'settings-not-exposed',
message: `settings namespace "${ns}" is not exposed to configuration clients`,
details: { ns },
})
}
/**
* Run one settings write (merge or wholesale replace) and acknowledge with
* the namespace's new redacted view. A namespace outside the configuration
* boundary is refused before the seam is touched; every seam refusal —
* unknown or invalid namespace, read-only provider, schema validation,
* storage — becomes one `settings-rejected` carrying the seam's own message.
* the namespace's new redacted view. Every seam refusal — unknown or invalid
* namespace, read-only provider, schema validation, storage — becomes one
* `settings-rejected` carrying the seam's own message.
*/
async function settingsWrite(
request: RpcRequest<unknown>,
@@ -1991,11 +1943,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
try {
branded = settingsNamespace(ns)
} catch (error: unknown) {
// A malformed name is a client bug, reported as such; it could never be
// in the exposed set either, so naming the real fault costs no ground.
// A malformed name can address no registration, so it fails exactly as
// an unregistered one does.
return rejected(error)
}
if (!exposedNamespaces().has(ns)) return notExposed(request, ns)
try {
if (mode === 'update') await settings.update(branded, section, expectedRevision)
else if (mode === 'replace') await settings.replace(branded, section, expectedRevision)
@@ -3250,13 +3201,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
describe(request) {
const settings = ctx.get('settings')
if (settings === undefined) return Promise.resolve(err(request, settingsAbsent()))
const exposed = exposedNamespaces()
return Promise.resolve(ok(request, {
writable: settings.writable,
hasDocument: settings.documentPath !== undefined,
namespaces: settings.describe({ redactSecrets: true })
.filter(descriptor => exposed.has(String(descriptor.ns)))
.map(namespaceView),
namespaces: settings.describe({ redactSecrets: true }).map(namespaceView),
}))
},
async openDocument(request, signal) {

View File

@@ -59,7 +59,6 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }),

View File

@@ -63,12 +63,6 @@ export interface RpcErrorDetailsMap {
* read-only provider, or storage failure); the message is the seam's text.
*/
'settings-rejected': { ns: string }
/**
* A settings namespace exists in the seam but is outside the configuration
* plane's model-provider boundary, so this proxy neither reads nor writes
* it; the message names the namespace.
*/
'settings-not-exposed': { ns: string }
/**
* A settings write carried an `expectedRevision` the namespace has already
* moved past: another writer (tab, editor, or an external file edit) landed

View File

@@ -321,12 +321,11 @@ describe('settings domain', () => {
expect(opened).toEqual([])
})
it('serves model-provider and explicitly allowlisted Web namespaces only', async () => {
// 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; locale, permission, conversation, theme, and the product
// onboarding namespace are intentionally admitted by this surface.
it('serves every registered namespace, including one this repository never named', async () => {
// Registering IS the exposure: a plugin distributed outside this
// repository configures itself from the browser without a change here.
// The plane stays loopback-only and secret-redacted, and which surface
// renders a namespace is the browser's decision, not this proxy's.
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig)
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
@@ -357,8 +356,8 @@ describe('settings domain', () => {
const value = expectOk(await api.settings.describe(request({})))
expect(value.namespaces.map(view => view.ns)).toEqual([
'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation',
'shell', 'agent-loop', 'web-search-deepseek',
'llm-deepseek', 'some-other-plugin', 'permission', 'ui-theme', 'locale',
'ui-conversation', 'shell', 'agent-loop', 'web-search-deepseek',
])
const permission = expectOk(await api.settings.mutate(request({
ns: 'permission',
@@ -396,16 +395,13 @@ describe('settings domain', () => {
})))
expect(webSearch.value).toEqual({ baseURL: 'https://search.test/v1' })
for (const response of [
await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })),
await api.settings.replace(request({ ns: 'some-other-plugin', section: {} })),
]) {
const error = expectErr(response)
expect(error.code).toBe('settings-not-exposed')
expect(error.details).toEqual({ ns: 'some-other-plugin' })
}
// The write never reached the seam.
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({})
const other = expectOk(await api.settings.update(request({
ns: 'some-other-plugin',
patch: { secretPath: '/etc/shadow' },
})))
expect(other.value).toEqual({ secretPath: '/etc/shadow' })
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value)
.toEqual({ secretPath: '/etc/shadow' })
})
it('serves product preference namespaces without invalidating the model catalog', async () => {
@@ -445,13 +441,17 @@ describe('settings domain', () => {
.toEqual({ default: 'minimal' })
})
it('refuses even a model-provider namespace once its directory entry is gone', async () => {
it('keeps serving a provider namespace whose directory entry is gone', async () => {
// The configurable-provider directory says what the Models page can offer,
// not what a user may configure: a dormant route's stored section is still
// theirs to edit, and losing the entry must not strand it.
const ctx = await harness({ configurableProviders: false })
ctx.settings.register(NS, AdapterConfig)
const api = createApiProxy(ctx, DEFAULTS)
expect(expectOk(await api.settings.describe(request({}))).namespaces).toEqual([])
expect(expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://x' } }))).code)
.toBe('settings-not-exposed')
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
.toEqual(['llm-deepseek'])
expect(expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://x' } }))).value)
.toMatchObject({ baseURL: 'https://x' })
})
it('forwards a provider settings change for model-catalog consumers', async () => {
@@ -551,19 +551,18 @@ describe('settings domain', () => {
expect(error.details).toEqual({ ns })
})
it('answers an unregistered namespace exactly like an unexposed one', async () => {
// Deliberately indistinguishable: separating "does not exist" from
// "exists but is not yours to configure" would let a caller enumerate the
// registered namespaces one probe at a time.
it('answers an unregistered namespace as the seam does, and a malformed one alike', async () => {
// A name no registration answers and a name no registration could answer
// fold into the same rejection: the proxy adds no boundary of its own, so
// the seam's own refusal is the whole answer.
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig)
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
const api = createApiProxy(ctx, DEFAULTS)
const unknown = expectErr(await api.settings.update(request({ ns: 'unknown-ns', patch: {} })))
const unexposed = expectErr(await api.settings.update(request({ ns: 'some-other-plugin', patch: {} })))
expect(unknown.code).toBe('settings-not-exposed')
expect(unexposed.code).toBe(unknown.code)
expect(unexposed.message.replace('some-other-plugin', 'unknown-ns')).toBe(unknown.message)
const malformed = expectErr(await api.settings.update(request({ ns: 'Not A Namespace', patch: {} })))
expect(unknown.code).toBe('settings-rejected')
expect(unknown.message).toContain('is not registered')
expect(malformed.code).toBe(unknown.code)
})
it('maps a read-only provider refusal onto the same rejection', async () => {

View File

@@ -7,7 +7,7 @@
* turn/end cleared it.
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -285,6 +285,45 @@ describe('mux live view computation', () => {
expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
})
it('paginates a message with many provenance sources without variadic argument expansion', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index, text: 'x' },
}).seq)
const message = session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'x'.repeat(sources.length) }],
source: { kind: 'model', provider: 'p', model: 'm' },
}),
}, { surfaceOp: 'append', sourceEventSeqs: sources })
const scalarMin = Math.min
const min = vi.spyOn(Math, 'min').mockImplementation((...values) => {
if (values.length > 2) throw new RangeError('variadic minimum rejected by regression harness')
return scalarMin(...values)
})
try {
const response = await api.sessions.history({
rpcId: RpcId('t-hist-large-provenance'),
payload: { sessionId: session.id, maxMessages: 1 },
})
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.events.map(entry => entry.event.seq)).toEqual([...sources, message.seq])
expect(response.result.value.hasMore).toBe(true)
} finally {
min.mockRestore()
}
})
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
README.md: 6120f8d982c6d475cd508e6cf9e41cabfc9ba159
README.zh.md: 4b47976c6c6c67968b5b93edbdfd5dfa9530eb1d
README.md: d775e72616822ce0deee063ac0f3fc453af1a126
README.zh.md: 621d67d1c181c6d4c78ea0078f521acccce92653

View File

@@ -137,9 +137,9 @@ Credentials never enter that collection. The harness resolves a route's key thro
The selected model descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name.
Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them. At request time, `LlmRuntime` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response.
Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them, as a `ReplayEnvelope`: a response-level half (kind, version, API, route, response ids, native stop reason) plus one per-block entry per streamed block carrying that block's signatures. The per-block alignment is what `BlockAssembler` prunes when assembly drops a block (a `max-tokens` tool call), so the stored entries always describe the stored content — the retained blocks keep their signatures. At request time, `LlmRuntime` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response.
If a listener rewrites assembled assistant content, the loop drops replay state before logging the message because its provider metadata no longer describes the content. Invalid versions, malformed metadata, provider/model mismatches between the message and replay state, and content/block mismatches fail explicitly with `LlmError('INVALID_REPLAY_STATE')`.
Durable content is the authoritative record; replay state only restores native fidelity. A stored state this build cannot use — another adapter's kind, another version (including the flat pre-envelope form older logs carry), malformed metadata, provider/model mismatches between the message and replay state, or content/block mismatches — degrades that one assistant message to the same foreign provider-neutral conversion instead of failing the request, and the plugin logs the `INVALID_REPLAY_STATE` diagnostic through its `onReplayDegrade` hook.
## Vocabulary differences

View File

@@ -138,9 +138,9 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog而不是扩
所选模型 descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型harness 适配器不会按模型名称硬编码端点选择。
成功的 assistant 响应会将经版本化的无损 JSON 回放状态与生成该响应的提供方和模型一同存储。请求时,`LlmRuntime` 只有在历史提供方路由与目标提供方路由当前由同一个 `PiAiAdapter` 实例拥有时,才会传递回放状态。即使目标提供方或模型改变,适配器也会验证状态并恢复 pi-ai 响应 id 与提供方 signature随后由 pi-ai 判定目标 API 可以复用哪些元数据。没有回放状态的历史会被转换为外来的、与提供方无关的内容,绝不伪装为原生 pi-ai 响应。
成功的 assistant 响应会将经版本化的无损 JSON 回放状态与生成该响应的提供方和模型一同存储,其形式是 `ReplayEnvelope`一个响应级半区kind、版本、API、路由、响应 id、原生停止原因加上每个流式块一条、携带该块 signature 的逐块条目。逐块对齐正是 `BlockAssembler` 在组装丢弃某个块(`max-tokens` 下的工具调用)时裁剪的对象,因此存储的条目始终描述存储的内容——保留的块保有其 signature。请求时,`LlmRuntime` 只有在历史提供方路由与目标提供方路由当前由同一个 `PiAiAdapter` 实例拥有时,才会传递回放状态。即使目标提供方或模型改变,适配器也会验证状态并恢复 pi-ai 响应 id 与提供方 signature随后由 pi-ai 判定目标 API 可以复用哪些元数据。没有回放状态的历史会被转换为外来的、与提供方无关的内容,绝不伪装为原生 pi-ai 响应。
如果 listener 改写已组装 assistant 内容loop 会在记录消息前丢弃回放状态,因为其提供方元数据不再描述该内容。无效版本、格式错误元数据、消息与回放状态之间的提供方/模型不匹配,以及内容/块不匹配都会显式以 `LlmError('INVALID_REPLAY_STATE')` 失败
持久化内容是权威记录;回放状态只负责恢复原生保真度。当前构建无法使用的已存状态——其他适配器的 kind、其他版本包括旧日志携带的平铺前信封形式、格式错误元数据、消息与回放状态之间的提供方/模型不匹配,内容/块不匹配——会把这一条 assistant 消息降级为同样的外来提供方无关转换而不是让请求失败,插件通过其 `onReplayDegrade` 钩子记录 `INVALID_REPLAY_STATE` 诊断
## 词汇差异

View File

@@ -76,6 +76,11 @@ export interface PiAiAdapterOptions {
resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise<string | undefined>
/** Resolve the optional durable attachment service at request time. */
resolveAttachments?: () => AttachmentStore | undefined
/**
* Observe one assistant history message degrading to provider-neutral
* conversion because its stored replay state is unusable by this build.
*/
onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
}
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
@@ -307,9 +312,12 @@ export class PiAiAdapter extends LlmAdapter {
if (containsImage && attachments === undefined) {
throw new LlmError('pi-ai image input requires the durable attachment service', 'UNSUPPORTED_CONTENT')
}
const onReplayDegrade = (reason: string): void => {
this.config.onReplayDegrade?.({ provider: options.provider, model: options.model, reason })
}
const context = attachments === undefined
? toPiContext(options)
: await toPiContext(options, attachments)
? toPiContext(options, undefined, onReplayDegrade)
: await toPiContext(options, attachments, onReplayDegrade)
const events = snapshot.models.streamSimple(model, context, {
...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature },

View File

@@ -84,7 +84,7 @@ function piContext(options: GenerateOptions, messages: PiMessage[]): PiContext {
}
}
function textOnlyContext(options: GenerateOptions): PiContext {
function textOnlyContext(options: GenerateOptions, onReplayDegrade?: (reason: string) => void): PiContext {
const toolNames = new Map<CallId, string>()
const messages: PiMessage[] = []
for (const message of options.messages) {
@@ -96,7 +96,7 @@ function textOnlyContext(options: GenerateOptions): PiContext {
continue
}
if (message.role === 'assistant') {
const assistant = toPiAssistant(message)
const assistant = toPiAssistant(message, onReplayDegrade)
for (const block of assistant.content) if (block.type === 'toolCall') toolNames.set(CallId(block.id), block.name)
messages.push(assistant)
continue
@@ -125,22 +125,43 @@ function textOnlyContext(options: GenerateOptions): PiContext {
* Convert text-only harness history to a synchronous pi-ai Context. Tool
* result names are recovered from preceding assistant tool calls.
* @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot.
* @param attachments - absent; selects the synchronous conversion.
* @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
* @returns the pi-ai context; `tools` is omitted when the request declares none.
*/
export function toPiContext(options: GenerateOptions): PiContext
export function toPiContext(
options: GenerateOptions,
attachments?: undefined,
onReplayDegrade?: (reason: string) => void,
): PiContext
/**
* Convert harness history to a pi-ai Context while resolving durable images.
* Tool result names are recovered from preceding assistant tool calls.
* @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot.
* @param attachments - durable byte resolver for image references.
* @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
* @returns the asynchronously resolved pi-ai context.
*/
export function toPiContext(options: GenerateOptions, attachments: AttachmentStore): Promise<PiContext>
export function toPiContext(options: GenerateOptions, attachments?: AttachmentStore): PiContext | Promise<PiContext> {
return attachments === undefined ? textOnlyContext(options) : toPiContextWithImages(options, attachments)
export function toPiContext(
options: GenerateOptions,
attachments: AttachmentStore,
onReplayDegrade?: (reason: string) => void,
): Promise<PiContext>
export function toPiContext(
options: GenerateOptions,
attachments?: AttachmentStore,
onReplayDegrade?: (reason: string) => void,
): PiContext | Promise<PiContext> {
return attachments === undefined
? textOnlyContext(options, onReplayDegrade)
: toPiContextWithImages(options, attachments, onReplayDegrade)
}
async function toPiContextWithImages(options: GenerateOptions, attachments: AttachmentStore): Promise<PiContext> {
async function toPiContextWithImages(
options: GenerateOptions,
attachments: AttachmentStore,
onReplayDegrade?: (reason: string) => void,
): Promise<PiContext> {
const toolNames = new Map<CallId, string>()
const messages: PiMessage[] = []
@@ -156,7 +177,7 @@ async function toPiContextWithImages(options: GenerateOptions, attachments: Atta
continue
}
if (message.role === 'assistant') {
const assistant = toPiAssistant(message)
const assistant = toPiAssistant(message, onReplayDegrade)
for (const block of assistant.content) {
if (block.type === 'toolCall') toolNames.set(CallId(block.id), block.name)
}

View File

@@ -201,6 +201,12 @@ export function apply(ctx: Context, config: Config): void {
profiles,
resolveApiKey,
resolveAttachments: () => ctx.get('attachments'),
onReplayDegrade: ({ provider, model, reason }) => {
ctx.logger.warn(
`llm-pi-ai: unusable replay state on assistant history for route "${provider}/${model}";`
+ ` sending that message as provider-neutral content (${reason})`,
)
},
})
// The full installed catalog is configurable from the moment the plugin
// mounts — dormant or not — so configuration surfaces can offer every

View File

@@ -9,24 +9,30 @@
*/
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { Message, ModelMessageSource } from '@deepseek-ai/dsh-llm'
import type { Message, ModelMessageSource, ReplayEnvelope } from '@deepseek-ai/dsh-llm'
import type { Api, AssistantMessage, Usage as PiUsage } from '@earendil-works/pi-ai'
type PiAiReplayBlock =
/** Per-block half of the pi-ai replay envelope, one entry per content block. */
export type PiAiReplayBlock =
| { type: 'text'; textSignature?: string }
| { type: 'reasoning'; thinkingSignature?: string; redacted?: boolean }
| { type: 'tool-call'; thoughtSignature?: string }
/** Versioned adapter-private projection required to replay a pi-ai response. */
export interface PiAiReplayState {
/** Versioned response-level half of the pi-ai replay envelope. */
export interface PiAiReplayResponse {
kind: 'pi-ai'
version: 1
version: 2
api: Api
provider: string
model: string
responseModel?: string
responseId?: string
stopReason: AssistantMessage['stopReason']
}
/** The validated halves of one pi-ai replay envelope. */
interface PiAiReplayState {
response: PiAiReplayResponse
blocks: PiAiReplayBlock[]
}
@@ -57,19 +63,25 @@ function emptyPiUsage(): PiUsage {
/**
* Project a successful pi-ai response into the minimal durable replay state.
* The per-block half is index-aligned with the streamed blocks (pi-ai content
* order), so `BlockAssembler` prunes an entry with its block whenever assembly
* removes one.
* @param message - completed native pi-ai assistant response.
* @returns the versioned lossless-JSON replay projection.
*/
export function toPiReplayState(message: AssistantMessage): PiAiReplayState {
return {
export function toPiReplayState(message: AssistantMessage): ReplayEnvelope {
const response: PiAiReplayResponse = {
kind: 'pi-ai',
version: 1,
version: 2,
api: message.api,
provider: message.provider,
model: message.model,
...message.responseModel === undefined ? {} : { responseModel: message.responseModel },
...message.responseId === undefined ? {} : { responseId: message.responseId },
stopReason: message.stopReason,
}
return {
response,
blocks: message.content.map((block): PiAiReplayBlock => {
switch (block.type) {
case 'text': return {
@@ -94,22 +106,26 @@ function invalidReplay(message: string): never {
throw new LlmError(`invalid pi-ai replay state: ${message}`, 'INVALID_REPLAY_STATE')
}
/** Validate the adapter-private state before it reaches pi-ai. */
/** Validate the durable adapter-private envelope before it reaches pi-ai. */
function readReplayState(value: unknown): PiAiReplayState {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidReplay('expected an object')
const state = value as Record<string, unknown>
if (state['kind'] !== 'pi-ai') return invalidReplay('unknown state kind')
if (state['version'] !== 1) return invalidReplay(`unsupported version ${String(state['version'])}`)
if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidReplay('expected a replay envelope')
const envelope = value as Record<string, unknown>
const rawResponse = envelope['response']
if (typeof rawResponse !== 'object' || rawResponse === null || Array.isArray(rawResponse)) return invalidReplay('expected a response object')
const response = rawResponse as Record<string, unknown>
if (response['kind'] !== 'pi-ai') return invalidReplay('unknown state kind')
if (response['version'] !== 2) return invalidReplay(`unsupported version ${String(response['version'])}`)
for (const key of ['api', 'provider', 'model'] as const) {
if (typeof state[key] !== 'string' || state[key].length === 0) return invalidReplay(`${key} must be a non-empty string`)
if (typeof response[key] !== 'string' || response[key].length === 0) return invalidReplay(`${key} must be a non-empty string`)
}
if (!['stop', 'length', 'toolUse', 'error', 'aborted'].includes(String(state['stopReason']))) {
if (!['stop', 'length', 'toolUse', 'error', 'aborted'].includes(String(response['stopReason']))) {
return invalidReplay('unknown stopReason')
}
if (state['responseModel'] !== undefined && typeof state['responseModel'] !== 'string') return invalidReplay('responseModel must be a string')
if (state['responseId'] !== undefined && typeof state['responseId'] !== 'string') return invalidReplay('responseId must be a string')
if (!Array.isArray(state['blocks'])) return invalidReplay('blocks must be an array')
for (const [index, value] of state['blocks'].entries()) {
if (response['responseModel'] !== undefined && typeof response['responseModel'] !== 'string') return invalidReplay('responseModel must be a string')
if (response['responseId'] !== undefined && typeof response['responseId'] !== 'string') return invalidReplay('responseId must be a string')
const blocks = envelope['blocks']
if (!Array.isArray(blocks)) return invalidReplay('blocks must be an array')
for (const [index, value] of blocks.entries()) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidReplay(`block ${index} must be an object`)
const block = value as Record<string, unknown>
if (!['text', 'reasoning', 'tool-call'].includes(String(block['type']))) return invalidReplay(`block ${index} has an unknown type`)
@@ -118,7 +134,10 @@ function readReplayState(value: unknown): PiAiReplayState {
}
if (block['redacted'] !== undefined && typeof block['redacted'] !== 'boolean') return invalidReplay(`block ${index} redacted must be boolean`)
}
return state as unknown as PiAiReplayState
return {
response: response as unknown as PiAiReplayResponse,
blocks: blocks as PiAiReplayBlock[],
}
}
/** Convert provider-neutral blocks without trusting them as same-model replay. */
@@ -159,8 +178,8 @@ function foreignAssistant(message: Message): AssistantMessage {
/** Recombine durable Harness content with validated pi-ai replay metadata. */
function replayedAssistant(message: Message, source: ModelMessageSource, rawState: unknown): AssistantMessage {
const state = readReplayState(rawState)
if (state.provider !== source.provider) return invalidReplay('provider does not match assistant source')
if (state.model !== source.model) return invalidReplay('model does not match assistant source')
if (state.response.provider !== source.provider) return invalidReplay('provider does not match assistant source')
if (state.response.model !== source.model) return invalidReplay('model does not match assistant source')
if (state.blocks.length !== message.content.length) return invalidReplay('block count does not match assistant content')
const content: AssistantMessage['content'] = message.content.map((block, index) => {
const replay = state.blocks[index]
@@ -191,25 +210,40 @@ function replayedAssistant(message: Message, source: ModelMessageSource, rawStat
return {
role: 'assistant',
content,
api: state.api,
provider: state.provider,
model: state.model,
...state.responseModel === undefined ? {} : { responseModel: state.responseModel },
...state.responseId === undefined ? {} : { responseId: state.responseId },
api: state.response.api,
provider: state.response.provider,
model: state.response.model,
...state.response.responseModel === undefined ? {} : { responseModel: state.response.responseModel },
...state.response.responseId === undefined ? {} : { responseId: state.response.responseId },
usage: emptyPiUsage(),
stopReason: state.stopReason,
stopReason: state.response.stopReason,
timestamp: 0,
}
}
/**
* Convert one durable Harness assistant message into pi-ai history.
*
* Durable content is the authoritative record; replay metadata only restores
* native fidelity (ids, signatures). A replay state this build cannot use —
* another adapter's kind, another version, a malformed value, or metadata that
* no longer matches the content — therefore degrades the one message to
* provider-neutral history instead of failing the request.
* @param message - assistant content with required source and optional adapter-owned replay metadata.
* @param onDegrade - called with the diagnostic reason when an unusable replay
* state falls back to provider-neutral conversion.
* @returns a native pi-ai assistant message reconstructed from durable content.
*/
export function toPiAssistant(message: Message): AssistantMessage {
export function toPiAssistant(message: Message, onDegrade?: (reason: string) => void): AssistantMessage {
const source = message.source
return source.kind !== 'model' || source.replayState === undefined
? foreignAssistant(message)
: replayedAssistant(message, source, source.replayState)
if (source.kind !== 'model' || source.replayState === undefined) return foreignAssistant(message)
try {
return replayedAssistant(message, source, source.replayState)
} catch (error: unknown) {
/* v8 ignore next -- replayedAssistant throws only INVALID_REPLAY_STATE LlmErrors today; the
guard keeps a future non-replay failure loud instead of silently degrading it */
if (!(error instanceof LlmError) || error.code !== 'INVALID_REPLAY_STATE') throw error
onDegrade?.(error.message)
return foreignAssistant(message)
}
}

View File

@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import { createUserMessage, CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError, createMessage } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, createMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai'
import { toPiContext } from '../src/context.ts'
@@ -415,35 +415,68 @@ describe('toPiContext', () => {
expect(context.messages[0]).not.toHaveProperty('responseId')
})
it('rejects unsupported replay-state versions with a stable error code', () => {
try {
toPiContext({
provider: 'deepseek',
model: 'm',
messages: [createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
source: {
kind: 'model',
...{
provider: 'deepseek',
model: 'old',
replayState: { kind: 'pi-ai', version: 2 },
},
it('degrades unsupported replay-state versions to provider-neutral history', () => {
const onDegrade = vi.fn()
const context = toPiContext({
provider: 'deepseek',
model: 'm',
messages: [createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
source: {
kind: 'model',
...{
provider: 'deepseek',
model: 'old',
replayState: { response: { kind: 'pi-ai', version: 3 }, blocks: [] },
},
})],
})
expect.fail('expected invalid replay state')
} catch (error: unknown) {
expect(error).toBeInstanceOf(LlmError)
expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE')
expect((error as Error).message).toContain('unsupported version 2')
}
},
})],
}, undefined, onDegrade)
expect(context.messages[0]).toMatchObject({
role: 'assistant',
api: 'dsh-foreign',
provider: 'deepseek',
model: 'old',
content: [{ type: 'text', text: 'done' }],
})
expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining('unsupported version 3'))
})
it('rejects replay metadata whose blocks do not match the durable content', () => {
it('degrades the flat pre-envelope replay state a legacy session log carries', () => {
const onDegrade = vi.fn()
const context = toPiContext({
provider: 'deepseek',
model: 'm',
messages: [createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
source: {
kind: 'model',
...{
provider: 'deepseek',
model: 'deepseek-v4-flash',
replayState: {
kind: 'pi-ai',
version: 1,
api: 'openai-completions',
provider: 'deepseek',
model: 'deepseek-v4-flash',
stopReason: 'stop',
blocks: [{ type: 'text' }],
},
},
},
})],
}, undefined, onDegrade)
expect(context.messages[0]).toMatchObject({ role: 'assistant', api: 'dsh-foreign' })
expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining('expected a response object'))
})
it('degrades replay metadata whose blocks do not match the durable content', () => {
const onDegrade = vi.fn()
const state = toPiReplayState(assistant({ content: [{ type: 'text', text: 'done' }] }))
expect(() => toPiContext({
const context = toPiContext({
provider: 'deepseek',
model: 'm',
messages: [createMessage({
@@ -454,12 +487,19 @@ describe('toPiContext', () => {
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
},
})],
})).toThrow(/block 0 does not match assistant content/)
}, undefined, onDegrade)
expect(context.messages[0]).toMatchObject({
role: 'assistant',
api: 'dsh-foreign',
content: [{ type: 'thinking', thinking: 'done' }],
})
expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining('block 0 does not match assistant content'))
})
it('rejects replay metadata whose block count differs from durable content', () => {
it('degrades replay metadata whose block count differs from durable content', () => {
const onDegrade = vi.fn()
const state = toPiReplayState(assistant())
expect(() => toPiContext({
const context = toPiContext({
provider: 'deepseek',
model: 'm',
messages: [createMessage({
@@ -470,66 +510,34 @@ describe('toPiContext', () => {
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
},
})],
})).toThrow(/block count does not match assistant content/)
}, undefined, onDegrade)
expect(context.messages[0]).toMatchObject({
role: 'assistant',
api: 'dsh-foreign',
provider: 'deepseek',
model: 'deepseek-v4-flash',
content: [{ type: 'text', text: 'done' }],
stopReason: 'stop',
})
expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining('block count does not match assistant content'))
})
const validReplay = {
const validResponse = {
kind: 'pi-ai',
version: 1,
version: 2,
api: 'openai-completions',
provider: 'deepseek',
model: 'deepseek-v4-flash',
stopReason: 'stop',
blocks: [{ type: 'text' }],
}
const validReplay = { response: validResponse, blocks: [{ type: 'text' }] }
it.each([
['provider', { ...validReplay, provider: 'openai' }],
['model', { ...validReplay, model: 'deepseek-v4-pro' }],
])('rejects replay metadata whose %s differs from assistant source', (field, replayState) => {
try {
toPiContext({
provider: 'deepseek',
model: 'next-model',
messages: [createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
source: {
kind: 'model',
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState },
},
})],
})
expect.fail('expected invalid replay state')
} catch (error: unknown) {
expect(error).toBeInstanceOf(LlmError)
expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE')
expect((error as Error).message).toContain(`${field} does not match assistant source`)
}
})
it.each([
['number state', 1, 'expected an object'],
['null state', null, 'expected an object'],
['array state', [], 'expected an object'],
['unknown kind', { ...validReplay, kind: 'other' }, 'unknown state kind'],
['non-string api', { ...validReplay, api: 1 }, 'api must be a non-empty string'],
['empty provider', { ...validReplay, provider: '' }, 'provider must be a non-empty string'],
['missing model', { ...validReplay, model: undefined }, 'model must be a non-empty string'],
['unknown stop reason', { ...validReplay, stopReason: 'pause' }, 'unknown stopReason'],
['non-string response model', { ...validReplay, responseModel: 1 }, 'responseModel must be a string'],
['non-string response id', { ...validReplay, responseId: 1 }, 'responseId must be a string'],
['non-array blocks', { ...validReplay, blocks: 'text' }, 'blocks must be an array'],
['number block', { ...validReplay, blocks: [1] }, 'block 0 must be an object'],
['null block', { ...validReplay, blocks: [null] }, 'block 0 must be an object'],
['array block', { ...validReplay, blocks: [[]] }, 'block 0 must be an object'],
['unknown block type', { ...validReplay, blocks: [{ type: 'audio' }] }, 'block 0 has an unknown type'],
['non-string signature', { ...validReplay, blocks: [{ type: 'text', textSignature: 1 }] }, 'textSignature must be a string'],
['non-boolean redaction', { ...validReplay, blocks: [{ type: 'reasoning', redacted: 'yes' }] }, 'redacted must be boolean'],
])('rejects malformed replay state: %s', (_name, replayState, message) => {
expect(() => toPiContext({
/** Convert with the given state and assert the message degraded to foreign with the given reason. */
function expectDegraded(replayState: unknown, message: string): void {
const onDegrade = vi.fn()
const context = toPiContext({
provider: 'deepseek',
model: 'm',
model: 'next-model',
messages: [createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
@@ -538,7 +546,45 @@ describe('toPiContext', () => {
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState },
},
})],
})).toThrow(message)
}, undefined, onDegrade)
expect(context.messages[0]).toMatchObject({
role: 'assistant',
api: 'dsh-foreign',
content: [{ type: 'text', text: 'done' }],
})
expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining(message))
}
it.each([
['provider', { ...validReplay, response: { ...validResponse, provider: 'openai' } }],
['model', { ...validReplay, response: { ...validResponse, model: 'deepseek-v4-pro' } }],
])('degrades replay metadata whose %s differs from assistant source', (field, replayState) => {
expectDegraded(replayState, `${field} does not match assistant source`)
})
it.each([
['number state', 1, 'expected a replay envelope'],
['null state', null, 'expected a replay envelope'],
['array state', [], 'expected a replay envelope'],
['missing response', { blocks: [] }, 'expected a response object'],
['array response', { ...validReplay, response: [] }, 'expected a response object'],
['unknown kind', { ...validReplay, response: { ...validResponse, kind: 'other' } }, 'unknown state kind'],
['non-string api', { ...validReplay, response: { ...validResponse, api: 1 } }, 'api must be a non-empty string'],
['empty provider', { ...validReplay, response: { ...validResponse, provider: '' } }, 'provider must be a non-empty string'],
['missing model', { ...validReplay, response: { ...validResponse, model: undefined } }, 'model must be a non-empty string'],
['unknown stop reason', { ...validReplay, response: { ...validResponse, stopReason: 'pause' } }, 'unknown stopReason'],
['non-string response model', { ...validReplay, response: { ...validResponse, responseModel: 1 } }, 'responseModel must be a string'],
['non-string response id', { ...validReplay, response: { ...validResponse, responseId: 1 } }, 'responseId must be a string'],
['missing blocks', { response: validResponse }, 'blocks must be an array'],
['non-array blocks', { ...validReplay, blocks: 'text' }, 'blocks must be an array'],
['number block', { ...validReplay, blocks: [1] }, 'block 0 must be an object'],
['null block', { ...validReplay, blocks: [null] }, 'block 0 must be an object'],
['array block', { ...validReplay, blocks: [[]] }, 'block 0 must be an object'],
['unknown block type', { ...validReplay, blocks: [{ type: 'audio' }] }, 'block 0 has an unknown type'],
['non-string signature', { ...validReplay, blocks: [{ type: 'text', textSignature: 1 }] }, 'textSignature must be a string'],
['non-boolean redaction', { ...validReplay, blocks: [{ type: 'reasoning', redacted: 'yes' }] }, 'redacted must be boolean'],
])('degrades malformed replay state: %s', (_name, replayState, message) => {
expectDegraded(replayState, message)
})
})
@@ -565,12 +611,14 @@ describe('toStreamChunks', () => {
type: 'finish',
reason: { kind: 'stop' },
replayState: {
kind: 'pi-ai',
version: 1,
api: 'openai-completions',
provider: 'deepseek',
model: 'deepseek-v4-flash',
stopReason: 'stop',
response: {
kind: 'pi-ai',
version: 2,
api: 'openai-completions',
provider: 'deepseek',
model: 'deepseek-v4-flash',
stopReason: 'stop',
},
blocks: [{ type: 'text' }],
},
},
@@ -614,12 +662,14 @@ describe('toStreamChunks', () => {
type: 'finish',
reason: { kind: 'tool-calls' },
replayState: {
kind: 'pi-ai',
version: 1,
api: 'openai-completions',
provider: 'deepseek',
model: 'deepseek-v4-flash',
stopReason: 'toolUse',
response: {
kind: 'pi-ai',
version: 2,
api: 'openai-completions',
provider: 'deepseek',
model: 'deepseek-v4-flash',
stopReason: 'toolUse',
},
blocks: [{ type: 'tool-call' }],
},
},

View File

@@ -16,13 +16,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import LlmRuntime, { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local'
import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
/** One text block, then a tool call truncated by the output-token ceiling. */
const truncatedToolCallEvents = [
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{"content":"partial"},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"echo","arguments":"{\\"text\\":"}}]},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{},"index":0,"finish_reason":"length"}],"usage":{"prompt_tokens":3,"completion_tokens":4}}',
'[DONE]',
]
let root: string | undefined
let context: Context | undefined
@@ -113,4 +122,123 @@ describe('llm-pi-ai real dormant composition', () => {
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(server.headers[0]?.authorization).toBe('Bearer key-from-store')
})
it('continues natively after max-token assembly drops a tool call, with pruned replay metadata', async () => {
vi.stubEnv('PI_COMPOSITION_KEY', '')
const server = await mockServer([
{ events: truncatedToolCallEvents },
{ events: textEvents },
])
const { ctx, settingsPath } = await loadComposition()
await writeFile(settingsPath, [
'llm-pi-ai:',
' providers:',
' deepseek:',
' apiKeyEnv: PI_COMPOSITION_KEY',
` baseURL: ${server.url}`,
'',
].join('\n'))
await vi.waitFor(() => {
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
}, { timeout: 5000 })
const truncated = await assemble(ctx, {
provider: 'deepseek',
model: 'deepseek-v4-flash',
messages: [],
})
expect(truncated.finish).toEqual({ kind: 'max-tokens' })
expect(truncated.message.content).toEqual([{ type: 'text', text: 'partial' }])
expect(truncated.message.source).toEqual({
kind: 'model',
provider: 'deepseek',
model: 'deepseek-v4-flash',
replayState: {
response: {
kind: 'pi-ai',
version: 2,
api: 'openai-completions',
provider: 'deepseek',
model: 'deepseek-v4-flash',
stopReason: 'length',
},
blocks: [{ type: 'text' }],
},
})
const continued = await assemble(ctx, {
provider: 'deepseek',
model: 'deepseek-v4-flash',
messages: [
truncated.message,
createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }),
],
})
expect(continued.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(server.requests).toHaveLength(2)
expect(server.requests[1]).toMatchObject({
messages: [
{ role: 'assistant', content: 'partial' },
{ role: 'user', content: 'continue' },
],
})
const followup = server.requests[1] as { messages?: unknown[] }
expect(followup.messages?.[0]).not.toHaveProperty('tool_calls')
})
it('continues a legacy session whose stored replay state no longer matches its content', async () => {
vi.stubEnv('PI_COMPOSITION_KEY', '')
const server = await mockServer([{ events: textEvents }])
const { ctx, settingsPath } = await loadComposition()
await writeFile(settingsPath, [
'llm-pi-ai:',
' providers:',
' deepseek:',
' apiKeyEnv: PI_COMPOSITION_KEY',
` baseURL: ${server.url}`,
'',
].join('\n'))
await vi.waitFor(() => {
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
}, { timeout: 5000 })
// A pre-envelope session log entry: max-token assembly dropped the tool
// call from content while the flat v1 state still describes both blocks.
const poisoned = createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'partial' }],
source: {
kind: 'model',
...{
provider: 'deepseek',
model: 'deepseek-v4-flash',
replayState: {
kind: 'pi-ai',
version: 1,
api: 'openai-completions',
provider: 'deepseek',
model: 'deepseek-v4-flash',
stopReason: 'length',
blocks: [{ type: 'text' }, { type: 'tool-call' }],
},
},
},
})
const continued = await assemble(ctx, {
provider: 'deepseek',
model: 'deepseek-v4-flash',
messages: [
poisoned,
createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }),
],
})
expect(continued.finish).toEqual({ kind: 'stop' })
expect(continued.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(server.requests[0]).toMatchObject({
messages: [
{ role: 'assistant', content: 'partial' },
{ role: 'user', content: 'continue' },
],
})
})
})

View File

@@ -11,7 +11,7 @@ import type {
import LlmRuntime, { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import type { PiAiReplayState } from '../src/replay.ts'
import type { PiAiReplayResponse } from '../src/replay.ts'
import { assemble, type AssembledResult } from './assemble.ts'
interface ProviderCase {
@@ -118,18 +118,20 @@ function expectFinish(result: AssembledResult, expected: 'stop' | 'tool-calls'):
expect(result.finish.kind).toBe(expected)
}
function expectNativeReplay(result: AssembledResult, profile: ProviderCase): PiAiReplayState {
function expectNativeReplay(result: AssembledResult, profile: ProviderCase): PiAiReplayResponse {
const replayState = result.message.source.kind === 'model'
? result.message.source.replayState
: undefined
expect(replayState).toMatchObject({
kind: 'pi-ai',
version: 1,
api: profile.api,
provider: profile.provider,
model: profile.model,
response: {
kind: 'pi-ai',
version: 2,
api: profile.api,
provider: profile.provider,
model: profile.model,
},
})
return replayState as PiAiReplayState
return (replayState as { response: PiAiReplayResponse }).response
}
const lookupTool: ToolSchema = {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
README.md: 2cae9a05a58295b06d25382729a3304dbdc9a6fa
README.zh.md: 110cc128f1c19bef1741ae59d8106139c4a72649
README.md: fb6bd84240b41dd730d45b3eb34c35827dc4c991
README.zh.md: 5c22767a7c654972cbf614505382fa4755d7d318

View File

@@ -53,7 +53,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o
Message content is an array of typed blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. A successful `finish` may carry a `ReplayEnvelope` — opaque response-level replay metadata plus optional per-block entries aligned with the emitted block sequence. Assembly makes one keep/drop decision for content and metadata together: a `max-tokens` finish drops tool calls that may have been truncated, and the envelope loses the entry at each dropped position, so stored metadata always describes stored content.
### Call configuration (`call-config.ts`)

View File

@@ -53,7 +53,7 @@
消息内容是类型化内容块数组:`text``reasoning``tool-call``tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源其中携带生成该消息的提供方和模型以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加并一并添加相应的适配器UI压缩compaction支持。
流式输出是原始分片协议(`block-start``text-delta``reasoning-delta``tool-call-delta``block-end``usage``finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error``aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。
流式输出是原始分片协议(`block-start``text-delta``reasoning-delta``tool-call-delta``block-end``usage``finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error``aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。成功的 `finish` 可以携带 `ReplayEnvelope`——不透明的响应级回放元数据,加上与发射块序列对齐的可选逐块条目。组装对内容与元数据只做一次保留/丢弃决定:`max-tokens` 结束会丢弃可能被截断的工具调用,数据在每个被丢弃的位置同步失去对应条目,因此存储的元数据始终描述存储的内容。
### 调用配置(`call-config.ts`

View File

@@ -10,7 +10,7 @@ import { CallId } from './brand.ts'
import { assertNever } from './never.ts'
import { createMessage } from './message.ts'
import type { Message, MessageSource } from './message.ts'
import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from './types.ts'
import type { ContentBlock, FinishReason, ReplayEnvelope, StreamChunk, TokenUsage } from './types.ts'
interface PartialBlock {
blockType: string
@@ -38,7 +38,7 @@ export class BlockAssembler {
private order: number[] = []
private _usage: TokenUsage | undefined
private _finish: FinishReason | undefined
private _replayState: unknown = undefined
private _replayState: ReplayEnvelope | undefined
/**
* Feed one chunk into the assembly state.
@@ -125,6 +125,28 @@ export class BlockAssembler {
return partial
}
/**
* The one shared keep/drop decision over all seen blocks: max-token
* truncation drops tool calls that cannot be executed safely. Emitted blocks
* and replay metadata both derive from this result, so they cannot disagree.
*/
private assembled(): { blocks: ContentBlock[]; replay: ReplayEnvelope | undefined } {
const all = this.order.map(index => this.assemble(this.mustGet(index), index))
const kept = this.finish.kind === 'max-tokens'
? all.map(block => block.type !== 'tool-call')
: undefined
const blocks = kept === undefined ? all : all.filter((_, position) => kept[position])
const envelope = this._replayState
if (envelope?.blocks === undefined) return { blocks, replay: envelope }
if (envelope.blocks.length !== all.length) return { blocks, replay: undefined }
return {
blocks,
replay: kept === undefined || blocks.length === all.length
? envelope
: { response: envelope.response, blocks: envelope.blocks.filter((_, position) => kept[position]) },
}
}
/**
* Assemble all blocks seen so far, in stream order.
* @returns one block per seen index, except that max-token truncation drops
@@ -132,10 +154,7 @@ export class BlockAssembler {
* its accumulated deltas (an unknown block type never closed by `block-end` throws).
*/
blocks(): ContentBlock[] {
const blocks = this.order.map(index => this.assemble(this.mustGet(index), index))
return this.finish.kind === 'max-tokens'
? blocks.filter(block => block.type !== 'tool-call')
: blocks
return this.assembled().blocks
}
/** Usage from the `usage` chunk; undefined until one arrives. */
@@ -148,9 +167,13 @@ export class BlockAssembler {
return this._finish ?? { kind: 'stop' }
}
/** Adapter-private replay state from the terminal finish chunk, if any. */
get replayState(): unknown {
return this._replayState
/**
* Replay metadata from the terminal finish chunk, if any, with per-block
* entries pruned in step with {@link blocks}. Undefined when the envelope's
* entries do not align with the emitted blocks.
*/
get replayState(): ReplayEnvelope | undefined {
return this.assembled().replay
}
/**

View File

@@ -280,6 +280,27 @@ export interface LlmResolvedModelInfo extends LlmModelInfo {
reasoning?: LlmModelReasoningInfo
}
/**
* Adapter-private lossless-JSON state for replaying a successful response,
* carried by a terminal `finish` chunk and stored on the assembled assistant
* message's model source. Both halves stay opaque to the harness; only the
* split is shared vocabulary, so assembly can keep stored metadata aligned
* with stored content without reading either half.
*/
export interface ReplayEnvelope {
/** Response-level adapter-private metadata (ids, native stop reason). */
response: unknown
/**
* Per-block adapter-private metadata, one entry per emitted block in
* first-seen stream order. When assembly drops a block it drops the entry at
* the same position; entries whose length does not match the emitted block
* count discard the whole envelope. An adapter whose metadata is independent
* of block structure omits this field and the envelope passes through
* assembly unchanged.
*/
blocks?: readonly unknown[]
}
/**
* Raw streaming protocol emitted by adapters.
* Block indexes correlate interleaved deltas, and `block-end` carries the
@@ -298,8 +319,8 @@ export type StreamChunk =
| {
type: 'finish'
reason: FinishReason
/** Adapter-private lossless-JSON state for replaying a successful response. */
replayState?: unknown
/** Replay metadata for a successful response; see {@link ReplayEnvelope}. */
replayState?: ReplayEnvelope
}
/**

View File

@@ -118,6 +118,85 @@ describe('BlockAssembler', () => {
})
})
describe('BlockAssembler replay metadata', () => {
const response = { responseId: 'resp-1' }
it('prunes per-block replay entries with the tool calls a max-tokens finish drops', () => {
const assembler = new BlockAssembler()
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'lead' } })
assembler.push({
type: 'block-end',
index: 1,
block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{"text":' },
})
assembler.push({ type: 'block-end', index: 2, block: { type: 'reasoning', text: 'tail' } })
assembler.push({
type: 'finish',
reason: { kind: 'max-tokens' },
replayState: { response, blocks: ['meta-0', 'meta-1', 'meta-2'] },
})
expect(assembler.blocks()).toEqual([
{ type: 'text', text: 'lead' },
{ type: 'reasoning', text: 'tail' },
])
expect(assembler.replayState).toEqual({ response, blocks: ['meta-0', 'meta-2'] })
})
it('omits replay metadata whose per-block entries misalign with the emitted blocks', () => {
const assembler = new BlockAssembler()
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'one' } })
assembler.push({ type: 'block-end', index: 1, block: { type: 'text', text: 'two' } })
assembler.push({
type: 'finish',
reason: { kind: 'stop' },
replayState: { response, blocks: ['meta-0'] },
})
expect(assembler.blocks()).toHaveLength(2)
expect(assembler.replayState).toBeUndefined()
})
it('passes replay metadata through unchanged when assembly drops nothing', () => {
const replayState = { response, blocks: ['meta-0', 'meta-1'] }
const assembler = new BlockAssembler()
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'partial' } })
assembler.push({
type: 'block-end',
index: 1,
block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
})
assembler.push({ type: 'finish', reason: { kind: 'tool-calls' }, replayState })
expect(assembler.replayState).toBe(replayState)
})
it('keeps a max-tokens replay state with no per-block entries across a tool-call drop', () => {
const replayState = { response }
const assembler = new BlockAssembler()
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'partial' } })
assembler.push({
type: 'block-end',
index: 1,
block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{"text":' },
})
assembler.push({ type: 'finish', reason: { kind: 'max-tokens' }, replayState })
expect(assembler.blocks()).toEqual([{ type: 'text', text: 'partial' }])
expect(assembler.replayState).toBe(replayState)
})
it('keeps a text-only max-tokens response and its replay metadata intact', () => {
const replayState = { response, blocks: ['meta-0'] }
const assembler = new BlockAssembler()
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'partial' } })
assembler.push({ type: 'finish', reason: { kind: 'max-tokens' }, replayState })
expect(assembler.blocks()).toEqual([{ type: 'text', text: 'partial' }])
expect(assembler.replayState).toBe(replayState)
})
})
describe('assertNever', () => {
it('throws with diagnostics when a value escapes a closed union at runtime', async () => {
const { assertNever } = await import('@deepseek-ai/dsh-llm')