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 () => { 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 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`, { const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -1042,7 +1043,7 @@ describe('WebSocket Chat Integration', () => {
}) })
expect(error?.message).toContain('Desktop service diagnostics:') expect(error?.message).toContain('Desktop service diagnostics:')
expect(error?.message).toContain(`sessionId: ${sessionId}`) 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('runtimeOverride: (none)')
expect(error?.message).toContain('activeProviderId:') expect(error?.message).toContain('activeProviderId:')
expect(error?.message).toContain('configuredProviders:') expect(error?.message).toContain('configuredProviders:')

View File

@ -849,7 +849,8 @@ describe('SessionService', () => {
) )
// Verify the file was created // 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 filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
const stat = await fs.stat(filePath) const stat = await fs.stat(filePath)
expect(stat.isFile()).toBe(true) expect(stat.isFile()).toBe(true)
@ -981,11 +982,12 @@ describe('SessionService', () => {
}) })
it('should detect placeholder launch info for desktop-created sessions', async () => { 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) const launchInfo = await service.getSessionLaunchInfo(sessionId)
expect(launchInfo).not.toBeNull() expect(launchInfo).not.toBeNull()
expect(launchInfo!.workDir).toBe(os.tmpdir()) expect(launchInfo!.workDir).toBe(workDir)
expect(launchInfo!.transcriptMessageCount).toBe(0) expect(launchInfo!.transcriptMessageCount).toBe(0)
expect(launchInfo!.customTitle).toBeNull() expect(launchInfo!.customTitle).toBeNull()
}) })
@ -1252,7 +1254,7 @@ describe('Sessions API', () => {
isGitRepo: boolean isGitRepo: boolean
} }
expect(statusBody.state).toBe('ok') 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.isGitRepo).toBe(true)
expect(statusBody.changedFiles).toEqual( expect(statusBody.changedFiles).toEqual(
expect.arrayContaining([ 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 () => { 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 sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff'
const firstUserId = crypto.randomUUID() 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()) { if (!fs.existsSync(workDir) || !fs.statSync(workDir).isDirectory()) {
throw new ConversationStartupError( throw new ConversationStartupError(
`Working directory does not exist or is not a directory: ${workDir}`, `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( const args = this.buildSessionCliArgs(
sessionId, sessionId,
sdkUrl, 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 { markSessionDeleted(sessionId: string): void {
this.deletedSessions.add(sessionId) this.deletedSessions.add(sessionId)
this.stopSession(sessionId) this.stopSession(sessionId)

View File

@ -1096,9 +1096,7 @@ export async function executeSessionRewind(
target.targetUserMessageId, target.targetUserMessageId,
) )
if (conversationService.hasSession(sessionId)) { await conversationService.stopSessionAndWait(sessionId)
conversationService.stopSession(sessionId)
}
if (preview.available && snapshots) { if (preview.available && snapshots) {
const targetSnapshot = findTargetSnapshot(snapshots, target.targetUserMessageId) 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 // expand relative paths — in bundled sidecar mode the server's cwd is
// typically '/'. Callers (IM adapters) already send absolute realPath, // typically '/'. Callers (IM adapters) already send absolute realPath,
// but we log here so cwd regressions are caught early. // 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( console.log(
`[SessionService] createSession: requested workDir=${JSON.stringify( `[SessionService] createSession: requested workDir=${JSON.stringify(
workDir, workDir,
)}, resolved=${absWorkDir} (process.cwd()=${process.cwd()})`, )}, resolved=${absWorkDir} (process.cwd()=${process.cwd()})`,
) )
let stat let stat
try { stat = await fs.stat(absWorkDir)
stat = await fs.stat(absWorkDir)
} catch {
throw ApiError.badRequest(`Working directory does not exist: ${absWorkDir}`)
}
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
throw ApiError.badRequest(`Working directory is not a directory: ${absWorkDir}`) 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> { async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise<void> {
let found = await this.findSessionFile(sessionId) let found = await this.findSessionFile(sessionId)
if (!found && fallbackWorkDir) { 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)) const dirPath = path.join(this.getProjectsDir(), this.sanitizePath(absWorkDir))
await fs.mkdir(dirPath, { recursive: true }) await fs.mkdir(dirPath, { recursive: true })
found = { found = {
@ -1462,6 +1465,11 @@ export class SessionService {
const removedMessageIds = activeMessages const removedMessageIds = activeMessages
.slice(startIndex) .slice(startIndex)
.map((message) => message.id) .map((message) => message.id)
const remainingMessageIds = new Set(
activeMessages
.slice(0, startIndex)
.map((message) => message.id),
)
if (removedMessageIds.length === 0) { if (removedMessageIds.length === 0) {
return { removedCount: 0, removedMessageIds: [] } return { removedCount: 0, removedMessageIds: [] }
@ -1469,7 +1477,17 @@ export class SessionService {
const removedIds = new Set(removedMessageIds) const removedIds = new Set(removedMessageIds)
const filteredEntries = entries.filter( 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 = const content =