• feat(self-modification): add dynamic Cordis plugin runtime and UI

This commit is contained in:
imccyu
2026-08-12 23:51:31 +08:00
parent 0367506471
commit 4064198560
147 changed files with 20904 additions and 2412 deletions

View File

@@ -6,7 +6,20 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** Keyed atomic Tool call view, dispatched by the wire Tool name. */
/**
* Keyed atomic Tool call view, dispatched by the wire Tool name. Register
* with `key: '<tool name>'` to own how one tool's calls render inside a
* turn — the key domain is open (any wire tool name, including a tool your
* own package registered), so there is no compile-time key set to pick
* from and a typo simply never renders.
*
* A key the shipped composition already covers is replaced, not shared;
* an unclaimed key falls back to the generic tool row, so registering is
* additive for your own tool and a takeover for a shipped one. The owner
* passes the call's identity, its frozen running-or-settled node, and the
* expansion state (see ToolCallOwnerProps), so the view stays a pure
* function of what the turn already knows.
*/
'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps }
}
}

View File

@@ -25,7 +25,15 @@ export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call',
}
/** Known tool name -> variant. */
/**
* Known tool name -> variant.
*
* `cordis_define` is deliberately absent: ui-cordis registers a keyed
* `tool.call.toolview` entry for it, and a keyed hit REPLACES the generic row
* (this table is only reached through GenericToolCard, the dispatch fallback in
* ToolCallTree). An entry here would be unreachable, and a second title for the
* same call would be a second answer to a question the card already owns.
*/
const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
bash: 'bash',
// The PowerShell twin is a shell tool: the bash row family (icon, colors)
@@ -39,16 +47,24 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
write: 'write',
edit: 'edit',
run_code: 'code',
cordis_inspect: 'read',
cordis_mount: 'code',
cordis_unmount: 'others',
cordis_package_inspect: 'read',
cordis_runtime_inspect: 'read',
// The three run-control verbs take one package id and produce a receipt, so
// the generic row is the decided intent, not an unclassified default: there is
// no program to show (that is `cordis_define`'s card) and no file to open. The
// id lands in the summary slot, and the titles below name the act.
cordis_run: 'others',
cordis_stop: 'others',
cordis_undefine: 'others',
}
/** Tool-owned titles that refine a generic row variant without replacing it. */
const TOOL_TITLES: Record<string, string> = {
cordis_inspect: 'Inspect',
cordis_mount: 'Mount temporary Plugin',
cordis_unmount: 'Unmount temporary Plugin',
cordis_package_inspect: 'Inspect',
cordis_runtime_inspect: 'Inspect',
cordis_run: 'Run dynamic package',
cordis_stop: 'Stop dynamic package',
cordis_undefine: 'Discard dynamic package',
pwsh: 'Pwsh',
}

View File

