diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 99783135..3d3636bd 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -551,7 +551,7 @@ describe('SessionService', () => { expect(page2.sessions).toHaveLength(1) }) - it('should only scan the requested page when listing many sessions', async () => { + it('should scan summaries before pagination so metadata-only writes cannot skew order', async () => { for (let i = 0; i < 12; i++) { const id = `1000000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee` const filePath = await writeSessionFile('-tmp-many-sessions', id, [ @@ -576,7 +576,49 @@ describe('SessionService', () => { expect(result.total).toBe(12) expect(result.sessions).toHaveLength(3) - expect(scanCount).toBe(3) + expect(scanCount).toBe(12) + }) + + it('should ignore metadata-only writes when sorting and dating the session list', async () => { + const activeSessionId = '10000000-aaaa-bbbb-cccc-eeeeeeeeeeee' + const viewedHistorySessionId = '10000001-aaaa-bbbb-cccc-eeeeeeeeeeee' + const activeFilePath = await writeSessionFile('-tmp-viewed-history-sessions', activeSessionId, [ + makeSnapshotEntry(), + { + ...makeUserEntry('Recent real work'), + timestamp: '2026-07-02T02:00:00.000Z', + }, + { + ...makeAssistantEntry('Recent reply'), + timestamp: '2026-07-02T02:05:00.000Z', + }, + ]) + const historyFilePath = await writeSessionFile('-tmp-viewed-history-sessions', viewedHistorySessionId, [ + makeSnapshotEntry(), + { + ...makeUserEntry('Older work'), + timestamp: '2026-07-01T02:00:00.000Z', + }, + { + ...makeAssistantEntry('Older reply'), + timestamp: '2026-07-01T02:05:00.000Z', + }, + { + ...makeSessionMetaEntry('/tmp/viewed-history'), + timestamp: '2026-07-02T03:00:00.000Z', + }, + ]) + await fs.utimes(activeFilePath, new Date('2026-07-02T02:05:00.000Z'), new Date('2026-07-02T02:05:00.000Z')) + await fs.utimes(historyFilePath, new Date('2026-07-02T03:00:00.000Z'), new Date('2026-07-02T03:00:00.000Z')) + + const result = await service.listSessions({ project: '/tmp/viewed-history-sessions', limit: 2 }) + + expect(result.sessions.map((session) => session.id)).toEqual([ + activeSessionId, + viewedHistorySessionId, + ]) + expect(result.sessions.find((session) => session.id === viewedHistorySessionId)?.modifiedAt) + .toBe('2026-07-01T02:05:00.000Z') }) it('should reuse cached list metadata for repeated requests', async () => { @@ -604,7 +646,7 @@ describe('SessionService', () => { const second = await service.listSessions({ limit: 3, offset: 0 }) expect(first.sessions.map((session) => session.id)).toEqual(second.sessions.map((session) => session.id)) - expect(scanCount).toBe(3) + expect(scanCount).toBe(5) }) it('should reuse unchanged file summaries after the list response cache is cleared', async () => { @@ -1345,6 +1387,42 @@ describe('SessionService', () => { expect(launchInfo?.permissionMode).toBe('plan') }) + it('should not append duplicate runtime metadata when it already matches', async () => { + const workDir = '/tmp/runtime-idempotent' + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const filePath = await writeSessionFile(sanitizePath(workDir), sessionId, [ + makeSnapshotEntry(), + { + ...makeSessionMetaEntry(workDir), + runtimeProviderId: 'provider-a', + runtimeModelId: 'model-a', + effortLevel: 'max', + }, + makeUserEntry('Runtime metadata should stay stable'), + ]) + const before = await fs.readFile(filePath, 'utf-8') + + await service.appendSessionMetadata(sessionId, { + workDir, + runtimeProviderId: 'provider-a', + runtimeModelId: 'model-a', + effortLevel: 'max', + }) + + expect(await fs.readFile(filePath, 'utf-8')).toBe(before) + + await service.appendSessionMetadata(sessionId, { + workDir, + runtimeProviderId: 'provider-a', + runtimeModelId: 'model-b', + effortLevel: 'max', + }) + + const afterChange = await fs.readFile(filePath, 'utf-8') + expect(afterChange).not.toBe(before) + expect(afterChange).toContain('"runtimeModelId":"model-b"') + }) + it('should remove stale placeholder files after native CLI worktree startup', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const sourceFile = await writeSessionFile('-tmp-source', sessionId, [ diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 7dea15d4..2a4c2084 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -286,6 +286,7 @@ type PersistedWorktreeSession = { type SessionListSummary = { title: string createdAt: string + modifiedAt: string messageCount: number workDir: string | null permissionMode?: string @@ -369,6 +370,64 @@ export class SessionService { } } + private latestTimestamp(current: string | null, candidate: unknown): string | null { + if (typeof candidate !== 'string') return current + const candidateTime = Date.parse(candidate) + if (!Number.isFinite(candidateTime)) return current + if (!current) return candidate + const currentTime = Date.parse(current) + return !Number.isFinite(currentTime) || candidateTime > currentTime + ? candidate + : current + } + + private metadataMatchesLaunchInfo( + launchInfo: SessionLaunchInfo | null, + metadata: { + workDir: string + repository?: PreparedSessionWorkspace['repository'] + permissionMode?: string + runtimeProviderId?: string | null + runtimeModelId?: string + effortLevel?: string + }, + ): boolean { + if (!launchInfo) return false + if (normalizeDriveRootPathForPlatform(launchInfo.workDir) !== metadata.workDir) { + return false + } + if ( + JSON.stringify(launchInfo.repository ?? null) !== + JSON.stringify(metadata.repository ?? null) + ) { + return false + } + if ( + metadata.permissionMode && + VALID_SESSION_PERMISSION_MODES.has(metadata.permissionMode) && + launchInfo.permissionMode !== metadata.permissionMode + ) { + return false + } + if ( + metadata.runtimeProviderId !== undefined && + launchInfo.runtimeProviderId !== metadata.runtimeProviderId + ) { + return false + } + if (metadata.runtimeModelId && launchInfo.runtimeModelId !== metadata.runtimeModelId) { + return false + } + if ( + metadata.effortLevel && + VALID_SESSION_EFFORT_LEVELS.has(metadata.effortLevel) && + launchInfo.effortLevel !== metadata.effortLevel + ) { + return false + } + return true + } + private async getCachedSessionListSummary( filePath: string, projectDir: string, @@ -469,10 +528,11 @@ export class SessionService { private async scanSessionListSummary( filePath: string, projectDir: string, - stat: { birthtime: Date }, + stat: { birthtime: Date; mtime: Date }, ): Promise { let createdAt = stat.birthtime.toISOString() let hasCreatedAt = false + let modifiedAt: string | null = null let messageCount = 0 let firstUserTitle: string | null = null let goalTitle: string | null = null @@ -515,6 +575,9 @@ export class SessionService { entry.message?.role ) { messageCount += 1 + if (!entry.isMeta) { + modifiedAt = this.latestTimestamp(modifiedAt, entry.timestamp) + } } if (entry.type === 'session-meta') { @@ -602,6 +665,7 @@ export class SessionService { firstUserTitle || 'Untitled Session', createdAt, + modifiedAt: modifiedAt ?? stat.mtime.toISOString(), messageCount, workDir: latestWorkDir || latestCwd || this.desanitizePath(projectDir), ...(permissionMode ? { permissionMode } : {}), @@ -631,7 +695,7 @@ export class SessionService { const summary = await this.scanSessionListSummary(filePath, projectPath, stat) return { title: summary.title, - modifiedAt: stat.mtime.toISOString(), + modifiedAt: summary.modifiedAt, workDir: summary.workDir ?? null, projectPath, } @@ -2163,20 +2227,44 @@ export class SessionService { } }))).filter((item): item is NonNullable => item !== null) - filesWithStats.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime()) + const summarizedFiles: Array<{ + filePath: string + projectDir: string + sessionId: string + stat: Stats + summary: SessionListSummary + }> = [] + for (const item of filesWithStats) { + try { + summarizedFiles.push({ + ...item, + summary: await this.getCachedSessionListSummary( + item.filePath, + item.projectDir, + item.stat, + ), + }) + } catch { + // Skip unreadable files + } + } - const total = filesWithStats.length + summarizedFiles.sort( + (a, b) => + Date.parse(b.summary.modifiedAt) - Date.parse(a.summary.modifiedAt), + ) + + const total = summarizedFiles.length const offset = options?.offset ?? 0 const limit = options?.limit ?? 50 - const paginatedFiles = filesWithStats.slice(offset, offset + limit) + const paginatedFiles = summarizedFiles.slice(offset, offset + limit) // Build session list items with metadata from file stats & a streaming // transcript summary. Keep this sequential so large JSONL files are not // loaded into memory concurrently by the sidebar's frequent refresh. const items: SessionListItem[] = [] - for (const { filePath, projectDir, sessionId, stat } of paginatedFiles) { + for (const { projectDir, sessionId, summary } of paginatedFiles) { try { - const summary = await this.getCachedSessionListSummary(filePath, projectDir, stat) const workDir = summary.workDir const projectRoot = await this.resolveProjectRootFromSessionMetadata({ worktreeSession: summary.worktreeSession, @@ -2190,7 +2278,7 @@ export class SessionService { id: sessionId, title: summary.title, createdAt: summary.createdAt, - modifiedAt: stat.mtime.toISOString(), + modifiedAt: summary.modifiedAt, messageCount: summary.messageCount, projectPath: projectDir, projectRoot, @@ -2604,6 +2692,18 @@ export class SessionService { const normalizedWorkDir = normalizeDriveRootPathForPlatform(metadata.workDir) const targetProjectDir = this.sanitizePath(normalizedWorkDir) const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`) + + if (!metadata.customTitle) { + const launchInfo = await this.getSessionLaunchInfo(sessionId) + if (this.metadataMatchesLaunchInfo(launchInfo, { + ...metadata, + workDir: normalizedWorkDir, + repository, + })) { + return + } + } + await fs.mkdir(path.dirname(targetFilePath), { recursive: true }) await this.appendJsonlEntry(targetFilePath, {