feat: 階段 2 路由、改寫與好感度
- Haiku 一次分類(難度、模型、思考程度、語氣),角色立刻回應 - turn.step 覆寫模型與思考程度,額度低於門檻自動降一級,支援手動鎖定 - 主模型回覆改寫為角色口吻,程式碼/路徑/指令/數字/URL 原樣保留,失敗回原文並另存原文 log - 好感度規則、五階段、不跨階段掉落、個性倍率、狀態列愛心、/jsc-anima:anima-affinity - 年齡與關係規則(未成年友情、成年移轉、戀愛式依賴需明確開啟) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { expect, mock, test } from 'claude-code/testing'
|
||||
|
||||
type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any }
|
||||
|
||||
const setup = (on: any): Ctl => {
|
||||
const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} } }
|
||||
mock.store(on)
|
||||
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) => ({ 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('好感度:由 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)
|
||||
})
|
||||
Reference in New Issue
Block a user