chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
@@ -0,0 +1,115 @@
|
||||
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' }))
|
||||
Reference in new issue
Block a user