fix: preserve historical workspace changes on windows

This commit is contained in:
Relakkes Yang 2026-05-02 17:53:24 +08:00
parent e7f6f8dfe5
commit 9eb7da5377
16 changed files with 1019 additions and 48 deletions

View File

@ -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: {

View File

@ -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

View File

@ -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(<MessageList />)
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: [

View File

@ -178,11 +178,13 @@ function buildTurnCardInsertionMap(
turnChangeCards: TurnChangeCardModel[],
) {
const lastResponseIndexByTurnId = new Map<string, number>()
const userIndexByTurnId = new Map<string, number>()
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<number, TurnChangeCardModel[]>()
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) {

View File

@ -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: '<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>',
},
{
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('<task-notification>')
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: '<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command &amp; agent done</summary>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
},
])
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[] = [
{

View File

@ -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<ChatStore>((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 = /^<task-notification>\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('<teammate-message') && text.includes('</teammate-message>')
}
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<string, unknown>
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(/<task-notification>\s*[\s\S]*?<\/task-notification>/i)?.[0] ?? null
}
function decodeXmlText(text: string): string {
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/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<string, AgentTaskNotification> {
return Object.fromEntries(
notifications.map((notification) => [notification.toolUseId, notification]),
)
}
const TEAMMATE_CONTENT_REGEX = /<teammate-message\s+teammate_id="([^"]+)"[^>]*>\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<string, AgentTaskNotification> {
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<string, string>()
@ -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 <teammate-message> 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)) {

View File

@ -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<void> {
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)

View File

@ -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')

View File

@ -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)

View File

@ -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(
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>',
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('<task-notification>')
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(
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>failed</status>\n<summary>Background command failed &amp; stopped</summary>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
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('<task-notification>')
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 <main>Tetris</main> }\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(
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>',
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 <main>Tetris</main> }')
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)

View File

@ -204,6 +204,84 @@ describe('WorkspaceService', () => {
expect(diff.diff).toContain('+export default function App() { return <main>New</main> }')
})
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 <main>Tetris</main> }\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 <main>Tetris</main> }')
})
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)

View File

@ -20,7 +20,14 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
}
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<Response> {
}
async function handleBrowse(url: URL): Promise<Response> {
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)) {

View File

@ -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<Response> {
}
async function getSessionMessages(sessionId: string): Promise<Response> {
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(

View File

@ -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<SearchResult[]> {
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<void> {
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<void> {
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<boolean> {
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))
})

View File

@ -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 = /^<task-notification>\s*[\s\S]*<\/task-notification>$/i
const TASK_NOTIFICATION_BLOCK_RE = /<task-notification>\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<string, unknown>).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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/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<SessionTaskNotification[]> {
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<string, RawEntry>()
const parentToolUseIdCache = new Map<string, string | undefined>()
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

View File

@ -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<MessageEntry[]> = async () => [],
private readonly resolveSessionFileHistorySnapshots: (
sessionId: string,
) => Promise<FileHistorySnapshot[]> = async () => [],
) {}
async getStatus(sessionId: string): Promise<WorkspaceStatusResult> {
@ -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<SessionFileChange[]> {
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<string | null> {
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<string, SessionFileChange>()
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<string> {
const trackedPaths = new Set<string>()
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<string | null | undefined> {
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<string | null> {
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<string, unknown>,
@ -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 {