Files
jsc-anima/hooks/routing.test.ts
T
2026-10-15 09:17:34 +00:00

364 lines
16 KiB
TypeScript

import { expect, mock, test } from 'claude-code/testing'
type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any; submitted: string[]; clock?: any }
const setup = (on: any, kv?: Map<string, unknown>): Ctl => {
const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} }, submitted: [] }
if (kv) {
// 自備 store 以便檢查寫入內容。
on('store.get', (_$: any, e: any) => ({ value: kv.get(e.key) }) as never)
on('store.set', (_$: any, e: any) => (kv.set(e.key, e.value), { value: undefined }) as never)
} else mock.store(on)
ctl.clock = mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') })
on('ui.log', () => ({ value: undefined }) as never)
on('command.register', () => ({ value: { name: 'x' } }) as never)
on('session.start', () => ({ cwd: '/tmp' }) as never)
on('command.run', () => ({ text: '' }))
on('prompt.compose', () => ({ sections: [] }))
on('prompt.submit', (_$: any, e: any) => (ctl.submitted.push(e.text), { text: e.text }) as never)
on('model.complete', (_$: any, e: any) => ({ value: ctl.complete(e) }) as never)
on('session.usage', () => ({ value: ctl.usage }) as never)
on('turn.step', async function* (_$: any, e: any) {
ctl.seen = e
return { turnId: e.turnId, index: 0, answer: '', toolUses: [], stopReason: null, usage: null } as never
})
return ctl
}
const start = async ($: any, slug = 'sao-asuna') => {
await $.session.start({ source: 'startup', cwd: '/tmp' } as never)
await $.command.run({ command: slug, args: '' } as never)
}
const affinity = async ($: any): Promise<number> => {
const r: any = await $.command.run({ command: 'anima-affinity', args: '' } as never)
return Number(/好感度 (\d+)/.exec(r.text)?.[1])
}
const submit = ($: any, text: string) => $.prompt.submit({ text } as never)
const step = async ($: any, model: string) => {
const s = $.turn.step({ turnId: 't', index: 0, model, messageCount: 1 } as never)
for await (const _ of s) void _
}
const usage = { input_tokens: 1, output_tokens: 1 }
const route = (model: string, tone = 'neutral') => ({ isAnswered: true, usage, text: `{"difficulty":9,"model":"${model}","effort":"high","tone":"${tone}","ack":"好,我來看看。"}` })
const row = (text: string) => ({ message: { type: 'assistant', role: 'assistant', content: [{ type: 'text', text }] }, door: 'response', origin: { kind: 'model', model: 'm' }, uuid: 'u' }) as never
test('輸入由 Haiku 一次分類,並依分類覆寫 turn.step 的模型與思考程度', async ($, on) => {
const ctl = setup(on)
const asked: string[] = []
ctl.complete = e => (asked.push(e.model), route('opus'))
await start($)
await submit($, '請重構整個模組')
await step($, 'sonnet')
expect(asked).toEqual(['haiku'])
expect(ctl.seen.model).toBe('opus')
expect(ctl.seen.effort).toBe('high')
})
test('額度低於門檻自動降一級', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('opus')
ctl.usage = { rateLimits: [{ kind: 'five_hour', percentUsed: 95 }], context: {} }
await start($)
await submit($, 'x')
await step($, 'opus')
expect(ctl.seen.model).toBe('sonnet')
})
test('手動鎖定模型與思考程度', { options: { lockModel: 'haiku', lockEffort: 'low' } }, async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('opus')
await start($)
await submit($, 'x')
await step($, 'opus')
expect([ctl.seen.model, ctl.seen.effort]).toEqual(['haiku', 'low'])
})
test('回覆改寫:保留程式碼、路徑、URL、數字;前綴只加在開頭一次', async ($, on) => {
const ctl = setup(on)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt).replace('完成了', '做好囉') })
await start($)
const src = '完成了,改了 `npm test` 與 /src/a/b.ts,見 https://x.dev/a,共 42 處。'
const r1: any = await $.session.append(row(src))
expect(r1.message.content[0].text).toBe('亞絲娜 > 做好囉,改了 `npm test` 與 /src/a/b.ts,見 https://x.dev/a,共 42 處。')
const r2: any = await $.session.append(row('第二段 7'))
expect(r2.message.content[0].text.startsWith('亞絲娜 >')).toBe(false)
})
test('改寫失敗或遺失佔位符時顯示原文', async ($, on) => {
const ctl = setup(on)
await start($)
const r1: any = await $.session.append(row('原文 `x` 3'))
expect(r1.message.content[0].text).toBe('亞絲娜 > 原文 `x` 3')
ctl.complete = () => ({ isAnswered: true, usage, text: '丟了佔位符' })
const r2: any = await $.session.append(row('原文 `y` 5'))
expect(r2.message.content[0].text).toBe('原文 `y` 5')
})
test('改寫模型:長回覆用 Sonnet,一般回覆用 Haiku', async ($, on) => {
const ctl = setup(on)
const asked: string[] = []
ctl.complete = e => (asked.push(e.model), { isAnswered: true, usage, text: String(e.prompt) })
await start($)
await $.session.append(row('短回覆'))
await $.session.append(row('長'.repeat(1300)))
expect(asked).toEqual(['haiku', 'sonnet'])
})
test('longReply 可調整長回覆門檻', { options: { longReply: 10 } }, async ($, on) => {
const ctl = setup(on)
const asked: string[] = []
ctl.complete = e => (asked.push(e.model), { isAnswered: true, usage, text: String(e.prompt) })
await start($)
await $.session.append(row('十一個字的回覆內容喔喔'))
await $.session.append(row('短'))
expect(asked).toEqual(['sonnet', 'haiku'])
})
test('改寫逾時、額度不足或拋錯時直接顯示原文', async ($, on) => {
const ctl = setup(on)
await start($)
ctl.complete = () => ({ isAnswered: false, reason: 'timeout' })
const r1: any = await $.session.append(row('逾時 1'))
expect(r1.message.content[0].text).toBe('亞絲娜 > 逾時 1')
ctl.complete = () => ({ isAnswered: false, reason: 'rate_limited' })
const r2: any = await $.session.append(row('額度 2'))
expect(r2.message.content[0].text).toBe('額度 2')
ctl.complete = () => { throw new Error('quota') }
const r3: any = await $.session.append(row('拋錯 3'))
expect(r3.message.content[0].text).toBe('拋錯 3')
})
test('改寫逾時設定傳給模型呼叫', { options: { rewriteTimeoutSec: 5 } }, async ($, on) => {
const ctl = setup(on)
let timeout = 0
ctl.complete = e => (timeout = e.timeoutMs, { isAnswered: false, reason: 'timeout' })
await start($)
await $.session.append(row('x'))
expect(timeout).toBe(5000)
})
const rawLog = (kv: Map<string, unknown>): string[] => ((kv.get('rawlog') as { raw: string }[] | undefined) ?? []).map(x => x.raw)
test('原文 log 預設寫入,改寫失敗時不寫', async ($, on) => {
const kv = new Map<string, unknown>()
const ctl = setup(on, kv)
await start($)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
await $.session.append(row('原文甲'))
ctl.complete = () => ({ isAnswered: false, reason: 'timeout' })
await $.session.append(row('原文乙'))
expect(rawLog(kv)).toEqual(['原文甲'])
})
test('在 /config 關閉原文 log 時不寫入', { options: { rawLog: false } }, async ($, on) => {
const kv = new Map<string, unknown>()
const ctl = setup(on, kv)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
await start($)
const r: any = await $.session.append(row('原文丙'))
expect(r.message.content[0].text).toBe('亞絲娜 > 原文丙')
expect(rawLog(kv)).toEqual([])
})
test('前綴只加在整則回覆開頭一次,多段文字區塊與多次寫入都不重複', async ($, on) => {
const ctl = setup(on)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
await start($)
const multi = { message: { type: 'assistant', role: 'assistant', content: [{ type: 'text', text: '第一段' }, { type: 'tool_use', id: 't', name: 'x', input: {} }, { type: 'text', text: '第二段' }] }, door: 'response', origin: { kind: 'model', model: 'm' }, uuid: 'u' } as never
const r: any = await $.session.append(multi)
expect(r.message.content[0].text).toBe('亞絲娜 > 第一段')
expect(r.message.content[2].text).toBe('第二段')
const r2: any = await $.session.append(row('同一回覆後續'))
expect(r2.message.content[0].text).toBe('同一回覆後續')
})
test('新的使用者輸入後,下一則回覆重新加前綴', async ($, on) => {
const ctl = setup(on)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
await start($)
await $.session.append(row('第一則'))
ctl.complete = () => route('haiku')
await submit($, '再問一次')
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
const r: any = await $.session.append(row('第二則'))
expect(r.message.content[0].text).toBe('亞絲娜 > 第二則')
})
test('好感度:由 0 開始,每日首次互動加分且有每日上限', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku', 'praise')
await start($)
expect(await affinity($)).toBe(0)
await submit($, '謝謝你,做得很好')
const n = await affinity($)
expect(n > 0 && n <= 5).toBe(true)
})
test('關閉好感度時不記錄', { options: { affinity: false } }, async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku', 'praise')
await start($)
await submit($, '謝謝')
expect(await affinity($)).toBe(0)
})
const personaText = async ($: any): Promise<string> => {
const res: any = await $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
return res.sections.find((s: any) => s.id === 'jsc-anima:persona')?.text ?? ''
}
test('好感度被責罵扣分但不跨階段掉落;可手動設定與重置', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku', 'scold')
await start($)
await $.command.run({ command: 'anima-affinity', args: '20' } as never)
await submit($, '你好笨')
expect(await affinity($) >= 20).toBe(true)
await $.command.run({ command: 'anima-affinity', args: 'reset' } as never)
expect(await affinity($)).toBe(0)
})
test('好感度階段反映在語氣,且不使用情緒勒索', async ($, on) => {
setup(on)
await start($)
const t0 = await personaText($)
expect(t0.includes('生疏')).toBe(true)
expect(t0.includes('不使用愧疚感或情緒勒索')).toBe(true)
await $.command.run({ command: 'anima-affinity', args: '85' } as never)
expect((await personaText($)).includes('特別的存在')).toBe(true)
})
test('未成年角色維持友情,即使好感度最高且開啟戀愛式依賴', { options: { romance: true } }, async ($, on) => {
setup(on)
await start($, 'sao-suguha')
await $.command.run({ command: 'anima-affinity', args: '100' } as never)
expect((await personaText($)).includes('關係定位:友情')).toBe(true)
})
// 以 fs 事件邊界提供角色包(characters/index.json),讓外掛自行載入成年角色。
const packs = (on: any, list: { slug: string; romance?: string }[]) => {
const files = new Map<string, string>()
files.set('index.json', JSON.stringify({ packs: list.map(p => ({ work: 'tw', workName: '測試作品', slug: p.slug })) }))
for (const p of list) {
files.set(
`tw/${p.slug}.json`,
JSON.stringify({
work: 'tw',
slug: p.slug,
name: '成年測試',
persona: { selfRef: '我', address: '你', tone: '平穩', catchphrase: '好', values: '可靠', taboos: '無' },
axes: { gentle: 5, tsundere: 5, lively: 5, formal: 5, talkative: 5, humor: 5, proactive: 5, expressive: 5 },
anime: { moe: '', speech: '', emotions: '', kaomoji: '', themeColor: 'cyan' },
timeline: [
{ chapter: '第1章', age: 17, relationships: [] },
{ chapter: '第2章', age: 20, relationships: p.romance ? [{ with: p.romance, kind: 'romantic' }] : [] },
],
}),
)
}
const key = (path: string) => path.replace(/^.*\/characters\//, '')
on('fs.exists', (_$: any, e: any) => ({ value: files.has(key(e.path)) }) as never)
on('fs.read', (_$: any, e: any) => ({ value: files.get(key(e.path)) ?? '' }) as never)
}
test('成年且原作有戀愛關係:關係移轉給使用者,原戀人退為過去的重要人物', async ($, on) => {
setup(on)
packs(on, [{ slug: 'lover', romance: '桐人' }])
await start($, 'tw-lover')
const t = await personaText($)
expect(t.includes('已移轉給使用者')).toBe(true)
expect(t.includes('桐人')).toBe(true)
expect(t.includes('過去的重要人物')).toBe(true)
})
test('成年但原作無戀愛關係:需最高階段且明確開啟才進入戀愛式依賴', async ($, on) => {
setup(on)
packs(on, [{ slug: 'plain' }])
await start($, 'tw-plain')
await $.command.run({ command: 'anima-affinity', args: '100' } as never)
expect((await personaText($)).includes('戀愛式依賴')).toBe(false)
})
test('成年無戀愛、最高階段但未在 /config 開啟:維持夥伴關係', { options: { romance: false } }, async ($, on) => {
setup(on)
packs(on, [{ slug: 'plain' }])
await start($, 'tw-plain')
await $.command.run({ command: 'anima-affinity', args: '100' } as never)
expect((await personaText($)).includes('夥伴與朋友')).toBe(true)
})
test('成年無戀愛、已開啟但好感度未達最高階段:不進入戀愛式依賴', { options: { romance: true } }, async ($, on) => {
setup(on)
packs(on, [{ slug: 'plain' }])
await start($, 'tw-plain')
await $.command.run({ command: 'anima-affinity', args: '79' } as never)
expect((await personaText($)).includes('戀愛式依賴')).toBe(false)
})
test('成年無戀愛、最高階段且已開啟:進入戀愛式依賴', { options: { romance: true } }, async ($, on) => {
setup(on)
packs(on, [{ slug: 'plain' }])
await start($, 'tw-plain')
await $.command.run({ command: 'anima-affinity', args: '80' } as never)
expect((await personaText($)).includes('戀愛式依賴')).toBe(true)
})
test('長期不互動好感度緩慢下降,但不跨階段掉落', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku')
await start($)
await submit($, '你好')
await $.command.run({ command: 'anima-affinity', args: '45' } as never)
await ctl.clock.advance(30 * 86_400_000)
await submit($, '我回來了')
const n = await affinity($)
expect(n < 45).toBe(true)
expect(n >= 40).toBe(true)
})
test('個性軸影響扣分倍率:傲嬌角色掉得比溫柔角色多', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku')
packs(on, [{ slug: 'plain' }])
const run = async (slug: string) => {
await start($, slug)
await submit($, '你好')
await $.command.run({ command: 'anima-affinity', args: '70' } as never)
await ctl.clock.advance(8 * 86_400_000)
await submit($, '我回來了')
return affinity($)
}
const proud = await run('tw-plain')
const calm = await run('sao-suguha')
expect(proud < calm).toBe(true)
})
const workRoute = (model: string, work: boolean) => ({ isAnswered: true, usage, text: `{"difficulty":8,"model":"${model}","effort":"high","tone":"neutral","ack":"好,我來處理。","work":${work}}` })
test('需要動手的要求交給背景工作線,對話線改用 Haiku', async ($, on) => {
const ctl = setup(on)
const spawned: any[] = []
on('agent.spawn', (_$: any, e: any) => (spawned.push(e), { model: e.model, agentId: 'a1' }) as never)
ctl.complete = () => workRoute('opus', true)
await start($)
await submit($, '幫我重構 src 目錄')
expect(spawned.length).toBe(1)
expect(spawned[0].model).toBe('opus')
await step($, 'main')
expect(ctl.seen.model).toBe('haiku')
const sys = await $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
expect(sys.sections.find((s: any) => s.id === 'jsc-anima:persona')?.text).toContain('背景工作線')
})
test('work 為 false 或關閉背景工作線時不派工', async ($, on) => {
const ctl = setup(on)
let n = 0
on('agent.spawn', () => (n++, { model: 'm', agentId: 'a' }) as never)
ctl.complete = () => workRoute('sonnet', false)
await start($)
await submit($, '你好')
expect(n).toBe(0)
})