diff --git a/src/main/presenter/remoteControlPresenter/qqbot/qqbotRuntime.ts b/src/main/presenter/remoteControlPresenter/qqbot/qqbotRuntime.ts index fe14ca1f5..583793198 100644 --- a/src/main/presenter/remoteControlPresenter/qqbot/qqbotRuntime.ts +++ b/src/main/presenter/remoteControlPresenter/qqbot/qqbotRuntime.ts @@ -8,10 +8,10 @@ import { type QQBotTransportTarget } from '../types' import { RemoteBindingStore } from '../services/remoteBindingStore' +import { REMOTE_NO_RESPONSE_TEXT } from '../services/remoteBlockRenderer' import type { QQBotCommandRouteResult } from '../services/qqbotCommandRouter' import { QQBotCommandRouter } from '../services/qqbotCommandRouter' import type { RemoteConversationExecution } from '../services/remoteConversationRunner' -import { REMOTE_NO_RESPONSE_TEXT } from '../services/remoteBlockRenderer' import { buildFeishuPendingInteractionText } from '../feishu/feishuInteractionPrompt' import { QQBotClient } from './qqbotClient' import { QQBotGatewaySession, type QQBotGatewayBotUser } from './qqbotGatewaySession' @@ -21,6 +21,7 @@ const QQBOT_INBOUND_DEDUP_LIMIT = 500 const QQBOT_INBOUND_DEDUP_TTL_MS = 10 * 60 * 1000 const QQBOT_MAX_PASSIVE_REPLIES = 5 const QQBOT_INTERNAL_ERROR_REPLY = 'An internal error occurred while processing your request.' +const QQBOT_TIMEOUT_REPLY = 'The current conversation timed out before finishing. Please try again.' const sleep = async (ms: number): Promise => { await new Promise((resolve) => setTimeout(resolve, ms)) @@ -42,14 +43,16 @@ type QQBotProcessedInboundEntry = { receivedAt: number } -type QQBotRemoteDeliveryState = { - sourceMessageId: string - segments: Array<{ - key: string - kind: 'process' | 'answer' | 'terminal' - messageIds: Array - lastText: string - }> +type QQBotPendingProcessBatch = { + keys: string[] + ready: boolean +} + +type QQBotToolBufferState = { + sourceMessageId: string | null + pendingProcessSegments: QQBotPendingProcessBatch[] + lastProcessTextByKey: Map + flushedProcessKeys: Set } type QQBotSendContext = { @@ -71,6 +74,7 @@ export class QQBotRuntime { } private readonly processedInboundByMessage = new Map() private readonly endpointOperations = new Map>() + private readonly endpointToolBuffers = new Map() constructor(private readonly deps: QQBotRuntimeDeps) { this.gateway = new QQBotGatewaySession({ @@ -136,6 +140,7 @@ export class QQBotRuntime { this.runId += 1 await this.gateway.stop() this.endpointOperations.clear() + this.endpointToolBuffers.clear() this.processedInboundByMessage.clear() this.setStatus({ state: 'stopped' @@ -274,6 +279,13 @@ export class QQBotRuntime { return } + const endpointKey = buildQQBotEndpointKey(parsed.chatType, parsed.chatId) + this.markBufferedProcessBatchesReady(endpointKey) + await this.flushBufferedProcessMessages(endpointKey, sendContext, { + reserveTerminalSlot: true + }).catch(() => undefined) + this.clearToolBuffer(endpointKey) + this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) await this.sendText(sendContext, QQBOT_INTERNAL_ERROR_REPLY).catch(() => undefined) } } @@ -310,6 +322,8 @@ export class QQBotRuntime { ): Promise { const startedAt = Date.now() const endpointKey = buildQQBotEndpointKey(message.chatType, message.chatId) + this.clearToolBuffer(endpointKey) + this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) while (this.isCurrentRun(runId)) { const snapshot = await execution.getSnapshot() @@ -317,144 +331,75 @@ export class QQBotRuntime { return } - const sourceMessageId = snapshot.messageId ?? execution.eventId ?? null - let deliveryState = this.getStoredDeliveryState(endpointKey) - deliveryState = await this.prepareDeliveryStateForSource( - endpointKey, - sourceMessageId, - deliveryState - ) - let deliverySegments = this.getSnapshotDeliverySegments(snapshot, sourceMessageId) - - if (sourceMessageId) { - deliveryState = deliveryState ?? this.createDeliveryState(sourceMessageId) - } + const sourceMessageId = this.getConversationSourceMessageId(message, execution, snapshot) + const deliverySegments = this.getSnapshotDeliverySegments(snapshot, sourceMessageId) + const timedOut = Date.now() - startedAt >= FEISHU_CONVERSATION_POLL_TIMEOUT_MS + this.syncToolBuffer(endpointKey, sourceMessageId, deliverySegments, { + flushTrailingBatch: snapshot.completed || timedOut + }) if (snapshot.completed) { if (snapshot.pendingInteraction) { - if (deliveryState && deliverySegments.length > 0) { - deliveryState = await this.syncDeliverySegments( - deliveryState, - endpointKey, - sendContext, - deliverySegments - ) - } - + await this.flushBufferedProcessMessages(endpointKey, sendContext, { + reserveTerminalSlot: true + }) await this.sendText( sendContext, buildFeishuPendingInteractionText(snapshot.pendingInteraction) ) + this.clearToolBuffer(endpointKey) this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) return } const finalText = this.getFinalDeliveryText(snapshot) - deliverySegments = this.appendTerminalDeliverySegment( - deliverySegments, - sourceMessageId, - finalText + const skipNoResponseTerminal = this.shouldSkipNoResponseTerminal(endpointKey, finalText) + const didFlushProcessOutput = await this.flushBufferedProcessMessages( + endpointKey, + sendContext, + { + reserveTerminalSlot: Boolean(finalText) && !skipNoResponseTerminal + } ) - if (deliveryState) { - if (deliverySegments.length > 0) { - deliveryState = await this.syncDeliverySegments( - deliveryState, - endpointKey, - sendContext, - deliverySegments - ) - } - this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) - } else if (finalText) { + if (finalText && (!skipNoResponseTerminal || !didFlushProcessOutput)) { await this.sendText(sendContext, finalText) } - - return - } - - if (Date.now() - startedAt >= FEISHU_CONVERSATION_POLL_TIMEOUT_MS) { - await this.sendText( - sendContext, - 'The current conversation timed out before finishing. Please try again.' - ) + this.clearToolBuffer(endpointKey) this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) - return - } - if (deliveryState && deliverySegments.length > 0) { - deliveryState = await this.syncDeliverySegments( - deliveryState, - endpointKey, - sendContext, - deliverySegments - ) + return } - if (sendContext.sentCount >= QQBOT_MAX_PASSIVE_REPLIES) { + if (timedOut) { + await this.flushBufferedProcessMessages(endpointKey, sendContext, { + reserveTerminalSlot: true + }) + await this.sendText(sendContext, QQBOT_TIMEOUT_REPLY) + this.clearToolBuffer(endpointKey) this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) return } + await this.flushBufferedProcessMessages(endpointKey, sendContext, { + reserveTerminalSlot: true + }) await sleep(TELEGRAM_STREAM_POLL_INTERVAL_MS) } } - private getStoredDeliveryState(endpointKey: string): QQBotRemoteDeliveryState | null { - const state = this.deps.bindingStore.getRemoteDeliveryState(endpointKey) - if (!state) { - return null - } - - return { - sourceMessageId: state.sourceMessageId, - segments: state.segments.map((segment) => ({ - key: segment.key, - kind: segment.kind, - messageIds: segment.messageIds.filter( - (messageId): messageId is string | null => - typeof messageId === 'string' || messageId === null - ), - lastText: segment.lastText - })) - } - } - - private rememberDeliveryState( - endpointKey: string, - state: QQBotRemoteDeliveryState - ): QQBotRemoteDeliveryState { - this.deps.bindingStore.rememberRemoteDeliveryState(endpointKey, state) - return state - } - - private createDeliveryState(sourceMessageId: string): QQBotRemoteDeliveryState { - return { - sourceMessageId, - segments: [] - } - } - - private async prepareDeliveryStateForSource( - endpointKey: string, - sourceMessageId: string | null, - state: QQBotRemoteDeliveryState | null - ): Promise { - if (!state) { - return sourceMessageId ? this.createDeliveryState(sourceMessageId) : null - } - - if (sourceMessageId && state.sourceMessageId === sourceMessageId) { - return state - } - - this.deps.bindingStore.clearRemoteDeliveryState(endpointKey) - - if (!sourceMessageId) { - return null - } - - return this.createDeliveryState(sourceMessageId) + private getConversationSourceMessageId( + message: QQBotInboundMessage, + execution: RemoteConversationExecution, + snapshot: Awaited> + ): string | null { + return ( + snapshot.messageId?.trim() || + execution.eventId?.trim() || + message.eventId?.trim() || + message.messageId?.trim() || + null + ) } private getSnapshotDeliverySegments( @@ -494,123 +439,272 @@ export class QQBotRuntime { return segments } - private getFinalDeliveryText( - snapshot: Awaited> - ): string { - return (snapshot.finalText ?? snapshot.fullText ?? snapshot.text).trim() + private syncToolBuffer( + endpointKey: string, + sourceMessageId: string | null, + segments: RemoteDeliverySegment[], + options: { + flushTrailingBatch: boolean + } + ): QQBotToolBufferState | null { + const state = this.getOrCreateToolBuffer(endpointKey, sourceMessageId) + if (!state) { + return null + } + + const pendingProcessSegments: QQBotPendingProcessBatch[] = [] + let currentBatchKeys: string[] = [] + + for (const segment of segments) { + if (segment.sourceMessageId !== state.sourceMessageId) { + continue + } + + const normalizedKey = segment.key.trim() + const normalizedText = segment.text.trim() + if (!normalizedKey || !normalizedText) { + continue + } + + if (segment.kind === 'process') { + if (state.flushedProcessKeys.has(normalizedKey)) { + continue + } + + state.lastProcessTextByKey.set(normalizedKey, normalizedText) + if (!currentBatchKeys.includes(normalizedKey)) { + currentBatchKeys.push(normalizedKey) + } + continue + } + + if (currentBatchKeys.length > 0) { + pendingProcessSegments.push({ + keys: currentBatchKeys, + ready: true + }) + currentBatchKeys = [] + } + } + + if (currentBatchKeys.length > 0) { + pendingProcessSegments.push({ + keys: currentBatchKeys, + ready: options.flushTrailingBatch + }) + } + + state.pendingProcessSegments = pendingProcessSegments + return state } - private appendTerminalDeliverySegment( - segments: RemoteDeliverySegment[], - sourceMessageId: string | null, - finalText: string - ): RemoteDeliverySegment[] { - const normalized = finalText.trim() - if (!sourceMessageId || !normalized) { - return segments + private getOrCreateToolBuffer( + endpointKey: string, + sourceMessageId: string | null + ): QQBotToolBufferState | null { + const current = this.endpointToolBuffers.get(endpointKey) + if (current) { + if (!sourceMessageId || current.sourceMessageId === sourceMessageId) { + return current + } + + this.migrateToolBufferSourceMessageId(current, sourceMessageId) + return current + } + + if (!sourceMessageId) { + return null } - const lastAnswerSegment = [...segments].reverse().find((segment) => segment.kind === 'answer') - if (lastAnswerSegment?.text === normalized) { - return segments + const nextState: QQBotToolBufferState = { + sourceMessageId, + pendingProcessSegments: [], + lastProcessTextByKey: new Map(), + flushedProcessKeys: new Set() } + this.endpointToolBuffers.set(endpointKey, nextState) + return nextState + } + + private clearToolBuffer(endpointKey: string): void { + this.endpointToolBuffers.delete(endpointKey) + } - if (normalized === REMOTE_NO_RESPONSE_TEXT && segments.length > 0) { - return segments + private migrateToolBufferSourceMessageId( + state: QQBotToolBufferState, + nextSourceMessageId: string + ): void { + const previousSourceMessageId = state.sourceMessageId + if (!previousSourceMessageId || previousSourceMessageId === nextSourceMessageId) { + state.sourceMessageId = nextSourceMessageId + return } - return [ - ...segments, - { - key: `${sourceMessageId}:terminal`, - kind: 'terminal', - text: normalized, - sourceMessageId + const migratedLastProcessTextByKey = new Map() + for (const [key, text] of state.lastProcessTextByKey.entries()) { + migratedLastProcessTextByKey.set( + this.rewriteToolBufferKey(key, previousSourceMessageId, nextSourceMessageId), + text + ) + } + + state.lastProcessTextByKey = migratedLastProcessTextByKey + state.flushedProcessKeys = new Set( + [...state.flushedProcessKeys].map((key) => + this.rewriteToolBufferKey(key, previousSourceMessageId, nextSourceMessageId) + ) + ) + state.pendingProcessSegments = state.pendingProcessSegments.map((batch) => ({ + ...batch, + keys: this.dedupeKeysInOrder( + batch.keys.map((key) => + this.rewriteToolBufferKey(key, previousSourceMessageId, nextSourceMessageId) + ) + ) + })) + state.sourceMessageId = nextSourceMessageId + } + + private rewriteToolBufferKey( + key: string, + previousSourceMessageId: string, + nextSourceMessageId: string + ): string { + const previousPrefix = `${previousSourceMessageId}:` + if (!key.startsWith(previousPrefix)) { + return key + } + + return `${nextSourceMessageId}:${key.slice(previousPrefix.length)}` + } + + private dedupeKeysInOrder(keys: string[]): string[] { + const seenKeys = new Set() + const dedupedKeys: string[] = [] + + for (const key of keys) { + if (seenKeys.has(key)) { + continue } - ] + + seenKeys.add(key) + dedupedKeys.push(key) + } + + return dedupedKeys } - private isDeliveryStateCompatible( - state: QQBotRemoteDeliveryState, - segments: RemoteDeliverySegment[] - ): boolean { - if (segments.length < state.segments.length) { - return false + private markBufferedProcessBatchesReady(endpointKey: string): void { + const state = this.endpointToolBuffers.get(endpointKey) + if (!state) { + return } - return state.segments.every((segment, index) => segments[index]?.key === segment.key) + state.pendingProcessSegments = state.pendingProcessSegments.map((batch) => ({ + keys: [...batch.keys], + ready: true + })) } - private async syncDeliverySegments( - state: QQBotRemoteDeliveryState, + private async flushBufferedProcessMessages( endpointKey: string, sendContext: QQBotSendContext, - segments: RemoteDeliverySegment[] - ): Promise { - if (segments.length === 0) { - return state + options: { + reserveTerminalSlot: boolean } - - if (!this.isDeliveryStateCompatible(state, segments)) { - return state + ): Promise { + const state = this.endpointToolBuffers.get(endpointKey) + if (!state || state.pendingProcessSegments.length === 0) { + return false } - const syncedSegments = [...state.segments] - let reachedPassiveReplyLimit = false - for (let index = 0; index < state.segments.length; index += 1) { - const segment = segments[index] - const existingSegment = syncedSegments[index] - if (!segment || !existingSegment) { + const retainedBatches: QQBotPendingProcessBatch[] = [] + let didFlushProcessOutput = false + + for (let index = 0; index < state.pendingProcessSegments.length; index += 1) { + const batch = state.pendingProcessSegments[index] + if (!batch.ready) { + retainedBatches.push(batch) continue } - const normalizedText = segment.text.trim() - if (normalizedText === existingSegment.lastText) { - continue + const reservedSlots = options.reserveTerminalSlot ? 1 : 0 + if (sendContext.sentCount + reservedSlots >= QQBOT_MAX_PASSIVE_REPLIES) { + retainedBatches.push(batch, ...state.pendingProcessSegments.slice(index + 1)) + state.pendingProcessSegments = retainedBatches + return didFlushProcessOutput } - const messageId = await this.sendText(sendContext, segment.text) - syncedSegments[index] = { - key: segment.key, - kind: segment.kind, - messageIds: [...existingSegment.messageIds, messageId], - lastText: normalizedText + const processText = this.buildBufferedProcessText(state, batch) + if (!processText) { + this.markProcessBatchFlushed(state, batch) + continue } - if (!messageId && sendContext.sentCount >= QQBOT_MAX_PASSIVE_REPLIES) { - reachedPassiveReplyLimit = true - break + const sent = await this.sendText(sendContext, processText) + if (!sent) { + retainedBatches.push(batch, ...state.pendingProcessSegments.slice(index + 1)) + state.pendingProcessSegments = retainedBatches + return didFlushProcessOutput } + + didFlushProcessOutput = true + this.markProcessBatchFlushed(state, batch) } - if (reachedPassiveReplyLimit) { - return this.rememberDeliveryState(endpointKey, { - sourceMessageId: state.sourceMessageId, - segments: syncedSegments - }) + state.pendingProcessSegments = retainedBatches + return didFlushProcessOutput + } + + private buildBufferedProcessText( + state: QQBotToolBufferState, + batch: QQBotPendingProcessBatch + ): string { + return batch.keys + .map((key) => state.lastProcessTextByKey.get(key)?.trim() || '') + .filter((text) => text.length > 0) + .join('\n') + .trim() + } + + private markProcessBatchFlushed( + state: QQBotToolBufferState, + batch: QQBotPendingProcessBatch + ): void { + for (const key of batch.keys) { + state.flushedProcessKeys.add(key) + state.lastProcessTextByKey.delete(key) } + } - for (let index = state.segments.length; index < segments.length; index += 1) { - const segment = segments[index] - const messageId = await this.sendText(sendContext, segment.text) - syncedSegments.push({ - key: segment.key, - kind: segment.kind, - messageIds: [messageId], - lastText: segment.text.trim() - }) + private shouldSkipNoResponseTerminal(endpointKey: string, finalText: string): boolean { + if (finalText !== REMOTE_NO_RESPONSE_TEXT) { + return false + } - if (!messageId && sendContext.sentCount >= QQBOT_MAX_PASSIVE_REPLIES) { - break - } + const state = this.endpointToolBuffers.get(endpointKey) + if (!state) { + return false } - return this.rememberDeliveryState(endpointKey, { - sourceMessageId: state.sourceMessageId, - segments: syncedSegments + return state.pendingProcessSegments.some((batch) => { + if (!batch.ready) { + return false + } + + return batch.keys.some((key) => { + const processText = state.lastProcessTextByKey.get(key)?.trim() || '' + return processText.length > 0 + }) }) } + private getFinalDeliveryText( + snapshot: Awaited> + ): string { + return (snapshot.finalText ?? snapshot.fullText ?? snapshot.text).trim() + } + private createSendContext(target: QQBotTransportTarget, nextMsgSeq: number): QQBotSendContext { return { target, diff --git a/src/main/presenter/sqlitePresenter/schemaCatalog.ts b/src/main/presenter/sqlitePresenter/schemaCatalog.ts index 6812c98d3..f0c3c7921 100644 --- a/src/main/presenter/sqlitePresenter/schemaCatalog.ts +++ b/src/main/presenter/sqlitePresenter/schemaCatalog.ts @@ -112,6 +112,7 @@ const CATALOG_DEFINITIONS: CatalogDefinition[] = [ summary_cursor_order_seq: 'ALTER TABLE deepchat_sessions ADD COLUMN summary_cursor_order_seq INTEGER NOT NULL DEFAULT 1;', summary_updated_at: 'ALTER TABLE deepchat_sessions ADD COLUMN summary_updated_at INTEGER;', + timeout_ms: 'ALTER TABLE deepchat_sessions ADD COLUMN timeout_ms INTEGER;', force_interleaved_thinking_compat: 'ALTER TABLE deepchat_sessions ADD COLUMN force_interleaved_thinking_compat INTEGER;', reasoning_visibility: 'ALTER TABLE deepchat_sessions ADD COLUMN reasoning_visibility TEXT;' diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatSessions.ts b/src/main/presenter/sqlitePresenter/tables/deepchatSessions.ts index 6da0a0a7f..fcbf508f4 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatSessions.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatSessions.ts @@ -161,6 +161,9 @@ export class DeepChatSessionsTable extends BaseTable { if (!this.hasColumn('summary_updated_at')) { statements.push('ALTER TABLE deepchat_sessions ADD COLUMN summary_updated_at INTEGER;') } + if (!this.hasColumn('timeout_ms')) { + statements.push('ALTER TABLE deepchat_sessions ADD COLUMN timeout_ms INTEGER;') + } if (!this.hasColumn('force_interleaved_thinking_compat')) { statements.push( 'ALTER TABLE deepchat_sessions ADD COLUMN force_interleaved_thinking_compat INTEGER;' diff --git a/test/main/presenter/remoteControlPresenter/qqbotRuntime.test.ts b/test/main/presenter/remoteControlPresenter/qqbotRuntime.test.ts index ed7dc0f7d..e89b95d64 100644 --- a/test/main/presenter/remoteControlPresenter/qqbotRuntime.test.ts +++ b/test/main/presenter/remoteControlPresenter/qqbotRuntime.test.ts @@ -1,12 +1,20 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { buildFeishuPendingInteractionText } from '@/presenter/remoteControlPresenter/feishu/feishuInteractionPrompt' import { QQBotRuntime } from '@/presenter/remoteControlPresenter/qqbot/qqbotRuntime' -import type { - QQBotTransportTarget, - RemoteDeliverySegment +import { + FEISHU_CONVERSATION_POLL_TIMEOUT_MS, + TELEGRAM_STREAM_POLL_INTERVAL_MS, + type QQBotInboundMessage, + type QQBotTransportTarget, + type RemoteDeliverySegment, + type RemotePendingInteraction } from '@/presenter/remoteControlPresenter/types' const createRuntime = () => { const onFatalError = vi.fn() + const router = { + handleMessage: vi.fn() + } const client = { sendC2CMessage: vi.fn(), sendGroupMessage: vi.fn() @@ -20,13 +28,14 @@ const createRuntime = () => { const runtime = new QQBotRuntime({ client: client as any, parser: {} as any, - router: {} as any, + router: router as any, bindingStore: bindingStore as any, onFatalError }) return { runtime, + router, client, bindingStore, onFatalError @@ -39,68 +48,714 @@ const C2C_TARGET: QQBotTransportTarget = { msgId: 'source-msg-1' } +const GROUP_TARGET: QQBotTransportTarget = { + chatType: 'group', + openId: 'group-open-id-1', + msgId: 'group-source-msg-1' +} + +const createInboundMessage = ( + target: QQBotTransportTarget, + messageSeq: number = 1 +): QQBotInboundMessage => ({ + kind: 'message', + eventId: `${target.chatType}-event-${messageSeq}`, + chatId: target.openId, + chatType: target.chatType, + messageId: target.msgId, + messageSeq, + senderUserId: `${target.chatType}-user-1`, + senderUserName: `${target.chatType}-user`, + text: 'hello', + command: null, + mentionedBot: target.chatType === 'group' +}) + +const createExecution = ( + snapshots: Array<{ + messageId?: string | null + completed: boolean + text?: string + traceText?: string + deliverySegments?: RemoteDeliverySegment[] + fullText?: string + finalText?: string + pendingInteraction?: RemotePendingInteraction | null + }>, + options?: { + eventId?: string | null + } +) => { + let index = 0 + const normalizedSnapshots = snapshots.map((snapshot) => ({ + messageId: 'assistant-msg-1', + text: '', + traceText: '', + deliverySegments: undefined as RemoteDeliverySegment[] | undefined, + fullText: snapshot.fullText ?? snapshot.finalText ?? snapshot.text ?? '', + finalText: snapshot.finalText ?? '', + completed: snapshot.completed, + pendingInteraction: snapshot.pendingInteraction ?? null, + ...snapshot + })) + + const getSnapshot = vi.fn( + async () => normalizedSnapshots[Math.min(index++, snapshots.length - 1)] + ) + + return { + getSnapshot, + execution: { + sessionId: 'session-1', + eventId: options?.eventId ?? 'assistant-msg-1', + getSnapshot + } + } +} + +const createProcessSegment = ( + sourceMessageId: string, + index: number, + text: string +): RemoteDeliverySegment => ({ + key: `${sourceMessageId}:${index}:process`, + kind: 'process', + text, + sourceMessageId +}) + +const createAnswerSegment = ( + sourceMessageId: string, + index: number, + text: string +): RemoteDeliverySegment => ({ + key: `${sourceMessageId}:${index}:answer`, + kind: 'answer', + text, + sourceMessageId +}) + +const activateRuntime = (runtime: QQBotRuntime, runId: number = 1): void => { + ;(runtime as any).runId = runId + ;(runtime as any).started = true + ;(runtime as any).stopRequested = false +} + +const flushMicrotasks = async (): Promise => { + await Promise.resolve() + await Promise.resolve() +} + +const createExpectedPayload = ( + target: QQBotTransportTarget, + msgSeq: number, + content: string +): Record => + target.chatType === 'c2c' + ? { + openId: target.openId, + msgId: target.msgId, + msgSeq, + content + } + : { + groupOpenId: target.openId, + msgId: target.msgId, + msgSeq, + content + } + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + describe('QQBotRuntime', () => { - it('re-sends changed existing segments before appending new segments', async () => { - const { runtime, client, bindingStore } = createRuntime() - client.sendC2CMessage - .mockResolvedValueOnce({ id: 'updated-msg-1' }) - .mockResolvedValueOnce({ id: 'terminal-msg-1' }) + it.each([ + { + label: 'c2c', + target: C2C_TARGET, + message: createInboundMessage(C2C_TARGET, 1), + getSendMock: (client: ReturnType['client']) => client.sendC2CMessage + }, + { + label: 'group', + target: GROUP_TARGET, + message: createInboundMessage(GROUP_TARGET, 3), + getSendMock: (client: ReturnType['client']) => client.sendGroupMessage + } + ])( + 'waits for completion before sending $label final text', + async ({ target, message, getSendMock }) => { + vi.useFakeTimers() - const state = { - sourceMessageId: 'source-msg-1', - segments: [ + const { runtime, client, bindingStore } = createRuntime() + activateRuntime(runtime) + const sendMock = getSendMock(client) + sendMock.mockResolvedValue({ id: `${target.chatType}-final-msg` }) + + const { execution, getSnapshot } = createExecution([ + { + completed: false, + text: 'Draft answer' + }, + { + completed: false, + text: 'Draft answer expanded' + }, { - key: 'source-msg-1:legacy:answer', - kind: 'answer' as const, - messageIds: ['initial-msg-1'], - lastText: 'Draft answer' + completed: true, + text: 'Draft answer expanded', + fullText: 'Final answer', + finalText: 'Final answer' } - ] + ]) + + const sendContext = (runtime as any).createSendContext(target, message.messageSeq) + const deliveryPromise = (runtime as any).deliverConversation( + message, + sendContext, + execution, + 1 + ) + + await flushMicrotasks() + expect(getSnapshot).toHaveBeenCalledTimes(1) + expect(sendMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + expect(getSnapshot).toHaveBeenCalledTimes(2) + expect(sendMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + await deliveryPromise + + expect(getSnapshot).toHaveBeenCalledTimes(3) + expect(sendMock).toHaveBeenCalledTimes(1) + expect(sendMock).toHaveBeenCalledWith( + createExpectedPayload(target, message.messageSeq, 'Final answer') + ) + expect(bindingStore.getRemoteDeliveryState).not.toHaveBeenCalled() + expect(bindingStore.rememberRemoteDeliveryState).not.toHaveBeenCalled() } - const segments: RemoteDeliverySegment[] = [ + ) + + it('flushes the latest tool batch when answer text appears and sends the final answer on completion', async () => { + vi.useFakeTimers() + + const { runtime, client } = createRuntime() + activateRuntime(runtime) + client.sendC2CMessage.mockResolvedValue({ id: 'c2c-msg-1' }) + + const sourceMessageId = 'assistant-msg-1' + const { execution, getSnapshot } = createExecution([ { - key: 'source-msg-1:legacy:answer', - kind: 'answer', - text: 'Updated answer', - sourceMessageId: 'source-msg-1' + completed: false, + deliverySegments: [createProcessSegment(sourceMessageId, 0, 'šŸ’» shell_command: "pwd"')] }, { - key: 'source-msg-1:terminal', - kind: 'terminal', - text: 'Final answer', - sourceMessageId: 'source-msg-1' + completed: false, + text: 'Draft answer', + deliverySegments: [ + createProcessSegment( + sourceMessageId, + 0, + 'šŸ’» shell_command: "pwd"\nšŸ“– read_file: "/tmp/report.md"' + ), + createAnswerSegment(sourceMessageId, 1, 'Draft answer') + ] + }, + { + completed: true, + text: 'Draft answer', + finalText: 'Final answer', + fullText: 'Final answer', + deliverySegments: [ + createProcessSegment( + sourceMessageId, + 0, + 'šŸ’» shell_command: "pwd"\nšŸ“– read_file: "/tmp/report.md"' + ), + createAnswerSegment(sourceMessageId, 1, 'Final answer') + ] } - ] - const sendContext = (runtime as any).createSendContext(C2C_TARGET, 1) + ]) + + const message = createInboundMessage(C2C_TARGET, 1) + const sendContext = (runtime as any).createSendContext(C2C_TARGET, message.messageSeq) + const deliveryPromise = (runtime as any).deliverConversation(message, sendContext, execution, 1) + + await flushMicrotasks() + expect(getSnapshot).toHaveBeenCalledTimes(1) + expect(client.sendC2CMessage).not.toHaveBeenCalled() - const result = await (runtime as any).syncDeliverySegments( - state, - 'qqbot:c2c:open-id-1', - sendContext, - segments + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + expect(getSnapshot).toHaveBeenCalledTimes(2) + expect(client.sendC2CMessage).toHaveBeenCalledTimes(1) + expect(client.sendC2CMessage).toHaveBeenNthCalledWith( + 1, + createExpectedPayload( + C2C_TARGET, + 1, + 'šŸ’» shell_command: "pwd"\nšŸ“– read_file: "/tmp/report.md"' + ) ) + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + await deliveryPromise + + expect(getSnapshot).toHaveBeenCalledTimes(3) expect(client.sendC2CMessage).toHaveBeenCalledTimes(2) - expect(result).toEqual({ - sourceMessageId: 'source-msg-1', - segments: [ + expect(client.sendC2CMessage).toHaveBeenNthCalledWith( + 2, + createExpectedPayload(C2C_TARGET, 2, 'Final answer') + ) + }) + + it('flushes each process batch in segment order while keeping answer delivery final-only', async () => { + vi.useFakeTimers() + + const { runtime, client } = createRuntime() + activateRuntime(runtime) + client.sendGroupMessage.mockResolvedValue({ id: 'group-msg-1' }) + + const sourceMessageId = 'assistant-msg-1' + const { execution, getSnapshot } = createExecution([ + { + completed: false, + text: 'Opening answer', + deliverySegments: [createAnswerSegment(sourceMessageId, 0, 'Opening answer')] + }, + { + completed: false, + text: 'Opening answer', + deliverySegments: [ + createAnswerSegment(sourceMessageId, 0, 'Opening answer'), + createProcessSegment(sourceMessageId, 1, 'šŸ“– read_file: "/tmp/a.md"') + ] + }, + { + completed: false, + text: 'Middle answer', + deliverySegments: [ + createAnswerSegment(sourceMessageId, 0, 'Opening answer'), + createProcessSegment( + sourceMessageId, + 1, + 'šŸ“– read_file: "/tmp/a.md"\nšŸ’» shell_command: "git status"' + ), + createAnswerSegment(sourceMessageId, 2, 'Middle answer'), + createProcessSegment(sourceMessageId, 3, 'šŸ“ write_file: "/tmp/b.md"') + ] + }, + { + completed: true, + text: 'Final answer', + finalText: 'Final answer', + fullText: 'Final answer', + deliverySegments: [ + createAnswerSegment(sourceMessageId, 0, 'Opening answer'), + createProcessSegment( + sourceMessageId, + 1, + 'šŸ“– read_file: "/tmp/a.md"\nšŸ’» shell_command: "git status"' + ), + createAnswerSegment(sourceMessageId, 2, 'Middle answer'), + createProcessSegment(sourceMessageId, 3, 'šŸ“ write_file: "/tmp/b.md"'), + createAnswerSegment(sourceMessageId, 4, 'Final answer') + ] + } + ]) + + const message = createInboundMessage(GROUP_TARGET, 2) + const sendContext = (runtime as any).createSendContext(GROUP_TARGET, message.messageSeq) + const deliveryPromise = (runtime as any).deliverConversation(message, sendContext, execution, 1) + + await flushMicrotasks() + expect(getSnapshot).toHaveBeenCalledTimes(1) + expect(client.sendGroupMessage).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + expect(getSnapshot).toHaveBeenCalledTimes(2) + expect(client.sendGroupMessage).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + expect(getSnapshot).toHaveBeenCalledTimes(3) + expect(client.sendGroupMessage).toHaveBeenCalledTimes(1) + expect(client.sendGroupMessage).toHaveBeenNthCalledWith( + 1, + createExpectedPayload( + GROUP_TARGET, + 2, + 'šŸ“– read_file: "/tmp/a.md"\nšŸ’» shell_command: "git status"' + ) + ) + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + await deliveryPromise + + expect(getSnapshot).toHaveBeenCalledTimes(4) + expect(client.sendGroupMessage).toHaveBeenCalledTimes(3) + expect(client.sendGroupMessage).toHaveBeenNthCalledWith( + 2, + createExpectedPayload(GROUP_TARGET, 3, 'šŸ“ write_file: "/tmp/b.md"') + ) + expect(client.sendGroupMessage).toHaveBeenNthCalledWith( + 3, + createExpectedPayload(GROUP_TARGET, 4, 'Final answer') + ) + }) + + it('keeps flushed process state when the source message id changes mid-conversation', async () => { + vi.useFakeTimers() + + const { runtime, client } = createRuntime() + activateRuntime(runtime) + client.sendC2CMessage.mockResolvedValue({ id: 'c2c-msg-1' }) + + const initialSourceMessageId = 'assistant-event-1' + const finalSourceMessageId = 'assistant-msg-1' + const { execution, getSnapshot } = createExecution( + [ + { + messageId: null, + completed: false, + text: 'Draft answer', + deliverySegments: [ + createProcessSegment(initialSourceMessageId, 0, 'šŸ’» shell_command: "pwd"'), + createAnswerSegment(initialSourceMessageId, 1, 'Draft answer') + ] + }, { - key: 'source-msg-1:legacy:answer', - kind: 'answer', - messageIds: ['initial-msg-1', 'updated-msg-1'], - lastText: 'Updated answer' + messageId: finalSourceMessageId, + completed: false, + text: 'Draft answer expanded', + deliverySegments: [ + createProcessSegment(finalSourceMessageId, 0, 'šŸ’» shell_command: "pwd"'), + createAnswerSegment(finalSourceMessageId, 1, 'Draft answer expanded') + ] }, { - key: 'source-msg-1:terminal', - kind: 'terminal', - messageIds: ['terminal-msg-1'], - lastText: 'Final answer' + messageId: finalSourceMessageId, + completed: true, + text: 'Final answer', + finalText: 'Final answer', + fullText: 'Final answer', + deliverySegments: [ + createProcessSegment(finalSourceMessageId, 0, 'šŸ’» shell_command: "pwd"'), + createAnswerSegment(finalSourceMessageId, 1, 'Final answer') + ] } - ] + ], + { + eventId: initialSourceMessageId + } + ) + + const message = createInboundMessage(C2C_TARGET, 8) + const sendContext = (runtime as any).createSendContext(C2C_TARGET, message.messageSeq) + const deliveryPromise = (runtime as any).deliverConversation(message, sendContext, execution, 1) + + await flushMicrotasks() + expect(getSnapshot).toHaveBeenCalledTimes(1) + expect(client.sendC2CMessage).toHaveBeenCalledTimes(1) + expect(client.sendC2CMessage).toHaveBeenNthCalledWith( + 1, + createExpectedPayload(C2C_TARGET, 8, 'šŸ’» shell_command: "pwd"') + ) + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + expect(getSnapshot).toHaveBeenCalledTimes(2) + expect(client.sendC2CMessage).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + await deliveryPromise + + expect(getSnapshot).toHaveBeenCalledTimes(3) + expect(client.sendC2CMessage).toHaveBeenCalledTimes(2) + expect(client.sendC2CMessage).toHaveBeenNthCalledWith( + 2, + createExpectedPayload(C2C_TARGET, 9, 'Final answer') + ) + }) + + it('flushes the buffered tool batch before the pending interaction prompt', async () => { + vi.useFakeTimers() + + const { runtime, client } = createRuntime() + activateRuntime(runtime) + client.sendGroupMessage.mockResolvedValue({ id: 'pending-msg-1' }) + + const interaction: RemotePendingInteraction = { + type: 'question', + messageId: 'assistant-msg-1', + toolCallId: 'tool-call-1', + toolName: 'question_tool', + toolArgs: '', + question: { + header: 'Need confirmation', + question: 'Choose one option', + options: [ + { + label: 'Option A', + description: 'Use option A' + } + ], + custom: false, + multiple: false + } + } + + const { execution } = createExecution([ + { + completed: false, + deliverySegments: [createProcessSegment('assistant-msg-1', 0, 'šŸ”Ž search: "release notes"')] + }, + { + completed: true, + deliverySegments: [ + createProcessSegment('assistant-msg-1', 0, 'šŸ”Ž search: "release notes"') + ], + pendingInteraction: interaction + } + ]) + + const message = createInboundMessage(GROUP_TARGET, 4) + const sendContext = (runtime as any).createSendContext(GROUP_TARGET, message.messageSeq) + const deliveryPromise = (runtime as any).deliverConversation(message, sendContext, execution, 1) + + await flushMicrotasks() + expect(client.sendGroupMessage).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + await deliveryPromise + + expect(client.sendGroupMessage).toHaveBeenCalledTimes(2) + expect(client.sendGroupMessage).toHaveBeenNthCalledWith( + 1, + createExpectedPayload(GROUP_TARGET, 4, 'šŸ”Ž search: "release notes"') + ) + expect(client.sendGroupMessage).toHaveBeenNthCalledWith( + 2, + createExpectedPayload(GROUP_TARGET, 5, buildFeishuPendingInteractionText(interaction)) + ) + }) + + it('flushes the buffered tool batch before timeout text', async () => { + vi.useFakeTimers() + + const { runtime, client } = createRuntime() + activateRuntime(runtime) + client.sendC2CMessage.mockResolvedValue({ id: 'timeout-msg-1' }) + + const { execution } = createExecution([ + { + completed: false, + deliverySegments: [ + createProcessSegment('assistant-msg-1', 0, 'šŸ’» shell_command: "sleep 1"') + ] + } + ]) + + const message = createInboundMessage(C2C_TARGET, 2) + const sendContext = (runtime as any).createSendContext(C2C_TARGET, message.messageSeq) + const deliveryPromise = (runtime as any).deliverConversation(message, sendContext, execution, 1) + + await flushMicrotasks() + expect(client.sendC2CMessage).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync( + FEISHU_CONVERSATION_POLL_TIMEOUT_MS + TELEGRAM_STREAM_POLL_INTERVAL_MS * 2 + ) + await deliveryPromise + + expect(client.sendC2CMessage).toHaveBeenCalledTimes(2) + expect(client.sendC2CMessage).toHaveBeenNthCalledWith( + 1, + createExpectedPayload(C2C_TARGET, 2, 'šŸ’» shell_command: "sleep 1"') + ) + expect(client.sendC2CMessage).toHaveBeenNthCalledWith( + 2, + createExpectedPayload( + C2C_TARGET, + 3, + 'The current conversation timed out before finishing. Please try again.' + ) + ) + }) + + it('omits the no-response terminal text when buffered tool output was sent', async () => { + const { runtime, client, bindingStore } = createRuntime() + activateRuntime(runtime) + client.sendC2CMessage.mockResolvedValue({ id: 'no-response-msg-1' }) + + const { execution } = createExecution([ + { + completed: true, + deliverySegments: [ + createProcessSegment('assistant-msg-1', 0, 'šŸ“– read_file: "/tmp/empty.md"') + ], + fullText: 'No assistant response was produced.', + finalText: 'No assistant response was produced.' + } + ]) + + const message = createInboundMessage(C2C_TARGET, 5) + const sendContext = (runtime as any).createSendContext(C2C_TARGET, message.messageSeq) + + await (runtime as any).deliverConversation(message, sendContext, execution, 1) + + expect(client.sendC2CMessage).toHaveBeenCalledTimes(1) + expect(client.sendC2CMessage).toHaveBeenCalledWith( + createExpectedPayload(C2C_TARGET, 5, 'šŸ“– read_file: "/tmp/empty.md"') + ) + expect(bindingStore.getRemoteDeliveryState).not.toHaveBeenCalled() + expect(bindingStore.rememberRemoteDeliveryState).not.toHaveBeenCalled() + }) + + it('keeps the final reply slot reserved when the passive reply limit is almost exhausted', async () => { + const { runtime, client } = createRuntime() + activateRuntime(runtime) + client.sendC2CMessage.mockResolvedValue({ id: 'final-msg-1' }) + + const { execution } = createExecution([ + { + completed: true, + text: 'Final answer', + finalText: 'Final answer', + fullText: 'Final answer', + deliverySegments: [ + createProcessSegment('assistant-msg-1', 0, 'šŸ“– read_file: "/tmp/a.md"'), + createAnswerSegment('assistant-msg-1', 1, 'Final answer') + ] + } + ]) + + const message = createInboundMessage(C2C_TARGET, 1) + const sendContext = (runtime as any).createSendContext(C2C_TARGET, 5) + sendContext.sentCount = 4 + + await (runtime as any).deliverConversation(message, sendContext, execution, 1) + + expect(client.sendC2CMessage).toHaveBeenCalledTimes(1) + expect(client.sendC2CMessage).toHaveBeenCalledWith( + createExpectedPayload(C2C_TARGET, 5, 'Final answer') + ) + }) + + it('falls back to legacy trace text snapshots when delivery segments are unavailable', async () => { + vi.useFakeTimers() + + const { runtime, client } = createRuntime() + activateRuntime(runtime) + client.sendC2CMessage.mockResolvedValue({ id: 'legacy-msg-1' }) + + const { execution } = createExecution([ + { + completed: false, + traceText: 'šŸ’» shell_command: "git status"', + text: '' + }, + { + completed: false, + traceText: 'šŸ’» shell_command: "git status"\nšŸ“– read_file: "/tmp/a.md"', + text: 'Draft answer' + }, + { + completed: true, + traceText: 'šŸ’» shell_command: "git status"\nšŸ“– read_file: "/tmp/a.md"', + text: 'Final answer', + fullText: 'Final answer', + finalText: 'Final answer' + } + ]) + + const message = createInboundMessage(C2C_TARGET, 7) + const sendContext = (runtime as any).createSendContext(C2C_TARGET, message.messageSeq) + const deliveryPromise = (runtime as any).deliverConversation(message, sendContext, execution, 1) + + await flushMicrotasks() + expect(client.sendC2CMessage).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + expect(client.sendC2CMessage).toHaveBeenCalledTimes(1) + expect(client.sendC2CMessage).toHaveBeenNthCalledWith( + 1, + createExpectedPayload( + C2C_TARGET, + 7, + 'šŸ’» shell_command: "git status"\nšŸ“– read_file: "/tmp/a.md"' + ) + ) + + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + await deliveryPromise + + expect(client.sendC2CMessage).toHaveBeenCalledTimes(2) + expect(client.sendC2CMessage).toHaveBeenNthCalledWith( + 2, + createExpectedPayload(C2C_TARGET, 8, 'Final answer') + ) + }) + + it('flushes buffered tool text before sending the internal error reply', async () => { + vi.useFakeTimers() + + const { runtime, router, client } = createRuntime() + activateRuntime(runtime) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + client.sendC2CMessage.mockResolvedValue({ id: 'internal-error-msg-1' }) + + let snapshotCallCount = 0 + const execution = { + sessionId: 'session-1', + eventId: 'assistant-msg-1', + getSnapshot: vi.fn(async () => { + if (snapshotCallCount === 0) { + snapshotCallCount += 1 + return { + messageId: 'assistant-msg-1', + text: '', + traceText: '', + deliverySegments: [ + createProcessSegment('assistant-msg-1', 0, 'šŸ’» shell_command: "pwd"') + ], + fullText: '', + finalText: '', + completed: false, + pendingInteraction: null + } + } + + throw new Error('snapshot failed') + }) + } + + router.handleMessage.mockResolvedValue({ + replies: [], + conversation: execution }) - expect(bindingStore.rememberRemoteDeliveryState).toHaveBeenCalledWith( - 'qqbot:c2c:open-id-1', - result + + const message = createInboundMessage(C2C_TARGET, 6) + const deliveryPromise = (runtime as any).processInboundMessage(message, 1) + + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(TELEGRAM_STREAM_POLL_INTERVAL_MS) + await deliveryPromise + + expect(router.handleMessage).toHaveBeenCalledWith(message) + expect(client.sendC2CMessage).toHaveBeenCalledTimes(2) + expect(client.sendC2CMessage).toHaveBeenNthCalledWith( + 1, + createExpectedPayload(C2C_TARGET, 6, 'šŸ’» shell_command: "pwd"') + ) + expect(client.sendC2CMessage).toHaveBeenNthCalledWith( + 2, + createExpectedPayload( + C2C_TARGET, + 7, + 'An internal error occurred while processing your request.' + ) ) }) diff --git a/test/main/presenter/sqlitePresenter.test.ts b/test/main/presenter/sqlitePresenter.test.ts index 520b73e88..e794e6bb5 100644 --- a/test/main/presenter/sqlitePresenter.test.ts +++ b/test/main/presenter/sqlitePresenter.test.ts @@ -718,6 +718,84 @@ describeIfSqlite('SQLitePresenter legacy schema bootstrap', () => { checkDb.close() }) + it('repairs missing timeout_ms in deepchat_sessions when schema version is already 24', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepchat-sqlite-presenter-')) + tempDirs.push(tempDir) + + const dbPath = path.join(tempDir, 'agent.db') + const bootstrapDb = new DatabaseCtor(dbPath) + bootstrapDb.exec(` + CREATE TABLE IF NOT EXISTS schema_versions ( + version INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + ); + INSERT INTO schema_versions (version, applied_at) VALUES (24, ${Date.now()}); + CREATE TABLE IF NOT EXISTS deepchat_sessions ( + id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + model_id TEXT NOT NULL, + permission_mode TEXT NOT NULL DEFAULT 'full_access', + system_prompt TEXT, + temperature REAL, + context_length INTEGER, + max_tokens INTEGER, + thinking_budget INTEGER, + reasoning_effort TEXT, + verbosity TEXT, + summary_text TEXT, + summary_cursor_order_seq INTEGER NOT NULL DEFAULT 1, + summary_updated_at INTEGER, + force_interleaved_thinking_compat INTEGER, + reasoning_visibility TEXT + ); + INSERT INTO deepchat_sessions ( + id, + provider_id, + model_id, + permission_mode, + reasoning_visibility + ) VALUES ( + 'session-1', + 'anthropic', + 'claude-sonnet-4', + 'full_access', + 'auto' + ); + `) + bootstrapDb.close() + + const presenter = new SQLitePresenterCtor(dbPath) + const diagnosis = await presenter.diagnoseSchema() + expect(diagnosis.issues.some((issue) => issue.name === 'timeout_ms')).toBe(true) + + const repairReport = await presenter.repairSchema() + expect(repairReport.status).toBe('repaired') + presenter.close() + + const checkDb = new DatabaseCtor(dbPath) + const deepchatColumns = checkDb.prepare('PRAGMA table_info(deepchat_sessions)').all() as Array<{ + name: string + }> + const columnNames = new Set(deepchatColumns.map((column) => column.name)) + + expect(columnNames.has('timeout_ms')).toBe(true) + + const row = checkDb + .prepare('SELECT reasoning_visibility, timeout_ms FROM deepchat_sessions WHERE id = ?') + .get('session-1') as + | { + reasoning_visibility: string | null + timeout_ms: number | null + } + | undefined + + expect(row).toEqual({ + reasoning_visibility: 'auto', + timeout_ms: null + }) + checkDb.close() + }) + it('runs the v23 and v24 recovery migrations for deepchat_sessions when schema version is 22', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepchat-sqlite-presenter-')) tempDirs.push(tempDir) diff --git a/test/main/presenter/sqlitePresenter/deepchatSessionsTable.test.ts b/test/main/presenter/sqlitePresenter/deepchatSessionsTable.test.ts index 4ef1d73b6..decd40e55 100644 --- a/test/main/presenter/sqlitePresenter/deepchatSessionsTable.test.ts +++ b/test/main/presenter/sqlitePresenter/deepchatSessionsTable.test.ts @@ -110,6 +110,7 @@ describe('DeepChatSessionsTable.updateSummaryStateIfMatches', () => { expect(table.getMigrationSQL(23)).toBe( [ + 'ALTER TABLE deepchat_sessions ADD COLUMN timeout_ms INTEGER;', 'ALTER TABLE deepchat_sessions ADD COLUMN force_interleaved_thinking_compat INTEGER;', 'ALTER TABLE deepchat_sessions ADD COLUMN reasoning_visibility TEXT;' ].join('\n')