Merge remote-tracking branch 'origin/master' into worktree/tui-file-autocomplete

# Conflicts:
#	docs/config-catalog.md
This commit is contained in:
Yichen Jiang
2026-07-23 19:30:29 +08:00
286 changed files with 13961 additions and 3531 deletions

View File

@@ -60,7 +60,7 @@ import {
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { displayPromptContent, SessionId } from '@deepseek-ai/dsh-session'
import { displayPromptContent, SessionId, type JsonValue } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -1499,7 +1499,7 @@ export class ToolPresenter {
* @param meta - the result's machine-readable meta, forwarded when present.
* @returns a normalized tool-owned view or raw-content fallback.
*/
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: JsonValue): ToolResultView {
const call = this.pending.get(callId)
this.pending.delete(callId)
// No remembered call (unknown/late callId) → nothing to present from; raw content.

View File

@@ -12,6 +12,11 @@ import FsLocal from '@deepseek-ai/dsh-fs-local'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts'
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
schema: { type: 'null' },
render: () => [],
}
/** Collect the updates a single event produces (no presenter → generic fallback). */
function updatesFor(event: SessionEvent): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
@@ -279,6 +284,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'bash',
description: 'run a command',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: (args: unknown) => {
const a = args as { command: string; description: string }
@@ -336,7 +342,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
})
it('a tool with no presentCall/presentResult gets the generic fallback (title = name)', () => {
const plain: ToolDefinition = { name: 'plain', description: 'p', parameters: {}, execute: async () => [] }
const plain: ToolDefinition = { name: 'plain', description: 'p', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [] }
const presenter = new ToolPresenter(registryOf(plain))
const [update] = updatesWith(presenter, evt('tool/call', {
turn: 1, step: 1, callId: CallId('c1'), name: 'plain', arguments: '{"a":1}',
@@ -352,6 +358,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'mini',
description: 'm',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Doing a thing' }),
presentResult: () => ({ card: 'generic', title: 'Did the thing' }),
@@ -398,6 +405,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'boom',
description: 'b',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => { throw new Error('call boom') },
presentResult: () => { throw new Error('result boom') },
@@ -427,6 +435,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'boom',
description: 'b',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => { throw new Error('call boom') },
presentResult: () => { throw new Error('result boom') },
@@ -450,6 +459,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'rogue',
description: 'r',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
// A card value outside the union — forced with a cast (no valid input reaches this).
presentCall: () => ({ card: 'chart', title: 'nope' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentCall']>>,
@@ -468,6 +478,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
name: 'rogue',
description: 'r',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'r' }),
presentResult: () => ({ card: 'chart' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentResult']>>,
@@ -530,6 +541,7 @@ describe('terminal-card mapping (capability-gated)', () => {
name: 'bash',
description: 'run a command',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: (args: unknown) => {
const command = (args as { command: string }).command
@@ -691,6 +703,7 @@ describe('terminal-card mapping (capability-gated)', () => {
name: 'bash',
description: 'run a command',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: (args: unknown) => ({ card: 'terminal', title: (args as { command: string }).command }),
}
@@ -713,6 +726,7 @@ describe('diff-card mapping', () => {
name: 'writer',
description: 'writes a file',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => view as ReturnType<NonNullable<ToolDefinition['presentCall']>>,
})
@@ -848,6 +862,7 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
name: 'writer',
description: 'writes a file',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: async () => [],
presentCall: () => ({ card: 'diff', title: 'Write x', diffs: [{ path: 'x', oldText: null, newText: 'y' }] }),
presentResult: () => ({ card: 'diff', diffs: [] }),

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import {
errorResponse,
@@ -63,7 +63,7 @@ describe('acp bridge — turn outcomes', () => {
storageDir,
script: [toolCallResponse('c1', 'bash', { command: 'echo hi' }), textResponse('done')],
})
harness.ctx.tools.register(defineTool({
harness.ctx.tools.register(defineContentToolFixture({
name: 'bash',
description: 'run a command',
parameters: { command: { type: 'string' } },
@@ -204,7 +204,7 @@ describe('acp bridge — turn outcomes', () => {
storageDir,
script: [toolCallResponse('c1', 'kaboom', { x: 1 }), textResponse('done')],
})
harness.ctx.tools.register(defineTool({
harness.ctx.tools.register(defineContentToolFixture({
name: 'kaboom',
description: 'explodes when presented',
parameters: { x: { type: 'number' } },
@@ -227,7 +227,7 @@ describe('acp bridge — turn outcomes', () => {
storageDir,
script: [toolCallResponse('c1', 'bash', { command: 'boom' }), textResponse('ok')],
})
harness.ctx.tools.register(defineTool({
harness.ctx.tools.register(defineContentToolFixture({
name: 'bash',
description: 'run a command',
parameters: { command: { type: 'string' } },

View File

@@ -13,7 +13,7 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo
- `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label.
- `multi_select` — whether that question may return more than one selected option.
The tool calls `ctx.userInteraction.ask()` and returns JSON text shaped as `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices.
The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`.
## Role
@@ -52,4 +52,4 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **A pending question blocks the tool call until the human answers** — the tool declares no `timeout-policy` budget; cancellation rides the turn's `exec.signal` only.
- **Answers return as JSON text** — the seam's structured `AskUserQuestionAnswer` is serialized into the tool result rather than carried as typed content blocks.
- **Native answers render as JSON text** — the canonical value remains structured, but the model-facing result uses compact JSON rather than a richer content-block vocabulary.

View File

@@ -27,6 +27,7 @@ export function apply(ctx: Context): void {
description: 'Questions to ask the user before continuing.',
items: {
type: 'object',
additionalProperties: true,
properties: {
id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' },
question: { type: 'string', required: true, description: 'The specific question to ask the user.' },
@@ -39,6 +40,7 @@ export function apply(ctx: Context): void {
description: 'Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label.',
items: {
type: 'object',
additionalProperties: true,
properties: {
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
@@ -53,6 +55,28 @@ export function apply(ctx: Context): void {
},
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
answers: {
type: 'array',
required: true,
items: {
type: 'object',
additionalProperties: false,
properties: {
id: { type: 'string', required: true },
selected: { type: 'array', required: true, items: { type: 'string' } },
custom: { type: 'string' },
},
},
},
},
},
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
},
async execute(args, exec) {
const result = await ctx.userInteraction.ask({
questions: args.questions.map(question => ({
@@ -65,7 +89,13 @@ export function apply(ctx: Context): void {
...exec.agent !== undefined ? { agent: exec.agent } : {},
signal: exec.signal,
})
return [{ type: 'text', text: JSON.stringify(result) }]
return {
answers: result.answers.map(answer => ({
id: answer.id,
selected: [...answer.selected],
...answer.custom !== undefined ? { custom: answer.custom } : {},
})),
}
},
}))
}

View File

@@ -164,6 +164,14 @@ describe('ask_user_question tool', () => {
},
})
expect(result.isError).toBe(false)
if (result.isError) throw new Error('expected ask_user_question success')
expect(result.value).toEqual({
answers: [
{ id: 'targets', selected: ['tests', 'docs'] },
{ id: 'notes', selected: [], custom: 'ship today' },
],
})
expect(result.content).toEqual([{
type: 'text',
text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
@@ -226,7 +234,7 @@ describe('ask_user_question tool', () => {
expect(result).toMatchObject({
isError: true,
error: { name: 'UserInteractionError', code: 'NO_PROVIDER' },
error: { info: { name: 'UserInteractionError', code: 'NO_PROVIDER' } },
})
})
@@ -242,7 +250,7 @@ describe('ask_user_question tool', () => {
expect(result).toMatchObject({
isError: true,
error: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' },
error: { info: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' } },
})
})

View File

@@ -60,6 +60,7 @@ import type {} from '@deepseek-ai/dsh-llm-retry'
import {
displayPromptContent,
SessionId,
type JsonValue,
type Session,
type SessionEvent,
type SessionHeader,
@@ -840,7 +841,7 @@ function diffLines(diff: FileDiff, palette: Palette): string[] {
}
class ToolCardComponent implements Component {
private result: { content: ContentBlock[]; isError: boolean; meta?: unknown } | undefined
private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
private expanded = false
private callView: ToolCallView
private resultView: ToolResultView | undefined

View File

@@ -7,8 +7,7 @@ import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Session } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
@@ -159,7 +158,7 @@ function appendToolResult(
session: Session,
id: string,
content: ContentBlock[],
options: { isError?: boolean; meta?: unknown } = {},
options: { isError?: boolean; meta?: JsonValue } = {},
): void {
session.append('tool/result', {
turn: 1,
@@ -180,6 +179,7 @@ function visualTool(
name,
description: `${name} snapshot fixture`,
parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: () => Promise.resolve([]),
presentCall: call,
...result === undefined ? {} : { presentResult: result },

View File

@@ -34,6 +34,11 @@ import {
type TuiHarnessOptions,
} from './harness.ts'
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
schema: { type: 'null' },
render: () => [],
}
class FakeTerminal implements Terminal {
columns = 88
rows = 32
@@ -1084,6 +1089,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
name: 'read',
description: 'Read a file.',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: () => Promise.resolve([]),
},
},
@@ -1157,6 +1163,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
name: 'read',
description: 'Read a file.',
parameters: {},
output: UNUSED_TOOL_OUTPUT,
execute: () => Promise.resolve([]),
}
let visibility: 'none' | 'global' | 'agent' = 'none'
@@ -1986,17 +1993,17 @@ describe('renderSkillInvocation', () => {
describe('tool cards and surface replay', () => {
const tools: Record<string, ToolDefinition> = {
bash: {
name: 'bash', description: '', parameters: {}, execute: async () => [],
name: 'bash', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'terminal', title: 'printf hello', description: 'Run command', cwd: '/tmp' }),
presentResult: () => ({ card: 'terminal', output: 'hello\nworld\nthird', exitCode: 0 }),
},
signal: {
name: 'signal', description: '', parameters: {}, execute: async () => [],
name: 'signal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'terminal', title: 'sleep 10' }),
presentResult: () => ({ card: 'terminal', signal: 'SIGTERM' }),
},
edit: {
name: 'edit', description: '', parameters: {}, execute: async () => [],
name: 'edit', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({
card: 'diff',
title: 'Edit files',
@@ -2008,35 +2015,35 @@ describe('tool cards and surface replay', () => {
presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }),
},
generic: {
name: 'generic', description: '', parameters: {}, execute: async () => [],
name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
presentResult: () => ({ card: 'generic', title: 'Inspected', content: [{ type: 'text', text: 'result text' }] }),
},
throwing: {
name: 'throwing', description: '', parameters: {}, execute: async () => [],
name: 'throwing', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => { throw new Error('call presenter boom') },
presentResult: () => { throw new Error('result presenter boom') },
},
rawTerminal: {
name: 'rawTerminal', description: '', parameters: {}, execute: async () => [],
name: 'rawTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'terminal', title: 'raw command' }),
},
undefinedViews: {
name: 'undefinedViews', description: '', parameters: {}, execute: async () => [],
name: 'undefinedViews', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => undefined,
presentResult: () => undefined,
},
empty: {
name: 'empty', description: '', parameters: {}, execute: async () => [],
name: 'empty', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Empty card' }),
},
terminalResult: {
name: 'terminalResult', description: '', parameters: {}, execute: async () => [],
name: 'terminalResult', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
},
symbolic: {
name: 'symbolic', description: '', parameters: {}, execute: async () => [],
name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
},
}