diff --git a/apps/cli/tests/profiles/sdk/desktop-tools.expected.e2e.ts b/apps/cli/tests/profiles/sdk/desktop-tools.expected.e2e.ts new file mode 100644 index 0000000000..0a0a0ffad2 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/desktop-tools.expected.e2e.ts @@ -0,0 +1,155 @@ +/** + * Keyless comparison of actual SDK and Desktop/Web step-admitted tool sets. + * Runs in test:expected, hence ci-consumers and PR all checks passed, after the build. + * Keep intentional differences in tool-parity.ts; unused exceptions fail. + * SDK minimal, Creator-only tools, graphical context and optional runtime payloads + * are outside this fixed standard/PTC, top-level-session comparison. + */ +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import { z } from 'zod' +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' +import { expect, it, type TestContext } from 'vitest' +import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts' +import { messagesResponse } from './fixtures/messages-response.ts' +import { assertToolParity, type ToolMode } from './tool-parity.ts' + +const repo = fileURLToPath(new URL('../../../../../', import.meta.url)) +const observer = new URL('./fixtures/tool-parity.mjs', import.meta.url).href +const probe = new URL('./fixtures/parity-probe.mjs', import.meta.url).href +const inventorySchema = z.object({ names: z.array(z.string()).min(1), hmr: z.boolean() }) +type Inventory = z.infer + +async function fixture(test: TestContext) { + const root = await mkdtemp(join(tmpdir(), 'sdk-desktop-tools-')) + const requests: { tools: { name: string }[] }[] = [] + const server = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + requests.push(JSON.parse(body) as { tools: { name: string }[] }) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(messagesResponse({ type: 'text', text: 'tools inspected' }, 'end_turn')) + }) + }) + test.onTestFinished(async () => { + try { + await new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) + } + finally { await rm(root, { recursive: true, force: true }) } + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model fixture has no TCP address') + // No ambient DSH options, runtime payload, MCP profile, or personal skill directory enters this comparison. + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('DSH_'))) + Object.assign(env, { + DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + DSH_AGENTS_HOME: join(root, 'agents'), DSH_PRIMARY_RUNTIME: '', + DEEPSEEK_API_KEY: 'keyless-parity', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + NODE_OPTIONS: '', NODE_PATH: '', + }) + clearAmbientProxyEnv(env) + + async function collect(side: 'sdk' | 'desktop', mode: ToolMode, mutations: object[] = []): Promise { + const cwd = await mkdtemp(join(root, `${side}-${mode}-`)) + const output = join(cwd, 'tools.json') + const patch = join(cwd, 'observer.patch.yml') + await writeFile(patch, JSON.stringify([ + { id: 'session-title-llm', disabled: true }, + { insert: [{ id: 'tool-parity-observer', name: observer, + config: { output, cwd, ...(side === 'desktop' ? { preset: mode } : {}) } }] }, + ...mutations, + ])) + const before = requests.length + let inventory: Inventory + if (side === 'sdk') { + const harness = new DeepSeekHarness({ + cwd, processCwd: cwd, dshHome: join(cwd, 'home'), env, model: 'deepseek-v4-pro', + patches: [...mode === 'ptc' ? [join(repo, 'apps/cli/config/examples/ptc.yml')] : [], patch], + }) + const abort = (): void => { void harness.close() } + test.signal.addEventListener('abort', abort, { once: true }) + try { + const result = await harness.session('tool-parity').run('Inspect tools') + expect(result.finalResponse).toBe('tools inspected') + inventory = inventorySchema.parse(JSON.parse(await readFile(output, 'utf8'))) + } finally { + await harness.close() + test.signal.removeEventListener('abort', abort) + } + expect(inventory.hmr).toBe(false) + } else { + const child = execa(process.execPath, [join(repo, 'apps/cli/lib/bin.js'), '--profile', 'web', + '--patch', patch, '--host', '127.0.0.1', '--port', '0', '--no-open'], { + cwd, env: { ...env, DSH_HOME: join(cwd, 'home') }, extendEnv: false, + ipc: true, stdin: 'ignore', reject: false, cancelSignal: test.signal, forceKillAfterDelay: 10_000, + }) + let stdout = '' + let stderr = '' + const ready = Promise.withResolvers() + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + if (stdout.includes('dsh web: http://')) ready.resolve(true) + }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + void child.then((result) => { ready.reject(new Error(`Web exited: ${result.exitCode}\n${stderr}`)) }) + try { + await ready.promise + const reply = child.getOneMessage() + await child.sendMessage('collect-tool-parity') + const message = z.object({ result: inventorySchema.optional(), error: z.string().optional() }).parse(await reply) + if (message.error !== undefined) throw new Error(message.error) + inventory = inventorySchema.parse(message.result) + } catch (error) { + throw new Error(`Web collection failed\n${stdout}\n${stderr}`, { cause: error }) + } finally { + // Ask the CLI to drain on Windows too, where an OS signal kills immediately. + const watchdog = setTimeout(() => { child.kill('SIGKILL') }, 10_000) + try { + try { await child.sendMessage('stop-tool-parity') } + catch (_closedChannel: unknown) { child.kill('SIGTERM') } + const result = await child + expect(result.exitCode, result.stderr).toBe(0) + expect(result.signal, result.stderr).toBeUndefined() + } finally { clearTimeout(watchdog) } + } + expect(inventory.hmr).toBe(true) + } + expect(requests.length).toBeGreaterThan(before) + const presented = requests[before]!.tools.map(tool => tool.name).sort() + if (mode === 'ptc') expect(presented).toEqual(['run_code']) + else expect(presented).toEqual(inventory.names) + return inventory + } + return collect +} + +it.for(['standard', 'ptc'] as const)('compares the shipped %s tool sets and detects one-sided profile edits', async (mode, test) => { + const collect = await fixture(test) + const sdk = await collect('sdk', mode) + const desktop = await collect('desktop', mode) + assertToolParity(mode, sdk.names, desktop.names) + expect(sdk.names).toContain('plugin_manager') + const optOut = await collect('sdk', mode, [{ id: 'tool-plugin-manager', disabled: true }]) + expect(optOut.names).not.toContain('plugin_manager') + expect(() => { assertToolParity(mode, optOut.names, desktop.names) }).toThrow('plugin_manager') + for (const [id, tool] of [['present', 'present'], ['tool-ask-user', 'ask_user_question'], ['tool-schedule', 'schedule_create']]) { + const missing = await collect('sdk', mode, [{ id, disabled: true }]) + expect(() => { assertToolParity(mode, missing.names, desktop.names) }).toThrow(new RegExp(`Missing from SDK:.*${tool}`)) + } + const addition = [{ insert: [{ id: 'parity-probe', name: probe }] }] + const desktopExtra = await collect('desktop', mode, addition) + expect(() => { assertToolParity(mode, sdk.names, desktopExtra.names) }).toThrow('Missing from SDK: parity_probe') + const sdkExtra = await collect('sdk', mode, addition) + expect(() => { assertToolParity(mode, sdkExtra.names, desktop.names) }).toThrow('Only in SDK: parity_probe') + assertToolParity(mode, sdkExtra.names, desktopExtra.names) +}) diff --git a/apps/cli/tests/profiles/sdk/fixtures/parity-probe.mjs b/apps/cli/tests/profiles/sdk/fixtures/parity-probe.mjs new file mode 100644 index 0000000000..03596ae9bc --- /dev/null +++ b/apps/cli/tests/profiles/sdk/fixtures/parity-probe.mjs @@ -0,0 +1,12 @@ +/** A real extra registration used to detect one-sided profile additions. */ +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const inject = ['tools'] + +export function apply(ctx) { + ctx.tools.register(defineTool({ + name: 'parity_probe', description: 'Test profile addition.', parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + async execute() { return 'probe' }, + })) +} diff --git a/apps/cli/tests/profiles/sdk/fixtures/tool-parity.mjs b/apps/cli/tests/profiles/sdk/fixtures/tool-parity.mjs new file mode 100644 index 0000000000..a6ae0f5743 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/fixtures/tool-parity.mjs @@ -0,0 +1,53 @@ +/** Observe step-admitted capabilities without changing either shipped tool executor. */ +import { writeFile } from 'node:fs/promises' +import { createUserMessage } from '@deepseek-ai/dsh-llm' + +export const inject = ['tools', 'agents', 'loader'] + +export function apply(ctx, config) { + let observed + ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + if (context.toolSet !== undefined) { + observed = { + names: ctx.tools.definitions(context.scope, context.toolSet).map(tool => tool.name).sort(), + hmr: ctx.get('hmr') !== undefined, + } + await writeFile(config.output, JSON.stringify(observed)) + } + return next() + }) + if (config.preset === undefined) return + const receive = message => { + if (message === 'stop-tool-parity') { + process.emit('SIGTERM') + return + } + if (message !== 'collect-tool-parity') return + void collect().then( + result => process.send({ result }), + error => process.send({ error: String(error.stack ?? error) }), + ) + } + ctx.effect(() => { + process.on('message', receive) + return () => process.off('message', receive) + }) + async function collect() { + await ctx.loader.await() + const presets = ctx.get('agentPresets') + if (presets === undefined) throw new Error('Web tool parity requires the shipped presets') + const handle = await ctx.agents.create({ + sessionId: 'tool-parity', cwd: config.cwd, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + setup: scope => presets.mount(scope, config.preset).then(() => undefined), + }) + try { + handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Inspect tools' }], source: { kind: 'user' } })) + await handle.agent.whenIdle() + if (observed === undefined) throw new Error('Web did not assemble a model step') + return observed + } finally { + await handle.dispose() + } + } +} diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 6f8add8c49..bee0a10775 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -174,7 +174,7 @@ describe('Python SDK dsh profile keyless smoke', () => { expect(modelRequests[0]?.output_config).toEqual({ effort: 'max' }) expect(modelRequests[0]?.max_tokens).toBe(1234) const expectedTools = ['read', 'write', 'edit', 'web_fetch', 'web_search', 'subagent', 'subagent_fork', - 'present', 'ask_user_question', 'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update'] + 'present', 'ask_user_question', 'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update', 'plugin_manager'] const requestText = JSON.stringify(modelRequests[0]) if (ptc) { expect(toolNames).toEqual(['run_code']) diff --git a/apps/cli/tests/profiles/sdk/plugin-manager.expected.e2e.ts b/apps/cli/tests/profiles/sdk/plugin-manager.expected.e2e.ts new file mode 100644 index 0000000000..3a0c7cdde7 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/plugin-manager.expected.e2e.ts @@ -0,0 +1,110 @@ +/** SDK plugin management persists changes while the live stdio process keeps its original composition. */ +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' +import { expect, it } from 'vitest' +import { z } from 'zod' +import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts' +import { messagesResponse } from './fixtures/messages-response.ts' + +it.for(['standard', 'ptc'] as const)('keeps %s plugin changes pending until the SDK runtime restarts', async (mode, test) => { + const root = await mkdtemp(join(tmpdir(), 'sdk-plugin-manager-')) + const requests: Record[] = [] + let phase: 'list' | 'set' | 'done' = 'list' + let changedAt = 0 + let fixtureError: unknown + const listPage = z.object({ + entries: z.array(z.object({ entryId: z.string(), moduleName: z.string() })), nextOffset: z.number().nullable(), + }) + const server = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + const current = JSON.parse(body) as Record + requests.push(current) + let args: Record | undefined + try { + if (requests.length === 1) args = { action: 'list_plugins', limit: 100 } + else if (phase === 'list') { + const messages = current.messages as { content: { type: string; content?: { text: string }[] }[] }[] + const result = messages.flatMap(message => message.content).findLast(block => block.type === 'tool_result') + const page = listPage.parse(JSON.parse(result!.content![0]!.text)) + const row = page.entries.find(entry => entry.moduleName === '@deepseek-ai/dsh-tool-web') + if (row !== undefined) { + args = { action: 'set_plugin', target: row.entryId, enabled: false } + phase = 'set' + } else if (page.nextOffset !== null) args = { action: 'list_plugins', offset: page.nextOffset, limit: 100 } + else throw new Error('SDK plugin inventory omitted tool-web') + } else if (phase === 'set') { + changedAt = requests.length - 1 + phase = 'done' + } + } catch (error) { fixtureError = error } + const call = mode === 'ptc' + ? { type: 'tool_use', id: `manage-${requests.length}`, name: 'run_code', input: { + code: `return await tools.plugin_manager(${JSON.stringify(args)});`, description: 'Disable web tools after restart', + } } + : { type: 'tool_use', id: `manage-${requests.length}`, name: 'plugin_manager', input: args } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(messagesResponse(args !== undefined ? call : { type: 'text', text: 'done' }, + args !== undefined ? 'tool_use' : 'end_turn')) + }) + }) + test.onTestFinished(async () => { + try { + await new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) + } + finally { await rm(root, { recursive: true, force: true }) } + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model fixture has no TCP address') + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('DSH_'))) + Object.assign(env, { DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + DSH_AGENTS_HOME: join(root, 'agents'), DSH_PRIMARY_RUNTIME: '', + DEEPSEEK_API_KEY: 'keyless-plugin-manager', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` }) + clearAmbientProxyEnv(env) + const options = { + cwd: root, processCwd: root, dshHome: join(root, 'home'), env, model: 'deepseek-v4-pro', + patches: mode === 'ptc' ? [fileURLToPath(new URL('../../../config/examples/ptc.yml', import.meta.url))] : [], + } + async function run(restarted: boolean): Promise { + const harness = new DeepSeekHarness(options) + const abort = (): void => { void harness.close() } + test.signal.addEventListener('abort', abort, { once: true }) + try { + const session = harness.session(restarted ? 'restarted' : 'original') + expect((await session.run('Disable web tools after restart')).finalResponse).toBe('done') + if (!restarted) { + expect((await session.run('Continue on the same connection')).finalResponse).toBe('done') + } + } finally { + await harness.close() + test.signal.removeEventListener('abort', abort) + } + } + const advertises = (request: Record, name: string): boolean => mode === 'ptc' + ? JSON.stringify(request.system).includes(`${name}:`) + : (request.tools as { name: string }[]).some(tool => tool.name === name) + await run(false) + expect(fixtureError).toBeUndefined() + expect(changedAt).toBeGreaterThan(0) + expect(advertises(requests[0]!, 'plugin_manager')).toBe(true) + expect(JSON.stringify(requests[changedAt]!.messages)).toContain('restart-required') + expect(JSON.stringify(requests[changedAt]!.messages)).toContain('no-hmr') + expect(JSON.stringify(requests[changedAt]!.messages)).toContain('restart DSH') + expect(advertises(requests.at(-1)!, 'web_fetch')).toBe(true) + const saved = await readFile(join(root, 'home/profiles/sdk/cordis.patch.yml'), 'utf8') + expect(saved).toContain('tool-web') + expect(saved).toContain('disabled: true') + await run(true) + expect(advertises(requests.at(-1)!, 'web_fetch')).toBe(false) + expect(advertises(requests.at(-1)!, 'plugin_manager')).toBe(true) +}) diff --git a/apps/cli/tests/profiles/sdk/tool-parity.spec.ts b/apps/cli/tests/profiles/sdk/tool-parity.spec.ts new file mode 100644 index 0000000000..7e3e5a8585 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/tool-parity.spec.ts @@ -0,0 +1,26 @@ +/** Diagnostics and exact exception consumption for cross-profile tool comparison. */ +import { expect, it } from 'vitest' +import { assertToolParity } from './tool-parity.ts' + +it('accepts an exact directional exception and rejects it after the profiles converge', () => { + const exceptions = [{ tool: 'desktop_only', side: 'desktop' as const, reason: 'Requires a graphical client.' }] + expect(() => { assertToolParity('standard', ['read'], ['read', 'desktop_only'], exceptions) }).not.toThrow() + expect(() => { assertToolParity('standard', ['read'], ['read'], exceptions) }).toThrow('Stale exceptions: desktop:desktop_only') + expect(() => { assertToolParity('standard', ['read', 'desktop_only'], ['read'], exceptions) }).toThrow('Only in SDK: desktop_only') +}) + +it('reports unexpected differences in both directions without hiding shared additions', () => { + expect(() => { assertToolParity('standard', ['read', 'new_shared'], ['new_shared', 'read'], []) }).not.toThrow() + expect(() => { assertToolParity('ptc', ['read', 'sdk_extra'], ['read', 'present'], []) }).toThrow( + 'Missing from SDK: present\nOnly in SDK: sdk_extra', + ) +}) + +it('rejects empty observations, undocumented exceptions and duplicate exceptions', () => { + expect(() => { assertToolParity('standard', [], [], []) }).toThrow('Empty tool inventory') + expect(() => { assertToolParity('standard', ['read'], ['read'], [ + { tool: 'extra', side: 'sdk', reason: ' ' }, + ]) }).toThrow('requires a reason') + const exception = { tool: 'extra', side: 'sdk' as const, reason: 'Intentional.' } + expect(() => { assertToolParity('standard', ['read', 'extra'], ['read'], [exception, exception]) }).toThrow('Stale exceptions: sdk:extra') +}) diff --git a/apps/cli/tests/profiles/sdk/tool-parity.ts b/apps/cli/tests/profiles/sdk/tool-parity.ts new file mode 100644 index 0000000000..b91ca64f93 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/tool-parity.ts @@ -0,0 +1,53 @@ +/** Exact, directional exceptions to the shipped SDK/Desktop capability comparison. */ +export type ToolMode = 'standard' | 'ptc' + +interface Exception { + tool: string + side: 'sdk' | 'desktop' + reason: string +} + +/** Intentional product differences; obsolete entries fail alongside undeclared differences. */ +export const toolParityExceptions: Record = { + standard: [{ tool: 'list_subagent_models', side: 'desktop', + reason: 'Desktop enables subagent model discovery; SDK delegation inherits the caller model by default.' }], + ptc: [ + { tool: 'list_subagent_models', side: 'desktop', + reason: 'Desktop enables subagent model discovery; SDK delegation inherits the caller model by default.' }, + { tool: 'plugin_manager', side: 'sdk', + reason: 'SDK exposes restart-required profile management in both executors; the Desktop PTC preset disables management.' }, + { tool: 'workflow', side: 'sdk', + reason: 'The SDK executor overlay preserves base workflow delegation; Desktop PTC disables the separate workflow tool.' }, + ], +} + +/** + * Compare actual step-admitted sets, rejecting both drift and unused exceptions. + * @param mode - matching SDK executor and Desktop preset. + * @param sdk - SDK capability names. + * @param desktop - Desktop capability names. + * @param exceptions - exact one-sided product differences with their rationale. + */ +export function assertToolParity( + mode: ToolMode, sdk: readonly string[], desktop: readonly string[], + exceptions = toolParityExceptions[mode], +): void { + if (sdk.length === 0 || desktop.length === 0) throw new Error(`Empty tool inventory: ${mode}`) + const only = { + sdk: new Set(sdk.filter(name => !desktop.includes(name))), + desktop: new Set(desktop.filter(name => !sdk.includes(name))), + } + const stale: string[] = [] + for (const exception of exceptions) { + if (!exception.reason.trim()) throw new Error(`Tool exception requires a reason: ${exception.tool}`) + if (!only[exception.side].delete(exception.tool)) stale.push(`${exception.side}:${exception.tool}`) + } + if (only.sdk.size || only.desktop.size || stale.length) { + throw new Error([ + `SDK/Desktop tool mismatch: ${mode}`, + `Missing from SDK: ${[...only.desktop].sort().join(', ') || '(none)'}`, + `Only in SDK: ${[...only.sdk].sort().join(', ') || '(none)'}`, + `Stale exceptions: ${stale.sort().join(', ') || '(none)'}`, + ].join('\n')) + } +} diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index ed528810fc..8b6e317dfe 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -15,8 +15,8 @@ en: 4e53164159b5ce41 zh: 89582dc0294d0776 /deepseek-ai-dsh-sdk-app/use-this-package: - en: b9a0e01492ffe278 - zh: 7c6ffd1f9e9c0906 + en: 84fc7e5640bd14dd + zh: 855d4d1de24688a6 /deepseek-ai-dsh-sdk-app/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 @@ -36,5 +36,5 @@ en: 451497677de7e6b9 zh: dcfdc671c38d54d4 /deepseek-ai-dsh-sdk-app/known-limitations-and-deferred-work/dev-note: - en: e67f633dc6fe7773 - zh: e88aeb617ffb74ea + en: cf2f59217862b9bb + zh: 554761e87179df29 diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index d1be289939..1e4d70e0d5 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -35,6 +35,8 @@ The startup provider binds stdin EOF to the launcher's bounded successful shutdo The SDK uses the base `read`, `write`, and `edit` defaults. To add `str_replace_editor`, use the explicit insertion patch in the [base configuration guide](../base/README.md#use-this-package). The standalone `sdk-minimal` profile owns its separate tool selection. +The model can use [`plugin_manager`](../../boot/plugin-manager/README.md) in native and PTC modes. Every action requires full access or approval for that call. Saved profile changes report `restart-required` with `no-hmr`; the current runtime and its sessions retain their tools until the caller closes and starts the runtime again. Set `tool-plugin-manager.disabled: true` through a profile patch to hide this tool. The standalone `sdk-minimal` profile does not include it. + The Python runtime exposes explicitly downloaded authoring resources and Office sidecars to this profile. `DSH_PRIMARY_RUNTIME` overrides the authoring payload; an empty value disables its query. Office skills can use either resource independently. Profile patches can disable `skill-office` or replace its `assetRoot` independently of `workspace-dependencies`; filesystem skills with the same name take precedence. See the [runtime package](../../../python/sdk-runtime/README.md) for downloads and configuration. ----- @@ -75,6 +77,6 @@ The static prefix is stable for a fixed profile, provider, model, and tool roste
Working context for maintainers — click to expand -None. +The [SDK/Desktop tool comparison](../../../apps/cli/tests/profiles/sdk/desktop-tools.expected.e2e.ts) runs without API credentials in `test:expected`, included in the required PR consumer job. After building, run `CI=true pnpm run test:expected apps/cli/tests/profiles/sdk/desktop-tools.expected.e2e.ts`. Maintain exact, reasoned differences in the adjacent `tool-parity.ts`; resolved differences invalidate their exceptions. Existing SDK session snapshots and installed-runtime smokes independently check model output and packaging.
diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index 70523123d7..a339086ad7 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -35,6 +35,8 @@ kind: "package-bundle" SDK 使用 base 默认提供的 `read`、`write` 和 `edit`。要添加 `str_replace_editor`,请使用 [base 配置指南](../base/README.zh.md#use-this-package)中的显式插入 patch。独立的 `sdk-minimal` profile 自行决定其工具选择。 +模型可以在 native 和 PTC 模式下使用 [`plugin_manager`](../../boot/plugin-manager/README.zh.md)。每个操作都要求完整权限或本次调用的批准。保存的 profile 变更返回 `restart-required` 和 `no-hmr`;当前运行时及其会话保留原有工具,直到调用方关闭并重新启动运行时。通过 profile patch 设置 `tool-plugin-manager.disabled: true` 可隐藏此工具。独立的 `sdk-minimal` profile 不包含它。 + Python 运行时向本 profile 提供显式下载的创作资源和 Office sidecar。`DSH_PRIMARY_RUNTIME` 覆盖创作载荷;空值禁用其查询。Office skills 可独立使用任一种资源。Profile patch 可独立于 `workspace-dependencies` 禁用 `skill-office` 或替换其 `assetRoot`;同名文件系统 skills 优先。下载与配置见[运行时包](../../../python/sdk-runtime/README.zh.md)。 ----- @@ -75,6 +77,6 @@ profile 继承 base 系统提示词,包括 `You are an AI agent powered by Dee
维护者工作上下文——点击展开 -无。 +[SDK/Desktop 工具比较](../../../apps/cli/tests/profiles/sdk/desktop-tools.expected.e2e.ts) 在 `test:expected` 中无需 API 凭据运行,并纳入 PR 必需的 consumer 作业。构建后运行 `CI=true pnpm run test:expected apps/cli/tests/profiles/sdk/desktop-tools.expected.e2e.ts`。在相邻的 `tool-parity.ts` 中维护精确、带理由的差异;差异消除后,对应例外会使检查失败。已有 SDK 会话快照和安装后运行时 smoke 分别检查模型输出和打包结果。
diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index afcbfd2c0d..15a5245ade 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -23,6 +23,9 @@ - id: hmr disabled: true +- id: tool-plugin-manager + disabled: false + # The packaged carrier supplies a default; DSH_PRIMARY_RUNTIME overrides it, # including an empty-string opt-out. Each row can be patched independently. - insert: diff --git a/packages/bundle/sdk-app/tests/sdk-app.spec.ts b/packages/bundle/sdk-app/tests/sdk-app.spec.ts index 2e040c2487..6ab9bd10ff 100644 --- a/packages/bundle/sdk-app/tests/sdk-app.spec.ts +++ b/packages/bundle/sdk-app/tests/sdk-app.spec.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' describe('dsh-sdk-app bundle', () => { - it('declares startup-gated JSON-RPC serving without overriding base HMR policy', () => { + it('enables plugin management while keeping JSON-RPC dependencies stable until restart', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dependencies?: Record @@ -21,6 +21,7 @@ describe('dsh-sdk-app bundle', () => { { schema: entryListSchema }, ) as Array<{ id?: string; disabled?: boolean; insert?: Array<{ id?: string; inject?: string[]; name?: string }> }> expect(patches.find(patch => patch.id === 'hmr')).toMatchObject({ disabled: true }) + expect(patches.find(patch => patch.id === 'tool-plugin-manager')).toMatchObject({ disabled: false }) expect(patches.find(patch => patch.id === 'session-title-llm')).toMatchObject({ disabled: true }) expect(patches.find(patch => patch.id === 'system-prompt')).toMatchObject({ config: { personaPrefix: 'You are a coding agent powered by the {{model}} model.' }, diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 3141b7bb24..a0958af989 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -91,6 +91,7 @@ LEGACY_CUSTOM_DISABLED_ROWS = ( "tool-fs", "tool-fs-search", "tool-goal", + "tool-plugin-manager", "tool-schedule", "tool-skill", "tool-str-replace-editor", @@ -476,6 +477,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(RESUME_TEXT) if prompt == DYNAMIC_TOOLS_PROMPT: assert_advertised_tool(body, "read") + assert_advertised_tool(body, "plugin_manager") if "snapshot_ping" in advertised_tool_names(body): raise AssertionError("dynamic tool was registered before its enabling read") return tool_call_chunks("python-dynamic-read", "read", {"file_path": "dynamic-tool-task.txt"}) diff --git a/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json b/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json index 066d4c24fb..4d90188ca8 100644 --- a/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json +++ b/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json @@ -56,6 +56,7 @@ "job_list", "job_output", "list_agents", + "plugin_manager", "present", "read", "read_image", @@ -117,6 +118,7 @@ "job_list", "job_output", "list_agents", + "plugin_manager", "present", "read", "read_image", @@ -193,6 +195,7 @@ "job_list", "job_output", "list_agents", + "plugin_manager", "present", "read", "read_image", diff --git a/snapshots/sdk/background-wait-steer/cordis.yml b/snapshots/sdk/background-wait-steer/cordis.yml index 5280cf451b..04e4e08f00 100644 --- a/snapshots/sdk/background-wait-steer/cordis.yml +++ b/snapshots/sdk/background-wait-steer/cordis.yml @@ -25,6 +25,10 @@ - id: time-context disabled: true +# Keep the recorded job-wait composition; default SDK tools have separate snapshots. +- id: tool-plugin-manager + disabled: true + - id: tool-jobs name: '@deepseek-ai/dsh-tool-jobs' config: diff --git a/snapshots/sdk/desktop-context/tool-schemas.expected.json b/snapshots/sdk/desktop-context/tool-schemas.expected.json index a28a4eba08..72d46f4bca 100644 --- a/snapshots/sdk/desktop-context/tool-schemas.expected.json +++ b/snapshots/sdk/desktop-context/tool-schemas.expected.json @@ -307,6 +307,67 @@ } } }, + { + "name": "plugin_manager", + "description": "List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Unmet DSH version requirements (engines.dsh, else DSH peer dependencies) block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "Management operation.", + "enum": [ + "list_plugins", + "list_bundles", + "set_plugin", + "set_bundle", + "install_bundle", + "remove_bundle", + "list_version_exemptions", + "set_version_exemption" + ] + }, + "target": { + "type": "string", + "description": "Plugin entry id, bundle package name, or installation spec, according to action. For an Official catalog entry, pass list_bundles installTarget.spec to install_bundle." + }, + "enabled": { + "type": "boolean", + "description": "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes." + }, + "runtimeVersion": { + "type": "string", + "description": "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range." + }, + "acceptRisk": { + "type": "boolean", + "description": "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough." + }, + "approvedBuilds": { + "type": "array", + "description": "For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.", + "items": { + "type": "string" + } + }, + "registry": { + "type": "string", + "description": "For install_bundle: the npm registry URL asked first, when the user names one; otherwise the configured registry is asked, and its configured fallbacks while a registry is unreachable." + }, + "offset": { + "type": "number", + "description": "Zero-based list offset; defaults to 0." + }, + "limit": { + "type": "number", + "description": "List page size, from 1 to 100; defaults to 25." + } + }, + "required": [ + "action" + ] + } + }, { "name": "present", "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", diff --git a/snapshots/sdk/desktop-ptc-context/system-prompt.expected.md b/snapshots/sdk/desktop-ptc-context/system-prompt.expected.md index 7a0ca76e3f..6dd695f005 100644 --- a/snapshots/sdk/desktop-ptc-context/system-prompt.expected.md +++ b/snapshots/sdk/desktop-ptc-context/system-prompt.expected.md @@ -155,6 +155,27 @@ interface ToolArgsMap { /** children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent. */ scope?: "children" | "descendants"; } & Record; + /** List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Unmet DSH version requirements (engines.dsh, else DSH peer dependencies) block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one. */ + plugin_manager: { + /** Management operation. */ + action: "list_plugins" | "list_bundles" | "set_plugin" | "set_bundle" | "install_bundle" | "remove_bundle" | "list_version_exemptions" | "set_version_exemption"; + /** Plugin entry id, bundle package name, or installation spec, according to action. For an Official catalog entry, pass list_bundles installTarget.spec to install_bundle. */ + target?: string; + /** Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes. */ + enabled?: boolean; + /** For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range. */ + runtimeVersion?: string; + /** For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough. */ + acceptRisk?: boolean; + /** For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile. */ + approvedBuilds?: string[]; + /** For install_bundle: the npm registry URL asked first, when the user names one; otherwise the configured registry is asked, and its configured fallbacks while a registry is unreachable. */ + registry?: string; + /** Zero-based list offset; defaults to 0. */ + offset?: number; + /** List page size, from 1 to 100; defaults to 25. */ + limit?: number; + } & Record; /** Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied. */ present: { /** Usually the 1-2 most important deliverables; at most 4 per call. */ @@ -523,6 +544,7 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; + plugin_manager: string; present: { turn: number; files: { diff --git a/snapshots/sdk/legacy-context.patch.yml b/snapshots/sdk/legacy-context.patch.yml index 10d7998b30..a02fe180c2 100644 --- a/snapshots/sdk/legacy-context.patch.yml +++ b/snapshots/sdk/legacy-context.patch.yml @@ -18,3 +18,6 @@ - id: time-context disabled: true + +- id: tool-plugin-manager + disabled: true