refactor: group native chat turn presentation (#34629)

This commit is contained in:
Neil
2026-10-27 17:33:03 -07:00
committed by GitHub
parent 59dbb54dad
commit ea532813b1
33 changed files with 57 additions and 51 deletions
@@ -0,0 +1,41 @@
import { expect, it, vi } from 'vitest'
import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity'
it('does not trim every preceding activity line to select the final activity', () => {
const text = `${'Previous activity\n'.repeat(500)}Preparing the answer`
const trim = vi.spyOn(String.prototype, 'trim')
try {
expect(selectStructuredAgentTurnActivity([], 'turn', { turnId: 'turn', text })).toEqual({
kind: 'description',
text: 'Preparing the answer'
})
expect(trim.mock.calls.length).toBeLessThan(10)
} finally {
trim.mockRestore()
}
})
it('preserves the last nonempty LF-delimited line before prompt normalization', () => {
const cases: [string, string | null][] = [
['', null],
['\n', null],
['\n\n', null],
[' \r\n\t', null],
['first\rsecond', 'first second'],
['\nfirst\r\nsecond\r\n', 'second'],
['first\n\u00a0\u2003\n', 'first'],
['first\n\u2028second\u2029', 'second'],
['a\n😀', '😀'],
['a\n\ud800', '\ud800'],
['a\n\udc00', '\udc00'],
[`a\n${'x'.repeat(199)}😀`, 'x'.repeat(199)],
[`first\n${' '.repeat(3000)}last`, 'last'],
['first\n\0\n', '\0'],
['first\n\ufeff\n', 'first']
]
for (const [text, expected] of cases) {
expect(selectStructuredAgentTurnActivity([], 'turn', { turnId: 'turn', text })).toEqual(
expected ? { kind: 'description', text: expected } : null
)
}
})
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalRenderItem
} from '../../agent-session-journal-types'
import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity'
function item(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem {
return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body }
}
const turnStart = item(1, {
kind: 'status',
text: 'Codex is working…',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
})
describe('selectStructuredAgentTurnActivity', () => {
it('shows the latest line of the live provider activity in the active turn', () => {
const activity = selectStructuredAgentTurnActivity(
[
turnStart,
item(2, {
kind: 'tool-call',
name: 'shell',
input: { command: 'pnpm test' },
state: 'completed'
})
],
'turn-1',
{ turnId: 'turn-1', text: 'Checking the results\nPreparing the answer' }
)
expect(activity).toEqual({ kind: 'description', text: 'Preparing the answer' })
})
it("never puts the model's reasoning on the indicator line", () => {
const reasoning = item(2, {
kind: 'message',
role: 'reasoning',
blocks: [{ type: 'text', text: 'Let me check whether the journal already records this' }]
})
// Reasoning is the turn's content; the row says the turn is thinking instead.
expect(selectStructuredAgentTurnActivity([turnStart, reasoning], 'turn-1')).toBeNull()
// Provider-authored copy is unaffected, so Codex keeps its line.
expect(
selectStructuredAgentTurnActivity([turnStart, reasoning], 'turn-1', {
turnId: 'turn-1',
text: 'Running a command'
})
).toEqual({ kind: 'description', text: 'Running a command' })
})
it('shows matching ephemeral provider activity, never an older journal status', () => {
const activity = selectStructuredAgentTurnActivity(
[turnStart, item(2, { kind: 'status', text: 'Older journal status' })],
'turn-1',
{ turnId: 'turn-1', text: 'Inspecting the session wire' }
)
expect(activity).toEqual({ kind: 'description', text: 'Inspecting the session wire' })
})
it('ignores ephemeral activity from another or settled turn', () => {
const providerActivity = { turnId: 'turn-1', text: 'Inspecting the session wire' }
expect(selectStructuredAgentTurnActivity([turnStart], 'turn-2', providerActivity)).toBeNull()
expect(selectStructuredAgentTurnActivity([turnStart], null, providerActivity)).toBeNull()
})
it.each([
['active', 'Still running pnpm test'],
['most recently settled', 'Running shell pnpm lint now']
])('never repeats the %s tool label as provider activity', (_kind, text) => {
const activity = selectStructuredAgentTurnActivity(
[
turnStart,
item(2, {
kind: 'tool-call',
name: 'shell',
input: { command: 'pnpm test' },
state: 'running'
}),
item(3, {
kind: 'tool-call',
name: 'shell',
input: { command: 'pnpm lint' },
state: 'completed'
})
],
'turn-1',
{ turnId: 'turn-1', text }
)
expect(activity).toBeNull()
})
})
describe('selectStructuredAgentTurnActivity — which agent it answers for', () => {
/** A row a subagent produced, which shares the session's journal. */
function childItem(sequence: number, body: AgentJournalItemBody): AgentJournalRenderItem {
return { ...item(sequence, body), agentId: 'task-1', producerKind: 'agent' }
}
const childRunningBash = childItem(2, {
kind: 'tool-call',
name: 'shell',
input: { command: 'pnpm test' },
state: 'running'
})
it("does not let a child's tool label suppress the provider's line for the parent", () => {
// Child tool labels cannot dedupe the session parent’s own provider activity line.
expect(
selectStructuredAgentTurnActivity([turnStart, childRunningBash], 'turn-1', {
turnId: 'turn-1',
text: 'pnpm test'
})
).toEqual({ kind: 'description', text: 'pnpm test' })
})
it("still suppresses a line repeating the session's OWN running tool", () => {
// The scoping must not disable the de-duplication it was narrowing.
expect(
selectStructuredAgentTurnActivity(
[
turnStart,
item(2, {
kind: 'tool-call',
name: 'shell',
input: { command: 'pnpm test' },
state: 'running'
})
],
'turn-1',
{ turnId: 'turn-1', text: 'pnpm test' }
)
).toBeNull()
})
})
@@ -0,0 +1,100 @@
import { readAgentJournalTurn } from '../../agent-session-turn-record'
import type { AgentJournalRenderItem } from '../../agent-session-journal-types'
import type { AgentSessionTurnActivity } from '../../agent-session-wire'
import { isRootAgentJournalItem } from '../../agent-session-journal-producer'
import { normalizePromptField } from '../../agent-status-field-normalization'
import { describeActiveToolCall, formatActiveToolLabel } from '../tools/native-chat-tool-activity'
export type NativeChatTurnActivity = { kind: 'description'; text: string }
function activityLine(text: string): string | null {
let end = text.length
while (end > 0) {
const start = text.lastIndexOf('\n', end - 1) + 1
const latest = text.slice(start, end).trim()
if (latest) {
return normalizePromptField(latest) || null
}
if (start === 0) {
break
}
end = start - 1
}
return null
}
function recentToolActivityLabels(items: readonly AgentJournalRenderItem[]): Set<string> {
const labels = new Set<string>()
let foundRunning = false
let foundSettled = false
for (let index = items.length - 1; index >= 0 && (!foundRunning || !foundSettled); index -= 1) {
const body = items[index]?.body
if (body?.kind !== 'tool-call') {
continue
}
const isRunning = body.state === 'running'
if ((isRunning && foundRunning) || (!isRunning && foundSettled)) {
continue
}
const descriptor = describeActiveToolCall({
type: 'tool-call',
name: body.name,
input: body.input,
state: body.state
})
const candidates = [
formatActiveToolLabel(descriptor),
descriptor.preview,
descriptor.preview ? `${descriptor.toolName} ${descriptor.preview}` : descriptor.toolName
]
for (const candidate of candidates) {
const label = activityLine(candidate)?.toLowerCase()
if (label) {
labels.add(label)
}
}
foundRunning ||= isRunning
foundSettled ||= !isRunning
}
return labels
}
function repeatsRecentToolLabel(text: string, labels: ReadonlySet<string>): boolean {
const normalized = text.toLowerCase()
for (const label of labels) {
if (
normalized === label ||
normalized.startsWith(`${label} `) ||
normalized.endsWith(` ${label}`) ||
normalized.includes(` ${label} `)
) {
return true
}
}
return false
}
/** The live provider activity for this turn; callers provide the broad fallback.
* Journal rows are history, not the present, so none of them ever becomes this line. */
export function selectStructuredAgentTurnActivity(
items: readonly AgentJournalRenderItem[],
turnId: string | null,
providerActivity?: AgentSessionTurnActivity | null
): NativeChatTurnActivity | null {
if (!turnId || providerActivity?.turnId !== turnId) {
return null
}
const text = activityLine(providerActivity.text)
if (!text) {
return null
}
const turnStartIndex = items.findLastIndex((item) => {
const turn = readAgentJournalTurn(item.body)
return turn?.turnId === turnId && turn.state === 'running'
})
// Root-scoped: a subagent's tool label must not suppress the parent's own line.
const turnItems = items.slice(Math.max(0, turnStartIndex)).filter(isRootAgentJournalItem)
return repeatsRecentToolLabel(text, recentToolActivityLabels(turnItems))
? null
: { kind: 'description', text }
}
@@ -0,0 +1,220 @@
import { describe, expect, it } from 'vitest'
import {
nativeChatTurnAnswerRows,
nativeChatTurnFold,
type NativeChatTurnFoldRow
} from './native-chat-turn-fold'
function row(overrides: Partial<NativeChatTurnFoldRow> = {}): NativeChatTurnFoldRow {
return {
turnKey: 'turn-1',
role: 'assistant',
rendersProse: true,
draws: true,
outlivesTurn: false,
reportsFailure: false,
explainsTurn: false,
...overrides
}
}
/** The turn in the reported session: a prompt, narration, work, then the answer. */
const TURN: NativeChatTurnFoldRow[] = [
row({ role: 'user' }),
row(),
row({ rendersProse: false }),
row(),
row()
]
/** An error-toned system row: the agent or host reporting the turn failed. */
const FAILURE = row({ role: 'system', reportsFailure: true })
const SETTLED = new Set(['turn-1'])
const NONE = new Set<string>()
describe('nativeChatTurnAnswerRows', () => {
it('names the last assistant row that renders prose, not the first', () => {
expect(nativeChatTurnAnswerRows(TURN).get('turn-1')).toBe(4)
})
it('ignores rows that render no prose, so a trailing tool run is not the answer', () => {
const rows = [row({ role: 'user' }), row(), row({ rendersProse: false })]
expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(1)
})
it('ignores reasoning and system rows, which are never the agent answering', () => {
const rows = [row({ role: 'user' }), row(), row({ role: 'reasoning' }), row({ role: 'system' })]
expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(1)
})
it('names a failure report as the answer when nothing the agent said comes after it', () => {
const rows = [row({ role: 'user' }), row(), FAILURE]
expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(2)
})
it('names the later prose as the answer when the agent recovered from a failure', () => {
const rows = [row({ role: 'user' }), FAILURE, row()]
expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(2)
})
it('reports no answer for a turn that only ran tools', () => {
const rows = [row({ role: 'user' }), row({ rendersProse: false })]
expect(nativeChatTurnAnswerRows(rows).has('turn-1')).toBe(false)
})
})
describe('nativeChatTurnFold', () => {
it('folds a settled turn to its answer', () => {
const { foldedRows } = nativeChatTurnFold({
rows: TURN,
settledTurnKeys: SETTLED,
expandedTurnKeys: NONE
})
expect([...foldedRows].sort()).toEqual([1, 2, 3])
})
// A stored-only provider event draws nothing: a disclosure over it would open onto nothing.
it('offers no disclosure for a turn whose only other row draws nothing', () => {
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
rows: [
row({ role: 'user' }),
row({ role: 'system', rendersProse: false, draws: false }),
row()
],
settledTurnKeys: SETTLED,
expandedTurnKeys: NONE
})
expect(foldedRows.size).toBe(0)
expect(foldableTurnKeys.size).toBe(0)
})
it('folds nothing while the turn is still running', () => {
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
rows: TURN,
settledTurnKeys: NONE,
expandedTurnKeys: NONE
})
expect(foldedRows.size).toBe(0)
expect(foldableTurnKeys.size).toBe(0)
})
it('reveals every row of a turn the reader opened, and still reports it foldable', () => {
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
rows: TURN,
settledTurnKeys: SETTLED,
expandedTurnKeys: SETTLED
})
expect(foldedRows.size).toBe(0)
expect([...foldableTurnKeys]).toEqual(['turn-1'])
})
it('keeps a spawn roster or background task out of the fold', () => {
const rows = [row({ role: 'user' }), row(), row({ outlivesTurn: true }), row()]
const { foldedRows } = nativeChatTurnFold({
rows,
settledTurnKeys: SETTLED,
expandedTurnKeys: NONE
})
expect(foldedRows.has(2)).toBe(false)
expect(foldedRows.has(1)).toBe(true)
})
it('folds a prose-less turn whole, so its commands do not return to the transcript', () => {
const rows = [row({ role: 'user' }), row({ rendersProse: false }), row({ rendersProse: false })]
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
rows,
settledTurnKeys: SETTLED,
expandedTurnKeys: NONE
})
expect([...foldedRows].sort()).toEqual([1, 2])
expect([...foldableTurnKeys]).toEqual(['turn-1'])
})
it('offers no disclosure on a turn that is nothing but its answer', () => {
const rows = [row({ role: 'user' }), row()]
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
rows,
settledTurnKeys: SETTLED,
expandedTurnKeys: NONE
})
expect(foldedRows.size).toBe(0)
expect(foldableTurnKeys.size).toBe(0)
})
it('folds each settled turn to its own answer and leaves a running turn alone', () => {
const rows = [
row({ turnKey: 'turn-1', role: 'user' }),
row({ turnKey: 'turn-1' }),
row({ turnKey: 'turn-1' }),
row({ turnKey: 'turn-2', role: 'user' }),
row({ turnKey: 'turn-2' }),
row({ turnKey: 'turn-2' })
]
const { foldedRows } = nativeChatTurnFold({
rows,
settledTurnKeys: new Set(['turn-1']),
expandedTurnKeys: NONE
})
expect([...foldedRows]).toEqual([1])
})
it('leaves rows before the first prompt alone', () => {
const rows = [row({ turnKey: undefined }), row({ turnKey: undefined })]
const { foldedRows } = nativeChatTurnFold({
rows,
settledTurnKeys: SETTLED,
expandedTurnKeys: NONE
})
expect(foldedRows.size).toBe(0)
})
it('ends a failed turn on its error, folding the work and prose before it', () => {
const rows = [row({ role: 'user' }), row(), row({ rendersProse: false }), FAILURE]
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
rows,
settledTurnKeys: SETTLED,
expandedTurnKeys: NONE
})
expect([...foldedRows].sort()).toEqual([1, 2])
expect([...foldableTurnKeys]).toEqual(['turn-1'])
})
it('folds an error the agent recovered from behind the answer that followed it', () => {
const rows = [row({ role: 'user' }), row({ rendersProse: false }), FAILURE, row()]
const { foldedRows } = nativeChatTurnFold({
rows,
settledTurnKeys: SETTLED,
expandedTurnKeys: NONE
})
expect([...foldedRows].sort()).toEqual([1, 2])
})
it('shows a turn that is nothing but its error as that error, with no disclosure', () => {
const rows = [row({ role: 'user' }), FAILURE]
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
rows,
settledTurnKeys: SETTLED,
expandedTurnKeys: NONE
})
expect(foldedRows.size).toBe(0)
expect(foldableTurnKeys.size).toBe(0)
})
it('never folds a compaction report, and keeps the last prose as the answer', () => {
const rows = [
row({ role: 'user' }),
row(),
row({ role: 'system', explainsTurn: true }),
row(),
row({ role: 'system', explainsTurn: true })
]
const { foldedRows } = nativeChatTurnFold({
rows,
settledTurnKeys: SETTLED,
expandedTurnKeys: NONE
})
expect([...foldedRows]).toEqual([1])
expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(3)
})
})
@@ -0,0 +1,134 @@
// Derive settled-turn answers from the last assistant prose or system failure; the journal has no answer marker.
// Earlier work/recovered errors fold behind that answer, while failed turns end visibly on their error.
import { AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION } from '../../agent-session-compaction'
import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../agent-session-orca-stop'
import { AGENT_SESSION_TURN_NOTICE_PRESENTATION } from '../../agent-session-turn-notice'
import { nativeChatRowRendersContent } from '../../native-chat-row-content'
import { isStoppedBeforeStartBlock } from '../native-chat-stopped-before-start'
import { nativeChatRowRendersProse } from '../native-chat-trailing-run'
import {
isBackgroundTaskBlock,
isSubagentGroupBlock,
type NativeChatMessage,
type NativeChatRole
} from '../../native-chat-types'
/** Each surface supplies its own row-content results so folding sees exactly the prose it renders. */
export type NativeChatTurnFoldRow = {
/** The turn this row belongs to; undefined before the first user message. */
turnKey: string | undefined
role: NativeChatRole
/** Whether the row draws prose — the only thing that can be an answer. */
rendersProse: boolean
/** Invisible rows cannot justify a disclosure: they hide nothing. */
draws: boolean
/** Spawn/task rows outlive their turn and may be the only outcome report; never fold them. */
outlivesTurn: boolean
/** System failures compete as final answers so failed turns end on visible errors. */
reportsFailure: boolean
/** Compaction/interruption/turn notices remain visible; hiding them leaves only generic turn status. */
explainsTurn: boolean
}
/** How the fold reads a transcript row, with the same derivation the row renders from. */
export function nativeChatTurnFoldRow(
message: NativeChatMessage,
turnKey: string | undefined,
hasReceipt: boolean
): NativeChatTurnFoldRow {
return {
turnKey,
role: message.role,
rendersProse: nativeChatRowRendersProse(message),
draws: hasReceipt || nativeChatRowRendersContent(message.blocks),
// Use raw roster blocks: empty rosters render no block, making their plain-text twin the sole spawn record.
outlivesTurn: message.blocks.some(
(block) =>
isSubagentGroupBlock(block) ||
isBackgroundTaskBlock(block) ||
isStoppedBeforeStartBlock(block)
),
// A row about Orca's own stop is stored red for clients that predate it; it reports no failure.
reportsFailure: message.blocks.some(
(block) =>
block.type === 'text' &&
(block.tone === 'error' || block.failure?.kind === 'usageLimitReached') &&
block.presentation !== AGENT_SESSION_ORCA_STOP_PRESENTATION
),
explainsTurn: message.blocks.some(
(block) =>
block.type === 'text' &&
(block.presentation === 'compaction' ||
block.presentation === AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION ||
block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION ||
block.presentation === AGENT_SESSION_TURN_NOTICE_PRESENTATION)
)
}
}
export type NativeChatTurnFold = {
/** Rows a folded turn must not draw, by index. */
foldedRows: ReadonlySet<number>
/** Offer disclosure only for turns that actually hide rows. */
foldableTurnKeys: ReadonlySet<string>
}
export const NATIVE_CHAT_EMPTY_TURN_FOLD: NativeChatTurnFold = {
foldedRows: new Set(),
foldableTurnKeys: new Set()
}
/** Last assistant prose/system failure per turn; no answer means fold all work. */
export function nativeChatTurnAnswerRows(
rows: readonly NativeChatTurnFoldRow[]
): ReadonlyMap<string, number> {
const answers = new Map<string, number>()
for (const [index, row] of rows.entries()) {
const isCandidate =
row.rendersProse &&
(row.role === 'assistant' || (row.role === 'system' && row.reportsFailure))
if (row.turnKey !== undefined && isCandidate) {
answers.set(row.turnKey, index)
}
}
return answers
}
/** Fold settled, unexpanded turns when they gain a duration/status row. Running turns stay open; newly settled turns start collapsed without separate disclosure cleanup. */
export function nativeChatTurnFold({
rows,
settledTurnKeys,
expandedTurnKeys
}: {
rows: readonly NativeChatTurnFoldRow[]
settledTurnKeys: ReadonlySet<string>
expandedTurnKeys: ReadonlySet<string>
}): NativeChatTurnFold {
const answers = nativeChatTurnAnswerRows(rows)
const foldedRows = new Set<number>()
const foldableTurnKeys = new Set<string>()
for (const [index, row] of rows.entries()) {
const { turnKey } = row
// User anchors, durable task/roster reports and turn explanations remain outside the fold.
if (
turnKey === undefined ||
row.role === 'user' ||
!row.draws ||
row.outlivesTurn ||
row.explainsTurn ||
!settledTurnKeys.has(turnKey)
) {
continue
}
// No-prose turns fold wholly behind the status anchor instead of leaving every command expanded.
if (index === answers.get(turnKey)) {
continue
}
foldableTurnKeys.add(turnKey)
if (!expandedTurnKeys.has(turnKey)) {
foldedRows.add(index)
}
}
return { foldedRows, foldableTurnKeys }
}
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../native-chat-types'
import {
nativeChatRowTurnKeys,
nativeChatTurnBarRows,
nativeChatTurnDrawOrder
} from './native-chat-turn-grouping'
function message(id: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage {
return { id, role, blocks: [{ type: 'text', text: id }], timestamp: null, source: 'transcript' }
}
describe('nativeChatRowTurnKeys', () => {
it('keeps rows after a mid-turn send with the turn that produced them', () => {
// The #23621 shape: B lands mid-turn, three tool rows follow, all one turn.
const messages = [
message('A', 'user'),
message('t1'),
message('B', 'user'),
message('t2'),
message('t3'),
message('t4')
]
const owned = new Map([
['A', 'A'],
['t1', 'A'],
['B', 'A'],
['t2', 'A'],
['t3', 'A'],
['t4', 'A']
])
expect(nativeChatRowTurnKeys(messages, owned)).toEqual(['A', 'A', 'A', 'A', 'A', 'A'])
})
it('lets an unmapped user row key itself and unmapped rows inherit it', () => {
// C is an optimistic echo the journal has not admitted yet: positional rules.
const messages = [message('A', 'user'), message('t1'), message('C', 'user'), message('t2')]
const owned = new Map([
['A', 'A'],
['t1', 'A']
])
expect(nativeChatRowTurnKeys(messages, owned)).toEqual(['A', 'A', 'C', 'C'])
})
it('reproduces preceding-user grouping exactly when the host attributes nothing', () => {
const messages = [
message('lead'),
message('A', 'user'),
message('t1'),
message('B', 'user'),
message('t2')
]
const positional = [undefined, 'A', 'A', 'B', 'B']
expect(nativeChatRowTurnKeys(messages, null)).toEqual(positional)
expect(nativeChatRowTurnKeys(messages, new Map())).toEqual(positional)
})
it('keys a provider-opened turn to a record no message carries', () => {
const messages = [message('A', 'user'), message('t1'), message('t2')]
const owned = new Map([
['A', 'A'],
['t1', 'wake-turn'],
['t2', 'wake-turn']
])
expect(nativeChatRowTurnKeys(messages, owned)).toEqual(['A', 'wake-turn', 'wake-turn'])
})
})
describe('nativeChatTurnBarRows', () => {
it('anchors a turn with no user bubble above its first rendered row', () => {
const messages = [message('A', 'user'), message('t1'), message('t2'), message('t3')]
const turnKeys = ['A', 'A', 'wake-turn', 'wake-turn']
expect(nativeChatTurnBarRows(messages, turnKeys)).toEqual(
new Map([
['A', { index: 0, above: false }],
['wake-turn', { index: 2, above: true }]
])
)
})
})
describe('nativeChatTurnDrawOrder', () => {
it("draws a message that opened a later turn after the earlier turn's remaining rows", () => {
// B was sent while A ran and Claude queued it: A's tool run and answer follow B in the journal.
const messages = [
message('A', 'user'),
message('a1'),
message('B', 'user'),
message('a2'),
message('a-done'),
message('b1')
]
const turnKeys = ['A', 'A', 'B', 'A', 'A', 'B']
expect(nativeChatTurnDrawOrder(messages, turnKeys, new Set(['A', 'B']))).toEqual([
0, 1, 3, 4, 2, 5
])
})
it('keeps each of several queued openers ahead of its own rows', () => {
const messages = [
message('A', 'user'),
message('B', 'user'),
message('C', 'user'),
message('a-done'),
message('b1'),
message('c1')
]
const turnKeys = ['A', 'B', 'C', 'A', 'B', 'C']
const order = nativeChatTurnDrawOrder(messages, turnKeys, new Set(['A', 'B', 'C']))
expect(order?.map((index) => messages[index]!.id)).toEqual([
'A',
'a-done',
'B',
'b1',
'C',
'c1'
])
})
it('leaves a steer, and a message that opened no turn, where they were written', () => {
const messages = [message('A', 'user'), message('steer', 'user'), message('a1')]
expect(nativeChatTurnDrawOrder(messages, ['A', 'A', 'A'], new Set(['A']))).toBeNull()
const waiting = [message('A', 'user'), message('C', 'user'), message('a1')]
expect(nativeChatTurnDrawOrder(waiting, ['A', 'C', 'A'], new Set(['A']))).toBeNull()
})
})
@@ -0,0 +1,143 @@
// Share desktop/mobile turn ownership, bar placement and row order.
import type { AgentJournalRenderItem } from '../../agent-session-journal-types'
import { readAgentJournalTurn } from '../../agent-session-turn-record'
import type { NativeChatRole } from '../../native-chat-types'
type NativeChatTurnRow = { id: string; role: NativeChatRole }
/** Which rows can open a turn by themselves: in a conversation, its user rows. */
export type NativeChatOpensTurn = (message: NativeChatTurnRow) => boolean
export const nativeChatUserRowOpensTurn: NativeChatOpensTurn = (message) => message.role === 'user'
/** Host turn attribution wins over mid-turn send position. Unmapped rows use preceding-user grouping;
* opensTurn restricts which unmapped rows can anchor turns in interleaved streams. */
export function nativeChatRowTurnKeys(
messages: readonly NativeChatTurnRow[],
turnKeysByItemId?: ReadonlyMap<string, string> | null,
opensTurn: NativeChatOpensTurn = nativeChatUserRowOpensTurn
): (string | undefined)[] {
let currentTurnKey: string | undefined
return messages.map((message) => {
const owned = turnKeysByItemId?.get(message.id)
if (owned !== undefined) {
currentTurnKey = owned
return owned
}
if (opensTurn(message)) {
currentTurnKey = message.id
}
return currentTurnKey
})
}
/** Legacy unscoped hosts assign rows between root records to that turn’s mapped opener. Openers key themselves;
* steers inherit only after later turn content proves continuation, so fresh tail sends keep waiting. Rows before the first record remain unmapped. */
export function nativeChatJournalOrderTurnKeys(
items: readonly AgentJournalRenderItem[],
recordKeys: ReadonlyMap<string, string | null>
): ReadonlyMap<string, string> {
const openers = new Set([...recordKeys.values()].filter((key) => key !== null))
const keys = new Map<string, string>()
let currentKey: string | null = null
// User items folded into the current turn, held until a later row proves the turn continued.
let pendingUserItemIds: string[] = []
for (const item of items) {
if (recordKeys.has(item.itemId)) {
currentKey = recordKeys.get(item.itemId) ?? null
pendingUserItemIds = []
continue
}
if (readAgentJournalTurn(item.body)) {
continue
}
if (item.body.kind === 'message' && item.body.role === 'user') {
if (openers.has(item.itemId)) {
keys.set(item.itemId, item.itemId)
} else if (currentKey !== null) {
pendingUserItemIds.push(item.itemId)
}
continue
}
if (currentKey !== null) {
for (const userItemId of pendingUserItemIds) {
keys.set(userItemId, currentKey)
}
pendingUserItemIds = []
keys.set(item.itemId, currentKey)
}
}
return keys
}
/** Place each turn bar at its first row; use an above-row bar when the provider opened it or its user opener is unloaded. */
export function nativeChatTurnBarRows(
messages: readonly { id: string }[],
turnKeys: readonly (string | undefined)[]
): ReadonlyMap<string, { index: number; above: boolean }> {
const bars = new Map<string, { index: number; above: boolean }>()
turnKeys.forEach((turnKey, index) => {
if (turnKey !== undefined && !bars.has(turnKey)) {
bars.set(turnKey, { index, above: messages[index]?.id !== turnKey })
}
})
return bars
}
/** Return draw indexes or null for journal order. Provider-queued sends are written mid-turn but open only after earlier work ends;
* draw each opener after that prior work and just before its own turn’s rows. */
export function nativeChatTurnDrawOrder(
messages: readonly NativeChatTurnRow[],
turnKeys: readonly (string | undefined)[],
openers: ReadonlySet<string>
): number[] | null {
const opensTurn = (index: number): boolean =>
messages[index]?.role === 'user' &&
turnKeys[index] === messages[index].id &&
openers.has(messages[index].id)
const firstRow = new Map<string, number>()
turnKeys.forEach((turnKey, index) => {
if (turnKey !== undefined && !firstRow.has(turnKey)) {
firstRow.set(turnKey, index)
}
})
const movedAfter = new Map<number, number[]>()
const moved = new Set<number>()
for (const [index, message] of messages.entries()) {
if (!opensTurn(index)) {
continue
}
let after = -1
for (let row = index + 1; row < messages.length; row += 1) {
const turnKey = turnKeys[row]
if (turnKey === undefined || opensTurn(row)) {
continue
}
// Its own turn's rows, or a later turn's, end the earlier turns' rows it waited behind.
if (turnKey === message.id || (firstRow.get(turnKey) ?? row) > index) {
break
}
after = row
}
if (after !== -1) {
moved.add(index)
movedAfter.set(after, [...(movedAfter.get(after) ?? []), index])
}
}
if (moved.size === 0) {
return null
}
return messages.flatMap((_, index) => [
...(moved.has(index) ? [] : [index]),
...(movedAfter.get(index) ?? [])
])
}
/** `rows` in the transcript's draw order. */
export function nativeChatRowsInDrawOrder<T>(
rows: readonly T[],
drawOrder: readonly number[] | null
): readonly T[] {
return drawOrder === null ? rows : drawOrder.map((index) => rows[index]!)
}