diff --git a/opensquilla-webui/src/composables/chat/useChatRouterDecisionRuntime.test.ts b/opensquilla-webui/src/composables/chat/useChatRouterDecisionRuntime.test.ts index 3e0ca6e014..1caf043b14 100644 --- a/opensquilla-webui/src/composables/chat/useChatRouterDecisionRuntime.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatRouterDecisionRuntime.test.ts @@ -153,6 +153,82 @@ describe('router decision identity', () => { }) describe('appendEnsembleProgress', () => { + it('promotes a same-turn provisional card when its real decision arrives', () => { + const { runtime, messagesRef } = makeRuntime([ + { role: 'user', text: 'q', ts: 0, turnId: 'turn-1' }, + ]) + + runtime.appendEnsembleProgress({ + event_type: 'proposer_finish', + turn_id: 'turn-1', + proposer_provider: 'openrouter', + proposer_model: 'qwen/qwen3.7-plus', + }) + const provisional = messagesRef.value.find(message => message.role === 'router')! + ;(provisional as ChatMessage & { routerExecutionModel?: string }).routerExecutionModel = 'fallback-model' + + runtime.queueRouterDecision({ + turn_id: 'turn-1', + stream_seq: 12, + tier: 'c1', + model: 'provider/selected', + source: 'squilla_router', + }) + + const routers = messagesRef.value.filter(message => message.role === 'router') + expect(routers).toHaveLength(1) + expect(routers[0]).toMatchObject({ + messageId: 'router-sess-12', + turnId: 'turn-1', + routerDecision: { model: 'provider/selected' }, + }) + expect(routers[0]?.ensemble?.models).toHaveLength(1) + expect((routers[0] as ChatMessage & { routerExecutionModel?: string }).routerExecutionModel) + .toBe('fallback-model') + }) + + it('does not promote a provisional card across a router control replay boundary', () => { + const { runtime, messagesRef } = makeRuntime([ + { role: 'user', text: 'q', ts: 0, turnId: 'turn-1' }, + ]) + + runtime.appendEnsembleProgress({ + event_type: 'proposer_start', + turn_id: 'turn-1', + proposer_provider: 'openrouter', + proposer_model: 'qwen/qwen3.7-plus', + }) + runtime.handleRouterControlReplay() + runtime.queueRouterDecision({ + turn_id: 'turn-1', + stream_seq: 13, + tier: 'c1', + model: 'provider/replayed', + source: 'squilla_router', + }) + + expect(messagesRef.value.filter(message => message.role === 'router')).toHaveLength(2) + }) + + it('promotes a handoff card without dropping its identity', () => { + const { runtime, messagesRef } = makeRuntime([ + { role: 'user', text: 'q', ts: 0, turnId: 'turn-1' }, + ]) + + runtime.markEnsembleHandoff() + runtime.queueRouterDecision({ + turn_id: 'turn-1', + stream_seq: 14, + tier: 'c1', + model: 'provider/selected', + source: 'squilla_router', + }) + + const routers = messagesRef.value.filter(message => message.role === 'router') + expect(routers).toHaveLength(1) + expect(routers[0]).toMatchObject({ messageId: 'router-sess-14', turnId: 'turn-1' }) + }) + it('normalizes every internal candidate label to the public Proposer role', () => { const { runtime, messagesRef } = makeRuntime([{ role: 'user', text: 'q', ts: 0 }]) diff --git a/opensquilla-webui/src/composables/chat/useChatRouterDecisionRuntime.ts b/opensquilla-webui/src/composables/chat/useChatRouterDecisionRuntime.ts index c7dd5e296c..0dfc381066 100644 --- a/opensquilla-webui/src/composables/chat/useChatRouterDecisionRuntime.ts +++ b/opensquilla-webui/src/composables/chat/useChatRouterDecisionRuntime.ts @@ -34,6 +34,8 @@ export function useChatRouterDecisionRuntime(options: UseChatRouterDecisionRunti messageId: string } | null>(null) let localRouterMessageSeq = 0 + let routerReplayGeneration = 0 + const provisionalRouterMessageGenerations = new Map() // Router and ensemble events can arrive throughout a long streamed answer. // They should follow the live edge only while the reader has elected to stay @@ -43,6 +45,7 @@ export function useChatRouterDecisionRuntime(options: UseChatRouterDecisionRunti } function handleRouterControlReplay() { + routerReplayGeneration += 1 if (!options.isStreaming.value) options.startStreaming() pendingRouterDecision.value = null options.resetStreamForRouterReplay() @@ -169,6 +172,27 @@ export function useChatRouterDecisionRuntime(options: UseChatRouterDecisionRunti const acceptedDecision = freezeAcceptedRoutingMode(decision, turnId) if (options.messages.value.some(message => message.messageId === messageId)) return + if (turnId) { + for (let i = options.messages.value.length - 1; i >= 0; i--) { + const message = options.messages.value[i] + const provisionalMessageId = message.messageId || '' + if ( + message.role === 'router' + && message.provenanceKind === 'router_decision' + && provisionalMessageId.startsWith(`router-${options.sessionKey.value}-`) + && message.turnId === turnId + && provisionalRouterMessageGenerations.get(provisionalMessageId) === routerReplayGeneration + ) { + message.routerDecision = acceptedDecision + message.messageId = messageId + message.turnId = turnId + provisionalRouterMessageGenerations.delete(provisionalMessageId) + scrollToBottomIfFollowing() + return + } + } + } + options.messages.value.push({ role: 'router', text: '', @@ -303,6 +327,7 @@ export function useChatRouterDecisionRuntime(options: UseChatRouterDecisionRunti ...(turnId ? { turnId } : {}), } options.messages.value.push(message) + provisionalRouterMessageGenerations.set(message.messageId!, routerReplayGeneration) return message } @@ -343,7 +368,7 @@ export function useChatRouterDecisionRuntime(options: UseChatRouterDecisionRunti let target = findLiveRouterMessage(turnId) if (!target) { - options.messages.value.push({ + const provisionalMessage: ChatMessage = { role: 'router', text: '', ts: new Date().toISOString(), @@ -352,7 +377,9 @@ export function useChatRouterDecisionRuntime(options: UseChatRouterDecisionRunti messageId: `router-${options.sessionKey.value}-ensemble`, ensemble: emptyEnsemble(), ...(turnId ? { turnId } : {}), - }) + } + options.messages.value.push(provisionalMessage) + provisionalRouterMessageGenerations.set(provisionalMessage.messageId!, routerReplayGeneration) // Re-read through the reactive array so nested mutations below trigger. target = options.messages.value[options.messages.value.length - 1] }