From 9eb7da5377afdc032b9adbcd8fb9b42fa736a26d Mon Sep 17 00:00:00 2001 From: Relakkes Yang Date: Sat, 2 May 2026 17:53:24 +0800 Subject: [PATCH] fix: preserve historical workspace changes on windows --- desktop/src/api/sessions.ts | 6 +- .../components/chat/CurrentTurnChangeCard.tsx | 11 +- .../src/components/chat/MessageList.test.tsx | 53 +++++ desktop/src/components/chat/MessageList.tsx | 6 +- desktop/src/stores/chatStore.test.ts | 79 +++++- desktop/src/stores/chatStore.ts | 110 ++++++++- src/server/__tests__/conversations.test.ts | 26 +- src/server/__tests__/filesystem.test.ts | 3 +- .../__tests__/haha-oauth-service.test.ts | 4 +- src/server/__tests__/sessions.test.ts | 213 ++++++++++++++++- .../__tests__/workspace-service.test.ts | 78 ++++++ src/server/api/filesystem.ts | 11 +- src/server/api/sessions.ts | 19 +- src/server/services/searchService.ts | 109 ++++++++- src/server/services/sessionService.ts | 115 ++++++++- src/server/services/workspaceService.ts | 224 ++++++++++++++++-- 16 files changed, 1019 insertions(+), 48 deletions(-) diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 9765d29a..2ec470b0 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -1,8 +1,12 @@ import { api } from './client' +import type { AgentTaskNotification } from '../types/chat' import type { SessionListItem, MessageEntry } from '../types/session' type SessionsResponse = { sessions: SessionListItem[]; total: number } -type MessagesResponse = { messages: MessageEntry[] } +type MessagesResponse = { + messages: MessageEntry[] + taskNotifications?: AgentTaskNotification[] +} type CreateSessionResponse = { sessionId: string } export type SessionRewindResponse = { target: { diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.tsx index edd27666..dd4457cc 100644 --- a/desktop/src/components/chat/CurrentTurnChangeCard.tsx +++ b/desktop/src/components/chat/CurrentTurnChangeCard.tsx @@ -199,13 +199,16 @@ export function CurrentTurnChangeCard({ ) } -function relativizeWorkspacePath(filePath: string, workDir: string | null): string { +export function relativizeWorkspacePath(filePath: string, workDir: string | null): string { const normalizedPath = filePath.replace(/\\/g, '/') - if (!workDir || !normalizedPath.startsWith('/')) return normalizedPath + const isAbsolute = normalizedPath.startsWith('/') || /^[a-zA-Z]:\//.test(normalizedPath) + if (!workDir || !isAbsolute) return normalizedPath const normalizedWorkDir = workDir.replace(/\\/g, '/').replace(/\/+$/, '') - if (normalizedPath === normalizedWorkDir) return '' - if (normalizedPath.startsWith(`${normalizedWorkDir}/`)) { + const comparablePath = normalizedPath.toLowerCase() + const comparableWorkDir = normalizedWorkDir.toLowerCase() + if (comparablePath === comparableWorkDir) return '' + if (comparablePath.startsWith(`${comparableWorkDir}/`)) { return normalizedPath.slice(normalizedWorkDir.length + 1) } return normalizedPath diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 70d16cbb..b1e75d32 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { MessageList, buildRenderModel } from './MessageList' +import { relativizeWorkspacePath } from './CurrentTurnChangeCard' import { sessionsApi } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' import { useSettingsStore } from '../../stores/settingsStore' @@ -945,6 +946,13 @@ describe('MessageList nested tool calls', () => { ) }) + it('relativizes Windows checkpoint paths against the turn workdir', () => { + expect(relativizeWorkspacePath( + 'C:\\Users\\Relakkes\\aacc\\src\\App.tsx', + 'c:/users/relakkes/aacc', + )).toBe('src/App.tsx') + }) + 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: [ @@ -1003,6 +1011,51 @@ describe('MessageList nested tool calls', () => { ) }) + it('keeps turn change cards anchored when the only response item is filtered from rendering', async () => { + vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ + checkpoints: [ + { + target: { + targetUserMessageId: 'user-1', + userMessageIndex: 0, + userMessageCount: 1, + }, + code: { + available: true, + filesChanged: ['src/blank-response.ts'], + insertions: 3, + deletions: 0, + }, + }, + ], + }) + + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: '生成文件', + timestamp: 1, + }, + { + id: 'assistant-empty', + type: 'assistant_text', + content: '\n ', + timestamp: 2, + }, + ], + }), + }, + }) + + render() + + expect(await screen.findByText('src/blank-response.ts')).toBeTruthy() + }) + it('keeps historical turn change cards visible while the next turn is running', async () => { vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({ checkpoints: [ diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index a221ad24..ce9f1f1a 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -178,11 +178,13 @@ function buildTurnCardInsertionMap( turnChangeCards: TurnChangeCardModel[], ) { const lastResponseIndexByTurnId = new Map() + const userIndexByTurnId = new Map() let activeTurnId: string | null = null renderItems.forEach((item, index) => { if (item.kind === 'message' && item.message.type === 'user_text' && !item.message.pending) { activeTurnId = item.message.id + userIndexByTurnId.set(activeTurnId, index) return } @@ -193,7 +195,9 @@ function buildTurnCardInsertionMap( const cardsByRenderIndex = new Map() turnChangeCards.forEach((card) => { - const renderIndex = lastResponseIndexByTurnId.get(card.target.messageId) + const renderIndex = + lastResponseIndexByTurnId.get(card.target.messageId) ?? + userIndexByTurnId.get(card.target.messageId) if (renderIndex === undefined) return const existing = cardsByRenderIndex.get(renderIndex) if (existing) { diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 91a30f63..b518964e 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -96,7 +96,7 @@ vi.mock('./cliTaskStore', () => ({ }, })) -import { mapHistoryMessagesToUiMessages, useChatStore } from './chatStore' +import { mapHistoryMessagesToUiMessages, reconstructAgentNotifications, useChatStore } from './chatStore' const TEST_SESSION_ID = 'test-session-1' const initialState = useChatStore.getState() @@ -213,6 +213,83 @@ describe('chatStore history mapping', () => { ]) }) + it('filters task-notification turns and resumes at the next real user message', () => { + const messages: MessageEntry[] = [ + { + id: 'user-real-1', + type: 'user', + timestamp: '2026-04-06T00:00:00.000Z', + content: '创建项目', + }, + { + id: 'assistant-real-1', + type: 'assistant', + timestamp: '2026-04-06T00:00:01.000Z', + content: [{ type: 'text', text: '项目创建好了' }], + }, + { + id: 'task-notification', + type: 'user', + timestamp: '2026-04-06T00:00:02.000Z', + content: '\nbg-1\ntoolu_bg\ncompleted\nBackground command completed\n', + }, + { + id: 'assistant-task-response', + type: 'assistant', + timestamp: '2026-04-06T00:00:03.000Z', + content: [{ type: 'text', text: '旧后台任务通知,无需处理' }], + }, + { + id: 'user-real-2', + type: 'user', + timestamp: '2026-04-06T00:00:04.000Z', + content: '继续真实问题', + }, + ] + + const mapped = mapHistoryMessagesToUiMessages(messages) + + expect(mapped).toMatchObject([ + { + id: 'user-real-1', + type: 'user_text', + content: '创建项目', + }, + { + type: 'assistant_text', + content: '项目创建好了', + }, + { + id: 'user-real-2', + type: 'user_text', + content: '继续真实问题', + }, + ]) + expect(JSON.stringify(mapped)).not.toContain('') + expect(JSON.stringify(mapped)).not.toContain('旧后台任务通知') + }) + + it('reconstructs task notifications from transcript XML before filtering it from UI', () => { + const restored = reconstructAgentNotifications([ + { + id: 'task-notification', + type: 'user', + timestamp: '2026-04-06T00:00:00.000Z', + content: '\nbg-1\ntoolu_bg\ncompleted\nBackground command & agent done\nC:\\Temp\\bg.output\n', + }, + ]) + + expect(restored).toEqual({ + toolu_bg: { + taskId: 'bg-1', + toolUseId: 'toolu_bg', + status: 'completed', + summary: 'Background command & agent done', + outputFile: 'C:\\Temp\\bg.output', + }, + }) + }) + it('surfaces teammate prompt content when mapping member transcript history', () => { const messages: MessageEntry[] = [ { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 5284489f..754948da 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -183,11 +183,14 @@ function updateSessionIn( } async function fetchAndMapSessionHistory(sessionId: string) { - const { messages } = await sessionsApi.getMessages(sessionId) + const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId) return { rawMessages: messages, uiMessages: mapHistoryMessagesToUiMessages(messages), - restoredNotifications: reconstructAgentNotifications(messages), + restoredNotifications: { + ...reconstructAgentNotifications(messages), + ...agentNotificationRecordFromList(taskNotifications ?? []), + }, lastTodos: extractLastTodoWriteFromHistory(messages), hasMessagesAfterTaskCompletion: hasUserMessagesAfterTaskCompletion(messages), } @@ -863,6 +866,8 @@ export const useChatStore = create((set, get) => ({ type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown } type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string } +const TASK_NOTIFICATION_RE = /^\s*[\s\S]*<\/task-notification>$/i + /** * Check if text is a teammate-message (internal agent-to-agent communication). * Uses full open+close tag match to avoid false positives on user text @@ -872,6 +877,82 @@ function isTeammateMessage(text: string): boolean { return text.includes('') } +function extractHistoryTextBlocks(content: unknown): string[] { + if (typeof content === 'string') return [content] + if (!Array.isArray(content)) return [] + + return content + .flatMap((block) => { + if (!block || typeof block !== 'object') return [] + const record = block as Record + return record.type === 'text' && typeof record.text === 'string' + ? [record.text] + : [] + }) + .map((text) => text.trim()) + .filter(Boolean) +} + +function isTaskNotificationContent(content: unknown): boolean { + const textBlocks = extractHistoryTextBlocks(content) + return textBlocks.length > 0 && textBlocks.every((text) => extractTaskNotificationXml(text) !== null) +} + +function extractTaskNotificationXml(text: string): string | null { + const trimmed = text.trim() + if (TASK_NOTIFICATION_RE.test(trimmed)) return trimmed + return trimmed.match(/\s*[\s\S]*?<\/task-notification>/i)?.[0] ?? null +} + +function decodeXmlText(text: string): string { + return text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') +} + +function readXmlTag(xml: string, tag: string): string | undefined { + const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i')) + return match?.[1] ? decodeXmlText(match[1].trim()) : undefined +} + +function extractTaskNotification(content: unknown): AgentTaskNotification | null { + const xml = extractHistoryTextBlocks(content) + .map((text) => extractTaskNotificationXml(text)) + .find((value): value is string => value !== null) + if (!xml) return null + + const toolUseId = readXmlTag(xml, 'tool-use-id') + const status = readXmlTag(xml, 'status') + if ( + !toolUseId || + (status !== 'completed' && status !== 'failed' && status !== 'stopped') + ) { + return null + } + + const taskId = readXmlTag(xml, 'task-id') || toolUseId + const summary = readXmlTag(xml, 'summary') + const outputFile = readXmlTag(xml, 'output-file') + return { + taskId, + toolUseId, + status, + ...(summary ? { summary } : {}), + ...(outputFile ? { outputFile } : {}), + } +} + +function agentNotificationRecordFromList( + notifications: AgentTaskNotification[], +): Record { + return Object.fromEntries( + notifications.map((notification) => [notification.toolUseId, notification]), + ) +} + const TEAMMATE_CONTENT_REGEX = /]*>\n?([\s\S]*?)\n?<\/teammate-message>/g function extractVisibleTeammateMessageContents(text: string): string[] { @@ -982,6 +1063,11 @@ function extractLeadingFileReferences(text: string): { * teammate_ids found in subsequent user messages. */ export function reconstructAgentNotifications(messages: MessageEntry[]): Record { + const taskNotifications = messages + .filter((message) => message.type === 'user') + .map((message) => extractTaskNotification(message.content)) + .filter((notification): notification is AgentTaskNotification => notification !== null) + // Step 1: Collect Agent tool_use blocks → map agent name to toolUseId const agentNameToToolUseId = new Map() @@ -998,7 +1084,9 @@ export function reconstructAgentNotifications(messages: MessageEntry[]): Record< } } - if (agentNameToToolUseId.size === 0) return {} + if (agentNameToToolUseId.size === 0) { + return agentNotificationRecordFromList(taskNotifications) + } // Step 2: Extract content by teammate_id // Skip lifecycle messages (shutdown_approved, idle_notification, etc.) @@ -1044,6 +1132,10 @@ export function reconstructAgentNotifications(messages: MessageEntry[]): Record< } } + for (const notification of taskNotifications) { + notifications[notification.toolUseId] = notification + } + return notifications } @@ -1053,7 +1145,19 @@ export function mapHistoryMessagesToUiMessages( ): UIMessage[] { const includeTeammateMessages = options?.includeTeammateMessages === true const uiMessages: UIMessage[] = [] + let suppressTaskNotificationResponse = false + for (const msg of messages) { + if (msg.type === 'user' && isTaskNotificationContent(msg.content)) { + suppressTaskNotificationResponse = true + continue + } + if (msg.type === 'user') { + suppressTaskNotificationResponse = false + } else if (suppressTaskNotificationResponse) { + continue + } + const timestamp = new Date(msg.timestamp).getTime() if (msg.type === 'user' && typeof msg.content === 'string') { if (isTeammateMessage(msg.content)) { diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 05a0b031..1a07b691 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -14,6 +14,24 @@ import { ConversationService, ConversationStartupError, conversationService } fr import { SessionService } from '../services/sessionService.js' import { ProviderService } from '../services/providerService.js' +async function rmWithRetry(targetPath: string): Promise { + const attempts = process.platform === 'win32' ? 5 : 1 + for (let attempt = 0; attempt < attempts; attempt++) { + try { + await fs.rm(targetPath, { recursive: true, force: true }) + return + } catch (error) { + if ( + attempt === attempts - 1 || + !['EBUSY', 'EPERM', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code || '') + ) { + throw error + } + await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))) + } + } +} + // ============================================================================ // ConversationService unit tests // ============================================================================ @@ -909,6 +927,7 @@ describe('WebSocket Chat Integration', () => { it('should keep a long desktop session alive in a /tmp project across engineering turns', async () => { const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-issue247-project-')) + let sessionId: string | undefined try { await fs.writeFile( @@ -927,7 +946,7 @@ describe('WebSocket Chat Integration', () => { body: JSON.stringify({ workDir: projectDir }), }) expect(createRes.status).toBe(201) - const { sessionId } = await createRes.json() as { sessionId: string } + ;({ sessionId } = await createRes.json() as { sessionId: string }) const prompts = [ 'Inspect this TypeScript project and summarize what you see.', @@ -943,7 +962,10 @@ describe('WebSocket Chat Integration', () => { expect(messages.some((m) => m.type === 'message_complete')).toBe(true) } } finally { - await fs.rm(projectDir, { recursive: true, force: true }) + if (sessionId) { + await conversationService.stopSession(sessionId) + } + await rmWithRetry(projectDir) } }, 20_000) diff --git a/src/server/__tests__/filesystem.test.ts b/src/server/__tests__/filesystem.test.ts index 8eddfeab..ca53d838 100644 --- a/src/server/__tests__/filesystem.test.ts +++ b/src/server/__tests__/filesystem.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'bun:test' import * as fs from 'fs' import * as fsp from 'fs/promises' +import * as os from 'os' import * as path from 'path' import { handleFilesystemRoute } from '../api/filesystem.js' @@ -23,7 +24,7 @@ afterEach(async () => { describe('filesystem API', () => { it('allows browsing a directory under the user home directory', async () => { - const homeFixtureDir = await fsp.mkdtemp(path.join(process.env.HOME || path.sep, 'claude-filesystem-test-')) + const homeFixtureDir = await fsp.mkdtemp(path.join(os.homedir(), 'claude-filesystem-test-')) cleanupDirs.add(homeFixtureDir) await fsp.writeFile(path.join(homeFixtureDir, 'note.txt'), 'hello') diff --git a/src/server/__tests__/haha-oauth-service.test.ts b/src/server/__tests__/haha-oauth-service.test.ts index 423ad24d..3965656e 100644 --- a/src/server/__tests__/haha-oauth-service.test.ts +++ b/src/server/__tests__/haha-oauth-service.test.ts @@ -51,7 +51,9 @@ describe('HahaOAuthService — file storage', () => { const oauthPath = path.join(tmpDir, 'cc-haha', 'oauth.json') const stat = await fs.stat(oauthPath) - expect(stat.mode & 0o777).toBe(0o600) + if (process.platform !== 'win32') { + expect(stat.mode & 0o777).toBe(0o600) + } const loaded = await service.loadTokens() expect(loaded).toEqual(tokens) diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index e3e208d6..040009ba 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -626,6 +626,98 @@ describe('SessionService', () => { }) }) + it('should hide task-notification turns and their automatic responses from history', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const firstUserId = crypto.randomUUID() + const firstAssistantId = crypto.randomUUID() + const taskNotificationId = crypto.randomUUID() + const taskAssistantId = crypto.randomUUID() + const taskToolUseMessageId = crypto.randomUUID() + const taskToolResultId = crypto.randomUUID() + const taskAfterToolId = crypto.randomUUID() + const realFollowUpId = crypto.randomUUID() + const realAssistantId = crypto.randomUUID() + + await writeSessionFile('-tmp-project', sessionId, [ + makeSnapshotEntry(), + { + ...makeUserEntry('创建一个项目', firstUserId), + parentUuid: null, + }, + { + ...makeAssistantEntry('项目已经创建', firstUserId), + uuid: firstAssistantId, + }, + { + ...makeUserEntry( + '\nbg-1\ntoolu_bg\ncompleted\nBackground command completed\n', + taskNotificationId, + ), + parentUuid: firstAssistantId, + }, + { + ...makeAssistantEntry('旧后台任务通知,无需处理', taskNotificationId), + uuid: taskAssistantId, + }, + { + ...makeAssistantToolUseEntry([{ + id: 'toolu_restart', + name: 'Bash', + input: { command: 'npm run dev' }, + }], taskAssistantId), + uuid: taskToolUseMessageId, + }, + { + type: 'user', + message: { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'toolu_restart', + content: 'server restarted', + }], + }, + uuid: taskToolResultId, + parentUuid: taskToolUseMessageId, + timestamp: '2026-01-01T00:03:00.000Z', + }, + { + ...makeAssistantEntry('后台任务触发的工具调用完成', taskToolResultId), + uuid: taskAfterToolId, + }, + { + ...makeUserEntry('继续真实问题', realFollowUpId), + parentUuid: taskAfterToolId, + }, + { + ...makeAssistantEntry('真实回答', realFollowUpId), + uuid: realAssistantId, + }, + ]) + + const messages = await service.getSessionMessages(sessionId) + const taskNotifications = await service.getSessionTaskNotifications(sessionId) + + expect(messages.map((message) => message.id)).toEqual([ + firstUserId, + firstAssistantId, + realFollowUpId, + realAssistantId, + ]) + expect(JSON.stringify(messages)).not.toContain('') + expect(JSON.stringify(messages)).not.toContain('旧后台任务通知') + expect(JSON.stringify(messages)).not.toContain('server restarted') + expect(JSON.stringify(messages)).not.toContain('后台任务触发的工具调用完成') + expect(taskNotifications).toEqual([ + { + taskId: 'bg-1', + toolUseId: 'toolu_bg', + status: 'completed', + summary: 'Background command completed', + }, + ]) + }) + it('should reconstruct parent agent tool linkage from parentUuid chains', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const userUuid = crypto.randomUUID() @@ -1030,13 +1122,31 @@ describe('Sessions API', () => { makeSnapshotEntry(), makeUserEntry('Hello'), makeAssistantEntry('World'), + makeUserEntry( + '\nbg-1\ntoolu_bg\nfailed\nBackground command failed & stopped\nC:\\Temp\\bg.output\n', + crypto.randomUUID(), + ), + makeAssistantEntry('internal task response'), ]) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`) expect(res.status).toBe(200) - const body = (await res.json()) as { messages: unknown[] } + const body = (await res.json()) as { + messages: unknown[] + taskNotifications: unknown[] + } expect(body.messages).toHaveLength(2) + expect(JSON.stringify(body.messages)).not.toContain('') + expect(body.taskNotifications).toEqual([ + { + taskId: 'bg-1', + toolUseId: 'toolu_bg', + status: 'failed', + summary: 'Background command failed & stopped', + outputFile: 'C:\\Temp\\bg.output', + }, + ]) }) it('DELETE /api/sessions/:id should delete the session', async () => { @@ -1332,6 +1442,107 @@ describe('Sessions API', () => { expect(todoDiffBody.diff).toContain('+- ship workspace panel') }) + it('GET /api/sessions/:id/workspace/* should surface file-history changes for a non-git generated subdirectory', async () => { + const sessionId = crypto.randomUUID() + const workDir = path.join(tmpDir, 'workspace-file-history-generated') + const generatedFile = path.join(workDir, 'aacc', 'src', 'App.tsx') + const userId = crypto.randomUUID() + + await fs.mkdir(path.dirname(generatedFile), { recursive: true }) + await fs.writeFile( + generatedFile, + 'export default function App() { return
Tetris
}\n', + 'utf-8', + ) + + await writeSessionFile(sanitizePath(workDir), sessionId, [ + makeSessionMetaEntry(workDir), + makeFileHistorySnapshotEntry(userId, { + 'aacc/src/App.tsx': { + backupFileName: null, + version: 1, + backupTime: '2026-01-01T00:00:00.000Z', + }, + }), + { + ...makeUserEntry('create aacc project', userId), + cwd: workDir, + sessionId, + }, + makeAssistantEntry('DONE', userId), + makeUserEntry( + '\nbg-1\ntoolu_bg\ncompleted\nBackground command completed\n', + crypto.randomUUID(), + ), + makeAssistantEntry('Background task completed again, no action needed'), + ]) + + const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`) + expect(statusRes.status).toBe(200) + const statusBody = await statusRes.json() as { + state: string + workDir: string + isGitRepo: boolean + changedFiles: Array<{ + path: string + status: string + additions: number + deletions: number + }> + } + expect(statusBody).toMatchObject({ + state: 'ok', + workDir, + isGitRepo: false, + }) + expect(statusBody.changedFiles).toEqual([ + expect.objectContaining({ + path: 'aacc/src/App.tsx', + status: 'added', + additions: 1, + deletions: 0, + }), + ]) + + const diffRes = await fetch( + `${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('aacc/src/App.tsx')}`, + ) + expect(diffRes.status).toBe(200) + const diffBody = await diffRes.json() as { + state: string + path: string + diff: string + } + expect(diffBody).toMatchObject({ + state: 'ok', + path: 'aacc/src/App.tsx', + }) + expect(diffBody.diff).toContain('diff --session /dev/null b/aacc/src/App.tsx') + expect(diffBody.diff).toContain('+export default function App() { return
Tetris
}') + + const checkpointsRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/turn-checkpoints`) + expect(checkpointsRes.status).toBe(200) + const checkpointsBody = await checkpointsRes.json() as { + checkpoints: Array<{ + target: { + targetUserMessageId: string + userMessageIndex: number + userMessageCount: number + } + code: { + filesChanged: string[] + } + }> + } + expect(checkpointsBody.checkpoints).toHaveLength(1) + expect(checkpointsBody.checkpoints[0]?.target).toMatchObject({ + targetUserMessageId: userId, + userMessageIndex: 0, + userMessageCount: 1, + }) + expect(checkpointsBody.checkpoints[0]?.code.filesChanged).toEqual([generatedFile]) + }) + it('GET /api/sessions/:id/workspace/file and diff should require a path query', async () => { const workDir = await createWorkspaceApiGitRepo(tmpDir) const { sessionId } = await service.createSession(workDir) diff --git a/src/server/__tests__/workspace-service.test.ts b/src/server/__tests__/workspace-service.test.ts index 82f29043..408c7c3e 100644 --- a/src/server/__tests__/workspace-service.test.ts +++ b/src/server/__tests__/workspace-service.test.ts @@ -204,6 +204,84 @@ describe('WorkspaceService', () => { expect(diff.diff).toContain('+export default function App() { return
New
}') }) + it('reports file-history changes without requiring a git repository', async () => { + const nonGitDir = await makeTempDir('workspace-service-file-history-') + const generatedFile = path.join(nonGitDir, 'aacc', 'src', 'App.tsx') + await fs.mkdir(path.dirname(generatedFile), { recursive: true }) + await fs.writeFile(generatedFile, 'export default function App() { return
Tetris
}\n') + + const service = new WorkspaceService( + async () => nonGitDir, + async () => [], + async () => [{ + messageId: '11111111-1111-4111-8111-111111111111', + timestamp: new Date('2026-01-01T00:00:00.000Z'), + trackedFileBackups: { + 'aacc/src/App.tsx': { + backupFileName: null, + version: 1, + backupTime: new Date('2026-01-01T00:00:00.000Z'), + }, + }, + }], + ) + + const status = await service.getStatus('session-1') + + expect(status).toMatchObject({ + state: 'ok', + workDir: nonGitDir, + isGitRepo: false, + changedFiles: [{ + path: 'aacc/src/App.tsx', + status: 'added', + additions: 1, + deletions: 0, + }], + }) + + const diff = await service.getDiff('session-1', 'aacc/src/App.tsx') + expect(diff.state).toBe('ok') + expect(diff.diff).toContain('diff --session /dev/null b/aacc/src/App.tsx') + expect(diff.diff).toContain('+export default function App() { return
Tetris
}') + }) + + it('matches Windows file-history paths case-insensitively inside the workspace', async () => { + if (process.platform !== 'win32') return + + const nonGitDir = await makeTempDir('workspace-service-windows-paths-') + const targetFile = path.join(nonGitDir, 'Child', 'index.ts') + await fs.mkdir(path.dirname(targetFile), { recursive: true }) + await fs.writeFile(targetFile, 'export const value = 1\n') + + const lowerDrivePath = targetFile[0]?.toLowerCase() + targetFile.slice(1) + const service = new WorkspaceService( + async () => nonGitDir, + async () => [], + async () => [{ + messageId: '22222222-2222-4222-8222-222222222222', + timestamp: new Date('2026-01-01T00:00:00.000Z'), + trackedFileBackups: { + [lowerDrivePath]: { + backupFileName: null, + version: 1, + backupTime: new Date('2026-01-01T00:00:00.000Z'), + }, + }, + }], + ) + + const status = await service.getStatus('session-1') + + expect(status.changedFiles).toEqual([{ + path: 'Child/index.ts', + oldPath: undefined, + status: 'added', + additions: 1, + deletions: 0, + }]) + }) + it('rejects traversal attempts for file, diff, and tree access', async () => { const repoDir = await createGitWorkspace() const service = new WorkspaceService(async () => repoDir) diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts index 9ebab116..252bd01f 100644 --- a/src/server/api/filesystem.ts +++ b/src/server/api/filesystem.ts @@ -20,7 +20,14 @@ const IMAGE_MIME_TYPES: Record = { } function isWithinRoot(targetPath: string, rootPath: string): boolean { - return targetPath === rootPath || targetPath.startsWith(`${rootPath}${path.sep}`) + const target = normalizeComparablePath(targetPath) + const root = normalizeComparablePath(rootPath) + return target === root || target.startsWith(`${root}${path.sep}`) +} + +function normalizeComparablePath(filePath: string): string { + const resolved = path.resolve(filePath) + return process.platform === 'win32' ? resolved.toLowerCase() : resolved } function isAllowedFilesystemPath(targetPath: string): boolean { @@ -95,7 +102,7 @@ async function handleServeFile(url: URL): Promise { } async function handleBrowse(url: URL): Promise { - const targetPath = url.searchParams.get('path') || process.env.HOME || '/' + const targetPath = url.searchParams.get('path') || os.homedir() || '/' const resolvedPath = path.resolve(targetPath) if (!isAllowedFilesystemPath(resolvedPath)) { diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 324a9cbd..0c940c04 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -29,10 +29,14 @@ import { type RewindTargetSelector, } from '../services/sessionRewindService.js' -const workspaceService = new WorkspaceService(async (sessionId) => ( - conversationService.getSessionWorkDir(sessionId) || - await sessionService.getSessionWorkDir(sessionId) -), async (sessionId) => sessionService.getSessionMessages(sessionId)) +const workspaceService = new WorkspaceService( + async (sessionId) => ( + conversationService.getSessionWorkDir(sessionId) || + await sessionService.getSessionWorkDir(sessionId) + ), + async (sessionId) => sessionService.getSessionMessages(sessionId), + async (sessionId) => sessionService.getSessionFileHistorySnapshots(sessionId), +) export async function handleSessionsApi( req: Request, @@ -202,8 +206,11 @@ async function getSession(sessionId: string): Promise { } async function getSessionMessages(sessionId: string): Promise { - const messages = await sessionService.getSessionMessages(sessionId) - return Response.json({ messages }) + const [messages, taskNotifications] = await Promise.all([ + sessionService.getSessionMessages(sessionId), + sessionService.getSessionTaskNotifications(sessionId), + ]) + return Response.json({ messages, taskNotifications }) } async function handleSessionWorkspaceRoute( diff --git a/src/server/services/searchService.ts b/src/server/services/searchService.ts index a3edad2d..ce7c5800 100644 --- a/src/server/services/searchService.ts +++ b/src/server/services/searchService.ts @@ -56,7 +56,16 @@ export class SearchService { } } - return this.searchWithGrep(query, cwd, maxResults, options) + const hasGrep = await this.commandExists('grep') + if (hasGrep) { + try { + return await this.searchWithGrep(query, cwd, maxResults, options) + } catch { + // grep failed or is not available; fall back to a portable search. + } + } + + return this.searchWithFilesystem(query, cwd, maxResults, options) } // --------------------------------------------------------------------------- @@ -285,6 +294,101 @@ export class SearchService { return results } + // --------------------------------------------------------------------------- + // Portable filesystem fallback + // --------------------------------------------------------------------------- + + private async searchWithFilesystem( + query: string, + cwd: string, + maxResults: number, + options?: { + glob?: string + caseSensitive?: boolean + }, + ): Promise { + const results: SearchResult[] = [] + const needle = options?.caseSensitive === false ? query.toLowerCase() : query + + await this.searchDirectory(cwd, needle, results, maxResults, { + caseSensitive: options?.caseSensitive !== false, + glob: options?.glob, + }) + + return results + } + + private async searchDirectory( + dir: string, + needle: string, + results: SearchResult[], + maxResults: number, + options: { + caseSensitive: boolean + glob?: string + }, + ): Promise { + if (results.length >= maxResults) return + + let entries: import('fs').Dirent[] + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + if (results.length >= maxResults) return + if (entry.name === 'node_modules' || entry.name === '.git') continue + + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + await this.searchDirectory(fullPath, needle, results, maxResults, options) + continue + } + + if (!entry.isFile()) continue + if (options.glob && !this.matchesSimpleGlob(entry.name, options.glob)) continue + + await this.searchFile(fullPath, needle, results, maxResults, options.caseSensitive) + } + } + + private async searchFile( + filePath: string, + needle: string, + results: SearchResult[], + maxResults: number, + caseSensitive: boolean, + ): Promise { + let content: string + try { + const buffer = await fs.readFile(filePath) + if (buffer.includes(0)) return + content = buffer.toString('utf8') + } catch { + return + } + + const lines = content.split(/\r?\n/) + for (let index = 0; index < lines.length && results.length < maxResults; index++) { + const haystack = caseSensitive ? lines[index] : lines[index].toLowerCase() + if (!haystack.includes(needle)) continue + + results.push({ + file: filePath, + line: index + 1, + text: lines[index], + }) + } + } + + private matchesSimpleGlob(fileName: string, glob: string): boolean { + if (!glob.includes('*')) return fileName === glob + const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + return new RegExp(`^${escaped}$`).test(fileName) + } + // --------------------------------------------------------------------------- // 工具方法 // --------------------------------------------------------------------------- @@ -318,7 +422,8 @@ export class SearchService { /** 检测命令是否存在 */ private commandExists(cmd: string): Promise { return new Promise((resolve) => { - const proc = spawn('which', [cmd], { stdio: 'ignore' }) + const lookup = process.platform === 'win32' ? 'where' : 'which' + const proc = spawn(lookup, [cmd], { stdio: 'ignore' }) proc.on('close', (code) => resolve(code === 0)) proc.on('error', () => resolve(false)) }) diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 6abab2cd..292bbf93 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -62,6 +62,14 @@ export type MessageEntry = { isSidechain?: boolean } +export type SessionTaskNotification = { + taskId: string + toolUseId: string + status: 'completed' | 'failed' | 'stopped' + summary?: string + outputFile?: string +} + export type TranscriptUsageSnapshot = { source: 'transcript' totalCostUSD: number @@ -175,6 +183,8 @@ const USER_INTERRUPTION_TEXTS = new Set([ ]) const NO_RESPONSE_REQUESTED_TEXT = 'No response requested.' +const TASK_NOTIFICATION_RE = /^\s*[\s\S]*<\/task-notification>$/i +const TASK_NOTIFICATION_BLOCK_RE = /\s*[\s\S]*?<\/task-notification>/i // ============================================================================ // Service @@ -352,6 +362,72 @@ export class SessionService { ) } + private isToolResultContent(content: unknown): boolean { + return ( + Array.isArray(content) && + content.some((block) => + block && + typeof block === 'object' && + (block as Record).type === 'tool_result' + ) + ) + } + + private isTaskNotificationContent(content: unknown): boolean { + const textBlocks = this.extractTextBlocks(content) + return ( + textBlocks.length > 0 && + textBlocks.every((text) => this.extractTaskNotificationXml(text) !== null) + ) + } + + private extractTaskNotificationXml(text: string): string | null { + const trimmed = text.trim() + if (TASK_NOTIFICATION_RE.test(trimmed)) return trimmed + return trimmed.match(TASK_NOTIFICATION_BLOCK_RE)?.[0] ?? null + } + + private decodeXmlText(text: string): string { + return text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') + } + + private readXmlTag(xml: string, tag: string): string | undefined { + const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i')) + return match?.[1] ? this.decodeXmlText(match[1].trim()) : undefined + } + + private parseTaskNotificationContent(content: unknown): SessionTaskNotification | null { + const xml = this.extractTextBlocks(content) + .map((text) => this.extractTaskNotificationXml(text)) + .find((value): value is string => value !== null) + if (!xml) return null + + const toolUseId = this.readXmlTag(xml, 'tool-use-id') + const status = this.readXmlTag(xml, 'status') + if ( + !toolUseId || + (status !== 'completed' && status !== 'failed' && status !== 'stopped') + ) { + return null + } + + const taskId = this.readXmlTag(xml, 'task-id') || toolUseId + const summary = this.readXmlTag(xml, 'summary') + const outputFile = this.readXmlTag(xml, 'output-file') + return { + taskId, + toolUseId, + status, + ...(summary ? { summary } : {}), + ...(outputFile ? { outputFile } : {}), + } + } + private shouldHideTranscriptEntry(entry: RawEntry): boolean { const role = entry.message?.role const content = entry.message?.content @@ -359,7 +435,8 @@ export class SessionService { if (role === 'user') { return ( this.isInternalCommandBreadcrumb(content) || - this.isSyntheticUserInterruption(content) + this.isSyntheticUserInterruption(content) || + this.isTaskNotificationContent(content) ) } @@ -1446,6 +1523,24 @@ export class SessionService { return [...snapshotsByMessageId.values()] } + async getSessionTaskNotifications( + sessionId: string, + ): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) { + throw ApiError.notFound(`Session not found: ${sessionId}`) + } + + const entries = await this.readJsonlFile(found.filePath) + const notifications: SessionTaskNotification[] = [] + for (const entry of entries) { + if (entry.message?.role !== 'user') continue + const notification = this.parseTaskNotificationContent(entry.message.content) + if (notification) notifications.push(notification) + } + return notifications + } + // -------------------------------------------------------------------------- // Private helpers // -------------------------------------------------------------------------- @@ -1454,6 +1549,7 @@ export class SessionService { const messages: MessageEntry[] = [] const entriesByUuid = new Map() const parentToolUseIdCache = new Map() + let suppressTaskNotificationResponse = false for (const entry of entries) { if (typeof entry.uuid === 'string' && entry.uuid.length > 0) { @@ -1468,6 +1564,23 @@ export class SessionService { // Skip meta entries (CLI internal bookkeeping) if (entry.isMeta) continue + const isTaskNotification = + entry.message.role === 'user' && + this.isTaskNotificationContent(entry.message.content) + if (isTaskNotification) { + suppressTaskNotificationResponse = true + continue + } + + if ( + entry.message.role === 'user' && + !this.isToolResultContent(entry.message.content) + ) { + suppressTaskNotificationResponse = false + } else if (suppressTaskNotificationResponse) { + continue + } + if (this.shouldHideTranscriptEntry(entry)) continue // Skip non-transcript entry types diff --git a/src/server/services/workspaceService.ts b/src/server/services/workspaceService.ts index e2883b07..2d93e290 100644 --- a/src/server/services/workspaceService.ts +++ b/src/server/services/workspaceService.ts @@ -2,7 +2,10 @@ import * as fs from 'node:fs/promises' import { execFile as execFileCallback } from 'node:child_process' import * as path from 'node:path' import { promisify } from 'node:util' +import { diffLines } from 'diff' import type { MessageEntry } from './sessionService.js' +import type { FileHistorySnapshot } from '../../utils/fileHistory.js' +import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' const MAX_PREVIEW_BYTES = 1024 * 1024 const MAX_UNTRACKED_STAT_BYTES = 256 * 1024 @@ -225,6 +228,9 @@ export class WorkspaceService { private readonly resolveSessionMessages: ( sessionId: string, ) => Promise = async () => [], + private readonly resolveSessionFileHistorySnapshots: ( + sessionId: string, + ) => Promise = async () => [], ) {} async getStatus(sessionId: string): Promise { @@ -253,33 +259,28 @@ export class WorkspaceService { } const repoInfo = await this.getGitRepoInfo(workDir) - const sessionChanges = await this.getSessionFileChanges( - sessionId, - workspaceInfo.workspaceRoot, + const sessionChanges = this.mergeSessionFileChanges( + [ + ...await this.getSessionFileChanges( + sessionId, + workspaceInfo.workspaceRoot, + ), + ...await this.getFileHistoryChanges( + sessionId, + workspaceInfo.workspaceRoot, + ), + ], ) - if (sessionChanges.length > 0) { - sessionChanges.sort((a, b) => a.path.localeCompare(b.path)) - return { - state: 'ok', - workDir, - repoName: repoInfo.kind === 'ok' - ? path.basename(repoInfo.repoRoot) - : path.basename(workspaceInfo.workspaceRoot), - branch: repoInfo.kind === 'ok' ? repoInfo.branch : null, - isGitRepo: repoInfo.kind === 'ok', - changedFiles: sessionChanges.map(({ diff: _diff, ...change }) => change), - } - } - if (repoInfo.kind === 'not_git_repo') { + sessionChanges.sort((a, b) => a.path.localeCompare(b.path)) return { state: 'ok', workDir, repoName: path.basename(workspaceInfo.workspaceRoot), branch: null, isGitRepo: false, - changedFiles: [], + changedFiles: sessionChanges.map(({ diff: _diff, ...change }) => change), } } if (repoInfo.kind === 'error') { @@ -363,6 +364,20 @@ export class WorkspaceService { } changedFiles.sort((a, b) => a.path.localeCompare(b.path)) + const changedFileByPath = new Map(changedFiles.map((file) => [file.path, file])) + for (const change of sessionChanges) { + if (!changedFileByPath.has(change.path)) { + changedFileByPath.set(change.path, { + path: change.path, + oldPath: change.oldPath, + status: change.status, + additions: change.additions, + deletions: change.deletions, + }) + } + } + const mergedChangedFiles = [...changedFileByPath.values()] + .sort((a, b) => a.path.localeCompare(b.path)) return { state: 'ok', @@ -370,7 +385,7 @@ export class WorkspaceService { repoName: path.basename(repoInfo.repoRoot), branch: repoInfo.branch, isGitRepo: true, - changedFiles, + changedFiles: mergedChangedFiles, } } @@ -542,6 +557,15 @@ export class WorkspaceService { return { state: 'ok', path: resolvedPath.relativePath, diff: sessionDiff } } + const fileHistoryDiff = await this.getFileHistoryDiff( + sessionId, + resolvedPath.workspaceRoot, + resolvedPath.relativePath, + ) + if (fileHistoryDiff) { + return { state: 'ok', path: resolvedPath.relativePath, diff: fileHistoryDiff } + } + const repoInfo = await this.getGitRepoInfo(resolvedPath.workspaceRoot) if (repoInfo.kind === 'not_git_repo') { return { state: 'not_git_repo', path: resolvedPath.relativePath } @@ -681,6 +705,151 @@ export class WorkspaceService { return [...changes.values()] } + private async getFileHistoryChanges( + sessionId: string, + workspaceRoot: string, + ): Promise { + let snapshots: FileHistorySnapshot[] + try { + snapshots = await this.resolveSessionFileHistorySnapshots(sessionId) + } catch { + return [] + } + if (snapshots.length === 0) return [] + + const changes: SessionFileChange[] = [] + const trackedPaths = this.collectFileHistoryTrackedPaths(snapshots) + + for (const trackingPath of trackedPaths) { + const relativePath = this.resolveFileHistoryRelativePath(trackingPath, workspaceRoot) + if (!relativePath) continue + + const beforeContent = await this.readFileHistoryBackupContent( + sessionId, + this.getEarliestFileHistoryBackupName(trackingPath, snapshots), + ) + if (beforeContent === undefined) continue + + const absolutePath = path.resolve(workspaceRoot, relativePath) + const afterContent = await this.readTextFileOrNull(absolutePath) + if (beforeContent === afterContent) continue + + const stats = this.countDiffStats(beforeContent ?? '', afterContent ?? '') + changes.push({ + path: relativePath, + status: beforeContent === null + ? 'added' + : afterContent === null + ? 'deleted' + : 'modified', + additions: stats.additions, + deletions: stats.deletions, + diff: this.buildSyntheticDiff( + beforeContent === null ? '/dev/null' : relativePath, + afterContent === null ? '/dev/null' : relativePath, + beforeContent ?? '', + afterContent ?? '', + ), + }) + } + + return changes + } + + private async getFileHistoryDiff( + sessionId: string, + workspaceRoot: string, + relativePath: string, + ): Promise { + const changes = await this.getFileHistoryChanges(sessionId, workspaceRoot) + return changes.find((change) => change.path === relativePath)?.diff ?? null + } + + private mergeSessionFileChanges(changes: SessionFileChange[]): SessionFileChange[] { + const merged = new Map() + for (const change of changes) { + const existing = merged.get(change.path) + if (!existing) { + merged.set(change.path, change) + continue + } + + merged.set(change.path, { + ...existing, + status: change.status, + additions: change.additions, + deletions: change.deletions, + diff: change.diff ?? existing.diff, + }) + } + return [...merged.values()] + } + + private collectFileHistoryTrackedPaths(snapshots: FileHistorySnapshot[]): Set { + const trackedPaths = new Set() + for (const snapshot of snapshots) { + for (const trackingPath of Object.keys(snapshot.trackedFileBackups)) { + trackedPaths.add(trackingPath) + } + } + return trackedPaths + } + + private getEarliestFileHistoryBackupName( + trackingPath: string, + snapshots: FileHistorySnapshot[], + ): string | null | undefined { + for (const snapshot of snapshots) { + const backup = snapshot.trackedFileBackups[trackingPath] + if (backup !== undefined) { + return backup.backupFileName + } + } + return undefined + } + + private resolveFileHistoryRelativePath( + trackingPath: string, + workspaceRoot: string, + ): string | null { + const absolutePath = path.isAbsolute(trackingPath) + ? path.resolve(trackingPath) + : path.resolve(workspaceRoot, trackingPath) + if (!this.isWithinRoot(absolutePath, workspaceRoot)) return null + return this.normalizeRelativePath(path.relative(workspaceRoot, absolutePath)) + } + + private async readFileHistoryBackupContent( + sessionId: string, + backupFileName: string | null | undefined, + ): Promise { + if (backupFileName === undefined) return undefined + if (backupFileName === null) return null + return await this.readTextFileOrNull( + path.join(getClaudeConfigHomeDir(), 'file-history', sessionId, backupFileName), + ) + } + + private async readTextFileOrNull(filePath: string): Promise { + try { + const content = await fs.readFile(filePath) + if (content.includes(0)) return null + return content.toString('utf8') + } catch { + return null + } + } + + private countDiffStats(oldContent: string, newContent: string): { additions: number; deletions: number } { + let additions = 0 + let deletions = 0 + for (const change of diffLines(oldContent, newContent)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + return { additions, deletions } + } + private extractSessionChangesFromTool( toolName: string, input: Record, @@ -812,9 +981,13 @@ export class WorkspaceService { if (newLines.at(-1) === '') newLines.pop() return [ - `diff --session a/${oldPath} b/${newPath}`, + `diff --session ${ + oldPath === '/dev/null' ? '/dev/null' : `a/${oldPath}` + } ${ + newPath === '/dev/null' ? '/dev/null' : `b/${newPath}` + }`, `--- ${oldPath === '/dev/null' ? '/dev/null' : `a/${oldPath}`}`, - `+++ b/${newPath}`, + `+++ ${newPath === '/dev/null' ? '/dev/null' : `b/${newPath}`}`, `@@ -1,${oldLines.length} +1,${newLines.length} @@`, ...oldLines.map((line) => `-${line}`), ...newLines.map((line) => `+${line}`), @@ -971,7 +1144,14 @@ export class WorkspaceService { } private isWithinRoot(targetPath: string, rootPath: string): boolean { - return targetPath === rootPath || targetPath.startsWith(`${rootPath}${path.sep}`) + const target = this.normalizeComparableAbsolutePath(targetPath) + const root = this.normalizeComparableAbsolutePath(rootPath) + return target === root || target.startsWith(`${root}${path.sep}`) + } + + private normalizeComparableAbsolutePath(filePath: string): string { + const resolved = path.resolve(filePath) + return process.platform === 'win32' ? resolved.toLowerCase() : resolved } private normalizeRelativePath(filePath: string): string {