1063 lines
47 KiB
TypeScript
1063 lines
47 KiB
TypeScript
import assert from 'node:assert/strict'
|
||
import { useChatStore } from '../src/renderer/src/stores/chat'
|
||
import { useSettingsStore } from '../src/renderer/src/stores/settings'
|
||
import type { ActiveChatRun, SendResult } from '../src/shared/ipc'
|
||
import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta, ToolCallRecord } from '../src/shared/types'
|
||
|
||
const session: SessionMeta = { id: 'session-1', title: 'Regression', modelId: 'model-profile-1', createdAt: 1, updatedAt: 1 }
|
||
|
||
function tool(overrides: Partial<ToolCallRecord> = {}): ToolCallRecord {
|
||
return {
|
||
id: 'tool-1',
|
||
name: 'read_file',
|
||
args: { path: 'notes.txt' },
|
||
argsRaw: '{"path":"notes.txt"}',
|
||
status: 'running',
|
||
...overrides
|
||
}
|
||
}
|
||
|
||
function sendResult(runId: string): SendResult {
|
||
return {
|
||
runId,
|
||
assistantMessageId: `${runId}-assistant`,
|
||
modelId: 'model-profile-1',
|
||
modelName: 'Frozen Model',
|
||
userMessage: {
|
||
id: `${runId}-user`,
|
||
role: 'user',
|
||
content: 'question',
|
||
createdAt: 10
|
||
}
|
||
}
|
||
}
|
||
|
||
function activeRunSnapshot(
|
||
runId: string,
|
||
content = 'snapshot',
|
||
sequence = 5,
|
||
sessionId = session.id,
|
||
contextUsage?: ContextUsageSnapshot
|
||
): ActiveChatRun {
|
||
const userMessage: ChatMessage = {
|
||
id: `${runId}-user`,
|
||
role: 'user',
|
||
content: 'question',
|
||
requestId: runId,
|
||
createdAt: 10,
|
||
status: 'done'
|
||
}
|
||
const assistant: ChatMessage = {
|
||
id: `${runId}-assistant`,
|
||
role: 'assistant',
|
||
content,
|
||
requestId: runId,
|
||
modelId: 'model-profile-1',
|
||
modelName: 'Frozen Model',
|
||
contextUsage,
|
||
tools: [],
|
||
parts: content ? [{ type: 'text', text: content }] : [],
|
||
createdAt: 11,
|
||
turnStartedAt: 10,
|
||
status: 'streaming'
|
||
}
|
||
return {
|
||
runId,
|
||
assistantMessageId: assistant.id,
|
||
modelId: 'model-profile-1',
|
||
modelName: 'Frozen Model',
|
||
userMessage,
|
||
messages: [userMessage, assistant],
|
||
sessionId,
|
||
sequence
|
||
}
|
||
}
|
||
|
||
function resetStore(): void {
|
||
useSettingsStore.setState({ models: [{ id: 'model-profile-1', name: 'Fixture', providerId: 'fixture', model: 'fixture', baseUrl: 'http://localhost', hasApiKey: false, keyStorage: 'none' }], selectedModelId: 'model-profile-1' })
|
||
useChatStore.setState({
|
||
sessions: [session],
|
||
currentSessionId: session.id,
|
||
messages: [],
|
||
sessionMessages: {},
|
||
sessionUsage: {},
|
||
sessionContextWindow: {},
|
||
runs: {},
|
||
runSequences: {},
|
||
sendingSessions: [],
|
||
running: null,
|
||
sending: false,
|
||
loading: false, loadError: null,
|
||
error: null,
|
||
draftContext: {},
|
||
draftId: 0
|
||
})
|
||
}
|
||
|
||
function installFakeWindow(send: (payload: Parameters<typeof window.ycode.chat.send>[0]) => Promise<SendResult>): void {
|
||
const api = {
|
||
session: {
|
||
list: async () => [session],
|
||
create: async () => session,
|
||
messages: async (id: string) => useChatStore.getState().sessionMessages[id] ?? [],
|
||
rename: async () => undefined,
|
||
remove: async () => undefined,
|
||
updateContext: async (_id: string, patch: object) => ({ ...session, ...patch })
|
||
},
|
||
chat: {
|
||
send,
|
||
setSessionVisible: async () => ({ accepted: true }),
|
||
cancel: async () => ({ cancelled: true }),
|
||
revert: async () => ({ messages: [] }),
|
||
activeRun: async (sessionId: string) => {
|
||
const state = useChatStore.getState()
|
||
const run = Object.values(state.runs).find((item) => item.sessionId === sessionId)
|
||
if (!run) return null
|
||
const messages = state.sessionMessages[sessionId]
|
||
?? (state.currentSessionId === sessionId ? state.messages : [])
|
||
const userMessage = messages.find((message) => message.role === 'user')
|
||
const assistant = messages.find((message) => message.id === run.assistantId)
|
||
if (!userMessage || !assistant) return null
|
||
return {
|
||
runId: run.runId,
|
||
assistantMessageId: run.assistantId,
|
||
modelId: assistant.modelId,
|
||
modelName: assistant.modelName,
|
||
assistantDisplay: assistant.assistantDisplay,
|
||
userMessage,
|
||
messages,
|
||
sessionId,
|
||
sequence: state.runSequences[run.runId] ?? 0
|
||
}
|
||
},
|
||
onEvent: () => () => undefined,
|
||
onStarted: () => () => undefined
|
||
}
|
||
}
|
||
Object.defineProperty(globalThis, 'window', {
|
||
configurable: true,
|
||
value: { ycode: api }
|
||
})
|
||
}
|
||
|
||
function assistantMessage(): ChatMessage {
|
||
const message = useChatStore.getState().messages.find((entry) => entry.role === 'assistant')
|
||
assert.ok(message, 'send result should create an assistant placeholder')
|
||
return message
|
||
}
|
||
|
||
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void; reject(reason?: unknown): void } {
|
||
let resolve!: (value: T) => void
|
||
let reject!: (reason?: unknown) => void
|
||
const promise = new Promise<T>((done, fail) => {
|
||
resolve = done
|
||
reject = fail
|
||
})
|
||
return { promise, resolve, reject }
|
||
}
|
||
|
||
async function testFailedCreatedSessionIsRemoved(): Promise<void> {
|
||
resetStore()
|
||
const created = { ...session, id: 'created-empty', title: '' }
|
||
const switched = deferred<void>()
|
||
const sendRejected = deferred<SendResult>()
|
||
let removeCalls = 0
|
||
installFakeWindow(async () => {
|
||
switched.resolve()
|
||
return sendRejected.promise
|
||
})
|
||
window.ycode.session.create = async () => created
|
||
window.ycode.session.list = async () => [created]
|
||
window.ycode.session.messages = async () => []
|
||
window.ycode.session.remove = async (id, options) => {
|
||
assert.equal(id, created.id)
|
||
assert.deepEqual(options, { onlyIfEmpty: true }, 'automatic cleanup must use the atomic empty-session delete')
|
||
removeCalls++
|
||
}
|
||
useChatStore.setState({ currentSessionId: null, draftId: 7, draftContext: { workspaceId: 'space-draft' } })
|
||
|
||
const sending = useChatStore.getState().send('first draft')
|
||
await switched.promise
|
||
sendRejected.reject(new Error('send rejected after create'))
|
||
await assert.rejects(sending, /send rejected after create/)
|
||
|
||
const state = useChatStore.getState()
|
||
assert.equal(removeCalls, 1, 'an unstarted, message-free session created for this send must be deleted')
|
||
assert.equal(state.currentSessionId, null, 'the UI must return to a real new-task state')
|
||
assert.deepEqual(state.messages, [])
|
||
assert.equal(created.id in state.sessionMessages, false)
|
||
assert.equal(created.id in state.sessionUsage, false)
|
||
assert.equal(created.id in state.sessionContextWindow, false)
|
||
assert.equal(state.sessions.some((item) => item.id === created.id), false)
|
||
assert.equal(state.draftId, 7, 'failure recovery must preserve the draft owner id')
|
||
assert.deepEqual(state.draftContext, { workspaceId: 'space-draft', modelId: 'model-profile-1' }, 'failure recovery must preserve draft context')
|
||
assert.equal(state.sending, false)
|
||
assert.match(state.error ?? '', /send rejected after create/)
|
||
}
|
||
|
||
async function testFailedCreatedSessionWithMessagesIsRetained(): Promise<void> {
|
||
resetStore()
|
||
const created = { ...session, id: 'created-with-message', title: 'Persisted' }
|
||
const persisted: ChatMessage = { id: 'persisted-user', role: 'user', content: 'persisted prompt', createdAt: 20, status: 'done' }
|
||
let removeCalls = 0
|
||
installFakeWindow(async () => { throw new Error('start turn failed') })
|
||
window.ycode.session.create = async () => created
|
||
window.ycode.session.list = async () => [created]
|
||
window.ycode.session.messages = async () => [persisted]
|
||
window.ycode.session.remove = async () => { removeCalls++ }
|
||
useChatStore.setState({ currentSessionId: null, draftId: 8 })
|
||
|
||
await assert.rejects(useChatStore.getState().send('must persist'), /start turn failed/)
|
||
|
||
const state = useChatStore.getState()
|
||
assert.equal(removeCalls, 0, 'a session with persisted messages must never be deleted')
|
||
assert.equal(state.currentSessionId, created.id, 'the real failed session must stay selected')
|
||
assert.deepEqual(state.messages, [persisted])
|
||
assert.equal(state.sessions.some((item) => item.id === created.id), true)
|
||
assert.equal(state.sending, false)
|
||
assert.match(state.error ?? '', /start turn failed.*已有持久化消息/)
|
||
}
|
||
|
||
async function testUnsafeCleanupDoesNotMasqueradeAsHome(): Promise<void> {
|
||
resetStore()
|
||
const created = { ...session, id: 'created-cleanup-failed', title: '' }
|
||
installFakeWindow(async () => { throw new Error('runtime unavailable') })
|
||
window.ycode.session.create = async () => created
|
||
window.ycode.session.list = async () => [created]
|
||
window.ycode.session.messages = async () => []
|
||
window.ycode.session.remove = async () => { throw new Error('remove denied') }
|
||
useChatStore.setState({ currentSessionId: null, draftId: 9 })
|
||
|
||
await assert.rejects(useChatStore.getState().send('keep failure visible'), /runtime unavailable/)
|
||
|
||
const state = useChatStore.getState()
|
||
assert.equal(state.currentSessionId, created.id, 'failed cleanup must retain the real session')
|
||
assert.equal(state.sessions.some((item) => item.id === created.id), true)
|
||
assert.equal(state.messages.length, 2, 'the failed user/assistant pair must remain visible')
|
||
assert.equal(state.messages[1].status, 'error')
|
||
assert.match(state.error ?? '', /空会话清理失败.*remove denied/)
|
||
}
|
||
|
||
async function testBufferedBackgroundEventPreventsDeletion(): Promise<void> {
|
||
resetStore()
|
||
const created = { ...session, id: 'created-with-early-event', title: '' }
|
||
let removeCalls = 0
|
||
installFakeWindow(async () => {
|
||
useChatStore.getState().applyEvent({
|
||
sessionId: created.id,
|
||
runId: 'background-before-response',
|
||
type: 'text',
|
||
delta: 'background started'
|
||
})
|
||
throw new Error('response lost')
|
||
})
|
||
window.ycode.session.create = async () => created
|
||
window.ycode.session.list = async () => [created]
|
||
window.ycode.session.messages = async () => []
|
||
window.ycode.session.remove = async () => { removeCalls++ }
|
||
useChatStore.setState({ currentSessionId: null, draftId: 11 })
|
||
|
||
await assert.rejects(useChatStore.getState().send('race event'), /response lost/)
|
||
|
||
const state = useChatStore.getState()
|
||
assert.equal(removeCalls, 0, 'an early run event proves deletion is unsafe')
|
||
assert.equal(state.currentSessionId, created.id)
|
||
assert.equal(state.sessions.some((item) => item.id === created.id), true)
|
||
assert.equal(state.messages.length, 2, 'the retained failed send remains visible')
|
||
assert.match(state.error ?? '', /检测到后台回合/)
|
||
}
|
||
|
||
async function testLateSendFailureCannotOverrideAnotherTask(): Promise<void> {
|
||
resetStore()
|
||
const created = { ...session, id: 'created-race', title: '' }
|
||
const sendStarted = deferred<void>()
|
||
const sendResponse = deferred<SendResult>()
|
||
let removeCalls = 0
|
||
installFakeWindow(async () => {
|
||
sendStarted.resolve()
|
||
return sendResponse.promise
|
||
})
|
||
window.ycode.session.create = async () => created
|
||
window.ycode.session.list = async () => [created]
|
||
window.ycode.session.messages = async () => []
|
||
window.ycode.session.remove = async () => { removeCalls++ }
|
||
useChatStore.setState({ currentSessionId: null, draftId: 10 })
|
||
|
||
const sending = useChatStore.getState().send('race prompt')
|
||
await sendStarted.promise
|
||
useChatStore.getState().startTask({ projectId: 'newer-task' })
|
||
const switchedDraftId = useChatStore.getState().draftId
|
||
sendResponse.reject(new Error('late failure'))
|
||
await assert.rejects(sending, /late failure/)
|
||
|
||
const state = useChatStore.getState()
|
||
assert.equal(removeCalls, 1)
|
||
assert.equal(state.currentSessionId, null, 'a late failure must not replace the newer task selection')
|
||
assert.equal(state.draftId, switchedDraftId, 'a late failure must not advance or rewind the draft owner')
|
||
assert.deepEqual(state.draftContext, { projectId: 'newer-task' })
|
||
assert.equal(state.sessions.some((item) => item.id === created.id), false)
|
||
}
|
||
|
||
async function testModelChangePreservesFrozenUsage(): Promise<void> {
|
||
resetStore()
|
||
const used = { inputTokens: 120, outputTokens: 30, totalTokens: 150 }
|
||
const stale = { inputTokens: 900, outputTokens: 50, totalTokens: 950 }
|
||
useChatStore.setState({
|
||
messages: [{
|
||
id: 'model-history-assistant',
|
||
role: 'assistant',
|
||
content: 'history',
|
||
createdAt: 1,
|
||
contextUsage: { usage: used, contextWindow: 64_000 }
|
||
}],
|
||
sessionUsage: { [session.id]: stale },
|
||
sessionContextWindow: { [session.id]: 128_000 }
|
||
})
|
||
installFakeWindow(async () => sendResult('unused'))
|
||
window.ycode.session.updateContext = async (_id, patch) => ({ ...session, ...patch })
|
||
|
||
await useChatStore.getState().selectModel('model-next')
|
||
assert.deepEqual(useChatStore.getState().sessionUsage[session.id], used,
|
||
'changing the selected model must restore the latest sample from retained history')
|
||
assert.equal(useChatStore.getState().sessionContextWindow[session.id], 64_000,
|
||
'changing the selected model must retain that sample’s frozen denominator')
|
||
|
||
useChatStore.setState({ sessionUsage: { [session.id]: used } })
|
||
window.ycode.session.updateContext = async () => { throw new Error('model update failed') }
|
||
await assert.rejects(useChatStore.getState().selectModel('model-rejected'), /model update failed/)
|
||
assert.deepEqual(useChatStore.getState().sessionUsage[session.id], used,
|
||
'a rejected model change must leave the existing usage sample intact')
|
||
assert.equal(useChatStore.getState().sessionContextWindow[session.id], 64_000)
|
||
window.ycode.session.updateContext = async (_id, patch) => ({ ...session, ...patch })
|
||
await useChatStore.getState().selectModel('model-profile-1') // Explicit repair clears the failed request-settings barrier.
|
||
}
|
||
|
||
async function testRevertRestoresBranchUsage(): Promise<void> {
|
||
resetStore()
|
||
let received: { sessionId: string; messageId: string; options: unknown } | null = null
|
||
installFakeWindow(async () => sendResult('unused'))
|
||
window.ycode.chat.revert = async (sessionId, messageId, options) => {
|
||
received = { sessionId, messageId, options }
|
||
return {
|
||
messages: [{
|
||
id: 'kept-assistant',
|
||
role: 'assistant',
|
||
content: 'kept',
|
||
createdAt: 1,
|
||
contextUsage: {
|
||
usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 },
|
||
contextWindow: 64_000
|
||
}
|
||
}]
|
||
}
|
||
}
|
||
useChatStore.setState({
|
||
sessionUsage: { [session.id]: { inputTokens: 900, outputTokens: 50, totalTokens: 950 } },
|
||
sessionContextWindow: { [session.id]: 128_000 }
|
||
})
|
||
|
||
await useChatStore.getState().revert('user-message', { rollbackArtifacts: false })
|
||
assert.deepEqual(received, {
|
||
sessionId: session.id,
|
||
messageId: 'user-message',
|
||
options: { rollbackArtifacts: false }
|
||
}, 'revert options must be forwarded without changing the default artifact behavior')
|
||
assert.deepEqual(useChatStore.getState().sessionUsage[session.id],
|
||
{ inputTokens: 120, outputTokens: 30, totalTokens: 150 },
|
||
'rewind must restore the last real usage sample on the retained branch')
|
||
assert.equal(useChatStore.getState().sessionContextWindow[session.id], 64_000,
|
||
'rewind must restore the denominator frozen into that sample')
|
||
|
||
window.ycode.chat.revert = async () => ({ messages: [] })
|
||
await useChatStore.getState().revert('older-message')
|
||
assert.equal(session.id in useChatStore.getState().sessionUsage, false,
|
||
'rewind to a branch without usage must clear the abandoned sample')
|
||
assert.equal(session.id in useChatStore.getState().sessionContextWindow, false)
|
||
}
|
||
|
||
async function testStoredUsageRestoresPerSessionAndKeepsLegacyUnknown(): Promise<void> {
|
||
resetStore()
|
||
installFakeWindow(async () => sendResult('unused'))
|
||
const branchA: ChatMessage[] = [
|
||
{ id: 'a-user-1', role: 'user', content: 'a1', createdAt: 1 },
|
||
{
|
||
id: 'a-assistant-1',
|
||
role: 'assistant',
|
||
content: 'a1',
|
||
createdAt: 2,
|
||
contextUsage: {
|
||
usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 },
|
||
contextWindow: 64_000
|
||
}
|
||
},
|
||
{ id: 'a-user-2', role: 'user', content: 'a2', createdAt: 3 },
|
||
{ id: 'a-streaming', role: 'assistant', content: '', createdAt: 4, status: 'streaming' }
|
||
]
|
||
const branchB: ChatMessage[] = [{
|
||
id: 'b-assistant',
|
||
role: 'assistant',
|
||
content: 'b',
|
||
createdAt: 5,
|
||
contextUsage: {
|
||
usage: { inputTokens: 300, outputTokens: 40, totalTokens: 340 },
|
||
contextWindow: 128_000
|
||
}
|
||
}]
|
||
const legacy: ChatMessage[] = [{
|
||
id: 'legacy-assistant',
|
||
role: 'assistant',
|
||
content: 'legacy',
|
||
createdAt: 6,
|
||
contextUsage: { usage: { inputTokens: 90, outputTokens: 10, totalTokens: 100 } }
|
||
}]
|
||
window.ycode.session.messages = async (id) => id === 'session-a' ? branchA : id === 'session-b' ? branchB : legacy
|
||
|
||
await useChatStore.getState().selectSession('session-a')
|
||
assert.equal(useChatStore.getState().sessionUsage['session-a'],undefined,
|
||
'a new assistant without a sample must not masquerade as the previous request')
|
||
assert.equal(useChatStore.getState().sessionContextWindow['session-a'],undefined)
|
||
|
||
await useChatStore.getState().selectSession('session-b')
|
||
assert.deepEqual(useChatStore.getState().sessionUsage['session-b'],
|
||
{ inputTokens: 300, outputTokens: 40, totalTokens: 340 })
|
||
assert.equal(useChatStore.getState().sessionContextWindow['session-b'], 128_000)
|
||
assert.equal(useChatStore.getState().sessionContextWindow['session-a'], undefined,
|
||
'selecting another task must not resurrect a superseded sample')
|
||
|
||
await useChatStore.getState().selectSession('legacy-session')
|
||
assert.deepEqual(useChatStore.getState().sessionUsage['legacy-session'],
|
||
{ inputTokens: 90, outputTokens: 10, totalTokens: 100 })
|
||
assert.equal('legacy-session' in useChatStore.getState().sessionContextWindow, false,
|
||
'historical samples without a denominator must stay unknown instead of borrowing the selected model window')
|
||
}
|
||
|
||
async function testUsageEventsKeepLatestSampleAndProjectIntoAssistant(): Promise<void> {
|
||
resetStore()
|
||
const result = sendResult('run-context-usage')
|
||
installFakeWindow(async () => result)
|
||
await useChatStore.getState().send('question')
|
||
|
||
useChatStore.getState().applyEvent({
|
||
runId: result.runId,
|
||
type: 'usage',
|
||
usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 },
|
||
contextWindow: 64_000
|
||
})
|
||
useChatStore.getState().applyEvent({
|
||
runId: result.runId,
|
||
type: 'usage',
|
||
usage: { inputTokens: 300, outputTokens: 40, totalTokens: 340 },
|
||
contextWindow: 128_000
|
||
})
|
||
|
||
assert.deepEqual(useChatStore.getState().sessionUsage[session.id],
|
||
{ inputTokens: 300, outputTokens: 40, totalTokens: 340 },
|
||
'multiple usage events must use the latest sample, never a sum')
|
||
assert.equal(useChatStore.getState().sessionContextWindow[session.id], 128_000)
|
||
assert.deepEqual(assistantMessage().contextUsage, {
|
||
requestId:result.runId,
|
||
usage: { inputTokens: 300, outputTokens: 40, totalTokens: 340 },
|
||
contextWindow: 128_000
|
||
}, 'the live assistant sample must match the recovered session sample')
|
||
|
||
useChatStore.getState().applyEvent({
|
||
runId: result.runId,
|
||
type: 'usage',
|
||
usage: { inputTokens: 50, outputTokens: 5, totalTokens: 55 }
|
||
})
|
||
assert.equal(useChatStore.getState().sessionContextWindow[session.id], undefined,
|
||
'a newer usage event without a real denominator must clear the previous denominator')
|
||
assert.deepEqual(assistantMessage().contextUsage, {
|
||
requestId:result.runId,
|
||
usage: { inputTokens: 50, outputTokens: 5, totalTokens: 55 }
|
||
})
|
||
}
|
||
|
||
async function testReopenedActiveRunUsesSnapshotSequence(): Promise<void> {
|
||
resetStore()
|
||
const active = deferred<ActiveChatRun | null>()
|
||
const contextUsage: ContextUsageSnapshot = {
|
||
usage: { inputTokens: 128, outputTokens: 8, totalTokens: 136 },
|
||
contextWindow: 64_000
|
||
}
|
||
installFakeWindow(async () => sendResult('unused'))
|
||
window.ycode.chat.activeRun = async (sessionId) => {
|
||
assert.equal(sessionId, 'reopened-session')
|
||
return active.promise
|
||
}
|
||
window.ycode.session.messages = async () => {
|
||
throw new Error('active run hydration must not fall back to disk')
|
||
}
|
||
|
||
const selection = useChatStore.getState().selectSession('reopened-session')
|
||
useChatStore.getState().applyEvent({
|
||
sessionId: 'reopened-session',
|
||
runId: 'run-reopen',
|
||
sequence: 6,
|
||
type: 'text',
|
||
delta: ' queued'
|
||
})
|
||
active.resolve(activeRunSnapshot('run-reopen', 'snapshot', 5, 'reopened-session', contextUsage))
|
||
await selection
|
||
|
||
const state = useChatStore.getState()
|
||
assert.equal(assistantMessage().content, 'snapshot queued',
|
||
'events after the active snapshot watermark must be replayed exactly once')
|
||
assert.equal(assistantMessage().status, 'streaming')
|
||
assert.equal(state.running?.runId, 'run-reopen')
|
||
assert.equal(state.runSequences['run-reopen'], 6)
|
||
assert.deepEqual(state.sessionUsage['reopened-session'], contextUsage.usage,
|
||
'an active snapshot must restore its persisted latest usage sample')
|
||
assert.equal(state.sessionContextWindow['reopened-session'], 64_000,
|
||
'an active snapshot must restore the turn’s frozen context window')
|
||
|
||
useChatStore.getState().applyEvent({
|
||
sessionId: 'reopened-session',
|
||
runId: 'run-reopen',
|
||
sequence: 6,
|
||
type: 'text',
|
||
delta: ' duplicate'
|
||
})
|
||
assert.equal(assistantMessage().content, 'snapshot queued', 'a duplicate sequence must be ignored')
|
||
}
|
||
|
||
async function testStartedSnapshotAdoptionAcrossWindows(): Promise<void> {
|
||
resetStore()
|
||
const localMessage: ChatMessage = {
|
||
id: 'local-other',
|
||
role: 'user',
|
||
content: 'other task',
|
||
createdAt: 1,
|
||
status: 'done'
|
||
}
|
||
useChatStore.setState({
|
||
currentSessionId: 'other-session',
|
||
messages: [localMessage],
|
||
sessionMessages: { 'other-session': [localMessage] },
|
||
runs: {},
|
||
runSequences: {},
|
||
sendingSessions: []
|
||
})
|
||
const background = activeRunSnapshot('run-background', 'background', 0)
|
||
useChatStore.getState().observeRun(background)
|
||
|
||
assert.deepEqual(useChatStore.getState().messages, [localMessage],
|
||
'a started snapshot for another task must not steal focus')
|
||
assert.equal(useChatStore.getState().sessionMessages[session.id]?.[1].content, 'background')
|
||
assert.equal(useChatStore.getState().runs['run-background']?.sessionId, session.id)
|
||
assert.equal(useChatStore.getState().running, null,
|
||
'a background started snapshot must update runs without changing the current running task')
|
||
|
||
useChatStore.setState({
|
||
currentSessionId: session.id,
|
||
messages: [],
|
||
sessionMessages: {},
|
||
runs: {},
|
||
runSequences: {},
|
||
sendingSessions: [session.id]
|
||
})
|
||
const local = activeRunSnapshot('run-local-send', 'local result', 0)
|
||
useChatStore.getState().observeRun(local)
|
||
assert.deepEqual(useChatStore.getState().messages, [],
|
||
'the window owning a pending local send must ignore its started broadcast')
|
||
assert.deepEqual(useChatStore.getState().runs, {})
|
||
|
||
useChatStore.setState({ sendingSessions: [] })
|
||
useChatStore.getState().observeRun(local)
|
||
const adopted = useChatStore.getState()
|
||
assert.equal(adopted.messages[1].content, 'local result')
|
||
assert.equal(adopted.running?.runId, 'run-local-send')
|
||
assert.equal(adopted.sending, false)
|
||
}
|
||
|
||
async function testHydrationBuffersKnownRunAndFiltersByWatermark(): Promise<void> {
|
||
resetStore()
|
||
const initial = activeRunSnapshot('run-watermark', 'old', 2)
|
||
useChatStore.setState({
|
||
currentSessionId: session.id,
|
||
messages: initial.messages,
|
||
sessionMessages: { [session.id]: initial.messages },
|
||
runs: { 'run-watermark': { runId: 'run-watermark', assistantId: 'run-watermark-assistant', sessionId: session.id } },
|
||
runSequences: { 'run-watermark': 2 }
|
||
})
|
||
const active = deferred<ActiveChatRun | null>()
|
||
installFakeWindow(async () => sendResult('unused'))
|
||
window.ycode.chat.activeRun = async () => active.promise
|
||
|
||
const selection = useChatStore.getState().selectSession(session.id, { reload: true })
|
||
useChatStore.getState().applyEvent({
|
||
sessionId: session.id,
|
||
runId: 'run-watermark',
|
||
sequence: 2,
|
||
type: 'text',
|
||
delta: ' duplicate'
|
||
})
|
||
useChatStore.getState().applyEvent({
|
||
sessionId: session.id,
|
||
runId: 'run-watermark',
|
||
sequence: 8,
|
||
type: 'text',
|
||
delta: ' after'
|
||
})
|
||
active.resolve(activeRunSnapshot('run-watermark', 'fresh', 7))
|
||
await selection
|
||
|
||
assert.equal(assistantMessage().content, 'fresh after',
|
||
'hydration must buffer events for a known run and replay only sequence greater than the snapshot')
|
||
assert.equal(useChatStore.getState().runSequences['run-watermark'], 8)
|
||
useChatStore.getState().applyEvent({
|
||
sessionId: session.id,
|
||
runId: 'run-watermark',
|
||
sequence: 8,
|
||
type: 'text',
|
||
delta: ' duplicate'
|
||
})
|
||
assert.equal(assistantMessage().content, 'fresh after')
|
||
}
|
||
|
||
async function testLateTerminalPreventsHydratedRunResurrection(): Promise<void> {
|
||
resetStore()
|
||
const active = deferred<ActiveChatRun | null>()
|
||
installFakeWindow(async () => sendResult('unused'))
|
||
window.ycode.chat.activeRun = async () => active.promise
|
||
|
||
const selection = useChatStore.getState().selectSession('terminal-session')
|
||
useChatStore.getState().applyEvent({
|
||
sessionId: 'terminal-session',
|
||
runId: 'run-late-terminal',
|
||
sequence: 4,
|
||
type: 'done',
|
||
content: 'finished',
|
||
tools: []
|
||
})
|
||
active.resolve(activeRunSnapshot('run-late-terminal', 'snapshot', 3, 'terminal-session'))
|
||
await selection
|
||
|
||
const state = useChatStore.getState()
|
||
assert.equal(assistantMessage().content, 'finished')
|
||
assert.equal(assistantMessage().status, 'done')
|
||
assert.equal(state.runs['run-late-terminal'], undefined, 'a late active snapshot must not revive a completed run')
|
||
assert.equal(state.running, null)
|
||
assert.equal(state.runSequences['run-late-terminal'], 4)
|
||
}
|
||
|
||
async function testTerminalActiveSnapshotDoesNotCreateRunning(): Promise<void> {
|
||
for (const status of ['done', 'cancelled', 'error'] as const) {
|
||
resetStore()
|
||
installFakeWindow(async () => sendResult('unused'))
|
||
const snapshot = activeRunSnapshot(`run-terminal-snapshot-${status}`, 'finished', 9)
|
||
const assistant = snapshot.messages?.find((message) => message.id === snapshot.assistantMessageId)
|
||
assert.ok(assistant)
|
||
assistant.status = status
|
||
if (status === 'error') assistant.error = 'snapshot failed'
|
||
window.ycode.chat.activeRun = async () => snapshot
|
||
|
||
await useChatStore.getState().selectSession(session.id, { reload: true })
|
||
|
||
const state = useChatStore.getState()
|
||
assert.equal(state.messages.find((message) => message.id === snapshot.assistantMessageId)?.status, status)
|
||
assert.equal(state.running, null, `${status}: terminal activeRun snapshot must not create a running task`)
|
||
assert.equal(state.runs[snapshot.runId], undefined, `${status}: terminal snapshot must not enter runs`)
|
||
assert.equal(state.runSequences[snapshot.runId], snapshot.sequence)
|
||
assert.equal(state.loading, false)
|
||
if (status === 'error') assert.equal(state.error, 'snapshot failed')
|
||
}
|
||
}
|
||
|
||
async function testLateStartedCannotReviveCompletedRun(): Promise<void> {
|
||
resetStore()
|
||
installFakeWindow(async () => sendResult('unused'))
|
||
const started = activeRunSnapshot('run-completed-watermark', 'started', 0)
|
||
useChatStore.getState().observeRun(started)
|
||
useChatStore.getState().applyEvent({
|
||
sessionId: session.id,
|
||
runId: started.runId,
|
||
sequence: 5,
|
||
type: 'done',
|
||
content: 'completed',
|
||
tools: []
|
||
})
|
||
assert.equal(useChatStore.getState().runs[started.runId], undefined)
|
||
assert.equal(useChatStore.getState().runSequences[started.runId], 5)
|
||
|
||
useChatStore.getState().observeRun(activeRunSnapshot(started.runId, 'late started', 0))
|
||
|
||
const state = useChatStore.getState()
|
||
assert.equal(state.runs[started.runId], undefined,
|
||
'a late started snapshot at or below the completed watermark must not recreate the run')
|
||
assert.equal(state.running, null)
|
||
assert.equal(assistantMessage().content, 'completed')
|
||
assert.equal(assistantMessage().status, 'done')
|
||
}
|
||
|
||
async function testEarlyOrderedEventsAndDuplicateGuard(): Promise<void> {
|
||
resetStore()
|
||
const result = sendResult('run-order')
|
||
const response = deferred<SendResult>()
|
||
let sendCalls = 0
|
||
const enteredSend = deferred<void>()
|
||
const events: ChatEvent[] = [
|
||
{ runId: 'other-run', type: 'text', delta: 'must be ignored' },
|
||
{ runId: result.runId, type: 'text', delta: 'before' },
|
||
{ runId: result.runId, type: 'tool_call', call: tool() },
|
||
{ runId: result.runId, type: 'text', delta: 'after' },
|
||
{
|
||
runId: result.runId,
|
||
type: 'tool_result',
|
||
call: tool({ status: 'ok', output: 'file contents', summary: 'file contents', durationMs: 12 })
|
||
},
|
||
{
|
||
runId: result.runId,
|
||
type: 'done',
|
||
content: 'beforeafter',
|
||
tools: [tool({ status: 'ok', output: 'file contents', summary: 'file contents', durationMs: 12 })]
|
||
}
|
||
]
|
||
|
||
installFakeWindow(async () => {
|
||
sendCalls++
|
||
enteredSend.resolve()
|
||
for (const event of events) useChatStore.getState().applyEvent(event)
|
||
return response.promise
|
||
})
|
||
|
||
const firstSend = useChatStore.getState().send('question')
|
||
assert.equal(useChatStore.getState().sending, true, 'sending should cover the pending IPC request')
|
||
assert.equal(useChatStore.getState().messages.length, 0, 'early events wait for the send result placeholder')
|
||
await assert.rejects(useChatStore.getState().send('duplicate'), /提交/)
|
||
// The duplicate rejects synchronously; preference persistence/capture may still
|
||
// be settling. Observe the actual IPC boundary rather than count microtasks.
|
||
let deadline: ReturnType<typeof setTimeout> | undefined
|
||
try {
|
||
await Promise.race([enteredSend.promise, new Promise<never>((_, reject) => {
|
||
deadline = setTimeout(() => reject(new Error('first send did not reach IPC')), 2000)
|
||
})])
|
||
} finally { clearTimeout(deadline) }
|
||
assert.equal(sendCalls, 1, 'a second send must be ignored while the first send is pending')
|
||
|
||
response.resolve(result)
|
||
await firstSend
|
||
assert.equal(sendCalls, 1, 'duplicate must never reach IPC after the first submission completes')
|
||
|
||
const message = assistantMessage()
|
||
assert.equal(message.turnStartedAt, undefined, 'terminal events clear the live timer timestamp')
|
||
assert.equal(message.content, 'beforeafter')
|
||
assert.deepEqual(message.parts, [
|
||
{ type: 'text', text: 'before' },
|
||
{ type: 'tool', toolId: 'tool-1' },
|
||
{ type: 'text', text: 'after' }
|
||
])
|
||
assert.equal(message.status, 'done')
|
||
assert.equal(message.tools?.length, 1)
|
||
assert.equal(message.tools?.[0].output, 'file contents')
|
||
assert.equal(useChatStore.getState().running, null, 'done should clear the running turn')
|
||
assert.equal(useChatStore.getState().sending, false, 'the send request should leave the sending state')
|
||
assert.equal(message.content.includes('must be ignored'), false, 'events from another run must be ignored')
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'late' })
|
||
assert.equal(assistantMessage().content, 'beforeafter', 'events after a terminal event must not change the message')
|
||
}
|
||
|
||
async function testLiveTurnStartTimestamp(): Promise<void> {
|
||
resetStore()
|
||
const result = sendResult('run-live-timer')
|
||
installFakeWindow(async () => result)
|
||
|
||
await useChatStore.getState().send('question')
|
||
const message = assistantMessage()
|
||
assert.equal(message.status, 'streaming')
|
||
assert.equal(message.modelId, 'model-profile-1', 'assistant placeholder keeps the model identity from the send result')
|
||
assert.equal(message.modelName, 'Frozen Model', 'assistant placeholder can render its model label before reloading history')
|
||
assert.equal(message.turnStartedAt, result.userMessage.createdAt,
|
||
'the live timer starts from the same user-submit timestamp used by terminal duration')
|
||
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'answer' })
|
||
assert.equal(assistantMessage().turnStartedAt, result.userMessage.createdAt,
|
||
'ordinary stream updates keep the timer origin stable')
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'done', content: 'answer', tools: [], turnDurationMs: 1200 })
|
||
assert.equal(assistantMessage().turnDurationMs, 1200)
|
||
assert.equal(assistantMessage().turnStartedAt, undefined)
|
||
}
|
||
|
||
async function testEarlyTerminalEvents(): Promise<void> {
|
||
const cases: Array<{
|
||
name: string
|
||
terminal: ChatEvent
|
||
messageStatus: 'cancelled' | 'error'
|
||
toolStatus: 'cancelled' | 'error'
|
||
}> = [
|
||
{
|
||
name: 'cancelled',
|
||
terminal: { runId: 'run-cancel', type: 'cancelled' },
|
||
messageStatus: 'cancelled',
|
||
toolStatus: 'cancelled'
|
||
},
|
||
{
|
||
name: 'error',
|
||
terminal: { runId: 'run-error', type: 'error', message: 'provider failed' },
|
||
messageStatus: 'error',
|
||
toolStatus: 'error'
|
||
}
|
||
]
|
||
|
||
for (const scenario of cases) {
|
||
resetStore()
|
||
const result = sendResult(scenario.terminal.runId)
|
||
const response = deferred<SendResult>()
|
||
installFakeWindow(async () => {
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'prefix' })
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'tool_call', call: tool({ output: 'partial output' }) })
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'suffix' })
|
||
useChatStore.getState().applyEvent(scenario.terminal)
|
||
return response.promise
|
||
})
|
||
|
||
const sending = useChatStore.getState().send('question')
|
||
response.resolve(result)
|
||
await sending
|
||
|
||
const message = assistantMessage()
|
||
assert.equal(message.content, 'prefixsuffix', `${scenario.name}: text received before the result must be retained`)
|
||
assert.deepEqual(message.parts, [
|
||
{ type: 'text', text: 'prefix' },
|
||
{ type: 'tool', toolId: 'tool-1' },
|
||
{ type: 'text', text: 'suffix' }
|
||
], `${scenario.name}: ordered content should survive a terminal event`)
|
||
assert.equal(message.status, scenario.messageStatus)
|
||
assert.equal(message.tools?.[0].status, scenario.toolStatus)
|
||
assert.equal(message.tools?.[0].output, 'partial output', `${scenario.name}: terminal state must preserve tool output`)
|
||
assert.equal(useChatStore.getState().running, null)
|
||
assert.equal(useChatStore.getState().sending, false)
|
||
if (scenario.name === 'error') assert.equal(useChatStore.getState().error, 'provider failed')
|
||
}
|
||
}
|
||
|
||
async function testFailedSendCanRetry(): Promise<void> {
|
||
resetStore()
|
||
let sendCalls = 0
|
||
const retryResult = sendResult('run-retry')
|
||
installFakeWindow(async () => {
|
||
sendCalls++
|
||
if (sendCalls === 1) {
|
||
useChatStore.getState().applyEvent({ runId: 'run-failed', type: 'text', delta: 'discarded failed run' })
|
||
throw new Error('send failed')
|
||
}
|
||
useChatStore.getState().applyEvent({
|
||
runId: retryResult.runId,
|
||
type: 'done',
|
||
content: 'retry complete',
|
||
tools: []
|
||
})
|
||
return retryResult
|
||
})
|
||
|
||
await assert.rejects(useChatStore.getState().send('question'), /send failed/)
|
||
assert.equal(useChatStore.getState().sending, false, 'a rejected send must reset sending')
|
||
assert.equal(useChatStore.getState().error, 'send failed')
|
||
assert.equal(useChatStore.getState().messages.length, 0, 'a failed send must not install a placeholder')
|
||
|
||
await useChatStore.getState().send('retry')
|
||
assert.equal(sendCalls, 2, 'a new send should be accepted after a failed request')
|
||
const message = assistantMessage()
|
||
assert.equal(message.content, 'retry complete', 'failed-run buffered events must not leak into a retry')
|
||
assert.equal(message.status, 'done')
|
||
assert.equal(useChatStore.getState().sending, false)
|
||
assert.equal(useChatStore.getState().running, null)
|
||
}
|
||
|
||
async function testCancelFlushesPendingAndIgnoresLateEvents(): Promise<void> {
|
||
resetStore()
|
||
const result = sendResult('run-user-cancel')
|
||
installFakeWindow(async () => result)
|
||
let cancelCalls = 0
|
||
window.ycode.chat.cancel = async (runId) => {
|
||
cancelCalls++
|
||
assert.equal(runId, result.runId)
|
||
return { cancelled: true }
|
||
}
|
||
|
||
await useChatStore.getState().send('question')
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'already rendered' })
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: ' pending text' })
|
||
await useChatStore.getState().cancel()
|
||
assert.equal(cancelCalls, 1)
|
||
assert.equal(assistantMessage().content, 'already rendered pending text', 'cancel must flush pending text before requesting cancellation')
|
||
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'late before terminal' })
|
||
assert.equal(assistantMessage().content, 'already rendered pending text', 'text after a cancel request must be ignored')
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'cancelled' })
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'late after terminal' })
|
||
assert.equal(assistantMessage().content, 'already rendered pending text')
|
||
assert.equal(assistantMessage().status, 'cancelled')
|
||
}
|
||
|
||
async function testSessionSwitchFlushesPendingIntoItsOwner(): Promise<void> {
|
||
resetStore()
|
||
const result = sendResult('run-session-switch')
|
||
installFakeWindow(async () => result)
|
||
await useChatStore.getState().send('question')
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'session one' })
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: ' pending' })
|
||
|
||
await useChatStore.getState().selectSession('session-2')
|
||
assert.equal(useChatStore.getState().messages.length, 0, 'switching sessions must not show the outgoing assistant message')
|
||
assert.equal(useChatStore.getState().sessionMessages[session.id]?.find((message) => message.id === result.assistantMessageId)?.content,
|
||
'session one pending', 'switching must flush buffered text into the session that owns the run')
|
||
|
||
await useChatStore.getState().selectSession(session.id)
|
||
assert.equal(assistantMessage().content, 'session one pending', 'restoring the session must preserve the flushed stream order')
|
||
useChatStore.getState().applyEvent({ runId: result.runId, type: 'done', content: 'session one pending', tools: [] })
|
||
}
|
||
|
||
async function testParallelSessionsAndStaleLoads(): Promise<void> {
|
||
resetStore()
|
||
const a = deferred<SendResult>()
|
||
const b = deferred<SendResult>()
|
||
installFakeWindow(async (payload) => payload.sessionId === session.id ? a.promise : b.promise)
|
||
const first = useChatStore.getState().send('A')
|
||
await useChatStore.getState().selectSession('session-2')
|
||
assert.equal(useChatStore.getState().sending, false)
|
||
const second = useChatStore.getState().send('B')
|
||
useChatStore.getState().applyEvent({ sessionId: session.id, runId: 'a', type: 'text', delta: 'early A' })
|
||
useChatStore.getState().applyEvent({ sessionId: 'session-2', runId: 'b', type: 'text', delta: 'early B' })
|
||
b.resolve(sendResult('b'))
|
||
await second
|
||
a.resolve(sendResult('a'))
|
||
await first
|
||
assert.equal(assistantMessage().content, 'early B', 'a background send cannot replace the foreground')
|
||
assert.equal(useChatStore.getState().running?.runId, 'b')
|
||
useChatStore.getState().applyEvent({ sessionId: session.id, runId: 'a', type: 'done', content: 'finished A', tools: [] })
|
||
assert.equal(useChatStore.getState().running?.runId, 'b', 'background terminal cannot stop foreground run')
|
||
await useChatStore.getState().selectSession(session.id)
|
||
assert.equal(assistantMessage().content, 'finished A', 'switch reads current in-memory data')
|
||
assert.equal(useChatStore.getState().running, null)
|
||
await useChatStore.getState().selectSession('session-2')
|
||
assert.equal(useChatStore.getState().running?.runId, 'b')
|
||
useChatStore.getState().applyEvent({ runId: 'b', type: 'cancelled' })
|
||
|
||
const slow = deferred<ChatMessage[]>()
|
||
window.ycode.session.messages = async (id) => id === 'slow' ? slow.promise : [{ id: 'fast-user', role: 'user', content: 'fast', createdAt: 1 }]
|
||
const oldSelection = useChatStore.getState().selectSession('slow')
|
||
await useChatStore.getState().selectSession('fast')
|
||
slow.resolve([{ id: 'old', role: 'user', content: 'slow', createdAt: 1 }])
|
||
await oldSelection
|
||
assert.equal(useChatStore.getState().currentSessionId, 'fast')
|
||
assert.equal(useChatStore.getState().messages[0].content, 'fast', 'stale session fetch cannot replace a newer selection')
|
||
}
|
||
|
||
async function testNativeSegmentBoundaryBeforeSendResponse(): Promise<void> {
|
||
resetStore()
|
||
const gate = deferred<SendResult>()
|
||
installFakeWindow(async () => gate.promise)
|
||
const sending = useChatStore.getState().send('root')
|
||
const runId = 'native-segment'
|
||
const root = sendResult(runId)
|
||
useChatStore.getState().applyEvent({ runId, sessionId: session.id, sequence: 1, type: 'text', delta: 'root answer' })
|
||
const guide: ChatMessage = { id: 'guide-user', role: 'user', content: 'guidance', createdAt: 12, status: 'done' }
|
||
const answer: ChatMessage = { id: 'guide-assistant', role: 'assistant', content: '', createdAt: 13, status: 'streaming', requestId: runId }
|
||
useChatStore.getState().observeRun({ ...root, sessionId: session.id, sequence: 2, segmentBoundary: true,
|
||
assistantMessageId: answer.id, userMessage: guide, messages: [root.userMessage,
|
||
{ id: root.assistantMessageId, role: 'assistant', content: 'root answer', createdAt: 11, status: 'done' }, guide, answer] })
|
||
useChatStore.getState().applyEvent({ runId, sessionId: session.id, sequence: 3, type: 'text', delta: 'guide answer' })
|
||
gate.resolve(root)
|
||
await sending
|
||
useChatStore.getState().applyEvent({ runId, sessionId: session.id, sequence: 4, type: 'done', content: 'guide answer', tools: [] })
|
||
const messages = useChatStore.getState().messages
|
||
assert.equal(messages.find(message => message.id === root.assistantMessageId)?.content, 'root answer')
|
||
assert.equal(messages.find(message => message.id === answer.id)?.content, 'guide answer')
|
||
assert.equal(messages.find(message => message.id === answer.id)?.status, 'done')
|
||
assert.equal(useChatStore.getState().running, null)
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
await testNativeSegmentBoundaryBeforeSendResponse()
|
||
resetStore()
|
||
let inlinePayload: Parameters<typeof window.ycode.chat.send>[0] | undefined
|
||
installFakeWindow(async payload => { inlinePayload = payload; return sendResult('inline-layout') })
|
||
await useChatStore.getState().send(' 前后 ', { quotes: [{ id: 'q', kind: 'table', sourceId: 'source', markdown: '| x |', preview: '| x |' }],
|
||
userContent: [{ type: 'text', text: ' 前' }, { type: 'quote', id: 'q' }, { type: 'text', text: '后 ' }] })
|
||
assert.equal(inlinePayload?.text, '前后')
|
||
assert.deepEqual(inlinePayload?.userContent, [{ type: 'text', text: '前' }, { type: 'quote', id: 'q' }, { type: 'text', text: '后' }],
|
||
'trimmed edit and retry submissions must keep the inline reference order')
|
||
resetStore()
|
||
let referenceOnlySends = 0
|
||
installFakeWindow(async () => { referenceOnlySends++; return sendResult('reference-only') })
|
||
await useChatStore.getState().send('', { resources: ['C:/workspace/reference.md'] })
|
||
assert.equal(referenceOnlySends, 1, 'a resource-only draft must reach IPC instead of being silently discarded')
|
||
resetStore()
|
||
installFakeWindow(async () => { referenceOnlySends++; return sendResult('skill-only') })
|
||
await useChatStore.getState().send('', { skillIds: ['skill'] })
|
||
assert.equal(referenceOnlySends, 2, 'a skill-only draft must reach IPC')
|
||
let capabilityPayload: Parameters<typeof window.ycode.chat.send>[0] | undefined
|
||
resetStore()
|
||
installFakeWindow(async payload => { capabilityPayload = payload; return sendResult('mcp-only') })
|
||
await useChatStore.getState().send('', { mcpServerIds: ['server'] })
|
||
assert.deepEqual(capabilityPayload?.mcpServerIds, ['server'], 'an MCP-only draft must reach IPC with its selected server')
|
||
resetStore()
|
||
installFakeWindow(async payload => { capabilityPayload = payload; return sendResult('compact-only') })
|
||
await useChatStore.getState().send('/compact', { command: 'compact' })
|
||
assert.equal(capabilityPayload?.command, 'compact', 'a command-only draft must reach IPC as a native command')
|
||
|
||
resetStore()
|
||
useChatStore.setState({ currentSessionId: null, messages: [], draftContext: {}, draftId: 7 })
|
||
const deferredResult = deferred<SendResult>()
|
||
installFakeWindow(async () => deferredResult.promise)
|
||
const optimisticSend = useChatStore.getState().send('', { skillIds: ['optimistic-skill'], mcpServerIds: ['optimistic-server'] })
|
||
const optimisticUser = useChatStore.getState().messages.find(message => message.role === 'user')
|
||
assert.deepEqual(optimisticUser?.skills?.map(item => item.id), ['optimistic-skill'], 'the optimistic user message must retain selected skill metadata')
|
||
assert.deepEqual(optimisticUser?.mcpServerIds, ['optimistic-server'], 'the optimistic user message must retain MCP selection metadata')
|
||
deferredResult.resolve(sendResult('optimistic-mcp'))
|
||
await optimisticSend
|
||
await testFailedCreatedSessionIsRemoved()
|
||
await testFailedCreatedSessionWithMessagesIsRetained()
|
||
await testUnsafeCleanupDoesNotMasqueradeAsHome()
|
||
await testBufferedBackgroundEventPreventsDeletion()
|
||
await testLateSendFailureCannotOverrideAnotherTask()
|
||
await testModelChangePreservesFrozenUsage()
|
||
await testRevertRestoresBranchUsage()
|
||
await testStoredUsageRestoresPerSessionAndKeepsLegacyUnknown()
|
||
await testUsageEventsKeepLatestSampleAndProjectIntoAssistant()
|
||
await testReopenedActiveRunUsesSnapshotSequence()
|
||
await testStartedSnapshotAdoptionAcrossWindows()
|
||
await testHydrationBuffersKnownRunAndFiltersByWatermark()
|
||
await testLateTerminalPreventsHydratedRunResurrection()
|
||
await testTerminalActiveSnapshotDoesNotCreateRunning()
|
||
await testLateStartedCannotReviveCompletedRun()
|
||
await testEarlyOrderedEventsAndDuplicateGuard()
|
||
await testLiveTurnStartTimestamp()
|
||
await testEarlyTerminalEvents()
|
||
await testFailedSendCanRetry()
|
||
await testCancelFlushesPendingAndIgnoresLateEvents()
|
||
await testSessionSwitchFlushesPendingIntoItsOwner()
|
||
await testParallelSessionsAndStaleLoads()
|
||
resetStore()
|
||
installFakeWindow(async () => sendResult('unused'))
|
||
const creation = deferred<SessionMeta>()
|
||
window.ycode.session.create = async () => creation.promise
|
||
const creating = useChatStore.getState().newSession()
|
||
await useChatStore.getState().selectSession('chosen-later')
|
||
creation.resolve({ ...session, id: 'slow-created' })
|
||
await creating
|
||
assert.equal(useChatStore.getState().currentSessionId, 'chosen-later', 'late session creation cannot override a later selection')
|
||
|
||
resetStore()
|
||
useChatStore.setState({ currentSessionId: null })
|
||
let sentTo = ''
|
||
installFakeWindow(async (payload) => { sentTo = payload.sessionId; return sendResult('owned-create') })
|
||
const ownedCreation = deferred<SessionMeta>()
|
||
window.ycode.session.create = async () => ownedCreation.promise
|
||
const ownedSend = useChatStore.getState().send('original draft')
|
||
await useChatStore.getState().selectSession('other-session')
|
||
ownedCreation.resolve({ ...session, id: 'owned-session' })
|
||
await ownedSend
|
||
assert.equal(sentTo, 'owned-session', 'pending creation keeps the send bound to its own session')
|
||
assert.equal(useChatStore.getState().currentSessionId, 'other-session')
|
||
resetStore()
|
||
installFakeWindow(async () => sendResult('unused'))
|
||
window.ycode.session.list = async () => { throw new Error('corrupt session store') }
|
||
await useChatStore.getState().init()
|
||
assert.equal(useChatStore.getState().error, 'corrupt session store', 'load failure is visible instead of silently creating an empty session')
|
||
console.log('chat-store-regression: all checks passed')
|
||
}
|
||
|
||
void main()
|