Merge remote-tracking branch 'origin/master' into feat/web-diff-card

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
#	packages/client/ui-conversation/src/client/chat/ToolRow.tsx
#	packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
#	packages/client/ui-primitives/README.i18n.yaml
#	packages/client/ui-primitives/src/index.ts
This commit is contained in:
Chinesezjc
2026-07-31 04:22:57 +08:00
782 changed files with 20829 additions and 3372 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
README.md: 4d8c7de65515a251f227075c7baf041fc1b210c8
README.zh.md: d9b69a2b102a60685524288f75edeaabb21802db
README.md: 1beffd6fbff2b84202683b010cd104f7c84297c7
README.zh.md: d9ce9774b9b492a98556bbd9aa4564b711dbe40e

View File

@@ -27,8 +27,8 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.

View File

@@ -27,8 +27,8 @@
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md)使用demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
- **`.env`**调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay请使用 `[]` 或删除该文件。
- **`.env`**[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay请使用 `[]` 或删除该文件。
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture测试前置数据中。

View File

@@ -234,7 +234,8 @@ export function assertEntriesActive(ctx: Context, binName: string): void {
* @param patches - optional overlay patches applied over the included tree
* (see {@link loadPersonalPatches}); an empty list mounts none.
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
* @returns the root context once every entry has started.
* @returns the root context once every entry has started, or as soon as a
* surface disposed the tree while startup was still in flight.
*/
export async function boot(
binName: string,
@@ -255,6 +256,13 @@ export async function boot(
},
})
await ctx.loader.await()
// A surface can finish and dispose the whole tree while that await is still
// pending: the TUI renders as soon as its own fiber starts, so an `/exit`
// typed before the last entry settles tears the context down under us. The
// Loader service goes with it, and both assertions below describe a live
// tree — reading `ctx.loader` here would throw a TypeError over an app that
// exited exactly as asked.
if (ctx.get('loader') === undefined) return ctx
assertEntriesLoaded(ctx, binName)
assertEntriesActive(ctx, binName)
return ctx

View File

@@ -207,6 +207,25 @@ describe('boot', () => {
}
})
it('returns instead of asserting over a tree a surface disposed mid-startup', async () => {
// What a TUI `/exit` does (ui-tui's disposeRootAndExit): dispose the root
// fiber, which lands while boot() is still awaiting the Loader whenever the
// surface renders before the last entry settles. The Loader service goes
// with the tree, so reading it for the post-boot assertions would crash an
// app that exited exactly as the user asked.
const dir = tmp()
writeFileSync(join(dir, 'exiting.mjs'), [
'export const name = "exiting"',
'export function apply(ctx) {',
' void ctx.root.fiber.dispose()',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n')
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
expect(ctx.get('loader')).toBeUndefined()
})
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
const dir = tmp()
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md
README.md: b1219ba10269fc7d046da22c280ff1b91424a5ae
README.zh.md: 1c27f5edf2f1f172aa6303697b17e2e77a65842a
README.md: ac47af28e69e647ba44a7718478db163d406f5dc
README.zh.md: 2ab600dce52f471d8eef63848e6283217008dcf6

View File

@@ -6,7 +6,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc
## Wiring
`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding `cordis.yml`.
`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek-official` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding `cordis.yml`.
## Config
@@ -22,7 +22,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu
## Wire notes
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no cap and preserves provider defaults. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
## Model Experience

View File

@@ -6,7 +6,7 @@
## 组装
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他功能由外围 `cordis.yml` 提供。
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek-official` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他功能由外围 `cordis.yml` 提供。
## 配置
@@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger诊断应写
## 协议说明
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送上限并保留提供方默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
## 模型体验

View File

@@ -55,8 +55,8 @@ function successStatus(reason: string, options: HarnessSdkServerOptions): 'ok' |
*/
export class HarnessSdkServer {
private cwd = process.cwd()
private provider = 'deepseek'
private model = 'deepseek'
private provider = 'deepseek-official'
private model = 'deepseek-official'
private maxTokens: number | undefined
private llmFiber: { dispose(): Promise<void> } | undefined
private readonly sessions = new Map<string, SessionRecord>()
@@ -124,7 +124,7 @@ export class HarnessSdkServer {
this.model = params.model
this.maxTokens = params.maxTokens
if (!this.hasAdapterFor(this.provider)) {
if (this.provider !== 'deepseek') throw new Error(`no adapter registered for provider "${this.provider}"`)
if (this.provider !== 'deepseek-official') throw new Error(`no adapter registered for provider "${this.provider}"`)
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
}
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }

View File

@@ -153,7 +153,7 @@ describe('dsh-jsonrpc plugin apply', () => {
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
const harness = await mountPlugin(storageDir)
try {
harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'apply-model' } })
harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'apply-model' } })
const response = await harness.waitForFrame(frame => frame.id === 'init-1', 'initialize response')
expect(response).toEqual({
@@ -175,7 +175,7 @@ describe('dsh-jsonrpc plugin apply', () => {
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
const harness = await mountPlugin(storageDir)
try {
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'dsagent-model' } })
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'dsagent-model' } })
await harness.waitForFrame(frame => frame.id === 1, 'initialize response')
harness.send({
@@ -236,7 +236,7 @@ describe('dsh-jsonrpc plugin apply', () => {
expect(harness.exits()).toEqual([0])
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
await settle()
expect(harness.frames().length).toBe(before)
} finally {
@@ -257,7 +257,7 @@ describe('dsh-jsonrpc plugin apply', () => {
expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed'])
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
await settle()
expect(harness.frames().length).toBe(before)
} finally {
@@ -281,7 +281,7 @@ describe('dsh-jsonrpc plugin apply', () => {
await harness.fiber.dispose()
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'x' } })
await settle()
expect(harness.frames().length).toBe(before)
expect(harness.exits()).toEqual([])

View File

@@ -121,7 +121,7 @@ describe('HarnessSdkServer', () => {
const init = await server.handleRequest('initialize', {
cwd: storageDir,
provider: 'deepseek',
provider: 'deepseek-official',
model: 'dsagent-model',
maxTokens: 321,
}) as { serverInfo: { name: string } }
@@ -154,7 +154,7 @@ describe('HarnessSdkServer', () => {
const orphanHandle = await ctx.agents.create({
sessionId: SessionId('orphan-session'),
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
agentOptions: { provider: 'deepseek-official', model: 'dsagent-model' },
})
orphanHandle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } }))
await orphanHandle.agent.whenIdle()
@@ -352,7 +352,7 @@ describe('HarnessSdkServer', () => {
try {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'plain-model' })
await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'plain-model' })
await server.prompt({
sessionId: 'plain',
contentBlocks: [{ type: 'text', text: 'hello' }],
@@ -376,20 +376,20 @@ describe('HarnessSdkServer', () => {
const parentHandle = await ctx.agents.create({
sessionId: SessionId('main'),
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'deepseek' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
// A custom in-process provider may own its child at the provider/root
// scope while preserving durable parent lineage.
const handle = await ctx.agents.create({
sessionId: SessionId('child-session'),
meta: { cwd: storageDir, parentSession: SessionId('main') },
agentOptions: { provider: 'deepseek', model: 'deepseek' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
expect(ctx.agents.roots()).toContain(handle.agent)
const parentlessHandle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('parentless-child-session'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
await settleSubagent(ctx, parentHandle.agent, {
provider: 'spawn',
@@ -446,12 +446,12 @@ describe('HarnessSdkServer', () => {
const parentHandle = await ctx.agents.create({
sessionId: SessionId('collision-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const collidingChild = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('remote-run-id'),
meta: { cwd: storageDir, parentSession: SessionId('collision-parent') },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
await settleSubagent(ctx, parentHandle.agent, {
@@ -485,12 +485,12 @@ describe('HarnessSdkServer', () => {
const parentHandle = await ctx.agents.create({
sessionId: SessionId('continuation-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const childHandle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('continuation-child'),
meta: { cwd: storageDir, parentSession: SessionId('continuation-parent') },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
await settleSubagent(ctx, parentHandle.agent, {
@@ -530,12 +530,12 @@ describe('HarnessSdkServer', () => {
const oldParent = await ctx.agents.create({
sessionId: SessionId('old-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const oldChild = await oldParent.agent.ctx.agents.create({
sessionId: SessionId('reused-child'),
meta: { cwd: storageDir, parentSession: SessionId('old-parent') },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const first = Promise.withResolvers<SubagentResult>()
const sameLifetime = Promise.withResolvers<SubagentResult>()
@@ -571,12 +571,12 @@ describe('HarnessSdkServer', () => {
const newParent = await ctx.agents.create({
sessionId: SessionId('new-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const newChild = await newParent.agent.ctx.agents.create({
sessionId: SessionId('reused-child'),
meta: { cwd: storageDir, parentSession: SessionId('new-parent') },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
currentLocalAgent = newChild.agent
const secondRun = await ctx.subagents.start('reused', {
@@ -629,12 +629,12 @@ describe('HarnessSdkServer', () => {
const parent = await ctx.agents.create({
sessionId: SessionId('provider-reuse-parent'),
meta: { cwd: storageDir },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const child = await parent.agent.ctx.agents.create({
sessionId: SessionId('provider-reuse-child'),
meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') },
agentOptions: { model: 'deepseek' },
agentOptions: { model: 'deepseek-official' },
})
const localResult = Promise.withResolvers<SubagentResult>()
const remoteResult = Promise.withResolvers<SubagentResult>()
@@ -722,18 +722,18 @@ describe('HarnessSdkServer', () => {
parentHandle = await ctx.agents.create({
sessionId: SessionId('fallback-parent'),
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'deepseek' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
handle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('fallback-child-session'),
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
agentOptions: { provider: 'deepseek', model: 'deepseek' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
const fallbackChild = handle.agent
failedHandle = await parentHandle.agent.ctx.agents.create({
sessionId: SessionId('failed-child-session'),
meta: { cwd: storageDir },
agentOptions: { provider: 'deepseek', model: 'deepseek' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
})
const missedStartResult = Promise.withResolvers<SubagentResult>()
const disposeMissedStartProvider = ctx.subagents.registerProvider({
@@ -831,11 +831,11 @@ describe('HarnessSdkServer', () => {
const server = new HarnessSdkServer(ctx, new FakeTransport())
const inspect = server as unknown as { hasAdapterFor(provider: string): boolean }
expect(inspect.hasAdapterFor('deepseek')).toBe(true)
expect(inspect.hasAdapterFor('deepseek-official')).toBe(true)
expect(inspect.hasAdapterFor('missing-provider')).toBe(false)
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'preinstalled-model' })
await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'preinstalled-model' })
expect(ctx.get('llm')?.listProviders().filter(provider => provider.id === 'deepseek')).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
expect(ctx.get('llm')?.listProviders().filter(provider => provider.id === 'deepseek-official')).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
await server.shutdown()
} finally {
await ctx.fiber.dispose()
@@ -854,7 +854,7 @@ describe('HarnessSdkServer', () => {
await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'new-model' }))
.rejects.toThrow('no adapter registered for provider "private"')
expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
await server.shutdown()
} finally {
await ctx.fiber.dispose()
@@ -871,7 +871,7 @@ describe('HarnessSdkServer', () => {
const server = new HarnessSdkServer(ctx, new FakeTransport())
await expect(server.initialize({
cwd: storageDir,
provider: 'deepseek',
provider: 'deepseek-official',
model: 'model',
maxTokens,
})).rejects.toThrow('initialize maxTokens must be a positive safe integer')

View File

@@ -211,16 +211,19 @@ export class PermissionService extends Service {
name: 'permission',
description: 'Switch the permission preset (sandbox mode + approval policy)',
input: { hint: '<preset>' },
// No settlement text labels its value with this command's own name: a
// surface that renders `name · text` (the web command row) would
// otherwise read `permission · Permission preset: workspace-write.`
handler: ({ agent, rawInput }) => {
const name = rawInput.trim()
if (name === '') {
return { kind: 'success', text: `Current permission preset: ${this.current(agent.session.events)}. Available: ${this.names.join(', ')}.` }
return { kind: 'success', text: `current preset ${this.current(agent.session.events)} (available: ${this.names.join(', ')})` }
}
if (!this.names.includes(name)) {
return { kind: 'error', text: `unknown permission preset "${name}" (available: ${this.names.join(', ')})` }
return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` }
}
this.set(agent.session, name)
return { kind: 'success', text: `Permission preset: ${name}.` }
return { kind: 'success', text: `preset ${name}` }
},
})
})

View File

@@ -89,7 +89,7 @@ describe('/permission command', () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal)
expect(execution?.result).toEqual({ kind: 'success', text: 'Permission preset: danger-full-access.' })
expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' })
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
const run = session.events.find(event => event.type === 'command/run')
expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' })
@@ -101,7 +101,7 @@ describe('/permission command', () => {
const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal)
expect(execution?.result).toEqual({
kind: 'success',
text: 'Current permission preset: workspace-write. Available: workspace-write, danger-full-access.',
text: 'current preset workspace-write (available: workspace-write, danger-full-access)',
})
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0)
})
@@ -110,7 +110,13 @@ describe('/permission command', () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal)
expect(execution?.result).toMatchObject({ kind: 'error' })
// The error text carries the same no-self-labelling rule as the success
// texts: `permission · unknown preset "yolo" (…)`, not `unknown permission
// preset`, which the row's own title already says.
expect(execution?.result).toEqual({
kind: 'error',
text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)',
})
expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0)
})
})

View File

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

View File

@@ -105,10 +105,10 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
await ctx.plugin(UserInteractionService)
await ctx.plugin(TuiPromptService)
const catalog = options.catalog ?? {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
models: [
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
],
}
ctx.provide('tokenMeter', {
@@ -194,7 +194,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const cancelled: AgentCancelCause[] = []
const agent: FakeAgent = {
id: sessionId,
options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
options: options.agentOptions ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
session,
status: options.status ?? 'idle',
get acceptsNextStep() {

View File

@@ -152,8 +152,8 @@ describe('TUI prompt values', () => {
describe('TUI prompt templates', () => {
it('interpolates values and removes separators around unavailable values', () => {
const tokens = parseTuiPromptTemplate('${cwd} ${git/worktree} :: ${missing} ${model}')
const values = new Map([['cwd', '/work'], ['model', 'deepseek']])
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('/work :: deepseek')
const values = new Map([['cwd', '/work'], ['model', 'deepseek-official']])
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('/work :: deepseek-official')
})
it('keeps a trailing literal after the last value', () => {

View File

@@ -36,9 +36,9 @@ buffer
14| " │ │ "
style 8-8 fg=bright-magenta
style 83-83 fg=bright-magenta
15| " │ → deepseek/deepseek-v4-pro DeepSeek V4 Pro │ "
15| " │ → deepseek-official/deepseek-v4- DeepSeek V4 Pro │ "
style 8-8 fg=bright-magenta
style 10-58 fg=bright-magenta inverse
style 10-41 fg=bright-magenta inverse
style 83-83 fg=bright-magenta
16| " │ │ "
style 8-8 fg=bright-magenta

View File

@@ -36,13 +36,13 @@ buffer
14| " │ │ "
style 8-8 fg=bright-magenta
style 83-83 fg=bright-magenta
15| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ "
15| " │ → deepseek-official/deepseek-v4- DeepSeek V4 Flash — current │ "
style 8-8 fg=bright-magenta
style 10-70 fg=bright-magenta inverse
style 10-41 fg=bright-magenta inverse
style 83-83 fg=bright-magenta
16| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ "
16| " │ deepseek-official/deepseek-v4- DeepSeek V4 Pro │ "
style 8-8 fg=bright-magenta
style 36-58 dim
style 42-58 dim
style 83-83 fg=bright-magenta
17| " │ │ "
style 8-8 fg=bright-magenta

View File

@@ -16,8 +16,8 @@ buffer
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "Model selected: deepseek/deepseek-v4-pro. New steps will use it. "
style 0-63 dim
7| "Model selected: deepseek-official/deepseek-v4-pro. New steps will use it. "
style 0-72 dim
8| <blank>
9| "/workspace/project (tui-staging) deepseek-v4-pro ↑0 ↓0 0% context"
style 0-17 fg=bright-magenta bold

View File

@@ -31,15 +31,15 @@ buffer
13| " unavailable: current session "
style 2-31 fg=yellow
14| " Other workspace work "
15| " 2024-02-02T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro "
style 2-74 dim
15| " 2024-02-02T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
style 2-83 dim
16| " persisted · elsewhere-session "
style 2-32 dim
17| " workspace /workspace/other "
style 2-29 dim
18| " Resume selector design "
19| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro "
style 2-74 dim
19| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
style 2-83 dim
20| " persisted · earlier-session "
style 2-30 dim
21| " workspace /workspace/project "

View File

@@ -29,8 +29,8 @@ buffer
12| " unavailable: current session "
style 2-31 fg=yellow
13| " Resume selector design "
14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro "
style 2-74 dim
14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
style 2-83 dim
15| " persisted · earlier-session "
style 2-30 dim
16| " "

View File

@@ -1,7 +1,7 @@
terminal 56x36 buffer=normal length=44 base=8 viewport=8
terminal 56x36 buffer=normal length=45 base=9 viewport=9
lifecycle started=1 stopped=0 progress=inactive
title "Inspect session diagnostics — DSH snapshot"
cursor hidden column=7 viewportRow=35 bufferRow=43
cursor hidden column=7 viewportRow=35 bufferRow=44
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
@@ -37,84 +37,87 @@ buffer
style 0-0 dim
style 3-12 dim
style 55-55 dim
15| "│ Model: deepseek/deepseek-v4-pro (effort │"
style 0-0 dim
style 3-12 dim
style 40-55 dim
16| "│ default; reasoning blocks shown) │"
style 0-0 dim
style 15-46 dim
style 55-55 dim
17| "│ │"
style 0-0 dim
style 55-55 dim
18| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 │"
15| "│ Model: deepseek-official/deepseek-v4-pro │"
style 0-0 dim
style 3-12 dim
style 55-55 dim
19| "│ tool call │"
16| "│ (effort default; reasoning blocks │"
style 0-0 dim
style 15-55 dim
17| "│ shown) │"
style 0-0 dim
style 15-20 dim
style 55-55 dim
18| "│ │"
style 0-0 dim
style 55-55 dim
20| "│ │"
style 0-0 dim
style 55-55 dim
21| "│ Tokens: 1,250 input + 340 output │"
19| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 │"
style 0-0 dim
style 3-12 dim
style 55-55 dim
22| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │"
20| "│ tool call │"
style 0-0 dim
style 55-55 dim
21| "│ │"
style 0-0 dim
style 55-55 dim
22| "│ Tokens: 1,250 input + 340 output │"
style 0-0 dim
style 3-12 dim
style 55-55 dim
23| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │"
style 0-0 dim
style 3-12 dim
style 15-15 dim
style 16-26 fg=bright-magenta
style 27-32 dim
style 55-55 dim
23| "│ + 250 write) │"
24| "│ + 250 write) │"
style 0-0 dim
style 55-55 dim
24| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │"
25| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │"
style 0-0 dim
style 3-12 dim
style 15-15 dim
style 16-20 fg=bright-magenta
style 21-32 dim
style 55-55 dim
25| "│ 128,000) │"
26| "│ 128,000) │"
style 0-0 dim
style 55-55 dim
26| "│ │"
27| "│ │"
style 0-0 dim
style 55-55 dim
27| "│ Created: 2026-07-22 09:10:11 UTC │"
28| "│ Created: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 dim
style 55-55 dim
28| "│ Active: 2026-07-22 09:10:11 UTC │"
29| "│ Active: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 dim
style 55-55 dim
29| "╰──────────────────────────────────────────────────────╯"
30| "╰──────────────────────────────────────────────────────╯"
style 0-55 dim
30| <blank>
31| "System prompt "
31| <blank>
32| "System prompt "
style 0-12 fg=bright-magenta bold
32| "You are an AI agent powered by the DeepSeek Harness SDK."
33| " "
34| "Paths prefixed with @ are files explicitly referenced by"
35| "the user. Use the read tool when their contents are "
36| "needed; do not claim to have inspected a file before "
37| "reading it. "
38| <blank>
39| "Registered tools "
33| "You are an AI agent powered by the DeepSeek Harness SDK."
34| " "
35| "Paths prefixed with @ are files explicitly referenced by"
36| "the user. Use the read tool when their contents are "
37| "needed; do not claim to have inspected a file before "
38| "reading it. "
39| <blank>
40| "Registered tools "
style 0-15 fg=bright-magenta bold
40| "read, write "
41| <blank>
42| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k"
41| "read, write "
42| <blank>
43| "/workspace/project (tui-staging) deepseek-v4-pro ↑1.3k"
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-48 dim
style 51-55 dim
43| " dsh > "
44| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse

View File

@@ -21,68 +21,68 @@ buffer
style 0-2 fg=bright-magenta bold underline
9| "inspect this session "
10| <blank>
11| "╭─ Session status ───────────────────────────────────────────────────────────────╮"
11| "╭─ Session status ────────────────────────────────────────────────────────────────────────╮"
style 0-2 dim
style 3-16 fg=bright-magenta bold
style 17-81 dim
12| "│ Session: main-session │"
style 17-90 dim
12| "│ Session: main-session │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
13| "│ Title: Inspect session diagnostics │"
style 90-90 dim
13| "│ Title: Inspect session diagnostics │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
14| "│ Directory: /workspace/project │"
style 90-90 dim
14| "│ Directory: /workspace/project │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
15| "│ Model: deepseek/deepseek-v4-pro (effort default; reasoning blocks shown) │"
style 90-90 dim
15| "│ Model: deepseek-official/deepseek-v4-pro (effort default; reasoning blocks shown) │"
style 0-0 dim
style 3-12 dim
style 40-79 dim
style 81-81 dim
16| "│ │"
style 49-88 dim
style 90-90 dim
16| "│ │"
style 0-0 dim
style 81-81 dim
17| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 tool call │"
style 90-90 dim
17| "│ Agent: idle · 8 events · 1 turn · 1 step · 1 tool call │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
18| "│ │"
style 90-90 dim
18| "│ │"
style 0-0 dim
style 81-81 dim
19| "│ Tokens: 1,250 input + 340 output │"
style 90-90 dim
19| "│ Tokens: 1,250 input + 340 output │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
20| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
style 90-90 dim
20| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
style 0-0 dim
style 3-12 dim
style 15-15 dim
style 16-26 fg=bright-magenta
style 27-32 dim
style 81-81 dim
21| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
style 90-90 dim
21| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
style 0-0 dim
style 3-12 dim
style 15-15 dim
style 16-20 fg=bright-magenta
style 21-32 dim
style 81-81 dim
22| "│ │"
style 90-90 dim
22| "│ │"
style 0-0 dim
style 81-81 dim
23| "│ Created: 2026-07-22 09:10:11 UTC │"
style 90-90 dim
23| "│ Created: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
24| "│ Active: 2026-07-22 09:10:11 UTC │"
style 90-90 dim
24| "│ Active: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 dim
style 81-81 dim
25| "╰────────────────────────────────────────────────────────────────────────────────╯"
style 0-81 dim
style 90-90 dim
25| "╰─────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-90 dim
26| <blank>
27| "System prompt "
style 0-12 fg=bright-magenta bold

View File

@@ -554,7 +554,7 @@ describe('TUI terminal-state snapshots', () => {
description: 'Audit terminal states from independent angles',
phases: [
{ title: 'Inspect', detail: 'Map renderer branches' },
{ title: 'Verify', detail: 'Challenge missing states', provider: 'deepseek', model: 'deepseek-v4-flash' },
{ title: 'Verify', detail: 'Challenge missing states', provider: 'deepseek-official', model: 'deepseek-v4-flash' },
],
},
args: { packages: ['ui/tui', 'workflow/tool-workflow'] },
@@ -866,13 +866,13 @@ describe('TUI terminal-state snapshots', () => {
{ type: 'turn/start', seq: 0, time: Date.parse(`${day}T00:00:01Z`), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: Date.parse(`${day}T00:00:02Z`), data: createUserMessage({ content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }), surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: Date.parse(`${day}T00:00:03Z`), data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 3, time: Date.parse(`${day}T00:00:04Z`), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } },
{ type: 'request/header', seq: 3, time: Date.parse(`${day}T00:00:04Z`), data: { header: { config: { provider: 'deepseek-official', model: 'deepseek-v4-pro' } }, reason: 'initial' } },
{ type: 'assistant/message', seq: 4, time: Date.parse(`${day}T00:00:05Z`), data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'ready' }],
source: { kind: 'model', provider: 'deepseek', model: 'deepseek-v4-pro' },
source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-pro' },
}),
}, surfaceOp: 'append' },
{ type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } },
@@ -913,7 +913,7 @@ describe('TUI terminal-state snapshots', () => {
const harness = await setupSnapshot({
contextWindow: 128_000,
contextTokens: 42_000,
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-pro' },
tools: {
read: {
name: 'read',

View File

@@ -247,7 +247,7 @@ describe('goodbye message and /resume', () => {
({ version: 0, id: SessionId(id), createdAt, cwd })
const resumeEvents = (
title: string,
provider = 'deepseek',
provider = 'deepseek-official',
time = 100,
reason: TurnEndReason = { kind: 'completed' },
): SessionEvent[] => [
@@ -319,8 +319,8 @@ describe('goodbye message and /resume', () => {
sessionPersistence: {
list: async () => [older, newer, header('foreign-session', 3000, '/elsewhere')],
load: async id => id === newer.id
? { meta: newer, events: resumeEvents('Newer product work', 'deepseek', 300) }
: { meta: older, events: resumeEvents('Older investigation', 'deepseek', 100) },
? { meta: newer, events: resumeEvents('Newer product work', 'deepseek-official', 300) }
: { meta: older, events: resumeEvents('Older investigation', 'deepseek-official', 100) },
},
})
result.terminal.send('/resume')
@@ -419,7 +419,7 @@ describe('goodbye message and /resume', () => {
list: async () => targets,
load: async id => ({
meta: targets.find(target => target.id === id)!,
events: resumeEvents(`Paged ${id.slice('paged-'.length)}`, 'deepseek', 1000 - Number(id.slice('paged-'.length)) * 10),
events: resumeEvents(`Paged ${id.slice('paged-'.length)}`, 'deepseek-official', 1000 - Number(id.slice('paged-'.length)) * 10),
}),
},
})
@@ -475,7 +475,7 @@ describe('goodbye message and /resume', () => {
cwd: '/workspace',
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek', 100, reason) }),
load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek-official', 100, reason) }),
},
})
result.terminal.send('/resume')
@@ -711,7 +711,7 @@ describe('goodbye message and /resume', () => {
it('falls back to assistant provenance and header creation time for sparse logs', async () => {
const assistantOnly = header('assistant-route', 20, '/workspace')
const empty = header('empty-log', 10, '/workspace')
const events = resumeEvents('Assistant route', 'deepseek')
const events = resumeEvents('Assistant route', 'deepseek-official')
.filter(event => event.type !== 'request/header')
.map((event, seq) => ({ ...event, seq })) as SessionEvent[]
const result = await setup({
@@ -726,7 +726,7 @@ describe('goodbye message and /resume', () => {
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('deepseek/model-1')
expect(result.terminal.output).toContain('deepseek-official/model-1')
expect(result.terminal.output).toContain(new Date(empty.createdAt).toISOString())
await dispose(result)
})
@@ -2397,7 +2397,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
contextWindow: 128_000,
contextTokens: 42_000,
config: { showReasoning: false },
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-pro' },
tools: {
read: {
name: 'read', description: 'Read a file', parameters: {},
@@ -2445,7 +2445,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('main-session')
expect(result.terminal.output).toContain('Inspect status \\x1b]2;unsafe\\x07')
expect(result.terminal.output).toContain('/workspace/status')
expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (effort default; reasoning blocks')
expect(result.terminal.output).toContain('deepseek-official/deepseek-v4-pro (effort default; reasoning blocks')
expect(result.terminal.output).toContain('hidden)')
// 6 domain events + the /status invocation's own command/run (open turn: joined directly).
expect(result.terminal.output).toContain('running · 7 events · 1 turn · 1 step · 2 tool calls')
@@ -3601,7 +3601,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const failed = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
models: [],
listModels: () => Promise.reject(new Error('catalog offline')),
resolveModelInfo: () => Promise.reject(new Error('capacity offline')),
@@ -3617,8 +3617,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
const reasoningFailed = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [{ provider: 'deepseek', id: 'model-1', name: 'Model One' }],
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
models: [{ provider: 'deepseek-official', id: 'model-1', name: 'Model One' }],
resolveModelInfo: () => Promise.reject(new Error('reasoning metadata offline')),
},
})
@@ -3634,7 +3634,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const deferred = Promise.withResolvers<never[]>()
const result = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
models: [],
listModels: () => deferred.promise,
},
@@ -3650,7 +3650,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const rejected = Promise.withResolvers<never[]>()
const rejectedResult = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
models: [],
listModels: () => rejected.promise,
},
@@ -3667,7 +3667,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const contextResult = await setup({
contextTokens: 99,
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
providers: [{ id: 'deepseek-official', name: 'DeepSeek' }],
models: [],
resolveModelInfo: () => context.promise.then(value => ({ context: value })),
},
@@ -4395,6 +4395,14 @@ describe('tool cards and surface replay', () => {
name: 'knownXml', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Known XML' }),
},
// A web card carries no `content` copy, so it falls back to the raw result
// content, which must still render through the dim Markdown path (bold
// markers stripped) rather than as bare text.
webCard: {
name: 'webCard', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Fetch page', kind: 'fetch' }),
presentResult: () => ({ card: 'web', kind: 'fetch', title: 'https://a.test', url: 'https://a.test', statusCode: 200, truncated: false }),
},
}
it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
@@ -4415,6 +4423,7 @@ describe('tool cards and surface replay', () => {
['c11', 'terminalResult', '{}'],
['c12', 'symbolic', '{}'],
['c13', 'knownXml', '{}'],
['c16', 'webCard', '{}'],
] as const
appendAssistant(result.session, [
{ type: 'text', text: 'Calling tools' },
@@ -4508,6 +4517,14 @@ describe('tool cards and surface replay', () => {
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: 'c16' as never,
content: [{ type: 'text', text: 'Fetched **body** text' }],
isError: false,
}),
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1,
step: 1,
@@ -4557,6 +4574,11 @@ describe('tool cards and surface replay', () => {
expect(output).toContain('Empty card')
expect(output).toContain('converted terminal')
expect(output).toContain('<known><value>literal</value></known>')
// A web card carries no `content` copy, so it falls back to the raw result
// content, which still renders through the dim Markdown path: the bold
// markers are stripped rather than shown literally.
expect(output).toContain('Fetched body text')
expect(output).not.toContain('Fetched **body** text')
expect(output).toContain('path: /tmp/a.txt')
expect(output).toContain('line (number="1"): hello')
expect(output).not.toContain('<result>')

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md
README.md: d234d6677bdd772f1bbd2c979c0d41f90aef5c32
README.zh.md: c89210b6955a661313ca9e0e82e43da5a4d1db79
README.md: d62e75d110b8be339c5f9449b0834320f695ac99
README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816

View File

@@ -13,14 +13,19 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
### Key Types
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
- `AskUserQuestionOption``{ label, description? }`.
- `AskUserQuestionIntent``{ kind: 'plan-review', approve }`; the tagged presentation intent below.
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`.
- `UserInteractionProvider` — UI implementation with `ask(request)`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
### Presentation intent
`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of.
## Role
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; `dsh-tui` and the host runtime provide interactive implementations. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop.

View File

@@ -13,14 +13,19 @@
### 关键类型
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }``detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }``detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。
- `AskUserQuestionOption``{ label, description? }`
- `AskUserQuestionIntent``{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`
- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED` 等代码。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``BAD_INTENT``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED` 等代码。
当回答包含 `custom` 时,`selected` 为空自定义文本是所选选项的替代而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
### 呈现意图
`intent` 声明某个问题本身就是一次已知形状的决定,因此认识该标签的 UI 可以照此呈现 —— `plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上 —— 而 `detail` 正是它自称在审阅的东西。
## 职责
这是接口包package`@deepseek-ai/dsh-tool-ask-user` 等面向模型的消费方依赖此 seam`dsh-tui` 和宿主运行时提供交互式实现。循环保持不变:工具调用等待 Promise工具结果随后恢复正常的 agent loop智能体循环

View File

@@ -20,7 +20,8 @@ declare module 'cordis' {
import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts'
export type {
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionOption,
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem,
AskUserQuestionOption,
} from './types.ts'
/** Request for a human answer. */
@@ -86,6 +87,28 @@ export class UserInteractionService extends Service {
if (request.questions.length === 0) {
throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
}
// A presentation intent asserts two things the types cannot: that the
// named approve label is one of this question's own options, and that a
// plan-review carries the plan it is a review of. A UI honouring the
// intent answers with that label, and shows that detail as the plan, so
// either gap would put a choice the asker never offered — or an approval of
// something invisible — in front of the user. Caught at the asker, where
// the mistake is, rather than in each UI.
for (const question of request.questions) {
const intent = question.intent
if (intent === undefined) continue
if (!(question.options ?? []).some(option => option.label === intent.approve)) {
throw new UserInteractionError(
`question ${question.id} declares intent ${intent.kind} whose approve label `
+ `${JSON.stringify(intent.approve)} names none of its options`,
'BAD_INTENT')
}
if (question.detail === undefined) {
throw new UserInteractionError(
`question ${question.id} declares intent ${intent.kind} without the detail it reviews`,
'BAD_INTENT')
}
}
if (this.provider === undefined) {
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
}

View File

@@ -13,6 +13,24 @@ export interface AskUserQuestionOption {
description?: string
}
/**
* A caller-declared presentation intent: the question IS a decision of this
* shape, so a UI that recognises the tag may present it as such instead of as a
* generic option list. Tagged so further intents can be added; a UI that does
* not know a tag renders the generic flow, and the answer encoding is identical
* either way — an intent shapes presentation only, never the protocol.
*/
export type AskUserQuestionIntent = {
/** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */
kind: 'plan-review'
/**
* The option label that approves the plan; every other option declines it.
* Named rather than positional so no UI infers the verdict from option order.
* An `approve` naming no option of its own question is rejected at `ask()`.
*/
approve: string
}
/** One question in a user-interaction request. */
export interface AskUserQuestionItem {
/** Stable caller-provided question id, echoed in the answer. */
@@ -27,6 +45,8 @@ export interface AskUserQuestionItem {
options?: AskUserQuestionOption[]
/** Whether more than one option may be selected. Defaults to single-select. */
multiSelect?: boolean
/** Optional presentation intent for capable UIs; absent asks for the generic option list. */
intent?: AskUserQuestionIntent
}
/** Answer to one question. */

View File

@@ -83,4 +83,63 @@ describe('UserInteractionService', () => {
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' })
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects an intent whose approve label names none of its own options', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' }
// A wrong label among offered options, and no options offered at all.
for (const options of [[{ label: 'Approve' }], undefined]) {
await expect(ctx.userInteraction.ask({
questions: [{
...question,
...(options === undefined ? {} : { options }),
intent: { kind: 'plan-review', approve: 'Ship it' },
}],
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
}
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects a plan-review intent on a question carrying no plan to review', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
// Detail IS the plan for this intent, so a UI honouring it would ask the
// user to approve something they cannot see.
await expect(ctx.userInteraction.ask({
questions: [{
id: 'plan-review', question: 'Approve?',
options: [{ label: 'Approve' }, { label: 'Keep planning' }],
intent: { kind: 'plan-review', approve: 'Approve' },
}],
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'BAD_INTENT' })
expect(p.ask).not.toHaveBeenCalled()
})
it('passes an intent through once its approve label names an offered option', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = provider('Approve')
ctx.userInteraction.registerProvider(p)
const intent = { kind: 'plan-review', approve: 'Approve' } as const
const result = await ctx.userInteraction.ask({
questions: [
{ id: 'plain', question: 'Proceed?' },
{
id: 'plan-review', question: 'Approve?', detail: '# Plan',
options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent,
},
],
})
expect(result.answers).toEqual([{ id: 'plain', selected: ['Approve'] }])
expect(p.seen[0]?.questions[1]?.intent).toEqual(intent)
})
})