Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// @vitest-environment happy-dom

// A message shown as not sent stays in the outbox and draws below newer turns, after their live
// activity. It is in no turn: the newer turn's "Working for" clock and "Worked for" bar stay with
// that turn, never under it.

import '@testing-library/jest-dom/vitest'

import { cleanup, render } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalRenderItem,
AgentJournalSubmission,
AgentJournalTurnScope
} from '../../../../shared/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key'
import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status'
import {
createStructuredAgentSessionOutboxEntry,
type StructuredAgentSessionOutboxEntry
} from '../../../../shared/structured-agent-session-outbox'
import { NativeChatMessageList } from './NativeChatMessageList'
import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'

let restoreViewport = (): void => {}
beforeAll(() => {
restoreViewport = installNativeChatMessageListTestViewport()
})
afterAll(() => restoreViewport())
afterEach(cleanup)

type Phase = 'in flight' | 'running' | 'done'

const SEED = agentJournalSubmissionKey('seed')
const NEW = agentJournalSubmissionKey('new')

function said(role: 'user' | 'assistant', text: string): AgentJournalItemBody {
return { kind: 'message', role, blocks: [{ type: 'text', text }] }
}

function journal(phase: Phase, scoped: boolean): AgentJournalRenderItem[] {
const thread: AgentJournalTurnScope = { kind: 'thread' }
const row = (
itemId: string,
body: AgentJournalItemBody,
turnScope: AgentJournalTurnScope
): AgentJournalRenderItem => ({
itemId,
revision: 0,
sequence: rows.length + 1,
observedAt: 1000 + rows.length,
body,
...(scoped ? { turnScope } : {})
})
const rows: AgentJournalRenderItem[] = []
rows.push(row(SEED, said('user', 'SEED PROMPT'), thread))
rows.push(
row(
'turn-seed',
{ kind: 'turn', turnId: 'turn-seed', state: 'completed', userItemId: SEED },
thread
)
)
rows.push(
row('seed-answer', said('assistant', 'SEED OK'), { kind: 'turn', turnItemId: 'turn-seed' })
)
rows.push(row(NEW, said('user', 'NEW PROMPT'), thread))
if (phase !== 'in flight') {
rows.push(
row(
'turn-new',
{
kind: 'turn',
turnId: 'turn-new',
state: phase === 'done' ? 'completed' : 'running',
userItemId: NEW
},
thread
)
)
}
if (phase === 'done') {
rows.push(
row('new-answer', said('assistant', 'NEW OK'), { kind: 'turn', turnItemId: 'turn-new' })
)
}
return rows
}

