fix(desktop): preserve history after runtime config errors (#1303)

Treat rejected runtime selections as non-terminal errors and restore the full transcript when only configuration errors are present. Cover errors before, during, and after history loading, plus active streaming and permission states.
This commit is contained in:
Relakkes Yang
2026-09-08 13:10:48 +08:00
parent 95834e156e
commit 951cd0be3f
2 changed files with 134 additions and 14 deletions
+108
View File
@@ -493,6 +493,114 @@ describe('chatStore history mapping', () => {
})
})
it.each(['before', 'during', 'after'] as const)(
'restores an existing transcript when runtime selection fails %s history loading',
async (errorTiming) => {
let resolveHistory!: (value: { messages: MessageEntry[] }) => void
vi.mocked(sessionsApi.getMessages).mockReturnValueOnce(new Promise((resolve) => {
resolveHistory = resolve
}))
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ chatState: 'idle' }),
},
})
const runtimeError: ServerMessage = {
type: 'error',
message: 'Runtime effort selection is invalid.',
code: 'RUNTIME_CONFIG_INVALID',
}
if (errorTiming === 'before') {
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, runtimeError)
}
const historyLoad = useChatStore.getState().loadHistory(TEST_SESSION_ID)
if (errorTiming === 'during') {
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, runtimeError)
}
resolveHistory({
messages: [
{
id: 'old-user',
type: 'user',
timestamp: '2026-07-01T00:00:00.000Z',
content: [{ type: 'text', text: 'Previously saved question' }],
},
{
id: 'old-assistant',
type: 'assistant',
timestamp: '2026-07-01T00:00:01.000Z',
content: [{ type: 'text', text: 'Previously saved answer' }],
},
],
})
await historyLoad
if (errorTiming === 'after') {
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, runtimeError)
}
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.messages).toEqual([
expect.objectContaining({ type: 'user_text', content: 'Previously saved question' }),
expect.objectContaining({ type: 'assistant_text', content: 'Previously saved answer' }),
expect.objectContaining({ type: 'error', code: 'RUNTIME_CONFIG_INVALID' }),
])
expect(session?.historyStatus).toBe('ready')
},
)
it.each(['streaming', 'permission_pending'] as const)(
'keeps the active %s turn intact when runtime selection is rejected',
(chatState) => {
const elapsedTimer = setInterval(() => {}, 1_000)
try {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'streaming',
streamingText: 'Unfinished reply',
activeThinkingId: 'thinking-current',
elapsedTimer,
historyMutationEpoch: 3,
}),
},
})
if (chatState === 'permission_pending') {
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'permission_request',
requestId: 'permission-current',
toolName: 'Read',
toolUseId: 'tool-current',
input: { file_path: '/fixture/outside.txt' },
})
}
const before = useChatStore.getState().sessions[TEST_SESSION_ID]!
updateTabStatusMock.mockClear()
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'error',
message: 'Runtime effort selection is invalid.',
code: 'RUNTIME_CONFIG_INVALID',
})
const after = useChatStore.getState().sessions[TEST_SESSION_ID]!
expect(after.chatState).toBe(chatState)
expect(after.streamingText).toBe(before.streamingText)
expect(after.activeThinkingId).toBe(before.activeThinkingId)
expect(after.pendingPermission).toEqual(before.pendingPermission)
expect(after.pendingPermissions).toEqual(before.pendingPermissions)
expect(after.elapsedTimer).toBe(elapsedTimer)
expect(after.historyMutationEpoch).toBe(before.historyMutationEpoch)
expect(after.messages).toEqual([
...before.messages,
expect.objectContaining({ type: 'error', code: 'RUNTIME_CONFIG_INVALID' }),
])
expect(updateTabStatusMock).not.toHaveBeenCalledWith(TEST_SESSION_ID, 'error')
} finally {
clearInterval(elapsedTimer)
}
},
)
it('does not prewarm an existing transcript when opening it for history review', () => {
sessionStoreSnapshot.sessions = [{
id: TEST_SESSION_ID,
+26 -14
View File
@@ -2276,7 +2276,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
: state
}
historyApplied = true
if (session.messages.length > 0) {
// A rejected configuration can arrive before the initial transcript.
// These notices do not mean that conversation history is hydrated.
if (session.messages.some((message) => (
message.type !== 'error' || message.code !== 'RUNTIME_CONFIG_INVALID'
))) {
return { sessions: updateSessionIn(state.sessions, sessionId, (s) => {
const backgroundAgentTasks = mergeBackgroundAgentTaskRecords(
s.backgroundAgentTasks ?? {},
@@ -2310,7 +2314,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
s.backgroundAgentTasks ?? {},
restoredBackgroundTasks,
)
const messages = mergeBackgroundTaskMessages(uiMessages, backgroundAgentTasks)
const messages = mergeBackgroundTaskMessages(
[...uiMessages, ...s.messages],
backgroundAgentTasks,
)
return {
historyStatus: 'ready',
historyError: null,
@@ -3626,7 +3633,21 @@ export const useChatStore = create<ChatStore>((set, get) => ({
break
}
case 'error':
case 'error': {
const errorMessage: Extract<UIMessage, { type: 'error' }> = {
id: nextId(),
type: 'error',
message: msg.message,
code: msg.code,
...(msg.businessErrorCode ? { businessErrorCode: msg.businessErrorCode } : {}),
timestamp: Date.now(),
}
if (msg.code === 'RUNTIME_CONFIG_INVALID') {
// Validation rejects the selection before changing the runtime. It
// neither ends the active turn nor invalidates an in-flight history load.
update((s) => ({ messages: [...s.messages, errorMessage] }))
break
}
update((s) => {
const pendingText = `${s.streamingText}${consumePendingDelta(sessionId)}`
let newMessages = s.messages
@@ -3634,17 +3655,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
newMessages = appendAssistantTextMessage(newMessages, pendingText, Date.now())
}
newMessages = dropTailCompactingCompactSummary(newMessages)
newMessages = [
...newMessages,
{
id: nextId(),
type: 'error',
message: msg.message,
code: msg.code,
...(msg.businessErrorCode ? { businessErrorCode: msg.businessErrorCode } : {}),
timestamp: Date.now(),
},
]
newMessages = [...newMessages, errorMessage]
return {
messages: newMessages,
chatState: 'idle',
@@ -3670,6 +3681,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}
}
break
}
case 'background_task_stop_failed':
update((session) => {