Merge commit '784118e'

This commit is contained in:
程序员阿江(Relakkes) 2026-05-03 15:37:54 +08:00
commit 3cd63fabef
5 changed files with 101 additions and 20 deletions

View File

@ -1024,6 +1024,7 @@ describe('WebSocket Chat Integration', () => {
it('should include desktop service diagnostics when CLI startup fails', async () => {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-startup-missing-workdir-'))
const canonicalWorkDir = await fs.realpath(workDir)
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -1042,7 +1043,7 @@ describe('WebSocket Chat Integration', () => {
})
expect(error?.message).toContain('Desktop service diagnostics:')
expect(error?.message).toContain(`sessionId: ${sessionId}`)
expect(error?.message).toContain(`workDir: ${workDir}`)
expect(error?.message).toContain(`workDir: ${canonicalWorkDir}`)
expect(error?.message).toContain('runtimeOverride: (none)')
expect(error?.message).toContain('activeProviderId:')
expect(error?.message).toContain('configuredProviders:')

View File

@ -849,7 +849,8 @@ describe('SessionService', () => {
)
// Verify the file was created
const sanitized = sanitizePath(workDir)
const canonicalWorkDir = await fs.realpath(workDir)
const sanitized = sanitizePath(canonicalWorkDir)
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
const stat = await fs.stat(filePath)
expect(stat.isFile()).toBe(true)
@ -981,11 +982,12 @@ describe('SessionService', () => {
})
it('should detect placeholder launch info for desktop-created sessions', async () => {
const { sessionId } = await service.createSession(os.tmpdir())
const workDir = await fs.realpath(os.tmpdir())
const { sessionId } = await service.createSession(workDir)
const launchInfo = await service.getSessionLaunchInfo(sessionId)
expect(launchInfo).not.toBeNull()
expect(launchInfo!.workDir).toBe(os.tmpdir())
expect(launchInfo!.workDir).toBe(workDir)
expect(launchInfo!.transcriptMessageCount).toBe(0)
expect(launchInfo!.customTitle).toBeNull()
})
@ -1252,7 +1254,7 @@ describe('Sessions API', () => {
isGitRepo: boolean
}
expect(statusBody.state).toBe('ok')
expect(statusBody.workDir).toBe(workDir)
expect(statusBody.workDir).toBe(await fs.realpath(workDir))
expect(statusBody.isGitRepo).toBe(true)
expect(statusBody.changedFiles).toEqual(
expect.arrayContaining([
@ -1733,6 +1735,54 @@ describe('Sessions API', () => {
])
})
it('trimSessionMessagesFrom should remove orphan transcript entries beyond the rewind point', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const firstUserId = crypto.randomUUID()
const firstAssistantId = crypto.randomUUID()
const secondUserId = crypto.randomUUID()
const secondAssistantId = crypto.randomUUID()
const filePath = await writeSessionFile('-tmp-api-rewind-orphans', sessionId, [
makeSnapshotEntry(),
makeSessionMetaEntry('/tmp/project-with-hyphen'),
{
...makeUserEntry('first prompt', firstUserId),
sessionId,
},
{
...makeAssistantEntry('first reply', firstUserId),
uuid: firstAssistantId,
},
{
...makeUserEntry('second prompt', secondUserId),
parentUuid: firstAssistantId,
sessionId,
},
{
...makeAssistantEntry('second reply', secondUserId),
uuid: secondAssistantId,
},
{
...makeAssistantEntry('late stale reply', secondUserId),
uuid: crypto.randomUUID(),
},
])
const result = await service.trimSessionMessagesFrom(sessionId, firstUserId)
expect(result.removedMessageIds).toContain(firstUserId)
expect(result.removedMessageIds).toContain(secondUserId)
const raw = await fs.readFile(filePath, 'utf-8')
expect(raw).toContain('"type":"session-meta"')
expect(raw).not.toContain('late stale reply')
expect(await service.getSessionMessages(sessionId)).toEqual([])
const launchInfo = await service.getSessionLaunchInfo(sessionId)
expect(launchInfo).not.toBeNull()
expect(launchInfo!.workDir).toBe('/tmp/project-with-hyphen')
expect(launchInfo!.transcriptMessageCount).toBe(0)
})
it('POST /api/sessions/:id/rewind should target the selected message id instead of a shifted visible index', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff'
const firstUserId = crypto.randomUUID()

View File

@ -133,10 +133,6 @@ export class ConversationService {
)
}
if (shouldReplacePlaceholder) {
await sessionService.deleteSessionFile(sessionId)
}
if (!fs.existsSync(workDir) || !fs.statSync(workDir).isDirectory()) {
throw new ConversationStartupError(
`Working directory does not exist or is not a directory: ${workDir}`,
@ -144,6 +140,10 @@ export class ConversationService {
)
}
if (shouldReplacePlaceholder) {
await sessionService.clearSessionTranscript(sessionId, workDir)
}
const args = this.buildSessionCliArgs(
sessionId,
sdkUrl,
@ -540,6 +540,20 @@ export class ConversationService {
}
}
async stopSessionAndWait(sessionId: string, timeoutMs = 2_000): Promise<void> {
const session = this.sessions.get(sessionId)
if (!session) return
this.sessions.delete(sessionId)
session.proc.kill()
await Promise.race([
session.proc.exited.catch(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
])
await this.waitForProcessOutputDrain(session, timeoutMs)
}
markSessionDeleted(sessionId: string): void {
this.deletedSessions.add(sessionId)
this.stopSession(sessionId)

View File

@ -1096,9 +1096,7 @@ export async function executeSessionRewind(
target.targetUserMessageId,
)
if (conversationService.hasSession(sessionId)) {
conversationService.stopSession(sessionId)
}
await conversationService.stopSessionAndWait(sessionId)
if (preview.available && snapshots) {
const targetSnapshot = findTargetSnapshot(snapshots, target.targetUserMessageId)

View File

@ -1197,18 +1197,20 @@ export class SessionService {
// expand relative paths — in bundled sidecar mode the server's cwd is
// typically '/'. Callers (IM adapters) already send absolute realPath,
// but we log here so cwd regressions are caught early.
const absWorkDir = path.resolve(resolvedWorkDir)
const resolvedPath = path.resolve(resolvedWorkDir)
let absWorkDir: string
try {
absWorkDir = await fs.realpath(resolvedPath)
} catch {
throw ApiError.badRequest(`Working directory does not exist: ${resolvedPath}`)
}
console.log(
`[SessionService] createSession: requested workDir=${JSON.stringify(
workDir,
)}, resolved=${absWorkDir} (process.cwd()=${process.cwd()})`,
)
let stat
try {
stat = await fs.stat(absWorkDir)
} catch {
throw ApiError.badRequest(`Working directory does not exist: ${absWorkDir}`)
}
stat = await fs.stat(absWorkDir)
if (!stat.isDirectory()) {
throw ApiError.badRequest(`Working directory is not a directory: ${absWorkDir}`)
}
@ -1378,7 +1380,8 @@ export class SessionService {
async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise<void> {
let found = await this.findSessionFile(sessionId)
if (!found && fallbackWorkDir) {
const absWorkDir = path.resolve(fallbackWorkDir)
const resolvedPath = path.resolve(fallbackWorkDir)
const absWorkDir = await fs.realpath(resolvedPath).catch(() => resolvedPath)
const dirPath = path.join(this.getProjectsDir(), this.sanitizePath(absWorkDir))
await fs.mkdir(dirPath, { recursive: true })
found = {
@ -1462,6 +1465,11 @@ export class SessionService {
const removedMessageIds = activeMessages
.slice(startIndex)
.map((message) => message.id)
const remainingMessageIds = new Set(
activeMessages
.slice(0, startIndex)
.map((message) => message.id),
)
if (removedMessageIds.length === 0) {
return { removedCount: 0, removedMessageIds: [] }
@ -1469,7 +1477,17 @@ export class SessionService {
const removedIds = new Set(removedMessageIds)
const filteredEntries = entries.filter(
(entry) => !(typeof entry.uuid === 'string' && removedIds.has(entry.uuid)),
(entry) => {
if (typeof entry.uuid !== 'string') return true
if (removedIds.has(entry.uuid)) return false
if (
entry.message?.role &&
(entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system')
) {
return remainingMessageIds.has(entry.uuid)
}
return true
},
)
const content =