Files
ycode/scripts/expert-usage-regression.ts
T

116 lines
9.1 KiB
TypeScript

import assert from 'node:assert/strict'
import { useChatStore } from '../src/renderer/src/stores/chat'
import { clearExpertInputPrefill, expertInputUpdate } from '../src/renderer/src/capabilities/expert-input'
const events: Array<{ owner: string; prompt: string; explicit: boolean }> = []
const writes: Array<{ owner: string; expertId: string | null }> = []
const calls: string[] = []
const gates = new Map<string, ReturnType<typeof deferred<any>>>()
let failWrite = false
function deferred<T>() { let resolve!: (value: T) => void, reject!: (error: Error) => void; const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
function activation(id: string) { return { id, name: id, expertType: 'agent' as const, runtimeIdentity: 'expert-v2:' + id, defaultInitPrompt: { zh: '默认提问:' + id } } }
const sessions = [
{ id: 'one', title: 'one', createdAt: 1, updatedAt: 1, workspaceId: 'space-one', cwd: 'C:/fixture', modelId: 'custom-model', expertId: 'old-expert' },
{ id: 'two', title: 'two', createdAt: 2, updatedAt: 2 }
]
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: class { type: string; detail: unknown; constructor(type: string, options: { detail: unknown }) { this.type = type; this.detail = options.detail } } })
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
dispatchEvent(event: { type: string; detail: any }) { if (event.type === 'ycode:expert-input') events.push(event.detail); return true },
ycode: { experts: { activate: async (id: string) => { calls.push(id); return gates.get(id)?.promise ?? activation(id) } },
session: { messages: async () => [], updateContext: async (owner: string, patch: any) => { if (failWrite) throw Error('disk unavailable'); writes.push({ owner, expertId: patch.expertId }); return { ...sessions.find(session => session.id === owner), ...patch } } } }
} })
function reset(owner: string | null = null) {
useChatStore.getState().startTask({ cwd: 'C:/draft', workspaceId: 'draft-space', modelId: 'draft-model' })
useChatStore.setState({ sessions: structuredClone(sessions), currentSessionId: owner, messages: [], sessionMessages: {}, running: null, sending: false, runs: {}, sendingSessions: [] })
events.length = 0; writes.length = 0; calls.length = 0; gates.clear(); failWrite = false
}
let checks = 0
function eq(actual: unknown, expected: unknown, label: string) { assert.deepEqual(actual, expected, label); checks++ }
const empty = { text: '', attachments: [] as unknown[] }
eq(expertInputUpdate(empty, 'A').text, 'A', 'empty draft gets default')
eq(expertInputUpdate({ ...empty, text: 'user question' }, 'A').text, 'user question', 'user text survives')
eq(expertInputUpdate({ ...empty, text: 'A', autoFilledPrompt: 'A' }, 'B').text, 'B', 'unchanged expert default replaced')
eq(expertInputUpdate({ ...empty, text: 'A edited', autoFilledPrompt: 'A' }, 'B').text, 'A edited', 'edited default survives')
eq(expertInputUpdate({ ...empty, attachments: ['image'] }, 'A').text, '', 'non-text-only attachment survives without autofill')
eq(expertInputUpdate({ ...empty, resources: ['file'] }, 'A').text, '', 'non-text-only reference survives without autofill')
eq(expertInputUpdate({ ...empty, skills: ['skill'] }, 'A').text, '', 'manual skill-only input survives')
eq(expertInputUpdate({ ...empty, text: 'A', autoFilledPrompt: 'A', attachments: ['image'] }, 'B').attachments, ['image'], 'replacing default keeps attachments')
eq(expertInputUpdate({ ...empty, text: 'edit', editingId: 'old-message' }, 'A').text, 'edit', 'editing message preserved')
eq(expertInputUpdate({ ...empty, text: 'user', attachments: ['image'] }, 'explicit', true).text, 'explicit', 'explicit quick prompt adopted')
eq(expertInputUpdate({ ...empty, text: 'A', autoFilledPrompt: 'A' }, '').text, 'A', 'expert without default keeps input')
eq(expertInputUpdate({ ...empty, text: 'A', autoFilledPrompt: 'A' }, '', false, true).text, '', 'conversation expert without default clears old prefill')
eq(expertInputUpdate({ ...empty, text: 'user edited', autoFilledPrompt: 'A' }, '', false, true).text, 'user edited', 'conversation expert without default preserves user text')
eq(clearExpertInputPrefill({ ...empty, text: 'A', autoFilledPrompt: 'A' }).text, '', 'conversation clear removes untouched prefill')
eq(clearExpertInputPrefill({ ...empty, text: 'A edited', autoFilledPrompt: 'A' }).text, 'A edited', 'conversation clear preserves edited text')
reset()
const draft = useChatStore.getState().draftId
await useChatStore.getState().selectExpert('cpp')
eq(useChatStore.getState().draftId, draft, 'picker binds same unsent owner')
eq(useChatStore.getState().draftContext, { cwd: 'C:/draft', workspaceId: 'draft-space', modelId: 'draft-model', expertId: 'cpp', agentMode: 'craft' }, 'model and workspace preserved and expert resumes craft')
eq(events, [{ owner: `new-${draft}`, prompt: '默认提问:cpp', explicit: false }], 'single validated activation supplies default')
eq(writes.length, 0, 'no persisted empty conversation created')
await useChatStore.getState().selectExpert('cpp')
eq(calls.length, 2, 'explicit repeat refreshes activation like source provider')
await useChatStore.getState().selectExpert(null)
eq(useChatStore.getState().draftContext.expertId, null, 'remove pending expert')
eq(events.length, 2, 'homepage remove never rewrites input')
reset('one')
await useChatStore.getState().summonExpert('pm')
eq(writes, [{ owner: 'one', expertId: 'pm' }], 'market return binds existing conversation')
eq(useChatStore.getState().currentSessionId, 'one', 'return retains conversation')
eq(events.length, 1, 'market return offers default with user input protection in composer')
eq(useChatStore.getState().sessions[0].modelId, 'custom-model', 'conversation model unchanged')
await useChatStore.getState().selectExpert('ui')
eq(events.at(-1)?.owner, 'one', 'picker targets same conversation')
eq(events.at(-1)?.prompt, '默认提问:ui', 'picker default gated by current input in composer')
reset('one')
await useChatStore.getState().summonExpert('algo', { newTask: true })
eq(useChatStore.getState().currentSessionId, null, 'standalone market summon prepares homepage')
eq(useChatStore.getState().draftContext, { workspaceId: 'space-one', cwd: 'C:/fixture', modelId: 'custom-model', expertId: 'algo', agentMode: 'craft' }, 'new expert task inherits model and space and uses craft')
eq(writes.length, 0, 'standalone summon leaves old session unchanged')
eq(events.at(-1)?.owner, `new-${useChatStore.getState().draftId}`, 'default follows new owner immediately')
reset('one'); failWrite = true
await assert.rejects(useChatStore.getState().selectExpert('bad'), /disk unavailable/); checks++
eq(useChatStore.getState().sessions[0].expertId, 'old-expert', 'failed persist retains prior expert')
eq(events.length, 0, 'failed persist leaves draft untouched')
reset(); const bad = deferred<any>(); gates.set('bad', bad)
const rejected = useChatStore.getState().selectExpert('bad'); bad.reject(Error('missing dependency'))
await assert.rejects(rejected, /missing dependency/); checks++
eq(useChatStore.getState().draftContext.expertId, undefined, 'failed activation does not bind')
eq(events.length, 0, 'failed activation never inserts default')
reset(); const slow = deferred<any>(); gates.set('slow', slow)
const stale = useChatStore.getState().selectExpert('slow')
await useChatStore.getState().selectExpert('latest')
slow.resolve(activation('slow'))
await assert.rejects(stale, /选择已切换/); checks++
eq(useChatStore.getState().draftContext.expertId, 'latest', 'late activation cannot overwrite latest')
eq(events.map(event => event.prompt), ['默认提问:latest'], 'late activation cannot overwrite text')
reset(); const shared = deferred<any>(); gates.set('same', shared)
const first = useChatStore.getState().selectExpert('same'), second = useChatStore.getState().selectExpert('same')
const obsolete = assert.rejects(first, /选择已切换/)
shared.resolve(activation('same')); await Promise.all([obsolete, second]); checks++
eq(calls, ['same'], 'concurrent same expert shares activation')
eq(events.length, 1, 'only newest concurrent selection publishes')
reset('one'); const late = deferred<any>(); gates.set('late', late)
const departed = useChatStore.getState().selectExpert('late')
await useChatStore.getState().selectSession('two'); late.resolve(activation('late'))
await assert.rejects(departed, /任务或专家选择已切换/); checks++
eq(writes.length, 0, 'changing conversation prevents stale bind')
eq(events.length, 0, 'changing conversation prevents stale prompt')
reset('one'); useChatStore.setState({ running: { runId: 'running', assistantId: 'assistant', sessionId: 'one' } })
await assert.rejects(useChatStore.getState().selectExpert('next'), /先停止/); checks++
eq(calls.length, 0, 'running conversation switch rejected before activation')
await useChatStore.getState().summonExpert('next', { newTask: true })
eq(useChatStore.getState().draftContext.expertId, 'next', 'independent new task may be prepared during another turn')
console.log(JSON.stringify({ result: 'PASS', checks, scope: 'production Zustand expert state and composer input policy with controlled IPC; no model requests' }))