feat: ship skill and MCP workflows with signed updater for v0.0.7
This commit is contained in:
1 parent
1b52df0603
commit
a01085c656
136 files changed
+18646
-217
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user