initial ai assistance
This commit is contained in:
1 parent
1af9ec3172
commit
2e0bf1e88e
24 files changed
+1293
-60
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanReply, rewriteMessages } from '../src/ai/rewrite'
|
||||
|
||||
const generate = vi.fn()
|
||||
vi.mock('../src/ai/engine', () => ({
|
||||
aiSupported: () => true,
|
||||
getEngine: async () => ({ generate }),
|
||||
}))
|
||||
const { RewriteButton } = await import('../src/ai/RewriteButton')
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
generate.mockReset()
|
||||
})
|
||||
|
||||
describe('rewrite prompts', () => {
|
||||
it('carries the task, text, format and length limit', () => {
|
||||
const m = rewriteMessages('shorten', 'Hello world', { 'x-editor': 'markdown', maxLength: 50 }, 'Intro')
|
||||
expect(m[0].content).toContain('Markdown')
|
||||
expect(m[0].content).toContain('50 characters')
|
||||
expect(m[1].content).toContain('Field: Intro')
|
||||
expect(m[1].content).toContain('Hello world')
|
||||
})
|
||||
it('strips fences/quotes and enforces maxLength', () => {
|
||||
expect(cleanReply('```\nHi there\n```', {})).toBe('Hi there')
|
||||
expect(cleanReply('"Hi there"', {})).toBe('Hi there')
|
||||
expect(cleanReply('abcdefghij', { maxLength: 4 })).toBe('abcd')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RewriteButton', () => {
|
||||
it('only writes after Accept', async () => {
|
||||
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' } })
|
||||
await screen.findByLabelText('AI suggestion')
|
||||
expect(onAccept).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText('Accept'))
|
||||
expect(onAccept).toHaveBeenCalledWith('Better text')
|
||||
})
|
||||
it('Reject leaves the value alone', async () => {
|
||||
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(await screen.findByText('Reject'))
|
||||
expect(onAccept).not.toHaveBeenCalled()
|
||||
await waitFor(() => screen.getByLabelText('Rewrite with AI'))
|
||||
})
|
||||
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' } })
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('boom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
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 () => ({}))
|
||||
return {
|
||||
setData,
|
||||
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,
|
||||
} 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('<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_selected_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_page_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('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.<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>'
|
||||
},
|
||||
}
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -145,6 +145,8 @@ describe('the property editor uses icons, not text glyphs', () => {
|
||||
}
|
||||
expect(q('[aria-label="Add entry"] svg')).not.toBeNull()
|
||||
expect(q('.cms-group > summary svg.cms-chev')).not.toBeNull()
|
||||
expect(q('.cms-actions')).toBeNull() // nothing changed yet: no footer
|
||||
await act(async () => click(q('[aria-label="Add item"]')!))
|
||||
expect(q('.cms-actions .cms-btn--primary svg')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user