Merge pull request #1418 from NanmiCoder/fix/integrate-1363-1401

fix: preserve history completeness and recover failed inline images
This commit is contained in:
程序员阿江-Relakkes
2026-09-30 17:50:19 +08:00
committed by GitHub
9 changed files with 240 additions and 5 deletions
@@ -21,6 +21,61 @@ function imgSrcs(): string[] {
}
describe('InlineImageGallery', () => {
it('shows a failed image notice and filename instead of hiding the gallery entry', () => {
render(<InlineImageGallery text="See E:/test/denied.png" />)
fireEvent.error(screen.getByRole('img'))
const notice = screen.getByRole('alert')
expect(notice).toBeVisible()
expect(notice).toHaveTextContent('Unable to load image')
expect(notice).toHaveTextContent('denied.png')
expect(notice).toHaveTextContent('The file may be missing or access may be denied.')
expect(screen.getByRole('button', { name: 'Retry' })).toBeVisible()
})
it('keeps other images usable and tracks failures by source when the list changes', () => {
const { rerender } = render(<InlineImageGallery text="See /tmp/denied.png and /tmp/allowed.png" />)
fireEvent.error(screen.getByRole('img', { name: 'denied.png' }))
expect(screen.getByRole('img', { name: 'allowed.png' })).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: /allowed.png/ }))
expect(screen.getByRole('dialog')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
rerender(<InlineImageGallery text="See /tmp/new.png and /tmp/denied.png" />)
expect(screen.getByRole('img', { name: 'new.png' })).toBeVisible()
expect(screen.getByRole('alert')).toHaveTextContent('denied.png')
expect(screen.queryByRole('img', { name: 'denied.png' })).not.toBeInTheDocument()
})
it('retries the same protected URL and keeps feedback if the retry fails', () => {
render(<InlineImageGallery text="See /tmp/denied.png" />)
const source = screen.getByRole('img').getAttribute('src')
fireEvent.error(screen.getByRole('img'))
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute('src', source)
fireEvent.error(screen.getByRole('img'))
expect(screen.getByRole('alert')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
fireEvent.load(screen.getByRole('img'))
expect(screen.getByRole('img')).toBeVisible()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it.each([
{ sessionId: 'new-session', workDir: '/tmp/old' },
{ sessionId: 'old-session', workDir: '/tmp/new' },
])('clears a failed absolute source when context changes to %j', (context) => {
const { rerender } = render(<InlineImageGallery text="See /tmp/denied.png" sessionId="old-session" workDir="/tmp/old" />)
const source = screen.getByRole('img').getAttribute('src')
fireEvent.error(screen.getByRole('img'))
rerender(<InlineImageGallery text="See /tmp/denied.png" {...context} />)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute('src', source)
})
it('suppresses host-managed ImageGen paths when their dedicated card owns the image', () => {
render(
<InlineImageGallery
@@ -1,4 +1,6 @@
import { useMemo, useState } from 'react'
import { ErrorState } from '@/components/ui/ErrorState'
import { useTranslation } from '@/i18n'
import { ImageGalleryModal } from './ImageGalleryModal'
import { isManagedGeneratedImagePath, localImageFileUrl } from '../../lib/attachmentImages'
import {
@@ -71,7 +73,14 @@ type Props = {
}
export function InlineImageGallery({ text, sessionId, workDir, changedFiles, suppressManagedGeneratedImages = false }: Props) {
const t = useTranslation()
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const [failureState, setFailureState] = useState(() => ({ sessionId, workDir, sources: new Set<string>() }))
// The same absolute URL can become readable in a different workspace/session.
if (failureState.sessionId !== sessionId || failureState.workDir !== workDir) {
setFailureState({ sessionId, workDir, sources: new Set() })
}
const failedSources = failureState.sources
const markdownImageSources = useMemo(
() => new Set(extractMarkdownImageSources(text).map(normalizeImageReference)),
@@ -155,7 +164,25 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
{images.length === 1 ? '1 image' : `${images.length} images`}
</div>
<div className={`grid gap-2 ${images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
{images.map((img, i) => (
{images.map((img, i) => failedSources.has(img.src) ? (
<ErrorState
key={img.src}
size="sm"
title={t('chat.imageLoadFailed')}
retryLabel={t('common.retry')}
onRetry={() => setFailureState((previous) => {
const sources = new Set(previous.sources)
sources.delete(img.src)
return { ...previous, sources }
})}
detail={(
<>
<span className="block break-all">{img.name}</span>
{t('chat.imageLoadFailedHint')}
</>
)}
/>
) : (
<button
key={img.src}
type="button"
@@ -168,9 +195,10 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
loading="lazy"
className="w-full object-cover"
style={{ maxHeight: images.length === 1 ? 400 : 240 }}
onError={(e) => {
// Hide broken images
(e.target as HTMLImageElement).closest('button')!.style.display = 'none'
onError={() => {
// img errors expose no HTTP status: a denied, missing or invalid
// image needs visible feedback without claiming a specific cause.
setFailureState((previous) => ({ ...previous, sources: new Set(previous.sources).add(img.src) }))
}}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all group-hover/image:bg-black/20 group-hover/image:opacity-100">
+2
View File
@@ -2619,6 +2619,8 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
'chat.contextReferencesOnly': 'Added {count} references',
'chat.addSelectionToChat': 'Add to chat',
'chat.imageLoadFailed': 'Unable to load image',
'chat.imageLoadFailedHint': 'The file may be missing or access may be denied.',
'chat.branchFromHere': 'Fork a new conversation',
'chat.branchSuccess': 'Created forked conversation "{title}".',
'chat.branchError': 'Failed to branch from this message. Detail: {detail}',
+2
View File
@@ -2620,6 +2620,8 @@ export const jp: Record<TranslationKey, string> = {
'chat.workspaceReferencesOnly': 'ワークスペース参照を {count} 件追加しました',
'chat.contextReferencesOnly': '参照を {count} 件追加しました',
'chat.addSelectionToChat': 'チャットに追加',
'chat.imageLoadFailed': '画像を読み込めません',
'chat.imageLoadFailedHint': 'ファイルが存在しないか、アクセスが許可されていない可能性があります。',
'chat.branchFromHere': '新しい会話を分岐',
'chat.branchSuccess': '分岐した会話「{title}」を作成しました。',
'chat.branchError': 'このメッセージから分岐できませんでした。詳細: {detail}',
+2
View File
@@ -2622,6 +2622,8 @@ export const kr: Record<TranslationKey, string> = {
'chat.workspaceReferencesOnly': '작업 공간 참조 {count}개를 추가했습니다',
'chat.contextReferencesOnly': '참조 {count}개를 추가했습니다',
'chat.addSelectionToChat': '채팅에 추가',
'chat.imageLoadFailed': '이미지를 불러올 수 없습니다',
'chat.imageLoadFailedHint': '파일이 없거나 접근 권한이 없을 수 있습니다.',
'chat.branchFromHere': '새 대화 분기',
'chat.branchSuccess': '분기된 대화 "{title}"을(를) 만들었습니다.',
'chat.branchError': '이 메시지에서 분기할 수 없습니다. 세부 정보: {detail}',
+2
View File
@@ -2619,6 +2619,8 @@ export const zh: Record<TranslationKey, string> = {
'chat.workspaceReferencesOnly': '已新增 {count} 個工作區引用',
'chat.contextReferencesOnly': '已新增 {count} 個引用',
'chat.addSelectionToChat': '新增到對話',
'chat.imageLoadFailed': '無法載入圖片',
'chat.imageLoadFailedHint': '檔案可能不存在,或沒有存取權限。',
'chat.branchFromHere': 'Fork 一個新對話',
'chat.branchSuccess': '已 Fork 新對話“{title}”。',
'chat.branchError': '從該訊息 Fork 新對話失敗。詳情:{detail}',
+2
View File
@@ -2618,6 +2618,8 @@ export const zh: Record<TranslationKey, string> = {
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
'chat.contextReferencesOnly': '已添加 {count} 个引用',
'chat.addSelectionToChat': '添加到对话',
'chat.imageLoadFailed': '无法加载图片',
'chat.imageLoadFailedHint': '文件可能不存在,或没有访问权限。',
'chat.branchFromHere': 'Fork 一个新对话',
'chat.branchSuccess': '已 Fork 新对话“{title}”。',
'chat.branchError': '从该消息 Fork 新对话失败。详情:{detail}',
@@ -161,3 +161,130 @@ test('a multi-megabyte foreground tool output preserves complete paged messages
expect(recovery.omittedRecords).toBe(0)
expect((await service.getSessionLaunchInfo(id))?.transcriptMessageCount).toBe(160)
})
test('activity overflow does not invalidate complete workspace evidence', async () => {
const activity = Array.from({ length: 2050 }, (_, index) => entry('assistant', `agent-${index}`, [
{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: { description: 'activity only', prompt: 'inspect' } },
]))
await writeFile(file, [...activity,
entry('assistant', 'write', [{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: '/tmp/file', content: 'complete workspace evidence' } }]),
].map(value => JSON.stringify(value)).join('\n') + '\n')
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.status).toBe('incomplete')
expect(recovery.completeness?.activity).toBe(false)
expect(recovery.completeness?.workspace).toBe(true)
expect(recovery.messages).toHaveLength(2048)
expect(recovery.messages.some(message => message.id === 'write')).toBe(true)
})
test('workspace evidence discarded by the shared record budget remains incomplete', async () => {
const activity = Array.from({ length: 2050 }, (_, index) => entry('assistant', `agent-${index}`, [
{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: {} },
]))
await writeFile(file, [
entry('assistant', 'write', [{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: '/tmp/file', content: 'must not be silently lost' } }]),
...activity,
].map(value => JSON.stringify(value)).join('\n') + '\n')
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.completeness?.activity).toBe(false)
expect(recovery.completeness?.workspace).toBe(false)
expect(recovery.messages).toHaveLength(2048)
expect(recovery.messages.some(message => message.id === 'write')).toBe(false)
})
test('the byte budget marks only discarded activity incomplete', async () => {
await writeFile(file, Array.from({ length: 800 }, (_, index) => JSON.stringify(entry('assistant', `agent-${index}`, [
{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: { description: 'x'.repeat(4096) } },
]))).join('\n') + '\n')
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.completeness?.activity).toBe(false)
expect(recovery.completeness?.workspace).toBe(true)
expect(recovery.messages.length).toBeLessThan(800)
expect(recovery.messages.reduce((bytes, message) => bytes + Buffer.byteLength(JSON.stringify(message)), 0)).toBeLessThanOrEqual(3 * 1024 * 1024)
})
test('workspace-only overflow does not mark activity incomplete', async () => {
await writeFile(file, Array.from({ length: 2050 }, (_, index) => JSON.stringify(entry('assistant', `write-${index}`, [
{ type: 'tool_use', id: `write-${index}`, name: 'Write', input: { file_path: '/tmp/file', content: 'bounded' } },
]))).join('\n') + '\n')
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.completeness?.workspace).toBe(false)
expect(recovery.completeness?.activity).toBe(true)
expect(recovery.messages).toHaveLength(2048)
})
for (const order of ['write-agent', 'agent-write'] as const) {
for (const overflow of [false, true]) {
test(`mixed ${order} replacement stays incomplete (overflow=${overflow})`, async () => {
const write = { type: 'tool_use', id: 'mixed-write', name: 'Write', input: { file_path: '/tmp/file', content: 'must not disappear' } }
const agent = { type: 'tool_use', id: 'mixed-agent', name: 'Agent', input: { description: 'inspect' } }
const tail = overflow ? Array.from({ length: 2050 }, (_, index) => entry('assistant', `agent-${index}`, [
{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: {} },
])) : []
await writeFile(file, [
entry('assistant', 'mixed', order === 'write-agent' ? [write, agent] : [agent, write]),
...tail,
].map(value => JSON.stringify(value)).join('\n') + '\n')
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.status).toBe('incomplete')
expect(recovery.completeness?.workspace).toBe(false)
expect(recovery.omittedRecords).toBeGreaterThan(0)
})
}
}
test('activity-only discarded rows do not reset an earlier oversized workspace failure', async () => {
await writeFile(file, [
entry('assistant', 'large-write', [{ type: 'tool_use', id: 'large-write', name: 'Write', input: { file_path: '/tmp/file', content: 'x'.repeat(128 * 1024) } }]),
...Array.from({ length: 2050 }, (_, index) => entry('assistant', `agent-${index}`, [
{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: {} },
])),
].map(value => JSON.stringify(value)).join('\n') + '\n')
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.completeness?.workspace).toBe(false)
expect(recovery.completeness?.activity).toBe(false)
})
for (const tailCount of [2045, 2046]) {
test(`equal-ordinal workspace and background evidence is conservative at the cap (${tailCount})`, async () => {
await writeFile(file, [
entry('assistant', 'mixed', [
{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: '/tmp/file', content: 'retained' } },
{ type: 'tool_use', id: 'shell', name: 'Bash', input: { command: 'sleep 1', run_in_background: true } },
]),
entry('user', 'result', [{ type: 'tool_result', tool_use_id: 'shell', content: 'background' }], { toolUseResult: { backgroundTaskId: 'task' } }),
...Array.from({ length: tailCount }, (_, index) => entry('assistant', `agent-${index}`, [
{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: {} },
])),
].map(value => JSON.stringify(value)).join('\n') + '\n')
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.completeness?.workspace).toBe(tailCount === 2045)
expect(recovery.messages).toHaveLength(2048)
if (tailCount === 2045) {
expect(recovery.messages.some(message => message.id === 'mixed')).toBe(true)
expect(recovery.messages.some(message => message.id === 'mixed:background:shell')).toBe(true)
}
})
}
for (const order of ['shell-write', 'write-shell'] as const) {
test(`mixed tool results preserve lost activity across workspace overflow (${order})`, async () => {
const shellResult = { type: 'tool_result', tool_use_id: 'shell', content: 'background' }
const writeResult = { type: 'tool_result', tool_use_id: 'write', content: 'written' }
await writeFile(file, [
...Array.from({ length: 2050 }, (_, index) => entry('assistant', `old-write-${index}`, [
{ type: 'tool_use', id: `old-write-${index}`, name: 'Write', input: { file_path: '/tmp/file', content: 'old' } },
])),
entry('assistant', 'calls', [
{ type: 'tool_use', id: 'shell', name: 'Bash', input: { command: 'sleep 1', run_in_background: true } },
{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: '/tmp/file', content: 'latest' } },
]),
entry('user', 'results', order === 'shell-write' ? [shellResult, writeResult] : [writeResult, shellResult], { toolUseResult: { backgroundTaskId: 'task' } }),
].map(value => JSON.stringify(value)).join('\n') + '\n')
const recovery = await service.getSessionHistoryRecovery(id)
expect(recovery.completeness?.workspace).toBe(false)
expect(recovery.completeness?.activity).toBe(false)
expect(recovery.messages.some(message => message.id === 'results')).toBe(true)
expect(recovery.messages).toHaveLength(2048)
})
}
+16 -1
View File
@@ -47,6 +47,7 @@ export async function recoverBoundedSessionHistory(options: {
const saveTool = database.query('INSERT OR REPLACE INTO tools VALUES (?, ?, ?, ?, ?)')
const getTool = database.query('SELECT ordinal, json, legacy, name FROM tools WHERE id = ?')
const saveEvidence = database.query('INSERT OR REPLACE INTO evidence VALUES (?, ?, ?, ?)')
const getEvidenceCategory = database.query('SELECT category FROM evidence WHERE id = ?')
const saveNotice = database.query('INSERT OR REPLACE INTO notices VALUES (?, ?, ?)')
const teammate = database.query('INSERT OR IGNORE INTO teammates VALUES (?)')
let ordinal = 0
@@ -61,6 +62,13 @@ export async function recoverBoundedSessionHistory(options: {
const saveActivity = (evidence: Evidence, category: 'activity' | 'workspace' = 'activity') => {
const json = JSON.stringify(evidence.message)
if (Buffer.byteLength(json) > STATE_RECORD_BYTES) { omitted++; completeness[category] = false; return }
const previous = getEvidenceCategory.get(evidence.message.id) as { category: 'activity' | 'workspace' } | null
if (previous && previous.category !== category) {
// A mixed message can produce multiple projections with the same id.
// Replacing one loses evidence even when the surviving row fits the budget.
omitted++
completeness[previous.category] = false
}
saveEvidence.run(evidence.message.id, evidence.ordinal, json, category)
}
const scan = await streamBoundedHistory(options.filePath, (entry) => {
@@ -179,7 +187,14 @@ export async function recoverBoundedSessionHistory(options: {
for (const row of database.query('SELECT ordinal, json, category FROM evidence ORDER BY ordinal DESC').iterate() as Iterable<{ ordinal: number; json: string; category: 'activity' | 'workspace' }>) {
if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError')
bytes += Buffer.byteLength(row.json)
if (bytes > RECOVERY_BYTES || messages.size >= 2048) { omitted++; completeness.activity = false; completeness.workspace = false; break }
if (bytes > RECOVERY_BYTES || messages.size >= 2048) {
omitted++
// Only categories present in the discarded suffix are incomplete. A
// long activity history must not invalidate fully retained file changes.
const discarded = database.query('SELECT DISTINCT category FROM evidence WHERE ordinal <= ?').all(row.ordinal) as Array<{ category: 'activity' | 'workspace' }>
for (const { category } of discarded) completeness[category] = false
break
}
const message = JSON.parse(row.json)
messages.set(message.id, { ordinal: row.ordinal, message })
if (messages.size % 64 === 0) await new Promise<void>(resolve => setImmediate(resolve))