Files
jsc-marketplace/plugins/cockpit/companion/companion.test.ts
T

160 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import { createCompanion } from './core.ts'
import type { Card } from './core.ts'
import { estimateTokens } from './inject.ts'
import { parseMoodTag } from './mood.ts'
import { redactSecrets } from './secrets.ts'
import type { Fs } from './types.ts'
// 記憶體檔案系統與假時鐘
function memFs(): Fs & { files: Map<string, string> } {
const files = new Map<string, string>()
return { files, read: async p => files.get(p) ?? null, write: async (p, d) => void files.set(p, d) }
}
const T0 = Date.parse('2026-10-12T10:00:00Z')
function clock(t = T0) {
return { now: () => t, set: (x: number) => (t = x), advance: (ms: number) => (t += ms) }
}
// 原創範例角色
const CARD: Card = {
name: '小澄',
lines: ['個性:開朗、愛照顧人', '口癖:句尾常說「喔」', '喜歡:紅茶'],
}
const KEY = 'sk-abcdefghijklmnop1234567890'
async function setup() {
const fs = memFs()
const clk = clock()
fs.files.set('/c/sumi/card.json', JSON.stringify(CARD))
const c = createCompanion({ fs, clock: clk, root: '/c', id: 'sumi' })
return { fs, clk, c }
}
describe('心情標記', () => {
test('解析並移除末尾標記', () => {
const r = parseMoodTag('好的喔!\n<!--mood p=0.1 a=-0.05 d=0-->')
assert.equal(r.text, '好的喔!')
assert.deepEqual(r.delta, { p: 0.1, a: -0.05, d: 0 })
})
test('格式錯誤時移除標記且無 delta', () => {
const r = parseMoodTag('好的喔!<!--mood p=abc-->')
assert.equal(r.text, '好的喔!')
assert.equal(r.delta, null)
})
test('缺失標記退回關鍵字規則', async () => {
const { c } = await setup()
await c.userTurn('謝謝你,太棒了')
const shown = await c.assistantReply('不客氣喔')
assert.equal(shown, '不客氣喔')
assert.ok((await c.mood()).pad.p > 0)
})
test('標記有效時採用標記', async () => {
const { c } = await setup()
await c.userTurn('隨便聊聊')
await c.assistantReply('嗯喔<!--mood p=-0.2 a=0 d=0-->')
assert.ok((await c.mood()).pad.p < 0)
})
})
describe('心情上限與事件', () => {
test('單軸每回合變化不超過 0.3', async () => {
const { c } = await setup()
await c.userTurn('你很笨', ['scold', 'scold', 'scold'])
await c.assistantReply('嗚<!--mood p=-0.9 a=0.9 d=-0.9-->')
const { pad } = await c.mood()
assert.ok(Math.abs(pad.p) <= 0.3 + 1e-9 && Math.abs(pad.a) <= 0.3 + 1e-9 && Math.abs(pad.d) <= 0.3 + 1e-9)
assert.ok(pad.p < 0)
})
test('事件表:稱讚提升愉悅', async () => {
const { c } = await setup()
await c.userTurn('做得好', ['praise'])
assert.ok(Math.abs((await c.mood()).pad.p - 0.15) < 1e-9)
})
})
describe('好感度', () => {
test('有意義互動 +0.01,每日上限 +0.05', async () => {
const { c } = await setup()
await c.userTurn('今天的進度如何')
assert.ok(Math.abs((await c.affinity()).value - 0.01) < 1e-9)
for (let i = 0; i < 20; i++) await c.userTurn('繼續處理這個問題吧')
assert.ok(Math.abs((await c.affinity()).value - 0.05) < 1e-9)
})
test('負面事件 −0.03', async () => {
const { c } = await setup()
await c.userTurn('隨便', ['scold'])
assert.ok(Math.abs((await c.affinity()).value + 0.03) < 1e-9)
})
test('離開超過 7 天後回落', async () => {
const { c, clk } = await setup()
for (let i = 0; i < 5; i++) await c.userTurn('我們繼續吧', [])
const before = (await c.affinity()).value
clk.advance(37 * 86_400_000)
assert.ok(Math.abs((await c.affinity()).value - before / 2) < 1e-9)
})
})
describe('互動記憶', () => {
test('與原著記憶分開存放,且只存對話文字', async () => {
const { c, fs } = await setup()
await c.userTurn('幫我看看這段')
await c.assistantReply('好喔\n```ts\nconst a = 1\n```\n看完了<!--mood p=0 a=0 d=0-->')
const key = [...fs.files.keys()].find(k => k.includes('/interaction/'))!
assert.ok(key.startsWith('/c/sumi/interaction/'))
const stored = fs.files.get(key)!
assert.ok(!stored.includes('const a = 1'))
assert.ok(!stored.includes('mood'))
})
test('金鑰在寫入前被過濾', async () => {
const { c, fs } = await setup()
await c.userTurn(`我的金鑰是 ${KEY} 請記住`)
for (const v of fs.files.values()) assert.ok(!v.includes(KEY))
})
test('累積後壓縮成摘要', async () => {
const { c, fs } = await setup()
for (let i = 0; i < 35; i++) await c.userTurn(`第 ${i} 則訊息內容`)
const m = JSON.parse(fs.files.get('/c/sumi/interaction/memory.json')!)
assert.ok(m.recent.length <= 30)
assert.ok(m.summary.length > 0)
})
})
describe('人設注入', () => {
test('角色卡在前、心情與記憶在後', async () => {
const { c } = await setup()
await c.userTurn('我喜歡紅茶')
const out = await c.buildInjection()
assert.ok(out.indexOf('## 角色') < out.indexOf('## 互動記憶'))
assert.ok(out.indexOf('## 互動記憶') < out.indexOf('## 目前心情'))
})
test('不含疑似金鑰與工具輸出', async () => {
const { c } = await setup()
await c.userTurn(`token=${KEY}`)
await c.assistantReply(`好的 ${KEY}<!--mood p=0 a=0 d=0-->`)
const out = await c.buildInjection({ care: [`提醒 ${KEY}`], lore: [{ text: `設定 ${KEY}`, score: 1 }] })
assert.ok(!out.includes(KEY))
assert.equal(redactSecrets(out), out)
})
test('超出預算時截斷低分項目', async () => {
const { c } = await setup()
for (let i = 0; i < 30; i++) await c.userTurn(`這是一則相當長的訊息,用來撐滿預算,編號 ${i},${'字'.repeat(60)}`)
const out = await c.buildInjection()
const mem = out.split('## 互動記憶\n')[1]!.split('\n\n')[0]!
assert.ok(estimateTokens(mem) <= 800)
assert.ok(mem.includes('編號 29'))
assert.ok(!mem.includes('編號 0,'))
assert.ok(estimateTokens(out) <= 1600)
})
test('tone off 時不注入語氣規則', async () => {
const { c } = await setup()
assert.ok((await c.buildInjection()).includes('逐字保持原樣'))
assert.equal(await c.command('tone off'), '語氣已關閉')
assert.ok(!(await c.buildInjection()).includes('逐字保持原樣'))
await c.command('tone on')
assert.ok((await c.buildInjection()).includes('逐字保持原樣'))
})
})