diff --git a/ui-tui-opentui-v2/src/boundary/gateway/liveGateway.ts b/ui-tui-opentui-v2/src/boundary/gateway/liveGateway.ts index d0f3ab61c9f..7e3f0cd7b4f 100644 --- a/ui-tui-opentui-v2/src/boundary/gateway/liveGateway.ts +++ b/ui-tui-opentui-v2/src/boundary/gateway/liveGateway.ts @@ -113,7 +113,14 @@ function makeLiveGateway(): { service: GatewayServiceShape; stop: () => void } { sessionId: () => sessionId } - return { service, stop: () => client.stop() } + // Clear a pending coalesce timer on teardown so a queued flush() can't fire + // batch()/handlers into a torn-down store after the layer scope releases. + const stop = () => { + if (timer) clearTimeout(timer) + timer = undefined + client.stop() + } + return { service, stop } } /** diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 881a129a617..aa9683300ac 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -323,15 +323,15 @@ export function createSessionStore() { return created } - /** Find a tool part by id, scanning recent assistant turns (complete may land late). */ + /** Find a tool part by id in the CURRENT (last) assistant turn — a tool.complete + * always pairs with a tool.start in the live turn, so scoping there avoids + * matching a same-id tool in an older/resumed turn (and is O(parts), not O(all)). */ function findToolPart(draft: StoreState, id: string): ToolPartState | undefined { - for (let i = draft.messages.length - 1; i >= 0; i--) { - const parts = draft.messages[i]?.parts - if (!parts) continue - for (let j = parts.length - 1; j >= 0; j--) { - const p = parts[j] - if (p && p.type === 'tool' && p.id === id) return p - } + const parts = liveAssistant(draft)?.parts + if (!parts) return undefined + for (let j = parts.length - 1; j >= 0; j--) { + const p = parts[j] + if (p && p.type === 'tool' && p.id === id) return p } return undefined } @@ -473,10 +473,12 @@ export function createSessionStore() { case 'message.complete': setState( produce(draft => { - const live = liveAssistant(draft, true) + // complete-only gateways may send `message.complete{text}` with no prior + // start/delta → create the turn so the final text isn't dropped. + const finalText = event.payload?.text + const live = liveAssistant(draft, true) ?? (finalText ? ensureAssistant(draft) : undefined) if (!live) return // If no deltas arrived (complete-only gateways), seed the full text once. - const finalText = event.payload?.text const hasText = (live.parts ?? []).some(p => p.type === 'text' && p.text.length > 0) if (finalText && !hasText) appendPart(live, 'text', finalText) live.streaming = false diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index 536d18132cf..c9d13a0cc1d 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -91,6 +91,24 @@ describe('session store — ordered parts (Phase 2b)', () => { } }) + test('message.complete with text but NO prior start creates the turn (complete-only gateway; no drop)', () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + // no message.start / no deltas — straight to complete with the full text + store.apply({ type: 'message.complete', payload: { text: 'The whole answer.' } }) + const msg = store.state.messages.at(-1)! + expect(msg.role).toBe('assistant') + expect(msg.streaming).toBe(false) + expect(msg.parts?.some(p => p.type === 'text' && p.text === 'The whole answer.')).toBe(true) + }) + + test('message.complete with no live turn and no text does NOT create an empty bubble', () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + store.apply({ type: 'message.complete', payload: {} }) + expect(store.state.messages.filter(m => m.role === 'assistant')).toHaveLength(0) + }) + test('tool.complete updates the running tool part IN PLACE (not a new row)', () => { const store = createSessionStore() store.apply({ type: 'message.start' })