fix(chat): defer turn diff details and localize resolution times

This commit is contained in:
Merge Sim
2026-09-07 00:06:35 -07:00
parent edaa1bc8a6
commit 35fe0cfc90
15 changed files with 585 additions and 152 deletions
@@ -2,10 +2,13 @@
import '@testing-library/jest-dom/vitest'
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as NativeChatProseModule from './native-chat-prose'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import type * as UnifiedPatchModule from '../../../../shared/native-chat-unified-patch'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
// Counting real per-row work rather than a render counter: a future refactor could keep the
@@ -22,6 +25,22 @@ vi.mock('./native-chat-prose', async (importOriginal) => {
}
})
const patchCalls = vi.hoisted(() => ({ detailed: 0, summary: 0 }))
vi.mock('../../../../shared/native-chat-unified-patch', async (importOriginal) => {
const actual = await importOriginal<typeof UnifiedPatchModule>()
return {
...actual,
editLinesFromUnifiedPatch: (...args: Parameters<typeof actual.editLinesFromUnifiedPatch>) => {
patchCalls.detailed += 1
return actual.editLinesFromUnifiedPatch(...args)
},
summarizeUnifiedPatch: (...args: Parameters<typeof actual.summarizeUnifiedPatch>) => {
patchCalls.summary += 1
return actual.summarizeUnifiedPatch(...args)
}
}
})
const { NativeChatMessageList } = await import('./NativeChatMessageList')
afterEach(cleanup)
@@ -88,4 +107,68 @@ describe('native chat transcript re-render cost during a streaming turn', () =>
// rows keep their block identity, so only the streaming tail should rebuild.
expect(perFrame).toBeLessThan(TRANSCRIPT_LENGTH / 10)
})
it('keeps structured diff rows lazy and reuses counts across journal updates', () => {
patchCalls.detailed = 0
patchCalls.summary = 0
const user: AgentJournalRenderItem = {
itemId: 'user',
revision: 1,
sequence: 1,
observedAt: 1000,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Edit a file' }] }
}
const diff: AgentJournalRenderItem = {
itemId: 'diff',
revision: 1,
sequence: 2,
observedAt: 2000,
body: {
kind: 'diff',
path: 'src/a.ts',
patch: {
head: '@@ -1 +1 @@\n-old\n+new',
truncated: false,
digest: 'fixture',
byteLength: 25
}
}
}
const view = (items: AgentJournalRenderItem[]) => (
<NativeChatMessageList
session={sessionWith(projectStructuredAgentSessionMessages(items, [], []))}
journalItems={items}
isWorking={false}
expandSignal={false}
fontScale={1}
/>
)
const { rerender } = render(view([user, diff]))
expect(patchCalls).toEqual({ summary: 1, detailed: 0 })
for (let frame = 0; frame < 20; frame += 1) {
rerender(
view([
user,
diff,
{
itemId: 'tail',
revision: frame + 1,
sequence: 3,
observedAt: 3000,
body: {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: `Token ${frame}` }]
}
}
])
)
}
expect(patchCalls).toEqual({ summary: 1, detailed: 0 })
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
expect(patchCalls.detailed).toBe(0)
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
expect(patchCalls).toEqual({ summary: 1, detailed: 1 })
expect(screen.getByText('new')).toBeInTheDocument()
})
})
@@ -80,6 +80,8 @@ describe('turn history presentation', () => {
expect(screen.getByText('after')).toBeInTheDocument()
expect(screen.getByText('before')).toBeInTheDocument()
expect(scrollTo).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: /1× Diff/ }))
expect(screen.queryByText('Edited file')).toBeNull()
fireEvent.click(header)
expect(header).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(header)
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { i18n } from '@/i18n/i18n'
import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp'
afterEach(async () => {
cleanup()
await i18n.changeLanguage('en')
})
describe('NativeChatMessageTimestamp', () => {
it.each([null, Number.NaN, Infinity, -Infinity, 8.64e15 + 1])(
'omits missing or invalid time %s',
(timestamp) => {
const { container } = render(<NativeChatMessageTimestamp timestamp={timestamp} focusable />)
expect(container).toBeEmptyDOMElement()
}
)
it.each([0, Date.parse('2026-09-06T19:04:05Z')])(
'renders absolute time and full metadata for %s',
(timestamp) => {
render(<NativeChatMessageTimestamp timestamp={timestamp} />)
const time = screen.getByRole('time')
expect(time).toHaveAttribute('datetime', new Date(timestamp).toISOString())
expect(time).toHaveTextContent(
new Intl.DateTimeFormat('en', { hour: 'numeric', minute: '2-digit' }).format(timestamp)
)
expect(time).toHaveAccessibleName(
new Intl.DateTimeFormat('en', { dateStyle: 'full', timeStyle: 'long' }).format(timestamp)
)
expect(time).not.toHaveAttribute('tabindex')
}
)
it('provides a focus target only when requested for user metadata', () => {
render(<NativeChatMessageTimestamp timestamp={0} focusable />)
const time = screen.getByRole('time')
time.focus()
expect(time).toHaveFocus()
expect(time).toHaveAttribute('tabindex', '0')
})
it('updates settled time when the UI language changes without a parent rerender', async () => {
const timestamp = Date.parse('2026-09-06T19:04:05Z')
render(<NativeChatMessageTimestamp timestamp={timestamp} />)
await act(async () => {
await i18n.changeLanguage('fr')
})
const time = screen.getByRole('time')
expect(time).toHaveTextContent(
new Intl.DateTimeFormat('fr', { hour: 'numeric', minute: '2-digit' }).format(timestamp)
)
expect(time).toHaveAccessibleName(
new Intl.DateTimeFormat('fr', { dateStyle: 'full', timeStyle: 'long' }).format(timestamp)
)
expect(time).toHaveAttribute('datetime', new Date(timestamp).toISOString())
})
})
@@ -0,0 +1,55 @@
import { useTranslation } from 'react-i18next'
import { getIntlLocale } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
let cached: {
locale: string
time: Intl.DateTimeFormat
full: Intl.DateTimeFormat
} | null = null
function getTimestampFormatters(): NonNullable<typeof cached> {
const locale = getIntlLocale()
if (!cached || cached.locale !== locale) {
cached = {
locale,
time: new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }),
full: new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeStyle: 'long' })
}
}
return cached
}
export function NativeChatMessageTimestamp({
timestamp,
focusable = false,
className
}: {
timestamp: number | null
focusable?: boolean
className?: string
}): React.JSX.Element | null {
useTranslation()
if (timestamp === null) {
return null
}
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) {
return null
}
const formatters = getTimestampFormatters()
return (
<time
dateTime={date.toISOString()}
aria-label={formatters.full.format(date)}
tabIndex={focusable ? 0 : undefined}
className={cn(
'rounded-md text-xs whitespace-nowrap text-muted-foreground tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
className
)}
>
{formatters.time.format(date)}
</time>
)
}
@@ -1,7 +1,8 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { i18n } from '@/i18n/i18n'
import type { AgentJournalQuestionItem } from '../../../../shared/agent-session-journal-types'
import { encodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer'
import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt'
@@ -10,7 +11,10 @@ import {
type NativeChatResolvedPrompt
} from './native-chat-resolution-receipt'
afterEach(cleanup)
afterEach(async () => {
cleanup()
await i18n.changeLanguage('en')
})
const approval: NativeChatResolvedPrompt = {
kind: 'approval',
title: 'Run command?',
@@ -28,6 +32,19 @@ const approval: NativeChatResolvedPrompt = {
}
describe('resolution receipts', () => {
it('localizes the resolved time when the UI language changes', async () => {
render(<NativeChatResolutionReceipt body={approval} />)
await act(async () => {
await i18n.changeLanguage('fr')
})
expect(screen.getByRole('time')).toHaveTextContent(
new Intl.DateTimeFormat('fr', { hour: 'numeric', minute: '2-digit' }).format(1000)
)
expect(screen.getByRole('time')).toHaveAccessibleName(
new Intl.DateTimeFormat('fr', { dateStyle: 'full', timeStyle: 'long' }).format(1000)
)
})
it.each([
['yes', 'Allow once'],
['no', 'Deny']
@@ -1,5 +1,5 @@
import { translate } from '@/i18n/i18n'
import { formatShortTimeAgo } from '@/lib/short-time-ago'
import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp'
import {
nativeChatReceiptAnswers,
type NativeChatResolvedPrompt
@@ -26,7 +26,7 @@ export function NativeChatResolutionReceipt({
<p className="line-clamp-3 whitespace-pre-wrap break-words">{body.detail}</p>
) : null}
{answers.map((answer, index) => (
<div key={index}>
<div key={body.kind === 'question' ? (body.questions?.[index]?.id ?? 'answer') : 'answer'}>
{answer.question ? <p>{answer.question}</p> : null}
<p className="line-clamp-3 whitespace-pre-wrap break-words">
{answer.answer ??
@@ -54,12 +54,7 @@ export function NativeChatResolutionReceipt({
})}
</span>
) : null}
{resolution.resolvedAt !== null &&
Number.isFinite(new Date(resolution.resolvedAt).getTime()) ? (
<time dateTime={new Date(resolution.resolvedAt).toISOString()}>
{formatShortTimeAgo(resolution.resolvedAt)}
</time>
) : null}
<NativeChatMessageTimestamp timestamp={resolution.resolvedAt} />
</div>
</div>
)
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { Check, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
@@ -152,15 +152,23 @@ export function NativeChatToolRun({
activeTurnIsWorking?: boolean
structuredActivityUi?: boolean
}): React.JSX.Element | null {
const [open, setOpen] = useState(expandOverride ?? expandSignal)
// Re-sync when the global toolbar toggle flips.
useEffect(() => setOpen(expandOverride ?? expandSignal), [expandOverride, expandSignal])
useEffect(() => {
if (revealedDiff) {
const [open, setOpen] = useState(revealedDiff ? true : (expandOverride ?? expandSignal))
const [controls, setControls] = useState({ expandOverride, expandSignal, revealedDiff })
if (
controls.expandOverride !== expandOverride ||
controls.expandSignal !== expandSignal ||
controls.revealedDiff !== revealedDiff
) {
setControls({ expandOverride, expandSignal, revealedDiff })
if (revealedDiff && controls.revealedDiff !== revealedDiff) {
setOpen(true)
} else if (
controls.expandOverride !== expandOverride ||
controls.expandSignal !== expandSignal
) {
setOpen(expandOverride ?? expandSignal)
}
}, [revealedDiff])
}
const callCount = countToolCalls(blocks) || blocks.length
const summary = summarizeToolRun(blocks)
@@ -171,7 +179,7 @@ export function NativeChatToolRun({
// The turn caret opens the activity group, while each child tool remains
// collapsed. The global expand toolbar still opens child details together.
const expandToolLines = expandOverride === undefined ? open : false
// Other edits remain lazy; journal diffs reuse the rollup's normalized files.
// Rollups cache counts only; detailed diff rows are built when the run opens.
const { editCards, consumedResults } = useMemo(
() => (open ? buildEditCards(blocks) : NO_EDIT_CARDS),
[open, blocks]
@@ -4,6 +4,10 @@ import {
isEditToolName
} from '../../../../shared/native-chat-edit-normalize'
import type { NativeChatEditFile } from '../../../../shared/native-chat-edit-model'
import {
editFilesFromPatchText,
type NativeChatEditFileSummary
} from '../../../../shared/native-chat-edit-patch-files'
import { pairToolBlocks } from './native-chat-tool-fold'
const normalizedEdits = new WeakMap<
@@ -39,12 +43,12 @@ export const NO_EDIT_CARDS: EditCardModel = { editCards: new Map(), consumedResu
/** An edit renders as one card, so its result block is folded into the call. The
* model decides which calls have landed; a call that has not keeps the generic
* tool view, its result still visible as the provider's own error. */
export function buildEditCards(blocks: NativeChatBlock[], diffOnly = false): EditCardModel {
export function buildEditCards(blocks: NativeChatBlock[]): EditCardModel {
const editCards: EditCardModel['editCards'] = new Map()
const consumedResults: EditCardModel['consumedResults'] = new Set()
for (const [index, pair] of pairToolBlocks(blocks).entries()) {
const call = pair.call
if (!call || !isEditToolName(call.name) || (diffOnly && call.name !== 'Diff')) {
if (!call || !isEditToolName(call.name)) {
continue
}
const files = normalizedEditFiles(call, pair.result, () =>
@@ -73,3 +77,56 @@ export function buildEditCards(blocks: NativeChatBlock[], diffOnly = false): Edi
}
return { editCards, consumedResults }
}
const diffSummaries = new WeakMap<
NativeChatBlock,
{
result: NativeChatBlock | undefined
files: NativeChatEditFileSummary[] | null
}
>()
// Only the journal's path-only Diff envelope has counts that can be read without tool normalization.
export function buildDiffSummaries(blocks: NativeChatBlock[]): Map<
NativeChatBlock,
{
files: NativeChatEditFileSummary[]
key: string
}
> {
const summaries = new Map<NativeChatBlock, { files: NativeChatEditFileSummary[]; key: string }>()
for (const [index, pair] of pairToolBlocks(blocks).entries()) {
const { call, result } = pair
if (
!call ||
call.name !== 'Diff' ||
call.state === 'running' ||
call.state === 'failed' ||
result?.isError ||
result?.editPatch ||
!result?.output
) {
continue
}
const input = call.input
if (
!input ||
typeof input !== 'object' ||
!('path' in input) ||
typeof input.path !== 'string' ||
Object.keys(input).some((key) => key !== 'path')
) {
continue
}
const cached = diffSummaries.get(call)
let files = cached?.result === result ? cached.files : undefined
if (files === undefined) {
files = editFilesFromPatchText(result.output, input.path, true)
diffSummaries.set(call, { result, files })
}
if (files?.length) {
summaries.set(call, { files, key: `${call.name}:${index}` })
}
}
return summaries
}
@@ -1,7 +1,8 @@
import { MAX_EDIT_LINES } from '../../../../shared/native-chat-edit-model'
import { describe, expect, it } from 'vitest'
import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types'
import { foldToolMessages } from './native-chat-tool-fold'
import { buildEditCards } from './native-chat-edit-cards'
import { buildDiffSummaries, buildEditCards } from './native-chat-edit-cards'
import { nativeChatTurnDiffs } from './native-chat-turn-diffs'
function diff(id: string, path: string, patch = '@@ -1 +1 @@\n-old\n+new'): NativeChatMessage {
@@ -103,9 +104,20 @@ describe('turn diff rollups', () => {
).toBe(0)
})
it('reuses parsed file identity across rollup/card consumers and refreshes new results', () => {
it('leaves non-journal Diff envelopes to the deferred tool card', () => {
for (const input of [{ path: 'x', patch: '@@\n+override' }, { file_path: 'x' }, null]) {
const message = diff('generic', 'x')
message.blocks[0] = { type: 'tool-call', name: 'Diff', input }
expect(buildDiffSummaries(message.blocks).size).toBe(0)
}
})
it('caches counts and deferred card models separately and refreshes new results', () => {
const message = diff('a', 'a.ts')
const first = [...buildEditCards(message.blocks, true).editCards.values()][0]!.files
const summary = [...buildDiffSummaries(message.blocks).values()][0]!.files
expect([...buildDiffSummaries([...message.blocks]).values()][0]!.files).toBe(summary)
expect(summary[0]).not.toHaveProperty('lines')
const first = [...buildEditCards(message.blocks).editCards.values()][0]!.files
expect([...buildEditCards([...message.blocks]).editCards.values()][0]!.files).toBe(first)
message.blocks = [
message.blocks[0]!,
@@ -114,5 +126,25 @@ describe('turn diff rollups', () => {
const updated = [...buildEditCards(message.blocks).editCards.values()][0]!.files
expect(updated).not.toBe(first)
expect(updated[0]?.added).toBe(2)
const updatedSummary = [...buildDiffSummaries(message.blocks).values()][0]!.files
expect(updatedSummary).not.toBe(summary)
expect(updatedSummary[0]?.added).toBe(2)
})
it.each([
'@@ -1 +1 @@\n-old\n+new',
'@@\n--- content\n+++ content\n\\ No newline at end of file',
'@@ -1 +1 @@\n-old\n+new\n@@ -5 +5 @@\n-again\n+again',
'diff --git a/a.ts b/b.ts\nrename from a.ts\nrename to b.ts',
`@@ -0,0 +1,2500 @@\n${'+new\n'.repeat(MAX_EDIT_LINES + 1)}`,
`@@ -0,0 +1,2500 @@\n${'+new\n'.repeat(MAX_EDIT_LINES - 1)}@@ -1 +1 @@\n+last`,
'@@ -1 +1 @@\n-old\n+new\n… (9999 bytes)'
])('keeps lightweight counts identical to the expanded card (case %#)', (patch) => {
const message = diff('parity', 'a.ts', patch)
const summary = [...buildDiffSummaries(message.blocks).values()][0]!.files
const detailed = [...buildEditCards(message.blocks).editCards.values()][0]!.files
expect(summary).toEqual(
detailed.map(({ lines: _lines, lineNumbersKnown: _known, ...file }) => file)
)
})
})
@@ -1,5 +1,5 @@
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { buildEditCards } from './native-chat-edit-cards'
import { buildDiffSummaries } from './native-chat-edit-cards'
export type NativeChatDiffTarget = {
messageId: string
@@ -35,7 +35,7 @@ export function nativeChatTurnDiffs(
if (!turnKey) {
continue
}
for (const edit of buildEditCards(message.blocks, true).editCards.values()) {
for (const edit of buildDiffSummaries(message.blocks).values()) {
let files = turns.get(turnKey)
if (!files) {
files = new Map()
+3 -74
View File
@@ -6,13 +6,8 @@ import {
type NativeChatEditFile,
type NativeChatEditLine
} from './native-chat-edit-model'
import { stripBoundedTextMarker } from './structured-agent-session-projection'
import {
editLinesFromUnifiedPatch,
editLinesFromWholeFile,
unifiedPatchSections,
type UnifiedPatchSection
} from './native-chat-unified-patch'
import { editFilesFromPatchText, splitMoveMarker } from './native-chat-edit-patch-files'
import { editLinesFromUnifiedPatch, editLinesFromWholeFile } from './native-chat-unified-patch'
import type { NativeChatEditPatch } from './native-chat-types'
// `NotebookEdit` is deliberately absent: its input carries only the new cell
@@ -25,9 +20,6 @@ const COMMAND_PATCH_TOOLS = new Set(['exec', 'shell', 'local_shell'])
/** Tools whose input may wrap a `*** Begin Patch` envelope. The dedicated patch
* tool applies whatever it is given; a command tool must say that it is. */
const PATCH_ENVELOPE_TOOLS = new Set(['apply_patch', ...COMMAND_PATCH_TOOLS])
/** A count standing in for a path, from a producer that joined several files'
* patches and kept no per-file path. */
const FILE_COUNT_PATH = /^\d+ files?$/
/** Tools whose whole payload is patch text. `Diff` reaches its patch only
* through the result, because the structured journal projects a diff item as a
* call carrying just the path. */
@@ -172,22 +164,6 @@ function claudeEditFiles(
]
}
/** A move is appended to the patch body as prose rather than a header field, on
* every lane that carries the body as text. Left in place it renders as a
* numbered line of the file it moved.
*
* Anchored to the start of the final line: unanchored, a row whose own content
* mentions a move was cut in half and the file it names claimed as a rename
* that never happened. */
const MOVE_MARKER = /(?:^|\n)Moved to: (.+)$/
function splitMoveMarker(patch: string): { body: string; movedTo: string | null } {
const match = MOVE_MARKER.exec(patch)
return match
? { body: patch.slice(0, match.index), movedTo: match[1]!.trim() }
: { body: patch, movedTo: null }
}
function codexChangeFiles(changes: unknown[]): NativeChatEditFile[] {
return changes.flatMap((entry) => {
const change = record(entry)
@@ -298,52 +274,5 @@ export function editFilesFromToolPair(pair: {
if (!patchText) {
return null
}
// The body carries its own marker when the journal clipped it. Read as
// content it becomes a numbered line of the file, and the rows that follow
// are reported complete.
const bounded = stripBoundedTextMarker(patchText)
const moved = splitMoveMarker(bounded.text)
// One card per file the patch touches: run together, the later files' rows
// and gutter numbers sit under the first file's name.
const split = unifiedPatchSections(moved.body)
const callerPath = text(input?.path) ?? text(input?.file_path)
if (callerPath !== null && FILE_COUNT_PATH.test(callerPath)) {
// The producer joined several files' patches and kept a count in place of a
// path, so nothing here can name a file. Naming the card after the count
// would assert a file that does not exist.
return null
}
// A patch that names one file is the file the call is reporting on, so the
// call's own path wins — it is the provider's, where the header's is relative
// to the patch. A patch naming several has no one path, and a rename's
// destination is only ever in the header. Sections that name nothing are
// preamble and must not change that count.
const namedSections = split.sections.filter((section) => section.path !== null).length
const named = (section: UnifiedPatchSection): string =>
(namedSections <= 1 && section.oldPath === null
? (callerPath ?? section.path)
: (section.path ?? callerPath)) ?? 'file'
const files = split.sections.flatMap((section) => {
const parsed = editLinesFromUnifiedPatch(section.body)
if (!parsed && section.path === null) {
return []
}
return [
finalizeEditFile({
path: named(section),
oldPath: section.oldPath,
changeKind: section.changeKind,
lines: parsed?.lines ?? [],
lineNumbersKnown: parsed?.lineNumbersKnown ?? false,
truncated: bounded.truncated || split.truncated || (parsed?.truncated ?? false)
})
]
})
// The move marker names where the whole patch moved, so it can only speak for
// a patch describing one file.
if (moved.movedTo !== null && files.length === 1 && files[0]) {
const only = files[0]
return [{ ...only, path: moved.movedTo, oldPath: only.path, changeKind: 'renamed' }]
}
return files.length > 0 ? files : null
return editFilesFromPatchText(patchText, text(input?.path) ?? text(input?.file_path))
}
+106
View File
@@ -0,0 +1,106 @@
import { finalizeEditFile, type NativeChatEditFile } from './native-chat-edit-model'
import { stripBoundedTextMarker } from './structured-agent-session-projection'
import {
editLinesFromUnifiedPatch,
summarizeUnifiedPatch,
unifiedPatchSections,
type UnifiedPatchSection
} from './native-chat-unified-patch'
const FILE_COUNT_PATH = /^\d+ files?$/
/** A move is appended to the patch body as prose rather than a header field, on
* every lane that carries the body as text. Left in place it renders as a
* numbered line of the file it moved.
*
* Anchored to the start of the final line: unanchored, a row whose own content
* mentions a move was cut in half and the file it names claimed as a rename
* that never happened. */
const MOVE_MARKER = /(?:^|\n)Moved to: (.+)$/
export function splitMoveMarker(patch: string): { body: string; movedTo: string | null } {
const match = MOVE_MARKER.exec(patch)
return match
? { body: patch.slice(0, match.index), movedTo: match[1]!.trim() }
: { body: patch, movedTo: null }
}
export type NativeChatEditFileSummary = Pick<
NativeChatEditFile,
'path' | 'oldPath' | 'changeKind' | 'added' | 'removed' | 'truncated'
>
export function editFilesFromPatchText(
patchText: string,
callerPath: string | null
): NativeChatEditFile[] | null
export function editFilesFromPatchText(
patchText: string,
callerPath: string | null,
summaryOnly: true
): NativeChatEditFileSummary[] | null
export function editFilesFromPatchText(
patchText: string,
callerPath: string | null,
summaryOnly = false
): NativeChatEditFileSummary[] | null {
// The body carries its own marker when the journal clipped it. Read as
// content it becomes a numbered line of the file, and the rows that follow
// are reported complete.
const bounded = stripBoundedTextMarker(patchText)
const moved = splitMoveMarker(bounded.text)
// One card per file the patch touches: run together, the later files' rows
// and gutter numbers sit under the first file's name.
const split = unifiedPatchSections(moved.body)
if (callerPath !== null && FILE_COUNT_PATH.test(callerPath)) {
// The producer joined several files' patches and kept a count in place of a
// path, so nothing here can name a file. Naming the card after the count
// would assert a file that does not exist.
return null
}
// A patch that names one file is the file the call is reporting on, so the
// call's own path wins — it is the provider's, where the header's is relative
// to the patch. A patch naming several has no one path, and a rename's
// destination is only ever in the header. Sections that name nothing are
// preamble and must not change that count.
const namedSections = split.sections.filter((section) => section.path !== null).length
const named = (section: UnifiedPatchSection): string =>
(namedSections <= 1 && section.oldPath === null
? (callerPath ?? section.path)
: (section.path ?? callerPath)) ?? 'file'
const files = split.sections.flatMap((section) => {
const parsed = summaryOnly
? summarizeUnifiedPatch(section.body)
: editLinesFromUnifiedPatch(section.body)
if (!parsed && section.path === null) {
return []
}
const metadata = {
path: named(section),
oldPath: section.oldPath,
changeKind: section.changeKind,
truncated: bounded.truncated || split.truncated || (parsed?.truncated ?? false)
}
return [
summaryOnly
? {
...metadata,
added: parsed && 'added' in parsed ? parsed.added : 0,
removed: parsed && 'removed' in parsed ? parsed.removed : 0
}
: finalizeEditFile({
...metadata,
lines: parsed && 'lines' in parsed ? parsed.lines : [],
lineNumbersKnown:
parsed && 'lineNumbersKnown' in parsed ? parsed.lineNumbersKnown : false
})
]
})
// The move marker names where the whole patch moved, so it can only speak for
// a patch describing one file.
if (moved.movedTo !== null && files.length === 1 && files[0]) {
const only = files[0]
return [{ ...only, path: moved.movedTo, oldPath: only.path, changeKind: 'renamed' }]
}
return files.length > 0 ? files : null
}
+60 -38
View File
@@ -1,5 +1,5 @@
import { FILE_SECTION_START, isFileHeaderPair } from './native-chat-diff'
import { pushEditGap, splitEditContent, type NativeChatEditLine } from './native-chat-edit-model'
import { MAX_EDIT_LINES, splitEditContent, type NativeChatEditLine } from './native-chat-edit-model'
const HUNK_RANGES = /^@@+ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
@@ -22,9 +22,52 @@ export function editLinesFromUnifiedPatch(
text: string,
options?: { implicitFirstHunk?: boolean }
): UnifiedPatchLines | null {
const lines: NativeChatEditLine[] = []
const metadata = visitUnifiedPatch(
text,
(kind, raw, oldLineNumber, newLineNumber) => {
lines.push({ kind, text: raw, oldLineNumber, newLineNumber })
},
options
)
return metadata ? { lines, ...metadata } : null
}
/** Counts the same capped rows as a card without allocating its line models. */
export function summarizeUnifiedPatch(text: string): {
added: number
removed: number
truncated: boolean
} | null {
let added = 0
let removed = 0
let rowCount = 0
const metadata = visitUnifiedPatch(text, (kind) => {
rowCount += 1
if (rowCount <= MAX_EDIT_LINES) {
added += Number(kind === 'add')
removed += Number(kind === 'del')
}
})
return metadata
? { added, removed, truncated: metadata.truncated || rowCount > MAX_EDIT_LINES }
: null
}
function visitUnifiedPatch(
text: string,
visit: (
kind: NativeChatEditLine['kind'],
text: string,
oldLineNumber: number | null,
newLineNumber: number | null
) => void,
options?: { implicitFirstHunk?: boolean }
): Omit<UnifiedPatchLines, 'lines'> | null {
const source = splitEditContent(text)
const rows = source.lines
const lines: NativeChatEditLine[] = []
let rowCount = 0
let lastWasGap = false
let oldNo: number | null = null
let newNo: number | null = null
let sawHunk = options?.implicitFirstHunk === true
@@ -37,15 +80,15 @@ export function editLinesFromUnifiedPatch(
const match = HUNK_RANGES.exec(raw)
oldNo = match ? Number(match[1]) : null
newNo = match ? Number(match[3]) : null
// Successive hunks are separate regions of the file; concatenated with no
// break the gutter jumps and the reader sees one continuous block.
pushEditGap(lines)
if (rowCount > 0 && !lastWasGap) {
visit('gap', '', null, null)
rowCount += 1
lastWasGap = true
}
sawHunk = true
inHunk = true
continue
}
// `\ No newline at end of file` sits mid-hunk, between the removed old last
// line and the added new one, so it ends nothing.
if (raw.startsWith('\\')) {
continue
}
@@ -60,43 +103,22 @@ export function editLinesFromUnifiedPatch(
if (!inHunk) {
continue
}
// Read off the rows rather than the header, so a body that opened with no
// header is reported as unlocatable just like a rangeless `@@`.
ranged &&= oldNo !== null || newNo !== null
rowCount += 1
lastWasGap = false
if (raw.startsWith('+')) {
lines.push({
kind: 'add',
text: raw.slice(1),
oldLineNumber: null,
newLineNumber: newNo
})
visit('add', raw.slice(1), null, newNo)
newNo = newNo === null ? null : newNo + 1
continue
}
if (raw.startsWith('-')) {
lines.push({
kind: 'del',
text: raw.slice(1),
oldLineNumber: oldNo,
newLineNumber: null
})
} else if (raw.startsWith('-')) {
visit('del', raw.slice(1), oldNo, null)
oldNo = oldNo === null ? null : oldNo + 1
continue
} else {
visit('context', raw.startsWith(' ') ? raw.slice(1) : raw, oldNo, newNo)
oldNo = oldNo === null ? null : oldNo + 1
newNo = newNo === null ? null : newNo + 1
}
lines.push({
kind: 'context',
text: raw.startsWith(' ') ? raw.slice(1) : raw,
oldLineNumber: oldNo,
newLineNumber: newNo
})
oldNo = oldNo === null ? null : oldNo + 1
newNo = newNo === null ? null : newNo + 1
}
if (!sawHunk || lines.length === 0) {
return null
}
return { lines, lineNumbersKnown: ranged, truncated: source.truncated }
return sawHunk && rowCount > 0 ? { lineNumbersKnown: ranged, truncated: source.truncated } : null
}
const GIT_DIFF_HEADER = 'diff --git '
@@ -20,6 +20,64 @@ function item(
}
describe('structured agent session status projection', () => {
it('reuses immutable item projections and refreshes revisions and resolved prompts', () => {
const original = item('diff', 1, {
kind: 'diff',
path: 'a.ts',
patch: {
head: '@@\n+first',
digest: 'one',
byteLength: 10,
truncated: false
}
})
const first = projectStructuredItemToNativeChat(original)
expect(projectStructuredItemToNativeChat(original)).toBe(first)
const revised = {
...original,
revision: 2,
observedAt: 2000,
body: {
kind: 'diff' as const,
path: 'a.ts',
patch: {
head: '@@\n+second',
digest: 'two',
byteLength: 11,
truncated: false
}
}
}
const second = projectStructuredItemToNativeChat(revised)
expect(second).not.toBe(first)
expect(second).toMatchObject({
timestamp: 2000,
blocks: [{ type: 'tool-call' }, { type: 'tool-result', output: '@@\n+second' }]
})
const pending = item('approval', 2, {
kind: 'approval',
title: 'Allow?',
options: [],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
})
expect(projectStructuredItemToNativeChat(pending)).toBeNull()
if (pending.body.kind !== 'approval') {
throw new Error('fixture')
}
const resolved = {
...pending,
revision: 2,
body: {
...pending.body,
resolution: { ...pending.body.resolution, state: 'resolved' as const }
}
}
expect(projectStructuredItemToNativeChat(resolved)).toMatchObject({
id: 'approval',
role: 'system'
})
})
it('projects running, attention, and completed lifecycle states', () => {
const running = item('running', 1, {
kind: 'status',
@@ -108,29 +108,37 @@ function itemBlocks(item: AgentJournalRenderItem): {
}
}
const projectedItems = new WeakMap<AgentJournalRenderItem, NativeChatMessage | null>()
export function projectStructuredItemsToNativeChat(
items: readonly AgentJournalRenderItem[]
): NativeChatMessage[] {
return items.flatMap((item) => {
const projected = itemBlocks(item)
return projected
? [
{
id: item.itemId,
role: projected.role,
blocks: projected.blocks,
timestamp: item.observedAt,
source: 'transcript'
}
]
: []
const projected = projectStructuredItemToNativeChat(item)
return projected ? [projected] : []
})
}
export function projectStructuredItemToNativeChat(
item: AgentJournalRenderItem
): NativeChatMessage | null {
return projectStructuredItemsToNativeChat([item])[0] ?? null
const cached = projectedItems.get(item)
if (cached !== undefined) {
return cached
}
// Reducer updates replace journal items, so unchanged rows keep their render caches.
const projected = itemBlocks(item)
const message: NativeChatMessage | null = projected
? {
id: item.itemId,
role: projected.role,
blocks: projected.blocks,
timestamp: item.observedAt,
source: 'transcript'
}
: null
projectedItems.set(item, message)
return message
}
export function activeStructuredAgentSessionTurnId(