• feat(self-modification): add dynamic Cordis plugin runtime and UI
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
// Card view model: what a definition card can and cannot derive from the frozen
|
||||
// call/result slice.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { cordisDefineCard } from '../src/client/card-model.ts'
|
||||
|
||||
const ARGS = '{"name":"clock","purpose":"顶栏时钟","code":{"client":"return {}","host":"harness.handle(\'now\', () => Date.now())"}}'
|
||||
|
||||
function running(over: Partial<RunningToolCall> = {}): RunningToolCall {
|
||||
return {
|
||||
callId: 'call-1', name: 'cordis_define', argsRaw: ARGS, turn: 1, step: 1, time: 1_000,
|
||||
callView: null, subCalls: [], ...over,
|
||||
}
|
||||
}
|
||||
|
||||
function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
|
||||
return {
|
||||
kind: 'tool-result', seq: 2, time: 2_000, callId: 'call-1',
|
||||
call: { name: 'cordis_define', argsRaw: ARGS }, callTime: 1_000,
|
||||
content: [{ type: 'text', text: 'defined dyn-1' }], isError: false,
|
||||
meta: { pluginId: 'dyn-1', packageId: 'pkg-1' }, callView: null, resultView: null, subCalls: [], ...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('cordisDefineCard', () => {
|
||||
it('reads name, purpose and both code halves off the call arguments', () => {
|
||||
const card = cordisDefineCard(running())
|
||||
expect(card).toMatchObject({
|
||||
name: 'clock', purpose: '顶栏时钟', clientCode: 'return {}', state: 'running', output: null,
|
||||
})
|
||||
expect(card.hostCode).toContain('harness.handle')
|
||||
// The host mints the id during define, so an unsettled call has none and the
|
||||
// card renders read-only.
|
||||
expect(card.pluginId).toBeNull()
|
||||
expect(card.packageId).toBeNull()
|
||||
})
|
||||
|
||||
it('takes the minted id from the result presentation meta', () => {
|
||||
expect(cordisDefineCard(settled()).pluginId).toBe('dyn-1')
|
||||
expect(cordisDefineCard(settled()).packageId).toBe('pkg-1')
|
||||
expect(cordisDefineCard(settled()).output).toBe('defined dyn-1')
|
||||
expect(cordisDefineCard(settled()).state).toBe('ok')
|
||||
})
|
||||
|
||||
it('renders read-only when the meta carries no usable id', () => {
|
||||
expect(cordisDefineCard(settled({ meta: undefined })).pluginId).toBeNull()
|
||||
expect(cordisDefineCard(settled({ meta: 'dyn-1' })).pluginId).toBeNull()
|
||||
expect(cordisDefineCard(settled({ meta: { pluginId: '' } })).pluginId).toBeNull()
|
||||
expect(cordisDefineCard(settled({ meta: { pluginId: 7 } })).pluginId).toBeNull()
|
||||
})
|
||||
|
||||
it('classifies the define call’s own lifecycle and never operates a failed one', () => {
|
||||
const failed = cordisDefineCard(settled({
|
||||
isError: true, content: [{ type: 'text', text: 'SyntaxError: unexpected token\n at line 3' }],
|
||||
}))
|
||||
expect(failed.state).toBe('error')
|
||||
expect(failed.errorSummary).toBe('SyntaxError: unexpected token')
|
||||
// A definition that failed to register has nothing to run.
|
||||
expect(failed.pluginId).toBeNull()
|
||||
|
||||
expect(cordisDefineCard(settled({ isError: true, error: { name: 'E', code: 'interrupted' } })).state).toBe('stopped')
|
||||
expect(cordisDefineCard(settled({ content: [] })).output).toBeNull()
|
||||
expect(cordisDefineCard(settled({ content: [], error: { name: 'E', code: 'boom' } })).output).toBe('E: boom')
|
||||
// A non-text block has no display text of its own, so the row shows its JSON.
|
||||
expect(cordisDefineCard(settled({ content: [{ type: 'reasoning', text: 'weighing it' }] })).output)
|
||||
.toContain('"type": "reasoning"')
|
||||
})
|
||||
|
||||
it('degrades on a truncated argument stream instead of dropping the row', () => {
|
||||
expect(cordisDefineCard(running({ argsRaw: '{"name":"clo' })).name).toBe('{"name":"clo')
|
||||
expect(cordisDefineCard(running({ argsRaw: '{"name":"clo' })).purpose).toBeNull()
|
||||
expect(cordisDefineCard(running({ argsRaw: '"just a string"' })).name).toBe('"just a string"')
|
||||
})
|
||||
|
||||
it('keeps the raw first line as the name when the arguments carry none', () => {
|
||||
expect(cordisDefineCard(running({ argsRaw: '{"purpose":"顶栏时钟"}' })).name).toBe('{"purpose":"顶栏时钟"}')
|
||||
expect(cordisDefineCard(running({ argsRaw: '{"name":"","purpose":"顶栏时钟"}' })).name).toBe('{"name":"","purpose":"顶栏时钟"}')
|
||||
})
|
||||
|
||||
it('reports an unknown name when the event window cut the call head', () => {
|
||||
// The host definition list answers identity and run state only, so the card
|
||||
// has no label left to fall back on and names its own call instead.
|
||||
const card = cordisDefineCard(settled({ call: null }))
|
||||
expect(card.name).toBeNull()
|
||||
expect(card.purpose).toBeNull()
|
||||
})
|
||||
})
|
||||
142
packages/extensions/ui-cordis/tests/inventory.client.spec.ts
Normal file
142
packages/extensions/ui-cordis/tests/inventory.client.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// The inventory store: how the panel's rows arrive, what a failed read leaves
|
||||
// behind, and why a read is single-flight.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createCordisInventory } from '../src/client/inventory.ts'
|
||||
import type { CordisDynamicPort, CordisInventoryRow } from '../src/client/dynamic-port.ts'
|
||||
|
||||
const ROW = {
|
||||
id: 'dyn-1', name: 'clock', purpose: '顶栏时钟', agentId: 'sess-1', running: true,
|
||||
} as unknown as CordisInventoryRow
|
||||
|
||||
/** A port whose inventory answer the test controls. */
|
||||
function port(answer: () => Promise<readonly CordisInventoryRow[]>): { port: CordisDynamicPort; reads: () => number } {
|
||||
let reads = 0
|
||||
return {
|
||||
port: {
|
||||
inventory: () => { reads += 1; return answer() },
|
||||
stop: () => Promise.reject(new Error('unused')),
|
||||
remove: () => Promise.reject(new Error('unused')),
|
||||
},
|
||||
reads: () => reads,
|
||||
}
|
||||
}
|
||||
|
||||
describe('reading the registry', () => {
|
||||
it('starts unread, then publishes the rows', async () => {
|
||||
const seam = port(() => Promise.resolve([ROW]))
|
||||
const inventory = createCordisInventory(seam.port, vi.fn())
|
||||
// Unread is not empty: the panel must not claim "nothing defined" before a
|
||||
// read settles.
|
||||
expect(inventory.getSnapshot()).toEqual({ rows: [], removed: new Set(), read: false })
|
||||
|
||||
const seen = vi.fn()
|
||||
const off = inventory.subscribe(seen)
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().read).toBe(true) })
|
||||
expect(inventory.getSnapshot().rows).toEqual([ROW])
|
||||
expect(seen).toHaveBeenCalled()
|
||||
|
||||
off()
|
||||
const before = seen.mock.calls.length
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(seam.reads()).toBe(2) })
|
||||
expect(seen.mock.calls.length).toBe(before)
|
||||
})
|
||||
|
||||
it('is single-flight: concurrent triggers read once', async () => {
|
||||
let release: ((rows: readonly CordisInventoryRow[]) => void) | undefined
|
||||
const seam = port(() => new Promise((resolve) => { release = resolve }))
|
||||
const inventory = createCordisInventory(seam.port, vi.fn())
|
||||
inventory.refresh()
|
||||
inventory.refresh()
|
||||
inventory.refresh()
|
||||
expect(seam.reads()).toBe(1)
|
||||
release?.([ROW])
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().read).toBe(true) })
|
||||
// The slot frees once it settles, so the next trigger reads again.
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(seam.reads()).toBe(2) })
|
||||
})
|
||||
|
||||
it('keeps the rows it had when a read fails, and says why', async () => {
|
||||
let fail = false
|
||||
const seam = port(() => (fail ? Promise.reject(new Error('socket closed')) : Promise.resolve([ROW])))
|
||||
const onError = vi.fn()
|
||||
const inventory = createCordisInventory(seam.port, onError)
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().read).toBe(true) })
|
||||
|
||||
fail = true
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().error).toBeDefined() })
|
||||
// Dropping the rows would turn a transient wire failure into "nothing is
|
||||
// defined", which is a different and wrong statement.
|
||||
expect(inventory.getSnapshot().rows).toEqual([ROW])
|
||||
expect(inventory.getSnapshot().read).toBe(true)
|
||||
expect(inventory.getSnapshot().error).toBe('socket closed')
|
||||
expect(onError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a non-Error rejection without inventing a message', async () => {
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario.
|
||||
const seam = port(() => Promise.reject('nope'))
|
||||
const inventory = createCordisInventory(seam.port, vi.fn())
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().error).toBeDefined() })
|
||||
expect(inventory.getSnapshot().error).toBe('reading the cordis inventory failed')
|
||||
})
|
||||
|
||||
it('forgets everything on reset, because the next host may be a new process', async () => {
|
||||
const seam = port(() => Promise.resolve([ROW]))
|
||||
const inventory = createCordisInventory(seam.port, vi.fn())
|
||||
inventory.refresh()
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().read).toBe(true) })
|
||||
inventory.reset()
|
||||
expect(inventory.getSnapshot()).toEqual({ rows: [], removed: new Set(), read: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a reconnect while a read is in flight', () => {
|
||||
it('discards the previous connection’s answer and lets the fresh read through', async () => {
|
||||
// Each read gets its own resolver, so the test can settle the stale one only.
|
||||
const releases: ((rows: readonly CordisInventoryRow[]) => void)[] = []
|
||||
const seam = port(() => new Promise((resolve) => { releases.push(resolve) }))
|
||||
const inventory = createCordisInventory(seam.port, vi.fn())
|
||||
inventory.refresh()
|
||||
expect(seam.reads()).toBe(1)
|
||||
|
||||
// The in-flight read belongs to the host we just left; a reset frees the slot
|
||||
// so the fresh read is not swallowed by it.
|
||||
inventory.reset()
|
||||
inventory.refresh()
|
||||
expect(seam.reads()).toBe(2)
|
||||
|
||||
// The stale answer arriving late must not repopulate what reset cleared.
|
||||
releases[0]?.([ROW])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(inventory.getSnapshot()).toEqual({ rows: [], removed: new Set(), read: false })
|
||||
|
||||
// The fresh read still lands.
|
||||
releases[1]?.([ROW])
|
||||
await vi.waitFor(() => { expect(inventory.getSnapshot().read).toBe(true) })
|
||||
expect(inventory.getSnapshot().rows).toEqual([ROW])
|
||||
})
|
||||
|
||||
it('swallows a stale read’s failure too, rather than blaming the new connection', async () => {
|
||||
const rejects: ((reason: unknown) => void)[] = []
|
||||
const seam = port(() => new Promise((_resolve, reject) => { rejects.push(reject) }))
|
||||
const onError = vi.fn()
|
||||
const inventory = createCordisInventory(seam.port, onError)
|
||||
inventory.refresh()
|
||||
inventory.reset()
|
||||
|
||||
rejects[0]?.(new Error('socket closed'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
// The failure belongs to a connection nobody is looking at any more.
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(inventory.getSnapshot()).toEqual({ rows: [], removed: new Set(), read: false })
|
||||
})
|
||||
})
|
||||
133
packages/extensions/ui-cordis/tests/versioning.client.spec.ts
Normal file
133
packages/extensions/ui-cordis/tests/versioning.client.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DynamicCordisLivePackage } from '@deepseek-ai/dsh-cordis-client-runner/client'
|
||||
import type {
|
||||
CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
DynamicCordisInventoryRow,
|
||||
} from '../src/client/events.ts'
|
||||
import { cordisDefineCard, cordisRunCard } from '../src/client/card-model.ts'
|
||||
import { CordisRunCardRegistry, cordisToolViewKey } from '../src/client/run-card-index.ts'
|
||||
import { cordisVisibleStatus } from '../src/client/status.ts'
|
||||
|
||||
const PLUGIN = 'clock-1' as CordisDynamicPluginId
|
||||
const PACKAGE = 'pkg-1' as CordisDynamicPackageId
|
||||
const RUN = 'run-1' as CordisDynamicPluginRunId
|
||||
|
||||
const row = (client: boolean): DynamicCordisInventoryRow => ({
|
||||
pluginId: PLUGIN,
|
||||
agentId: 'session-1' as DynamicCordisInventoryRow['agentId'],
|
||||
packages: [{
|
||||
packageId: PACKAGE,
|
||||
name: 'Clock',
|
||||
purpose: 'show time',
|
||||
hasHostHalf: true,
|
||||
hasClientHalf: client,
|
||||
}],
|
||||
currentPackageId: PACKAGE,
|
||||
activeRun: { packageId: PACKAGE, pluginRunId: RUN },
|
||||
})
|
||||
|
||||
describe('versioned Cordis card models', () => {
|
||||
it('reads symmetric Host and Client source fields from cordis_define', () => {
|
||||
const card = cordisDefineCard({
|
||||
callId: 'call-1',
|
||||
name: 'cordis_define',
|
||||
argsRaw: JSON.stringify({
|
||||
plugin: { kind: 'new', idPrefix: 'clock' },
|
||||
name: 'Clock',
|
||||
purpose: 'show time',
|
||||
code: { host: 'HOST_CODE', client: 'CLIENT_CODE' },
|
||||
}),
|
||||
turn: 1,
|
||||
step: 1,
|
||||
time: 1,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
})
|
||||
|
||||
expect(card).toMatchObject({
|
||||
pluginId: null,
|
||||
packageId: null,
|
||||
hostCode: 'HOST_CODE',
|
||||
clientCode: 'CLIENT_CODE',
|
||||
state: 'running',
|
||||
})
|
||||
})
|
||||
|
||||
it('reads exact activation metadata from a successful cordis_run result', () => {
|
||||
const card = cordisRunCard({
|
||||
kind: 'tool-result',
|
||||
seq: 9,
|
||||
time: 2,
|
||||
callId: 'call-2',
|
||||
call: { name: 'cordis_run', argsRaw: JSON.stringify({ pluginId: PLUGIN, packageId: PACKAGE, mode: 'run' }) },
|
||||
callTime: 1,
|
||||
content: [{ type: 'text', text: 'running' }],
|
||||
isError: false,
|
||||
meta: { pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN },
|
||||
callView: null,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
})
|
||||
|
||||
expect(card).toMatchObject({
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
pluginRunId: RUN,
|
||||
mode: 'run',
|
||||
seq: 9,
|
||||
state: 'ok',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the target identities while cordis_run waits for approval', () => {
|
||||
const card = cordisRunCard({
|
||||
callId: 'call-3',
|
||||
name: 'cordis_run',
|
||||
argsRaw: JSON.stringify({ pluginId: PLUGIN, packageId: PACKAGE, mode: 'update' }),
|
||||
turn: 1,
|
||||
step: 1,
|
||||
time: 1,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
})
|
||||
|
||||
expect(card).toMatchObject({
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
pluginRunId: null,
|
||||
mode: 'update',
|
||||
state: 'running',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cordis run-card ownership', () => {
|
||||
it('keeps the greatest Session log sequence for one Plugin and Package', () => {
|
||||
const store = new CordisRunCardRegistry().forSession('session-1' as DynamicCordisInventoryRow['agentId'])
|
||||
const changed = vi.fn()
|
||||
store.subscribe(changed)
|
||||
const key = cordisToolViewKey(PLUGIN, PACKAGE)
|
||||
|
||||
store.observe({ key, callId: 'new', seq: 20, pluginRunId: RUN })
|
||||
store.observe({ key, callId: 'old', seq: 10, pluginRunId: 'run-0' as CordisDynamicPluginRunId })
|
||||
|
||||
expect(store.getSnapshot().get(key)?.callId).toBe('new')
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cordis visible status', () => {
|
||||
it('distinguishes Host-only running, Client pending, and fully loaded', () => {
|
||||
expect(cordisVisibleStatus(row(false), PACKAGE, [])).toBe('running')
|
||||
expect(cordisVisibleStatus(row(true), PACKAGE, [])).toBe('client-pending')
|
||||
const loaded: DynamicCordisLivePackage[] = [{
|
||||
pluginId: PLUGIN,
|
||||
packageId: PACKAGE,
|
||||
pluginRunId: RUN,
|
||||
name: 'Clock',
|
||||
slots: [],
|
||||
styleCount: 0,
|
||||
}]
|
||||
expect(cordisVisibleStatus(row(true), PACKAGE, loaded)).toBe('running')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user