ai assistant
This commit is contained in:
commit
a4ed99f13e
9 files changed
+594
-256
No files matched your search
+103
-10
@@ -1,11 +1,19 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { parseToolCall, runAgent, visibleText } from '../src/ai/agent'
|
||||
import type { Engine } from '../src/ai/engine'
|
||||
import { applyProposal, createTools } from '../src/ai/tools'
|
||||
import { applyProposal, createTools as makeTools } from '../src/ai/tools'
|
||||
import { createScope } from '../src/ai/scope'
|
||||
import { Conversations } from '../src/ai/conversations'
|
||||
|
||||
const schema = { type: 'object', properties: { title: { type: 'string', maxLength: 20 }, links: { type: 'array', items: { type: 'object', properties: { text: { type: 'string' } } } } } }
|
||||
const node = { id: 'n1', dataType: 'hero', version: 3, data: { title: 'Hello wrold', links: [{ text: 'a' }] } }
|
||||
|
||||
const blocks: any[] = [
|
||||
{ id: 'n1', type: 'hero', depth: 0 },
|
||||
{ id: 'n2', type: 'text', depth: 1, parentId: 'n1' },
|
||||
{ id: 'n3', type: 'text', depth: 0 },
|
||||
]
|
||||
|
||||
function fakeCtrl() {
|
||||
const setData = vi.fn(async () => ({}))
|
||||
const saveData = vi.fn(async () => ({}))
|
||||
@@ -13,11 +21,11 @@ function fakeCtrl() {
|
||||
setData,
|
||||
saveData,
|
||||
ctrl: {
|
||||
store: { get: () => ({ selected: 'n1', blocks: [{ id: 'n1', type: 'hero', depth: 0 }] }) },
|
||||
block: (id: string) => (id === 'n1' ? { id } : undefined),
|
||||
store: { get: () => ({ selected: 'n1', blocks }) },
|
||||
block: (id: string) => blocks.find((b) => b.id === id),
|
||||
select: vi.fn(),
|
||||
schemaOf: () => schema,
|
||||
node: async () => node,
|
||||
node: async (id: string) => (id === 'n1' ? node : { id, dataType: 'text', version: 1, data: { title: 'child ' + id } }),
|
||||
schema: async () => schema,
|
||||
labelFor: () => 'Hero',
|
||||
setData,
|
||||
@@ -27,6 +35,8 @@ function fakeCtrl() {
|
||||
}
|
||||
const scripted = (replies: string[]): Engine => ({ generate: async () => replies.shift() ?? 'done' })
|
||||
|
||||
const createTools = (h: { ctrl: any; propose: (p: any) => void; root?: string | null }) => makeTools({ ctrl: h.ctrl, propose: h.propose, scope: createScope(h.ctrl, h.root === undefined ? 'n1' : h.root) })
|
||||
|
||||
describe('parseToolCall', () => {
|
||||
it('reads tagged, unclosed and fenced calls; plain text is not a call', () => {
|
||||
expect(parseToolCall('<tool_call>{"name":"a","arguments":{"x":1}}</tool_call>')).toEqual({ name: 'a', arguments: { x: 1 } })
|
||||
@@ -44,7 +54,7 @@ describe('runAgent + tools', () => {
|
||||
const tools = createTools({ ctrl, propose: (p) => proposals.push(p) })
|
||||
const out = await runAgent(
|
||||
scripted([
|
||||
'<tool_call>{"name":"get_selected_node","arguments":{}}</tool_call>',
|
||||
'<tool_call>{"name":"get_node","arguments":{}}</tool_call>',
|
||||
'<tool_call>{"name":"propose_set","arguments":{"path":"title","value":"Hello world"}}</tool_call>',
|
||||
'Fixed the typo.',
|
||||
]),
|
||||
@@ -77,7 +87,7 @@ describe('runAgent + tools', () => {
|
||||
})
|
||||
it('stops after maxSteps', async () => {
|
||||
const { ctrl } = fakeCtrl()
|
||||
const loop: Engine = { generate: async () => '<tool_call>{"name":"get_page_outline","arguments":{}}</tool_call>' }
|
||||
const loop: Engine = { generate: async () => '<tool_call>{"name":"get_outline","arguments":{}}</tool_call>' }
|
||||
expect(await runAgent(loop, 's', [], createTools({ ctrl, propose() {} }), { maxSteps: 2 })).toContain('too many steps')
|
||||
})
|
||||
})
|
||||
@@ -102,13 +112,13 @@ describe('more tools', () => {
|
||||
const t = createTools({ ctrl, propose() {} }).find((x) => x.name === 'select_block')!
|
||||
await t.run({ id: 'n1' })
|
||||
expect(ctrl.select).toHaveBeenCalledWith('n1', { scroll: true })
|
||||
await expect(t.run({ id: 'zzz' })).rejects.toThrow('No block')
|
||||
await expect(t.run({ id: 'zzz' })).rejects.toThrow('outside the current scope')
|
||||
})
|
||||
it('propose_remove queues a removal; applyProposal removes', async () => {
|
||||
const { ctrl } = fakeCtrl()
|
||||
ctrl.remove = vi.fn(async () => ({}))
|
||||
const got: any[] = []
|
||||
await createTools({ ctrl, propose: (p) => got.push(p) }).find((x) => x.name === 'propose_remove')!.run({ id: 'n1' })
|
||||
await createTools({ ctrl, propose: (p) => got.push(p), root: null }).find((x) => x.name === 'propose_remove')!.run({ id: 'n1' })
|
||||
expect(got[0]).toMatchObject({ kind: 'remove', nodeId: 'n1', summary: 'Hello wrold' })
|
||||
await applyProposal(ctrl, { ...got[0], id: 1 })
|
||||
expect(ctrl.remove).toHaveBeenCalledWith('n1')
|
||||
@@ -121,8 +131,8 @@ describe('streaming', () => {
|
||||
const seen: string[] = []
|
||||
const engine: Engine = {
|
||||
generate: async (_m, o) => {
|
||||
for (const t of ['Let me ', 'look.<tool', '_call>{"name":"get_page_outline","arguments":{}}</tool_call>']) o?.onToken?.(t)
|
||||
return 'Let me look.<tool_call>{"name":"get_page_outline","arguments":{}}</tool_call>'
|
||||
for (const t of ['Let me ', 'look.<tool', '_call>{"name":"get_outline","arguments":{}}</tool_call>']) o?.onToken?.(t)
|
||||
return 'Let me look.<tool_call>{"name":"get_outline","arguments":{}}</tool_call>'
|
||||
},
|
||||
}
|
||||
let n = 0
|
||||
@@ -133,3 +143,86 @@ describe('streaming', () => {
|
||||
expect(seen.every((s) => !s.includes('<tool'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scope', () => {
|
||||
it('contains the root and descendants only; page scope contains every block', () => {
|
||||
const { ctrl } = fakeCtrl()
|
||||
const sc = createScope(ctrl, 'n1')
|
||||
expect(sc.ids()).toEqual(['n1', 'n2'])
|
||||
expect(sc.contains('n3')).toBe(false)
|
||||
expect(sc.key).toBe('n1')
|
||||
const page = createScope(ctrl, null)
|
||||
expect(page.key).toBe('page')
|
||||
expect(page.ids()).toEqual(['n1', 'n2', 'n3'])
|
||||
})
|
||||
it('tools refuse blocks outside the scope and default to the root', async () => {
|
||||
const { ctrl } = fakeCtrl()
|
||||
const tools = createTools({ ctrl, propose() {} })
|
||||
const get = tools.find((t) => t.name === 'get_node')!
|
||||
expect((await get.run({}) as any).id).toBe('n1')
|
||||
expect((await get.run({ id: 'n2' }) as any).id).toBe('n2')
|
||||
await expect(get.run({ id: 'n3' })).rejects.toThrow('outside the current scope')
|
||||
const outline = (await tools.find((t) => t.name === 'get_outline')!.run({})) as any[]
|
||||
expect(outline.map((b) => b.id)).toEqual(['n1', 'n2'])
|
||||
await expect(createTools({ ctrl, propose() {}, root: null }).find((t) => t.name === 'get_node')!.run({})).rejects.toThrow('whole page')
|
||||
})
|
||||
it('propose_remove cannot delete the scope root but can delete children', async () => {
|
||||
const { ctrl } = fakeCtrl()
|
||||
const got: any[] = []
|
||||
const rm = createTools({ ctrl, propose: (p) => got.push(p) }).find((t) => t.name === 'propose_remove')!
|
||||
await expect(rm.run({ id: 'n1' })).rejects.toThrow('scope root')
|
||||
await rm.run({ id: 'n2' })
|
||||
expect(got[0]).toMatchObject({ kind: 'remove', nodeId: 'n2' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('style tools', () => {
|
||||
const gap = { name: 'gap', label: 'Gap', kind: 'gap', options: ['none', 'sm', 'md', 'lg'], responsive: ['mobile'] }
|
||||
const styled = () => {
|
||||
const { ctrl } = fakeCtrl()
|
||||
ctrl.styleFields = () => [gap]
|
||||
ctrl.doc = { defaultView: { innerWidth: 390 } }
|
||||
ctrl.setStyle = vi.fn(async () => ({}))
|
||||
node.data = { ...node.data, gap: 'lg', responsive: { mobile: { gap: 'md' } } } as any
|
||||
return ctrl
|
||||
}
|
||||
it('get_style reports value and per-breakpoint overrides', async () => {
|
||||
const ctrl = styled()
|
||||
const r = (await createTools({ ctrl, propose() {} }).find((t) => t.name === 'get_style')!.run({})) as any
|
||||
expect(r.viewportWidth).toBe(390)
|
||||
expect(r.fields[0]).toMatchObject({ name: 'gap', value: 'lg', breakpoints: { mobile: 'md' } })
|
||||
})
|
||||
it('propose_style validates options and breakpoints, then queues and applies with bp', async () => {
|
||||
const ctrl = styled()
|
||||
const got: any[] = []
|
||||
const t = createTools({ ctrl, propose: (p) => got.push(p) }).find((x) => x.name === 'propose_style')!
|
||||
await expect(t.run({ field: 'gap', value: 'huge', breakpoint: 'mobile' })).rejects.toThrow('must be one of')
|
||||
await expect(t.run({ field: 'gap', value: 'sm', breakpoint: 'tablet' })).rejects.toThrow('no tablet override')
|
||||
await expect(t.run({ field: 'nope', value: 'sm' })).rejects.toThrow('Unknown style field')
|
||||
await t.run({ field: 'gap', value: 'sm', breakpoint: 'mobile' })
|
||||
expect(got[0]).toMatchObject({ kind: 'style', field: 'gap', bp: 'mobile', before: 'md', after: 'sm' })
|
||||
await applyProposal(ctrl, { ...got[0], id: 1 })
|
||||
expect(ctrl.setStyle).toHaveBeenCalledWith('n1', gap, 'sm', { bp: 'mobile' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversations', () => {
|
||||
it('keeps separate histories per scope key and notifies subscribers', () => {
|
||||
const c = new Conversations()
|
||||
const spy = vi.fn()
|
||||
c.subscribe(spy)
|
||||
c.push('n1', { kind: 'user', text: 'a' })
|
||||
c.push('n2', { kind: 'user', text: 'b' })
|
||||
expect(c.get('n1').items).toHaveLength(1)
|
||||
expect(c.get('n2').items[0]).toMatchObject({ text: 'b' })
|
||||
expect(c.get('n1')).toBe(c.get('n1')) // stable snapshot
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
const ctl = new AbortController()
|
||||
c.update('n1', () => ({ abort: ctl, busy: 'x' }))
|
||||
expect(c.working()).toEqual(['n1'])
|
||||
c.clear('n1')
|
||||
expect(ctl.signal.aborted).toBe(true)
|
||||
expect(c.get('n1').items).toHaveLength(0)
|
||||
expect(c.get('n2').items).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user