返回 CodeWhale
prompt.test.mjs
根目录 / crates / tui / extension-host / test / prompt.test.mjs
1 // Author prompt contributions exercised through the committed host and CWX1.
2 import { test } from 'node:test'
3 import assert from 'node:assert/strict'
4 import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
5 import { tmpdir } from 'node:os'
6 import { join } from 'node:path'
7 import { activate, startHost } from './harness.mjs'
8
9 function plugin(t, body) {
10 const directory = mkdtempSync(join(tmpdir(), 'cw-prompt-section-'))
11 const entry = join(directory, 'index.mjs')
12 writeFileSync(entry, `export const inject = ['prompt']\nexport function apply(ctx) {\n${body}\n}\n`)
13 t.after(() => rmSync(directory, { recursive: true, force: true }))
14 return entry
15 }
16
17 test('prompt sections detach text, allow owner-local ids and dispose exact registrations', async (t) => {
18 const host = await startHost()
19 t.after(() => host.stop())
20 const entry = plugin(t, `
21 const section = { id: 'rules', text: 'First line\\nSecond line' }
22 ctx.prompt.registerSection(section)
23 section.text = 'mutated after registration'
24 `)
25 const first = await activate(host, 'first-prompt', entry)
26 const second = await activate(host, 'second-prompt', entry)
27 assert.equal(first.result.status, 'ok')
28 assert.equal(second.result.status, 'ok')
29 const registrations = host.registry.filter((item) => item.op === 'register' && item.kind === 'prompt_section')
30 assert.equal(registrations.length, 2)
31 assert.deepEqual(registrations.map((item) => item.spec), [
32 { name: 'rules', description: 'First line\nSecond line' },
33 { name: 'rules', description: 'First line\nSecond line' },
34 ])
35 assert.deepEqual(await host.call('ext/deactivate', { owner: first.ref }), { disposed: true, leaked: [] })
36 assert.ok(host.registry.some((item) => item.op === 'unregister' && item.handle === registrations[0].handle))
37 assert.ok(!host.registry.some((item) => item.op === 'unregister' && item.handle === registrations[1].handle))
38 })
39
40 test('disposing before admission permits a new same-id section without removing its handle', async (t) => {
41 const host = await startHost()
42 t.after(() => host.stop())
43 const entry = plugin(t, `
44 const dispose = ctx.prompt.registerSection({ id: 'rules', text: 'old' })
45 dispose()
46 dispose()
47 ctx.prompt.registerSection({ id: 'rules', text: 'new' })
48 `)
49 const { result, ref } = await activate(host, 'replacement-prompt', entry)
50 assert.equal(result.status, 'ok')
51 const registrations = host.registry.filter((item) => item.op === 'register' && item.kind === 'prompt_section')
52 assert.equal(registrations.length, 2)
53 assert.ok(host.registry.some((item) => item.op === 'unregister' && item.handle === registrations[0].handle))
54 assert.ok(!host.registry.some((item) => item.op === 'unregister' && item.handle === registrations[1].handle))
55 await host.call('ext/deactivate', { owner: ref })
56 assert.ok(host.registry.some((item) => item.op === 'unregister' && item.handle === registrations[1].handle))
57 })
58
59 test('invalid, duplicate and oversized UTF-8 sections fail activation and roll back', async (t) => {
60 const host = await startHost()
61 t.after(() => host.stop())
62 const cases = [
63 [`ctx.prompt.registerSection({ id: 'rules', text: ' ' })`, /non-empty text/],
64 [`ctx.prompt.registerSection({ id: 'rules', text: 'escape\\u001b[31m' })`, /control characters/],
65 [`ctx.prompt.registerSection({ id: 'rules', text: 'x', order: -1 })`, /only id and text/],
66 [`ctx.prompt.registerSection({ id: 'rules', text: '界'.repeat(1366) })`, /4096 UTF-8 bytes/],
67 [`ctx.prompt.registerSection({ id: 'rules', text: 'first' }); ctx.prompt.registerSection({ id: 'rules', text: 'second' })`, /already registered/],
68 [`for (let i = 0; i < 9; i++) ctx.prompt.registerSection({ id: 's' + i, text: 'x'.repeat(4096) })`, /byte limit/],
69 [`for (let i = 0; i < 129; i++) ctx.prompt.registerSection({ id: 's' + i, text: 'x' })`, /registration limit/],
70 ]
71 for (const [index, [body, reason]] of cases.entries()) {
72 const before = host.registry.length
73 const { result } = await activate(host, `bad-prompt-${index}`, plugin(t, body))
74 assert.equal(result.status, 'failed')
75 assert.match(result.diagnostic, reason)
76 const admitted = host.registry.slice(before).filter((item) => item.op === 'register' && item.kind === 'prompt_section')
77 for (const registration of admitted) {
78 if (!host.registry.some((item) => item.op === 'unregister' && item.handle === registration.handle)) {
79 await host.waitFor((message) => message.method === 'registry/unregister' && message.params.handle === registration.handle, 2000)
80 }
81 assert.ok(host.registry.some((item) => item.op === 'unregister' && item.handle === registration.handle), `registration ${registration.handle} rolled back`)
82 }
83 }
84 })
85
86 test('host UTF-8 budget counts pending sections across owners and recovers after teardown', async (t) => {
87 const host = await startHost()
88 t.after(() => host.stop())
89 const large = plugin(t, `for (let i = 0; i < 8; i++) ctx.prompt.registerSection({ id: 's' + i, text: 'x'.repeat(4096) })`)
90 const active = []
91 for (let index = 0; index < 4; index++) {
92 const owner = await activate(host, `full-prompt-${index}`, large)
93 assert.equal(owner.result.status, 'ok')
94 active.push(owner.ref)
95 }
96 const small = plugin(t, `ctx.prompt.registerSection({ id: 'rules', text: 'one' })`)
97 const refused = await activate(host, 'over-host-budget', small)
98 assert.equal(refused.result.status, 'failed')
99 assert.match(refused.result.diagnostic, /byte limit/)
100 await host.call('ext/deactivate', { owner: active[0] })
101 assert.equal((await activate(host, 'after-teardown', small)).result.status, 'ok')
102 })
103
104 test('core refusal fails prompt activation with its reason', async (t) => {
105 const host = await startHost({ admit: () => ({ refused: 'reviewed Native authority is gone' }) })
106 t.after(() => host.stop())
107 const { result } = await activate(host, 'refused-prompt', plugin(t, `ctx.prompt.registerSection({ id: 'rules', text: 'text' })`))
108 assert.equal(result.status, 'failed')
109 assert.match(result.diagnostic, /reviewed Native authority is gone/)
110 })
111
112
113 test('Native source preserves exact non-ASCII prompt text on both runtimes', async t => {
114 const host=await startHost();t.after(()=>host.stop())
115 const text='中文 界 日本語 한국어 🌊 café'
116 const entry=plugin(t,`ctx.prompt.registerSection({id:'unicode',text:${JSON.stringify(text)}})`)
117 const mounted=await activate(host,'unicode-prompt-source',entry)
118 assert.equal(mounted.result.status,'ok',mounted.result.diagnostic)
119 assert.equal(host.registry.find(row=>row.op==='register' && row.kind==='prompt_section').spec.description,text)
120 assert.deepEqual(await host.call('ext/deactivate',{owner:mounted.ref}),{disposed:true,leaked:[]})
121 })
122
123
124 test('template sections keep Core-owned values unresolved and share literal lifecycle and bounds', async t => {
125 const host = await startHost(); t.after(() => host.stop())
126 const source = plugin(t, `
127 const definition = {id:'persona-prefix',text:'Model {{model}} at {{cwd}}. lone {{',interpolate:'model-cwd'}
128 ctx.prompt.registerSection(definition)
129 definition.text='forged values'
130 ctx.prompt.registerSection({id:'literal',text:'{{model}} stays literal'})
131 `)
132 const mounted = await activate(host, 'templates', source)
133 assert.equal(mounted.result.status, 'ok', mounted.result.diagnostic)
134 const rows = host.registry.filter(row => row.op === 'register')
135 assert.equal(rows.find(row => row.kind === 'prompt_template').spec.description, 'Model {{model}} at {{cwd}}. lone {{')
136 assert.equal(rows.find(row => row.kind === 'prompt_section').spec.description, '{{model}} stays literal')
137 assert.deepEqual(await host.call('ext/deactivate', {owner:mounted.ref}), {disposed:true,leaked:[]})
138 for (const row of rows) assert.ok(host.registry.some(event => event.op === 'unregister' && event.handle === row.handle))
139 for (const [index, text] of ['{{model_id}}','{{}}','{{ model }}','{{{cwd}}'].entries()) {
140 const bad = await activate(host, 'bad-template-'+index, plugin(t, `ctx.prompt.registerSection({id:'invalid',text:${JSON.stringify(text)},interpolate:'model-cwd'})`))
141 assert.equal(bad.result.status, 'failed'); assert.match(bad.result.diagnostic,/prompt variable/)
142 }
143 const duplicate = await activate(host, 'duplicate-template', plugin(t, `ctx.prompt.registerSection({id:'same',text:'{{model}}',interpolate:'model-cwd'});ctx.prompt.registerSection({id:'same',text:'literal'})`))
144 assert.equal(duplicate.result.status, 'failed'); assert.match(duplicate.result.diagnostic,/already registered/)
145 })
146
146 lines Plain Text