Merge remote-tracking branch 'origin/master' into feat/account-provider-signout

This commit is contained in:
lsdsjy committed 2026-09-23 20:45:29 +08:00
commit 4e0283c89b
115 files changed
+1022 -376

No files matched your search

@@ -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 .agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md
2026-09-22-tool-call-three-phases.md: e7b42a4ed9e340b73257f7053bddd5221ccc0d20
2026-09-22-tool-call-three-phases.zh.md: 5a68575fd8e2a68199cbedf1ac180a22254b7c54
2026-09-22-tool-call-three-phases.md: f43eaf1a685358f251a446889b6ac25f5fdf0144
2026-09-22-tool-call-three-phases.zh.md: accbd2e966595ae7f124138147d191f97cb763ee
@@ -18,7 +18,7 @@ The [Tool Definition](../../../../packages/client/ui-chat/src/client/conversatio
| Stage | Creation or update evidence | Available data and presentation |
|---|---|---|
| `preparing` | A live delta with a call ID and tool name | Call identity, name, and time; no argument body, and one non-expandable row. |
| `preparing` | A live delta with a call ID and tool name | Call identity, name, and time; no complete arguments, and one non-expandable row. An opt-in Hook can read the raw argument prefix. |
| `start` | `tool/call` | Complete arguments; enables the tool's existing call presentation. |
| `result` | `tool/result` | The result and any complete arguments paired within the loaded window. |
@@ -30,7 +30,7 @@ The [Conversation Definition](../../../../packages/client/ui-conversation/src/cl
A named tool delta and `tool/call` are both initialization candidates. A live delta can create preparation; a historical `tool/call` creates the dispatched stage directly. Matching still reads only the current event, without Context lookup, additional roles, registries, or separate caches.
When stream settlement removes temporary deltas, the Assembler selects the start from the remaining Matches, refreshes predecessor indexes, and recomputes State. A published preparation node hides when no call evidence remains; a later dispatch retains the same callId. Undispatched calls do not receive synthetic execution results.
Successful stream settlement publishes the final Assistant message while retaining transient deltas until `step/end`. Each `tool/call` updates its existing callId; the Assistant keeps its live ordering anchor until the same cleanup. Failed, interrupted, or abandoned streams retire their deltas immediately. On retirement the Assembler reselects starts and recomputes State from remaining Matches. Undispatched preparations hide without synthetic execution results.
### Converge on final state without replaying preparation
@@ -40,7 +40,7 @@ Groups include Tool nodes from preparation onward and classify and count them by
### Shared rows reuse their existing presentation
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model selects the tool title and combines any generic tool-name prefix with the available argument summary. Preparation has no arguments or result, and the shared argument parser returns null without parsing JSON. ToolRow prevents preparation rows from expanding.
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model selects the tool title and combines any generic tool-name prefix with the available argument summary. Preparation has no complete arguments or result, and the shared argument parser returns null without parsing JSON. The existing slot Hook binding optionally exposes a call-scoped raw prefix; it adds no preparation registration mechanism. ToolRow prevents preparation rows from expanding.
Read, read_image, write/edit, search, web, todo, question, details, and the generic fallback use this path. Custom rows such as Bash, Skill, Present, and Cordis keep their own preparation branches. No automatic/custom registration declaration or second tool-classification list in ToolTree is introduced.
@@ -62,6 +62,6 @@ This decision replaces the [business-node assembly](2026-08-09-client-conversati
- A tool may appear before its arguments are complete, but preparation neither implies execution nor exposes interactions requiring arguments.
- Multiple starts with one ID belong to one lifecycle. Independent calls must use distinct IDs; a second-start error no longer detects identity reuse.
- Shared rows do not replace the whole row component when preparation becomes dispatch. Custom rows may still switch their internal components by stage.
- Partial JSON parsing and `useToolCallDelta` are not provided; argument-driven early presentation is outside this implementation.
- The shared row primitive is reused across stages. Write/edit separate preparing and dispatched components so only the former subscribes to raw arguments; custom rows may also switch internal components by stage.
- Partial JSON parsing and `useToolCallDelta` are not provided. `useToolCallArgumentsPartial` exposes the existing Step's raw prefix without a second accumulator; write/edit use only its length for preparation progress.
- Assembly tests separately cover live preparation, withdrawal, active-stream reconstruction, durable history, and paged convergence. Component tests cover argument-free presentation and stage changes; a recorded session covers browser preparation and reload results.
@@ -18,7 +18,7 @@ Status: implemented
| 阶段 | 创建或更新依据 | 可用数据与展示 |
|---|---|---|
| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称和时间;不提供参数正文,只显示不可展开的一行。 |
| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称和时间;不提供完整参数,只显示不可展开的一行。可选钩子可读取原始参数前缀。 |
| `start` | `tool/call` | 完整参数;启用工具原有的调用展示。 |
| `result` | `tool/result` | 调用结果,以及当前窗口内可配对的完整参数。 |
@@ -30,7 +30,7 @@ Status: implemented
Tool 的具名 delta 和 `tool/call` 都是创建入口。实时先收到 delta,就从 preparing 创建;历史先收到 `tool/call`,就从 start 创建。匹配函数仍然只读取当前事件,不查询 Context,不增加角色、注册表或旁路缓存。
流结束移除临时 delta 后,Assembler 从剩余 Match 重新选择 start,刷新前序索引并重算 State。没有剩余调用证据时,已发布的准备节点隐藏;后续正式调用仍使用相同 callId。未派发的调用不合成执行结果。
成功流结束时发布最终 Assistant 消息,瞬态 delta 保留到 `step/end`。每个 `tool/call` 更新已有 callId;Assistant 的实时排序锚点也保留到同一次清理。失败、中断或废弃的流立即移除 delta。清理时,Assembler 从剩余 Match 重新选择 start 并重算 State;未派发的准备节点隐藏,不合成执行结果。
### 最终状态收敛,不重现准备过程
@@ -40,7 +40,7 @@ Group 从准备阶段起就聚合 Tool 节点,按工具名分类和计数。As
### 通用行复用现有外壳
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型选择工具标题,并组合通用工具名前缀与已有参数摘要。准备阶段没有参数或结果,共享参数解析入口直接返回 null,不解析 JSON;ToolRow 禁止准备行展开。
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型选择工具标题,并组合通用工具名前缀与已有参数摘要。准备阶段没有完整参数或结果,共享参数解析入口直接返回 null,不解析 JSON。既有 slot 钩子绑定可选地提供当前调用的原始前缀,不增加准备态注册机制;ToolRow 禁止准备行展开。
read、read_image、write/edit、search、web、todo、question、details 和通用回退使用这条路径。Bash、Skill、Present、Cordis 等自定义外壳保留自己的准备态分支。不增加自动/自管注册声明,不在 ToolTree 维护另一份工具分类名单。
@@ -62,6 +62,6 @@ read、read_image、write/edit、search、web、todo、question、details 和通
- 工具可以在参数完整前出现,但准备展示不表示工具已经执行,也不暴露依赖参数的交互。
- 同一 ID 的多个 start 被视为同一个生命周期;业务必须为独立调用提供不同 ID,不能再依赖第二个 start 报错检测身份复用。
- 通用行不因准备/派发分支替换整行组件;自定义外壳仍可按阶段切换内部组件。
- 不提供部分 JSON 解析或 `useToolCallDelta`,参数驱动的提前展示不属于本次实现。
- 各阶段复用通用行基础组件。write/edit 分离准备态与派发后组件,只有前者订阅原始参数;自定义外壳也可按阶段切换内部组件。
- 不提供部分 JSON 解析或 `useToolCallDelta`。`useToolCallArgumentsPartial` 暴露既有 Step 的原始前缀,不另建累积器;write/edit 只使用其长度展示准备进度。
- 组装测试分别覆盖实时准备态、撤回、活跃流重建、持久历史与分页收敛;组件测试检查无参数展示和阶段切换,录制会话覆盖浏览器中的准备态与重载结果。
+4 -1
View File
@@ -74,7 +74,10 @@ export async function runPlugin(profile: string, args: readonly string[]): Promi
onOutput: (text, stream) => { process[stream].write(text) },
})
if (result.exitCode === 127) process.stderr.write('dsh: pnpm was not found; install pnpm and make it available on PATH.\n')
if (result.exitCode !== 0) process.stderr.write(`dsh: pnpm failed; diagnostics: ${result.logPath}\n`)
for (const { name, version, runtimeVersion } of result.incompatible ?? []) {
process.stderr.write(`dsh: to accept the risk, run: dsh plugin --profile ${profile} allow-version ${name}@${version} --dsh-version ${runtimeVersion} --accept-risk\n`)
}
if (result.exitCode !== 0) process.stderr.write(`dsh: plugin command failed; diagnostics: ${result.logPath}\n`)
if (result.exitCode !== 0 && args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) {
process.stderr.write(`dsh: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — add the exact key pnpm printed above under allowBuilds in ${join(resolveProfileDir(profile), 'pnpm-workspace.yaml')}, then re-run\n`)
}
+14 -1
View File
@@ -78,7 +78,20 @@ it.each([0, 1])('forwards ordinary pnpm output and exit status %s', async (exitC
expect(await runPlugin('test', ['list'])).toBe(exitCode)
expect(stdout).toHaveBeenCalledWith('pnpm output')
if (exitCode === 0) expect(stderr).not.toHaveBeenCalled()
else expect(stderr).toHaveBeenCalledWith('dsh: pnpm failed; diagnostics: /profile/log\n')
else expect(stderr).toHaveBeenCalledWith('dsh: plugin command failed; diagnostics: /profile/log\n')
})
it('names the exact grant command for each package a compatibility check refused', async () => {
const { stderr } = fixture()
vi.mocked(runPluginCommand).mockResolvedValue({
exitCode: 1, output: '', truncated: false, logPath: '/profile/log',
incompatible: [{ name: '@example/plugin', version: '1.2.3', runtimeVersion: '0.1.0', peers: { '@deepseek-ai/dsh': '^9.0.0' } }],
})
expect(await runPlugin('test', ['add', '@example/plugin'])).toBe(1)
expect(stderr.mock.calls.map(call => call[0])).toEqual([
'dsh: to accept the risk, run: dsh plugin --profile test allow-version @example/[email protected] --dsh-version 0.1.0 --accept-risk\n',
'dsh: plugin command failed; diagnostics: /profile/log\n',
])
})
it('forwards all other commands unchanged and retains package diagnostics', async () => {
+39 -2
View File
@@ -14,7 +14,7 @@ import {
fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { openSettings, connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
import { openSettings, connectFreshWorkspaceZh, expandOwningTurnProcess, ZH_BROWSER_LOCALE } from './support.ts'
const DIR = fileURLToPath(new URL('../../../snapshots/web/changed-files-turn', import.meta.url))
const FIXTURE = join(DIR, 'session.v3.jsonl')
@@ -62,6 +62,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
let tripwire: ReturnType<typeof watchConsole>
let cwd: string
let replayRoot: string | undefined
let releasePreparations: (() => void) | undefined
beforeAll(async () => {
let replayOverride: string | undefined
@@ -92,6 +93,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
})
afterAll(async () => {
releasePreparations?.()
try {
await browser?.close()
} finally {
@@ -106,10 +108,45 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
it('records the edited, created, and shell-appended files with their line counts', async () => {
if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
const settled = scaffold.whenTurnSettled()
const preparations = ['edit', 'write'].map(name => ({
name, ready: Promise.withResolvers<{ callId: string; kilobytes: number }>(),
release: Promise.withResolvers<undefined>(), held: false,
}))
const names = new Map<string, string>()
const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) {
for await (const chunk of next()) {
yield chunk
if (chunk.type !== 'tool-call-delta') continue
if (chunk.name !== undefined) names.set(chunk.id, chunk.name)
const preparation = preparations.find(value => value.name === names.get(chunk.id))
if (preparation === undefined || preparation.held || chunk.argumentsDelta.length === 0) continue
preparation.held = true
preparation.ready.resolve({ callId: chunk.id, kilobytes: Math.ceil(chunk.argumentsDelta.length / 1024) })
await preparation.release.promise
}
}, { prepend: true })
releasePreparations = () => {
for (const preparation of preparations) preparation.release.resolve(undefined)
dispose()
}
const input = page.locator('[data-composer-input]').first()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
const observations = preparations.map(async (preparation) => {
const { callId, kilobytes } = await Promise.race([
preparation.ready.promise,
settled.then(() => { throw new Error(`No ${preparation.name} argument prefix was streamed`) }),
])
const row = page.locator(`[data-chat-call-id="${callId}"] [data-state="preparing"]`)
await row.waitFor({ state: 'attached' })
await expandOwningTurnProcess(page, row)
await row.getByText(`正在准备内容 ${kilobytes}KB`, { exact: true }).waitFor()
expect(await row.getByRole('button').count()).toBe(0)
expect(await row.locator('pre').count()).toBe(0)
await compareOrRefreshGolden(join(DIR, `preparing-${preparation.name}.expected.md`), await row.ariaSnapshot(), MODE)
preparation.release.resolve(undefined)
})
const [sessionId] = await Promise.all([settled, ...observations]).finally(() => { releasePreparations?.() })
const session = scaffold.ctx.agents.get(sessionId)?.session
if (session?.header.cwd === undefined) throw new Error('changed-files Session has no workspace')
cwd = session.header.cwd
@@ -14,9 +14,9 @@
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Called tools" [expanded]
- button "Think"
- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package."
- button "Inspect plugins 0 dynamic plugins"
- button "Think"
- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package."
- button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded]
- tablist "Plugin source":
- tab "Client"
@@ -28,11 +28,11 @@
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
- text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings
- button "Inspect"
- button "Think"
- button "Think The Host returned snap-1/pkg-1, so I will request its first activation."
- text: Run Cordis Plugin snap-1 · pkg-1 Ready
- button "Inspect"
- text: snap-1/pkg-1 is awaiting user approval (run-1).
- button "Think"
- button "Think The activation request has been submitted, so I will return the requested readiness marker."
- paragraph: CORDIS_UI_READY
- button "Copy"
- button "Good response"
@@ -18,8 +18,8 @@
- text: 字号大小 仅影响会话内容的字号 14
- button "增大字号"
- button "减小字号"
- text: px 工作过程展示 控制轮次和步骤的默认展开方式
- button "简洁"
- text: px 工作步骤展示 选择希望看到多少工具调用细节
- button "标准"
- text: 性能与用量 选择性能与用量信息展示的详细程度
- button "详细"
- text: 开发者工具 显示用于调试和排查问题的工具与信息
@@ -18,8 +18,8 @@
- text: Font size Only affects conversation content 14
- button "Increase font size"
- button "Decrease font size"
- text: px Work details Controls how turns and steps expand by default
- button "Compact"
- text: px Work details Choose how much detail to show for tool calls
- button "Standard"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
- text: Developer tools Show tools and information for debugging and troubleshooting
@@ -18,8 +18,8 @@
- text: Font size Only affects conversation content 14
- button "Increase font size"
- button "Decrease font size"
- text: px Work details Controls how turns and steps expand by default
- button "Compact"
- text: px Work details Choose how much detail to show for tool calls
- button "Standard"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
- text: Developer tools Show tools and information for debugging and troubleshooting
@@ -18,8 +18,8 @@
- text: Font size Only affects conversation content 14
- button "Increase font size"
- button "Decrease font size"
- text: px Work details Controls how turns and steps expand by default
- button "Compact"
- text: px Work details Choose how much detail to show for tool calls
- button "Standard"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
- text: Developer tools Show tools and information for debugging and troubleshooting
@@ -18,8 +18,8 @@
- text: 字号大小 仅影响会话内容的字号 14
- button "增大字号"
- button "减小字号"
- text: px 工作过程展示 控制轮次和步骤的默认展开方式
- button "简洁"
- text: px 工作步骤展示 选择希望看到多少工具调用细节
- button "标准"
- text: 性能与用量 选择性能与用量信息展示的详细程度
- button "详细"
- text: 开发者工具 显示用于调试和排查问题的工具与信息
@@ -10,7 +10,7 @@
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- button "Analyzing the request"
- button "Analyzing the request · The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
- text: "Interjection: include the word BANANA in your final reply."
- button "Copy"
- text: "Interjection: include the word ORANGE in your final reply."
@@ -11,7 +11,7 @@
- status: Worked
- button "Took {{duration}}" [disabled]
- button "Asked questions" [expanded]
- button "Think"
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
- button "Ask question 1/1 answered"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy"
+8 -8
View File
@@ -560,15 +560,15 @@ describe('web e2e: settings modal and General preferences', () => {
}, 90_000)
it.each([
['detailed', '详细'], ['expanded', '完全展开'],
['compact', '简洁'], ['detailed', '详细'], ['verbose', '完全展开'],
] as const)('persists the %s work-details mode across reload', async (mode, label) => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view'))
await openSettings(page, 'zh')
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByText('工作过程展示', { exact: true }).waitFor({ timeout: 10_000 })
const details = dialog.getByText('工作过程展示', { exact: true }).locator('../..')
await details.getByRole('button', { name: '简洁', exact: true }).click()
await dialog.getByText('工作步骤展示', { exact: true }).waitFor({ timeout: 10_000 })
const details = dialog.getByText('工作步骤展示', { exact: true }).locator('../..')
await details.getByRole('button', { name: '标准', exact: true }).click()
await page.getByRole('menuitem', { name: label, exact: true }).click()
await details.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
@@ -581,14 +581,14 @@ describe('web e2e: settings modal and General preferences', () => {
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await openSettings(page, 'zh')
const reloaded = page.getByRole('dialog', { name: '设置' })
const restoredDetails = reloaded.getByText('工作过程展示', { exact: true }).locator('../..')
const restoredDetails = reloaded.getByText('工作步骤展示', { exact: true }).locator('../..')
await restoredDetails.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
await restoredDetails.getByRole('button', { name: label, exact: true }).click()
await page.getByRole('menuitem', { name: '简洁', exact: true }).click()
await restoredDetails.getByRole('button', { name: '简洁', exact: true }).waitFor({ timeout: 10_000 })
await page.getByRole('menuitem', { name: '标准', exact: true }).click()
await restoredDetails.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 })
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
.toContain('transcriptView: compact')
.toContain('transcriptView: standard')
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
+20 -13
View File
@@ -244,8 +244,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
}, 60_000)
it.skipIf(MODE === 'record').each([
['detailed', 'Detailed'], ['expanded', 'Expanded'],
] as const)('preserves whole-Turn folding in %s mode', async (mode, label) => {
['compact', 'Compact'], ['detailed', 'Detailed'], ['verbose', 'Verbose'],
] as const)('applies whole-Turn presentation in %s mode', async (mode, label) => {
await launch()
onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting'))
const { settled } = await sendPrompt()
@@ -259,31 +259,38 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
await openSettings(page, 'en')
const dialog = page.getByRole('dialog', { name: 'Settings' })
await dialog.getByText('Work details', { exact: true }).locator('../..')
.getByRole('button', { name: 'Compact', exact: true }).click()
.getByRole('button', { name: 'Standard', exact: true }).click()
await page.getByRole('menuitem', { name: label, exact: true }).click()
await page.keyboard.press('Escape')
expect(await process.count()).toBe(1)
expect(await process.getAttribute('aria-expanded')).toBe('false')
expect(await tool.isVisible()).toBe(false)
await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
.toContain(`transcriptView: ${mode}`)
await process.click()
const group = page.locator('[data-sample="bash"]').first().locator('xpath=ancestor::*[@data-chat-group-key]')
const groupControl = group.locator('[data-process-activity]')
expect(await groupControl.isVisible()).toBe(true)
expect(await groupControl.getAttribute('aria-expanded')).toBe('false')
expect(await tool.isVisible()).toBe(false)
await groupControl.click()
expect(await tool.isVisible()).toBe(true)
await process.click()
if (mode === 'verbose') {
expect(await process.isDisabled()).toBe(true)
expect(await process.getAttribute('aria-expanded')).toBe('true')
expect(await groupControl.isVisible()).toBe(false)
expect(await tool.isVisible()).toBe(true)
} else {
expect(await process.getAttribute('aria-expanded')).toBe('false')
expect(await tool.isVisible()).toBe(false)
await process.click()
expect(await groupControl.isVisible()).toBe(true)
expect(await groupControl.getAttribute('aria-expanded')).toBe('false')
expect(await tool.isVisible()).toBe(false)
await groupControl.click()
expect(await tool.isVisible()).toBe(true)
await process.click()
}
await openSettings(page, 'en')
const restored = page.getByRole('dialog', { name: 'Settings' })
await restored.getByText('Work details', { exact: true }).locator('../..')
.getByRole('button', { name: label, exact: true }).click()
await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
await page.getByRole('menuitem', { name: 'Standard', exact: true }).click()
await page.keyboard.press('Escape')
await process.waitFor({ timeout: 10_000 })
expect(await process.getAttribute('aria-expanded')).toBe('false')
+2 -2
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 docs/event-producer-consumer.md
event-producer-consumer.md: 217f2de54302cfd11b44475f3382770f2f18a76e
event-producer-consumer.zh.md: 809fdc869c0b9d6d2251750e99cabc8fe220e399
event-producer-consumer.md: ff9fc19ab364e61e3dd1847531bfac3377676a3b
event-producer-consumer.zh.md: f1f8d5cd80dd20037d0caba3b3061924c280fb55
+3 -3
View File
@@ -55,9 +55,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:230`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+3 -3
View File
@@ -57,9 +57,9 @@
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:230`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+2 -1
View File
@@ -27,7 +27,8 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
export type {
BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, InstallSpecKind, ManagementError, PackageResult,
BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind, ManagementError,
PackageResult,
PluginChange, PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind,
PluginInstallLogChunk, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection, ReadOnlyReason, Registry,
} from '@deepseek-ai/dsh-plugin-manager/types'
@@ -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/api/session-controller/README.md
README.md: b3307ce9d6eaab4f5247214531f354228b640172
README.zh.md: 4479d3f60046f0bd724a9b6cf42bcace63da20c5
README.md: 476eb9bb1acc356000380d0e4e50dfcc30c91f34
README.zh.md: 91e563c3a833e3a2922b318e342f2de1f3a55cc2
+1 -1
View File
@@ -43,7 +43,7 @@ Projection reads retain loading and failure state independently of their values.
Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index releases the durable entry. A successful message retains its transient rows until the owning `step/end`, so pending tool identities remain available for dispatch. Failed or interrupted settlements retire their rows immediately; earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
+1 -1
View File
@@ -43,7 +43,7 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时
已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布持久条目。成功消息的瞬态 row 保留到所属 `step/end`,让待派发工具的身份持续可用;失败或中断的 settlement 立即移除对应 row,同一步骤中更早的 retry 仍然保留。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
@@ -24,7 +24,12 @@ interface ActiveAttempt {
/** One Web publication decision from the assistant stream fold. */
export type ClientAssistantStreamResult =
| { readonly type: 'publish'; readonly entry: SessionLiveEventEntry }
| {
readonly type: 'publish'
readonly entry: SessionLiveEventEntry
/** Retire the successful attempt's transient rows after publishing its Step end. */
readonly retireAttemptId?: LlmAttemptId
}
| {
readonly type: 'settlement'
readonly attemptId: LlmAttemptId
@@ -38,6 +43,7 @@ export type ClientAssistantStreamResult =
/** Keeps transient Assistant presentation behind one settlement-aware interface. */
export class ClientAssistantStream {
private activeAttempt: ActiveAttempt | undefined
private retainedAttempt: Pick<ActiveAttempt, 'attemptId' | 'turn' | 'step'> | undefined
private readonly pending = new Map<number, SessionAssistantSettlementEntry>()
private publishedSeqs = new Set<number>()
private durableCursor = -1
@@ -56,6 +62,7 @@ export class ClientAssistantStream {
this.pending.clear()
this.transientInGap = 0
this.activeAttempt = undefined
this.retainedAttempt = undefined
const opening = baseline?.activeAttempt
if (opening !== undefined) {
this.activeAttempt = {
@@ -114,13 +121,16 @@ export class ClientAssistantStream {
/**
* Fold one dense transient frame and release its named durable settlement.
* Successful messages retain their transient rows until the owning Step ends;
* interrupted messages, failed attempts, and abandonment retire them immediately.
* @param frame - next Assistant stream frame received by the follow connection.
* @returns a transient, publication, or rebaseline decision, or `undefined` when no entry becomes visible.
*/
acceptFrame(frame: SessionAssistantStreamFrame): ClientAssistantStreamResult {
switch (frame.type) {
case 'start':
if (this.activeAttempt !== undefined || this.pending.size > 0) return { type: 'rebaseline' }
if (this.activeAttempt !== undefined || this.retainedAttempt !== undefined
|| this.pending.size > 0) return { type: 'rebaseline' }
this.pending.clear()
this.activeAttempt = {
attemptId: frame.attemptId,
@@ -176,6 +186,10 @@ export class ClientAssistantStream {
return { type: 'rebaseline' }
}
this.pending.delete(frame.outcome.seq)
if (entry.event.type === 'assistant/message' && entry.event.data.interrupted !== true) {
this.retainedAttempt = { attemptId: attempt.attemptId, turn: attempt.turn, step: attempt.step }
return this.publish(entry)
}
this.publishedSeqs.add(entry.event.seq)
return { type: 'settlement', attemptId: attempt.attemptId, entry }
}
@@ -196,6 +210,12 @@ export class ClientAssistantStream {
private publish(entry: SessionLiveEventEntry): ClientAssistantStreamResult {
this.publishedSeqs.add(entry.event.seq)
const retained = this.retainedAttempt
if (retained !== undefined && entry.event.type === 'step/end'
&& entry.event.data.turn === retained.turn && entry.event.data.step === retained.step) {
this.retainedAttempt = undefined
return { type: 'publish', entry, retireAttemptId: retained.attemptId }
}
return { type: 'publish', entry }
}
}
@@ -723,8 +723,10 @@ export class Session implements SessionFace {
this.notifier.markDirty()
return
}
if (result?.type === 'publish' && this.appendLive(result.entry)) {
this.notifier.markDirty()
if (result?.type === 'publish') {
const changed = this.appendLive(result.entry)
if (result.retireAttemptId !== undefined) this.eventSource.settleAssistant(result.retireAttemptId)
if (changed || result.retireAttemptId !== undefined) this.notifier.markDirty()
} else if (result?.type === 'transient') {
this.eventSource.append(result.entry)
this.notifier.markDirty()
@@ -136,14 +136,48 @@ describe('ClientAssistantStream', () => {
}
})
it('stages one owned settlement and releases it from the matching end frame', () => {
it('publishes a successful message at stream end and retires its chunks at its Step end', () => {
const stream = opened()
const durable = messageEvent(2)
expect(stream.acceptDurable(durable)).toBeUndefined()
expect(stream.acceptFrame(chunkFrame(0))).toEqual(expect.objectContaining({ type: 'transient' }))
expect(stream.acceptFrame(end(1, {
kind: 'committed', eventType: 'assistant/message', seq: 2,
}))).toEqual({ type: 'settlement', attemptId: String(ATTEMPT), entry: durable })
}))).toEqual({ type: 'publish', entry: durable })
for (const [turn, step] of [[2, 1], [1, 2]]) {
const unrelated = entry({ type: 'step/end', seq: SessionSeq(3), time: 3, data: { turn: turn!, step: step! } })
expect(stream.acceptDurable(unrelated)).toEqual({ type: 'publish', entry: unrelated })
}
const closed = entry({ type: 'step/end', seq: SessionSeq(4), time: 4, data: { turn: 1, step: 1 } })
expect(stream.acceptDurable(closed)).toEqual({ type: 'publish', entry: closed, retireAttemptId: ATTEMPT })
expect(stream.acceptDurable(ordinary(5))).toEqual({ type: 'publish', entry: ordinary(5) })
expect(stream.acceptFrame(start(LlmAttemptId('session:2'), 4))).toBeUndefined()
})
it('clears retained chunks on replacement and refuses a new attempt before Step completion', () => {
const stream = opened()
const durable = messageEvent(2)
stream.acceptDurable(durable)
stream.acceptFrame(end(0, { kind: 'committed', eventType: 'assistant/message', seq: 2 }))
expect(stream.acceptFrame(start(LlmAttemptId('session:2')))).toEqual({ type: 'rebaseline' })
expect(stream.replace([durable])).toEqual([durable])
const closed = entry({ type: 'step/end', seq: SessionSeq(3), time: 3, data: { turn: 1, step: 1 } })
expect(stream.acceptDurable(closed)).toEqual({ type: 'publish', entry: closed })
expect(stream.acceptFrame(start(LlmAttemptId('session:2'), 3))).toBeUndefined()
})
it('retires failed attempts and interrupted messages at stream end', () => {
const message = messageEvent(2)
if (message.event.type !== 'assistant/message') throw new Error('expected assistant message')
const interrupted = entry({ ...message.event, data: { ...message.event.data, interrupted: true as const } })
for (const durable of [attemptEvent(2), interrupted]) {
const stream = opened()
stream.acceptDurable(durable)
expect(stream.acceptFrame(end(0, {
kind: 'committed', eventType: durable.event.type as 'assistant/message' | 'assistant/attempt', seq: 2,
}))).toEqual({ type: 'settlement', attemptId: ATTEMPT, entry: durable })
expect(stream.acceptFrame(start(LlmAttemptId('session:2'), 2))).toBeUndefined()
}
})
it('rebaselines duplicate durable settlements or starts', () => {
@@ -164,7 +164,7 @@ describe('scope tree', () => {
expect(b.mock.remote.session.projections).not.toHaveBeenCalled()
})
it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async ({ bench }) => {
it('publishes the final Assistant message before retiring transient chunks at Step end', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
@@ -232,13 +232,20 @@ describe('scope tree', () => {
})
await b.mock.streams.drained(FOLLOW)
await vi.waitFor(() => {
expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
expect(binding.eventSource.getSnapshot().entries).toHaveLength(2)
})
expect(publications).toEqual([
['assistant/live-chunk'],
['assistant/message'],
['assistant/live-chunk', 'assistant/message'],
])
b.mock.streams.push(FOLLOW, {
type: 'event', event: { type: 'step/end', seq: 1, time: 3, data: { turn: 1, step: 1 } },
})
await b.mock.streams.drained(FOLLOW)
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
.toEqual(['assistant/message', 'step/end'])
expect(binding.eventSource.getSnapshot().change).toEqual({ kind: 'settle-assistant', attemptId })
dispose()
})
@@ -373,10 +380,10 @@ describe('scope tree', () => {
await b.mock.streams.drained(FOLLOW)
await vi.waitFor(() => {
expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
.toEqual(['assistant/message', 'assistant/message'])
.toEqual(['assistant/message', 'assistant/live-chunk', 'assistant/message'])
})
expect(binding.eventSource.getSnapshot().change).toEqual({
kind: 'settle-assistant', attemptId: String(attemptId), entry: currentMessage,
kind: 'append', entries: [currentMessage],
})
})
+2 -2
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/boot/app-boot/README.md
README.md: 729a4b1552cd28e89698580860f8c9e31043a76c
README.zh.md: c401978fdf05651479c8c6b74b01d48287fa2b63
README.md: c32f8e4dd9065897a7dcbe4b7a2fd2e067b25e46
README.zh.md: 0bbdab2a5d51deeb6b63bf972e6550f9b4034db1
+1 -1
View File
@@ -51,7 +51,7 @@ A profile is how one dsh installation ships different app surfaces: `web`, `head
Before a profile imports a plugin, DSH checks its `peerDependencies` on `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` against the single runtime version returned by `getDshRuntimeVersion()`. Every declared range must match; prereleases participate in range matching. Source-workspace `workspace:^`, `workspace:~`, and `workspace:*` refer to that same runtime. Missing DSH peers impose no constraint; invalid ranges are incompatible. These checks use peer declarations, not `engines.dsh`, and are not a sandbox against malicious package code.
Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` peer declaration, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin still appears there, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust.
Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. A bundle is not a row, so `loadProfileDirectory` checks each bundle's own DSH peers when it loads the profile's bundle layers, at startup and on every recomposition; an incompatible bundle without an exemption is skipped like an unreadable one. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` peer declaration, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin row still appears there while a denied bundle contributes no rows, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust.
Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires.
+1 -1
View File
@@ -51,7 +51,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek-ai/dsh` 和 `@deepseek-ai/dsh-*` 的依赖,与 `getDshRuntimeVersion()` 返回的唯一运行时版本比较。每个声明的版本范围都必须匹配;预发布版本参与范围匹配。源码工作区的 `workspace:^`、`workspace:~` 和 `workspace:*` 指向同一个运行时。未声明 DSH peer 时不施加版本约束;无效范围视为不兼容。这些检查使用 peer 声明,而不是 `engines.dsh`,也不是防范恶意包代码的沙箱。
检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` peer 声明,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件仍会出现在其中;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。
检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。组合包本身不是行,因此 `loadProfileDirectory` 在启动和每次重新组合加载 profile 的组合包层时,检查每个组合包自己声明的 DSH peer;没有豁免的不兼容组合包会像无法读取的组合包一样被跳过。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` peer 声明,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件行仍会出现在其中,而被拒绝的组合包不提供任何行;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。
精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。
@@ -77,7 +77,9 @@ export function prepareProfileEntries(
// Admission runs before the composed tree mounts, so no plugin-owned logger exporter exists yet;
// composition-stage diagnostics go to stderr like the profile launcher's skipped-bundle report.
return preflight(ctx, entries, parentURL, (row, reason) => {
process.stderr.write(`${binName}: disabling profile plugin ${row.name}: ${reason}\n`)
// Preset rows may omit ids, and a bundle row's name is its resolved module URL.
const label = typeof row.id === 'string' ? `row ${JSON.stringify(row.id)}` : row.name
process.stderr.write(`${binName}: disabling profile plugin ${label}: ${reason}\n`)
}).rows
}
+2 -2
View File
@@ -401,8 +401,8 @@ export interface ConfigDumpLayer {
* layer targeting a group child a plain config replacement introduced, which
* the single-pass id index never sees) compose identically — then render the
* result as YAML in the same dialect (`!!js` expressions print verbatim,
* unevaluated). Admission is a later stage: this dump reports what the profile
* configures, and a plugin the compatibility policy denies still appears here.
* unevaluated). Row admission is a later stage: a plugin row the compatibility
* policy denies still appears here, while a denied bundle contributes no layer.
*
* Every run of rows from the same file and patch layers is preceded by a `# ==` comment
* naming the file that contributed the rows and any layers that patched them,
@@ -89,14 +89,15 @@ export function evaluatePluginCompatibility(
/**
* Describe incompatible peers, their risk, and the exact-version remedy.
* Surfaces with their own grant mechanism or locale render the structured result themselves.
* @param issue - incompatible plugin/runtime result, including exempted mismatches.
* @returns a warning suitable for CLI and plugin-management diagnostics.
* @returns an English diagnostic for logs and stderr.
*/
export function pluginCompatibilityWarning(issue: PluginCompatibility): string {
const key = `${issue.name}@${issue.version}`
return `Plugin ${key} is incompatible with dsh ${issue.runtimeVersion}: peerDependencies ${JSON.stringify(issue.peers)}. `
+ 'Running it may cause crashes or data loss. '
+ 'Update the plugin or install a plugin version compatible with this dsh runtime. '
+ `To accept this risk explicitly, add the exact-version exemption ${JSON.stringify({ [key]: [issue.runtimeVersion] })}. `
+ `To accept this risk explicitly, grant the exact-version exemption for ${key} on dsh ${issue.runtimeVersion} with \`dsh plugin allow-version\` or the plugin manager, then retry the installation or restart dsh. `
+ `Exact-version exemption: ${issue.exempted ? 'active' : 'not active'}.`
}
+10 -3
View File
@@ -28,6 +28,8 @@ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import type { DshBundleManifest, DshPackageManifest } from '@deepseek-ai/dsh-package-manifest'
import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts'
import { readProfileVersionExemptions } from './profile-compatibility.ts'
import { loadOverlayPatches } from './index.ts'
import { realModuleDirectory } from './profile-resolution/legacy-links.ts'
@@ -629,7 +631,8 @@ export function resolveBundleDir(
* Load an already initialized profile directory without resolving it through
* the shared Harness home. This is used by application-owned profiles whose
* package project and lifecycle belong to that application.
* Unreadable bundles are reported on stderr and skipped without changing the manifest.
* Unreadable bundles, and bundles whose own dsh peers the profile does not exempt, are reported
* on stderr and skipped without changing the manifest.
* @param binName - the diagnostic prefix on thrown errors.
* @param dir - absolute profile package directory.
* @param installAnchor - absolute path of the owning dsh app's package.json.
@@ -645,6 +648,7 @@ export function loadProfileDirectory(
const manifest = readProfileManifest(binName, dir)
const bundles = manifest.dsh?.profile?.bundles ?? []
const layers: ProfileLayer[] = []
const exemptions = bundles.length === 0 ? {} : readProfileVersionExemptions(dir)
for (const packageName of bundles) {
try {
const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
@@ -653,6 +657,9 @@ export function loadProfileDirectory(
if (bundle === undefined) {
throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
}
// A bundle is not a plugin row, so row admission never reads its own peers.
const issue = evaluatePluginCompatibility(bundleManifest, exemptions)
if (issue !== undefined && !issue.exempted) throw new Error(pluginCompatibilityWarning(issue))
const patchPaths = bundlePatchPaths(packageDir, bundle)
const patches = patchPaths.flatMap(patchPath => loadOverlayPatches(binName, patchPath))
layers.push({ packageName, packageDir, patchPaths, patches })
@@ -669,8 +676,8 @@ export function loadProfileDirectory(
/**
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch
* layer and parse the profile's own patch file. Unreadable bundles are reported
* on stderr and skipped; profile manifest and user patch errors still throw.
* layer and parse the profile's own patch file. Unreadable or incompatible bundles
* are reported on stderr and skipped; profile manifest and user patch errors still throw.
* @param binName - the diagnostic prefix on thrown errors.
* @param name - the profile name.
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
@@ -5,6 +5,7 @@ import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import { entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { load } from 'js-yaml'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import {
@@ -289,6 +290,19 @@ describe('manifest resolution', () => {
expect(f.warnings.join('\n')).toContain('cannot be validated')
})
it('labels a denied row by its id, or by its module when a preset row omits the id', () => {
const f = fixture()
f.plugin('denied-plugin', { peer: '^9.0.0' })
const base = pathToFileURL(f.dir).href + '/'
prepareProfileEntries(context(f), [{ id: 'row', name: './node_modules/denied-plugin/index.mjs' }], base)
// A preset declaration may omit the id that the Loader type requires.
prepareProfileEntries(context(f), [{ name: 'denied-plugin' } as EntryOptions], base)
expect(f.warnings).toEqual([
expect.stringMatching(/^dsh: disabling profile plugin row "row": Plugin denied-plugin@1\.0\.0 /),
expect.stringMatching(/^dsh: disabling profile plugin denied-plugin: Plugin denied-plugin@1\.0\.0 /),
])
})
it('denies a group-shaped row and clears its group flag', () => {
const f = fixture()
f.plugin('denied-plugin', { peer: '^9.0.0' })
@@ -170,7 +170,8 @@ describe('compatibility warning', () => {
+ 'peerDependencies {"@deepseek-ai/dsh":"^0.2.0","@deepseek-ai/dsh-session":"broken"}. '
+ 'Running it may cause crashes or data loss. '
+ 'Update the plugin or install a plugin version compatible with this dsh runtime. '
+ 'To accept this risk explicitly, add the exact-version exemption {"@example/[email protected]":["0.1.7-alpha.1"]}. '
+ 'To accept this risk explicitly, grant the exact-version exemption for @example/[email protected] on dsh 0.1.7-alpha.1 '
+ 'with `dsh plugin allow-version` or the plugin manager, then retry the installation or restart dsh. '
+ `Exact-version exemption: ${exempted ? 'active' : 'not active'}.`,
)
})
@@ -15,9 +15,11 @@ import { afterAll, describe, expect, it, onTestFinished, vi } from 'vitest'
import {
composeEntries,
createRuntimeResolution,
getDshRuntimeVersion,
initProfile,
loadProfile,
loadProfileDirectory,
PROFILE_COMPATIBILITY_FILENAME,
PROFILE_PATCH_FILENAME,
PROFILE_TEMPLATES,
readProfileManifest,
@@ -427,6 +429,28 @@ describe('loadProfile', () => {
},
)
it('skips a bundle whose own dsh peers are incompatible until the profile exempts that exact pair', () => {
const anchor = stageInstallation({
guarded: { patch: '- insert: [{ id: a, name: pkg-a }]\n' },
kept: { patch: '- insert: [{ id: b, name: pkg-b }]\n' },
})
const manifestPath = join(anchor, '..', 'node_modules', 'guarded', 'package.json')
writeFileSync(manifestPath, JSON.stringify({
...JSON.parse(readFileSync(manifestPath, 'utf8')) as object, peerDependencies: { '@deepseek-ai/dsh': '999.0.0' },
}))
const dir = resolveProfileDir('demo', tmp())
initProfile(dir, ['guarded', 'kept'])
const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
onTestFinished(() => { warn.mockRestore() })
expect(loadProfileDirectory('dsh', dir, anchor).layers.map(layer => layer.packageName)).toEqual(['kept'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
`skipping profile bundle "guarded": Error: Plugin [email protected] is incompatible with dsh ${getDshRuntimeVersion()}`,
))
writeFileSync(join(dir, PROFILE_COMPATIBILITY_FILENAME), JSON.stringify({ '[email protected]': [getDshRuntimeVersion()] }))
expect(loadProfileDirectory('dsh', dir, anchor).layers.map(layer => layer.packageName)).toEqual(['guarded', 'kept'])
})
it('still rejects invalid profile manifests and user patches', () => {
const anchor = stageInstallation({})
const dir = tmp()
@@ -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/boot/plugin-manager/README.md
README.md: 4977a59a1a3a313580cb2ddad8c9054c36da7af9
README.zh.md: fcf4b6dc69689a6c71631bfd51ca3cdba5147f9e
README.md: c047c71476308f0123bdebed6d9ab21e8ececc2f
README.zh.md: 1f99f67498790d997a7e345ca301b85c319c7b09
+2 -2
View File
@@ -60,13 +60,13 @@ When pnpm 11 blocks dependency scripts, the failed installation reports every pe
<a id="version-compatibility-and-exemptions"></a>
### Version compatibility and exemptions
An install command that names packages (`add`, or `install` with specs) is checked before pnpm runs: a local path is read from its own `package.json`, and a registry spec is resolved through pnpm's registry lookup for the version its range selects and the peers that version declares. An incompatible DSH peer rejects the operation before pnpm runs, so nothing is downloaded and no build script runs; a build approval the caller supplied with the request is recorded before this check and remains. A git or tarball spec needs the fetch itself, so it is judged after installation: the operation then restores the profile manifest and lockfile, reinstalls the restored lockfile, and reports whether that recovery succeeded; already permitted build-script effects may remain. A dependency the run did not change never blocks an unrelated operation: it stays installed, the run reports a warning naming it, and profile startup denies it. Installation requests with `enabled: false` are checked the same way. Startup checks run independently; see [App boot](../app-boot/README.md#profiles) for range semantics. Version exemptions do not authorize dependency scripts.
An install command that names packages (`add`, or `install` with specs) is checked before pnpm runs: a local path is read from its own `package.json`, and a registry spec is resolved through pnpm's registry lookup for the version its range selects and the peers that version declares. An incompatible DSH peer rejects the operation before pnpm runs, so nothing is downloaded and no build script runs; a build approval the caller supplied with the request is recorded before this check and remains. A git or tarball spec needs the fetch itself, so it is judged after installation: the operation then restores the profile manifest and lockfile, reinstalls the restored lockfile (or, when the profile had none, reinstalls from the restored manifest without creating one), and reports whether that recovery succeeded; already permitted build-script effects may remain. A dependency the run did not change never blocks an unrelated operation: it stays installed, the run reports a warning naming it, and profile startup denies it. Installation requests with `enabled: false` are checked the same way. Startup checks run independently; see [App boot](../app-boot/README.md#profiles) for range semantics. Version exemptions do not authorize dependency scripts.
An exemption is an exact `package-name@version` mapped to a list of exact DSH runtime versions in the profile's own `compatibility.json`, beside `package.json` and `cordis.patch.yml`. Writing it changes no dependency, no bundle selection, and no patch layer. Neither plugin upgrades nor DSH upgrades inherit permission. Use `plugin_manager` with `list_version_exemptions` to obtain the runtime version and saved grants, then `set_version_exemption` with `target`, `runtimeVersion`, and `enabled`. Granting also requires `acceptRisk: true`, only after warning the user that incompatible plugins may cause crashes or data loss and receiving explicit permission for that pair. The service checks the acknowledgement and versions, not conversation history. Revocation can remove historical runtime grants.
A grant takes effect on the next composition. A live profile recomposes, so the granted plugin mounts in the running session and the result reports `applied`; a startup-only profile keeps its current entries until restart and reports `restart-required`.
The CLI exposes `dsh plugin --profile <profile> version-exemptions`, `allow-version <package@version> --dsh-version <runtime> --accept-risk`, and `revoke-version <package@version> --dsh-version <runtime>`. A grant prints a risk warning before saving. The Web page reports compatibility failures; use the tool or CLI to grant an exemption and retry the original operation.
The CLI exposes `dsh plugin --profile <profile> version-exemptions`, `allow-version <package@version> --dsh-version <runtime> --accept-risk`, and `revoke-version <package@version> --dsh-version <runtime>`. A grant prints a risk warning before saving. A compatibility refusal carries the `incompatible-version` code with each refused package's `name`, `version`, `runtimeVersion`, and unsatisfied `peers`; each surface renders that record itself. The Web page words it through its locale dictionary, and a CLI refusal prints the exact `allow-version` command. Use the tool or CLI to grant an exemption and retry the original operation.
### Configuration
+2 -2
View File
@@ -60,13 +60,13 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro
<a id="version-compatibility-and-exemptions"></a>
### 版本兼容性与豁免
点名软件包的安装命令(`add`,或带 spec 的 `install`)会在 pnpm 运行前完成检查:本地路径直接读取其 `package.json`,registry spec 通过 pnpm 的 registry 查询得到该范围选中的版本及其声明的 peer。不兼容的 DSH peer 会在 pnpm 运行前使操作失败,因此不会下载任何内容、不会运行构建脚本;调用方随请求提交的构建批准在此检查之前记录,会保留下来。git 或 tarball spec 必须先抓取,因此在安装后才判定:此时操作会恢复 profile 清单与锁文件,并按恢复后的锁文件重新安装,同时报告该恢复是否成功;已获准构建脚本的副作用可能保留。本次运行未改动的依赖不会阻塞无关操作:它保持已安装状态,运行会输出点名它的警告,由 profile 启动拒绝加载。请求 `enabled: false` 的安装同样受检。启动检查独立执行;版本范围语义见 [App boot](../app-boot/README.zh.md#profiles)。版本豁免不授权依赖脚本。
点名软件包的安装命令(`add`,或带 spec 的 `install`)会在 pnpm 运行前完成检查:本地路径直接读取其 `package.json`,registry spec 通过 pnpm 的 registry 查询得到该范围选中的版本及其声明的 peer。不兼容的 DSH peer 会在 pnpm 运行前使操作失败,因此不会下载任何内容、不会运行构建脚本;调用方随请求提交的构建批准在此检查之前记录,会保留下来。git 或 tarball spec 必须先抓取,因此在安装后才判定:此时操作会恢复 profile 清单与锁文件,并按恢复后的锁文件重新安装(profile 原本没有锁文件时,按恢复后的清单重新安装且不创建锁文件),同时报告该恢复是否成功;已获准构建脚本的副作用可能保留。本次运行未改动的依赖不会阻塞无关操作:它保持已安装状态,运行会输出点名它的警告,由 profile 启动拒绝加载。请求 `enabled: false` 的安装同样受检。启动检查独立执行;版本范围语义见 [App boot](../app-boot/README.zh.md#profiles)。版本豁免不授权依赖脚本。
豁免保存在 profile 自己的 `compatibility.json` 中(与 `package.json`、`cordis.patch.yml` 并列),将精确的 `package-name@version` 映射到精确 DSH 运行时版本列表。写豁免不改变依赖、组合包选择或 patch 层。插件升级和 DSH 升级都不继承授权。使用 `plugin_manager` 的 `list_version_exemptions` 获取运行时版本与已有授权,再通过 `set_version_exemption` 提交 `target`、`runtimeVersion` 和 `enabled`。授权还要求 `acceptRisk: true`;只能在警告用户不兼容插件可能导致崩溃或数据丢失,并获得用户对此版本组合的明确许可后传入。服务校验确认参数和版本,不核实对话历史。撤销可以移除历史运行时版本的授权。
授权在下一次组合时生效。在线 profile 会重新组合,被授权的插件会在当前会话中挂载,结果报告 `applied`;仅启动型 profile 在重启前保留当前条目并报告 `restart-required`。
CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version <package@version> --dsh-version <runtime> --accept-risk` 和 `revoke-version <package@version> --dsh-version <runtime>`。授权会在保存前打印风险警告。Web 页面报告兼容性失败;通过工具或 CLI 添加豁免后,重试原操作。
CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version <package@version> --dsh-version <runtime> --accept-risk` 和 `revoke-version <package@version> --dsh-version <runtime>`。授权会在保存前打印风险警告。兼容性拒绝带有 `incompatible-version` 错误码,以及每个被拒绝软件包的 `name`、`version`、`runtimeVersion` 和未满足的 `peers`;各界面自行呈现这份记录。Web 页面通过 locale 词典生成文案,CLI 拒绝时打印精确的 `allow-version` 命令。通过工具或 CLI 添加豁免后,重试原操作。
### 配置
+22 -3
View File
@@ -1,10 +1,29 @@
/** Localizable rejections shared by profile management operations. */
import type { ManagementError } from './types.ts'
import type { PluginCompatibility } from '@deepseek-ai/dsh-app-boot'
import type { IncompatiblePlugin, ManagementError } from './types.ts'
/** Expected management rejection; presentation belongs to the caller's locale. */
export class ManagementFailure extends Error {
/** Code rendered by the caller's locale dictionary. */
readonly code: ManagementError['code']
/** @param code Localizable management rejection. */
constructor(code: ManagementError['code']) { super(code); this.code = code }
/** The packages an `incompatible-version` rejection names. */
readonly incompatible: IncompatiblePlugin[] | undefined
/**
* @param code Localizable management rejection.
* @param incompatible Packages the running DSH version rejects, for `incompatible-version`.
*/
constructor(code: ManagementError['code'], incompatible?: IncompatiblePlugin[]) {
super(code)
this.code = code
this.incompatible = incompatible
}
}
/**
* Drop the exemption status from an unexempted compatibility result.
* @param issue Result whose exemption is not active.
* @returns The package, runtime, and rejected peer ranges.
*/
export function incompatiblePlugin(issue: PluginCompatibility): IncompatiblePlugin {
return { name: issue.name, version: issue.version, runtimeVersion: issue.runtimeVersion, peers: issue.peers }
}
+9 -6
View File
@@ -14,7 +14,7 @@ import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin
import {
readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries,
reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths,
evaluatePluginCompatibility, pluginCompatibilityWarning, readProfileCompatibility, readProfileVersionExemptions,
evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions,
setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME,
} from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-hmr'
@@ -24,7 +24,7 @@ import { classifyInstallFailure } from './install-failure.ts'
import { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts'
import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from './registry.ts'
import { writePluginEnabled } from './patch.ts'
import { ManagementFailure } from './failure.ts'
import { incompatiblePlugin, ManagementFailure } from './failure.ts'
import { approveBuilds, readPendingBuilds } from './build-approval.ts'
import { checkGithubConnection } from './github-connection.ts'
import type {
@@ -91,7 +91,8 @@ function messageOf(error: unknown): string { return error instanceof Error ? err
/** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */
function managementError(error: unknown): ManagementError {
return error instanceof ManagementFailure ? { code: error.code } : { code: 'operation-error', diagnostic: messageOf(error) }
if (!(error instanceof ManagementFailure)) return { code: 'operation-error', diagnostic: messageOf(error) }
return { code: error.code, ...error.incompatible === undefined ? {} : { incompatible: error.incompatible } }
}
/** The caller stopped an installation; its files are restored before this is thrown. */
@@ -298,7 +299,7 @@ export class PluginManager extends TypertRemoteService {
continue
}
const compatibility = evaluatePluginCompatibility(info, exemptions)
if (compatibility !== undefined && !compatibility.exempted) throw new Error(pluginCompatibilityWarning(compatibility))
if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)])
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href)
bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
@@ -505,6 +506,8 @@ export class PluginManager extends TypertRemoteService {
run = await this.runPnpm(['add', spec, ...registryArguments(registry)], control.abort.signal, requestId)
result.packageResult = run
if (stopped()) throw new InstallCancelledError()
// A compatibility refusal is the package's own answer, so no other registry is asked.
if (run.incompatible !== undefined) throw new ManagementFailure('incompatible-version', run.incompatible)
// A run this manager terminated is not a success, even when pnpm trapped the signal and exited 0.
if (run.exitCode === 0 && run.timedOut !== true) break
/* v8 ignore next 2 -- runPnpm classifies every run it does not report as succeeded */
@@ -541,7 +544,7 @@ export class PluginManager extends TypertRemoteService {
const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
if (manifest?.dsh?.bundle === undefined) throw new ManagementFailure('not-bundle')
const compatibility = evaluatePluginCompatibility(manifest, readProfileVersionExemptions(this.profile.dir))
if (compatibility !== undefined && !compatibility.exempted) throw new Error(pluginCompatibilityWarning(compatibility))
if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)])
for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) loadOverlayPatches('dsh', file)
} catch (error) {
// pnpm has exited by now, so the files it rewrote go back as they were.
@@ -717,7 +720,7 @@ export class PluginManager extends TypertRemoteService {
if (metadata === undefined) throw new ManagementFailure('not-bundle')
if (enabled) {
const compatibility = evaluatePluginCompatibility(metadata, readProfileVersionExemptions(this.profile.dir))
if (compatibility !== undefined && !compatibility.exempted) throw new Error(pluginCompatibilityWarning(compatibility))
if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)])
this.bundleRows(name)
}
}
+27 -8
View File
@@ -14,7 +14,8 @@ import {
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { parseInstallSpec } from './install-spec.ts'
import { awaitTreeGone, leadsOwnGroup } from './run-tree.ts'
import type { PackageResult, Registry } from './types.ts'
import { incompatiblePlugin } from './failure.ts'
import type { IncompatiblePlugin, PackageResult, Registry } from './types.ts'
export { setProfileVersionExemption, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
/** Profile and invocation locations supplied by the launcher. */
@@ -265,13 +266,15 @@ export async function runProfilePnpm(
else await writeFileAtomic(file.path, file.text, { mode: 0o600 })
}
}
/** Packages a compatibility check refused; callers render them for their own surface. */
const incompatible: IncompatiblePlugin[] = []
const rejected = async (warnings: readonly string[], restoration: string): Promise<PackageResult> => {
const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n`
await log.write(diagnostic)
options.onOutput?.(diagnostic, 'stderr')
append(Buffer.from(diagnostic))
await log.close()
return { exitCode: 1, output: output.toString('utf8'), truncated, logPath }
return { exitCode: 1, output: output.toString('utf8'), truncated, logPath, incompatible }
}
// An install command names the packages it adds, so their manifests are read and checked before
// pnpm runs: an incompatible version is never installed, and the one already in use keeps working.
@@ -286,7 +289,10 @@ export async function runProfilePnpm(
const manifest = await namedSpecManifest(dir, anchorPathSpec(raw, context.cwd), options, environment, registryFlags)
if (manifest === undefined) continue
const issue = evaluatePluginCompatibility(manifest, exemptions)
if (issue !== undefined && !issue.exempted) preflight.push(pluginCompatibilityWarning(issue))
if (issue !== undefined && !issue.exempted) {
preflight.push(pluginCompatibilityWarning(issue))
incompatible.push(incompatiblePlugin(issue))
}
} catch (error) { void error; continue }
}
if (preflight.length > 0) return rejected(preflight, 'nothing was installed')
@@ -410,17 +416,24 @@ export async function runProfilePnpm(
// A dependency this run did not touch never blocks an unrelated operation; profile startup denies it.
const untouched = beforeDependencies[name] === spec && installedBefore.get(name) === installed
const found: string[] = []
const issues: IncompatiblePlugin[] = []
try {
const manifest = readProfileManifest('dsh', packageDir)
for (const candidate of [manifest, ...bundleComponentManifests(manifest, packageDir, context.installAnchor)]) {
const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir))
if (issue !== undefined && !issue.exempted) found.push(pluginCompatibilityWarning(issue))
if (issue !== undefined && !issue.exempted) {
found.push(pluginCompatibilityWarning(issue))
issues.push(incompatiblePlugin(issue))
}
}
} catch (error) {
found.push(`Cannot validate installed package ${name}: ${String(error)}`)
}
if (found.length === 0) continue
if (!untouched) warnings.push(...found)
if (!untouched) {
warnings.push(...found)
incompatible.push(...issues)
}
else {
const notice = `\ndsh: warning: ${found.join('\n')}\ndsh: it stays installed but profile startup denies it until you grant an exemption for those exact versions.\n`
await log.write(notice)
@@ -430,9 +443,12 @@ export async function runProfilePnpm(
if (warnings.length > 0) {
// A bundle component's peers need installed contents, so this rejection lands after pnpm
// replaced the tree: restore the files, then reinstall the restored lockfile so the version
// that worked before this run keeps loading.
// that worked before this run keeps loading. A profile that had no lockfile is reinstalled
// from its restored manifest without creating one, which removes what this run added.
await restore()
const repaired = await execa(options.command ?? 'pnpm', [...options.args ?? [], 'install', '--frozen-lockfile'], {
const hadLockfile = savedFiles.some(file => file.path.endsWith('pnpm-lock.yaml') && file.text !== undefined)
const repair = ['install', hadLockfile ? '--frozen-lockfile' : '--config.lockfile=false']
const repaired = await execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], {
cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore',
...options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs },
})
@@ -453,7 +469,10 @@ export async function runProfilePnpm(
clearTimeout(idleTimer)
await log.close()
}
return { exitCode, output: output.toString('utf8'), truncated, logPath, ...control.stalled ? { timedOut: true } : {} }
return {
exitCode, output: output.toString('utf8'), truncated, logPath,
...control.stalled ? { timedOut: true } : {}, ...incompatible.length > 0 ? { incompatible } : {},
}
}
/** Initialize and run the dsh plugin command with the same write lock as the service.
+14 -1
View File
@@ -8,10 +8,21 @@ import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types
/** Reasons a profile control cannot modify its target. */
export type ReadOnlyReason = 'management-required' | 'unaddressable'
/** A package whose declared DSH peers reject the running DSH version, without an exemption for the exact pair. */
export interface IncompatiblePlugin {
name: string
version: string
runtimeVersion: string
/** Only the DSH peer ranges the running version does not satisfy. */
peers: Record<string, string>
}
/** Localizable management failure and optional external diagnostic. */
export interface ManagementError {
code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'operation-error'
code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'operation-error'
diagnostic?: string
/** Present with `incompatible-version`: the packages the running DSH version rejects. */
incompatible?: IncompatiblePlugin[]
}
/** One running-profile entry and its persistent control availability. */
@@ -92,6 +103,8 @@ export interface PackageResult {
kind?: PluginInstallFailureKind
/** The manager terminated the run after it printed nothing for its silence bound; `exitCode` still reports how it ended. */
timedOut?: boolean
/** Present when a compatibility check refused the run: the packages the running DSH version rejects. */
incompatible?: IncompatiblePlugin[]
}
/** Persisted change and independently observed application outcome. */
@@ -1332,9 +1332,12 @@ it.each(['live', 'startup'] as const)('requires exact risk acknowledgement and p
})
const runtime = getDshRuntimeVersion()
const managed = () => [...ctx.loader.entries()].find(entry => entry.id === 'include:managed')
// Admission disables the incompatible row where it is composed: the Loader keeps its entry and gives it no fiber.
expect(managed()?.disabled).toBe(true)
expect((await manager.listBundles()).find(bundle => bundle.name === 'extra')?.error?.diagnostic).toContain('crashes or data loss')
// The bundle's own peers are incompatible, so its whole layer is skipped and contributes no row.
expect(managed()).toBeUndefined()
expect((await manager.listBundles()).find(bundle => bundle.name === 'extra')?.error).toEqual({
code: 'incompatible-version',
incompatible: [{ name: 'extra', version: '1.0.0', runtimeVersion: runtime, peers: { '@deepseek-ai/dsh': '999.0.0' } }],
})
expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' })
expect(await manager.setVersionExemption('[email protected]', runtime, true)).toMatchObject({ changed: false, application: 'failed' })
expect(manager.listVersionExemptions()).toEqual({ exemptions: {}, warnings: [] })
@@ -1346,14 +1349,26 @@ it.each(['live', 'startup'] as const)('requires exact risk acknowledgement and p
// The grant is persisted only in the profile's compatibility file, never in its package manifest.
expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({ '[email protected]': [runtime] })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
// `setVersionExemption` reconciles the profile itself, so a live tree re-admits the row here;
// no manifest watch or Loader instrumentation participates. A startup-only profile keeps it disabled until restart.
// `setVersionExemption` reconciles the profile itself, so a live tree re-admits the bundle here;
// no manifest watch or Loader instrumentation participates. A startup-only profile keeps it out until restart.
if (mode === 'live') expect(managed()?.fiber?.state).toBe(2)
else expect(managed()?.disabled).toBe(true)
else expect(managed()).toBeUndefined()
expect(await manager.setVersionExemption('[email protected]', runtime, false)).toMatchObject({ changed: true })
expect(manager.listVersionExemptions()).toEqual({ exemptions: {}, warnings: [] })
expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({})
if (mode === 'live') expect(managed()?.disabled).toBe(true)
if (mode === 'live') expect(managed()).toBeUndefined()
})
it('reports a package run refused for compatibility as a typed refusal', async () => {
const { manager } = await fixture()
const incompatible = [{ name: 'dsh-x', version: '2.0.0', runtimeVersion: getDshRuntimeVersion(), peers: { '@deepseek-ai/dsh': '999.0.0' } }]
const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({
exitCode: 1, output: 'dsh: installation rejected', truncated: false, logPath: 'pnpm.log', kind: 'unknown', incompatible,
})
onTestFinished(() => { install.mockRestore() })
const result = await manager.installBundle('dsh-x')
expect(result).toMatchObject({ application: 'failed', changed: false, error: { code: 'incompatible-version', incompatible } })
expect(install).toHaveBeenCalledTimes(1)
})
it.each([false, true])('rechecks installed bundle peers before accepting a disabled installation (exempted=%s)', async (exempted) => {
@@ -1374,7 +1389,7 @@ it.each([false, true])('rechecks installed bundle peers before accepting a disab
const result = await manager.installBundle('incompatible', { enabled: false })
expect(result).toMatchObject({ application: exempted ? 'applied' : 'failed', changed: exempted })
if (!exempted) {
expect(result.error?.diagnostic).toContain('incompatible@1.0.0')
expect(result.error).toMatchObject({ code: 'incompatible-version', incompatible: [{ name: 'incompatible', version: '1.0.0' }] })
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
}
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
@@ -33,7 +33,7 @@ interface FakePnpm {
/** The run's exit code and captured output. */
exitCode: number
output: string
/** Exit code of the `install --frozen-lockfile` run that repairs a post-install refusal. */
/** Exit code of the `install` run that repairs a post-install refusal. */
repairExit: number
/** What the pre-install `pnpm view` lookup answers for one spec; `{}` declares no peers. */
view: (spec: string) => { exitCode: number; stdout: string }
@@ -70,7 +70,7 @@ function fixture() {
const answer = pnpm.view(spec)
return result(answer.exitCode, answer.stdout)
}
if (argv.includes('--frozen-lockfile')) return result(pnpm.repairExit, '')
if (argv.includes('--frozen-lockfile') || argv.includes('--config.lockfile=false')) return result(pnpm.repairExit, '')
return result(pnpm.exitCode, pnpm.output, () => { pnpm.mutate(runDir) })
})
onTestFinished(() => { command.run.mockReset(); rmSync(home, { recursive: true, force: true }) })
@@ -187,6 +187,9 @@ it.each([
execution: 'service', outputBytes: 8192, onOutput: (text) => { messages.push(text) },
})
expect(outcome.exitCode).toBe(1)
expect(outcome.incompatible).toEqual([
{ name: 'plugin', version: '1.0.0', runtimeVersion: getDshRuntimeVersion(), peers: { '@deepseek-ai/dsh': '999.0.0' } },
])
expect(outcome.output).toContain('installation rejected')
expect(outcome.output).toContain('[email protected]')
expect(outcome.output).toContain('nothing was installed')
@@ -335,6 +338,7 @@ it.each([true, false])('rejects incompatible installed manifests before activati
execution: 'service', outputBytes: 8192, activateNewBundles, onOutput: (text) => { messages.push(text) },
})
expect(outcome.exitCode).toBe(1)
expect(outcome.incompatible).toMatchObject([{ name: 'incompatible', version: '1.0.0', peers: { '@deepseek-ai/dsh-app-boot': '>=999.0.0' } }])
expect(outcome.output).toContain('[email protected]')
expect(outcome.output).toContain('installation rejected')
expect(readFileSync(outcome.logPath, 'utf8')).toContain('[email protected]')
@@ -346,6 +350,19 @@ it.each([true, false])('rejects incompatible installed manifests before activati
expect(outcome.output).toContain('and node_modules')
})
it('repairs a profile that had no lockfile without creating one', async () => {
const { dir, context, pnpm } = fixture()
pnpm.mutate = (target) => {
installGuarded(target, 'incompatible')
writeFileSync(join(target, 'pnpm-lock.yaml'), 'created-lock\n')
}
const outcome = await runProfilePnpm(context, ['add', 'incompatible'], { execution: 'service', outputBytes: 8192 })
expect(outcome.exitCode).toBe(1)
expect(existsSync(join(dir, 'pnpm-lock.yaml'))).toBe(false)
expect(command.run).toHaveBeenLastCalledWith(expect.anything(), ['install', '--config.lockfile=false'], expect.anything())
expect(outcome.output).toContain('and node_modules')
})
it('reports an unrepaired node_modules when the restoring install fails', async () => {
const { context, pnpm } = fixture()
pnpm.mutate = (target) => { installGuarded(target, 'incompatible') }
+2 -2
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-chat/README.md
README.md: cca209016220560b7f39831e11ebb677da515ea9
README.zh.md: 0b97e490f5a535c3b871ec7c6344d4299976b683
README.md: f6924e91b24d6da9bae7722734dd8c7455145abf
README.zh.md: 0834d1f556a0757dd40fd5d0cdd7fb28c3f8516b
+2 -2
View File
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
## Summary
Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Work-details modes control reasoning previews and fold eligible completed-turn process rows without hiding final answers. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests.
Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Work-details modes control reasoning previews and process visibility without hiding final answers; Verbose keeps completed-turn process rows visible. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests.
File-mention providers receive the viewed Session ID with the closing-turn owner, so links into inherited history can address the fork itself.
@@ -78,7 +78,7 @@ During uninterrupted following, local transcript and steering echoes remain moun
When Chat ends with an open Turn control and that Turn has no visible input, the first local transcript echo precedes the control. Other echoes remain at the flow tail. The control and echoes share one keyed list, so arrival of the control preserves the echo's mounted identity. Durable inputs replace their matching echoes in the same render.
Work-details modes control process-group display and reasoning previews; eligible completed Turns fold their process without hiding the final answer. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets.
Work-details modes control process-group display and reasoning previews. Compact, Standard, and Detailed fold eligible completed Turns without hiding the final answer; Verbose retains the duration/status header without a collapse action and shows historical process rows directly. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets.
-----
+3 -3
View File
@@ -8,7 +8,7 @@ kind: "package-reference"
## 概述
使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作过程展示模式控制思考预览,并收起符合条件的已完成轮次过程行,不隐藏最终答案。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。
使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作步骤展示模式控制思考预览与过程显隐,不隐藏最终答案;完全展开模式保持已完成轮次的过程行可见。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。
文件提及提供方同时接收当前查看的会话 ID 与收尾轮次的属主信息,因此继承历史中的链接可以指向 fork 自身。
@@ -40,7 +40,7 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H
<a id="system-prompt-row"></a>
## Chat 隐藏的行
Chat 在所有工作过程展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。
Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。
Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。
@@ -78,7 +78,7 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,
Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第一条本地 transcript 回显显示在控制行前。其他回显保留在正文末尾。控制行与回显共用一个 keyed 列表,因此控制行到达时不会重新挂载回显。持久输入在同一次渲染中替换匹配的回显。
工作过程展示模式控制过程组显示与推理预览;符合条件的已完成轮次折叠过程,但不隐藏最终答案。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。
工作步骤展示模式控制过程组显示与推理预览。简洁、标准、详细模式收起符合条件的已完成轮次,不隐藏最终答案;完全展开模式保留时长或状态抬头,但不支持收起,历史过程行直接显示。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。
-----
+15 -9
View File
@@ -9,22 +9,27 @@ export const CHAT_SETTINGS_NAMESPACE = 'ui-chat'
export const TRANSCRIPT_VIEW_FIELD = 'transcriptView'
/** Work-details presentation modes a user can choose. */
export const TRANSCRIPT_VIEW_MODES = ['compact', 'detailed', 'expanded'] as const
export const TRANSCRIPT_VIEW_MODES = ['compact', 'standard', 'detailed', 'verbose'] as const
/** Work-details presentation mode. */
export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number]
/**
* Saved value from the two-mode generation of this setting. Read as `detailed`;
* Saved value from the two-mode generation of this setting. Read as `standard`;
* never offered as a choice and never written back.
*/
export const LEGACY_TRANSCRIPT_VIEW_MODE = 'normal'
/** Every value the durable field accepts: current modes plus the legacy saved value. */
const TRANSCRIPT_VIEW_SETTING_VALUES = [...TRANSCRIPT_VIEW_MODES, LEGACY_TRANSCRIPT_VIEW_MODE] as const
/** Saved `expanded` values read as `detailed`, without being offered or written back. */
export const LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE = 'expanded'
/** Default preserves the compact process disclosure introduced by Chat. */
export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'compact'
/** Every value the durable field accepts: current modes plus legacy saved values. */
const TRANSCRIPT_VIEW_SETTING_VALUES = [
...TRANSCRIPT_VIEW_MODES, LEGACY_TRANSCRIPT_VIEW_MODE, LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE,
] as const
/** Standard process summaries for users without an explicit preference. */
export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'standard'
/** Performance and usage detail levels accepted by user settings. */
export const PERFORMANCE_USAGE_MODES = ['compact', 'detailed'] as const
@@ -43,8 +48,8 @@ export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar'
/** Durable Chat section shared by the Host schema and browser scope. */
export interface ChatSettings {
/** Work-details preference; the legacy value is accepted only from existing saved settings. */
transcriptView: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE
/** Work-details preference; legacy values are accepted only from existing saved settings. */
transcriptView: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE | typeof LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE
/** Detail level for composer statistics and completed-Turn usage. */
performanceUsage: PerformanceUsageMode
/** Default destination for Chat HTTP(S) links. */
@@ -55,7 +60,8 @@ export interface ChatSettings {
export const ChatSettingsFields = {
linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING),
performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE),
[TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_SETTING_VALUES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE),
// Missing and unrecognized modes both use Standard.
[TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_SETTING_VALUES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE).loose(),
}
/** Schema for shared configuration values. */
@@ -138,7 +138,8 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou
const location = node?.location
return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined
})
const grouped = props.usePresentation(policy => turnLocation?.status !== 'open' || policy.stepGrouping !== 'none')
const grouped = props.usePresentation(policy => policy.stepGrouping === 'collapsed'
|| (policy.stepGrouping === 'history' && turnLocation?.status !== 'open'))
const reason = turnLocation?.end?.data.reason.kind
const alwaysOpen = presentation?.turnClosed === false || presentation?.hasInterleavedInput === true
|| reason === 'aborted' || reason === 'error'
@@ -102,7 +102,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
foldable, interleavedInput, processOpen, processSpec, processPresentation?.hasExternalProcess, setOpen,
])
const controllerInactive = routedNode?.kind === 'turn-process'
&& !foldable
&& foldCompleted && !foldable
const compactAnswer = processAnswer
&& foldable
&& processPresentation.compactAnswer
@@ -11,23 +11,23 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
node, turnProcess, t,
}: ChatNodeViewProps<'turn-process'>) {
if (turnProcess === undefined) throw new Error('turn-process node requires Turn process owner state')
const open = turnProcess.open
const open = !turnProcess.foldable || turnProcess.open
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
? node.location.turn
: undefined
const [now, setNow] = useState(Date.now)
const ticking = turnProcess.foldable && turn?.status === 'open'
const ticking = turn?.status === 'open' && turn.start !== undefined
useEffect(() => {
if (!ticking) return
setNow(Date.now())
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
return () => { clearInterval(timer) }
}, [ticking])
if (!turnProcess.foldable) return null
const canCollapse = turnProcess.hasContent && !turnProcessAlwaysOpen(node)
const running = turn?.status === 'open'
const reason = turn?.end?.data.reason.kind
const elapsedMs = turn?.start === undefined ? undefined
if (turn?.start === undefined && turn?.status !== 'closed') return null
const canCollapse = turnProcess.foldable && turnProcess.hasContent && !turnProcessAlwaysOpen(node)
const running = turn.status === 'open'
const reason = turn.end?.data.reason.kind
const elapsedMs = turn.start === undefined ? undefined
: Math.max(1000, (turn.end?.time ?? now) - turn.start.time)
const duration = elapsedMs === undefined ? undefined
: running ? formatLiveRunDuration(elapsedMs, t) : formatRunDuration(elapsedMs, t)
@@ -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-chat/src/client/conversation-nodes/README.md
README.md: 3b6b82685c763837e706c60d8ea69b896f582ae2
README.zh.md: ae43726417becea973b22e2f2a16871cec116584
README.md: 12c63b54977d43120baae1f044675760b69303c9
README.zh.md: 341c2826a1a2d070d314808f9a4ba6aff2f2378a
@@ -44,7 +44,7 @@ Every reply separates secondary groups; only the final answer is protected from
| Which rows belong together? | Group boundaries | The reply separates G1 and G2; changing from `read` to `bash` does not. |
| What did a group do? | Category counts | G1 has `read=1, commands=1`; G2 has `code=1`. Reasoning adds no tool count. |
| What does its title say? | Group state and summary | Running groups use their latest running activity; closed groups use their highest-count categories. Counts never decide folding. |
| How much detail is visible? | Display mode and manual group opening | Completed Turns retain group headers and manual bodies in every mode; Expanded directly shows group bodies only in running Turns. |
| How much detail is visible? | Display mode and manual group opening | Compact, Standard, and Detailed retain historical group headers; Detailed shows running bodies directly, while Verbose shows both running and historical bodies directly. |
| Is the process visible at all? | Whole-Turn opening | Closing the Turn hides G1, the intermediate reply, and G2 together. |
### What the reader sees
@@ -53,21 +53,21 @@ Assume the Turn above completed normally, with no inner disclosure manually open
| Viewing state | Visible content |
|---|---|
| Whole Turn collapsed, any mode | Input, whole-Turn control, final response, and footer. No process headers, bodies, or intermediate reply. |
| Whole Turn open, Compact or Detailed | G1/G2 headers, the intermediate reply, and final response. Group bodies start collapsed. |
| Whole Turn and G1 open, Detailed | G1's reasoning and tool rows with the settled reasoning preview; G2 remains a header. Full reasoning/tool bodies are still manual. |
| Whole Turn open, Expanded | G1/G2 headers, intermediate reply, and final response. Completed Turns' group bodies still require manual opening. |
| Whole Turn collapsed, Compact/Standard/Detailed | Input, whole-Turn control, final response, and footer. No process headers, bodies, or intermediate reply. |
| Whole Turn open, Compact/Standard/Detailed | G1/G2 headers, the intermediate reply, and final response. Group bodies start collapsed. |
| Whole Turn and G1 open, Standard or Detailed | G1's reasoning and tool rows with the settled reasoning preview; G2 remains a header. Full reasoning/tool bodies are still manual. |
| Verbose | Whole-Turn duration/status header without a collapse action, all process rows, intermediate reply, and final response. No group headers; individual tool/reasoning bodies remain manual. |
Visibility applies from outside inward: whole Turn → secondary group → individual reasoning/tool disclosure. Opening an inner layer cannot bypass a closed outer layer. Category changes never open or close a layer. Mode changes preserve membership and manual opening choices.
If steering, a User message, or a trigger notice follows process output, the Turn offers only individual group disclosures, not whole-Turn collapse. In Compact/Detailed, `G1 → steering 1 → G2 → steering 2 → G3` retains all three headers and both inputs in that order, with intermediate replies visible; opening one group reveals only its body. Expanded directly shows group bodies only while the Turn runs; completed Turns retain headers and manual disclosure. The Turn control still shows duration or status, without a collapse action.
If steering, a User message, or a trigger notice follows process output, the Turn offers only individual group disclosures, not whole-Turn collapse. In Compact/Standard, `G1 → steering 1 → G2 → steering 2 → G3` retains all three headers and both inputs in that order, with intermediate replies visible; opening one group reveals only its body. Detailed directly shows group bodies only while the Turn runs; completed Turns retain headers and manual disclosure. Verbose shows group bodies directly in both cases. The Turn control still shows duration or status, without a collapse action.
-----
<a id="whole-turn-folding"></a>
## Whole-Turn folding
Whole-Turn folding controls the loaded process range, independently of secondary groups. A recorded Turn end makes that range eligible even when paging has not loaded the Turn start.
Whole-Turn folding controls the loaded process range, independently of secondary groups, in Compact, Standard, and Detailed. Verbose keeps that range visible and retains the duration/status header without a collapse action. A recorded Turn end makes that range eligible even when paging has not loaded the Turn start.
The Turn control follows all its opening inputs, including human steering and non-human trigger notices, while waiting for the first Assistant output and after that output arrives. Consecutive inputs before the first process evidence are opening inputs, anchored by the last one. Later inputs retain their positions: even when paging has not loaded their inbox insertions and they temporarily appear as ordinary User messages, preceding process content must not move after them.
@@ -108,21 +108,21 @@ Automatic collapse keeps the process open if hiding it would hide keyboard focus
<a id="display-modes"></a>
## Display modes
Settings → General → Work details stores `ui-chat.transcriptView` as `compact` (default), `detailed`, or `expanded`. A saved legacy `normal` value reads as `detailed` without being written back.
Settings → General → Work details offers `compact`, `standard` (default), `detailed`, and `verbose`; its description is “Choose how much detail to show for tool calls”. A saved `normal` reads as `standard`, and a saved `expanded` reads as `detailed`, without automatic write-back. Existing `detailed` remains `detailed`. Missing or invalid values, including the period before Host settings arrive, use `standard`; invalid values in other settings still fail validation.
| Behavior | Compact | Detailed | Expanded |
|---|---|---|---|
| Process-group header | Category summary | Summary and live task detail | Hidden in running Turns; retained in historical Turns |
| Process-group body | Initially collapsed | Initially collapsed | Directly visible without a group-level height cap in running Turns; manual disclosure in historical Turns |
| Settled reasoning preview | Hidden | First line | First line |
| Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion |
| Eligible completed Turn | Initially collapsed | Initially collapsed | Initially collapsed |
| Behavior | Compact | Standard | Detailed | Verbose |
|---|---|---|---|---|
| Process-group header | Category summary | Summary and live task detail | Hidden in running Turns; retained in historical Turns | Hidden |
| Process-group body | Initially collapsed | Initially collapsed | Directly visible without a group-level height cap in running Turns; manual disclosure in historical Turns | Directly visible without a group-level height cap in running and historical Turns |
| Settled reasoning preview | Hidden | First line | First line | First line |
| Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion | Manual expansion |
| Eligible completed Turn | Initially collapsed | Initially collapsed | Initially collapsed | Always open; duration/status header cannot collapse it |
A closed group's header names the first three categories from its ranked summary, without displaying counts. A group without categories uses the thinking label. A running header names its live tool category, otherwise thinking; Detailed appends live detail. Live titles remain visible for at least 150ms, retaining only the newest pending title.
A closed group's header names the first three categories from its ranked summary, without displaying counts. A group without categories uses the thinking label. A running header names its live tool category, otherwise thinking; Standard appends live detail. Live titles remain visible for at least 150ms, retaining only the newest pending title.
### Group-title rules
All three stages share the tool-name classification below. A preparing Tool node uses its category's preparation label: read files for `read`, read images for `read_image`, write files for `write`, edit files for `edit` and `apply_patch`, and update the plan for `todo_write` and goal tools. Only the generic “Preparing tool calls” category appends the wire tool name in Detailed mode; other categories omit it. It contributes one call without parsing arguments and renders one non-expandable row. A named live delta can create this node; historical calls start directly from tool/call without replaying preparation.
All three stages share the tool-name classification below. A preparing Tool node uses its category's preparation label: read files for `read`, read images for `read_image`, write files for `write`, edit files for `edit` and `apply_patch`, and update the plan for `todo_write` and goal tools. Only the generic “Preparing tool calls” category appends the wire tool name in Standard mode; other categories omit it. It contributes one call without parsing arguments and renders one non-expandable row. A named live delta can create this node; historical calls start directly from tool/call without replaying preparation.
The labels below describe recorded activity, not successful outcomes. For example, a failed read still participates in the “Read files” category.
@@ -145,23 +145,23 @@ The labels below describe recorded activity, not successful outcomes. For exampl
| Group state | Title composition |
|---|---|
| Not closed, with a running tool | Use the live category, not the highest-count category. Detailed appends nonempty detail with ` · `; Compact omits detail. |
| Not closed, without a running tool | Use the analysis label even when completed-tool counts are nonzero. Detailed may append running reasoning detail. |
| Not closed, with a running tool | Use the live category, not the highest-count category. Standard appends nonempty detail with ` · `; Compact omits detail. |
| Not closed, without a running tool | Use the analysis label even when completed-tool counts are nonzero. Standard may append running reasoning detail. |
| Closed, zero categories | Use the completed-analysis label. |
| Closed, one category | Use its closed label without a count. |
| Closed, two categories | Join ranked labels with “and”. Chinese removes the second leading `已` only when both labels start with it. |
| Closed, three categories | Join all three ranked labels with commas. |
| Closed, more than three categories | Show the first three labels followed by “etc.” (`等` in Chinese). |
English lowercases the initial letter of joined labels after the first. Closing a group immediately selects the completed summary; the 150ms minimum applies to running-title changes, not to delaying completion. Expanded hides group headers in running Turns, including groups ended by a reply or steering before their Turn ends.
English lowercases the initial letter of joined labels after the first. Closing a group immediately selects the completed summary; the 150ms minimum applies to running-title changes, not to delaying completion. Detailed hides group headers in running Turns, including groups ended by a reply or steering before their Turn ends. Verbose also hides historical group headers.
Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 8px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Expanded mode removes the group-level cap and uses 16px row spacing only in running Turns.
Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 8px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Detailed removes the group-level cap and uses 16px row spacing in running Turns; Verbose applies this layout to historical Turns as well.
An open capped group follows content growth only while its own scroll position is at the bottom. Scrolling away pauses that group's following; returning to the bottom resumes it, independently of outer transcript following. Manually opening an unclosed group starts at the bottom and follows growth; manually opening a closed group starts at the top with following disabled, even when its initial content fits without scrolling. Closing the group in the data or restoring its height cap through a mode change does not reset an already-open reader's position. Browser find retains its own reveal position.
Individual reasoning starts collapsed, including while streaming. All modes preview the latest paragraph whose first line ends with a newline; an unfinished single line has no preview. Later text in that paragraph does not change the preview. After settlement, the mode table applies. Expanded reasoning uses compact Markdown typography.
Switching modes retains manually opened groups and inner disclosures. It changes visibility and sizing without recreating the message rows. Expanded does not mean opening every individual disclosure or unfolding the whole Turn.
Switching modes retains manually opened groups and inner disclosures. It changes visibility and sizing without recreating the message rows. Detailed and Verbose do not open every individual disclosure. Only Verbose keeps the whole Turn open.
-----
@@ -170,7 +170,7 @@ Switching modes retains manually opened groups and inner disclosures. It changes
[process-groups.ts](process-groups.ts) groups visible Chat content; [process-activity.ts](process-activity.ts) summarizes the activity inside each group. Both follow the rules below.
A group collects adjacent process content within one Turn. Step-number changes alone do not split it. Compact, Detailed, and Expanded use the same grouping result. A group's `closed` flag means its content segment has ended, not that its UI disclosure is collapsed.
A group collects adjacent process content within one Turn. Step-number changes alone do not split it. All four modes use the same grouping result. A group's `closed` flag means its content segment has ended, not that its UI disclosure is collapsed.
| Input | Membership and segmentation |
|---|---|
@@ -44,7 +44,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
| 哪些行放在一起? | 分组边界 | 阶段回复分开 G1 与 G2;从 `read` 换成 `bash` 不会拆组。 |
| 一组做了什么? | 类别计数 | G1 为 `read=1, commands=1`,G2 为 `code=1`;思考不增加工具次数。 |
| 组标题写什么? | 组状态与摘要 | 未结束组显示最近运行活动;已结束组显示次数最多的类别。次数不决定折叠。 |
| 组内显示多少细节? | 展示模式与手动开合 | 已完成轮次各模式都保留组头,正文手动展开;Expanded 仅对运行中轮次直接显示组正文。 |
| 组内显示多少细节? | 展示模式与手动开合 | 简洁、标准、详细模式保留历史组头;详细模式直接显示运行中正文,完全展开模式直接显示运行中与历史正文。 |
| 过程整体是否可见? | 整轮开合 | 整轮收起时,G1、阶段回复和 G2 一起隐藏。 |
### 用户实际看到什么
@@ -53,21 +53,21 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
| 查看状态 | 可见内容 |
|---|---|
| 整轮收起,任意模式 | 输入、整轮控件、最终答案和页脚;过程组头、正文及阶段回复都不显示。 |
| 整轮展开,Compact 或 Detailed | G1/G2 组头、阶段回复及最终答案;组正文初始收起。 |
| 整轮和 G1 展开,Detailed | G1 的思考与工具行,思考带已结算预览;G2 仍是组头。单个完整正文仍需手动打开。 |
| 整轮展开,Expanded | G1/G2 组头、阶段回复及最终答案;已完成轮次的组正文仍需手动打开。 |
| 整轮收起,简洁/标准/详细模式 | 输入、整轮控件、最终答案和页脚;过程组头、正文及阶段回复都不显示。 |
| 整轮展开,简洁/标准/详细模式 | G1/G2 组头、阶段回复及最终答案;组正文初始收起。 |
| 整轮和 G1 展开,标准或详细模式 | G1 的思考与工具行,思考带已结算预览;G2 仍是组头。单个完整正文仍需手动打开。 |
| 完全展开模式 | 不可收起的整轮时长或状态抬头、全部过程行、阶段回复与最终答案。没有组头,单个工具/思考正文仍需手动展开。 |
显隐从外向内生效:整轮 → 二级组 → 单个思考或工具。内层展开不能越过外层收起。类别变化不会开合任何一层;切换模式保留分组和用户的手动展开选择。
若过程开始后又出现 steering、User 或触发通知,该轮不提供整轮收起,只保留各组自己的开合。`G1 → steering 1 → G2 → steering 2 → G3` 在 Compact/Detailed 中始终保留三个组头及两条输入的顺序,阶段回复也保持可见;打开一个组只显示该组正文。Expanded 仅在该轮仍运行时直接显示组正文,完成后保留组头及手动开合。整轮控件仍显示时长或状态,但不提供折叠操作。
若过程开始后又出现 steering、User 或触发通知,该轮不提供整轮收起,只保留各组自己的开合。`G1 → steering 1 → G2 → steering 2 → G3` 在简洁/标准模式中始终保留三个组头及两条输入的顺序,阶段回复也保持可见;打开一个组只显示该组正文。详细模式仅在该轮仍运行时直接显示组正文,完成后保留组头及手动开合;完全展开模式在两种情况下都直接显示组正文。整轮控件仍显示时长或状态,但不提供折叠操作。
-----
<a id="whole-turn-folding"></a>
## 整轮折叠
整轮折叠控制已加载的过程范围,独立于二级分组。已经记录轮次结束时,即使向前分页尚未加载到轮次起点,也可以对该范围折叠。
简洁、标准、详细模式的整轮折叠控制已加载的过程范围,独立于二级组。完全展开模式保持该范围可见,保留时长或状态抬头,但不支持收起。已经记录轮次结束时,即使向前分页尚未加载到轮次起点,也可以对该范围折叠。
整轮控件位于该轮所有起始输入之后,包括人工 steering 和非人工触发通知;等待首个 Assistant 输出及输出到达后均保持这一顺序。首个过程证据之前的连续输入都属于开场,以最后一条为准;过程证据之后的输入留在原位,即使分页尚未加载其入队记录、消息暂时归为普通 User,也不能把前面的过程移到它后面。
@@ -108,21 +108,21 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
<a id="display-modes"></a>
## 展示模式
设置 → 通用设置 → 工作过程展示将 `ui-chat.transcriptView` 保存为 `compact`(默认)、`detailed` 或 `expanded`。已保存的旧值 `normal` 读取为 `detailed`,不自动回写。
设置 → 通用设置 → 工作步骤展示提供 `compact`、`standard`(默认)、`detailed` 和 `verbose`,说明为「选择希望看到多少工具调用细节」。旧值 `normal` 读取为 `standard`,`expanded` 读取为 `detailed`,不自动回写;已有 `detailed` 保持不变。未配置、无效值以及 Host 设置到达前均使用 `standard`;其他设置的无效值仍会校验失败。
| 行为 | 简洁 | 详细 | 完全展开 |
|---|---|---|---|
| 过程组头 | 类别摘要 | 摘要与实时任务详情 | 运行中轮次隐藏;历史轮次保留 |
| 过程组正文 | 初始收起 | 初始收起 | 运行中轮次直接显示、不设组级限高;历史轮次按手动开合显示 |
| 已结算推理预览 | 隐藏 | 首行 | 首行 |
| 单个推理与工具正文 | 手动展开 | 手动展开 | 手动展开 |
| 符合条件的已完成轮次 | 初始收起 | 初始收起 | 初始收起 |
| 行为 | 简洁 | 标准 | 详细 | 完全展开 |
|---|---|---|---|---|
| 过程组头 | 类别摘要 | 摘要与实时任务详情 | 运行中轮次隐藏;历史轮次保留 | 隐藏 |
| 过程组正文 | 初始收起 | 初始收起 | 运行中轮次直接显示、不设组级限高;历史轮次按手动开合显示 | 运行中与历史轮次都直接显示,不设组级限高 |
| 已结算推理预览 | 隐藏 | 首行 | 首行 | 首行 |
| 单个推理与工具正文 | 手动展开 | 手动展开 | 手动展开 | 手动展开 |
| 符合条件的已完成轮次 | 初始收起 | 初始收起 | 初始收起 | 始终展开;时长或状态抬头不能收起它 |
已结束组的组头展示排序摘要的前三类,不显示次数;没有类别时使用思考标题。运行中的组头展示实时工具类别,没有运行工具时使用思考标题;详细模式附加实时详情。实时标题至少保留 150ms,只保留最新待显示标题。
已结束组的组头展示排序摘要的前三类,不显示次数;没有类别时使用思考标题。运行中的组头展示实时工具类别,没有运行工具时使用思考标题;标准模式附加实时详情。实时标题至少保留 150ms,只保留最新待显示标题。
### 组标题规则
三个阶段共用下方工具名分类。准备中的 Tool 节点使用对应类别的准备文案;`read` 为「准备读取文件」,`read_image` 为「准备读取图片」,`write` 为「准备写入文件」,`edit` 和 `apply_patch` 为「准备编辑文件」,`todo_write` 及目标工具为「准备更新计划」。只有通用类别的「准备调用工具」在 Detailed 中追加协议工具名,其他类别不追加。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。
三个阶段共用下方工具名分类。准备中的 Tool 节点使用对应类别的准备文案;`read` 为「准备读取文件」,`read_image` 为「准备读取图片」,`write` 为「准备写入文件」,`edit` 和 `apply_patch` 为「准备编辑文件」,`todo_write` 及目标工具为「准备更新计划」。只有通用类别的「准备调用工具」在标准模式中追加协议工具名,其他类别不追加。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。
以下文案描述已记录的活动,不保证操作成功;例如失败的读取仍参与「已读取文件」类别。
@@ -145,23 +145,23 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
| 组状态 | 标题组合规则 |
|---|---|
| 未结束,有运行工具 | 取实时类别,不取计数最多的类别。Detailed 用 ` · ` 追加非空详情,Compact 不追加。 |
| 未结束,没有运行工具 | 即使已完成工具次数非零,也使用分析文案;Detailed 可以追加运行中推理详情。 |
| 未结束,有运行工具 | 取实时类别,不取计数最多的类别。标准模式用 ` · ` 追加非空详情,简洁模式不追加。 |
| 未结束,没有运行工具 | 即使已完成工具次数非零,也使用分析文案;标准模式可以追加运行中推理详情。 |
| 已结束,零个类别 | 使用「已完成分析」。 |
| 已结束,一个类别 | 使用该类别的已结束文案,不带次数。 |
| 已结束,两个类别 | 按排名用「并」连接;仅当两项都以 `已` 开头时省略第二项的 `已`,例如 `已读取文件并搜索代码`。 |
| 已结束,三个类别 | 按排名用中文逗号 `,` 连接三项文案。 |
| 已结束,超过三个类别 | 只显示前三项文案,末尾加「等」。 |
英文组合标题将第一项之后各项的首字母转为小写。组结束时立即切换完成摘要;150ms 最短保留时间针对运行中标题变化,不延迟完成状态。Expanded 隐藏运行中轮次的组头,包括被回复或 steering 截断、但所属轮次尚未结束的小组。
英文组合标题将第一项之后各项的首字母转为小写。组结束时立即切换完成摘要;150ms 最短保留时间针对运行中标题变化,不延迟完成状态。详细模式隐藏运行中轮次的组头,包括被回复或 steering 截断、但所属轮次尚未结束的小组;完全展开模式也隐藏历史组头。
组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 8px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。完全展开模式仅对运行中轮次取消组级限高,使用 16px 行间距。
组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 8px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。详细模式对运行中轮次取消组级限高,使用 16px 行间距;完全展开模式对历史轮次也采用该布局。
已展开的限高组仅在自己的滚动位置处于底部时跟随内容增长。滚离底部暂停该组跟随,滚回底部后恢复,与外层文本记录的跟随状态独立。手动展开未关闭的组时定位到底部并跟随增长;手动展开已关闭的组时从顶部开始且不启用跟随,即使初始内容无需滚动即可容纳。数据中的组变为已关闭,或通过模式切换恢复限高时,不重置已展开组的阅读位置。浏览器查找沿用自身的展开定位。
单个推理初始收起,包括流式输出期间。三档都预览最近一个首行以换行结束的段落;未完成的单行没有预览,该段落后续文本不改变预览。结算后按上表展示。展开的推理使用紧凑 Markdown 排版。
单个推理初始收起,包括流式输出期间。四档都预览最近一个首行以换行结束的段落;未完成的单行没有预览,该段落后续文本不改变预览。结算后按上表展示。展开的推理使用紧凑 Markdown 排版。
切换模式保留手动展开的组和内部开合状态,只改变显隐与尺寸,不重建消息行。完全展开不表示自动打开每个内部控件,也不表示展开整轮。
切换模式保留手动展开的组和内部开合状态,只改变显隐与尺寸,不重建消息行。详细和完全展开都不表示自动打开每个内部控件;只有完全展开模式始终展开整轮。
-----
@@ -170,7 +170,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
[process-groups.ts](process-groups.ts) 划分可见 Chat 内容,[process-activity.ts](process-activity.ts) 汇总每组活动,两者共同遵循下列业务规则。
一组收集同一轮次内相邻的过程内容,仅 Step 编号变化不会拆组。Compact、Detailed 和 Expanded 使用同一分组结果。组的 `closed` 表示这段内容已经结束,不表示 UI 开合控件处于收起状态。
一组收集同一轮次内相邻的过程内容,仅 Step 编号变化不会拆组。四档模式使用同一分组结果。组的 `closed` 表示这段内容已经结束,不表示 UI 开合控件处于收起状态。
| 输入 | 归属与切分 |
|---|---|
@@ -259,7 +259,8 @@ function projectAssistant(context: ConversationNodeContext<AssistantState>): Ass
const status = settled?.interrupted === true
? 'interrupted'
: settled === undefined ? 'running' : 'settled'
const anchorSeq = settled?.seq ?? state.firstVisibleSeq ?? context.matches[0]?.event.seq ?? 0
const anchorSeq = (settled?.interrupted === true ? settled.seq : state.firstVisibleSeq ?? settled?.seq)
?? context.matches[0]?.event.seq ?? 0
const time = settled?.time ?? state.firstVisibleTime ?? context.matches[0]?.event.time ?? 0
return {
anchorSeq,
@@ -341,7 +342,9 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
const settled = data.finalNode
const visible = settled === undefined ? state.visibleBlocks > 0 : hasVisibleContent(data.blocks)
if (settled === undefined && !visible && current == null) return null
const anchorSeq = settled?.seq ?? state.firstVisibleSeq ?? context.matches[0]?.event.seq ?? 0
// A successful message retains its live anchor alongside pending Tool calls.
const anchorSeq = (settled?.interrupted === true ? settled.seq : state.firstVisibleSeq ?? settled?.seq)
?? context.matches[0]?.event.seq ?? 0
return chatNode(context, 'assistant-step', anchorSeq, data, {
visibility: settled?.interrupted === true || visible ? 'visible' : 'hidden',
})
+7 -5
View File
@@ -100,11 +100,12 @@ export const zh = {
'settings.links.description': '对话中网页链接的打开位置',
'settings.links.sidebar': '应用内侧边栏',
'settings.links.newTab': '默认浏览器',
'settings.transcript.title': '工作过程展示',
'settings.transcript.description': '控制轮次和步骤的默认展开方式',
'settings.transcript.title': '工作步骤展示',
'settings.transcript.description': '选择希望看到多少工具调用细节',
'settings.transcript.compact': '简洁',
'settings.transcript.standard': '标准',
'settings.transcript.detailed': '详细',
'settings.transcript.expanded': '完全展开',
'settings.transcript.verbose': '完全展开',
'fileOpen.title': '无法打开文件',
'fileOpen.unknown': '无法打开此文件',
'message.extraBlock': '附加内容块',
@@ -284,10 +285,11 @@ export const en = {
'settings.links.sidebar': 'In-App Sidebar',
'settings.links.newTab': 'Default Browser',
'settings.transcript.title': 'Work details',
'settings.transcript.description': 'Controls how turns and steps expand by default',
'settings.transcript.description': 'Choose how much detail to show for tool calls',
'settings.transcript.compact': 'Compact',
'settings.transcript.standard': 'Standard',
'settings.transcript.detailed': 'Detailed',
'settings.transcript.expanded': 'Expanded',
'settings.transcript.verbose': 'Verbose',
'fileOpen.title': 'Couldn’t open file',
'fileOpen.unknown': 'Couldn’t open this file',
'message.extraBlock': 'Extra content block',
@@ -13,8 +13,8 @@ export interface ChatPresentationPolicy {
readonly mode: TranscriptViewMode
/** Whether a normally completed Turn folds its process rows behind the whole-Turn control. */
readonly foldCompletedTurns: boolean
/** Whether running Turns' secondary groups expose a collapsible header; historical groups always do. */
readonly stepGrouping: 'collapsed' | 'none'
/** Collapsible group headers for all Turns, historical Turns only, or no Turns. */
readonly stepGrouping: 'collapsed' | 'history' | 'none'
/** Show the running command, path, query, or reasoning detail in group titles. */
readonly liveProcessDetail: boolean
/** Whether a settled reasoning row previews its first line beside the Think title. */
@@ -29,20 +29,27 @@ const POLICIES: Readonly<Record<TranscriptViewMode, ChatPresentationPolicy>> = {
liveProcessDetail: false,
settledReasoningPreview: false,
},
detailed: {
mode: 'detailed',
standard: {
mode: 'standard',
foldCompletedTurns: true,
stepGrouping: 'collapsed',
liveProcessDetail: true,
settledReasoningPreview: true,
},
expanded: {
mode: 'expanded',
detailed: {
mode: 'detailed',
foldCompletedTurns: true,
stepGrouping: 'none',
stepGrouping: 'history',
liveProcessDetail: true,
settledReasoningPreview: true,
},
verbose: {
mode: 'verbose',
foldCompletedTurns: false,
stepGrouping: 'none',
liveProcessDetail: false,
settledReasoningPreview: true,
},
}
/**
@@ -24,8 +24,9 @@ export type TranscriptViewRowProps =
const LABELS = {
compact: 'settings.transcript.compact',
standard: 'settings.transcript.standard',
detailed: 'settings.transcript.detailed',
expanded: 'settings.transcript.expanded',
verbose: 'settings.transcript.verbose',
} as const satisfies Record<TranscriptViewMode, ChatKey>
/**
@@ -3,14 +3,14 @@
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
import {
DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
type ChatSettings, type TranscriptViewMode,
} from '../chat-settings.ts'
/** Live work-details preference consumed by Chat and its Settings row. */
export class TranscriptViewPolicy {
private readonly unsubscribe: () => void
/** Reactive current mode; defaults to Compact before Host settings arrive. */
/** Reactive current mode; defaults to Standard before Host settings arrive. */
readonly mode: SnapshotStore<TranscriptViewMode> = createSnapshotStore(DEFAULT_TRANSCRIPT_VIEW_MODE)
/**
@@ -26,7 +26,7 @@ export class TranscriptViewPolicy {
/**
* Publish and persist one explicit user choice.
* @param mode - Compact, Detailed, or Expanded work details.
* @param mode - Compact, Standard, Detailed, or Verbose work details.
*/
setMode(mode: TranscriptViewMode): void {
if (this.mode.getSnapshot() === mode) return
@@ -38,7 +38,9 @@ export class TranscriptViewPolicy {
private adopt(): void {
const section = this.host.getSnapshot().value
if (section === undefined) return
const mode = section.transcriptView === LEGACY_TRANSCRIPT_VIEW_MODE ? 'detailed' : section.transcriptView
const saved = section.transcriptView
const mode = saved === LEGACY_TRANSCRIPT_VIEW_MODE ? 'standard'
: saved === LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE ? 'detailed' : saved
if (this.mode.getSnapshot() !== mode) this.mode.set(mode)
}
}
+2 -1
View File
@@ -9,7 +9,8 @@ import { TRANSCRIPT_VIEW_FIELD } from './chat-settings.ts'
import { ChatSettingsFields } from './chat-settings.ts'
export {
CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE,
LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
TRANSCRIPT_VIEW_MODES, type ChatSettings, type TranscriptViewMode,
} from './chat-settings.ts'
@@ -114,7 +114,7 @@ describe('Chat apply wiring', () => {
.find(entry => entry.options.id === 'transcript-view')!
const face = (row.inject as unknown as () => TranscriptViewRowInjected)()
expect(face.hooks.transcriptView.getSnapshot()).toBe('compact')
expect(face.hooks.transcriptView.getSnapshot()).toBe('standard')
face.setTranscriptView('detailed')
expect(face.hooks.transcriptView.getSnapshot()).toBe('detailed')
expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'detailed')
@@ -681,7 +681,7 @@ describe('ChatView', () => {
expect(parent?.tagName).toBe('DIV')
fireEvent.change(input, { target: { value: 'retained local input' } })
const observe = vi.spyOn(ResizeObserver.prototype, 'observe')
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
for (const mode of ['standard', 'detailed', 'compact'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(view.getByRole('textbox', { name: inside.key })).toBe(input)
expect(input.value).toBe('retained local input')
@@ -690,7 +690,7 @@ describe('ChatView', () => {
}
fireEvent.click(groupHeader)
expect(groupHeader.getAttribute('aria-expanded')).toBe('false')
act(() => { h.setTranscriptView('expanded') })
act(() => { h.setTranscriptView('detailed') })
expect(view.getByRole('textbox', { name: inside.key })).toBe(input)
act(() => { h.setTranscriptView('compact') })
expect(view.queryByRole('textbox', { name: inside.key })).toBeNull()
@@ -737,10 +737,10 @@ describe('ChatView', () => {
expect(bodies.map(body => body.hasAttribute('hidden'))).toEqual([true, true, true])
fireEvent.click(headers[0]!)
for (const mode of ['detailed', 'expanded', 'compact', 'expanded'] as const) {
for (const mode of ['standard', 'detailed', 'compact', 'detailed'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(bodies.map(body => body.hasAttribute('hidden'))).toEqual([false, true, mode !== 'expanded'])
expect(headers.map(header => header.closest('[hidden]') !== null)).toEqual([false, false, mode === 'expanded'])
expect(bodies.map(body => body.hasAttribute('hidden'))).toEqual([false, true, mode !== 'detailed'])
expect(headers.map(header => header.closest('[hidden]') !== null)).toEqual([false, false, mode === 'detailed'])
expect([...view.container.querySelectorAll('[data-chat-group-key]')]).toEqual(roots)
}
@@ -753,6 +753,16 @@ describe('ChatView', () => {
expect(bodies[2]!.hasAttribute('hidden')).toBe(true)
expect(bodies[0]!.hasAttribute('hidden')).toBe(false)
expect(bodies[1]!.hasAttribute('hidden')).toBe(true)
act(() => { h.setTranscriptView('verbose') })
expect(roots.every(root => root.closest('[hidden]') === null)).toBe(true)
expect(headers.every(header => header.closest('[hidden]') !== null)).toBe(true)
expect(bodies.every(body => !body.hasAttribute('hidden'))).toBe(true)
expect(control.disabled).toBe(true)
expect(control.textContent).toContain(h.props.t('message.turnProcess.took', { duration: formatRunDuration(3_000, h.props.t) }))
fireEvent.click(control)
expect(bodies.every(body => !body.hasAttribute('hidden'))).toBe(true)
expect([...view.container.querySelectorAll<HTMLElement>('[data-chat-group-key]')]).toEqual(roots)
})
it.each([
@@ -918,7 +928,7 @@ describe('ChatView', () => {
expect(headers).toHaveLength(40)
const titles = headers.map(header => header.textContent)
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
for (const mode of ['standard', 'detailed', 'compact'] as const) {
translate.mockClear()
act(() => { h.setTranscriptView(mode) })
expect(translate.mock.calls.filter(([key]) => key === 'message.stepProcess.thinking')).toHaveLength(0)
@@ -936,7 +946,7 @@ describe('ChatView', () => {
groupStore.publish()
})
translate.mockClear()
act(() => { h.setTranscriptView('detailed') })
act(() => { h.setTranscriptView('standard') })
expect(translate.mock.calls.filter(([key]) => key.startsWith('message.stepProcess.'))).toHaveLength(0)
})
@@ -1319,9 +1329,9 @@ describe('ChatView', () => {
})
it.each([
{ mode: 'compact', anchor: 'group' }, { mode: 'detailed', anchor: 'group' },
{ mode: 'expanded', anchor: 'group' },
{ mode: 'compact', anchor: 'node' }, { mode: 'detailed', anchor: 'node' }, { mode: 'expanded', anchor: 'node' },
{ mode: 'compact', anchor: 'group' }, { mode: 'standard', anchor: 'group' },
{ mode: 'detailed', anchor: 'group' },
{ mode: 'compact', anchor: 'node' }, { mode: 'standard', anchor: 'node' }, { mode: 'detailed', anchor: 'node' },
] as const)(
'keeps the first visible item in place when paging inserts a steering boundary ($mode, $anchor)', ({ mode, anchor }) => {
const tool = (seq: number, id: string) => ({ ...toolResult(seq, id), turn: 1 })
@@ -1387,14 +1397,14 @@ describe('ChatView', () => {
it.each([
{ mode: 'compact', hasProcess: false, hasSteering: false },
{ mode: 'standard', hasProcess: false, hasSteering: false },
{ mode: 'detailed', hasProcess: false, hasSteering: false },
{ mode: 'expanded', hasProcess: false, hasSteering: false },
{ mode: 'compact', hasProcess: true, hasSteering: false },
{ mode: 'standard', hasProcess: true, hasSteering: false },
{ mode: 'detailed', hasProcess: true, hasSteering: false },
{ mode: 'expanded', hasProcess: true, hasSteering: false },
{ mode: 'compact', hasProcess: false, hasSteering: true },
{ mode: 'standard', hasProcess: false, hasSteering: true },
{ mode: 'detailed', hasProcess: false, hasSteering: true },
{ mode: 'expanded', hasProcess: false, hasSteering: true },
] as const)('keeps the loaded message anchored when paging exposes steering inside a closed Turn ($mode, process=$hasProcess, steer=$hasSteering)', ({ mode, hasProcess, hasSteering }) => {
const tool = (seq: number, id: string) => ({ ...toolResult(seq, id), turn: 1 })
const initial = {
@@ -1716,7 +1726,7 @@ describe('ChatView', () => {
expect(view.getAllByText('即发即显')).toHaveLength(1)
})
it.each(['compact', 'detailed', 'expanded'] as const)(
it.each(['compact', 'standard', 'detailed'] as const)(
'keeps the opening echo above a new Turn title and steering below it (%s)', (mode) => {
const opening = {
requestId: 'opening' as never, placement: 'transcript' as const,
@@ -1825,7 +1835,7 @@ describe('ChatView', () => {
expect(view.getByLabelText('回到底部')).toBeTruthy()
})
it.each(['compact', 'detailed', 'expanded'] as const)(
it.each(['compact', 'standard', 'detailed'] as const)(
'hides an admitted local steer while its Inbox claim projection is delayed (%s)', (mode) => {
const pending: InboxState['next-step'] = ['first', 'second'].map(text => ({
id: text as never, role: 'user', source: { kind: 'user', rpcId: text as never },
@@ -2345,7 +2355,7 @@ describe('ChatView', () => {
expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false)
})
it.each(['compact', 'detailed', 'expanded'] as const)(
it.each(['compact', 'standard', 'detailed'] as const)(
'preserves steering-separated process groups in %s mode after Turn completion', (mode) => {
const nodes = [
userInTurn(1, 'question', 1),
@@ -2389,7 +2399,7 @@ describe('ChatView', () => {
view.getByText('second direction').closest('[data-chat-flow-kind]'),
roots[2], view.getByText('final answer').closest('[data-chat-flow-kind]'),
])
for (const next of ['compact', 'detailed', 'expanded', mode] as const) {
for (const next of ['compact', 'standard', 'detailed', mode] as const) {
act(() => { h.setTranscriptView(next) })
expect(visibleOrder()).toEqual(order)
expect([...view.container.querySelectorAll('[data-chat-group-key]')]).toEqual(roots)
@@ -2476,14 +2486,14 @@ describe('ChatView', () => {
expect(processRow.getAttribute('hidden')).toBe('until-found')
const toggle = turnProcessControl(view.container)!
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
for (const mode of ['standard', 'detailed', 'compact'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(turnProcessControl(view.container)).toBe(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(processRow.getAttribute('hidden')).toBe('until-found')
}
fireEvent.click(toggle)
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
for (const mode of ['standard', 'detailed', 'compact'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(turnProcessControl(view.container)).toBe(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
@@ -2674,7 +2684,7 @@ describe('ChatView', () => {
const toggle = view.getByRole('button', { name: h.props.t('message.turnProcess.worked') })
let row = view.container.querySelector<HTMLElement>('[data-chat-node-key="fixture:tool:partial"]')!
let group = view.container.querySelector<HTMLElement>('[data-chat-group-key]')
for (const mode of ['compact', 'detailed', 'expanded'] as const) {
for (const mode of ['compact', 'standard', 'detailed'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(row.getAttribute('hidden')).toBe('until-found')
@@ -2689,7 +2699,7 @@ describe('ChatView', () => {
const beforePageGroup = group
const groupToggle = group?.querySelector<HTMLButtonElement>('[data-process-activity]')
if (grouped && manualOpen) {
act(() => { h.setTranscriptView('detailed') })
act(() => { h.setTranscriptView('standard') })
fireEvent.click(groupToggle!)
expect(groupToggle?.getAttribute('aria-expanded')).toBe('true')
}
@@ -17,7 +17,14 @@ describe('ui-chat Host settings', () => {
expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed', linkOpening: 'sidebar' })
await configuration.update({ transcriptView: 'normal' })
expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar' })
await expect(configuration.update({ transcriptView: 'dense' })).rejects.toThrow()
for (const mode of ['expanded', 'compact', 'standard', 'detailed', 'verbose']) {
await configuration.update({ transcriptView: mode })
expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode })
}
for (const mode of ['dense', '', 42, false, {}, null, undefined]) {
await configuration.update({ transcriptView: mode })
expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: 'standard' })
}
await configuration.update({ performanceUsage: 'compact' })
expect(plainConfig(configuration.fiber.config)).toMatchObject({ performanceUsage: 'compact' })
await expect(configuration.update({ performanceUsage: 'hidden' })).rejects.toThrow()
@@ -10,12 +10,13 @@ afterEach(cleanup)
describe('Chat presentation policy', () => {
it.each([
['compact', 'collapsed', false, false],
['detailed', 'collapsed', true, true],
['expanded', 'none', true, true],
] as const)('maps %s to stable presentation capabilities', (mode, stepGrouping, settledReasoningPreview, liveProcessDetail) => {
['compact', true, 'collapsed', false, false],
['standard', true, 'collapsed', true, true],
['detailed', true, 'history', true, true],
['verbose', false, 'none', true, false],
] as const)('maps %s to stable presentation capabilities', (mode, foldCompletedTurns, stepGrouping, settledReasoningPreview, liveProcessDetail) => {
const policy = presentationPolicyFor(mode)
expect(policy).toEqual({ mode, foldCompletedTurns: true, stepGrouping, settledReasoningPreview, liveProcessDetail })
expect(policy).toEqual({ mode, foldCompletedTurns, stepGrouping, settledReasoningPreview, liveProcessDetail })
expect(presentationPolicyFor(mode)).toBe(policy)
})
@@ -29,7 +30,7 @@ describe('Chat presentation policy', () => {
expect(listener).toHaveBeenCalledTimes(1)
expect(policy.getSnapshot()).toBe(presentationPolicyFor('detailed'))
dispose()
mode.set('expanded')
mode.set('verbose')
expect(listener).toHaveBeenCalledTimes(1)
})
@@ -54,7 +55,7 @@ describe('Chat presentation policy', () => {
act(() => { mode.set('detailed') })
expect(foldRender).toHaveBeenCalledTimes(1)
expect(previewRender).toHaveBeenCalledTimes(2)
act(() => { mode.set('expanded') })
act(() => { mode.set('standard') })
expect(foldRender).toHaveBeenCalledTimes(1)
expect(previewRender).toHaveBeenCalledTimes(2)
act(() => { mode.set('compact') })
@@ -45,14 +45,14 @@ describe('ReasoningRow', () => {
it('retains summaries and expanded Markdown when the work-details mode changes', () => {
const reset = createSnapshotStore(0)
const useDisclosure = bindDisclosure(reset)
const mode = createSnapshotStore<TranscriptViewMode>('detailed')
const mode = createSnapshotStore<TranscriptViewMode>('standard')
const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode))
const view = render(<ReasoningRow useDisclosure={useDisclosure}
text={'First line\n\nDetailed body'} running={false} usePresentation={usePresentation} t={t} />)
const root = view.container.querySelector('[data-variant="think"]')!
const summary = view.getByText('First line')
const toggle = view.getByRole('button')
for (const next of ['compact', 'detailed', 'expanded', 'compact'] as const) {
for (const next of ['compact', 'standard', 'detailed', 'verbose', 'compact'] as const) {
act(() => { mode.set(next) })
expect(view.getByText('First line')).toBe(summary)
expect(root.hasAttribute('data-preview')).toBe(next !== 'compact')
@@ -60,7 +60,7 @@ describe('ReasoningRow', () => {
}
fireEvent.click(toggle)
const body = view.getByText('Detailed body')
for (const next of ['detailed', 'expanded', 'compact'] as const) {
for (const next of ['standard', 'detailed', 'verbose', 'compact'] as const) {
act(() => { mode.set(next) })
expect(view.getByText('Detailed body')).toBe(body)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
@@ -25,22 +25,22 @@ const opening = [
entry({ type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } }),
]
function delta(seq: number, id = first, argumentsDelta = '', name: string | null = 'write'): SessionEventLikeEntry {
function delta(seq: number, id = first, argumentsDelta = '', name: string | null = 'write', index = id === first ? 0 : 1): SessionEventLikeEntry {
return {
type: 'transient',
event: {
type: 'assistant/live-chunk', seq, time: seq,
data: { turn: 1, step: 1, attemptId, chunk: {
type: 'tool-call-delta', index: id === first ? 0 : 1, id, argumentsDelta,
type: 'tool-call-delta', index, id, argumentsDelta,
...(name === null ? {} : { name }),
} },
},
}
}
function call(seq: number, callId = first): SessionLiveEventEntry {
function call(seq: number, callId = first, name = 'write', argumentsRaw = args): SessionLiveEventEntry {
return entry({ type: 'tool/call', seq: SessionSeq(seq), time: seq,
data: { turn: 1, step: 1, callId, name: 'write', arguments: args } })
data: { turn: 1, step: 1, callId, name, arguments: argumentsRaw } })
}
function result(seq: number, callId = first): SessionLiveEventEntry {
@@ -49,18 +49,20 @@ function result(seq: number, callId = first): SessionLiveEventEntry {
} })
}
function settlement(): SessionAssistantSettlementEntry {
function settlement(ids = [first, second], name = 'write', argumentsRaw = args, text = ''): SessionAssistantSettlementEntry {
const stream = new AssistantStreamAccumulator()
for (const [index, id] of [first, second].entries()) {
const chunk: StreamChunk = { type: 'tool-call-delta', index, id, name: 'write', argumentsDelta: args }
stream.push({ time: 3 + index, chunk })
if (text !== '') stream.push({ time: 3, chunk: { type: 'text-delta', index: 0, text } })
for (const [index, id] of ids.entries()) {
const chunk: StreamChunk = { type: 'tool-call-delta', index: index + Number(text !== ''), id, name, argumentsDelta: argumentsRaw }
stream.push({ time: 3, chunk })
}
return { type: 'event', event: {
type: 'assistant/message', seq: SessionSeq(5), time: 5, surfaceOp: 'append', data: {
turn: 1, step: 1, stream: [...stream.snapshot()],
message: createAssistantMessage({ source: { provider: 'test', model: 'test' }, content: [first, second].map(id => ({
type: 'tool-call', id, name: 'write', arguments: args,
})) }),
message: createAssistantMessage({ source: { provider: 'test', model: 'test' }, content: [
...text === '' ? [] : [{ type: 'text' as const, text }],
...ids.map(id => ({ type: 'tool-call' as const, id, name, arguments: argumentsRaw })),
] }),
},
} }
}
@@ -130,19 +132,62 @@ describe('Tool preparation and durable replay', () => {
expect(h.phases()).toEqual([[first, 'result'], [second, 'result']])
})
it('withdraws transient preparation on settlement and converges with a durable-only replacement', () => {
it('keeps five command preparations below the Assistant message through dispatch and Step end', () => {
const h = harness()
const ids = Array.from({ length: 5 }, (_, index) => ToolCallId(`command-${index}`))
const command = '{"command":"echo hello"}'
const text = 'Running five commands'
h.assembler.append({ type: 'transient', event: {
type: 'assistant/live-chunk', seq: 2.01, time: 3,
data: { turn: 1, step: 1, attemptId, chunk: { type: 'text-delta', index: 0, text } },
} })
for (const [index, id] of ids.entries()) h.assembler.append(delta(2.1 + index / 10, id, command, 'bash', index + 1))
const keys = h.tools().map(node => node.key)
const groups = h.assembler.grouped('chat')!
const group = groups.entries.find(entry => entry.kind === 'group')!
const order = groups.entries
expect(order[0]).toMatchObject({ kind: 'node', groupPart: 'response' })
expect(order[1]).toEqual(group)
const message = settlement(ids, 'bash', command, text)
const history = [...opening, message]
h.assembler.append(message)
expect(h.phases()).toEqual(ids.map(id => [id, 'preparing']))
expect(groups.entries).toEqual(order)
for (const [index, id] of ids.entries()) {
const started = call(6 + index * 2, id, 'bash', command)
h.assembler.append(started)
expect(h.phases()).toEqual(ids.map((callId, at) => [callId, at < index ? 'result' : at === index ? 'start' : 'preparing']))
expect(groups.entries).toEqual(order)
const completed = result(7 + index * 2, id)
h.assembler.append(completed)
history.push(started, completed)
expect(h.tools().map(node => node.key)).toEqual(keys)
expect(groups.entries).toEqual(order)
}
const closed = entry({ type: 'step/end', seq: SessionSeq(16), time: 16, data: { turn: 1, step: 1 } })
history.push(closed)
h.assembler.append(closed)
h.assembler.settleAssistant(attemptId)
const replay = harness(history)
expect(h.phases()).toEqual(ids.map(id => [id, 'result']))
expect(h.material()).toEqual(replay.material())
expect(groups.entries).toEqual(order)
})
it('hides undispatched preparations when a settled Step ends before every call starts', () => {
const h = harness()
h.assembler.append(delta(2.1))
const key = h.tools()[0]!.key
h.assembler.append(delta(2.2, second))
const message = settlement()
h.assembler.settleAssistant(attemptId, message)
expect(h.phases()).toEqual([])
const history = [...opening, message, call(6), result(7), call(8, second), result(9, second)]
for (const event of history.slice(3)) h.assembler.append(event)
expect(h.tools()[0]?.key).toBe(key)
const replay = harness(history)
expect(h.phases()).toEqual([[first, 'result'], [second, 'result']])
expect(h.material()).toEqual(replay.material())
h.assembler.append(message)
h.assembler.append(call(6))
h.assembler.append(result(7))
expect(h.phases()).toEqual([[first, 'result'], [second, 'preparing']])
const closed = entry({ type: 'step/end', seq: SessionSeq(8), time: 8, data: { turn: 1, step: 1 } })
h.assembler.append(closed)
expect(h.phases()).toEqual([[first, 'result']])
h.assembler.settleAssistant(attemptId)
expect(h.material()).toEqual(harness([...opening, message, call(6), result(7), closed]).material())
})
it('does not create preparing rows from stored Assistant streams in history or older pages', () => {
@@ -5,7 +5,7 @@ import type { ChatSettings } from '../src/chat-settings.ts'
import { TranscriptViewPolicy } from '../src/client/transcript-view.ts'
describe('TranscriptViewPolicy', () => {
it('defaults to Compact and publishes explicit choices before persistence settles', () => {
it('defaults to Standard and publishes explicit choices before persistence settles', () => {
const host = stubConfigForm<ChatSettings>()
const observed: string[] = []
let current = (): string => 'unconstructed'
@@ -19,20 +19,20 @@ describe('TranscriptViewPolicy', () => {
const policy = new TranscriptViewPolicy(scope)
current = () => policy.mode.getSnapshot()
expect(policy.mode.getSnapshot()).toBe('compact')
expect(policy.mode.getSnapshot()).toBe('standard')
policy.setMode('detailed')
expect(policy.mode.getSnapshot()).toBe('detailed')
expect(observed).toEqual(['transcriptView=detailed:detailed'])
expect(host.set).toHaveBeenCalledWith('transcriptView', 'detailed')
})
it('adopts Host state, reads the legacy value as Detailed, and ignores identical writes', () => {
it('adopts Host state, reads normal as Standard, and ignores identical writes', () => {
const host = stubConfigForm<ChatSettings>()
const policy = new TranscriptViewPolicy(host.scope)
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true })
expect(policy.mode.getSnapshot()).toBe('detailed')
policy.setMode('detailed')
expect(policy.mode.getSnapshot()).toBe('standard')
policy.setMode('standard')
expect(host.set).not.toHaveBeenCalled()
host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 })
@@ -42,7 +42,17 @@ describe('TranscriptViewPolicy', () => {
it('adopts an accepted section standing at construction', () => {
const host = stubConfigForm<ChatSettings>()
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'expanded', performanceUsage: 'detailed' }, revision: 1, writable: true })
expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('expanded')
expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('detailed')
expect(host.set).not.toHaveBeenCalled()
})
it.each(['compact', 'standard', 'detailed', 'verbose'] as const)('preserves an explicit %s setting without migration writes', (mode) => {
const host = stubConfigForm<ChatSettings>()
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: mode, performanceUsage: 'detailed' }, revision: 1, writable: true })
const policy = new TranscriptViewPolicy(host.scope)
expect(policy.mode.getSnapshot()).toBe(mode)
expect(host.set).not.toHaveBeenCalled()
policy.dispose()
})
})
@@ -34,7 +34,7 @@ function noPendingInteraction() {
// The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address.
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
function mount(mode: TranscriptViewMode = 'compact', dictionary: typeof en | typeof zh = en) {
function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | typeof zh = en) {
const source = createSnapshotStore<TranscriptViewMode>(mode)
const setTranscriptView = vi.fn((next: TranscriptViewMode) => { source.set(next) })
const props: TranscriptViewRowProps = {
@@ -53,20 +53,22 @@ function mount(mode: TranscriptViewMode = 'compact', dictionary: typeof en | typ
}
describe('TranscriptViewRow', () => {
it('explains the preference and shows Compact by default', () => {
it('explains the preference and shows Standard by default', () => {
mount()
expect(screen.getByText('Work details')).toBeDefined()
expect(screen.getByText('Controls how turns and steps expand by default')).toBeDefined()
expect(screen.getByRole('button', { name: /Compact/ }).getAttribute('aria-expanded')).toBe('false')
expect(screen.getByText('Choose how much detail to show for tool calls')).toBeDefined()
expect(screen.getByRole('button', { name: /Standard/ }).getAttribute('aria-expanded')).toBe('false')
})
it.each([
['compact', 'Compact'],
['detailed', 'Detailed'],
['expanded', 'Expanded'],
['verbose', 'Verbose'],
] as const)('selects %s and follows the mirrored value', (mode, label) => {
const b = mount()
fireEvent.click(screen.getByRole('button', { name: /Compact/ }))
fireEvent.click(screen.getByRole('button', { name: /Standard/ }))
expect(screen.queryByRole('menuitem', { name: 'Normal' })).toBeNull()
expect(screen.queryByRole('menuitem', { name: 'Expanded' })).toBeNull()
fireEvent.click(screen.getByRole('menuitem', { name: label }))
expect(b.setTranscriptView).toHaveBeenCalledWith(mode)
const trigger = screen.getByRole('button', { name: label })
@@ -76,21 +78,25 @@ describe('TranscriptViewRow', () => {
expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull()
})
it('shows all three work-detail values in Chinese', () => {
it('shows all four work-detail values in Chinese', () => {
const b = mount('compact', zh)
expect(screen.getByText('工作过程展示')).toBeDefined()
expect(screen.getByText('工作步骤展示')).toBeDefined()
expect(screen.getByText('选择希望看到多少工具调用细节')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: '简洁' }))
fireEvent.click(screen.getByRole('menuitem', { name: '标准' }))
expect(b.setTranscriptView).toHaveBeenLastCalledWith('standard')
fireEvent.click(screen.getByRole('button', { name: '标准' }))
fireEvent.click(screen.getByRole('menuitem', { name: '详细' }))
expect(b.setTranscriptView).toHaveBeenLastCalledWith('detailed')
fireEvent.click(screen.getByRole('button', { name: '详细' }))
fireEvent.click(screen.getByRole('menuitem', { name: '完全展开' }))
expect(b.setTranscriptView).toHaveBeenLastCalledWith('expanded')
expect(b.setTranscriptView).toHaveBeenLastCalledWith('verbose')
expect(screen.getByRole('button', { name: '完全展开' })).toBeDefined()
})
it('returns focus before publishing a mode change without refocusing after the menu closes', async () => {
const b = mount()
const trigger = screen.getByRole('button', { name: /Compact/ })
const trigger = screen.getByRole('button', { name: /Standard/ })
fireEvent.click(trigger)
const item = screen.getByRole('menuitem', { name: 'Detailed' })
item.focus()
@@ -173,7 +179,7 @@ describe('PerformanceUsageRow', () => {
fireEvent.click(screen.getByRole('button', { name: 'Detailed' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Compact' }))
expect(setPerformanceUsage).toHaveBeenCalledWith('compact')
expect(screen.getAllByRole('button', { name: 'Compact' })).toHaveLength(2)
expect(screen.getAllByRole('button', { name: 'Compact' })).toHaveLength(1)
expect(b.setTranscriptView).not.toHaveBeenCalled()
})
})
@@ -85,6 +85,7 @@ export const zh = {
'tool.title.createGoal': '创建目标',
'tool.title.getGoal': '查看目标',
'tool.title.updateGoal': '更新目标',
'tool.preparing.content': '正在准备内容 {kilobytes}KB',
'tool.title.createSchedule': '创建定时任务',
'tool.title.listSchedules': '查看定时任务',
'tool.title.deleteSchedule': '删除定时任务',
@@ -438,6 +439,7 @@ export const en = {
'tool.title.createGoal': 'Create goal',
'tool.title.getGoal': 'View goal',
'tool.title.updateGoal': 'Update goal',
'tool.preparing.content': 'Preparing content {kilobytes}KB',
'tool.title.createSchedule': 'Create reminder',
'tool.title.listSchedules': 'List reminders',
'tool.title.deleteSchedule': 'Delete reminder',
@@ -700,6 +700,10 @@ function registryOption(registry: Registry, t: Translate, resolved: string | nul
*/
function failureText(failure: InstallState['failure'], t: Translate, install?: Pick<InstallState, 'attempts' | 'subject' | 'registries'>): string {
if (failure === null) return t('installFailureGeneric')
// A compatibility refusal is the package's own answer, whatever pnpm's exit classified the run as.
if (failure.code === 'incompatible-version') {
return managementText({ code: failure.code, ...failure.incompatible === undefined ? {} : { incompatible: failure.incompatible } }, t)
}
// Blocked scripts the Host could not name leave the person to allow them in the profile's pnpm settings by hand.
if (failure.kind === 'build-blocked' && !failure.pendingBuilds?.length) return t('installFailureBuildBlockedManual')
const host = install?.subject?.host
@@ -183,6 +183,8 @@ export const zh = {
reasonStopProfile: '这个 profile 没有启用 HMR,正在使用的包要停止后用 dsh plugin 卸载',
reasonBundleInUse: '其他配置仍在使用这个组合包的组件,请先停用它们',
reasonStaleApproval: '待允许的安装脚本列表已变化,请重新安装以刷新',
reasonIncompatibleVersion: '{plugin} 与 DSH {runtime} 不兼容(要求 {peers}),运行它可能导致崩溃或数据丢失。请安装与当前 DSH 兼容的插件版本。',
reasonIncompatibleVersionUnnamed: '这个插件与当前 DSH 版本不兼容,运行它可能导致崩溃或数据丢失',
reasonOperationError: 'Host 报告了一个错误',
} satisfies Record<string, string>
@@ -372,5 +374,7 @@ export const en = {
reasonStopProfile: 'This profile runs without HMR; stop it and uninstall the package with dsh plugin.',
reasonBundleInUse: 'Other configuration still uses this bundle\'s components; switch them off first.',
reasonStaleApproval: 'The pending script approvals changed; install again to refresh them.',
reasonIncompatibleVersion: '{plugin} is incompatible with DSH {runtime} (requires {peers}); running it may cause crashes or data loss. Install a plugin version compatible with this DSH.',
reasonIncompatibleVersionUnnamed: 'This plugin is incompatible with the running DSH version; running it may cause crashes or data loss.',
reasonOperationError: 'The Host reported an error.',
} satisfies Record<PluginManagerLocaleKey, string>
@@ -11,6 +11,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {
BundleInfo,
ChangeResult,
IncompatiblePlugin,
ManagementError,
PluginEntryId,
PluginInfo,
@@ -49,6 +50,8 @@ export type ManagerNotice =
readonly code?: ManagementError['code']
/** The Host's diagnostic or the transport's words, shown verbatim; empty when the code says it all. */
readonly reason: string
/** The packages an `incompatible-version` refusal names. */
readonly incompatible?: readonly IncompatiblePlugin[]
readonly packageName?: string
readonly seq: number
}
@@ -185,6 +188,8 @@ export interface InstallState {
readonly failure: {
readonly reason: string
readonly code?: ManagementError['code']
/** The packages an `incompatible-version` refusal names. */
readonly incompatible?: readonly IncompatiblePlugin[]
readonly kind?: PluginInstallFailureKind
/** What the last failed run could not reach, as the Host attributed it: the registry, or the spec's own host. */
readonly failedAt?: 'registry' | 'spec-host'
@@ -301,7 +306,9 @@ type Answer<T> =
/** A refused answer or a change the Host could not apply, carrying what it said and, for a refusal, its code. */
class RemoteAnswerError extends Error {
constructor(readonly reason: string, readonly code?: ManagementError['code']) {
constructor(
readonly reason: string, readonly code?: ManagementError['code'], readonly incompatible?: readonly IncompatiblePlugin[],
) {
super(reason)
this.name = 'RemoteAnswerError'
}
@@ -315,6 +322,7 @@ function failureOf(
return {
reason: error?.diagnostic ?? '',
...error === undefined ? {} : { code: error.code },
...error?.incompatible === undefined ? {} : { incompatible: error.incompatible },
...kind === undefined ? {} : { kind },
...failedAt === undefined ? {} : { failedAt },
...pendingBuilds === undefined || pendingBuilds.length === 0 ? {} : { pendingBuilds },
@@ -324,7 +332,11 @@ function failureOf(
/** The notice a thrown failure becomes: a refusal keeps its code, anything else its words. */
function failedNotice(error: unknown, subject: { action: FailedAction; packageName?: string }, seq: number): ManagerNotice {
const code = error instanceof RemoteAnswerError ? error.code : undefined
return { kind: 'failed', reason: reasonOf(error), ...code === undefined ? {} : { code }, ...subject, seq }
const incompatible = error instanceof RemoteAnswerError ? error.incompatible : undefined
return {
kind: 'failed', reason: reasonOf(error), ...code === undefined ? {} : { code },
...incompatible === undefined ? {} : { incompatible }, ...subject, seq,
}
}
/** The runs with every one still open settled at `exitCode`. */
@@ -992,7 +1004,7 @@ export class PluginManagerController {
const result = answer.value
switch (result.application) {
case 'failed':
throw new RemoteAnswerError(result.error?.diagnostic ?? '', result.error?.code)
throw new RemoteAnswerError(result.error?.diagnostic ?? '', result.error?.code, result.error?.incompatible)
case 'cancelled':
this.patch({ notice: { kind: 'cancelled', seq: ++this.noticeSeq } })
return
@@ -1,6 +1,6 @@
/** Display labels and toast sentences for global plugin management. */
import type { ManagementError, Registry } from '@deepseek-ai/dsh-api-remotes/client'
import type { IncompatiblePlugin, ManagementError, Registry } from '@deepseek-ai/dsh-api-remotes/client'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { PluginManagerLocaleKey } from './locales.ts'
import type { FailedAction, ManagerNotice, PackageRow, PackageView, PluginManagerFace } from './manager-store.ts'
@@ -53,6 +53,7 @@ const CODE_KEYS = {
'stop-profile': 'reasonStopProfile',
'bundle-in-use': 'reasonBundleInUse',
'stale-approval': 'reasonStaleApproval',
'incompatible-version': 'reasonIncompatibleVersionUnnamed',
'operation-error': 'reasonOperationError',
} satisfies Record<ManagementError['code'], PluginManagerLocaleKey>
@@ -66,13 +67,23 @@ const FAILED_KEYS = {
} satisfies Record<FailedAction, PluginManagerLocaleKey>
/**
* What a management error reads as: the code's sentence, or, for an
* operation error, the Host's diagnostic as it is.
* @param error - the Host's code and its diagnostic, when it has one.
* What a management error reads as: the code's sentence, one sentence per
* package an incompatibility names, or, for an operation error, the Host's diagnostic as it is.
* @param error - the Host's code, its diagnostic, and the packages an incompatibility names.
* @param t - the manager's translate seat.
* @returns the sentence.
*/
export function managementText(error: { readonly code: ManagementError['code']; readonly diagnostic?: string }, t: Translate): string {
export function managementText(error: {
readonly code: ManagementError['code']
readonly diagnostic?: string
readonly incompatible?: readonly IncompatiblePlugin[]
}, t: Translate): string {
if (error.code === 'incompatible-version' && error.incompatible !== undefined && error.incompatible.length > 0) {
return error.incompatible.map(plugin => t('reasonIncompatibleVersion', {
plugin: `${plugin.name}@${plugin.version}`, runtime: plugin.runtimeVersion,
peers: Object.entries(plugin.peers).map(([name, range]) => `${name} ${range}`).join(', '),
})).join(' ')
}
if (error.code !== 'operation-error') return t(CODE_KEYS[error.code])
return error.diagnostic === undefined || error.diagnostic === '' ? t('reasonOperationError') : error.diagnostic
}
@@ -134,7 +145,9 @@ export function noticeText(notice: ManagerNotice, t: Translate): string {
unconfirmed: 'installBackgroundUnconfirmed', applying: 'installBackgroundApplying', unknown: 'installBackgroundUnknown',
} as const)[notice.outcome])
case 'failed': {
const reason = notice.code === undefined ? notice.reason : managementText({ code: notice.code, diagnostic: notice.reason }, t)
const reason = notice.code === undefined ? notice.reason : managementText({
code: notice.code, diagnostic: notice.reason, ...notice.incompatible === undefined ? {} : { incompatible: notice.incompatible },
}, t)
return t(FAILED_KEYS[notice.action], { reason: reason === '' ? t('reasonOperationError') : reason })
}
}
@@ -42,6 +42,10 @@ function row(overrides: Partial<PackageRow> = {}): PackageRow {
return { entryId: 'include:sidebar' as PluginEntryId, rowId: 'sidebar', moduleName: 'dsh-better-sidebar', enabled: true, phase: 'active', ...overrides }
}
const INCOMPATIBLE = { name: 'dsh-late', version: '2.0.0', runtimeVersion: '0.1.0', peers: { '@deepseek-ai/dsh': '^0.2.0', '@deepseek-ai/dsh-core': '^0.2.0' } }
/** The English sentence an incompatibility of {@link INCOMPATIBLE}, optionally renamed, reads as. */
const incompatibleText = (name = INCOMPATIBLE.name): string => en.reasonIncompatibleVersion
.replace('{plugin}', `${name}@2.0.0`).replace('{runtime}', '0.1.0').replace('{peers}', '@deepseek-ai/dsh ^0.2.0, @deepseek-ai/dsh-core ^0.2.0')
const MIRROR = 'https://registry.npmmirror.com/'
const OFFICIAL = 'https://registry.npmjs.org/'
@@ -755,6 +759,11 @@ describe('PluginManagerPage', () => {
expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonNotBundle}`)).toBeTruthy()
set({ packages: [pkg({ error: { code: 'operation-error' } })] })
expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonOperationError}`)).toBeTruthy()
// An incompatibility reads from its structured packages in the dictionary's words, one sentence per package.
set({ packages: [pkg({ error: { code: 'incompatible-version', incompatible: [INCOMPATIBLE, { ...INCOMPATIBLE, name: 'other' }] } })] })
expect(within(detail).getByText(`${en.reasonLabel}: ${incompatibleText()} ${incompatibleText('other')}`)).toBeTruthy()
set({ packages: [pkg({ error: { code: 'incompatible-version' } })] })
expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonIncompatibleVersionUnnamed}`)).toBeTruthy()
fireEvent.click(within(detail).getByRole('button', { name: en.backToList }))
expect(document.querySelector('[data-plugin-detail]')).toBeNull()
// A bundle that leaves the list drops back to the cards.
@@ -1046,6 +1055,12 @@ describe('PluginManagerPage', () => {
expect(screen.getByText(en.reasonNotBundle)).toBeTruthy()
set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'ERR_PNPM_ADDING_TO_ROOT', code: 'operation-error' } } })
expect(screen.getByText('ERR_PNPM_ADDING_TO_ROOT')).toBeTruthy()
// A compatibility refusal outranks the kind pnpm's exit was classified as.
set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed',
failure: { reason: '', code: 'incompatible-version', incompatible: [INCOMPATIBLE], kind: 'unknown' } } })
expect(screen.getByText(incompatibleText())).toBeTruthy()
set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: '', code: 'incompatible-version' } } })
expect(screen.getByText(en.reasonIncompatibleVersionUnnamed)).toBeTruthy()
set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'the transport said so' } } })
expect(screen.getByText('the transport said so')).toBeTruthy()
set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: '' } } })
@@ -1289,6 +1304,8 @@ describe('PluginManagerPage', () => {
expect(screen.getByRole('alert').textContent).toContain(en.failedEnable.replace('{reason}', 'the tree rejected it'))
set({ notice: { kind: 'failed', action: 'uninstall', code: 'bundle-in-use', reason: '', packageName: 'pkg-1', seq: 5 } })
expect(screen.getByRole('alert').textContent).toContain(en.failedUninstall.replace('{reason}', en.reasonBundleInUse))
set({ notice: { kind: 'failed', action: 'enable', code: 'incompatible-version', incompatible: [INCOMPATIBLE], reason: '', packageName: 'pkg-1', seq: 5 } })
expect(screen.getByRole('alert').textContent).toContain(en.failedEnable.replace('{reason}', incompatibleText()))
set({ notice: { kind: 'failed', action: 'rowDisable', code: 'operation-error', reason: 'EACCES', packageName: 'pkg-1', seq: 6 } })
expect(screen.getByRole('alert').textContent).toContain(en.failedRowDisable.replace('{reason}', 'EACCES'))
set({ notice: { kind: 'failed', action: 'disable', reason: '', packageName: 'pkg-1', seq: 7 } })
@@ -10,6 +10,7 @@ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConfigLedger } from '../src/client/config-ledger.ts'
import { packageView, PluginManagerController, rowKey, sortPackages } from '../src/client/manager-store.ts'
const INCOMPATIBLE = { name: 'dsh-late', version: '2.0.0', runtimeVersion: '0.1.0', peers: { '@deepseek-ai/dsh': '^0.2.0' } }
const ROW_ENTRY = 'include:sidebar' as PluginEntryId
const BUNDLE: BundleInfo = {
@@ -278,7 +279,8 @@ describe('PluginManagerController', () => {
.mockRejectedValueOnce('odd')
.mockResolvedValueOnce(ok(failed({ code: 'bundle-in-use' })))
.mockResolvedValueOnce(ok(failed()))
.mockResolvedValueOnce(ok({ ...failed(), application: 'cancelled' })),
.mockResolvedValueOnce(ok({ ...failed(), application: 'cancelled' }))
.mockResolvedValueOnce(ok(failed({ code: 'incompatible-version', incompatible: [INCOMPATIBLE] }))),
})
await controller.load()
face.setEnabled(BUNDLE.name, true)
@@ -297,6 +299,13 @@ describe('PluginManagerController', () => {
// A change the Host stopped is said in passing.
face.setEnabled(BUNDLE.name, true)
await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'cancelled', seq: 7 }) })
// An incompatibility keeps the packages it names for the page to word.
face.setEnabled(BUNDLE.name, true)
await vi.waitFor(() => {
expect(state().notice).toEqual({
kind: 'failed', action: 'enable', code: 'incompatible-version', incompatible: [INCOMPATIBLE], reason: '', packageName: BUNDLE.name, seq: 8,
})
})
face.dismissNotice()
expect(state().notice).toBeNull()
})
@@ -973,12 +982,17 @@ describe('PluginManagerController', () => {
{ jobId: 'k', command: 'pnpm add x', cwd: '/p', output: 'open', exitCode: null },
])
expect(state().install.failure).toEqual({ reason: '', code: 'not-bundle' })
// An incompatibility keeps the packages it names.
await installing()
answer(ok(failed({ code: 'incompatible-version', incompatible: [INCOMPATIBLE] })))
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
expect(state().install.failure).toEqual({ reason: '', code: 'incompatible-version', incompatible: [INCOMPATIBLE] })
// A failure the Host does not explain has neither code nor words.
await installing()
answer(ok(failed()))
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
expect(state().install.failure).toEqual({ reason: '' })
expect(plugins.installBundle).toHaveBeenCalledTimes(5)
expect(plugins.installBundle).toHaveBeenCalledTimes(6)
// Editing the spec after a failure starts over too.
face.editInstallSpec('y')
expect(state().install).toMatchObject({ phase: 'idle', spec: 'y', runs: [], failure: null })
+2 -2
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-tool/README.md
README.md: a00ed8e8c78dce29144a3ff89d2eb671b5f209fb
README.zh.md: c4a6f6183be082d8c21877b0c1c7052652bc7e2e
README.md: b4a529fbcb1ed33976d71baf043e625fcca4c89c
README.zh.md: 406dc884f3529af79d675e6fbe1586e7485c3780
+5 -2
View File
@@ -29,7 +29,7 @@ Tool calls appear in the conversation as cards: a root call tree with its nested
Shared Tool rows and Bash rows retain error and warning colors for failed and stopped summaries, including on hover. Hover darkens only summaries without those states.
Before dispatch, a named model call appears as one non-expandable row with its tool-owned icon and title. A generic row shows `Tool call · <tool name>`. Preparation provides no argument body, file link, result, or interaction requiring parameters. `tool/call` enables the existing call presentation; completing an argument block alone does not start execution.
Before dispatch, a named model call appears as one non-expandable row with its tool-owned icon and title. A generic row shows `Tool call · <tool name>`. Preparation exposes no complete arguments, file link, result, or parameter-dependent interaction. Write/edit show `Preparing content NKB` in the summary; N is `Math.ceil(raw.length / 1024)`, an integer estimate of the raw argument string length, not the file's byte size. `tool/call` enables the existing call presentation; completing an argument block alone does not start execution.
### Registering a business tool view
@@ -47,7 +47,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` dis
### Built-in views
Every registered view receives the explicit `preparing`, `start`, and `result` props declared in [the Tool slot types](src/client/contract/slots.ts). Shared rows use the same `ToolRow` in all stages. Their row model selects the title and combines any generic tool-name prefix with the available argument summary independently of lifecycle state. Tool-owned titles omit the English name. Preparation has no argument summary, and the shared argument parser returns no call without parsing JSON. Custom renderers such as Bash, Skill, and Cordis handle preparation separately; their argument-dependent components accept `StartedToolCallViewProps`.
Every registered view receives the explicit `preparing`, `start`, and `result` props declared in [the Tool slot types](src/client/contract/slots.ts). Shared rows use the same `ToolRow` in all stages. Their row model selects the title and combines any generic tool-name prefix with the available argument summary independently of lifecycle state. Tool-owned titles omit the English name. The shared argument parser returns no call during preparation and does not parse partial JSON. Write/edit use separate preparing and dispatched components, so only the preparing component invokes `useToolCallArgumentsPartial`; start and result share the dispatched component. Custom renderers such as Bash, Skill, and Cordis handle preparation separately; their argument-dependent components accept `StartedToolCallViewProps`.
This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
@@ -67,6 +67,9 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool
Tool owner props forward Chat's stable `useDisclosure` Hook through root and nested calls. Rows invoke it where they own their expanded bodies; intermediate renderers do not subscribe. Each invocation has independent open state that resets when the enclosing Turn collapses, without replacing React identity. Presentation-mode switches preserve it.
The slot-injected `useToolCallArgumentsPartial` Hook lazily subscribes to the owning Step's `assistant-step` source and selects this callId's raw argument prefix. Missing sources or calls return an empty string. Other calls in the same Step may trigger a snapshot check, but an unchanged selected string does not refresh the consumer. Tools that do not invoke the Hook add no subscription; dispatched calls have no argument-prefix source.
### Cards
+5 -2
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
共享工具行和 Bash 行的失败、停止摘要在悬停时仍保留错误色和警告色;只有不处于这两种状态的摘要会在悬停时加深。
派发前,模型已给出名称的调用显示为不可展开的一行,使用工具自己的图标与标题。通用行显示为`工具调用 · <工具名>`。准备阶段不提供参数正文、文件链接、结果或依赖参数的交互。`tool/call` 才启用既有调用展示;参数块结束本身不代表开始执行。
派发前,模型已给出名称的调用显示为不可展开的一行,使用工具自己的图标与标题。通用行显示为`工具调用 · <工具名>`。准备阶段不提供完整参数、文件链接、结果或依赖参数的交互。write/edit 的摘要显示「正在准备内容 NKB」;N 为 `Math.ceil(raw.length / 1024)`,是原始参数字符串长度的整数近似值,不是文件字节数。`tool/call` 才启用既有调用展示;参数块结束本身不代表开始执行。
### 注册业务工具视图
@@ -47,7 +47,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字
### 内置视图
每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。通用行在三个阶段使用同一个 `ToolRow`。行模型统一选择标题,并组合通用工具名前缀与已有参数摘要,不按生命周期阶段改变前缀;专用标题不附带英文名。准备阶段没有参数摘要,共享参数解析入口直接返回无调用,不解析 JSON。Bash、Skill、Cordis 等自定义 renderer 分别处理准备态,其依赖参数的组件接收 `StartedToolCallViewProps`。
每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。通用行在三个阶段使用同一个 `ToolRow`。行模型统一选择标题,并组合通用工具名前缀与已有参数摘要,不按生命周期阶段改变前缀;专用标题不附带英文名。准备阶段的共享参数解析入口直接返回无调用,不解析部分 JSON。write/edit 将准备态和派发后阶段拆成两个组件,只有准备态组件调用 `useToolCallArgumentsPartial`,start 与 result 共用派发后组件。Bash、Skill、Cordis 等自定义 renderer 分别处理准备态,其依赖参数的组件接收 `StartedToolCallViewProps`。
本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
@@ -67,6 +67,9 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字
Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调用及嵌套调用。工具行在拥有展开正文的位置调用它,中间 renderer 不订阅。每次调用拥有独立展开状态,外层轮次收起时重置该状态,不替换 React 身份;展示模式切换保留该状态。
slot 注入的 `useToolCallArgumentsPartial` 钩子按需订阅所属 Step 的 `assistant-step` 来源,并选取当前 callId 的原始参数前缀。来源或调用不存在时返回空字符串。同一步骤中的其他调用可能触发快照检查,但选中的字符串未变时不会刷新使用方。不调用钩子的工具不新增订阅,已派发的调用不再提供参数前缀来源。
### 卡片
+5 -1
View File
@@ -7,6 +7,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import { ToolCallTree } from './tool/ToolCallTree.tsx'
import { bindToolCallArgumentsPartial } from './tool/tool-call-arguments-partial.ts'
import { CONVERSATION_NS as NS } from './locale.ts'
import { askQuestionToolview } from './tool/toolviews/ask-question-row.tsx'
import { bashToolviewSample } from './tool/toolviews/bash-sample.tsx'
@@ -36,7 +37,10 @@ export function apply(ctx: ClientContext): void {
key: 'tool-call',
locale: NS,
children: {
'tool.call.toolview': { kind: 'keyed', scope: 'session' },
'tool.call.toolview': {
kind: 'keyed', scope: 'session',
inject: { hooks: { toolCallArgumentsPartial: bindToolCallArgumentsPartial } },
},
},
inject: toolInject,
}, ToolCallTree))
@@ -1,10 +1,10 @@
/** Tool UI slot declarations and their composed component props. */
import type {
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime,
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SlotHookFactory,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
import type {
OpenFileOptions, PreparingToolCall, StartedToolCall,
AssistantChatData, OpenFileOptions, PreparingToolCall, StartedToolCall,
ToolResultNode, UseDisclosure,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { MessageImageLoader, MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -19,9 +19,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*
* Registering an occupied key replaces its view; unclaimed keys use the
* generic row. The owner supplies the call identity and frozen running
* or settled node through explicit phase props; preparing inputs have no arguments.
* or settled node through explicit phase props. Preparing blocks have no dispatched
* arguments; useToolCallArgumentsPartial optionally subscribes to their raw prefix.
*/
'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps }
'tool.call.toolview': {
kind: 'keyed'
scope: 'session'
owner: ToolCallOwnerProps
hookContext: ToolCallHookContext
inject: ToolCallInjected
}
/**
* Durable images of a settled image-bearing Tool call, rendered through
* the attachment presentation plugin. The Tool layer never imports an
@@ -39,6 +46,23 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
}
/** Subscribe to this preparing call's raw argument prefix; other phases return an empty string. */
export type UseToolCallArgumentsPartial = () => string
/** Call-local sources supplied by the Tool tree to the slot's Hook binding. */
export interface ToolCallHookContext {
readonly callId: string
/** This call's Step source, present only while preparing. */
readonly assistant: HostObservable<Readonly<AssistantChatData> | undefined> | undefined
}
/** Framework-bound subscriptions available to atomic Tool views on demand. */
export interface ToolCallInjected {
hooks: {
toolCallArgumentsPartial: SlotHookFactory<'tool.call.toolview', UseToolCallArgumentsPartial>
}
}
/** Owner currency of the Tool image gallery slot: references plus the loader. */
export interface ToolImagesOwnerProps {
/** Durable references or submission-echo previews in result order. */
+1 -1
View File
@@ -2,5 +2,5 @@
export { apply, inject } from './apply.ts'
export type {
StartedToolCallViewProps, ToolCallCommonProps, ToolCallOwnerProps, ToolCallPhaseProps, ToolCallViewProps,
ToolHostInfoInjected, ToolTreeProps,
ToolCallHookContext, ToolCallInjected, ToolHostInfoInjected, ToolTreeProps, UseToolCallArgumentsPartial,
} from './contract/slots.ts'
@@ -1,7 +1,7 @@
/** Root/subcall Tool composition with one keyed atomic dispatch path. */
import { memo, useMemo, type ReactNode } from 'react'
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ToolCallOwnerProps, ToolCallPhaseProps, ToolTreeProps } from '../contract/slots.ts'
import type { ToolCallHookContext, ToolCallOwnerProps, ToolCallPhaseProps, ToolTreeProps } from '../contract/slots.ts'
import { toolRowModel } from './models/tool-call-model.ts'
import { GenericToolCard } from './toolviews/GenericToolCard.tsx'
import css from './ToolCallTree.module.css'
@@ -18,14 +18,19 @@ function callName(call: ToolCallPhaseProps): string {
/** One atomic call dispatched through the Tool-owned keyed slot. */
const ToolCall = memo(function ToolCall({
renderSlot, callId, toolName, call, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children,
renderSlot, callId, toolName, call, assistant, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children,
}: Pick<ToolTreeProps, 'renderSlot' | 'openFile' | 'cwd' | 'inspectCall' | 'loadImage' | 'useDisclosure' | 't'> & {
callId: string
toolName: string
call: ToolCallPhaseProps
assistant: ToolCallHookContext['assistant']
home?: string | undefined
children?: ReactNode
}) {
const preparing = call.phase === 'preparing'
const hookContext = useMemo<ToolCallHookContext>(() => ({
callId, assistant: preparing ? assistant : undefined,
}), [assistant, callId, preparing])
const owner: ToolCallOwnerProps = useMemo(() => ({
callId,
toolName,
@@ -51,6 +56,7 @@ const ToolCall = memo(function ToolCall({
? <GenericToolCard {...owner} t={t} />
: renderSlot('tool.call.toolview', owner, {
entryKey: toolName,
hookContext,
fallback: <GenericToolCard {...owner} t={t} />,
})}
{children}
@@ -59,9 +65,10 @@ const ToolCall = memo(function ToolCall({
})
const ToolCallBranch = memo(function ToolCallBranch({
renderSlot, block, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t,
renderSlot, block, assistant, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t,
}: Pick<ToolTreeProps, 'renderSlot' | 'cwd' | 'openFile' | 'inspectCall' | 'loadImage' | 'useDisclosure' | 't'> & {
block: ToolCallBlock
assistant: ToolCallHookContext['assistant']
home?: string | undefined
}) {
const call = useMemo(() => toolCallPhase(block), [block])
@@ -71,6 +78,7 @@ const ToolCallBranch = memo(function ToolCallBranch({
callId={call.block.callId}
toolName={callName(call)}
call={call}
assistant={assistant}
openFile={openFile}
cwd={cwd}
home={home}
@@ -86,6 +94,7 @@ const ToolCallBranch = memo(function ToolCallBranch({
key={child.callId}
renderSlot={renderSlot}
block={child}
assistant={assistant}
cwd={cwd}
home={home}
openFile={openFile}
@@ -111,10 +120,12 @@ export function ToolCallTree({
renderSlot, node, cwd, openFile, inspectCall, loadImage, useDisclosure, useHostInfo, t,
}: ToolTreeProps) {
const home = useHostInfo(info => info.home)
const assistant = node.location.kind === 'step' ? node.location.step.data.source('assistant-step') : undefined
return (
<ToolCallBranch
renderSlot={renderSlot}
block={node.data.root}
assistant={assistant}
cwd={cwd}
home={home}
openFile={openFile}
@@ -8,15 +8,16 @@ import { ToolRow, type ToolRowProps } from './ToolRow.tsx'
type PreparingToolRowProps = Pick<ToolCallViewProps, 'toolName' | 'useDisclosure'> & {
readonly icon: ReactNode
readonly title: string
readonly summary?: string
readonly t: ToolRowProps['t']
}
/**
* Render an argument-free, non-expandable tool prefix.
* Render a non-expandable tool prefix with an optional preparation summary.
* @param props - tool prefix and locale.
* @returns the preparation row.
*/
export function PreparingToolRow({ toolName, useDisclosure, icon, title, t }: PreparingToolRowProps) {
export function PreparingToolRow({ toolName, useDisclosure, icon, title, summary = '', t }: PreparingToolRowProps) {
return <ToolRow useDisclosure={useDisclosure} t={t} variant={classifyTool(toolName)} toolName={toolName}
icon={icon} title={title} summary="" state="preparing" />
icon={icon} title={title} summary={summary} state="preparing" />
}
@@ -0,0 +1,25 @@
/** Call-local argument subscription bound by the atomic Tool slot. */
import { useSyncExternalStore } from 'react'
import type { ToolCallInjected } from '../contract/slots.ts'
const subscribeEmpty = (): (() => void) => () => {}
/**
* Bind one call without subscribing until its component invokes the Hook.
* @param _standard - framework-provided scope props.
* @param context - the preparing call's Step source and identity.
* @returns a Hook that reads only this call's raw argument prefix.
*/
export const bindToolCallArgumentsPartial: ToolCallInjected['hooks']['toolCallArgumentsPartial'] = (
_standard, context,
) => {
const { assistant, callId } = context
const subscribe = assistant === undefined ? subscribeEmpty : (listener: () => void) => assistant.subscribe(listener)
const getSnapshot = (): string => {
const block = assistant?.getSnapshot()?.blocks.find(candidate => candidate.kind === 'tool-call' && candidate.callId === callId)
return block?.kind === 'tool-call' ? block.argsRaw : ''
}
return function useToolCallArgumentsPartial() {
return useSyncExternalStore(subscribe, getSnapshot)
}
}
@@ -3,16 +3,31 @@ import { IconEditOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolCallViewProps } from '../../contract/slots.ts'
import { diffCardModel } from '../models/diff-card-model.ts'
import { toolRowModel } from '../models/tool-call-model.ts'
import { toolRowModel, toolTitleKey } from '../models/tool-call-model.ts'
import { ToolRow } from '../components/ToolRow.tsx'
import { PreparingToolRow } from '../components/PreparingToolRow.tsx'
import { CONVERSATION_NS as NS } from '../../locale.ts'
type FileMutationRowProps = ToolCallViewProps & PropsLocale<'conversation'>
const FILE_MUTATION_ICON = <IconEditOutlineRegular size={14} />
/**
* Lets users expand an applied file diff and open the reported path.
*/
export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: FileMutationRowProps) {
export function FileMutationRow(props: FileMutationRowProps) {
return props.phase === 'preparing'
? <PreparingFileMutationRow {...props} />
: <StartedFileMutationRow {...props} />
}
function PreparingFileMutationRow({ toolName, useDisclosure, useToolCallArgumentsPartial, t }: Extract<FileMutationRowProps, { phase: 'preparing' }>) {
const raw = useToolCallArgumentsPartial()
return <PreparingToolRow toolName={toolName} useDisclosure={useDisclosure} icon={FILE_MUTATION_ICON}
title={t(toolTitleKey(toolName))} t={t}
summary={t('tool.preparing.content', { kilobytes: Math.ceil(raw.length / 1024) })} />
}
function StartedFileMutationRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: Exclude<FileMutationRowProps, { phase: 'preparing' }>) {
const model = toolRowModel(toolName, block, cwd, home)
const diff = diffCardModel(block)
return (
@@ -21,7 +36,7 @@ export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect,
t={t}
variant={model.variant}
toolName={toolName}
icon={<IconEditOutlineRegular size={14} />}
icon={FILE_MUTATION_ICON}
title={t(model.titleKey)}
summary={model.summary}
output={model.output}
@@ -207,6 +207,7 @@ describe('FileMutationRow diff card', () => {
})
const rowProps = (block: StartedToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
useToolCallArgumentsPartial: () => { throw new Error('dispatched calls must not subscribe to preparing arguments') },
useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), cwd: '/w/app',
sessionId: SID, useSessions: bindSnapshotSelector(list()),
t,
@@ -27,7 +27,7 @@ function preparation(name: string): Props {
return {
phase: 'preparing', callId: 'call', toolName: name,
block: { phase: 'preparing', callId: 'call', name, turn: 1, step: 1, time: 1, subCalls: [] },
t: makeTranslate(en, common), useDisclosure, openFile: vi.fn(), loadImage: vi.fn(),
t: makeTranslate(en, common), useDisclosure, useToolCallArgumentsPartial: vi.fn(() => ''), openFile: vi.fn(), loadImage: vi.fn(),
useTodoHistory: vi.fn(), useSession: vi.fn(() => false), renderSlot: vi.fn(() => null),
} as Props
}
@@ -53,16 +53,19 @@ describe('argument-free tool preparation', () => {
expect(view.container.querySelector('[aria-expanded="true"]')).toBeNull()
})
it('keeps the common write row mounted as complete arguments and a result arrive', () => {
it('replaces preparation with the dispatched row and retains that row through the result', () => {
const props = preparation('write')
const view = render(<FileMutationRow {...props} />)
const row = view.container.querySelector('[data-tool="write"]')
const preparingRow = view.container.querySelector('[data-tool="write"]')
vi.mocked(props.useToolCallArgumentsPartial).mockClear()
const started: StartedToolCall = {
phase: 'start', callId: 'call', name: 'write', turn: 1, step: 1, time: 2, subCalls: [],
argsRaw: '{"file_path":"hello.txt","content":"hello"}',
}
view.rerender(<FileMutationRow {...props} phase="start" block={started} />)
expect(view.container.querySelector('[data-tool="write"]')).toBe(row)
const row = view.container.querySelector('[data-tool="write"]')
expect(row).not.toBe(preparingRow)
expect(props.useToolCallArgumentsPartial).not.toHaveBeenCalled()
expect(view.getByText('hello.txt')).toBeTruthy()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
const result: ToolResultNode = {
@@ -71,6 +74,7 @@ describe('argument-free tool preparation', () => {
}
view.rerender(<FileMutationRow {...props} phase="result" block={result} />)
expect(view.container.querySelector('[data-tool="write"]')).toBe(row)
expect(props.useToolCallArgumentsPartial).not.toHaveBeenCalled()
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
@@ -0,0 +1,122 @@
// @vitest-environment jsdom
/** Call-scoped preparation subscriptions and file-mutation progress. */
import { useMemo } from 'react'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { AssistantChatData, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import type { ToolCallHookContext } from '../src/client/contract/slots.ts'
import { bindToolCallArgumentsPartial } from '../src/client/tool/tool-call-arguments-partial.ts'
import { FileMutationRow } from '../src/client/tool/toolviews/file-mutation-row.tsx'
afterEach(cleanup)
const standard = {} as Parameters<typeof bindToolCallArgumentsPartial>[0]
const data = (first: string, second = ''): AssistantChatData => ({
status: 'running', turn: 1, step: 1, time: 1,
blocks: [
{ kind: 'text', text: 'Preparing changes' },
{ kind: 'tool-call', callId: 'first', name: 'write', argsRaw: first },
{ kind: 'tool-call', callId: 'second', name: 'edit', argsRaw: second },
],
})
function source(initial: AssistantChatData | undefined) {
const store = createSnapshotStore<AssistantChatData | undefined>(initial)
const listeners = new Set<() => void>()
return {
set: (value: AssistantChatData | undefined) => { store.set(value) },
listeners,
getSnapshot: () => store.getSnapshot(),
subscribe: (listener: () => void) => {
listeners.add(listener)
const dispose = store.subscribe(listener)
return () => { listeners.delete(listener); dispose() }
},
}
}
function Probe({ context, label, rendered }: { context: ToolCallHookContext; label: string; rendered: () => void }) {
const usePartial = useMemo(() => bindToolCallArgumentsPartial(standard, context), [context])
const raw = usePartial()
rendered()
return <output aria-label={label}>{raw}</output>
}
describe('tool argument prefix Hook', () => {
it('does not subscribe until used and refreshes only the call whose prefix changed', () => {
const assistant = source(data('{', 'second prefix'))
const first = bindToolCallArgumentsPartial(standard, { assistant, callId: 'first' })
expect(assistant.listeners.size).toBe(0)
const firstRendered = vi.fn()
const secondRendered = vi.fn()
const firstContext = { assistant, callId: 'first' }
const secondContext = { assistant, callId: 'second' }
const view = render(<>
<Probe context={firstContext} label="first" rendered={firstRendered} />
<Probe context={secondContext} label="second" rendered={secondRendered} />
</>)
expect(first).toBeTypeOf('function')
expect(assistant.listeners.size).toBe(2)
firstRendered.mockClear()
secondRendered.mockClear()
act(() => { assistant.set(data('{"file_path":', 'second prefix')) })
expect(view.getByLabelText('first').textContent).toBe('{"file_path":')
expect(firstRendered).toHaveBeenCalled()
expect(secondRendered).not.toHaveBeenCalled()
firstRendered.mockClear()
act(() => { assistant.set(data('{"file_path":', 'more second prefix')) })
expect(firstRendered).not.toHaveBeenCalled()
expect(view.getByLabelText('second').textContent).toBe('more second prefix')
view.unmount()
expect(assistant.listeners.size).toBe(0)
})
it('handles missing sources and calls and detaches a replaced Step source', () => {
const old = source(undefined)
const next = source(data('new step'))
const rendered = vi.fn()
const view = render(<Probe context={{ assistant: old, callId: 'missing' }} label="raw" rendered={rendered} />)
expect(view.getByLabelText('raw').textContent).toBe('')
act(() => { old.set(data('unrelated')) })
expect(view.getByLabelText('raw').textContent).toBe('')
view.rerender(<Probe context={{ assistant: next, callId: 'first' }} label="raw" rendered={rendered} />)
expect(old.listeners.size).toBe(0)
expect(next.listeners.size).toBe(1)
expect(view.getByLabelText('raw').textContent).toBe('new step')
view.rerender(<Probe context={{ assistant: undefined, callId: 'first' }} label="raw" rendered={rendered} />)
expect(next.listeners.size).toBe(0)
expect(view.getByLabelText('raw').textContent).toBe('')
view.unmount()
})
it.each([
['write', en, 'Preparing content'], ['edit', zh, '正在准备内容'],
] as const)('%s subscribes only during preparation and rounds raw length upward', (toolName, dictionary, label) => {
const assistant = source(data(''))
const usePartial = bindToolCallArgumentsPartial(standard, { assistant, callId: 'first' })
const props = {
phase: 'preparing', toolName, callId: 'first', useToolCallArgumentsPartial: usePartial,
block: { phase: 'preparing', callId: 'first', name: toolName, turn: 1, step: 1, time: 1, subCalls: [] },
useDisclosure, openFile: vi.fn(), loadImage: vi.fn(), t: makeTranslate(dictionary),
} as Parameters<typeof FileMutationRow>[0]
const view = render(<FileMutationRow {...props} />)
for (const [length, kilobytes] of [[0, 0], [1, 1], [1024, 1], [1025, 2], [12 * 1024, 12]]) {
act(() => { assistant.set(data('中'.repeat(length!))) })
expect(view.getByText(`${label} ${kilobytes}KB`)).toBeTruthy()
expect(view.queryByRole('button')).toBeNull()
}
expect(assistant.listeners.size).toBe(1)
const started = { phase: 'start' as const, callId: 'first', name: toolName, turn: 1, step: 1, time: 2, subCalls: [], argsRaw: '{"file_path":"file.txt","content":"hello"}' }
view.rerender(<FileMutationRow {...props} phase="start" block={started} />)
expect(assistant.listeners.size).toBe(0)
expect(view.getByText('file.txt')).toBeTruthy()
const result: ToolResultNode = { kind: 'tool-result', callId: 'first', seq: 3, time: 3, callTime: 2, call: { name: toolName, argsRaw: started.argsRaw }, content: [], isError: false, subCalls: [] }
view.rerender(<FileMutationRow {...props} phase="result" block={result} />)
expect(assistant.listeners.size).toBe(0)
expect(view.getByText('file.txt')).toBeTruthy()
})
})
@@ -3815,14 +3815,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.images\', () => ctx.slots.register(\n { name: \'tool.call.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-tool/src/client/contract/slots.ts:38',
source: 'packages/client/ui-tool/src/client/contract/slots.ts:45',
},
{
key: 'tool.call.toolview',
kind: 'keyed',
scope: 'session',
summary: 'Keyed Tool call view dispatched by wire Tool name.',
doc: 'Keyed Tool call view dispatched by wire Tool name. Any name is allowed,\nincluding tools registered by your package. Register with\n`key: \'<tool name>\'`; a typo never renders.\n\nRegistering an occupied key replaces its view; unclaimed keys use the\ngeneric row. The owner supplies the call identity and frozen running\nor settled node through explicit phase props; preparing inputs have no arguments.',
doc: 'Keyed Tool call view dispatched by wire Tool name. Any name is allowed,\nincluding tools registered by your package. Register with\n`key: \'<tool name>\'`; a typo never renders.\n\nRegistering an occupied key replaces its view; unclaimed keys use the\ngeneric row. The owner supplies the call identity and frozen running\nor settled node through explicit phase props. Preparing blocks have no dispatched\narguments; useToolCallArgumentsPartial optionally subscribes to their raw prefix.',
registerOptions: [
{
name: 'key',
@@ -3862,8 +3862,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'useTrajectory: UseTrajectory',
],
keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_inspect_list, cordis_inspect_query, cordis_inspect_self, cordis_run, cordis_stop, cordis_undefine, create_goal, edit, get_goal, glob, grep, interrupt_agent, job_kill, job_list, job_output, list_agents, list_subagent_models, lsp, present, ralph, read, read_image, schedule_create, schedule_delete, schedule_list, send_message, session_event_read, session_event_search, session_event_trace, session_search, session_trace, skill, spawn_teammate, subagent, team_task_create, team_task_get, team_task_list, team_task_update, terminal_close, terminal_list, terminal_open, terminal_read, terminal_signal, todo_write, update_goal, wait_agent, web_fetch, web_search, workflow, write',
hookContext: '',
slotInject: '',
hookContext: 'ToolCallHookContext',
slotInject: 'ToolCallInjected',
declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-tool), so it exists while that entry is mounted',
occupants: [
'client-ui-deliverables PresentRow key \'present\'',
@@ -3922,7 +3922,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-tool/src/client/contract/slots.ts:24',
source: 'packages/client/ui-tool/src/client/contract/slots.ts:25',
},
{
key: 'tool.view.cordis',
@@ -5145,6 +5145,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ImageVariantId',
declaration: 'export type ImageVariantId = Branded<\'ImageVariantId\'>;',
},
{
name: 'IncompatiblePlugin',
declaration: 'export interface IncompatiblePlugin {\n name: string;\n version: string;\n runtimeVersion: string;\n peers: Record<string, string>;\n}',
},
{
name: 'IndexInjection',
declaration: 'export type IndexInjection = {\n kind: \'global\';\n name: string;\n value: unknown;\n} | {\n kind: \'script\';\n placement: IndexInjectionPlacement;\n text: string;\n} | {\n kind: \'script-src\';\n placement: IndexInjectionPlacement;\n src: string;\n} | {\n kind: \'script-preload\';\n src: string;\n} | {\n kind: \'style\';\n text: string;\n} | {\n kind: \'html\';\n placement: IndexInjectionPlacement;\n html: string;\n};',
@@ -5459,7 +5463,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ManagementError',
declaration: 'export interface ManagementError {\n code: ReadOnlyReason | \'unknown-plugin\' | \'invalid-spec\' | \'ambiguous-install\' | \'not-bundle\' | \'not-removable\' | \'stop-profile\' | \'bundle-in-use\' | \'stale-approval\' | \'operation-error\';\n diagnostic?: string;\n}',
declaration: 'export interface ManagementError {\n code: ReadOnlyReason | \'unknown-plugin\' | \'invalid-spec\' | \'ambiguous-install\' | \'not-bundle\' | \'not-removable\' | \'stop-profile\' | \'bundle-in-use\' | \'stale-approval\' | \'incompatible-version\' | \'operation-error\';\n diagnostic?: string;\n incompatible?: IncompatiblePlugin[];\n}',
},
{
name: 'ManualCompactAgentContext',
@@ -5647,7 +5651,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PackageResult',
declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n kind?: PluginInstallFailureKind;\n timedOut?: boolean;\n}',
declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n kind?: PluginInstallFailureKind;\n timedOut?: boolean;\n incompatible?: IncompatiblePlugin[];\n}',
},
{
name: 'PeerAdmission',
@@ -0,0 +1 @@
- text: 正在准备调用 编辑 正在准备内容 1KB
@@ -0,0 +1 @@
- text: 正在准备调用 写入 正在准备内容 1KB
@@ -3,12 +3,12 @@
- status: 已完成工作
- button "用时 {{duration}}" [expanded]
- button "执行了命令,已写入文件,已读取文件等" [expanded]
- button "思考"
- button "思考 The user wants four things done directly. But I need to read intro.md before editing (fs-observation-policy requires reading existing file first). The user says don't look at the directory first, but reading intro.md is needed for the edit tool. Let me just do it."
- button "读取 intro.md":
- text: 读取
- button "intro.md"
- button "运行命令 List files and show notes.txt"
- button "思考"
- button "思考 Now do all four actions. Edit intro.md, write src/util.ts, write app.local, bash append."
- button "编辑 intro.md +1 -1":
- text: 编辑
- button "intro.md"
@@ -22,7 +22,7 @@
- button "app.local"
- text: +1 -0
- button "运行命令 Append done to notes.txt"
- button "思考"
- 'button "思考 All four done. Present the files? The user asked to create files. I should call present for the deliverables. Files: intro.md, src/util.ts, app.local, notes.txt. All are outputs. Let me present them."'
- button "交付文件 已交付 intro.md, src/util.ts, app.local, notes.txt"
- paragraph: 四件事全部完成:
- list:
@@ -11,7 +11,7 @@
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Analysis completed" [expanded]
- button "Think"
- button "Think The user wants me to reply with a single word. Let me comply."
- paragraph: LIGHTHOUSE
- button "Copy"
- button "Good response"
@@ -11,7 +11,7 @@
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Analysis completed" [expanded]
- button "Think"
- button "Think The user wants me to reply with a single word. Let me comply."
- paragraph: LIGHTHOUSE
- button "Copy"
- button "Good response"
@@ -11,9 +11,9 @@
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Ran commands" [expanded]
- button "Think"
- button "Think The user wants me to run a simple bash command and reply with \"DONE\"."
- button "Bash Echo the test string"
- button "Think"
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."
- paragraph: DONE
- button "Copy"
- button "Good response"
@@ -17,18 +17,18 @@
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Ran commands and updated the plan" [expanded]
- button "Think"
- 'button "Think The user''s goal is: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\" — which translates to: \"Do two turns, each turn output the file structure of a random package. Note that after you finish one turn, directly output the content and stop; our system will help you open another turn, and you just do something similar.\""'
- button "Bash List package directories deterministically"
- button "View goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"
- button "Think"
- 'button "Think There''s a `packages` directory. Let me look inside to see what packages are available."'
- button "Bash List packages directory contents"
- button "Think"
- button "Think There are 4 packages:"
- button "Failed Bash Randomly pick one package directory"
- button "Think"
- 'button "Think shuf is not available on macOS. I''ll use a different approach for random selection. Let me use $RANDOM in bash or use `sort -R` or Python."'
- button "Bash Randomly select one package with Python"
- button "Think"
- 'button "Think The random pick for turn 1 is `packages/context/session-reference`. Now I''ll output its file structure. Let me get the file tree."'
- button "Bash List all files in session-reference package"
- button "Think"
- 'button "Think Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn."'
- paragraph:
- strong: Turn 1 / 2
- text: — 随机选中的包:
@@ -54,12 +54,12 @@
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Ran commands" [expanded]
- button "Think"
- 'button "Think This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."'
- button "Bash Randomly select another package with Python"
- button "Think"
- 'button "Think The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."'
- button "Bash Re-roll excluding the package from turn 1"
- button "Bash List all files in token-meter package"
- button "Think"
- 'button "Think This is turn 2 of 2. I''ve output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."'
- paragraph:
- strong: Turn 2 / 2
- text: — 随机选中的包:
@@ -11,7 +11,7 @@
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Analysis completed" [expanded]
- button "Think"
- button "Think The user wants me to reply with a single word. Let me comply."
- paragraph: LIGHTHOUSE
- button "Copy"
- button "Good response"
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more