mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Summarize turn file changes and preserve resolved prompt receipts (#19229)
* feat(chat): summarize turn changes and retain resolution receipts * fix(chat): defer turn diff details and localize resolution times * fix: complete approval projection test fixture * Align turn diff disclosure chevron and indent details --------- Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
d196942220
commit
e80fae0c4d
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronRight, FilePlus2, FileMinus2, FilePen } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -111,12 +111,23 @@ function DiffRow({ line, gutterWidth }: { line: NativeChatEditLine; gutterWidth:
|
||||
* nothing else — keeps the header rows and offers no empty disclosure. */
|
||||
export function NativeChatDiffCard({
|
||||
file,
|
||||
revealSignal,
|
||||
onReveal,
|
||||
initiallyExpanded = false
|
||||
}: {
|
||||
file: NativeChatEditFile
|
||||
revealSignal?: number
|
||||
onReveal?: (element: HTMLElement) => void
|
||||
initiallyExpanded?: boolean
|
||||
}): React.JSX.Element {
|
||||
const [expanded, setExpanded] = useState(initiallyExpanded)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (revealSignal && cardRef.current) {
|
||||
setExpanded(true)
|
||||
onReveal?.(cardRef.current)
|
||||
}
|
||||
}, [revealSignal, onReveal])
|
||||
// Joining every row to seed the copy button is the card's most expensive
|
||||
// work, and a collapsed card renders none of those rows.
|
||||
const copyText = useMemo(() => patchText(file.lines), [file.lines])
|
||||
@@ -127,7 +138,7 @@ export function NativeChatDiffCard({
|
||||
const gutterWidth = file.lineNumbersKnown ? Math.max(3, String(widest).length + 1) : 0
|
||||
|
||||
return (
|
||||
<div className="my-1 overflow-hidden rounded-md border border-border">
|
||||
<div ref={cardRef} className="my-1 overflow-hidden rounded-md border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasBody && setExpanded((value) => !value)}
|
||||
|
||||
+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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,16 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
import type { NativeChatTurnActivity } from './native-chat-turn-activity'
|
||||
import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine'
|
||||
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
nativeChatTurnDiffs,
|
||||
type NativeChatDiffReveal,
|
||||
type NativeChatDiffTarget,
|
||||
type NativeChatTurnDiff
|
||||
} from './native-chat-turn-diffs'
|
||||
import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup'
|
||||
import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt'
|
||||
|
||||
export { ProviderFrameRow } from './NativeChatTranscriptChrome'
|
||||
|
||||
function geometryOf(el: HTMLElement): ScrollGeometry {
|
||||
@@ -24,6 +34,7 @@ const MAX_EXPANDED_TURNS = 128
|
||||
|
||||
export function NativeChatMessageList({
|
||||
session,
|
||||
journalItems,
|
||||
isWorking,
|
||||
expandSignal,
|
||||
fontScale,
|
||||
@@ -36,6 +47,7 @@ export function NativeChatMessageList({
|
||||
runtimeContext
|
||||
}: {
|
||||
session: NativeChatLiveSession
|
||||
journalItems?: readonly AgentJournalRenderItem[]
|
||||
isWorking: boolean
|
||||
/** Toolbar-driven desired open state for every tool run; each flip re-syncs. */
|
||||
expandSignal: boolean
|
||||
@@ -50,6 +62,22 @@ export function NativeChatMessageList({
|
||||
turnActivity?: NativeChatTurnActivity | null
|
||||
runtimeContext?: RuntimeFileOperationArgs | null
|
||||
}): React.JSX.Element {
|
||||
const [revealedDiff, setRevealedDiff] = useState<NativeChatDiffReveal | null>(null)
|
||||
const revealDiff = useCallback((target: NativeChatDiffTarget) => {
|
||||
setRevealedDiff((current) => ({ ...target, requestId: (current?.requestId ?? 0) + 1 }))
|
||||
}, [])
|
||||
const receipts = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
journalItems?.flatMap((item) =>
|
||||
(item.body.kind === 'approval' || item.body.kind === 'question') &&
|
||||
item.body.resolution.state !== 'pending'
|
||||
? [[item.itemId, item.body] as const]
|
||||
: []
|
||||
)
|
||||
),
|
||||
[journalItems]
|
||||
)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||
const [stuckToBottom, setStuckToBottom] = useState(true)
|
||||
@@ -104,6 +132,13 @@ export function NativeChatMessageList({
|
||||
return currentTurnKey
|
||||
})
|
||||
}, [messages])
|
||||
const turnDiffs = useMemo(
|
||||
() =>
|
||||
journalItems
|
||||
? nativeChatTurnDiffs(messages, turnKeys)
|
||||
: new Map<string, NativeChatTurnDiff>(),
|
||||
[journalItems, messages, turnKeys]
|
||||
)
|
||||
const turnStatuses = useNativeChatTurnStatus({
|
||||
messages,
|
||||
latestUserIndex,
|
||||
@@ -230,27 +265,35 @@ export function NativeChatMessageList({
|
||||
: message.role === 'user' && turnKey
|
||||
? turnStatuses.completedByTurn[turnKey]
|
||||
: undefined
|
||||
const receipt = receipts.get(message.id)
|
||||
const turnDiff =
|
||||
turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<MessageRow
|
||||
message={message}
|
||||
expandSignal={expandSignal}
|
||||
// A missing transcript lifecycle is not evidence that the turn
|
||||
// ended. Structured sessions and legacy live hooks still expose
|
||||
// the authoritative session-level working state.
|
||||
activeTurnIsWorking={
|
||||
showTurnStatus &&
|
||||
isCurrentTurn &&
|
||||
(isWorking || session.transcriptLifecycle?.state === 'working')
|
||||
}
|
||||
onScrollMessageToTop={scrollMessageToTop}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true}
|
||||
structuredActivityUi={showTurnStatus}
|
||||
activityExpandOverride={turnKey ? expandedTurnIds.has(turnKey) : undefined}
|
||||
runtimeContext={runtimeContext}
|
||||
/>
|
||||
{receipt ? (
|
||||
<NativeChatResolutionReceipt body={receipt} />
|
||||
) : (
|
||||
<MessageRow
|
||||
message={message}
|
||||
revealedDiff={revealedDiff?.messageId === message.id ? revealedDiff : undefined}
|
||||
expandSignal={expandSignal}
|
||||
// A missing transcript lifecycle is not evidence that the turn
|
||||
// ended. Structured sessions and legacy live hooks still expose
|
||||
// the authoritative session-level working state.
|
||||
activeTurnIsWorking={
|
||||
showTurnStatus &&
|
||||
isCurrentTurn &&
|
||||
(isWorking || session.transcriptLifecycle?.state === 'working')
|
||||
}
|
||||
onScrollMessageToTop={scrollMessageToTop}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true}
|
||||
structuredActivityUi={showTurnStatus}
|
||||
activityExpandOverride={turnKey ? expandedTurnIds.has(turnKey) : undefined}
|
||||
runtimeContext={runtimeContext}
|
||||
/>
|
||||
)}
|
||||
{showTurnStatus &&
|
||||
status &&
|
||||
(index !== latestUserIndex || showTypingIndicator || !isWorking) ? (
|
||||
@@ -266,6 +309,9 @@ export function NativeChatMessageList({
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{turnDiff ? (
|
||||
<NativeChatTurnDiffRollup diff={turnDiff} onReveal={revealDiff} />
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
|
||||
const scrollTo = vi.fn()
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
function item(
|
||||
itemId: string,
|
||||
body: AgentJournalItemBody,
|
||||
sequence: number
|
||||
): AgentJournalRenderItem {
|
||||
return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 }
|
||||
}
|
||||
const user = item(
|
||||
'user',
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Make the change' }] },
|
||||
1
|
||||
)
|
||||
const prose = item(
|
||||
'prose',
|
||||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Updating the files.' }] },
|
||||
2
|
||||
)
|
||||
function diff(patch = '@@ -1 +1 @@\n-before\n+after'): AgentJournalRenderItem {
|
||||
return item(
|
||||
'diff',
|
||||
{
|
||||
kind: 'diff',
|
||||
path: 'src/a.ts',
|
||||
patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length }
|
||||
},
|
||||
3
|
||||
)
|
||||
}
|
||||
function session(items: AgentJournalRenderItem[]): NativeChatLiveSession {
|
||||
return {
|
||||
messages: projectStructuredItemsToNativeChat(items),
|
||||
status: 'ready',
|
||||
sessionId: 'session',
|
||||
agent: 'codex',
|
||||
hasMore: false,
|
||||
loadingEarlier: false,
|
||||
loadEarlier: vi.fn(),
|
||||
readPhase: 'ready'
|
||||
}
|
||||
}
|
||||
function view(items: AgentJournalRenderItem[], structured = true) {
|
||||
return (
|
||||
<NativeChatMessageList
|
||||
session={session(items)}
|
||||
journalItems={structured ? items : undefined}
|
||||
isWorking={false}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('turn history presentation', () => {
|
||||
it('reveals and scrolls to a folded diff card from a collapsed completed turn', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
|
||||
render(view([user, prose, diff()]))
|
||||
expect(screen.queryByText('Edited file')).toBeNull()
|
||||
const header = screen.getByRole('button', { name: /1 changed file/ })
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(header)
|
||||
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
|
||||
expect(screen.getByText('Edited file')).toBeInTheDocument()
|
||||
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)
|
||||
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
|
||||
expect(scrollTo.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('updates journal revisions and replaces flat approval text with a passive receipt', () => {
|
||||
const approval = item(
|
||||
'approval',
|
||||
{
|
||||
kind: 'approval',
|
||||
title: 'Run tests?',
|
||||
detail: 'pnpm test',
|
||||
options: [{ id: 'allow', label: 'Allow once' }],
|
||||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||||
},
|
||||
4
|
||||
)
|
||||
const initial = [user, prose, diff(), approval]
|
||||
const { rerender } = render(view(initial))
|
||||
expect(screen.queryByText('Run tests?')).toBeNull()
|
||||
if (approval.body.kind !== 'approval') {
|
||||
throw new Error('fixture')
|
||||
}
|
||||
const resolved = {
|
||||
...approval,
|
||||
revision: 2,
|
||||
body: {
|
||||
...approval.body,
|
||||
resolution: {
|
||||
state: 'resolved' as const,
|
||||
selectedOptionId: 'allow',
|
||||
resolvedBy: 'desktop',
|
||||
resolvedAt: 5000
|
||||
}
|
||||
}
|
||||
}
|
||||
rerender(view([user, prose, diff('@@ -0,0 +1,2 @@\n+first\n+second'), resolved]))
|
||||
expect(screen.getByRole('button', { name: /1 changed file \+2/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('Run tests?')).toBeInTheDocument()
|
||||
expect(screen.getByText('Allow once')).toBeInTheDocument()
|
||||
expect(screen.getByText('Answered on desktop')).toBeInTheDocument()
|
||||
expect(screen.getByText('Resolved').closest('[data-native-chat-receipt]')).not.toBeNull()
|
||||
expect(screen.queryByText('resolved')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps rollups turn-local and leaves legacy message lists unchanged', () => {
|
||||
const secondUser = item(
|
||||
'user-two',
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Again' }] },
|
||||
5
|
||||
)
|
||||
const secondDiff = { ...diff(), itemId: 'second-diff', sequence: 6, observedAt: 6000 }
|
||||
const items = [user, prose, diff(), secondUser, secondDiff]
|
||||
const { rerender } = render(view(items))
|
||||
expect(screen.getAllByRole('button', { name: /1 changed file/ })).toHaveLength(2)
|
||||
rerender(view(items, false))
|
||||
expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
NativeChatImageAttachments,
|
||||
ProviderFrameRow
|
||||
} from './NativeChatTranscriptChrome'
|
||||
import type { NativeChatDiffReveal } from './native-chat-turn-diffs'
|
||||
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
|
||||
/** One message: its prose first, then a collapsible run folding all of the
|
||||
@@ -28,6 +29,7 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
||||
* keep their block identity, so only the changed row re-renders. */
|
||||
export const MessageRow = memo(function MessageRow({
|
||||
message,
|
||||
revealedDiff,
|
||||
expandSignal,
|
||||
activeTurnIsWorking,
|
||||
onScrollMessageToTop,
|
||||
@@ -39,6 +41,7 @@ export const MessageRow = memo(function MessageRow({
|
||||
runtimeContext
|
||||
}: {
|
||||
message: NativeChatMessage
|
||||
revealedDiff?: NativeChatDiffReveal
|
||||
expandSignal: boolean
|
||||
activeTurnIsWorking?: boolean
|
||||
/** Align this message's top to the top of the scroll viewport. */
|
||||
@@ -199,6 +202,8 @@ export const MessageRow = memo(function MessageRow({
|
||||
{tools.length > 0 || subagentGroups.length > 0 ? (
|
||||
<NativeChatToolRun
|
||||
blocks={tools}
|
||||
revealedDiff={revealedDiff}
|
||||
onRevealDiff={onScrollMessageToTop}
|
||||
onLinkClick={onLinkClick}
|
||||
subagentGroups={subagentGroups}
|
||||
expandSignal={expandSignal}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// @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 type { AgentJournalQuestionItem } from '../../../../shared/agent-session-journal-types'
|
||||
import { encodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer'
|
||||
import { NativeChatResolutionReceipt } from './NativeChatResolutionReceipt'
|
||||
import {
|
||||
nativeChatReceiptAnswers,
|
||||
type NativeChatResolvedPrompt
|
||||
} from './native-chat-resolution-receipt'
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
const approval: NativeChatResolvedPrompt = {
|
||||
kind: 'approval',
|
||||
title: 'Run command?',
|
||||
detail: 'pnpm test',
|
||||
options: [
|
||||
{ id: 'yes', label: 'Allow once' },
|
||||
{ id: 'no', label: 'Deny' }
|
||||
],
|
||||
resolution: {
|
||||
state: 'resolved',
|
||||
selectedOptionId: 'yes',
|
||||
resolvedBy: 'phone-client',
|
||||
resolvedAt: 1000
|
||||
}
|
||||
}
|
||||
|
||||
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']
|
||||
])('shows the exact selected approval label for %s', (id, label) => {
|
||||
render(
|
||||
<NativeChatResolutionReceipt
|
||||
body={{ ...approval, resolution: { ...approval.resolution, selectedOptionId: id } }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Run command?')).toBeInTheDocument()
|
||||
expect(screen.getByText('pnpm test')).toBeInTheDocument()
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(screen.getByText('Answered on phone-client')).toBeInTheDocument()
|
||||
expect(document.querySelector('time')).toHaveAttribute('datetime', new Date(1000).toISOString())
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders cancellation quietly without inventing a choice or resolver', () => {
|
||||
render(
|
||||
<NativeChatResolutionReceipt
|
||||
body={{
|
||||
...approval,
|
||||
resolution: {
|
||||
state: 'cancelled',
|
||||
selectedOptionId: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Cancelled')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Allow once')).toBeNull()
|
||||
expect(screen.queryByText('Selected answer unavailable')).toBeNull()
|
||||
expect(document.querySelector('time')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([null, 'unknown'])('handles absent or unknown selections (%s)', (selectedOptionId) => {
|
||||
render(
|
||||
<NativeChatResolutionReceipt
|
||||
body={{ ...approval, resolution: { ...approval.resolution, selectedOptionId } }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Selected answer unavailable')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Allow once')).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes pending prompts', () => {
|
||||
const { container } = render(
|
||||
<NativeChatResolutionReceipt
|
||||
body={{ ...approval, resolution: { ...approval.resolution, state: 'pending' } }}
|
||||
/>
|
||||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('reads grouped options from each question despite an empty flat options list', () => {
|
||||
const body: AgentJournalQuestionItem = {
|
||||
kind: 'question',
|
||||
question: 'Choose settings',
|
||||
options: [],
|
||||
questions: [
|
||||
{
|
||||
id: 'q1',
|
||||
question: 'Features?',
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ id: 'a', label: 'First' },
|
||||
{ id: 'b', label: 'Second' }
|
||||
]
|
||||
},
|
||||
{ id: 'q2', question: 'Name?', multiSelect: false, options: [], freeTextQuestionId: 'q2' }
|
||||
],
|
||||
resolution: {
|
||||
...approval.resolution,
|
||||
selectedOptionId: encodeAgentSessionQuestionAnswers([
|
||||
{ questionId: 'q1', optionIds: ['a', 'b'] },
|
||||
{ questionId: 'q2', optionIds: [], other: 'Custom 100% name' }
|
||||
])
|
||||
}
|
||||
}
|
||||
render(<NativeChatResolutionReceipt body={body} />)
|
||||
expect(screen.getByText('Features?')).toBeInTheDocument()
|
||||
expect(screen.getByText('First · Second')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom 100% name')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Selected answer unavailable')).toBeNull()
|
||||
expect(
|
||||
nativeChatReceiptAnswers({
|
||||
...body,
|
||||
resolution: { ...body.resolution, selectedOptionId: 'question-group:invalid' }
|
||||
})
|
||||
).toEqual([
|
||||
{ question: 'Features?', answer: null },
|
||||
{ question: 'Name?', answer: null }
|
||||
])
|
||||
})
|
||||
|
||||
it('decodes single free-text answers only for the declared question', () => {
|
||||
const body: AgentJournalQuestionItem = {
|
||||
kind: 'question',
|
||||
question: 'Name?',
|
||||
options: [],
|
||||
freeTextQuestionId: 'name/id',
|
||||
resolution: { ...approval.resolution, selectedOptionId: 'name%2Fid:hello%20world' }
|
||||
}
|
||||
expect(nativeChatReceiptAnswers(body)).toEqual([{ question: null, answer: 'hello world' }])
|
||||
for (const selectedOptionId of ['other:hello', 'name%2Fid:%invalid']) {
|
||||
expect(
|
||||
nativeChatReceiptAnswers({ ...body, resolution: { ...body.resolution, selectedOptionId } })
|
||||
).toEqual([{ question: null, answer: null }])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp'
|
||||
import {
|
||||
nativeChatReceiptAnswers,
|
||||
type NativeChatResolvedPrompt
|
||||
} from './native-chat-resolution-receipt'
|
||||
|
||||
export function NativeChatResolutionReceipt({
|
||||
body
|
||||
}: {
|
||||
body: NativeChatResolvedPrompt
|
||||
}): React.JSX.Element | null {
|
||||
if (body.resolution.state === 'pending') {
|
||||
return null
|
||||
}
|
||||
const { resolution } = body
|
||||
const title = body.kind === 'approval' ? body.title : body.question
|
||||
const answers = nativeChatReceiptAnswers(body)
|
||||
return (
|
||||
<div
|
||||
className="space-y-1 border-l border-border pl-3 text-xs text-muted-foreground"
|
||||
data-native-chat-receipt={body.kind}
|
||||
>
|
||||
<div className="font-medium">{title}</div>
|
||||
{body.kind === 'approval' && body.detail ? (
|
||||
<p className="line-clamp-3 whitespace-pre-wrap break-words">{body.detail}</p>
|
||||
) : null}
|
||||
{answers.map((answer, 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 ??
|
||||
translate(
|
||||
'components.native-chat.receipt.unavailable',
|
||||
'Selected answer unavailable'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>
|
||||
{resolution.state === 'cancelled'
|
||||
? translate('components.native-chat.receipt.cancelled', 'Cancelled')
|
||||
: translate('components.native-chat.receipt.resolved', 'Resolved')}
|
||||
</span>
|
||||
{resolution.resolvedBy ? (
|
||||
<span>
|
||||
{resolution.state === 'cancelled'
|
||||
? translate('components.native-chat.receipt.cancelledBy', 'Cancelled on {{device}}', {
|
||||
device: resolution.resolvedBy
|
||||
})
|
||||
: translate('components.native-chat.receipt.resolver', 'Answered on {{device}}', {
|
||||
device: resolution.resolvedBy
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
<NativeChatMessageTimestamp timestamp={resolution.resolvedAt} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -192,6 +192,7 @@ export function NativeChatStructuredSession(
|
||||
) : (
|
||||
<NativeChatMessageList
|
||||
session={session}
|
||||
journalItems={controller.journalItems}
|
||||
isWorking={controller.isWorking}
|
||||
expandSignal={false}
|
||||
fontScale={fontScale.scale}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
NativeChatCommandMetadata,
|
||||
NativeChatSearchResults
|
||||
} from './NativeChatToolAnnotations'
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { Check, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -17,12 +17,8 @@ import {
|
||||
import { isRenderableSubagentGroup } from '../../../../shared/native-chat-subagent-summary'
|
||||
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
|
||||
import { NativeChatDiffCard } from './NativeChatDiffCard'
|
||||
import { pairToolBlocks } from './native-chat-tool-fold'
|
||||
import {
|
||||
editFilesFromToolPair,
|
||||
isEditToolName
|
||||
} from '../../../../shared/native-chat-edit-normalize'
|
||||
import type { NativeChatEditFile } from '../../../../shared/native-chat-edit-model'
|
||||
import type { NativeChatDiffReveal } from './native-chat-turn-diffs'
|
||||
import { buildEditCards, NO_EDIT_CARDS } from './native-chat-edit-cards'
|
||||
import {
|
||||
countToolCalls,
|
||||
createToolInputDisplay,
|
||||
@@ -157,55 +153,13 @@ function ToolLine({
|
||||
)
|
||||
}
|
||||
|
||||
type EditCardModel = {
|
||||
editCards: Map<NativeChatBlock, { files: NativeChatEditFile[]; key: string }>
|
||||
/** Result blocks the card already speaks for, so they render no second row. */
|
||||
consumedResults: Set<NativeChatBlock>
|
||||
}
|
||||
|
||||
const NO_EDIT_CARDS: EditCardModel = { editCards: new Map(), consumedResults: new Set() }
|
||||
|
||||
/** 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. */
|
||||
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)) {
|
||||
continue
|
||||
}
|
||||
const files = editFilesFromToolPair({
|
||||
name: call.name,
|
||||
input: call.input,
|
||||
...(call.state ? { state: call.state } : {}),
|
||||
...(pair.result
|
||||
? {
|
||||
result: {
|
||||
output: pair.result.output,
|
||||
isError: pair.result.isError,
|
||||
editPatch: pair.result.editPatch
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
if (!files || files.length === 0) {
|
||||
continue
|
||||
}
|
||||
editCards.set(call, { files, key: `${call.name}:${index}` })
|
||||
if (pair.result) {
|
||||
consumedResults.add(pair.result)
|
||||
}
|
||||
}
|
||||
return { editCards, consumedResults }
|
||||
}
|
||||
|
||||
/** A run of a message's tool calls/results, collapsed to a one-line summary that
|
||||
* expands to the individual inline tool lines. `expandSignal` lets the global
|
||||
* toolbar toggle drive every run at once while still allowing per-run override. */
|
||||
export function NativeChatToolRun({
|
||||
blocks,
|
||||
revealedDiff,
|
||||
onRevealDiff,
|
||||
subagentGroups = NO_SUBAGENT_GROUPS,
|
||||
expandSignal,
|
||||
activeTurnIsWorking,
|
||||
@@ -214,6 +168,8 @@ export function NativeChatToolRun({
|
||||
onLinkClick
|
||||
}: {
|
||||
blocks: NativeChatBlock[]
|
||||
revealedDiff?: NativeChatDiffReveal
|
||||
onRevealDiff?: (element: HTMLElement) => void
|
||||
/** Spawn-group rosters that belong with this run's activity, one row each. */
|
||||
subagentGroups?: NativeChatSubagentGroupBlock[]
|
||||
/** Toolbar-driven desired open state. Each change re-syncs this run's state. */
|
||||
@@ -225,9 +181,23 @@ export function NativeChatToolRun({
|
||||
structuredActivityUi?: boolean
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
}): React.JSX.Element | null {
|
||||
const [open, setOpen] = useState(expandOverride ?? expandSignal)
|
||||
// Re-sync when the global toolbar toggle flips.
|
||||
useEffect(() => setOpen(expandOverride ?? expandSignal), [expandOverride, expandSignal])
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Childless groups are dropped so `subagentRows.length` stays an honest test of
|
||||
// "something will draw": the roster-only branch below returns a margin-bearing
|
||||
@@ -261,8 +231,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
|
||||
// Diffing every edit is the run's most expensive work, so a collapsed run —
|
||||
// which renders none of it — never pays for it.
|
||||
// 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]
|
||||
@@ -300,6 +269,7 @@ export function NativeChatToolRun({
|
||||
if (
|
||||
structuredActivityUi &&
|
||||
expandOverride === false &&
|
||||
!(revealedDiff && open) &&
|
||||
isSettled &&
|
||||
activeTurnIsWorking === false
|
||||
) {
|
||||
@@ -420,6 +390,12 @@ export function NativeChatToolRun({
|
||||
<NativeChatDiffCard
|
||||
key={`${edit.key}:${fileIndex}`}
|
||||
file={file}
|
||||
revealSignal={
|
||||
revealedDiff?.editKey === edit.key && revealedDiff.fileIndex === fileIndex
|
||||
? revealedDiff.requestId
|
||||
: undefined
|
||||
}
|
||||
onReveal={onRevealDiff}
|
||||
initiallyExpanded={expandToolLines}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { DiffLineCounts } from '../right-sidebar/source-control/listing/diff-line-counts'
|
||||
import type { NativeChatDiffTarget, NativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
|
||||
export function NativeChatTurnDiffRollup({
|
||||
diff,
|
||||
onReveal
|
||||
}: {
|
||||
diff: NativeChatTurnDiff
|
||||
onReveal: (target: NativeChatDiffTarget) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Collapsible className="text-xs text-muted-foreground">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="xs" className="group w-full min-w-0 justify-start gap-1.5">
|
||||
<span className="min-w-0 truncate">
|
||||
{diff.files.length === 1
|
||||
? translate('components.native-chat.turnDiff.one', '1 changed file')
|
||||
: translate('components.native-chat.turnDiff.many', '{{count}} changed files', {
|
||||
count: diff.files.length
|
||||
})}
|
||||
</span>
|
||||
<DiffLineCounts added={diff.added} removed={diff.removed} />
|
||||
{diff.truncated ? (
|
||||
<span>{translate('components.native-chat.turnDiff.partial', 'Partial diff')}</span>
|
||||
) : null}
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="ml-auto size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-90 motion-reduce:transition-none"
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1 space-y-1 pl-4">
|
||||
<p className="px-2">
|
||||
{translate(
|
||||
'components.native-chat.turnDiff.recorded',
|
||||
'Totals from recorded edits in this turn.'
|
||||
)}
|
||||
</p>
|
||||
{diff.files.map((file) => (
|
||||
<Button
|
||||
key={file.path}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="flex w-full justify-start gap-2"
|
||||
onClick={() => onReveal(file.target)}
|
||||
>
|
||||
<span className="min-w-0 truncate font-mono" title={file.path}>
|
||||
{file.path}
|
||||
</span>
|
||||
<DiffLineCounts added={file.added} removed={file.removed} />
|
||||
</Button>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
editFilesFromToolPair,
|
||||
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<
|
||||
NativeChatBlock,
|
||||
{
|
||||
result: NativeChatBlock | undefined
|
||||
files: NativeChatEditFile[] | null
|
||||
}
|
||||
>()
|
||||
|
||||
function normalizedEditFiles(
|
||||
call: NativeChatBlock,
|
||||
result: NativeChatBlock | undefined,
|
||||
derive: () => NativeChatEditFile[] | null
|
||||
): NativeChatEditFile[] | null {
|
||||
const cached = normalizedEdits.get(call)
|
||||
if (cached && cached.result === result) {
|
||||
return cached.files
|
||||
}
|
||||
const files = derive()
|
||||
normalizedEdits.set(call, { result, files })
|
||||
return files
|
||||
}
|
||||
|
||||
export type EditCardModel = {
|
||||
editCards: Map<NativeChatBlock, { files: NativeChatEditFile[]; key: string }>
|
||||
/** Result blocks the card already speaks for, so they render no second row. */
|
||||
consumedResults: Set<NativeChatBlock>
|
||||
}
|
||||
|
||||
export const NO_EDIT_CARDS: EditCardModel = { editCards: new Map(), consumedResults: new Set() }
|
||||
|
||||
/** 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[]): 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)) {
|
||||
continue
|
||||
}
|
||||
const files = normalizedEditFiles(call, pair.result, () =>
|
||||
editFilesFromToolPair({
|
||||
name: call.name,
|
||||
input: call.input,
|
||||
...(call.state ? { state: call.state } : {}),
|
||||
...(pair.result
|
||||
? {
|
||||
result: {
|
||||
output: pair.result.output,
|
||||
isError: pair.result.isError,
|
||||
editPatch: pair.result.editPatch
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
)
|
||||
if (!files || files.length === 0) {
|
||||
continue
|
||||
}
|
||||
editCards.set(call, { files, key: `${call.name}:${index}` })
|
||||
if (pair.result) {
|
||||
consumedResults.add(pair.result)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
AgentJournalApprovalItem,
|
||||
AgentJournalQuestionItem
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { decodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer'
|
||||
|
||||
export type NativeChatResolvedPrompt = AgentJournalApprovalItem | AgentJournalQuestionItem
|
||||
export type NativeChatReceiptAnswer = { question: string | null; answer: string | null }
|
||||
|
||||
export function nativeChatReceiptAnswers(
|
||||
body: NativeChatResolvedPrompt
|
||||
): NativeChatReceiptAnswer[] {
|
||||
if (body.resolution.state !== 'resolved') {
|
||||
return []
|
||||
}
|
||||
const selected = body.resolution.selectedOptionId
|
||||
if (body.kind === 'question' && body.questions) {
|
||||
const answers = selected ? decodeAgentSessionQuestionAnswers(selected) : null
|
||||
return body.questions.map((question) => {
|
||||
const answer = answers?.find((entry) => entry.questionId === question.id)
|
||||
const labels = answer?.optionIds.map(
|
||||
(id) => question.options.find((option) => option.id === id)?.label
|
||||
)
|
||||
const valid = labels?.every((label) => label !== undefined)
|
||||
return {
|
||||
question: question.question,
|
||||
answer: valid
|
||||
? [...(labels ?? []), ...(answer?.other ? [answer.other] : [])].join(' · ') || null
|
||||
: null
|
||||
}
|
||||
})
|
||||
}
|
||||
const option = body.options.find((option) => option.id === selected)
|
||||
if (option) {
|
||||
return [{ question: null, answer: option.label }]
|
||||
}
|
||||
if (body.kind === 'question' && body.freeTextQuestionId && selected) {
|
||||
const prefix = `${encodeURIComponent(body.freeTextQuestionId)}:`
|
||||
if (selected.startsWith(prefix)) {
|
||||
try {
|
||||
return [
|
||||
{ question: null, answer: decodeURIComponent(selected.slice(prefix.length)) || null }
|
||||
]
|
||||
} catch {
|
||||
// Malformed persisted answers remain readable as an unavailable selection.
|
||||
}
|
||||
}
|
||||
}
|
||||
return [{ question: null, answer: null }]
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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 { 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 {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
source: 'transcript',
|
||||
timestamp: 1,
|
||||
blocks: [
|
||||
{ type: 'tool-call', name: 'Diff', input: { path } },
|
||||
{ type: 'tool-result', output: patch }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('turn diff rollups', () => {
|
||||
it('counts unique files, sums recorded edits, and targets the last existing card', () => {
|
||||
const messages = foldToolMessages([
|
||||
diff('first', 'a.ts'),
|
||||
diff('second', 'a.ts'),
|
||||
diff('third', 'b.ts')
|
||||
])
|
||||
const turn = nativeChatTurnDiffs(messages, ['turn']).get('turn')!
|
||||
expect(turn.files).toHaveLength(2)
|
||||
expect(turn).toMatchObject({ added: 3, removed: 3, truncated: false })
|
||||
expect(turn.files[0]).toMatchObject({
|
||||
path: 'a.ts',
|
||||
added: 2,
|
||||
target: { messageId: 'first', editKey: 'Diff:1', fileIndex: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
it('folds edits through chained renames into the destination and counts a deletion once', () => {
|
||||
const messages = [
|
||||
diff('edit', 'old.ts'),
|
||||
diff(
|
||||
'rename',
|
||||
'old.ts',
|
||||
'diff --git a/old.ts b/new.ts\nrename from old.ts\nrename to new.ts'
|
||||
),
|
||||
diff(
|
||||
'rename-again',
|
||||
'new.ts',
|
||||
'diff --git a/new.ts b/final.ts\nrename from new.ts\nrename to final.ts'
|
||||
),
|
||||
diff(
|
||||
'delete',
|
||||
'gone.ts',
|
||||
'diff --git a/gone.ts b/gone.ts\ndeleted file mode 100644\n--- a/gone.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-gone'
|
||||
)
|
||||
]
|
||||
const turn = nativeChatTurnDiffs(
|
||||
messages,
|
||||
messages.map(() => 'turn')
|
||||
).get('turn')!
|
||||
expect(turn.files.map((file) => file.path)).toEqual(['final.ts', 'gone.ts'])
|
||||
expect(turn).toMatchObject({ added: 1, removed: 2 })
|
||||
expect(turn.files[0]?.target.messageId).toBe('rename-again')
|
||||
})
|
||||
|
||||
it('keeps turns separate and excludes history without a known boundary', () => {
|
||||
const result = nativeChatTurnDiffs(
|
||||
[diff('orphan', 'orphan.ts'), diff('a', 'a.ts'), diff('b', 'a.ts')],
|
||||
[undefined, 'one', 'two']
|
||||
)
|
||||
expect([...result.keys()]).toEqual(['one', 'two'])
|
||||
expect(result.get('one')?.added).toBe(1)
|
||||
expect(result.get('two')?.files).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses the existing multi-file parser and preserves truncated counts', () => {
|
||||
const patch =
|
||||
'diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-old\n+new\ndiff --git a/b.ts b/b.ts\n--- a/b.ts\n+++ b/b.ts\n@@ -1 +1 @@\n-old\n+new\n… (9999 bytes)'
|
||||
const turn = nativeChatTurnDiffs([diff('multi', 'changes', patch)], ['turn']).get('turn')!
|
||||
expect(turn.files.map((file) => file.path)).toEqual(['a.ts', 'b.ts'])
|
||||
expect(turn).toMatchObject({ added: 2, removed: 2, truncated: true })
|
||||
expect(turn.files[1]?.target.fileIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('does not count generic output, failed edits, running edits, or unparseable patches', () => {
|
||||
const messages = ['shell', 'Edit'].map((name) => ({
|
||||
...diff(name, 'x'),
|
||||
blocks: [
|
||||
{ type: 'tool-call', name, input: { path: 'x' } },
|
||||
{ type: 'tool-result', output: '@@ -1 +1 @@\n-old\n+new' }
|
||||
] as NativeChatBlock[]
|
||||
}))
|
||||
messages.push(diff('invalid', 'x', 'no patch'))
|
||||
for (const state of ['running', 'failed'] as const) {
|
||||
const message = diff(state, 'x')
|
||||
message.blocks[0] = { type: 'tool-call', name: 'Diff', input: { path: 'x' }, state }
|
||||
messages.push(message)
|
||||
}
|
||||
expect(
|
||||
nativeChatTurnDiffs(
|
||||
messages,
|
||||
messages.map(() => 'turn')
|
||||
).size
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
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 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]!,
|
||||
{ type: 'tool-result', output: '@@ -0,0 +1,2 @@\n+one\n+two' }
|
||||
]
|
||||
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)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { buildDiffSummaries } from './native-chat-edit-cards'
|
||||
|
||||
export type NativeChatDiffTarget = {
|
||||
messageId: string
|
||||
editKey: string
|
||||
fileIndex: number
|
||||
}
|
||||
|
||||
export type NativeChatDiffReveal = NativeChatDiffTarget & { requestId: number }
|
||||
|
||||
export type NativeChatTurnDiffFile = {
|
||||
path: string
|
||||
added: number
|
||||
removed: number
|
||||
truncated: boolean
|
||||
target: NativeChatDiffTarget
|
||||
}
|
||||
|
||||
export type NativeChatTurnDiff = {
|
||||
files: NativeChatTurnDiffFile[]
|
||||
added: number
|
||||
removed: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Recorded edit totals, grouped by the transcript's already-resolved turn boundaries. */
|
||||
export function nativeChatTurnDiffs(
|
||||
messages: readonly NativeChatMessage[],
|
||||
turnKeys: readonly (string | undefined)[]
|
||||
): Map<string, NativeChatTurnDiff> {
|
||||
const turns = new Map<string, Map<string, NativeChatTurnDiffFile>>()
|
||||
for (const [index, message] of messages.entries()) {
|
||||
const turnKey = turnKeys[index]
|
||||
if (!turnKey) {
|
||||
continue
|
||||
}
|
||||
for (const edit of buildDiffSummaries(message.blocks).values()) {
|
||||
let files = turns.get(turnKey)
|
||||
if (!files) {
|
||||
files = new Map()
|
||||
turns.set(turnKey, files)
|
||||
}
|
||||
for (const [fileIndex, file] of edit.files.entries()) {
|
||||
const previous = files.get(file.path)
|
||||
const renamed =
|
||||
file.oldPath && file.oldPath !== file.path ? files.get(file.oldPath) : undefined
|
||||
if (renamed) {
|
||||
files.delete(renamed.path)
|
||||
}
|
||||
files.set(file.path, {
|
||||
path: file.path,
|
||||
added: file.added + (previous?.added ?? 0) + (renamed?.added ?? 0),
|
||||
removed: file.removed + (previous?.removed ?? 0) + (renamed?.removed ?? 0),
|
||||
truncated:
|
||||
file.truncated || (previous?.truncated ?? false) || (renamed?.truncated ?? false),
|
||||
target: { messageId: message.id, editKey: edit.key, fileIndex }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Map(
|
||||
Array.from(turns, ([key, byPath]) => {
|
||||
const files = Array.from(byPath.values())
|
||||
return [
|
||||
key,
|
||||
{
|
||||
files,
|
||||
added: files.reduce((sum, file) => sum + file.added, 0),
|
||||
removed: files.reduce((sum, file) => sum + file.removed, 0),
|
||||
truncated: files.some((file) => file.truncated)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -51,17 +51,8 @@ export function useStructuredAgentSession(args: {
|
||||
const { agent, isVisible, sessionId, target } = args
|
||||
// Declared first: the hold is what gives a restored session its provider child back, and the
|
||||
// read below is useless for sending until it lands.
|
||||
useStructuredAgentSessionHold({
|
||||
sessionId,
|
||||
target,
|
||||
surface: 'desktop-chat',
|
||||
enabled: isVisible
|
||||
})
|
||||
const { state, loadingOlder, loadOlder } = useStructuredAgentSessionRead({
|
||||
sessionId,
|
||||
target,
|
||||
isVisible
|
||||
})
|
||||
useStructuredAgentSessionHold({ sessionId, target, surface: 'desktop-chat', enabled: isVisible })
|
||||
const { state, loadingOlder, loadOlder } = useStructuredAgentSessionRead(args)
|
||||
const stateRef = useRef(state)
|
||||
const [writeError, setWriteError] = useState<string | null>(null)
|
||||
const operationIds = useRef(new Map<string, string>())
|
||||
@@ -267,6 +258,7 @@ export function useStructuredAgentSession(args: {
|
||||
{ command }
|
||||
)
|
||||
}),
|
||||
journalItems: state.items,
|
||||
messages,
|
||||
status: state.status,
|
||||
error: state.error ?? writeError ?? outboxController.error,
|
||||
|
||||
@@ -16994,6 +16994,19 @@
|
||||
"empty": "No users found"
|
||||
},
|
||||
"native-chat": {
|
||||
"turnDiff": {
|
||||
"one": "1 changed file",
|
||||
"many": "{{count}} changed files",
|
||||
"partial": "Partial diff",
|
||||
"recorded": "Totals from recorded edits in this turn."
|
||||
},
|
||||
"receipt": {
|
||||
"unavailable": "Selected answer unavailable",
|
||||
"cancelled": "Cancelled",
|
||||
"resolved": "Resolved",
|
||||
"resolver": "Answered on {{device}}",
|
||||
"cancelledBy": "Cancelled on {{device}}"
|
||||
},
|
||||
"notices": {
|
||||
"compaction": "Context compacted",
|
||||
"details": "Details",
|
||||
|
||||
@@ -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,65 @@ 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?',
|
||||
detail: null,
|
||||
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',
|
||||
|
||||
@@ -121,29 +121,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