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' 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' }] } } function fakeCtrl() { const setData = vi.fn(async () => ({})) const saveData = vi.fn(async () => ({})) return { setData, saveData, ctrl: { store: { get: () => ({ selected: 'n1', blocks: [{ id: 'n1', type: 'hero', depth: 0 }] }) }, block: (id: string) => (id === 'n1' ? { id } : undefined), select: vi.fn(), schemaOf: () => schema, node: async () => node, schema: async () => schema, labelFor: () => 'Hero', setData, saveData, } as any, } } const scripted = (replies: string[]): Engine => ({ generate: async () => replies.shift() ?? 'done' }) 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_selected_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_page_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('No block') }) 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' }) 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_page_outline","arguments":{}}']) o?.onToken?.(t) return 'Let me look.{"name":"get_page_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('