mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: preserve historical workspace changes on windows
This commit is contained in:
parent
e7f6f8dfe5
commit
9eb7da5377
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 & 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[] = [
|
||||
{
|
||||
|
||||
@ -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(/</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<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)) {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 & 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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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))
|
||||
})
|
||||
|
||||
@ -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(/</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<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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user