fix(native-chat): group chat rows by the turn that produced them (#23671)

* fix(native-chat): keep a turn's bar on the prompt that opened it

A message sent while a structured turn runs appears in the transcript at
once, so "the newest user message" is not the running turn's owner. The
live "Working for" bar moved to the mid-turn message and counted from the
earlier prompt's start, and a send queued behind the running turn counted
its wait twice: once in the previous turn and again from its own send.

Derive both from the host's turn records in one ordered pass:
- The running turn's bar belongs to the user message its lifecycle row
  names (resolved exactly as settled timing resolves it). A message sent
  mid-turn gets no bar until its own turn opens; a send folded into the
  running turn never gets one. Surfaces fall back to the latest user
  message only when the host names no opener.
- A turn counts from its send, but never before the previous turn in the
  journal ended (its recorded end, else its row's last host revision),
  capped at the turn's own start. The same origin feeds the live counter
  and the settled duration.

Desktop and mobile share the derivation; no wire, host, or storage change.

* feat(native-chat): derive each transcript row's owning turn from the journal

Rows between a turn record and the next belong to that record's turn, so a
message the provider folds into a running turn no longer captures the rows
produced after it. A turn whose opener the host cannot name in the loaded
window anchors to its own record instead of a bystander prompt, and shared
nativeChatRowTurnKeys keeps positional preceding-user grouping for anything
the host does not attribute (older hosts stay pixel-identical).

* fix(native-chat): fold and time transcript rows by their owning turn on desktop

A settled turn's bar now folds every row the turn produced, including rows
after a mid-turn send; the steered bubble stays visible and carries no bar. A
provider-opened turn renders its bar above its first row instead of borrowing
the newest prompt, and row liveness follows the owning turn rather than the
newest user message, so a running turn's rows stay live while a send waits.

* fix(mobile): group phone transcript rows and bars by their owning turn

Same shared derivation as desktop: the opener's bar owns every row of its
turn across a mid-turn send, a steered bubble never grows a bar, a
provider-opened turn's bar sits above its first row, and a running turn's
tool rows stay live while a newer message waits behind it.

* fix(mobile): declare the turn ownership map on the chat controller contract

* fix(native-chat): one diff rollup per turn, and no wake-turn clock on a later prompt

A turn's rows are no longer contiguous once rows are grouped by owning turn: a
prompt sent before the running turn's last row lands among its rows. The diff
rollup was drawn at every run boundary, so such a turn showed its rollup twice
and the later prompt's rollup appeared under its bubble. It now renders once,
under the turn's last row.

On the phone, a turn keyed to its own record is not a user message, so when it
ended its clock was treated as a replaced optimistic echo and handed to the
newest prompt - a message sent during a wake turn got a bar with the wake turn's
duration. Host-attributed turn keys now count as live turn keys.

* fix(native-chat): keep a Codex turn's bar on its send until the echo lands

Codex reports turn/started before it runs hooks and prewarm and before it
echoes the send, so for that gap the turn names a provider key no alias
resolves yet. Anchoring it to its own record left the running turn with no
bar at all; treat the send still in flight ahead of the record as its opener.

* fix(codex): restore each turn's record ahead of that turn's items

Rows are grouped by the nearest turn record before them in journal order,
which holds on the live path because a turn's record is written when the turn
opens. Full-history restore (the fallback for Codex app-servers that reject
excludeTurns) wrote each turn's items first and its record after, so every
restored turn's rows were credited to the previous turn and turn 1's answer
folded away.

The restore now appends the record before the turn's items. The record itself
is unchanged: same identity, state, outcome, opener key, endpoints and
duration, one append each. The restore-order test now expects the record
first, because that order is what keeps grouping correct; its old order was
incidental, not a contract any reader relied on. Readers that key turns by id
or opener key, or that scan for the newest record (all restored records are
settled), read the same result in either order.

Journals already imported in the old order stay as written; no migration.
This commit is contained in:
Brennan Benson
2026-09-29 00:55:45 -07:00
committed by GitHub
parent b7e3bbf3d1
commit 357a2fed08
28 changed files with 910 additions and 107 deletions
+15 -9
View File
@@ -73,6 +73,7 @@ function MobileNativeChatMessageImpl({
fontScale = 1,
onOpenFile,
turnStatus,
turnStatusAbove = false,
turnExpanded,
turnKey,
onToggleTurn,
@@ -85,8 +86,10 @@ function MobileNativeChatMessageImpl({
/** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */
fontScale?: number
onOpenFile?: (relativePath: string) => void
/** This settled turn's status row, rendered under its user message. */
/** This turn's status row, rendered under its opening user message. */
turnStatus?: NativeChatTurnStatus | null
/** Render the status above the row: its turn has no user bubble of its own. */
turnStatusAbove?: boolean
/** Whether the turn caret has disclosed this turn's activity. */
turnExpanded?: boolean
/** Set only when this row's turn has settled and can disclose its activity. */
@@ -127,8 +130,18 @@ function MobileNativeChatMessageImpl({
? null
: (subagentLabel ?? NATIVE_CHAT_SUBAGENT_ATTRIBUTION_COPY.unnamed)
const statusRow = turnStatus ? (
<MobileNativeChatTurnStatus
startedAt={turnStatus.startedAt}
workedSeconds={turnStatus.workedSeconds}
expanded={turnExpanded ?? false}
onToggleExpanded={turnKey && onToggleTurn ? () => onToggleTurn(turnKey) : undefined}
/>
) : null
return (
<>
{/* A turn with no user bubble carries its bar above its first row. */}
{turnStatusAbove ? statusRow : null}
<View style={[styles.row, isUser && styles.rowUser]}>
<View
style={[
@@ -177,14 +190,7 @@ function MobileNativeChatMessageImpl({
) : null}
</View>
</View>
{turnStatus ? (
<MobileNativeChatTurnStatus
startedAt={turnStatus.startedAt}
workedSeconds={turnStatus.workedSeconds}
expanded={turnExpanded ?? false}
onToggleExpanded={turnKey && onToggleTurn ? () => onToggleTurn(turnKey) : undefined}
/>
) : null}
{turnStatusAbove ? null : statusRow}
</>
)
}
@@ -77,6 +77,7 @@ export function MobileNativeChatOverlay({
workingStartedAt={controller.nativeChatWorkingStartedAt}
settledTurns={controller.nativeChatSettledTurns}
activeTurnOpenedBy={controller.nativeChatActiveTurnOpenedBy}
turnKeysByItemId={controller.nativeChatTurnKeysByItemId}
streaming={streaming}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
+5 -1
View File
@@ -66,8 +66,10 @@ type Props = {
/** Structured lane: host-recorded turn timing feeding the per-turn status rows. */
workingStartedAt?: number | null
settledTurns?: NativeChatSettledTurns | null
/** Structured lane: the user message the host says opened the running turn. */
/** Structured lane: the key the host says anchors the running turn's bar. */
activeTurnOpenedBy?: string | null
/** Structured lane: host-attributed turn ownership per journal item id. */
turnKeysByItemId?: ReadonlyMap<string, string> | null
/** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */
/** Interrupt a provider turn. */
onStop?: () => void
@@ -154,6 +156,7 @@ export function MobileNativeChatView({
workingStartedAt,
settledTurns,
activeTurnOpenedBy = null,
turnKeysByItemId = null,
onStop,
streaming,
hasMore,
@@ -274,6 +277,7 @@ export function MobileNativeChatView({
workingStartedAt,
settledTurns,
activeTurnOpenedBy,
turnKeysByItemId,
thinking: turnIndicator?.thinking === true,
activityText: turnIndicator?.activityText ?? null,
scopeKey: sendSurfaceId
@@ -39,6 +39,8 @@ export type MobileNativeChatController = {
nativeChatSettledTurns: NativeChatSettledTurns | null
/** Structured lane: the user message the host says opened the running turn. */
nativeChatActiveTurnOpenedBy: string | null
/** Structured lane: the turn that owns each journal item's row. */
nativeChatTurnKeysByItemId: ReadonlyMap<string, string> | null
nativeChatCanStop: boolean
nativeChatStreamingText?: string
/** Agent mid-turn, regardless of whether chat is the visible view. */
@@ -291,6 +291,7 @@ export function useMobileNativeChatController(args: {
nativeChatActiveTurnOpenedBy: activeChatStructured
? structuredNativeChat.activeTurnOpenedBy
: null,
nativeChatTurnKeysByItemId: activeChatStructured ? structuredNativeChat.turnKeysByItemId : null,
nativeChatCanStop: activeChatStructured
? structuredNativeChat.turnId !== null
: nativeChatAgentWorking,
@@ -22,6 +22,7 @@ function Harness({
settledTurns,
workingStartedAt,
activeTurnOpenedBy,
turnKeysByItemId,
scopeKey = 'host\0worktree\0tab-a'
}: {
messages: readonly NativeChatMessage[]
@@ -30,6 +31,7 @@ function Harness({
settledTurns?: NativeChatSettledTurns
workingStartedAt?: number | null
activeTurnOpenedBy?: string | null
turnKeysByItemId?: ReadonlyMap<string, string> | null
scopeKey?: string
}): React.JSX.Element {
const disclosure = useMobileNativeChatTurnDisclosure({
@@ -39,6 +41,7 @@ function Harness({
settledTurns,
workingStartedAt,
activeTurnOpenedBy,
turnKeysByItemId,
scopeKey
})
return createElement('result', { disclosure })
@@ -240,9 +243,9 @@ describe('useMobileNativeChatTurnDisclosure', () => {
let [rowA, rowTool, rowB] = rows()
expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null })
expect(rowB.turnStatus).toBeNull()
// Row liveness still follows the transcript's grouping under the newest prompt.
expect(rowTool.activeTurnIsWorking).toBe(false)
expect(rowB.activeTurnIsWorking).toBe(true)
// Liveness follows the owning turn: A's tool row stays live while B waits.
expect(rowTool.activeTurnIsWorking).toBe(true)
expect(rowB.activeTurnIsWorking).toBe(false)
// B's own turn opens: A takes the host's settled duration, B counts from A's end.
act(() => {
@@ -264,6 +267,144 @@ describe('useMobileNativeChatTurnDisclosure', () => {
}
})
it("keeps a running turn's rows live across a mid-turn send the host folded in", () => {
vi.useFakeTimers()
try {
vi.setSystemTime(10_000)
const tool = (id: string): NativeChatMessage => ({
id,
role: 'assistant',
blocks: [
{ type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' }
],
timestamp: null,
source: 'transcript'
})
// The #23621 shape: B lands mid-turn and the tool rows after it are still A's.
const messages = [userMessage('A'), tool('t1'), userMessage('B'), tool('t2')]
const owned = new Map([
['A', 'A'],
['t1', 'A'],
['B', 'A'],
['t2', 'A']
])
act(() => {
renderer = create(
createElement(Harness, {
messages,
enabled: true,
workingStartedAt: 5_000,
activeTurnOpenedBy: 'A',
turnKeysByItemId: owned
})
)
})
const disclosure = renderer!.root.findByType('result').props.disclosure
const [rowA, rowT1, rowB, rowT2] = messages.map((message, index) =>
disclosure.resolveRow(index, message)
)
expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null })
// The steered bubble shares A's turn but never carries a bar of its own.
expect(rowB.turnStatus).toBeNull()
expect(rowT1.activeTurnIsWorking).toBe(true)
expect(rowT2.activeTurnIsWorking).toBe(true)
} finally {
vi.useRealTimers()
}
})
it("anchors a provider-opened turn's bar above its first row", () => {
vi.useFakeTimers()
try {
vi.setSystemTime(10_000)
const woke: NativeChatMessage = {
id: 'woke',
role: 'assistant',
blocks: [{ type: 'text', text: 'Woke up.' }],
timestamp: null,
source: 'transcript'
}
const messages = [userMessage('u1'), woke]
act(() => {
renderer = create(
createElement(Harness, {
messages,
enabled: true,
isWorking: false,
settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 9 }]]),
turnKeysByItemId: new Map([
['u1', 'u1'],
['woke', 'wake']
])
})
)
})
const disclosure = renderer!.root.findByType('result').props.disclosure
const [rowU1, rowWoke] = messages.map((message, index) =>
disclosure.resolveRow(index, message)
)
expect(rowU1.turnStatus).toBeNull()
expect(rowWoke.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 9 })
expect(rowWoke.turnStatusAbove).toBe(true)
} finally {
vi.useRealTimers()
}
})
it("never hands a provider-opened turn's clock to a message sent during it", () => {
vi.useFakeTimers()
try {
vi.setSystemTime(10_000)
const row = (id: string): NativeChatMessage => ({
id,
role: 'assistant',
blocks: [{ type: 'text', text: id }],
timestamp: null,
source: 'transcript'
})
// A wake turn runs; B is sent during it and folded in, so B opened nothing.
const messages = [row('w1'), userMessage('B'), row('w2')]
const owned = new Map([
['w1', 'wake'],
['B', 'wake'],
['w2', 'wake']
])
act(() => {
renderer = create(
createElement(Harness, {
messages,
enabled: true,
workingStartedAt: 5_000,
activeTurnOpenedBy: 'wake',
turnKeysByItemId: owned,
settledTurns: new Map([['wake', null]])
})
)
})
// The wake turn settles: the host no longer names a running turn.
vi.setSystemTime(20_000)
act(() => {
renderer!.update(
createElement(Harness, {
messages,
enabled: true,
isWorking: false,
workingStartedAt: null,
activeTurnOpenedBy: null,
turnKeysByItemId: owned,
settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 15 }]])
})
)
})
const disclosure = renderer!.root.findByType('result').props.disclosure
const [rowW1, rowB] = messages.map((message, index) => disclosure.resolveRow(index, message))
expect(rowW1.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 15 })
expect(rowB.turnStatus).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('keeps at most the latest 128 turns expanded', () => {
vi.useFakeTimers()
try {
@@ -1,6 +1,11 @@
import { useCallback, useMemo, useState } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { NATIVE_CHAT_UNANCHORED_TURN_KEY } from '../../../src/shared/native-chat-turn-status'
import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status'
import {
nativeChatRowTurnKeys,
nativeChatSelfAnchoredTurnRows
} from '../../../src/shared/native-chat-turn-grouping'
import {
useMobileNativeChatTurnStatus,
type NativeChatTurnStatus
@@ -8,10 +13,13 @@ import {
const EMPTY_TURN_IDS: ReadonlySet<string> = new Set()
const EMPTY_TURN_KEYS: readonly undefined[] = []
const EMPTY_TURN_ANCHORS: ReadonlyMap<string, number> = new Map()
const MAX_EXPANDED_TURNS = 128
export type MobileNativeChatTurnRow = {
turnStatus: NativeChatTurnStatus | null
/** The bar renders above the row: its turn has no user bubble of its own. */
turnStatusAbove?: boolean
turnExpanded: boolean
/** Set only on a settled turn — the one row that has activity to disclose. */
turnKey?: string
@@ -28,6 +36,7 @@ export function useMobileNativeChatTurnDisclosure({
workingStartedAt,
settledTurns,
activeTurnOpenedBy = null,
turnKeysByItemId = null,
thinking = false,
activityText = null,
scopeKey
@@ -38,8 +47,11 @@ export function useMobileNativeChatTurnDisclosure({
workingStartedAt?: number | null
/** Host-recorded durations; they outrank whatever this client observed. */
settledTurns?: NativeChatSettledTurns | null
/** The user message the host says opened the running turn; absent, the latest one. */
/** The key the host says anchors the running turn's bar; absent, the latest user message. */
activeTurnOpenedBy?: string | null
/** Host-attributed turn ownership per journal item id; unnamed rows keep
* positional preceding-user grouping. */
turnKeysByItemId?: ReadonlyMap<string, string> | null
/** Whether the turn is reasoning right now, derived from its journal content. */
thinking?: boolean
/** What the provider says the live turn is doing; outranks the other labels. */
@@ -60,6 +72,7 @@ export function useMobileNativeChatTurnDisclosure({
workingStartedAt,
settledTurns,
activeTurnOpenedBy,
turnKeysByItemId,
thinking,
scopeKey
})
@@ -87,52 +100,61 @@ export function useMobileNativeChatTurnDisclosure({
},
[scopeKey]
)
// Resolve each row's turn boundary once — a findLast per row is quadratic on a
// Resolve each row's owning turn once — a findLast per row is quadratic on a
// long transcript.
const turnKeys = useMemo(() => {
if (!enabled) {
return EMPTY_TURN_KEYS
}
let turnKey: string | undefined
return messages.map((message) => {
if (message.role === 'user') {
turnKey = message.id
}
return turnKey
})
}, [enabled, messages])
const turnKeys = useMemo(
() => (enabled ? nativeChatRowTurnKeys(messages, turnKeysByItemId) : EMPTY_TURN_KEYS),
[enabled, messages, turnKeysByItemId]
)
// A turn with no user bubble (provider-opened) anchors its bar at its first row.
const selfAnchors = useMemo(
() => (enabled ? nativeChatSelfAnchoredTurnRows(messages, turnKeys) : EMPTY_TURN_ANCHORS),
[enabled, messages, turnKeys]
)
const { active, activeTurnKey, completedByTurn } = turnStatuses
// Liveness follows the transcript's own grouping (the latest prompt's rows), not the bar's owner.
const latestTurnKey = turnKeys.at(-1)
const activeActivityText = enabled && isWorking ? (activityText ?? null) : null
const resolveRow = useCallback(
(index: number, message: NativeChatMessage): MobileNativeChatTurnRow => {
const turnKey = turnKeys[index]
// The live turn's bar carries its running clock; it settles in place.
const turnStatus =
!enabled || message.role !== 'user' || !turnKey
? null
: turnKey === activeTurnKey
? active
: (completedByTurn[turnKey] ?? null)
const anchorsTurnHere = turnKey !== undefined && selfAnchors.get(turnKey) === index
// The live turn's bar carries its running clock; it settles in place. Only
// the bubble that opened a turn carries its bar: a message the provider
// folded into a running turn shares the turn's key but not its bar.
const turnStatus = !enabled
? null
: message.role === 'user' && message.id === activeTurnKey
? active
: message.role === 'user' && turnKey === message.id
? (completedByTurn[turnKey] ?? null)
: anchorsTurnHere
? turnKey === activeTurnKey
? (active ?? completedByTurn[turnKey] ?? null)
: (completedByTurn[turnKey] ?? null)
: null
return {
turnStatus,
...(anchorsTurnHere && turnStatus !== null ? { turnStatusAbove: true } : {}),
turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false,
// Why: the key travels and the row calls one stable handler with it. A
// closure per row would be a new identity every render of a streaming
// transcript, defeating the row's memo; caching one per turn would mean
// writing a ref during render, which react-freeze can discard.
turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined,
// With no user boundary at all, the session's working state stays authoritative.
activeTurnIsWorking: enabled && isWorking && turnKey === latestTurnKey
// Liveness is the owning turn's, not the newest prompt's: a running turn's
// rows stay live while a newer message waits behind it.
activeTurnIsWorking:
enabled &&
isWorking &&
(turnKey === activeTurnKey ||
(turnKey === undefined && activeTurnKey === NATIVE_CHAT_UNANCHORED_TURN_KEY))
}
},
[
turnKeys,
selfAnchors,
enabled,
activeTurnKey,
latestTurnKey,
active,
completedByTurn,
expandedTurnIds,
@@ -28,6 +28,7 @@ export function useMobileNativeChatTurnStatus({
workingStartedAt,
settledTurns,
activeTurnOpenedBy = null,
turnKeysByItemId = null,
thinking = false,
scopeKey
}: {
@@ -39,6 +40,9 @@ export function useMobileNativeChatTurnStatus({
settledTurns?: NativeChatSettledTurns | null
/** The user message the host says opened the running turn; absent, the latest one. */
activeTurnOpenedBy?: string | null
/** Host-attributed turn ownership; a turn keyed to its own record is still in
* the transcript, so its clock is not an echo to hand to the next prompt. */
turnKeysByItemId?: ReadonlyMap<string, string> | null
/** Whether the turn is reasoning right now, derived from its journal content. */
thinking?: boolean
/** Host/worktree/tab identity. Timings never carry across chat surfaces. */
@@ -72,6 +76,9 @@ export function useMobileNativeChatTurnStatus({
const validTurnKeys = new Set(
messages.filter((message) => message.role === 'user').map((message) => message.id)
)
for (const turnKey of turnKeysByItemId?.values() ?? []) {
validTurnKeys.add(turnKey)
}
const previousActiveTurnKey =
previousActiveTurn.current?.scopeKey === scopeKey
? previousActiveTurn.current.turnKey
@@ -92,7 +99,7 @@ export function useMobileNativeChatTurnStatus({
? current
: { scopeKey, timingByTurn: nextTiming }
})
}, [activeTurnKey, enabled, isWorking, messages, scopeKey, workingStartedAt])
}, [activeTurnKey, enabled, isWorking, messages, scopeKey, turnKeysByItemId, workingStartedAt])
// Why: the selection rebuilds its status objects on every call, and a streaming
// turn re-renders ~20x/s. Without this, every settled turn's row gets fresh
@@ -12,7 +12,8 @@ import {
} from '../../../src/shared/structured-agent-turn-clock-anchor'
/** Host-recorded turn timing for the structured lane: settled durations straight
* off the journal, the user message that owns the running turn's bar, and a
* off the journal, the transcript key that owns the running turn's bar, each
* row's owning turn, and a
* skew-free start for the live counter whose host-to-local conversion is latched
* once per turn. */
export function useMobileStructuredAgentTurnTiming(
@@ -30,8 +31,9 @@ export function useMobileStructuredAgentTurnTiming(
settledTurns: NativeChatSettledTurns
workingStartedAt: number | null
activeTurnOpenedBy: string | null
turnKeysByItemId: ReadonlyMap<string, string>
} {
const { settledTurns, runningTiming, activeTurnOpenedBy } = useMemo(
const { settledTurns, runningTiming, activeTurnOpenedBy, turnKeysByItemId } = useMemo(
() => selectStructuredAgentTurnBars(items, submissions, turnId),
[items, submissions, turnId]
)
@@ -48,5 +50,10 @@ export function useMobileStructuredAgentTurnTiming(
if (step.latch !== latch) {
setLatch(step.latch)
}
return { settledTurns, workingStartedAt: step.workingStartedAt, activeTurnOpenedBy }
return {
settledTurns,
workingStartedAt: step.workingStartedAt,
activeTurnOpenedBy,
turnKeysByItemId
}
}
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { nativeChatTurnFold } from '../../shared/native-chat-turn-fold'
import { nativeChatRowTurnKeys } from '../../shared/native-chat-turn-grouping'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import { projectStructuredItemToNativeChat } from '../../shared/structured-agent-session-projection'
import { selectStructuredAgentTurnBars } from '../../shared/structured-agent-session-turn-timing'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
const THREAD_ID = 'thread-abc'
function historicalTurn(index: number): Record<string, unknown> {
const id = `turn-${index}`
return {
id,
status: 'completed',
startedAt: 1_700_000_000 + index * 100,
completedAt: 1_700_000_050 + index * 100,
items: [
{
type: 'userMessage',
id: `user-${index}`,
content: [{ type: 'text', text: `ask ${index}` }]
},
{ type: 'agentMessage', id: `interim-${index}`, text: `looking ${index}` },
{ type: 'agentMessage', id: `answer-${index}`, text: `answer ${index}` }
]
}
}
/** What the journal hands a reader after a restore: rows in append order. */
function restoredJournal(turnCount: number): AgentJournalRenderItem[] {
const items: AgentJournalRenderItem[] = []
const translator = createCodexJournalTranslator({
sink: {
appendItem: (identity, body) => {
const sequence = items.length + 1
items.push({
itemId: agentJournalItemKey(identity),
revision: 1,
body,
sequence,
observedAt: sequence
})
},
appendTombstone: () => {},
publish: () => {}
},
sessionId: 'session-1',
primaryThreadId: () => THREAD_ID
})
const turns = Array.from({ length: turnCount }, (_, index) => historicalTurn(index + 1))
expect(translator.restoreThread(THREAD_ID, { turns })).toEqual({ accepted: true })
return items
}
describe('grouping a Codex thread restored from full history', () => {
it('keeps each turn with its own rows and folds each to its own answer', () => {
const items = restoredJournal(3)
const bars = selectStructuredAgentTurnBars(items, [], null)
const messages = items
.map(projectStructuredItemToNativeChat)
.filter((message): message is NativeChatMessage => message !== null)
const turnKeys = nativeChatRowTurnKeys(messages, bars.turnKeysByItemId)
const opener = (index: number): string => `codex:${THREAD_ID}:turn-${index}:0`
expect(messages.map((message, index) => [message.role, turnKeys[index]])).toEqual([
['user', opener(1)],
['assistant', opener(1)],
['assistant', opener(1)],
['user', opener(2)],
['assistant', opener(2)],
['assistant', opener(2)],
['user', opener(3)],
['assistant', opener(3)],
['assistant', opener(3)]
])
expect([...bars.settledTurns.keys()]).toEqual([opener(1), opener(2), opener(3)])
const { foldedRows } = nativeChatTurnFold({
rows: messages.map((message, index) => ({
turnKey: turnKeys[index],
role: message.role,
rendersProse: true,
outlivesTurn: false
})),
settledTurnKeys: new Set(bars.settledTurns.keys()),
expandedTurnKeys: new Set()
})
const visible = messages.filter((_, index) => !foldedRows.has(index))
expect(visible.map((message) => message.blocks)).toEqual(
['ask 1', 'answer 1', 'ask 2', 'answer 2', 'ask 3', 'answer 3'].map((text) => [
{ type: 'text', text }
])
)
})
})
@@ -59,6 +59,17 @@ export function restoreCodexJournalThread(input: {
if (!turnId) {
continue
}
// Ahead of the turn's items, as the live path writes it: readers credit every
// row to the nearest turn record before it.
const lifecycle = input.restoreTurnLifecycle
? historicalTurnLifecycle(input.threadId, turn)
: null
if (lifecycle) {
const admission = input.restoreTurnLifecycle?.(lifecycle) ?? { accepted: true }
if (!admission.accepted) {
return admission
}
}
input.currentTurnIds.set(input.threadId, new Set([turnId]))
for (const item of Array.isArray(turn.items) ? turn.items : []) {
const admission = input.handleItem({
@@ -72,15 +83,6 @@ export function restoreCodexJournalThread(input: {
}
input.currentTurnIds.delete(input.threadId)
input.ordinals.forgetTurn(input.threadId, turnId)
const lifecycle = input.restoreTurnLifecycle
? historicalTurnLifecycle(input.threadId, turn)
: null
if (lifecycle) {
const admission = input.restoreTurnLifecycle?.(lifecycle) ?? { accepted: true }
if (!admission.accepted) {
return admission
}
}
}
input.flush()
return { accepted: true }
@@ -533,8 +533,8 @@ describe('codex turn lifecycle rows', () => {
})
).toEqual({ accepted: true })
// Each record precedes its turn's items, the order the live path writes.
expect(tap.rows).toEqual([
expect.objectContaining({ body: expect.objectContaining({ kind: 'message' }) }),
{
key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-done',
body: {
@@ -548,6 +548,7 @@ describe('codex turn lifecycle rows', () => {
durationMs: 41_900
}
},
expect.objectContaining({ body: expect.objectContaining({ kind: 'message' }) }),
{
key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-cut',
body: {
@@ -47,6 +47,7 @@ import {
selectNativeChatActiveTurnKey,
type NativeChatSettledTurns
} from '../../../../shared/native-chat-turn-status'
import { nativeChatRowTurnKeys } from '../../../../shared/native-chat-turn-grouping'
import {
nativeChatTurnDiffs,
type NativeChatDiffReveal,
@@ -75,6 +76,7 @@ export function NativeChatMessageList({
workingStartedAt,
settledTurns,
activeTurnOpenedBy,
turnKeysByItemId = null,
deliveryNotices,
showTurnStatus = true,
showLiveTurnActivity = true,
@@ -94,8 +96,11 @@ export function NativeChatMessageList({
workingStartedAt?: number | null
/** Host-recorded turn durations keyed by user message id (structured lane). */
settledTurns?: NativeChatSettledTurns
/** The user message the host says opened the running turn (structured lane). */
/** The key the host says anchors the running turn's bar (structured lane). */
activeTurnOpenedBy?: string | null
/** Host-attributed turn ownership per journal item id (structured lane).
* Rows it does not name keep positional preceding-user grouping. */
turnKeysByItemId?: ReadonlyMap<string, string> | null
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
deliveryNotices?: ReadonlyMap<string, NativeChatDeliveryNotice>
@@ -160,18 +165,12 @@ export function NativeChatMessageList({
const showTypingIndicator = showTurnStatus
? isWorking
: shouldShowNativeChatTypingIndicator({ messages, isWorking })
const currentTurnKey = messages.findLast((message) => message.role === 'user')?.id ?? undefined
// Resolve each row's turn boundary once. Prefix slice/findLast in the render
// Resolve each row's owning turn once. Prefix slice/findLast in the render
// loop becomes quadratic for long transcripts.
const turnKeys = useMemo(() => {
let currentTurnKey: string | undefined
return messages.map((message) => {
if (message.role === 'user') {
currentTurnKey = message.id
}
return currentTurnKey
})
}, [messages])
const turnKeys = useMemo(
() => nativeChatRowTurnKeys(messages, showTurnStatus ? turnKeysByItemId : null),
[messages, showTurnStatus, turnKeysByItemId]
)
const turnDiffs = useMemo(
() =>
journalItems
@@ -201,7 +200,6 @@ export function NativeChatMessageList({
messages,
turnKeys,
activeTurnKey,
currentTurnKey,
receipts,
turnStatuses,
turnDiffs,
@@ -213,7 +211,6 @@ export function NativeChatMessageList({
}),
[
activeTurnKey,
currentTurnKey,
expandedTurnIds,
isWorking,
lifecycleWorking,
@@ -140,6 +140,44 @@ describe('NativeChatMessageList settled turn fold', () => {
expect(screen.queryByRole('button', { name: 'Toggle turn details' })).toBeNull()
})
// The #23621 shape: the host folded a mid-turn send into the running turn, so
// the rows after it are still the opener's. One bar under the opener folds
// them all; the steered bubble stays visible and never grows a bar or group.
it("folds the rows after a mid-turn send behind the opener's bar", () => {
const startedAt = Date.now() - 3000
const base = session(startedAt)
const steer: (typeof base.messages)[number] = {
id: 'mid-b',
role: 'user',
blocks: [{ type: 'text', text: 'Also check the tests.' }],
timestamp: startedAt + 2,
source: 'transcript'
}
const messages = [...base.messages.slice(0, 3), steer, ...base.messages.slice(3)]
const turnKeysByItemId = new Map(messages.map((message) => [message.id, 'user-1']))
render(
<NativeChatMessageList
session={{ ...base, messages }}
isWorking={false}
workingStartedAt={null}
settledTurns={new Map([['user-1', { startedAt, workedSeconds: 70 }]])}
turnKeysByItemId={turnKeysByItemId}
expandSignal={false}
fontScale={1}
/>
)
expect(screen.getByText('Also check the tests.')).toBeInTheDocument()
expect(screen.queryByText(NARRATION)).toBeNull()
expect(screen.queryByText(MORE_NARRATION)).toBeNull()
const toggles = screen.getAllByRole('button', { name: 'Toggle turn details' })
expect(toggles).toHaveLength(1)
fireEvent.click(toggles[0]!)
expect(screen.getByText(NARRATION)).toBeInTheDocument()
expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument()
})
// The answer is the LAST prose the agent produced. A turn whose final output
// is a tool run still answers with the prose before it.
it('keeps the last prose row when the turn ends on tool activity', () => {
@@ -280,6 +280,7 @@ export function NativeChatStructuredSession(
workingStartedAt={controller.workingStartedAt}
settledTurns={controller.settledTurns}
activeTurnOpenedBy={controller.activeTurnOpenedBy}
turnKeysByItemId={controller.turnKeysByItemId}
showTurnStatus
showLiveTurnActivity={prompt === null}
turnActivity={controller.turnActivity}
@@ -43,8 +43,20 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
const { message, turnKey, status, receipt, turnDiff } = slot
const predecessors = context.taskListPredecessors.get(message.id)
const expanded = turnKey ? context.expandedTurnIds.has(turnKey) : undefined
const statusRow = status ? (
<NativeChatWorkingStatus
startedAt={status.startedAt}
workedSeconds={status.workedSeconds}
expanded={expanded === true}
onToggleExpanded={
slot.turnFolds && turnKey ? () => context.onToggleExpandedTurn(turnKey) : undefined
}
/>
) : null
return (
<div className="flex flex-col gap-5">
{/* A turn with no user bubble carries its bar above its first row. */}
{slot.statusAbove ? statusRow : null}
{receipt ? (
<NativeChatResolutionReceipt body={receipt} disclosureId={message.id} />
) : (
@@ -68,16 +80,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
runtimeContext={context.runtimeContext}
/>
)}
{status ? (
<NativeChatWorkingStatus
startedAt={status.startedAt}
workedSeconds={status.workedSeconds}
expanded={expanded === true}
onToggleExpanded={
slot.turnFolds && turnKey ? () => context.onToggleExpandedTurn(turnKey) : undefined
}
/>
) : null}
{slot.statusAbove ? null : statusRow}
{turnDiff ? (
<NativeChatTurnDiffRollup diff={turnDiff} onReveal={context.onRevealDiff} />
) : null}
@@ -44,7 +44,6 @@ function slotsOf(messages: NativeChatMessage[]) {
messages,
turnKeys,
activeTurnKey: selectNativeChatActiveTurnKey(messages),
currentTurnKey: undefined,
receipts: new Map<string, NativeChatResolvedPrompt>(),
turnStatuses: { active: null, completedByTurn: {} },
turnDiffs: new Map<string, NativeChatTurnDiff>(),
@@ -83,7 +83,6 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour
messages,
turnKeys,
activeTurnKey: selectNativeChatActiveTurnKey(messages),
currentTurnKey: turn,
receipts: new Map<string, NativeChatResolvedPrompt>(),
turnStatuses: { active: null, completedByTurn: {} },
turnDiffs: new Map<string, NativeChatTurnDiff>(),
@@ -40,7 +40,6 @@ function build(
messages,
turnKeys,
activeTurnKey: selectNativeChatActiveTurnKey(messages),
currentTurnKey: undefined,
receipts: new Map<string, NativeChatResolvedPrompt>(),
turnStatuses: NO_STATUSES,
turnDiffs: new Map<string, NativeChatTurnDiff>(),
@@ -208,6 +207,113 @@ describe('a send the host rejected', () => {
})
})
// Rows belong to the turn the host says owns them, not to the nearest preceding
// user bubble. The owned turn keys reshape the fold, the bar and liveness.
describe('turn-owned grouping', () => {
const settled: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 70 }
// The #23621 shape: B lands mid-turn, three tool calls follow, one turn.
const midTurn = [
text('A', 'go', 'user'),
toolRun('t1'),
text('B', 'and also this', 'user'),
toolRun('t2'),
toolRun('t3'),
toolRun('t4'),
text('answer', 'Done.')
]
const ownedKeys = midTurn.map(() => 'A')
it("folds every row of a settled turn behind its opener's bar, across a mid-turn send", () => {
const slots = build(midTurn, {
turnKeys: ownedKeys,
turnStatuses: { active: null, completedByTurn: { A: settled } }
})
// All four tool rows fold; the steered bubble stays visible with no bar of its own.
expect(slots.map((slot) => [slot.message.id, slot.status ?? null])).toEqual([
['A', settled],
['B', null],
['answer', null]
])
expect(slots[0]?.turnFolds).toBe(true)
})
it('returns every row of the turn when the reader opens it', () => {
const slots = build(midTurn, {
turnKeys: ownedKeys,
turnStatuses: { active: null, completedByTurn: { A: settled } },
expandedTurnKeys: new Set(['A'])
})
expect(slots.map((slot) => slot.message.id)).toEqual([
'A',
't1',
'B',
't2',
't3',
't4',
'answer'
])
})
it("anchors a provider-opened turn's bar above its first row", () => {
const messages = [text('u1', 'earlier', 'user'), toolRun('w1'), text('done', 'Woke up.')]
const slots = build(messages, {
turnKeys: ['u1', 'wake', 'wake'],
activeTurnKey: 'wake',
turnStatuses: { active: settled, completedByTurn: {} },
isWorking: true
})
expect(slots.map((slot) => [slot.message.id, slot.status ?? null, slot.statusAbove])).toEqual([
['u1', null, false],
['w1', settled, true],
['done', null, false]
])
})
it("rolls a turn's diff up once, under its last row, when another prompt lands among its rows", () => {
// B opens its own turn but was sent before A's last row was written.
const messages = [
text('A', 'go', 'user'),
toolRun('t1'),
text('B', 'next', 'user'),
text('a-answer', 'Done with A.'),
toolRun('b1')
]
const diff = (added: number): NativeChatTurnDiff => ({
files: [],
added,
removed: 0,
truncated: false
})
const slots = build(messages, {
turnKeys: ['A', 'A', 'B', 'A', 'B'],
turnDiffs: new Map([
['A', diff(1)],
['B', diff(2)]
])
})
expect(
slots.filter((slot) => slot.turnDiff).map((slot) => [slot.message.id, slot.turnDiff?.added])
).toEqual([
['a-answer', 1],
['b1', 2]
])
})
it("keeps a running turn's rows live while a newer message waits behind it", () => {
const messages = [text('A', 'go', 'user'), toolRun('t1'), text('C', 'next up', 'user')]
const slots = build(messages, {
turnKeys: ['A', 'A', 'C'],
activeTurnKey: 'A',
isWorking: true
})
expect(slots.map((slot) => [slot.message.id, slot.activeTurnIsWorking])).toEqual([
['A', true],
['t1', true],
['C', false]
])
})
})
describe("a subagent's rows speak as that subagent", () => {
const settled: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 5 }
const roster: NativeChatMessage = {
@@ -15,7 +15,11 @@ import {
} from '../../../../shared/native-chat-types'
import { agentJournalItemSubagentId } from '../../../../shared/agent-session-journal-producer'
import { nativeChatSubagentLabel } from '../../../../shared/native-chat-subagent-attribution'
import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status'
import {
NATIVE_CHAT_UNANCHORED_TURN_KEY,
type NativeChatTurnStatus
} from '../../../../shared/native-chat-turn-status'
import { nativeChatSelfAnchoredTurnRows } from '../../../../shared/native-chat-turn-grouping'
import {
nativeChatTurnFold,
type NativeChatTurnFoldRow
@@ -44,6 +48,9 @@ export type NativeChatTranscriptSlot = {
receipt: NativeChatResolvedPrompt | undefined
/** Turn timing shown under this row, already filtered to "should render". */
status: NativeChatTurnStatus | undefined
/** The bar renders above the row: this turn has no user bubble of its own
* (provider-opened), so its bar sits at the turn's position instead. */
statusAbove?: boolean
/** This row is behind its turn's folded status row: it draws no prose and no
* tool activity, only work that outlives the turn. */
folded: boolean
@@ -60,9 +67,8 @@ export type NativeChatTranscriptSlot = {
export type NativeChatTranscriptSlotsInput = {
messages: readonly NativeChatMessage[]
turnKeys: readonly (string | undefined)[]
/** The user message whose bar carries the live turn's status. */
/** The transcript key whose bar carries the live turn's status. */
activeTurnKey: string
currentTurnKey: string | undefined
receipts: ReadonlyMap<string, NativeChatResolvedPrompt>
turnStatuses: {
active: NativeChatTurnStatus | null
@@ -86,7 +92,6 @@ export function buildNativeChatTranscriptSlots(
messages,
turnKeys,
activeTurnKey,
currentTurnKey,
receipts,
turnStatuses,
turnDiffs,
@@ -147,19 +152,37 @@ export function buildNativeChatTranscriptSlots(
settledTurnKeys,
expandedTurnKeys
})
// A turn with no user bubble (provider-opened) anchors its bar at its first row.
const selfAnchors = nativeChatSelfAnchoredTurnRows(messages, turnKeys)
// A turn's rows need not be contiguous (another turn's prompt can land among
// them), so its rollup goes under its last row, not every run boundary.
const lastRowByTurn = new Map<string, number>()
turnKeys.forEach((turnKey, index) => {
if (turnKey !== undefined) {
lastRowByTurn.set(turnKey, index)
}
})
const slots: NativeChatTranscriptSlot[] = []
for (const [index, message] of messages.entries()) {
const turnKey = turnKeys[index]
const receipt = receipts.get(message.id)
const anchorsTurnHere = turnKey !== undefined && selfAnchors.get(turnKey) === index
// Only the bubble that opened a turn carries its bar: a message the provider
// folded into a running turn shares the turn's key but not its bar.
const candidateStatus =
message.role === 'user' && message.id === activeTurnKey
? turnStatuses.active
: message.role === 'user' && turnKey
: message.role === 'user' && turnKey === message.id
? turnStatuses.completedByTurn[turnKey]
: undefined
: anchorsTurnHere
? turnKey === activeTurnKey
? (turnStatuses.active ?? turnStatuses.completedByTurn[turnKey])
: turnStatuses.completedByTurn[turnKey]
: undefined
// The live turn's bar carries its running clock; it settles in place.
const status = showTurnStatus ? (candidateStatus ?? undefined) : undefined
const turnDiff = turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined
const turnDiff =
turnKey && lastRowByTurn.get(turnKey) === index ? turnDiffs.get(turnKey) : undefined
const folded = foldedRows.has(index)
// Skipping a folded row entirely is what keeps windowing honest: a counted
// index the row declines to draw reserves estimated height for nothing and
@@ -173,12 +196,16 @@ export function buildNativeChatTranscriptSlots(
slots.push({
message,
turnKey,
// Liveness is the owning turn's, not the newest prompt's: a running turn's
// rows stay live while a newer message waits behind it.
activeTurnIsWorking:
(currentTurnKey ? turnKey === currentTurnKey : turnKey === undefined) &&
(turnKey === activeTurnKey ||
(turnKey === undefined && activeTurnKey === NATIVE_CHAT_UNANCHORED_TURN_KEY)) &&
(isWorking || lifecycleWorking),
trailingRun: trailingRunIndexes.has(index),
receipt,
status: status ?? undefined,
statusAbove: anchorsTurnHere && status !== undefined,
folded,
turnFolds: turnKey !== undefined && foldableTurnKeys.has(turnKey),
turnDiff,
@@ -33,7 +33,6 @@ function slotsOf(messages: NativeChatMessage[]) {
messages,
turnKeys,
activeTurnKey: selectNativeChatActiveTurnKey(messages),
currentTurnKey: undefined,
receipts: new Map<string, NativeChatResolvedPrompt>(),
turnStatuses: { active: null, completedByTurn: {} },
turnDiffs: new Map<string, NativeChatTurnDiff>(),
@@ -157,6 +157,7 @@ export function useStructuredAgentSession(args: {
workingStartedAt: transportState.turnTiming.workingStartedAt,
settledTurns: transportState.turnTiming.settledTurns,
activeTurnOpenedBy: transportState.turnTiming.activeTurnOpenedBy,
turnKeysByItemId: transportState.turnTiming.turnKeysByItemId,
turnActivity: transportState.turnActivity,
backgroundTasks: transportState.backgroundTasks,
turnId: transportState.turnId,
@@ -12,7 +12,8 @@ import {
} from '../../../../shared/structured-agent-turn-clock-anchor'
/** Host-recorded turn timing for the structured lane: settled durations straight
* off the journal, the user message that owns the running turn's bar, and a
* off the journal, the transcript key that owns the running turn's bar, each
* row's owning turn, and a
* skew-free start for the live counter whose host-to-local conversion is latched
* once per turn. */
export function useStructuredAgentTurnTiming(
@@ -30,8 +31,9 @@ export function useStructuredAgentTurnTiming(
settledTurns: NativeChatSettledTurns
workingStartedAt: number | null
activeTurnOpenedBy: string | null
turnKeysByItemId: ReadonlyMap<string, string>
} {
const { settledTurns, runningTiming, activeTurnOpenedBy } = useMemo(
const { settledTurns, runningTiming, activeTurnOpenedBy, turnKeysByItemId } = useMemo(
() => selectStructuredAgentTurnBars(items, submissions, turnId),
[items, submissions, turnId]
)
@@ -48,5 +50,10 @@ export function useStructuredAgentTurnTiming(
if (step.latch !== latch) {
setLatch(step.latch)
}
return { settledTurns, workingStartedAt: step.workingStartedAt, activeTurnOpenedBy }
return {
settledTurns,
workingStartedAt: step.workingStartedAt,
activeTurnOpenedBy,
turnKeysByItemId
}
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from './native-chat-types'
import { nativeChatRowTurnKeys, nativeChatSelfAnchoredTurnRows } 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('nativeChatSelfAnchoredTurnRows', () => {
it('anchors a turn with no user bubble at its first rendered row', () => {
const messages = [message('A', 'user'), message('t1'), message('t2'), message('t3')]
const turnKeys = ['A', 'A', 'wake-turn', 'wake-turn']
expect(nativeChatSelfAnchoredTurnRows(messages, turnKeys)).toEqual(new Map([['wake-turn', 2]]))
})
it('never anchors a turn whose key is a rendered message', () => {
const messages = [message('A', 'user'), message('t1')]
expect(nativeChatSelfAnchoredTurnRows(messages, ['A', 'A']).size).toBe(0)
})
})
+49
View File
@@ -0,0 +1,49 @@
// Which turn each transcript row belongs to, and where a turn with no user
// bubble anchors its bar. Shared because desktop and mobile both group rows and
// place bars from these keys, and a row grouped differently on each surface is
// the same bug twice.
import type { NativeChatMessage } from './native-chat-types'
/**
* Resolve each row's turn key. The host's attribution (`turnKeysByItemId`, from
* `selectStructuredAgentTurnBars`) wins, so rows after a mid-turn send stay with
* the turn that produced them. Rows it cannot name keep positional grouping —
* an unmapped user row keys itself, anything else inherits the previous row's
* key — which on a host without turn attribution is exactly today's
* preceding-user-message grouping.
*/
export function nativeChatRowTurnKeys(
messages: readonly NativeChatMessage[],
turnKeysByItemId?: ReadonlyMap<string, string> | null
): (string | undefined)[] {
let currentTurnKey: string | undefined
return messages.map((message) => {
const owned = turnKeysByItemId?.get(message.id)
if (owned !== undefined) {
currentTurnKey = owned
return owned
}
if (message.role === 'user') {
currentTurnKey = message.id
}
return currentTurnKey
})
}
/** For each turn key with no message of its own — a turn the provider opened, or
* whose opener is outside the loaded window — the row index that anchors its
* bar: the turn's first rendered row, matching the turn's own position. */
export function nativeChatSelfAnchoredTurnRows(
messages: readonly NativeChatMessage[],
turnKeys: readonly (string | undefined)[]
): ReadonlyMap<string, number> {
const messageIds = new Set(messages.map((message) => message.id))
const anchors = new Map<string, number>()
turnKeys.forEach((turnKey, index) => {
if (turnKey !== undefined && !messageIds.has(turnKey) && !anchors.has(turnKey)) {
anchors.set(turnKey, index)
}
})
return anchors
}
@@ -333,7 +333,7 @@ describe('turns whose host names no opener', () => {
expect([...selectStructuredAgentSettledTurns(items).keys()]).toEqual(['orca:u1'])
})
it('falls back for a turn the provider opened and keyed to itself', () => {
it('anchors a turn the provider opened to its own record, never a bystander prompt', () => {
const self = 'legacy:claude:55368cfb:turn-lifecycle%3Aresumed'
const items = [
user(1, 'u1', 1_000),
@@ -344,7 +344,37 @@ describe('turns whose host names no opener', () => {
userItemId: self
})
]
expect(selectStructuredAgentTurnBars(items, [], 'resumed').activeTurnOpenedBy).toBeNull()
expect(selectStructuredAgentTurnBars(items, [], 'resumed').activeTurnOpenedBy).toBe(self)
})
it('keeps the running bar on the send Codex opened a turn for before it echoes it', () => {
// Codex reports turn/started before hooks and prewarm run, so the turn names its
// provider key while the send that opened it is still pending (no alias yet).
const key = 'codex:thread:t2:0'
const items: AgentJournalRenderItem[] = [
user(1, 'first', 1_000),
turn(2, 1_100, {
turnId: 't1',
state: 'completed',
userItemId: 'orca:first',
startedAt: 1_100,
completedAt: 2_000
}),
tool(3, 1_500),
user(4, 'second', 3_000),
turn(5, 3_100, { turnId: 't2', state: 'running', startedAt: 3_100, userItemId: key })
]
const submissions = [accepted('first', 'claude:first'), accepted('second', null)]
const bars = selectStructuredAgentTurnBars(items, submissions, 't2')
expect(bars.activeTurnOpenedBy).toBe('orca:second')
expect(bars.turnKeysByItemId.get('orca:second')).toBe('orca:second')
// A turn with no send in flight still anchors to its own record.
const selfOpened = selectStructuredAgentTurnBars(
items,
[accepted('first', 'claude:first'), accepted('second', 'claude:second')],
't2'
)
expect(selfOpened.activeTurnOpenedBy).toBe('legacy:claude:55368cfb:turn-lifecycle%3At2')
})
it('names nothing for the unanchored transcript', () => {
@@ -399,3 +429,104 @@ describe('the previous turn has no recorded end', () => {
expect(structuredAgentTurnOrigin(queued(12_000))).toBe(9_000)
})
})
describe('which turn owns each transcript row', () => {
const keysOf = (snapshot: {
turnId: string | null
items: AgentJournalRenderItem[]
submissions: AgentJournalSubmission[]
}) =>
selectStructuredAgentTurnBars(snapshot.items, snapshot.submissions, snapshot.turnId)
.turnKeysByItemId
it('keeps the rows Claude produces after a mid-turn send with the turn that ran them', () => {
// The #23621 shape: B lands mid-turn and three tool calls follow, one turn.
const midTurn = {
turnId: turn1.turnId,
items: [
user(54, 'A', A.sent),
turn(57, T + 77_224, {
...turn1,
state: 'running',
startedAt: A.started,
requestedAt: A.sent
}),
tool(58, T + 77_300),
user(60, 'B', B.sent),
tool(61, T + 80_100),
tool(62, T + 80_200),
tool(63, T + 80_300)
],
submissions: [accepted('A', turn1.userItemId), accepted('B', null)]
}
const keys = keysOf(midTurn)
expect(keys.get('orca:A')).toBe('orca:A')
expect(keys.get('orca:B')).toBe('orca:A')
for (const sequence of [58, 61, 62, 63]) {
expect(keys.get(`orca:claude-tool%3A${sequence}`)).toBe('orca:A')
}
})
it('leaves a fresh tail send unowned until the turn proves it continued past it', () => {
// B is the newest row: nothing after it says the running turn absorbed it.
expect(keysOf(whileQueued).has('orca:B')).toBe(false)
// B's own turn opened: B is an opener and keys itself.
expect(keysOf(whileB).get('orca:B')).toBe('orca:B')
expect(keysOf(whileB).get(`orca:claude-tool%3A58`)).toBe('orca:A')
})
it('folds a Codex-coalesced send into the turn its provider key names', () => {
const key = 'codex:thread:t1:0'
const items: AgentJournalRenderItem[] = [
user(1, 'first', 1_000),
{
itemId: 'legacy:codex:s:turn-lifecycle%3At1',
revision: 1,
sequence: 2,
observedAt: 1_200,
body: {
kind: 'turn',
turnId: 't1',
state: 'running',
userItemId: key,
requestedAt: 1_000,
startedAt: 1_200
}
},
user(3, 'second', 4_000),
tool(4, 5_000)
]
const submissions = [accepted('first', key), accepted('second', key)]
const keys = keysOf({ turnId: 't1', items, submissions })
expect(keys.get('orca:first')).toBe('orca:first')
expect(keys.get('orca:second')).toBe('orca:first')
expect(keys.get('orca:claude-tool%3A4')).toBe('orca:first')
})
it('keys a provider-opened turn and its rows to the turn record itself', () => {
const self = 'legacy:claude:55368cfb:turn-lifecycle%3Aresumed'
const items = [
user(1, 'u1', 1_000),
turn(2, 5_000, {
turnId: 'resumed',
state: 'running',
startedAt: 5_000,
userItemId: self
}),
tool(3, 6_000)
]
const keys = keysOf({ turnId: 'resumed', items, submissions: [] })
// u1 predates the wake turn and never opened one: positional grouping keeps it.
expect(keys.has('orca:u1')).toBe(false)
expect(keys.get('orca:claude-tool%3A3')).toBe(self)
})
it('attributes nothing for an older host that names no opener', () => {
const items = [
user(1, 'u1', 1_000),
turn(2, 1_100, { turnId: 't1', state: 'running', startedAt: 1_100 }),
tool(3, 1_200)
]
expect(keysOf({ turnId: 't1', items, submissions: [] }).size).toBe(0)
})
})
@@ -226,7 +226,7 @@ describe('explicit user-item attribution', () => {
expect([...selectStructuredAgentTurnTimings(items).keys()]).toEqual(['claude:s:u1'])
})
it('attributes nothing when a keyed row names a user item nobody journaled', () => {
it('anchors to the record itself when a keyed row names a user item nobody journaled', () => {
const items = [
user('orca:first'),
lifecycle('auto', {
@@ -236,7 +236,11 @@ describe('explicit user-item attribution', () => {
completedAt: 2_000
})
]
expect(selectStructuredAgentTurnTimings(items).size).toBe(0)
// Never the preceding prompt, which did not open this turn; the record's own
// key gives the turn a bar at its own position instead.
expect([...selectStructuredAgentTurnTimings(items).keys()]).toEqual([
'legacy:codex:s:turn-lifecycle%3Aauto'
])
})
it('falls back to journal order only for rows without a key (older hosts)', () => {
@@ -75,9 +75,12 @@ function readTiming(
/** One turn record, read in journal order. */
type StructuredAgentJournalTurn = {
timing: StructuredAgentTurnTiming | null
/** The user message the host names as the turn's opener; null when it names
* none (an older host, or a turn the provider opened and keyed to itself). */
openedBy: string | null
/** The transcript key that anchors this turn's bar and owns its rows: the
* opener user item when the host names one it can resolve (or the send still
* in flight ahead of the record), else the turn record's own item (a turn the
* provider opened, or an opener outside the loaded window). Null only for an
* older host that names nothing. */
key: string | null
}
type StructuredAgentJournalTurns = {
@@ -105,13 +108,24 @@ function readStructuredAgentJournalTurns(
aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId))
}
}
// Sends not yet matched to a provider item. Codex reports a turn open before it
// echoes the send, so for that gap the turn names a key no alias resolves yet.
const inFlight = new Set(
submissions
.filter((submission) => submission.dispatchState === 'pending' && !submission.providerItemId)
.map((submission) => agentJournalSubmissionKey(submission.clientMessageId))
)
const byUserItem = new Map<string, StructuredAgentTurnTiming | null>()
const byTurnId = new Map<string, StructuredAgentJournalTurn>()
let precedingUserItemId: string | null = null
let inFlightSinceLastTurn: string | null = null
let precedingTurnEndedAt: number | undefined
for (const item of items) {
if (item.body.kind === 'message' && item.body.role === 'user') {
precedingUserItemId = item.itemId
if (inFlightSinceLastTurn === null && inFlight.has(item.itemId)) {
inFlightSinceLastTurn = item.itemId
}
continue
}
const turn = readAgentJournalTurn(item.body)
@@ -122,14 +136,16 @@ function readStructuredAgentJournalTurns(
precedingTurnEndedAt = timing?.completedAt ?? item.observedAt
const key = turn.userItemId
const named = key === undefined ? null : itemIds.has(key) ? key : (aliases.get(key) ?? null)
byTurnId.set(turn.turnId, {
timing,
openedBy: named === item.itemId ? null : named
})
// An unresolved opener is the send still in flight ahead of this record; with none,
// it is outside the window, and the turn anchors to its own record like a
// provider-opened one — never to a preceding prompt that did not open it.
const turnKey = key === undefined ? null : (named ?? inFlightSinceLastTurn ?? item.itemId)
inFlightSinceLastTurn = null
byTurnId.set(turn.turnId, { timing, key: turnKey })
if (!timing && turn.state !== 'unverifiable') {
continue
}
const userItemId = key === undefined ? precedingUserItemId : named
const userItemId = key === undefined ? precedingUserItemId : turnKey
if (userItemId !== null) {
byUserItem.set(userItemId, timing)
}
@@ -137,6 +153,58 @@ function readStructuredAgentJournalTurns(
return { byUserItem, byTurnId }
}
/**
* Which turn owns each journal item, as the transcript groups rows: every item
* between a turn record and the next belongs to that record's turn (the record
* is appended when the turn opens and revised in place, and an opener's user
* item is written ahead of dispatch, so journal order is turn order). A user
* item that opened any turn — even a later one it queued for — keys itself; one
* the provider folded into a running turn (a steer) takes that turn's key, but
* only once the turn produces more rows after it, so a fresh tail send is not
* pulled into the turn it is merely waiting behind. Items before the first turn
* record stay absent, and the surface keeps its positional grouping for them.
*/
function turnKeysByItemIdOf(
items: readonly AgentJournalRenderItem[],
byTurnId: ReadonlyMap<string, StructuredAgentJournalTurn>
): ReadonlyMap<string, string> {
const openers = new Set<string>()
for (const turn of byTurnId.values()) {
if (turn.key !== null) {
openers.add(turn.key)
}
}
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 past them.
let pendingUserItemIds: string[] = []
for (const item of items) {
const turn = readAgentJournalTurn(item.body)
if (turn) {
currentKey = byTurnId.get(turn.turnId)?.key ?? null
pendingUserItemIds = []
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
}
export function selectStructuredAgentTurnTimings(
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[] = []
@@ -237,8 +305,9 @@ function settledTurnsOf(
}
/** Everything a chat surface reads off the journal for its turn bars, from one
* pass: settled durations, the running turn's timing, and the user message that
* owns its bar — which a message sent while the turn runs is not. */
* pass: settled durations, the running turn's timing, the item that anchors its
* bar — which a message sent while the turn runs is not — and which turn owns
* each row of the transcript. */
export function selectStructuredAgentTurnBars(
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[],
@@ -246,13 +315,18 @@ export function selectStructuredAgentTurnBars(
): {
settledTurns: NativeChatSettledTurns
runningTiming: StructuredAgentTurnTiming | null
/** The transcript key the running turn's bar anchors to: its opening user
* message, or the turn record itself when the provider opened the turn.
* Null on an older host, where the newest user message stays the anchor. */
activeTurnOpenedBy: string | null
turnKeysByItemId: ReadonlyMap<string, string>
} {
const turns = readStructuredAgentJournalTurns(items, submissions)
const running = turnId === null ? undefined : turns.byTurnId.get(turnId)
return {
settledTurns: settledTurnsOf(turns, submissions),
runningTiming: running?.timing ?? null,
activeTurnOpenedBy: running?.openedBy ?? null
activeTurnOpenedBy: running?.key ?? null,
turnKeysByItemId: turnKeysByItemIdOf(items, turns.byTurnId)
}
}