feat: complete WorkBuddy parity and Windows release workflow
This commit is contained in:
1 parent
143516b3a8
commit
d5512df549
434 files changed
+54977
-1854
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { useChatStore } from '../src/renderer/src/stores/chat'
|
||||
import type { SendResult } from '../src/shared/ipc'
|
||||
import type { ChatEvent, ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types'
|
||||
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', createdAt: 1, updatedAt: 1 }
|
||||
|
||||
@@ -31,18 +31,64 @@ function sendResult(runId: string): SendResult {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
useChatStore.setState({
|
||||
sessions: [session],
|
||||
currentSessionId: session.id,
|
||||
messages: [],
|
||||
sessionMessages: {},
|
||||
sessionUsage: {},
|
||||
sessionContextWindow: {},
|
||||
runs: {},
|
||||
runSequences: {},
|
||||
sendingSessions: [],
|
||||
running: null,
|
||||
sending: false,
|
||||
loading: false,
|
||||
error: null
|
||||
error: null,
|
||||
draftContext: {},
|
||||
draftId: 0
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,14 +97,38 @@ function installFakeWindow(send: (payload: { sessionId: string; text: string })
|
||||
session: {
|
||||
list: async () => [session],
|
||||
create: async () => session,
|
||||
messages: async () => [],
|
||||
messages: async (id: string) => useChatStore.getState().sessionMessages[id] ?? [],
|
||||
rename: async () => undefined,
|
||||
remove: async () => undefined
|
||||
remove: async () => undefined,
|
||||
updateContext: async (_id: string, patch: object) => ({ ...session, ...patch })
|
||||
},
|
||||
chat: {
|
||||
send,
|
||||
cancel: async () => ({ cancelled: true }),
|
||||
onEvent: () => () => undefined
|
||||
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', {
|
||||
@@ -73,12 +143,546 @@ function assistantMessage(): ChatMessage {
|
||||
return message
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
|
||||
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void; reject(reason?: unknown): void } {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((done, fail) => {
|
||||
resolve = done
|
||||
reject = fail
|
||||
})
|
||||
return { promise, resolve }
|
||||
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' }, '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)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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> {
|
||||
@@ -325,6 +929,38 @@ async function testParallelSessionsAndStaleLoads(): Promise<void> {
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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')
|
||||
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()
|
||||
|
||||
Reference in new issue
Block a user