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 () => ({})) const saveData = vi.fn(async () => ({})) return { setData, saveData, 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, saveData, } 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('{"name":"a","arguments":{"x":1}}')).toEqual({ name: 'a', arguments: { x: 1 } }) expect(parseToolCall('{"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('{oops')).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([ '{"name":"get_node","arguments":{}}', '{"name":"propose_set","arguments":{"path":"title","value":"Hello world"}}', '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 ? '{"name":"propose_set","arguments":{"path":"title","value":"this title is far too long for the limit"}}' : seen.length === 2 ? '{"name":"propose_set","arguments":{"path":"nope","value":"x"}}' : '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 () => '{"name":"get_outline","arguments":{}}' } 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 it through the key-level conflict check', async () => { const { ctrl, saveData } = fakeCtrl() await applyProposal(ctrl, { id: 1, kind: 'set', nodeId: 'n1', label: 'Hero', path: 'links.0.text', before: 'a', after: 'b' }) expect(saveData).toHaveBeenCalledWith('n1', node.data, { links: [{ text: 'b' }] }) }) }) 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.{"name":"get_outline","arguments":{}}']) o?.onToken?.(t) return 'Let me look.{"name":"get_outline","arguments":{}}' }, } 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(' { 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) }) })