Merge remote-tracking branch 'origin/master' into feature/directory-listing-tool

Master had advanced 392 commits. Every conflict was a derived artifact both
sides had regenerated — `docs/tool-catalog.md`, four recorded headless
session logs, and one TUI terminal expectation — so each was resolved by
taking master's version and re-running the generator and the keyless
snapshot refresh on the merged tree. No source file conflicted.

The new `examples/jsonrpc-agent` fixtures keep master's scrubbed
`{{system}}` / `{{tools}}` headers: that suite scrubs request headers, so a
new tool changes nothing there and the refresh output was discarded.
This commit is contained in:
NI0317
2026-07-28 13:48:28 +08:00
1147 changed files with 36501 additions and 16763 deletions
+11 -1
View File
@@ -6,7 +6,7 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, GenericResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import { FsError } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -154,6 +154,16 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
ctx.emit('fs/observed', target, info.version, exec)
return outcome
},
presentResult(_args, result: ToolResult): GenericResultView | undefined {
if (result.isError) return undefined
const only = result.content.length === 1 ? result.content[0] : undefined
const text = only?.type === 'text' ? only.text : undefined
if (text === undefined) return undefined
// Group 1 always captures (possibly empty) when the envelope matches.
const body = /^<path>[^\n]*<\/path>\n<type>file<\/type>\n<content>\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1]
if (body === undefined) return undefined
return { card: 'generic', content: [{ type: 'text', text: body }] }
},
// Pure display: a generic card titled by the file with the read window appended (`Read
// foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the
// read's offset (defaulting to 1). The window reflects raw args, so an omitted limit keeps
+4 -4
View File
@@ -29,10 +29,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
// (config.cwd = workdir) is the workspace.
const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
agent.followup([{ type: 'text', text:
agent.followup({ content: [{ type: 'text', text:
'Create a file named note.txt containing exactly the line: status: draft. '
+ 'Then read it back, then edit it to replace the literal word draft with final. '
+ 'Tell me when done.' }])
+ 'Tell me when done.' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
// Assert the filesystem effect independently of the model response.
@@ -61,8 +61,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
meta: { cwd: sessionDir },
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
})
handle.agent.followup([{ type: 'text', text:
'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }])
handle.agent.followup({ content: [{ type: 'text', text:
'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }], source: { kind: 'user' } })
await waitForIdle(ctx, handle.agent)
// The file is in the SESSION dir, not the config dir.
+36 -1
View File
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { type ToolResult } from '@deepseek-ai/dsh-tools'
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
@@ -577,6 +577,11 @@ describe('tool-owned presentation (pure presentCall)', () => {
return ctx.tools.get(name)?.presentCall?.(args)
}
const presentResult = async (name: string, args: unknown, result: ToolResult) => {
const { ctx } = await setup()
return ctx.tools.get(name)?.presentResult?.(args, result)
}
it('read: generic card titled by file with the read window, read kind, location with the offset line', async () => {
expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({
card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read',
@@ -602,6 +607,36 @@ describe('tool-owned presentation (pure presentCall)', () => {
})
})
it('read: completed presentation removes the model-facing XML envelope', async () => {
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>' }],
isError: false,
})).toEqual({
card: 'generic',
content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }],
})
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: 'malformed replay' }],
isError: false,
})).toBeUndefined()
})
it('read: completed presentation declines errors and non-single-text content', async () => {
const envelope = '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\nbody\n</content>'
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: envelope }],
isError: true,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }],
isError: false,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'reasoning', text: envelope }],
isError: false,
})).toBeUndefined()
})
it('read: "from line N" window when only offset is set', async () => {
expect(await presentCall('read', { file_path: 'a.txt', offset: 5 })).toEqual({
card: 'generic', title: 'Read a.txt (from line 5)', kind: 'read', locations: [{ path: 'a.txt', line: 5 }],