• feat(self-modification): add dynamic Cordis plugin runtime and UI
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user