226 lines
11 KiB
TypeScript
226 lines
11 KiB
TypeScript
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 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 () => ({}))
|
|
return {
|
|
setData,
|
|
ctrl: {
|
|
store: { get: () => ({ selected: 'n1', blocks }) },
|
|
block: (id: string) => blocks.find((b) => b.id === id),
|
|
select: vi.fn(),
|
|
schemaOf: () => schema,
|
|
node: async (id: string) => (id === 'n1' ? node : { id, dataType: 'text', version: 1, data: { title: 'child ' + id } }),
|
|
schema: async () => schema,
|
|
labelFor: () => 'Hero',
|
|
setData,
|
|
} as any,
|
|
}
|
|
}
|
|
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 } })
|
|
expect(parseToolCall('<tool_call>{"name":"a"}')).toEqual({ name: 'a', arguments: {} })
|
|
expect(parseToolCall('```json\n{"name":"b","arguments":{}}\n```')?.name).toBe('b')
|
|
expect(parseToolCall('Just a sentence.')).toBeNull()
|
|
expect(parseToolCall('<tool_call>{oops</tool_call>')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('runAgent + tools', () => {
|
|
it('reads the node, queues a validated proposal, and finishes with text', async () => {
|
|
const { ctrl } = fakeCtrl()
|
|
const proposals: any[] = []
|
|
const tools = createTools({ ctrl, propose: (p) => proposals.push(p) })
|
|
const out = await runAgent(
|
|
scripted([
|
|
'<tool_call>{"name":"get_node","arguments":{}}</tool_call>',
|
|
'<tool_call>{"name":"propose_set","arguments":{"path":"title","value":"Hello world"}}</tool_call>',
|
|
'Fixed the typo.',
|
|
]),
|
|
'sys',
|
|
[{ role: 'user', content: 'fix spelling' }],
|
|
tools,
|
|
)
|
|
expect(out).toBe('Fixed the typo.')
|
|
expect(proposals).toEqual([{ kind: 'set', nodeId: 'n1', label: 'Hero', path: 'title', before: 'Hello wrold', after: 'Hello world' }])
|
|
})
|
|
it('rejects schema-invalid proposals and unknown fields, feeding the error back', async () => {
|
|
const { ctrl } = fakeCtrl()
|
|
const proposals: any[] = []
|
|
const tools = createTools({ ctrl, propose: (p) => proposals.push(p) })
|
|
const seen: string[] = []
|
|
const engine: Engine = {
|
|
generate: async (m) => {
|
|
seen.push(m.at(-1)!.content)
|
|
return seen.length === 1
|
|
? '<tool_call>{"name":"propose_set","arguments":{"path":"title","value":"this title is far too long for the limit"}}</tool_call>'
|
|
: seen.length === 2
|
|
? '<tool_call>{"name":"propose_set","arguments":{"path":"nope","value":"x"}}</tool_call>'
|
|
: 'ok'
|
|
},
|
|
}
|
|
await runAgent(engine, 's', [{ role: 'user', content: 'q' }], tools)
|
|
expect(proposals).toHaveLength(0)
|
|
expect(seen[1]).toContain('At most 20 characters')
|
|
expect(seen[2]).toContain('Unknown field')
|
|
})
|
|
it('stops after maxSteps', async () => {
|
|
const { ctrl } = fakeCtrl()
|
|
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')
|
|
})
|
|
})
|
|
|
|
describe('applyProposal', () => {
|
|
it('rebuilds the nested patch from the fresh node and saves with its version', async () => {
|
|
const { ctrl, setData } = fakeCtrl()
|
|
await applyProposal(ctrl, { id: 1, kind: 'set', nodeId: 'n1', label: 'Hero', path: 'links.0.text', before: 'a', after: 'b' })
|
|
expect(setData).toHaveBeenCalledWith('n1', { links: [{ text: 'b' }] }, { version: 3 })
|
|
})
|
|
})
|
|
|
|
describe('more tools', () => {
|
|
it('find_text matches case-insensitively with the path', async () => {
|
|
const { ctrl } = fakeCtrl()
|
|
const t = createTools({ ctrl, propose() {} }).find((x) => x.name === 'find_text')!
|
|
expect(await t.run({ query: 'WROLD' })).toEqual([{ id: 'n1', type: 'hero', path: 'title', value: 'Hello wrold' }])
|
|
await expect(t.run({})).rejects.toThrow('query')
|
|
})
|
|
it('select_block selects known blocks and refuses unknown ones', async () => {
|
|
const { ctrl } = fakeCtrl()
|
|
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('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), 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')
|
|
})
|
|
})
|
|
|
|
describe('streaming', () => {
|
|
it('forwards tokens and hides tool-call markup', async () => {
|
|
const { ctrl } = fakeCtrl()
|
|
const seen: string[] = []
|
|
const engine: Engine = {
|
|
generate: async (_m, o) => {
|
|
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
|
|
const wrapped: Engine = { generate: async (m, o) => (n++ ? 'Done.' : engine.generate(m, o)) }
|
|
await runAgent(wrapped, 's', [], createTools({ ctrl, propose() {} }), { onText: (p) => seen.push(visibleText(p)) })
|
|
expect(seen).toContain('Let me')
|
|
expect(seen).toContain('Let me look.')
|
|
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)
|
|
})
|
|
})
|