feat: ship skill and MCP workflows with signed updater for v0.0.7

This commit is contained in:
xiaohan.yuan committed 2026-10-21 00:04:17 +08:00
1 parent 1b52df0603
commit a01085c656
136 files changed
+18646 -217

No files matched your search

+437
View File
@@ -0,0 +1,437 @@
import assert from 'node:assert/strict'
import { nextRequestSettings } from '../src/main/agent/request-settings'
import { useChatStore } from '../src/renderer/src/stores/chat'
import type { ActiveChatRun, SendPayload, SendResult } from '../src/shared/ipc'
import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta } from '../src/shared/types'
import type { SessionContext } from '../src/shared/workspaces'
interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
reject(reason?: unknown): void
}
interface FakeBackend {
api: FakeApi
sessions: SessionMeta[]
messages: Record<string, ChatMessage[]>
replaceSession(session: SessionMeta): void
}
interface FakeApi {
session: {
list(): Promise<SessionMeta[]>
create(workspaceId: string | undefined, context?: SessionContext): Promise<SessionMeta>
messages(sessionId: string): Promise<ChatMessage[]>
updateContext(sessionId: string, patch: SessionContext): Promise<SessionMeta>
remove(sessionId: string, options?: { onlyIfEmpty?: boolean }): Promise<void>
rename(sessionId: string, title: string): Promise<void>
}
chat: {
send(payload: SendPayload): Promise<SendResult>
setSessionVisible(sessionId: string | null): Promise<{ accepted: boolean }>
cancel(runId: string): Promise<{ cancelled: boolean }>
revert(sessionId: string, messageId: string, options?: unknown): Promise<{ messages: ChatMessage[] }>
respond(runId: string, interactionId: string, response: unknown): Promise<{ accepted: boolean }>
activeRun(sessionId: string): Promise<ActiveChatRun | null>
onEvent(listener: (event: ChatEvent) => void): () => void
onStarted(listener: (run: ActiveChatRun) => void): () => void
queue: {
get(sessionId: string): Promise<{ items: unknown[] }>
enqueue(payload: SendPayload): Promise<unknown>
}
}
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((accept, decline) => {
resolve = accept
reject = decline
})
return { promise, resolve, reject }
}
async function flushAsync(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
async function waitFor(description: string, predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) return
await new Promise<void>((resolve) => setTimeout(resolve, 5))
}
throw new Error(`timed out waiting for ${description}`)
}
function makeSession(id: string, overrides: Partial<SessionMeta> = {}): SessionMeta {
return { id, title: id, createdAt: 1, updatedAt: 1, ...overrides }
}
function makeMessage(id: string, role: ChatMessage['role'], content: string): ChatMessage {
return { id, role, content, createdAt: 1, status: 'done', ...(role === 'assistant' ? { tools: [], parts: [] } : {}) }
}
function makeSendResult(
runId: string,
modelId: string,
modelName: string,
contextUsage?: ContextUsageSnapshot
): SendResult {
const userMessage = makeMessage(`${runId}-user`, 'user', 'question')
const assistant: ChatMessage = {
id: `${runId}-assistant`,
role: 'assistant',
content: '',
tools: [],
parts: [],
requestId: runId,
modelId,
modelName,
contextUsage,
createdAt: 2,
turnStartedAt: userMessage.createdAt,
status: 'streaming'
}
return {
runId,
assistantMessageId: assistant.id,
modelId,
modelName,
userMessage,
messages: [userMessage, assistant]
}
}
function installFakeBackend(
sessionSeeds: SessionMeta[],
messageSeeds: Record<string, ChatMessage[]> = {}
): FakeBackend {
const sessions = sessionSeeds.map((item) => ({ ...item }))
const messages = Object.fromEntries(Object.entries(messageSeeds).map(([id, items]) => [id, [...items]]))
let createdSequence = 0
const api: FakeApi = {
session: {
list: async () => sessions.map((item) => ({ ...item })),
create: async (_workspaceId, context = {}) => {
const created = makeSession(`created-${++createdSequence}`, context)
sessions.unshift(created)
messages[created.id] = []
return { ...created }
},
messages: async (sessionId) => [...(messages[sessionId] ?? [])],
updateContext: async (sessionId, patch) => {
const index = sessions.findIndex((item) => item.id === sessionId)
if (index < 0) throw new Error(`unknown session ${sessionId}`)
const updated = { ...sessions[index], ...patch }
sessions[index] = updated
return { ...updated }
},
remove: async (sessionId, options) => {
const index = sessions.findIndex((item) => item.id === sessionId)
if (index < 0) return
if (options?.onlyIfEmpty && (messages[sessionId]?.length ?? 0) > 0) throw new Error('session is not empty')
sessions.splice(index, 1)
delete messages[sessionId]
},
rename: async (sessionId, title) => {
const index = sessions.findIndex((item) => item.id === sessionId)
if (index >= 0) sessions[index] = { ...sessions[index], title }
}
},
chat: {
send: async () => { throw new Error('fake send was not configured') },
setSessionVisible: async () => ({ accepted: true }),
cancel: async () => ({ cancelled: true }),
revert: async () => ({ messages: [] }),
respond: async () => ({ accepted: true }),
activeRun: async () => null,
onEvent: () => () => undefined,
onStarted: () => () => undefined,
queue: {
get: async () => ({ items: [] }),
enqueue: async () => undefined
}
}
}
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { ycode: api }
})
return {
api,
sessions,
messages,
replaceSession(session) {
const index = sessions.findIndex((item) => item.id === session.id)
if (index >= 0) sessions[index] = { ...session }
else sessions.unshift({ ...session })
}
}
}
function resetStore(
sessions: SessionMeta[],
currentSessionId: string | null,
messages: ChatMessage[] = [],
draftContext: SessionContext = {}
): void {
useChatStore.setState({
sessions,
currentSessionId,
messages,
sessionMessages: currentSessionId ? { [currentSessionId]: messages } : {},
sessionUsage: {},
sessionContextWindow: {},
runs: {},
runSequences: {},
sendingSessions: [],
running: null,
sending: false,
loading: false,
error: null,
draftContext,
draftId: 0
})
}
async function testSerialModelWritesAndFrozenRunningTurn(): Promise<void> {
const main = makeSession('main', { modelId: 'model-a', thinkingLevel: 'low' })
resetStore([main], main.id)
const backend = installFakeBackend([main], { [main.id]: [] })
const frozenUsage: ContextUsageSnapshot = {
usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 },
contextWindow: 64_000,
sampledAt: 10,
requestId: 'run-a',
modelId: 'model-a'
}
let sentPayload: SendPayload | undefined
backend.api.chat.send = async (payload) => {
sentPayload = payload
return makeSendResult('run-a', 'model-a', 'Model A', frozenUsage)
}
await useChatStore.getState().send('freeze current run')
assert.equal(sentPayload?.sessionId, main.id)
assert.equal(useChatStore.getState().running?.runId, 'run-a')
const writes: Array<{ id: string; patch: SessionContext; gate: Deferred<SessionMeta> }> = []
backend.api.session.updateContext = (sessionId, patch) => {
const gate = deferred<SessionMeta>()
writes.push({ id: sessionId, patch, gate })
return gate.promise
}
const resolveWrite = (index: number): void => {
const write = writes[index]
const current = backend.sessions.find((item) => item.id === write.id) ?? makeSession(write.id)
const updated = { ...current, ...write.patch }
backend.replaceSession(updated)
write.gate.resolve(updated)
}
const pendingA = useChatStore.getState().selectModel('model-a')
await waitFor('first serialized model write', () => writes.length === 1)
const pendingB = useChatStore.getState().selectModel('model-b')
const pendingC = useChatStore.getState().selectModel('model-c')
await flushAsync()
assert.equal(writes.length, 1, 'model writes must be serialized instead of racing')
resolveWrite(0)
await waitFor('second serialized model write', () => writes.length === 2)
resolveWrite(1)
await waitFor('third serialized model write', () => writes.length === 3)
resolveWrite(2)
await Promise.all([pendingA, pendingB, pendingC])
assert.deepEqual(writes.map((write) => write.patch.modelId), ['model-a', 'model-b', 'model-c'])
const state = useChatStore.getState()
assert.equal(state.sessions.find((item) => item.id === main.id)?.modelId, 'model-c')
const assistant = state.messages.find((message) => message.id === 'run-a-assistant')
assert.ok(assistant)
assert.equal(assistant.modelId, 'model-a', 'the active assistant must keep its starting model')
assert.equal(assistant.modelName, 'Model A')
assert.deepEqual(assistant.contextUsage?.usage, frozenUsage.usage)
assert.equal(assistant.contextUsage?.contextWindow, 64_000)
assert.deepEqual(state.sessionUsage[main.id], frozenUsage.usage)
assert.equal(state.sessionContextWindow[main.id], 64_000)
}
async function testSendFailureDoesNotPoisonRetry(): Promise<void> {
const main = makeSession('retry-session', { modelId: 'model-a' })
resetStore([main], main.id)
const backend = installFakeBackend([main], { [main.id]: [] })
let calls = 0
backend.api.chat.send = async () => {
calls++
if (calls === 1) throw new Error('mock send failure')
return makeSendResult('run-retry', 'model-a', 'Model A')
}
const fakeWindow = (globalThis as typeof globalThis & { window: { ycode: Record<string, unknown> } }).window
assert.equal('models' in fakeWindow.ycode, false, 'the fake window must not expose paid model APIs')
assert.equal('providers' in fakeWindow.ycode, false, 'the fake window must not expose provider APIs')
await assert.rejects(useChatStore.getState().send('first attempt'), /mock send failure/)
let state = useChatStore.getState()
assert.equal(state.sending, false)
assert.deepEqual(state.sendingSessions, [])
assert.deepEqual(state.messages, [])
assert.equal(state.error, 'mock send failure')
await useChatStore.getState().send('retry attempt')
state = useChatStore.getState()
assert.equal(calls, 2, 'the retry must reach the send boundary exactly once')
assert.equal(state.error, null)
assert.equal(state.running?.runId, 'run-retry')
assert.equal(state.messages.some((message) => message.id === 'run-retry-assistant'), true)
}
async function testLateResultAndEventStayInOriginalSession(): Promise<void> {
const first = makeSession('session-a', { modelId: 'model-a' })
const second = makeSession('session-b', { modelId: 'model-b' })
resetStore([first, second], first.id)
const secondMessage = makeMessage('session-b-history', 'user', 'keep this session')
const backend = installFakeBackend([first, second], {
[first.id]: [],
[second.id]: [secondMessage]
})
const response = deferred<SendResult>()
let sendStarted = false
backend.api.chat.send = async () => {
sendStarted = true
return response.promise
}
const sending = useChatStore.getState().send('slow send')
await waitFor('slow send to reach IPC', () => sendStarted)
await useChatStore.getState().selectSession(second.id)
assert.equal(useChatStore.getState().currentSessionId, second.id)
assert.deepEqual(useChatStore.getState().messages, [secondMessage])
response.resolve(makeSendResult('late-run', 'model-a', 'Model A'))
await sending
let state = useChatStore.getState()
assert.equal(state.currentSessionId, second.id, 'a late send response must not switch sessions')
assert.deepEqual(state.messages, [secondMessage], 'a late send response must not replace the selected session messages')
assert.equal(state.running, null, 'the original session run must not become the selected session run')
assert.equal(state.runs['late-run']?.sessionId, first.id)
assert.equal(state.sessionMessages[first.id]?.some((message) => message.id === 'late-run-assistant'), true)
useChatStore.getState().applyEvent({ runId: 'late-run', sessionId: first.id, type: 'text', delta: 'late text', sequence: 1 })
useChatStore.getState().applyEvent({ runId: 'late-run', sessionId: first.id, type: 'done', content: 'late text', tools: [], sequence: 2 })
await waitFor('late terminal event to commit', () => {
const assistant = useChatStore.getState().sessionMessages[first.id]?.find((message) => message.id === 'late-run-assistant')
return assistant?.content === 'late text' && assistant.status === 'done'
})
state = useChatStore.getState()
assert.equal(state.currentSessionId, second.id)
assert.deepEqual(state.messages, [secondMessage], 'late stream events must not leak into the selected session')
const originalAssistant = state.sessionMessages[first.id]?.find((message) => message.id === 'late-run-assistant')
assert.equal(originalAssistant?.content, 'late text')
assert.equal(originalAssistant?.status, 'done')
}
async function testDraftContextWinsWhileFirstSessionIsCreated(): Promise<void> {
resetStore([], null, [], { modelId: 'draft-a', thinkingLevel: 'low' })
const backend = installFakeBackend([], {})
const createGate = deferred<SessionMeta>()
const updateGate = deferred<SessionMeta>()
let createContext: SessionContext | undefined
let updateCapture: { id: string; patch: SessionContext } | undefined
let sendCalls = 0
backend.api.session.create = async (_workspaceId, context) => {
createContext = context ? { ...context } : undefined
return createGate.promise
}
backend.api.session.updateContext = async (sessionId, patch) => {
updateCapture = { id: sessionId, patch: { ...patch } }
return updateGate.promise
}
backend.api.chat.send = async () => {
sendCalls++
return makeSendResult('run-first', 'draft-b', 'Draft B')
}
const sending = useChatStore.getState().send('first message')
await waitFor('first session creation', () => createContext !== undefined)
assert.deepEqual(createContext, { modelId: 'draft-a', thinkingLevel: 'low' })
await useChatStore.getState().selectModel('draft-b')
await useChatStore.getState().selectThinkingLevel('high')
assert.deepEqual(useChatStore.getState().draftContext, { modelId: 'draft-b', thinkingLevel: 'high' })
const created = makeSession('created-session', { modelId: 'draft-a', thinkingLevel: 'low' })
backend.replaceSession(created)
createGate.resolve(created)
await waitFor('draft context patch after session creation', () => updateCapture !== undefined)
assert.deepEqual(updateCapture, {
id: 'created-session',
patch: { modelId: 'draft-b', thinkingLevel: 'high' }
})
assert.equal(sendCalls, 0, 'the first send must wait for the newer draft settings to persist')
const updated = { ...created, ...updateCapture!.patch }
backend.replaceSession(updated)
updateGate.resolve(updated)
await sending
const state = useChatStore.getState()
assert.equal(state.currentSessionId, 'created-session')
assert.equal(state.sessions.find((item) => item.id === 'created-session')?.modelId, 'draft-b')
assert.equal(state.sessions.find((item) => item.id === 'created-session')?.thinkingLevel, 'high')
assert.deepEqual(state.draftContext, { modelId: 'draft-b', thinkingLevel: 'high' })
assert.equal(sendCalls, 1)
}
function testNextRequestSettingsWhitelist(): void {
assert.deepEqual(nextRequestSettings({ modelId: 'model-next', thinkingLevel: 'high' }), {
modelId: 'model-next',
thinkingLevel: 'high'
})
assert.deepEqual(nextRequestSettings({}), {})
const forbidden: SessionContext[] = [
{ permissionMode: 'full-access' },
{ cwd: 'C:\\workspace' },
{ agentMode: 'plan' },
{ projectId: 'project-1' },
{ workspaceId: 'workspace-1' },
{ expertId: 'expert-1' },
{ modelId: 'model-next', permissionMode: 'default-sandbox' }
]
for (const patch of forbidden) {
assert.throws(() => nextRequestSettings(patch), /运行期间只能修改下次发送的模型和思考强度/)
}
assert.throws(() => nextRequestSettings({ modelId: ' ' }), /模型标识无效/)
assert.throws(() => nextRequestSettings({ thinkingLevel: 'invalid' as never }), /无效的思考强度/)
}
const originalFetch = globalThis.fetch
globalThis.fetch = (async () => {
throw new Error('network access is forbidden in request-settings regression')
}) as typeof fetch
const completed: string[] = []
try {
await testSerialModelWritesAndFrozenRunningTurn()
completed.push('serial model writes + frozen active turn')
await testSendFailureDoesNotPoisonRetry()
completed.push('send failure retry')
await testLateResultAndEventStayInOriginalSession()
completed.push('late result/event session isolation')
await testDraftContextWinsWhileFirstSessionIsCreated()
completed.push('draft settings during first session creation')
testNextRequestSettingsWhitelist()
completed.push('nextRequestSettings whitelist')
console.log(JSON.stringify({ result: 'PASS', tests: completed }, null, 2))
} finally {
globalThis.fetch = originalFetch
}