/**
* Pending-turn + hidden-row contracts.
*
* Two reader-visible defects this pins against:
*
* 1. Approving a tool call minted an author-label row with NO body
* ("Michael Assraf:" then nothing). The approval decision is
* out-of-band metadata, so the reducer must not materialize a user
* row for it at all — fixing it at render would leave dead state.
* 2. While the model was thinking, the transcript showed a second,
* empty "Mingo:" row ABOVE the footer progress indicator — two
* owners for one pending turn, and an `aria-live` log announcing a
* bare "Mingo". Exactly one affordance must be present.
*
* Plus the two idioms that must SURVIVE both fixes: a hidden row that
* carries real text (auto-continuation directive) stays in state while
* never rendering, and the SYSTEM name-only row still renders.
*/
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { createChatStreamReducer } from '../stream/chat-stream-reducer'
import {
AUTO_CONTINUATION_DIRECTIVE_PREFIX,
buildAutoContinuationDirective,
} from '../utils/auto-continuation-directive'
import type { Message } from '../types/message.types'
// ─── Render harness (mirrors chat-message-list.test.tsx) ────────────────────
const scrollToBottom = vi.fn()
const stopScroll = vi.fn()
function makeRef() {
const fn = ((el: HTMLElement | null) => {
;(fn as { current: HTMLElement | null }).current = el
}) as ((el: HTMLElement | null) => void) & { current: HTMLElement | null }
fn.current = null
return fn
}
vi.mock('use-stick-to-bottom', () => ({
useStickToBottom: () => ({
scrollRef: makeRef(),
contentRef: makeRef(),
scrollToBottom,
stopScroll,
}),
}))
// Row stub that renders the author label the way the real component does
// (`Name:`), so "labeled row with no body" is directly assertable.
vi.mock('../chat-message-enhanced', () => ({
ChatMessageEnhanced: ({ name, content }: { name?: string; content: unknown }) => (
{name ?? ''}
{typeof content === 'string'
? content
: Array.isArray(content)
? content
.map((s) => (s && (s as { type?: string }).type === 'text' ? (s as { text: string }).text : '[seg]'))
.join('')
: ''}
),
}))
import { ChatMessageList } from '../chat-message-list'
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('IntersectionObserver', ObserverStub)
vi.stubGlobal('ResizeObserver', ObserverStub)
const names = (c: HTMLElement) =>
Array.from(c.querySelectorAll('[data-testid="row-name"]')).map((n) => n.textContent)
const bodies = (c: HTMLElement) =>
Array.from(c.querySelectorAll('[data-testid="row-body"]')).map((n) => n.textContent)
// ─── State-level contracts ─────────────────────────────────────────────────
describe('approval sends mint no visible user row', () => {
it('beginSseSend with empty text pushes only the assistant placeholder', () => {
const r = createChatStreamReducer({ transport: 'sse' })
// What `cardApprove` triggers: ('', { hidden: true, approvalAction }).
r.beginSseSend({ text: '', hidden: true, assistantName: 'Mingo AI' })
expect(r.state.messages).toHaveLength(1)
expect(r.state.messages[0].role).toBe('assistant')
expect(r.state.messages.some((m) => m.role === 'user')).toBe(false)
expect(r.state.streamingPhase).toBe('thinking')
})
it('pushOptimisticSend with empty text likewise mints no user row', () => {
const r = createChatStreamReducer({ transport: 'nats' })
r.pushOptimisticSend('', true)
expect(r.state.messages).toHaveLength(1)
expect(r.state.messages[0].role).toBe('assistant')
})
it('a normal send still mints the user row', () => {
const r = createChatStreamReducer({ transport: 'sse' })
r.beginSseSend({ text: 'hello', assistantName: 'Mingo AI' })
expect(r.state.messages).toHaveLength(2)
expect(r.state.messages[0]).toMatchObject({ role: 'user', content: 'hello' })
expect(r.state.messages[0].hidden).toBeUndefined()
})
})
describe('hidden rows that carry text', () => {
it('an auto-continuation directive IS state (the model sees it) but is flagged hidden', () => {
const directive = buildAutoContinuationDirective('create_ticket', { ticketId: '42' })
expect(directive.startsWith(AUTO_CONTINUATION_DIRECTIVE_PREFIX)).toBe(true)
const r = createChatStreamReducer({ transport: 'sse' })
r.beginSseSend({ text: directive, hidden: true, assistantName: 'Mingo AI' })
const userRow = r.state.messages.find((m) => m.role === 'user')
expect(userRow?.content).toBe(directive)
expect(userRow?.hidden).toBe(true)
})
it('never renders — the raw directive text must not reach the transcript', () => {
const directive = buildAutoContinuationDirective('create_ticket', { ticketId: '42' })
const messages: Message[] = [
{ id: 'u1', role: 'user', name: 'Michael Assraf', content: 'my laptop is dead' },
{ id: 'h1', role: 'user', name: 'Michael Assraf', content: directive, hidden: true },
{ id: 'a1', role: 'assistant', name: 'Mingo', content: 'Opened a ticket.' },
]
const { container } = render()
expect(container.textContent).not.toContain(AUTO_CONTINUATION_DIRECTIVE_PREFIX)
expect(bodies(container)).toEqual(['my laptop is dead', 'Opened a ticket.'])
})
})
// ─── Render-level contracts ────────────────────────────────────────────────
describe('a thinking turn has exactly ONE pending affordance', () => {
const pending: Message[] = [
{ id: 'u1', role: 'user', name: 'Michael Assraf', content: 'what is broken?' },
// `Message` carries its body in `content` — there is no `segments` field on
// it (that is `UnifiedChatMessage`), and the render path never reads one.
// An empty `content` IS the empty pending turn.
{ id: 'a1', role: 'assistant', name: 'Mingo', content: '' },
]
it('does not render the empty labeled assistant row while typing', () => {
const { container } = render(
,
)
// The bare "Mingo" label must be gone from the transcript entirely —
// removed from the DOM, not visually hidden (the list is an aria-live
// log; a zero-height node would still be announced).
expect(names(container)).toEqual(['Michael Assraf'])
expect(bodies(container)).toEqual(['what is broken?'])
// …and the single remaining owner is the polite status indicator.
expect(container.querySelectorAll('[role="status"]')).toHaveLength(1)
})
it('renders the assistant row as soon as it carries text', () => {
const withText: Message[] = [
pending[0],
{ id: 'a1', role: 'assistant', name: 'Mingo', content: [{ type: 'text', text: 'Chec' }] },
]
const { container } = render(
,
)
expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
})
it('renders the assistant row when it carries a NON-text segment (tool call)', () => {
const withTool: Message[] = [
pending[0],
{
id: 'a1',
role: 'assistant',
name: 'Mingo',
// The REAL `ToolExecutionSegment` shape — `{ type, data }`, not flat
// fields. The previous fixture put `toolExecutionRequestId` / `status`
// at the top level and needed an `as` cast to compile at all (the cast
// was the tell: the two types did not overlap). It passed only because
// the empty-pending-turn predicate looks at segment TYPE; the moment it
// inspected `data`, this test would have kept passing while the real
// path broke.
content: [
{
type: 'tool_execution',
data: {
type: 'EXECUTING_TOOL',
integratedToolType: 'SHELL',
toolFunction: 'run_command',
toolTitle: 'Run command',
parameters: { cmd: 'ls' },
toolExecutionRequestId: 'e1',
},
},
],
},
]
const { container } = render(
,
)
expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
})
it('renders the assistant row when it carries ONLY a guide segment', () => {
// A `guide` body is the whole visible answer for a how-to question. If
// the empty-pending-turn predicate ever counted it as "nothing visible"
// (e.g. by widening its text-only clause), a guide-only turn would render
// NOTHING until the turn ended — the footer loader would be the sole
// affordance while a fully-formed guide card sat in state.
const withGuide: Message[] = [
pending[0],
{
id: 'a1',
role: 'assistant',
name: 'Mingo',
content: [{ type: 'guide', text: '## Enroll a device' }],
},
]
const { container } = render(
,
)
expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
})
it('renders a completed empty-ish assistant row when NOT typing', () => {
const { container } = render()
expect(names(container)).toEqual(['Michael Assraf', 'Mingo'])
})
})
describe('the SYSTEM name-only row is a legitimate label-without-body', () => {
it('still renders', () => {
const messages: Message[] = [
{ id: 'u1', role: 'user', name: 'Michael Assraf', content: 'hi' },
{ id: 's1', role: 'user', name: 'Technician joined', content: '', authorType: 'system' },
]
const { container } = render()
expect(names(container)).toContain('Technician joined')
})
})