@@ -216,24 +216,23 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => {
const parent = 'call-cordis'
const code = 'return { name: "audit", apply(ctx) {} }'
const subCalls = [
subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'),
subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'),
subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'),
subCall(11, parent, 1, 'cordis_runtime_inspect', { what: 'temporary' }, '## Dynamic Packages'),
subCall(12, parent, 2, 'cordis_run', { id: 'dyn-2' }, 'Dynamic package dyn-2 is running'),
subCall(13, parent, 3, 'cordis_undefine', { id: 'dyn-2' }, 'Dynamic package dyn-2 was discarded.'),
]
const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots)
const nest = view.container.querySelector('[data-subcalls]')!
expect(nest.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
const mounted = nest.querySelector('[data-variant="code"]')
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
// Each run-control verb names its act and shows the package id; without the
// owned titles all three would read "Tool call · cordis_run · dyn-2".
expect(nest.querySelector('[data-tool="cordis_runtime_inspect"]')?.textContent).toContain('Inspect')
expect(nest.querySelector('[data-tool="cordis_run"]')?.textContent).toContain('Run dynamic packagedyn-2')
expect(nest.querySelector('[data-tool="cordis_undefine"]')?.textContent).toContain('Discard dynamic packagedyn-2')
// None of them is a code row: the program belongs to cordis_define, whose
// own keyed card renders it (the next case covers the code row itself).
expect(nest.querySelector('[data-variant="code"]')).toBeNull()
})
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {

View File

@@ -40,12 +40,44 @@ describe('tool-call-model', () => {
expect(classifyTool('grep')).toBe('search')
expect(classifyTool('write')).toBe('write')
expect(classifyTool('edit')).toBe('edit')
expect(classifyTool('cordis_inspect')).toBe('read')
expect(classifyTool('cordis_mount')).toBe('code')
expect(classifyTool('cordis_unmount')).toBe('others')
expect(classifyTool('cordis_runtime_inspect')).toBe('read')
// The v3 run-control verbs: `others` is the decided intent, not an
// unclassified default (there is no program to show and no file to open).
expect(classifyTool('cordis_run')).toBe('others')
expect(classifyTool('cordis_stop')).toBe('others')
expect(classifyTool('cordis_undefine')).toBe('others')
expect(classifyTool('todo_write')).toBe('others')
})
it('names each cordis verb instead of leaving it a bare tool call', () => {
// Every define/run pair the model makes puts a row in the flow, so the
// generic "Tool call · cordis_run · dyn-1" fallback is user-visible slop.
const titleOf = (name: string) => toolRowModel(name, running({ name, argsRaw: '{"id":"dyn-1"}' }))
expect(titleOf('cordis_run').title).toBe('Run dynamic package')
expect(titleOf('cordis_stop').title).toBe('Stop dynamic package')
expect(titleOf('cordis_undefine').title).toBe('Discard dynamic package')
// An owned title takes the tool name out of the summary slot, leaving the
// package id as the only mutable text.
expect(titleOf('cordis_run').summary).toBe('dyn-1')
})
it('leaves cordis_define to its own keyed toolview', () => {
// ui-cordis registers a keyed `tool.call.toolview` entry for cordis_define,
// and a keyed hit replaces the generic row (this model is only reached
// through the dispatch fallback). A mapping here would be unreachable, and a
// title here would be a second answer to what the card already renders.
const model = toolRowModel('cordis_define', running({ name: 'cordis_define', argsRaw: '{"name":"clock"}' }))
expect(model.variant).toBe('others')
expect(model.title).toBe('Tool call')
})
it('has dropped the v2 mount verbs that no longer exist', () => {
// Keeping them would be a mapping for a tool nothing can call.
expect(classifyTool('cordis_mount')).toBe('others')
expect(toolRowModel('cordis_mount', running({ name: 'cordis_mount', argsRaw: '{}' })).title).toBe('Tool call')
expect(toolRowModel('cordis_unmount', running({ name: 'cordis_unmount', argsRaw: '{}' })).title).toBe('Tool call')
})
it('gives the pwsh shell row the bash family treatment with its own title', () => {
const m = toolRowModel('pwsh', running())
expect(m.variant).toBe('bash')
@@ -141,28 +173,27 @@ describe('tool-call-model', () => {
})
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
expect(toolRowModel('cordis_inspect', running({
name: 'cordis_inspect',
expect(toolRowModel('cordis_runtime_inspect', running({
name: 'cordis_runtime_inspect',
argsRaw: '{"what":"api","name":"tools"}',
}))).toMatchObject({
variant: 'read',
title: 'Inspect',
summary: 'api',
})
expect(toolRowModel('cordis_mount', running({
name: 'cordis_mount',
argsRaw: '{"code":"return { name: \\"audit\\", apply(ctx) {} }"}',
}))).toMatchObject({
variant: 'code',
title: 'Mount temporary Plugin',
summary: 'return { name: "audit", apply(ctx) {} }',
body: 'return { name: "audit", apply(ctx) {} }',
})
expect(toolRowModel('cordis_unmount', result({
call: { name: 'cordis_unmount', argsRaw: '{"id":"dyn-2"}' },
expect(toolRowModel('cordis_run', running({
name: 'cordis_run',
argsRaw: '{"id":"dyn-2"}',
}))).toMatchObject({
variant: 'others',
title: 'Unmount temporary Plugin',
title: 'Run dynamic package',
summary: 'dyn-2',
})
expect(toolRowModel('cordis_undefine', result({
call: { name: 'cordis_undefine', argsRaw: '{"id":"dyn-2"}' },
}))).toMatchObject({
variant: 'others',
title: 'Discard dynamic package',
summary: 'dyn-2',
})
})

View File

@@ -10,7 +10,7 @@
// the declaration then land through slots.inject when the chat entry appears.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent } from '@testing-library/react'
import { cleanup } from '@testing-library/react'
import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
@@ -106,22 +106,25 @@ describe('keyed toolview hole through the real machinery', () => {
})
it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
const code = 'return { name: "audit", apply(ctx) {} }'
const b = await bench([
toolResult(3, 'cordis-1', 'cordis_inspect', '{"what":"api","name":"tools"}'),
toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
toolResult(3, 'cordis-1', 'cordis_runtime_inspect', '{"what":"api","name":"tools"}'),
toolResult(4, 'cordis-2', 'cordis_run', '{"id":"dyn-2"}'),
toolResult(5, 'cordis-3', 'cordis_stop', '{"id":"dyn-2"}'),
toolResult(6, 'cordis-4', 'cordis_undefine', '{"id":"dyn-2"}'),
])
const view = b.runtime.renderRoot()
expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
const mounted = view.container.querySelector('[data-variant="code"]')
expect(mounted?.textContent).toContain(`Mount temporary Plugin${code}`)
expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
// Every one of these rows is user-visible on each model define/run, so each
// names its act and carries the package id rather than falling back to the
// generic "Tool call · <name> · <id>" row.
const rowText = (name: string) => view.container.querySelector(`[data-tool="${name}"]`)?.textContent
expect(rowText('cordis_runtime_inspect')).toContain('Inspect')
expect(rowText('cordis_run')).toContain('Run dynamic packagedyn-2')
expect(rowText('cordis_stop')).toContain('Stop dynamic packagedyn-2')
expect(rowText('cordis_undefine')).toContain('Discard dynamic packagedyn-2')
// No run-control verb is a code row; the program is cordis_define's, and its
// own keyed card owns that rendering.
expect(view.container.querySelector('[data-variant="code"]')).toBeNull()
await b.runtime.dispose()
})