mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(chat): defer turn diff details and localize resolution times
This commit is contained in:
+84
-1
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user