function submission(
id: string,
dispatchState: AgentJournalSubmission['dispatchState']
): AgentJournalSubmission {
return {
clientMessageId: id,
fence: 1,
payloadFingerprint: id,
dispatchState,
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
}

function unsentEntry(kind: 'held' | 'rejected'): StructuredAgentSessionOutboxEntry {
const entry = createStructuredAgentSessionOutboxEntry({
clientMessageId: 'held',
sessionId: 'session-1',
text: 'HELD PROMPT',
attachments: [],
queuedAt: 500
})
return kind === 'held'
? {
...entry,
lastAttemptAt: 500,
lastFailure: { kind: 'refused', code: 'agent_session_journal_unreadable' }
}
: { ...entry, state: 'rejected', lastFailure: { kind: 'rejected', reason: null } }
}

function list(phase: Phase, scoped: boolean, outbox: StructuredAgentSessionOutboxEntry[]) {
const items = journal(phase, scoped)
const submissions = [
submission('seed', 'accepted'),
submission('new', phase === 'done' ? 'accepted' : 'pending')
]
const settledTurns: NativeChatSettledTurns = new Map([
[SEED, { startedAt: 1, workedSeconds: 3 }],
...(phase === 'done' ? [[NEW, { startedAt: 2, workedSeconds: 5 }] as const] : [])
])
return (
<NativeChatMessageList
session={{
messages: projectStructuredAgentSessionMessages(items, outbox, submissions),
status: phase === 'done' ? 'ready' : 'working',
sessionId: 'session-1',
agent: 'claude',
hasMore: false,
loadingEarlier: false,
olderHistoryGeneration: 0,
loadEarlier: vi.fn(),
readPhase: 'ready'
}}
journalItems={items}
journalSubmissions={submissions}
isWorking={phase !== 'done'}
workingStartedAt={phase === 'done' ? null : Date.now() - 1500}
settledTurns={settledTurns}
expandSignal={false}
fontScale={1}
/>
)
}

/** The drawn sequence of the prompts, bars and live activity line, top to bottom. */
function drawn(container: HTMLElement): string[] {
const out: string[] = []
for (const element of container.querySelectorAll('*')) {
if (element.hasAttribute('data-native-chat-turn-activity')) {
out.push('ACTIVITY')
} else if (element.hasAttribute('data-native-chat-turn-status')) {
out.push(
element.getAttribute('data-native-chat-turn-status') === 'active' ? 'WORKING' : 'WORKED'
)
} else if (element.childElementCount === 0 && (element.textContent ?? '').endsWith('PROMPT')) {
out.push(element.textContent ?? '')
}
}
return out
}

describe.each([
['held for its Retry', 'held'],
['rejected', 'rejected']
] as const)('a message %s, below a newer turn', (_label, kind) => {
it.each([
['states each row turn', true],
['states no turn scope', false]
])(
'keeps the newer turn bar with that turn while it runs and once done (host %s)',
(_host, scoped) => {
const outbox = [unsentEntry(kind)]
const { container, rerender } = render(list('in flight', scoped, outbox))
expect(drawn(container)).toEqual([
'SEED PROMPT',
'WORKED',
'NEW PROMPT',
'WORKING',
'ACTIVITY',
'HELD PROMPT'
])
rerender(list('running', scoped, outbox))
expect(drawn(container)).toEqual([
'SEED PROMPT',
'WORKED',
'NEW PROMPT',
'WORKING',
'ACTIVITY',
'HELD PROMPT'
])
rerender(list('done', scoped, outbox))
expect(drawn(container)).toEqual([
'SEED PROMPT',
'WORKED',
'NEW PROMPT',
'WORKED',
'HELD PROMPT'
])
expect(container.textContent).toContain('Worked for 5s')
}
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@ export function createStructuredSessionMocks() {
loadOlder: mocks.loadOlder,
prompts: mocks.promptItems,
outbox: outbox.outbox,
failedHere: outbox.failedHere,
submissions: mocks.submissions,
blockedClientMessageId: outbox.blockedClientMessageId,
send: outbox.send,
retry: outbox.retry,
isWorking: mocks.isWorking,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,19 +131,19 @@ export function NativeChatStructuredSession(
() =>
structuredAgentSessionDeliveryNotices(
controller.outbox,
controller.blockedClientMessageId,
agentLabel,
retryDelivery,
rejectionRows,
startFailures
startFailures,
controller.failedHere
),
[
controller.outbox,
controller.blockedClientMessageId,
agentLabel,
retryDelivery,
rejectionRows,
startFailures
startFailures,
controller.failedHere
]
)
const viewState = selectNativeChatViewState(session, { readRetries: true })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ vi.mock('./use-structured-agent-session', async () => {
loadOlder: vi.fn(),
prompts: mocks.promptItems,
outbox: outbox.outbox,
failedHere: outbox.failedHere,
submissions: mocks.submissions,
blockedClientMessageId: outbox.blockedClientMessageId,
send: outbox.send,
retry: outbox.retry,
isWorking: false,
Expand Down Expand Up @@ -183,6 +183,7 @@ vi.mock('./NativeChatQuestionCard', () => ({
}))

import { NativeChatStructuredSession } from './NativeChatStructuredSession'
import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage'

describe('NativeChatStructuredSession delivery', () => {
afterEach(() => {
Expand Down Expand Up @@ -768,4 +769,44 @@ describe('NativeChatStructuredSession delivery', () => {
vi.useRealTimers()
}
}, 30000)

// A cause seen while the chat was open is worded in full; one read back after the chat is
// reopened may have cleared, until a Retry it still stops brings it back.
it('words a refusal seen here in full, and after a reopen only once its Retry is refused', async () => {
const newerOrca =
'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.'
mocks.mode = 'outbox'
mocks.call.mockResolvedValue({
ok: false,
refusal: {
code: 'agent_session_journal_unreadable',
message: 'newer',
details: { reason: 'journalWrittenByNewerOrca' }
}
})
const view = (): React.JSX.Element => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="tab-held"
sessionId="session-held"
target={{ kind: 'local' }}
agent="codex"
/>
)
const first = render(view())
// The composer's own enqueue.
act(() => {
expect(appendStructuredAgentSessionOutboxMessage('session-held', 'hello')).not.toBeNull()
})
await waitFor(() => expect(screen.getByText(newerOrca)).toBeTruthy())
first.unmount()

render(view())
await waitFor(() => expect(screen.getByText('Your message was not sent.')).toBeTruthy())
expect(mocks.call).toHaveBeenCalledOnce()
fireEvent.click(screen.getByRole('button', { name: /Retry/ }))
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
await waitFor(() => expect(screen.getByText(newerOrca)).toBeTruthy())
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,31 @@ describe('active rail item', () => {
})
).toBe('u2')
})

// A rejected send the journal recorded keeps its place but opens no turn.
it('lights a prompt in no turn by its own id', () => {
const slots: NativeChatRailSlot[] = [
...TURNS,
{ turnKey: undefined, message: { id: 'rejected', role: 'user' } }
]
expect(
findActiveNativeChatRailItem({
slots,
virtualItems: rows(11),
scrollTop: 800,
clientHeight: VIEWPORT,
scrollHeight: 1100,
previousActiveId: null
})
).toBe('rejected')
expect(
findActiveNativeChatRailItem({
slots,
virtualItems: rows(11),
scrollTop: 1010,
...MID_SCROLL,
scrollHeight: 2000
})
).toBe('rejected')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,15 @@ export type NativeChatRailVirtualItem = {
end: number
}

/** Only the field the rail reads, so a test needs no slot builder. */
/** Only the fields the rail reads, so a test needs no slot builder. */
export type NativeChatRailSlot = {
turnKey: string | undefined
/** A user row in no turn (one shown as not sent) lights its own tick. */
message?: { id: string; role: string }
}

function railTickOf(slot: NativeChatRailSlot | undefined): string | null {
return slot?.turnKey ?? (slot?.message?.role === 'user' ? slot.message.id : null)
}

export function findActiveNativeChatRailItem({
Expand All @@ -53,7 +59,7 @@ export function findActiveNativeChatRailItem({
const atBottom = scrollHeight - clientHeight - scrollTop <= NATIVE_CHAT_BOTTOM_THRESHOLD_PX
if (atBottom) {
const last = virtualItems.at(-1)
return last === undefined ? previousActiveId : (slots[last.index]?.turnKey ?? null)
return last === undefined ? previousActiveId : railTickOf(slots[last.index])
}

let fold: NativeChatRailVirtualItem | undefined
Expand All @@ -65,12 +71,12 @@ export function findActiveNativeChatRailItem({
// Scrolled above everything the window holds: the first windowed row is the
// nearest thing to the fold.
if (fold === undefined) {
return slots[virtualItems[0]?.index ?? -1]?.turnKey ?? null
return railTickOf(slots[virtualItems[0]?.index ?? -1])
}
// The window lags the scroll by a commit, so a fold past every row it holds is
// a stale read, not an answer. Holding the previous tick beats blanking one.
if (fold.end <= scrollTop) {
return previousActiveId
}
return slots[fold.index]?.turnKey ?? null
return railTickOf(slots[fold.index])
}
Loading
Loading