ai assistant

This commit is contained in:
mats committed 2026-11-01 14:57:39 +01:00
commit a4ed99f13e
9 files changed
+594 -256

No files matched your search

+6 -3
View File
@@ -34,7 +34,8 @@ describe('RewriteButton', () => {
generate.mockResolvedValue('Better text')
const onAccept = vi.fn()
render(<RewriteButton schema={{}} value="Bad txt" onAccept={onAccept} />)
fireEvent.change(screen.getByLabelText('Rewrite with AI'), { target: { value: 'fix' } })
fireEvent.click(screen.getByLabelText('Rewrite with AI'))
fireEvent.click(screen.getByText('Fix spelling & grammar'))
await screen.findByLabelText('AI suggestion')
expect(onAccept).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Accept'))
@@ -44,7 +45,8 @@ describe('RewriteButton', () => {
generate.mockResolvedValue('Other')
const onAccept = vi.fn()
render(<RewriteButton schema={{}} value="x" onAccept={onAccept} />)
fireEvent.change(screen.getByLabelText('Rewrite with AI'), { target: { value: 'improve' } })
fireEvent.click(screen.getByLabelText('Rewrite with AI'))
fireEvent.click(screen.getByText('Improve'))
fireEvent.click(await screen.findByText('Reject'))
expect(onAccept).not.toHaveBeenCalled()
await waitFor(() => screen.getByLabelText('Rewrite with AI'))
@@ -52,7 +54,8 @@ describe('RewriteButton', () => {
it('surfaces engine errors', async () => {
generate.mockRejectedValue(new Error('boom'))
render(<RewriteButton schema={{}} value="x" onAccept={() => {}} />)
fireEvent.change(screen.getByLabelText('Rewrite with AI'), { target: { value: 'improve' } })
fireEvent.click(screen.getByLabelText('Rewrite with AI'))
fireEvent.click(screen.getByText('Improve'))
expect((await screen.findByRole('alert')).textContent).toBe('boom')
})
})
+103 -10
View File
@@ -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)
})
})