| 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 |