mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix: restore per-turn changed file cards
The chat timeline lost per-turn changed file cards when the visible user message ids did not match persisted transcript ids, and sessions without SDK file-history snapshots had no fallback source for turn changes. Match live cards by stable user-message index when needed and derive checkpoint previews from transcript tool calls when snapshots are absent. Constraint: Right-side workspace changes already derive from transcript tool calls, so chat checkpoint data must remain consistent with that source. Rejected: Force a history reload after every turn | would hide the id mismatch but add churn and still not cover transcript-only sessions. Confidence: high Scope-risk: moderate Directive: Keep snapshot checkpoints as the preferred source, and use transcript extraction only as the compatibility fallback. Tested: bun test src/server/__tests__/sessions.test.ts Tested: bun test src/server/__tests__/workspace-service.test.ts Tested: cd desktop && bun run test src/components/chat/MessageList.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run test Tested: git diff --check Not-tested: Browser E2E against a live model session.
This commit is contained in:
parent
f5cf5932b4
commit
103a6cc42b
@ -299,9 +299,17 @@ export const sessionsApi = {
|
||||
return api.get<SessionTurnCheckpointsResponse>(`/api/sessions/${sessionId}/turn-checkpoints`)
|
||||
},
|
||||
|
||||
getTurnCheckpointDiff(sessionId: string, targetUserMessageId: string, workspacePath: string) {
|
||||
getTurnCheckpointDiff(
|
||||
sessionId: string,
|
||||
targetUserMessageId: string,
|
||||
workspacePath: string,
|
||||
userMessageIndex?: number,
|
||||
) {
|
||||
const query = new URLSearchParams()
|
||||
query.set('targetUserMessageId', targetUserMessageId)
|
||||
if (Number.isInteger(userMessageIndex)) {
|
||||
query.set('userMessageIndex', String(userMessageIndex))
|
||||
}
|
||||
query.set('path', workspacePath)
|
||||
return api.get<TurnCheckpointDiffResult>(
|
||||
`/api/sessions/${sessionId}/turn-checkpoints/diff?${query.toString()}`,
|
||||
|
||||
@ -60,7 +60,12 @@ export function CurrentTurnChangeCard({
|
||||
}))
|
||||
|
||||
void sessionsApi
|
||||
.getTurnCheckpointDiff(sessionId, targetUserMessageId, fileEntry.apiPath)
|
||||
.getTurnCheckpointDiff(
|
||||
sessionId,
|
||||
targetUserMessageId,
|
||||
fileEntry.apiPath,
|
||||
checkpoint.target.userMessageIndex,
|
||||
)
|
||||
.then((result) => {
|
||||
setDiffByPath((current) => ({
|
||||
...current,
|
||||
|
||||
@ -838,6 +838,7 @@ describe('MessageList nested tool calls', () => {
|
||||
ACTIVE_TAB,
|
||||
'user-1',
|
||||
'src/first.ts',
|
||||
0,
|
||||
)
|
||||
expect(sessionsApi.getWorkspaceDiff).not.toHaveBeenCalled()
|
||||
})
|
||||
@ -905,6 +906,65 @@ describe('MessageList nested tool calls', () => {
|
||||
ACTIVE_TAB,
|
||||
'user-1',
|
||||
'/tmp/old-project/src/first.ts',
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
it('matches live turn change checkpoints by user message index when transcript ids differ from local UI ids', async () => {
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
|
||||
checkpoints: [
|
||||
{
|
||||
target: {
|
||||
targetUserMessageId: 'transcript-user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 1,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/live.ts'],
|
||||
insertions: 7,
|
||||
deletions: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getTurnCheckpointDiff').mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: 'src/live.ts',
|
||||
diff: 'diff --session a/src/live.ts b/src/live.ts\n+live',
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'local-user-temp-id',
|
||||
type: 'user_text',
|
||||
content: '实时这一轮',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'done',
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
expect(await screen.findByText('src/live.ts')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show diff for src/live.ts' }))
|
||||
await screen.findByTestId('workspace-code')
|
||||
expect(sessionsApi.getTurnCheckpointDiff).toHaveBeenCalledWith(
|
||||
ACTIVE_TAB,
|
||||
'transcript-user-1',
|
||||
'src/live.ts',
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -338,10 +338,15 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
const targetByMessageId = new Map(
|
||||
completedTurnTargets.map((target) => [target.messageId, target] as const),
|
||||
)
|
||||
const targetByUserMessageIndex = new Map(
|
||||
completedTurnTargets.map((target) => [target.userMessageIndex, target] as const),
|
||||
)
|
||||
|
||||
setTurnChangeCards(
|
||||
normalizeTurnCheckpoints(checkpointResponse).flatMap((checkpoint) => {
|
||||
const target = targetByMessageId.get(checkpoint.target.targetUserMessageId)
|
||||
const target =
|
||||
targetByMessageId.get(checkpoint.target.targetUserMessageId) ??
|
||||
targetByUserMessageIndex.get(checkpoint.target.userMessageIndex)
|
||||
if (!target || !checkpoint.code.available || checkpoint.code.filesChanged.length === 0) {
|
||||
return []
|
||||
}
|
||||
@ -475,7 +480,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
|
||||
<CurrentTurnChangeCard
|
||||
key={`turn-change-${card.target.messageId}`}
|
||||
sessionId={resolvedSessionId}
|
||||
targetUserMessageId={card.target.messageId}
|
||||
targetUserMessageId={card.checkpoint.target.targetUserMessageId}
|
||||
checkpoint={card.checkpoint}
|
||||
workDir={card.workDir}
|
||||
error={turnActionErrors[card.target.messageId] ?? null}
|
||||
|
||||
@ -2099,6 +2099,194 @@ describe('Sessions API', () => {
|
||||
expect(createdFileBody.diff).toContain('/dev/null')
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/turn-checkpoints should fall back to transcript tool changes when file snapshots are missing', async () => {
|
||||
const sessionId = '99999999-bbbb-cccc-dddd-000000000001'
|
||||
const workDir = path.join(tmpDir, 'transcript-only-session')
|
||||
const userId = crypto.randomUUID()
|
||||
await fs.mkdir(path.join(workDir, 'todo-app', 'src'), { recursive: true })
|
||||
|
||||
await writeSessionFile('-tmp-transcript-only-session', sessionId, [
|
||||
makeSessionMetaEntry(workDir),
|
||||
{
|
||||
...makeUserEntry('build a todo app', userId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantToolUseEntry([
|
||||
{
|
||||
id: 'Write:1',
|
||||
name: 'Write',
|
||||
input: {
|
||||
file_path: path.join(workDir, 'todo-app', 'src', 'App.tsx'),
|
||||
content: 'export function App() {\n return <main>Todo</main>\n}\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Write:2',
|
||||
name: 'Write',
|
||||
input: {
|
||||
file_path: 'todo-app/vite.config.ts',
|
||||
content: 'import { defineConfig } from "vite"\nexport default defineConfig({})\n',
|
||||
},
|
||||
},
|
||||
], userId),
|
||||
makeAssistantEntry('Todo app created', userId),
|
||||
])
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {
|
||||
checkpoints: Array<{
|
||||
target: { targetUserMessageId: string }
|
||||
code: {
|
||||
available: boolean
|
||||
filesChanged: string[]
|
||||
insertions: number
|
||||
deletions: number
|
||||
}
|
||||
workDir: string
|
||||
}>
|
||||
}
|
||||
|
||||
expect(body.checkpoints).toHaveLength(1)
|
||||
expect(body.checkpoints[0]!.target.targetUserMessageId).toBe(userId)
|
||||
expect(body.checkpoints[0]!.workDir).toBe(workDir)
|
||||
expect(body.checkpoints[0]!.code.available).toBe(true)
|
||||
expect(body.checkpoints[0]!.code.filesChanged.sort()).toEqual([
|
||||
path.join(workDir, 'todo-app', 'src', 'App.tsx'),
|
||||
path.join(workDir, 'todo-app', 'vite.config.ts'),
|
||||
].sort())
|
||||
expect(body.checkpoints[0]!.code.insertions).toBe(5)
|
||||
expect(body.checkpoints[0]!.code.deletions).toBe(0)
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/turn-checkpoints/diff should return transcript tool diffs when file snapshots are missing', async () => {
|
||||
const sessionId = '99999999-bbbb-cccc-dddd-000000000002'
|
||||
const workDir = path.join(tmpDir, 'transcript-only-diff-session')
|
||||
const userId = crypto.randomUUID()
|
||||
|
||||
await writeSessionFile('-tmp-transcript-only-diff-session', sessionId, [
|
||||
makeSessionMetaEntry(workDir),
|
||||
{
|
||||
...makeUserEntry('edit config', userId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantToolUseEntry([
|
||||
{
|
||||
id: 'Edit:1',
|
||||
name: 'Edit',
|
||||
input: {
|
||||
file_path: path.join(workDir, 'todo-app', 'vite.config.ts'),
|
||||
old_string: 'plugins: [react()]',
|
||||
new_string: 'plugins: [react(), tailwindcss()]',
|
||||
},
|
||||
},
|
||||
], userId),
|
||||
makeAssistantEntry('Config updated', userId),
|
||||
])
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints/diff?targetUserMessageId=${userId}&path=${encodeURIComponent('todo-app/vite.config.ts')}`,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {
|
||||
state: string
|
||||
path: string
|
||||
diff?: string
|
||||
target: { targetUserMessageId: string }
|
||||
}
|
||||
|
||||
expect(body.target.targetUserMessageId).toBe(userId)
|
||||
expect(body.state).toBe('ok')
|
||||
expect(body.path).toBe('todo-app/vite.config.ts')
|
||||
expect(body.diff).toContain('diff --session a/todo-app/vite.config.ts b/todo-app/vite.config.ts')
|
||||
expect(body.diff).toContain('-plugins: [react()]')
|
||||
expect(body.diff).toContain('+plugins: [react(), tailwindcss()]')
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/turn-checkpoints should include subagent transcript file changes for the parent turn', async () => {
|
||||
const sessionId = '99999999-bbbb-cccc-dddd-000000000003'
|
||||
const workDir = path.join(tmpDir, 'transcript-subagent-session')
|
||||
const firstUserId = crypto.randomUUID()
|
||||
const secondUserId = crypto.randomUUID()
|
||||
const agentMessageId = crypto.randomUUID()
|
||||
await fs.mkdir(path.join(workDir, 'todo-app', 'src'), { recursive: true })
|
||||
|
||||
await writeSessionFile('-tmp-transcript-subagent-session', sessionId, [
|
||||
makeSessionMetaEntry(workDir),
|
||||
{
|
||||
...makeUserEntry('build a todo app', firstUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
parentUuid: firstUserId,
|
||||
isSidechain: false,
|
||||
type: 'assistant',
|
||||
message: {
|
||||
model: 'claude-opus-4-7',
|
||||
id: `msg_${crypto.randomUUID().slice(0, 20)}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: 'Agent:todo',
|
||||
name: 'Agent',
|
||||
input: { description: 'Create todo app files' },
|
||||
}],
|
||||
},
|
||||
uuid: agentMessageId,
|
||||
timestamp: '2026-01-01T00:02:00.000Z',
|
||||
},
|
||||
{
|
||||
...makeUserEntry('now explain it', secondUserId),
|
||||
parentUuid: agentMessageId,
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
parentUuid: agentMessageId,
|
||||
isSidechain: true,
|
||||
type: 'assistant',
|
||||
message: {
|
||||
model: 'claude-opus-4-7',
|
||||
id: `msg_${crypto.randomUUID().slice(0, 20)}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: 'Write:child',
|
||||
name: 'Write',
|
||||
input: {
|
||||
file_path: path.join(workDir, 'todo-app', 'src', 'Board.tsx'),
|
||||
content: 'export function Board() {\n return null\n}\n',
|
||||
},
|
||||
}],
|
||||
},
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:03:00.000Z',
|
||||
},
|
||||
])
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {
|
||||
checkpoints: Array<{
|
||||
target: { targetUserMessageId: string }
|
||||
code: { filesChanged: string[]; insertions: number; deletions: number }
|
||||
}>
|
||||
}
|
||||
|
||||
expect(body.checkpoints).toHaveLength(1)
|
||||
expect(body.checkpoints[0]!.target.targetUserMessageId).toBe(firstUserId)
|
||||
expect(body.checkpoints[0]!.code.filesChanged).toEqual([
|
||||
path.join(workDir, 'todo-app', 'src', 'Board.tsx'),
|
||||
])
|
||||
expect(body.checkpoints[0]!.code.insertions).toBe(3)
|
||||
expect(body.checkpoints[0]!.code.deletions).toBe(0)
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should restore the base state when rewinding the first turn of a three-turn file history', async () => {
|
||||
const fixture = await createThreeTurnCheckpointFixture(
|
||||
'aaaaaaaa-1111-2222-3333-444444444444',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { UUID } from 'crypto'
|
||||
import { chmod, copyFile, mkdir, readFile, stat, unlink } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative } from 'node:path'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { createTwoFilesPatch, diffLines } from 'diff'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import {
|
||||
@ -8,7 +8,7 @@ import {
|
||||
} from '../../utils/fileHistory.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { conversationService } from './conversationService.js'
|
||||
import { sessionService } from './sessionService.js'
|
||||
import { sessionService, type MessageEntry } from './sessionService.js'
|
||||
|
||||
type RewindTarget = {
|
||||
targetUserMessageId: string
|
||||
@ -25,6 +25,14 @@ type RewindCodePreview = {
|
||||
deletions: number
|
||||
}
|
||||
|
||||
type TranscriptFileChange = {
|
||||
path: string
|
||||
absolutePath: string
|
||||
additions: number
|
||||
deletions: number
|
||||
diff?: string
|
||||
}
|
||||
|
||||
export type RewindTargetSelector = {
|
||||
targetUserMessageId?: string
|
||||
userMessageIndex?: number
|
||||
@ -409,6 +417,277 @@ function getNextUserMessageId(
|
||||
return userMessages[userMessageIndex + 1]?.id ?? null
|
||||
}
|
||||
|
||||
function isWithinBaseDir(absolutePath: string, baseDir: string): boolean {
|
||||
const relativePath = relative(baseDir, absolutePath)
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
|
||||
}
|
||||
|
||||
function normalizeTranscriptRelativePath(filePath: string): string {
|
||||
return normalizeComparablePath(filePath).replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
function resolveTranscriptToolPath(
|
||||
filePath: unknown,
|
||||
baseDir: string,
|
||||
): { path: string; absolutePath: string } | null {
|
||||
if (typeof filePath !== 'string' || !filePath.trim()) return null
|
||||
const normalizedBaseDir = resolve(baseDir)
|
||||
const absolutePath = isAbsolute(filePath)
|
||||
? resolve(filePath)
|
||||
: resolve(normalizedBaseDir, filePath)
|
||||
if (!isWithinBaseDir(absolutePath, normalizedBaseDir)) return null
|
||||
|
||||
return {
|
||||
path: normalizeTranscriptRelativePath(relative(normalizedBaseDir, absolutePath)),
|
||||
absolutePath,
|
||||
}
|
||||
}
|
||||
|
||||
function countTranscriptLines(content: string): number {
|
||||
if (!content) return 0
|
||||
const lines = content.split(/\r\n|\r|\n/)
|
||||
if (lines[lines.length - 1] === '') {
|
||||
lines.pop()
|
||||
}
|
||||
return lines.length
|
||||
}
|
||||
|
||||
function buildTranscriptDiff(
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
): string {
|
||||
const oldLines = oldContent ? oldContent.split('\n') : []
|
||||
const newLines = newContent ? newContent.split('\n') : []
|
||||
if (oldLines.at(-1) === '') oldLines.pop()
|
||||
if (newLines.at(-1) === '') newLines.pop()
|
||||
|
||||
return [
|
||||
`diff --session a/${oldPath} b/${newPath}`,
|
||||
`--- ${oldPath === '/dev/null' ? '/dev/null' : `a/${oldPath}`}`,
|
||||
`+++ b/${newPath}`,
|
||||
`@@ -1,${oldLines.length} +1,${newLines.length} @@`,
|
||||
...oldLines.map((line) => `-${line}`),
|
||||
...newLines.map((line) => `+${line}`),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildTranscriptEditChange(
|
||||
filePath: { path: string; absolutePath: string },
|
||||
input: Record<string, unknown>,
|
||||
): TranscriptFileChange {
|
||||
const oldString = typeof input.old_string === 'string' ? input.old_string : ''
|
||||
const newString = typeof input.new_string === 'string' ? input.new_string : ''
|
||||
return {
|
||||
path: filePath.path,
|
||||
absolutePath: filePath.absolutePath,
|
||||
additions: countTranscriptLines(newString),
|
||||
deletions: countTranscriptLines(oldString),
|
||||
diff: buildTranscriptDiff(filePath.path, filePath.path, oldString, newString),
|
||||
}
|
||||
}
|
||||
|
||||
function extractApplyPatchTranscriptChanges(
|
||||
patch: unknown,
|
||||
baseDir: string,
|
||||
): TranscriptFileChange[] {
|
||||
if (typeof patch !== 'string') return []
|
||||
const changes: TranscriptFileChange[] = []
|
||||
|
||||
for (const line of patch.split('\n')) {
|
||||
const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/)
|
||||
if (!match?.[1]) continue
|
||||
const filePath = resolveTranscriptToolPath(match[1], baseDir)
|
||||
if (!filePath) continue
|
||||
changes.push({
|
||||
path: filePath.path,
|
||||
absolutePath: filePath.absolutePath,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
function extractTranscriptChangesFromTool(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
baseDir: string,
|
||||
): TranscriptFileChange[] {
|
||||
const normalizedToolName = toolName.toLowerCase()
|
||||
if (normalizedToolName === 'write') {
|
||||
const filePath = resolveTranscriptToolPath(input.file_path ?? input.path, baseDir)
|
||||
if (!filePath) return []
|
||||
const content = typeof input.content === 'string' ? input.content : ''
|
||||
return [{
|
||||
path: filePath.path,
|
||||
absolutePath: filePath.absolutePath,
|
||||
additions: countTranscriptLines(content),
|
||||
deletions: 0,
|
||||
diff: buildTranscriptDiff('/dev/null', filePath.path, '', content),
|
||||
}]
|
||||
}
|
||||
|
||||
if (normalizedToolName === 'edit') {
|
||||
const filePath = resolveTranscriptToolPath(input.file_path ?? input.path, baseDir)
|
||||
if (!filePath) return []
|
||||
return [buildTranscriptEditChange(filePath, input)]
|
||||
}
|
||||
|
||||
if (normalizedToolName === 'multiedit') {
|
||||
const filePath = resolveTranscriptToolPath(input.file_path ?? input.path, baseDir)
|
||||
if (!filePath || !Array.isArray(input.edits)) return []
|
||||
return input.edits
|
||||
.filter((edit): edit is Record<string, unknown> => !!edit && typeof edit === 'object')
|
||||
.map((edit) => buildTranscriptEditChange(filePath, edit))
|
||||
}
|
||||
|
||||
if (normalizedToolName === 'notebookedit') {
|
||||
const filePath = resolveTranscriptToolPath(
|
||||
input.notebook_path ?? input.file_path ?? input.path,
|
||||
baseDir,
|
||||
)
|
||||
if (!filePath) return []
|
||||
const oldString = typeof input.old_source === 'string' ? input.old_source : ''
|
||||
const newString = typeof input.new_source === 'string' ? input.new_source : ''
|
||||
return [{
|
||||
path: filePath.path,
|
||||
absolutePath: filePath.absolutePath,
|
||||
additions: countTranscriptLines(newString),
|
||||
deletions: countTranscriptLines(oldString),
|
||||
diff: buildTranscriptDiff(filePath.path, filePath.path, oldString, newString),
|
||||
}]
|
||||
}
|
||||
|
||||
if (normalizedToolName === 'apply_patch') {
|
||||
return extractApplyPatchTranscriptChanges(input.patch, baseDir)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function getToolUseIds(messages: MessageEntry[]): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const message of messages) {
|
||||
if (message.type !== 'tool_use' || !Array.isArray(message.content)) continue
|
||||
for (const block of message.content) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const record = block as Record<string, unknown>
|
||||
if (record.type === 'tool_use' && typeof record.id === 'string') {
|
||||
ids.add(record.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function getTranscriptTurnMessages(
|
||||
activeMessages: MessageEntry[],
|
||||
targetUserMessageId: string,
|
||||
): MessageEntry[] {
|
||||
const range = getTurnMessageRange(activeMessages, targetUserMessageId)
|
||||
if (!range) return []
|
||||
|
||||
const rawTurnMessages = activeMessages.slice(range.start + 1, range.end)
|
||||
const parentTurnMessages = rawTurnMessages.filter((message) => !message.parentToolUseId)
|
||||
const turnToolUseIds = getToolUseIds(parentTurnMessages)
|
||||
if (turnToolUseIds.size === 0) return parentTurnMessages
|
||||
|
||||
const inlineChildMessages = rawTurnMessages.filter((message) =>
|
||||
message.parentToolUseId && turnToolUseIds.has(message.parentToolUseId)
|
||||
)
|
||||
const turnMessages = [...parentTurnMessages, ...inlineChildMessages]
|
||||
const includedIds = new Set(turnMessages.map((message) => message.id))
|
||||
const childMessages = activeMessages.filter((message) =>
|
||||
message.parentToolUseId &&
|
||||
turnToolUseIds.has(message.parentToolUseId) &&
|
||||
!includedIds.has(message.id)
|
||||
)
|
||||
|
||||
return [...turnMessages, ...childMessages]
|
||||
}
|
||||
|
||||
function collectTranscriptTurnFileChanges(
|
||||
activeMessages: MessageEntry[],
|
||||
targetUserMessageId: string,
|
||||
baseDir: string,
|
||||
): TranscriptFileChange[] {
|
||||
const turnMessages = getTranscriptTurnMessages(activeMessages, targetUserMessageId)
|
||||
if (turnMessages.length === 0) return []
|
||||
|
||||
const changes = new Map<string, TranscriptFileChange>()
|
||||
for (const message of turnMessages) {
|
||||
if (message.type !== 'tool_use' || !Array.isArray(message.content)) continue
|
||||
|
||||
for (const block of message.content) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const record = block as Record<string, unknown>
|
||||
if (record.type !== 'tool_use' || typeof record.name !== 'string') continue
|
||||
const input = record.input
|
||||
if (!input || typeof input !== 'object') continue
|
||||
|
||||
for (const change of extractTranscriptChangesFromTool(
|
||||
record.name,
|
||||
input as Record<string, unknown>,
|
||||
baseDir,
|
||||
)) {
|
||||
const existing = changes.get(change.path)
|
||||
if (!existing) {
|
||||
changes.set(change.path, change)
|
||||
continue
|
||||
}
|
||||
|
||||
changes.set(change.path, {
|
||||
...existing,
|
||||
additions: existing.additions + change.additions,
|
||||
deletions: existing.deletions + change.deletions,
|
||||
diff: [existing.diff, change.diff].filter(Boolean).join('\n'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...changes.values()].sort((a, b) => a.path.localeCompare(b.path))
|
||||
}
|
||||
|
||||
function buildTranscriptTurnCodePreview(
|
||||
activeMessages: MessageEntry[],
|
||||
targetUserMessageId: string,
|
||||
baseDir: string,
|
||||
): RewindCodePreview {
|
||||
const changes = collectTranscriptTurnFileChanges(activeMessages, targetUserMessageId, baseDir)
|
||||
if (changes.length === 0) {
|
||||
return {
|
||||
available: false,
|
||||
reason: 'No transcript file changes were recorded for this turn.',
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeDiffStats({
|
||||
filesChanged: changes.map((change) => change.absolutePath),
|
||||
insertions: changes.reduce((total, change) => total + change.additions, 0),
|
||||
deletions: changes.reduce((total, change) => total + change.deletions, 0),
|
||||
})
|
||||
}
|
||||
|
||||
function findTranscriptTurnDiff(
|
||||
activeMessages: MessageEntry[],
|
||||
targetUserMessageId: string,
|
||||
baseDir: string,
|
||||
requestedPath: string,
|
||||
): TranscriptFileChange | null {
|
||||
const changes = collectTranscriptTurnFileChanges(activeMessages, targetUserMessageId, baseDir)
|
||||
return changes.find((change) =>
|
||||
matchesCheckpointPath(requestedPath, change.path, baseDir) ||
|
||||
normalizeComparablePath(requestedPath) === normalizeComparablePath(change.absolutePath)
|
||||
) ?? null
|
||||
}
|
||||
|
||||
async function getTurnBoundaryContents(
|
||||
sessionId: string,
|
||||
checkpointBaseDir: string,
|
||||
@ -670,15 +949,16 @@ export async function listSessionTurnCheckpoints(
|
||||
const nextSnapshot = nextUserMessageId && snapshots
|
||||
? findTargetSnapshot(snapshots, nextUserMessageId)
|
||||
: null
|
||||
const preview = targetSnapshot
|
||||
const checkpointPreview = targetSnapshot
|
||||
? await buildTurnCodePreview(sessionId, checkpointBaseDir, targetSnapshot, nextSnapshot)
|
||||
: {
|
||||
available: false,
|
||||
reason: 'No file checkpoint is available for the selected message.',
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
: null
|
||||
const preview = checkpointPreview?.available && checkpointPreview.filesChanged.length > 0
|
||||
? checkpointPreview
|
||||
: buildTranscriptTurnCodePreview(
|
||||
activeMessages,
|
||||
target.targetUserMessageId,
|
||||
checkpointBaseDir,
|
||||
)
|
||||
|
||||
if (!preview.available || preview.filesChanged.length === 0) continue
|
||||
checkpoints.push(buildTurnPreview(target, preview, checkpointBaseDir))
|
||||
@ -699,6 +979,7 @@ export async function getSessionTurnCheckpointDiff(
|
||||
target.targetUserMessageId,
|
||||
workDir,
|
||||
)
|
||||
const activeMessages = await sessionService.getSessionMessages(sessionId)
|
||||
const snapshots = await loadFileHistorySnapshots(sessionId)
|
||||
const missingResult = {
|
||||
target: buildTurnPreview(
|
||||
@ -715,17 +996,31 @@ export async function getSessionTurnCheckpointDiff(
|
||||
path: normalizeComparablePath(requestedPath),
|
||||
state: 'missing' as const,
|
||||
}
|
||||
const transcriptChange = findTranscriptTurnDiff(
|
||||
activeMessages,
|
||||
target.targetUserMessageId,
|
||||
checkpointBaseDir,
|
||||
requestedPath,
|
||||
)
|
||||
const transcriptResult = transcriptChange?.diff
|
||||
? {
|
||||
target: missingResult.target,
|
||||
workDir: checkpointBaseDir,
|
||||
path: transcriptChange.path,
|
||||
state: 'ok' as const,
|
||||
diff: transcriptChange.diff,
|
||||
}
|
||||
: null
|
||||
|
||||
if (!snapshots) {
|
||||
return missingResult
|
||||
return transcriptResult ?? missingResult
|
||||
}
|
||||
|
||||
const targetSnapshot = findTargetSnapshot(snapshots, target.targetUserMessageId)
|
||||
if (!targetSnapshot) {
|
||||
return missingResult
|
||||
return transcriptResult ?? missingResult
|
||||
}
|
||||
const userMessages = (await sessionService.getSessionMessages(sessionId))
|
||||
.filter((message) => message.type === 'user')
|
||||
const userMessages = activeMessages.filter((message) => message.type === 'user')
|
||||
const nextUserMessageId = getNextUserMessageId(userMessages, target.userMessageIndex)
|
||||
const nextSnapshot = nextUserMessageId
|
||||
? findTargetSnapshot(snapshots, nextUserMessageId)
|
||||
@ -781,7 +1076,7 @@ export async function getSessionTurnCheckpointDiff(
|
||||
}
|
||||
}
|
||||
|
||||
return missingResult
|
||||
return transcriptResult ?? missingResult
|
||||
}
|
||||
|
||||
export async function executeSessionRewind(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user