From 22285244628e6fdc9951ac6789d8dfd822b1bf1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 2 Jul 2026 21:00:07 +0800 Subject: [PATCH] fix: reduce sidecar transcript memory pressure (#933) Stream inspection transcript aggregation and avoid trace/message polling paths that repeatedly hydrate large session files. Tested: bun run check:server Tested: bun run check:desktop Confidence: high Scope-risk: moderate --- desktop/src/components/layout/TabBar.test.tsx | 9 + desktop/src/pages/TraceSession.test.tsx | 9 + desktop/src/pages/TraceSession.tsx | 17 +- src/server/__tests__/conversations.test.ts | 24 +- src/server/__tests__/trace-capture.test.ts | 154 ++++--- src/server/api/sessions.ts | 69 ++- src/server/api/traces.ts | 25 +- src/server/services/sessionService.ts | 429 ++++++++++++++++-- 8 files changed, 587 insertions(+), 149 deletions(-) diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 87f3b0d9..db60e509 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -17,6 +17,9 @@ const deleteSessionMock = vi.hoisted(() => vi.fn()) const openProjectMenuMock = vi.hoisted(() => ({ paths: [] as Array, })) +const sessionsApiMock = vi.hoisted(() => ({ + delete: vi.fn(() => Promise.resolve()), +})) function makeChatSession(chatState: ChatState): PerSessionState { return { @@ -96,6 +99,10 @@ vi.mock('../../i18n', () => ({ }, })) +vi.mock('../../api/sessions', () => ({ + sessionsApi: sessionsApiMock, +})) + vi.mock('./OpenProjectMenu', () => ({ OpenProjectMenu: ({ path }: { path: string | null | undefined }) => { if (!path) return null @@ -157,6 +164,8 @@ describe('TabBar', () => { deleteSessionMock.mockReset() deleteSessionMock.mockResolvedValue(undefined) openProjectMenuMock.paths = [] + sessionsApiMock.delete.mockClear() + sessionsApiMock.delete.mockResolvedValue(undefined) windowControlsMock.show = true vi.resetModules() }) diff --git a/desktop/src/pages/TraceSession.test.tsx b/desktop/src/pages/TraceSession.test.tsx index 42e028d9..30f0f533 100644 --- a/desktop/src/pages/TraceSession.test.tsx +++ b/desktop/src/pages/TraceSession.test.tsx @@ -410,6 +410,15 @@ describe('TraceSession', () => { expect(detail.getByRole('heading', { level: 2, name: 'claude-sonnet-4-5' })).toBeInTheDocument() }) + it('does not refetch full messages for identical trace polls', async () => { + vi.mocked(sessionsApi.getTrace).mockResolvedValue(baseTrace) + + await renderReady(20) + + await waitFor(() => expect(vi.mocked(sessionsApi.getTrace).mock.calls.length).toBeGreaterThanOrEqual(3)) + expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1) + }) + it('applies poll updates when a call changes without changing row counts', async () => { const pendingTrace: TraceSessionData = { ...baseTrace, diff --git a/desktop/src/pages/TraceSession.tsx b/desktop/src/pages/TraceSession.tsx index a597b277..5fc758b3 100644 --- a/desktop/src/pages/TraceSession.tsx +++ b/desktop/src/pages/TraceSession.tsx @@ -64,16 +64,15 @@ export function TraceSession({ if (!silent) setState({ status: 'loading' }) if (silent) setRefreshing(true) try { - const [trace, messageResponse] = await Promise.all([ - sessionsApi.getTrace(sessionId), - sessionsApi.getMessages(sessionId).catch(() => ({ messages: [] })), - ]) + const trace = await sessionsApi.getTrace(sessionId) if (!isTraceSessionData(trace)) { throw new Error(t('trace.snapshotEmpty')) } if (cancelled) return - const signature = traceSnapshotSignature(trace, messageResponse.messages) + const signature = traceSnapshotSignature(trace) if (silent && snapshotSignatureRef.current === signature) return + const messageResponse = await sessionsApi.getMessages(sessionId).catch(() => ({ messages: [] })) + if (cancelled) return snapshotSignatureRef.current = signature setState({ status: 'ready', trace, messages: messageResponse.messages }) setClockNowMs(Date.now()) @@ -456,7 +455,7 @@ function TraceEmpty() { ) } -function traceSnapshotSignature(trace: TraceSessionData, messages: MessageEntry[]): string { +function traceSnapshotSignature(trace: TraceSessionData): string { return JSON.stringify({ summary: trace.summary, calls: trace.calls.map((call) => ({ @@ -478,12 +477,6 @@ function traceSnapshotSignature(trace: TraceSessionData, messages: MessageEntry[ callId: event.callId, message: event.message, })), - messages: messages.map((message) => ({ - id: message.id, - type: message.type, - timestamp: message.timestamp, - content: message.content, - })), }) } diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 97b1a89c..213c3a03 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -5,7 +5,7 @@ * WebSocket 集成测试验证消息从客户端经过服务端到达 CLI 的完整流转。 */ -import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { describe, it, expect, beforeAll, afterAll, spyOn } from 'bun:test' import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' @@ -1712,6 +1712,28 @@ describe('WebSocket Chat Integration', () => { }) }) + it('should avoid transcript scans for active context-only inspection', async () => { + const usageSpy = spyOn(sessionService, 'getTranscriptUsage') + const estimateSpy = spyOn(sessionService, 'getTranscriptContextEstimate') + try { + const sessionId = `chat-context-only-fast-${crypto.randomUUID()}` + await runTurn(sessionId, 'hello before fast context-only inspection') + + const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=1&contextOnly=1`) + expect(res.status).toBe(200) + const body = await res.json() as any + + expect(body.context.model).toBe('mock-opus') + expect(body.contextEstimate).toBeUndefined() + expect(body.usage).toBeUndefined() + expect(usageSpy).not.toHaveBeenCalled() + expect(estimateSpy).not.toHaveBeenCalled() + } finally { + usageSpy.mockRestore() + estimateSpy.mockRestore() + } + }) + it('should return initial context for a prewarmed empty session on the first inspection request', async () => { await withMockInitDelay(500, async () => { const createRes = await fetch(`${baseUrl}/api/sessions`, { diff --git a/src/server/__tests__/trace-capture.test.ts b/src/server/__tests__/trace-capture.test.ts index 9299f57c..64b74a9c 100644 --- a/src/server/__tests__/trace-capture.test.ts +++ b/src/server/__tests__/trace-capture.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' import { createHash } from 'crypto' import * as fs from 'fs/promises' import * as os from 'os' @@ -1148,6 +1148,41 @@ describe('session trace API', () => { }) }) + test('lists trace sessions without loading full session messages', async () => { + await traceCaptureService.recordCall({ + sessionId: 'session-list-lightweight', + source: 'proxy', + model: 'gpt-5.5', + startedAt: '2026-06-09T08:00:00.000Z', + completedAt: '2026-06-09T08:00:00.015Z', + durationMs: 15, + request: { + method: 'POST', + url: 'https://api.example.test/v1/messages', + body: { model: 'gpt-5.5' }, + }, + response: { + status: 200, + body: { ok: true }, + }, + }) + + const getSessionSpy = spyOn(sessionService, 'getSession') + try { + const req = new Request('http://localhost:3456/api/traces') + const res = await handleApiRequest(req, new URL(req.url)) + const body = await res.json() as { + traces: Array<{ sessionId: string; session: unknown }> + } + + expect(res.status).toBe(200) + expect(body.traces[0].sessionId).toBe('session-list-lightweight') + expect(getSessionSpy).not.toHaveBeenCalled() + } finally { + getSessionSpy.mockRestore() + } + }) + test('deletes a trace session file and invalidates cached reads', async () => { await traceCaptureService.recordCall({ sessionId: 'session-delete-trace', @@ -1191,8 +1226,18 @@ describe('session trace API', () => { }) test('searches trace sessions by session title and project path before paginating', async () => { + const checkoutDir = path.join(tmpDir, 'checkout') + const otherDir = path.join(tmpDir, 'other') + await fs.mkdir(checkoutDir, { recursive: true }) + await fs.mkdir(otherDir, { recursive: true }) + const resolvedCheckoutDir = await fs.realpath(checkoutDir) + const alpha = await sessionService.createSession(checkoutDir) + await sessionService.renameSession(alpha.sessionId, 'Debug stuck checkout agent') + const beta = await sessionService.createSession(otherDir) + await sessionService.renameSession(beta.sessionId, 'Unrelated model run') + await traceCaptureService.recordCall({ - sessionId: 'session-title-alpha', + sessionId: alpha.sessionId, source: 'proxy', model: 'gpt-5.5', startedAt: '2026-06-09T08:00:00.000Z', @@ -1209,7 +1254,7 @@ describe('session trace API', () => { }, }) await traceCaptureService.recordCall({ - sessionId: 'session-title-beta', + sessionId: beta.sessionId, source: 'proxy', model: 'gpt-5.5', startedAt: '2026-06-09T08:00:01.000Z', @@ -1226,77 +1271,40 @@ describe('session trace API', () => { }, }) - const originalGetSession = sessionService.getSession - sessionService.getSession = (async (sessionId: string) => { - if (sessionId === 'session-title-alpha') { - return { - id: sessionId, - title: 'Debug stuck checkout agent', - createdAt: '2026-06-09T08:00:00.000Z', - modifiedAt: '2026-06-09T08:00:00.015Z', - messageCount: 2, - projectPath: '/tmp/checkout', - projectRoot: '/tmp/checkout', - workDir: '/tmp/checkout', - workDirExists: true, - messages: [], - } - } - if (sessionId === 'session-title-beta') { - return { - id: sessionId, - title: 'Unrelated model run', - createdAt: '2026-06-09T08:00:01.000Z', - modifiedAt: '2026-06-09T08:00:01.015Z', - messageCount: 2, - projectPath: '/tmp/other', - projectRoot: '/tmp/other', - workDir: '/tmp/other', - workDirExists: true, - messages: [], - } - } - return null - }) as typeof sessionService.getSession - - try { - const titleReq = new Request('http://localhost:3456/api/traces?q=stuck%20agent&limit=10&offset=0') - const titleRes = await handleApiRequest(titleReq, new URL(titleReq.url)) - const titleBody = await titleRes.json() as { - traces: Array<{ sessionId: string; session: { title: string; projectPath: string } | null }> - total: number - } - - expect(titleRes.status).toBe(200) - expect(titleBody.total).toBe(1) - expect(titleBody.traces.map((trace) => trace.sessionId)).toEqual(['session-title-alpha']) - expect(titleBody.traces[0].session?.title).toBe('Debug stuck checkout agent') - - const pathReq = new Request('http://localhost:3456/api/traces?q=checkout&limit=10&offset=0') - const pathRes = await handleApiRequest(pathReq, new URL(pathReq.url)) - const pathBody = await pathRes.json() as { - traces: Array<{ sessionId: string; session: { projectPath: string } | null }> - total: number - } - - expect(pathRes.status).toBe(200) - expect(pathBody.total).toBe(1) - expect(pathBody.traces.map((trace) => trace.sessionId)).toEqual(['session-title-alpha']) - expect(pathBody.traces[0].session?.projectPath).toBe('/tmp/checkout') - - const missReq = new Request('http://localhost:3456/api/traces?q=missing-title&limit=10&offset=0') - const missRes = await handleApiRequest(missReq, new URL(missReq.url)) - const missBody = await missRes.json() as { - traces: Array<{ sessionId: string }> - total: number - } - - expect(missRes.status).toBe(200) - expect(missBody.total).toBe(0) - expect(missBody.traces).toEqual([]) - } finally { - sessionService.getSession = originalGetSession + const titleReq = new Request('http://localhost:3456/api/traces?q=stuck%20agent&limit=10&offset=0') + const titleRes = await handleApiRequest(titleReq, new URL(titleReq.url)) + const titleBody = await titleRes.json() as { + traces: Array<{ sessionId: string; session: { title: string; projectPath: string } | null }> + total: number } + + expect(titleRes.status).toBe(200) + expect(titleBody.total).toBe(1) + expect(titleBody.traces.map((trace) => trace.sessionId)).toEqual([alpha.sessionId]) + expect(titleBody.traces[0].session?.title).toBe('Debug stuck checkout agent') + + const pathReq = new Request('http://localhost:3456/api/traces?q=checkout&limit=10&offset=0') + const pathRes = await handleApiRequest(pathReq, new URL(pathReq.url)) + const pathBody = await pathRes.json() as { + traces: Array<{ sessionId: string; session: { projectPath: string; workDir: string | null } | null }> + total: number + } + + expect(pathRes.status).toBe(200) + expect(pathBody.total).toBe(1) + expect(pathBody.traces.map((trace) => trace.sessionId)).toEqual([alpha.sessionId]) + expect(pathBody.traces[0].session?.workDir).toBe(resolvedCheckoutDir) + + const missReq = new Request('http://localhost:3456/api/traces?q=missing-title&limit=10&offset=0') + const missRes = await handleApiRequest(missReq, new URL(missReq.url)) + const missBody = await missRes.json() as { + traces: Array<{ sessionId: string }> + total: number + } + + expect(missRes.status).toBe(200) + expect(missBody.total).toBe(0) + expect(missBody.traces).toEqual([]) }) }) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 6443b3c4..4fb0bb98 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -267,24 +267,39 @@ async function getSessionMessages(sessionId: string): Promise { } async function getSessionTrace(sessionId: string): Promise { - const [trace, session] = await Promise.all([ + const [trace, sessionMeta] = await Promise.all([ traceCaptureService.getSessionTrace(sessionId), - sessionService.getSession(sessionId).catch(() => null), + getSessionTraceMeta(sessionId), ]) return Response.json({ ...trace, calls: trace.calls.map((call) => trimTraceCallPreviews(call)), - session: session + session: sessionMeta ? { - id: session.id, - title: session.title, - projectPath: session.projectPath, - workDir: session.workDir, + id: sessionId, + title: sessionMeta.title, + projectPath: sessionMeta.projectPath, + workDir: sessionMeta.workDir, } : null, }) } +async function getSessionTraceMeta(sessionId: string): Promise<{ + title: string + projectPath: string + workDir: string | null +} | null> { + const found = await sessionService.findSessionFile(sessionId) + if (!found) return null + const meta = await sessionService.getSessionTitleAndMeta(found.filePath) + return { + title: meta.title, + projectPath: meta.projectPath, + workDir: meta.workDir, + } +} + async function getSessionTraceCall(sessionId: string, callId: string | undefined): Promise { if (!callId || callId.trim().length === 0) { throw ApiError.badRequest('callId is required') @@ -541,16 +556,23 @@ async function getSessionSlashCommands(sessionId: string): Promise { async function getSessionInspection(sessionId: string, url: URL): Promise { const includeContext = url.searchParams.get('includeContext') !== '0' const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1' + let transcriptSnapshot: Awaited> | undefined + const getTranscriptSnapshot = async () => { + if (transcriptSnapshot !== undefined) return transcriptSnapshot + transcriptSnapshot = await sessionService.getInspectionTranscriptSnapshot(sessionId).catch(() => null) + return transcriptSnapshot + } + + const active = conversationService.hasSession(sessionId) const workDir = conversationService.getSessionWorkDir(sessionId) || - await sessionService.getSessionWorkDir(sessionId) + (await getTranscriptSnapshot())?.launchInfo.workDir if (!workDir) { throw ApiError.notFound(`Session not found: ${sessionId}`) } - const active = conversationService.hasSession(sessionId) - const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null) + const launchInfo = !active ? (await getTranscriptSnapshot())?.launchInfo ?? null : null const permissionMode = active ? conversationService.getSessionPermissionMode(sessionId) : launchInfo?.permissionMode ?? 'default' @@ -558,7 +580,9 @@ async function getSessionInspection(sessionId: string, url: URL): Promise message?.type === 'system' && message.subtype === 'init') - const transcriptMetadata = await sessionService.getTranscriptMetadata(sessionId) + const transcriptMetadata = !active || !initMessage + ? (await getTranscriptSnapshot())?.metadata ?? null + : null const cachedSlashCommands = getSlashCommands(sessionId) const skillSlashCommands = await listSkillSlashCommands(workDir) const fallbackSlashCommands = cachedSlashCommands.length > 0 @@ -586,13 +610,14 @@ async function getSessionInspection(sessionId: string, url: URL): Promise { - const session = await sessionService.getSession(item.sessionId).catch(() => null) + const session = await getTraceSessionMeta(item.sessionId) return { ...item, session: session ? { - id: session.id, + id: item.sessionId, title: session.title, projectPath: session.projectPath, workDir: session.workDir, @@ -150,12 +150,12 @@ async function decorateTraceListItem(item: TraceSessionListItem): Promise> { - const session = await sessionService.getSession(item.sessionId).catch(() => null) + const session = await getTraceSessionMeta(item.sessionId) return { sessionId: item.sessionId, session: session ? { - id: session.id, + id: item.sessionId, title: session.title, projectPath: session.projectPath, workDir: session.workDir, @@ -164,6 +164,21 @@ async function decorateTraceFileCandidate(item: TraceSessionFileItem): Promise

{ + const found = await sessionService.findSessionFile(sessionId) + if (!found) return null + const meta = await sessionService.getSessionTitleAndMeta(found.filePath) + return { + title: meta.title, + projectPath: meta.projectPath, + workDir: meta.workDir, + } +} + function traceListItemMatchesQuery(item: Pick, query: string): boolean { const terms = query.toLowerCase().split(/\s+/).filter(Boolean) if (terms.length === 0) return true diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 70700da5..da9dcbaa 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -40,7 +40,9 @@ import { import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js' import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js' import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js' -import { roughTokenCountEstimationForMessages } from '../../services/tokenEstimation.js' +import { + roughTokenCountEstimationForMessage, +} from '../../services/tokenEstimation.js' import { ProviderService } from './providerService.js' // ============================================================================ @@ -89,6 +91,13 @@ export type SessionLaunchInfo = { effortLevel?: string } +export type SessionInspectionTranscriptSnapshot = { + launchInfo: SessionLaunchInfo + metadata: TranscriptMetadataSnapshot + usage: TranscriptUsageSnapshot | null + contextEstimate: TranscriptContextEstimate | null +} + export type TrimSessionResult = { removedCount: number removedMessageIds: string[] @@ -426,6 +435,36 @@ export class SessionService { return entries } + private async streamJsonlFile( + filePath: string, + onEntry: (entry: RawEntry) => void, + ): Promise { + const stream = createReadStream(filePath, { encoding: 'utf8' }) + const lines = createInterface({ + input: stream, + crlfDelay: Infinity, + }) + + try { + for await (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + try { + onEntry(JSON.parse(trimmed) as RawEntry) + } catch { + // skip malformed lines + } + } + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err + } + } finally { + lines.close() + stream.destroy() + } + } + private async scanSessionListSummary( filePath: string, projectDir: string, @@ -1401,8 +1440,9 @@ export class SessionService { private async getProviderContextWindowForSession( sessionId: string, model: string, + launchInfoOverride?: Pick | null, ): Promise { - const launchInfo = await this.getSessionLaunchInfo(sessionId).catch(() => null) + const launchInfo = launchInfoOverride ?? await this.getSessionLaunchInfo(sessionId).catch(() => null) const providerIds: string[] = [] const allowSavedProviderInference = launchInfo?.runtimeProviderId === undefined @@ -1528,8 +1568,16 @@ export class SessionService { return contextWindow } - private async getTranscriptContextWindow(sessionId: string, model: string): Promise { - const providerContextWindow = await this.getProviderContextWindowForSession(sessionId, model) + private async getTranscriptContextWindow( + sessionId: string, + model: string, + launchInfo?: Pick | null, + ): Promise { + const providerContextWindow = await this.getProviderContextWindowForSession( + sessionId, + model, + launchInfo, + ) if (providerContextWindow !== undefined) { return providerContextWindow } @@ -1571,49 +1619,22 @@ export class SessionService { return metadata } - async getTranscriptContextEstimate(sessionId: string): Promise { - const found = await this.findSessionFile(sessionId) - if (!found) return null - - const entries = await this.readJsonlFile(found.filePath) - let latest: { + private async buildTranscriptContextEstimate( + sessionId: string, + latest: { model: string inputTokens: number outputTokens: number cacheReadInputTokens: number cacheCreationInputTokens: number - } | null = null - - for (const entry of entries) { - const usage = entry.message?.usage - const model = entry.message?.model - if (!usage || typeof model !== 'string') continue - - const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0 - const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0 - const cacheReadInputTokens = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0 - const cacheCreationInputTokens = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0 - const promptTokens = inputTokens + cacheReadInputTokens + cacheCreationInputTokens - if (promptTokens === 0 && outputTokens === 0) continue - - latest = { - model, - inputTokens, - outputTokens, - cacheReadInputTokens, - cacheCreationInputTokens, - } - } - - if (!latest) return null - - const rawMaxTokens = await this.getTranscriptContextWindow(sessionId, latest.model) + }, + estimatedTokensFromMessages: number, + transcriptHasMediaInput: boolean, + launchInfo?: Pick | null, + ): Promise { + const rawMaxTokens = await this.getTranscriptContextWindow(sessionId, latest.model, launchInfo) const promptTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens - const transcriptMessages = entries.filter(entry => - entry.type === 'user' || entry.type === 'assistant' || entry.type === 'attachment', - ) as Parameters[0] - const estimatedTokens = - roughTokenCountEstimationForMessages(transcriptMessages) || promptTokens + const estimatedTokens = estimatedTokensFromMessages || promptTokens const contextBudget = calculateContextBudget({ estimatedTokens, contextWindow: rawMaxTokens, @@ -1626,7 +1647,7 @@ export class SessionService { usageTrust: getProviderUsageTrust({ isFirstPartyAnthropic: isFirstPartyAnthropicBaseUrl(), }), - hasMediaInput: hasMediaInput(transcriptMessages), + hasMediaInput: transcriptHasMediaInput, }) const totalTokens = contextBudget.usedTokens const percentage = rawMaxTokens > 0 ? Math.round((totalTokens / rawMaxTokens) * 100) : 0 @@ -1681,6 +1702,63 @@ export class SessionService { } } + async getTranscriptContextEstimate(sessionId: string): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) return null + + const entries = await this.readJsonlFile(found.filePath) + let latest: { + model: string + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number + } | null = null + let estimatedTokensFromMessages = 0 + let transcriptHasMediaInput = false + + for (const entry of entries) { + if ( + entry.type === 'user' || + entry.type === 'assistant' || + entry.type === 'attachment' + ) { + estimatedTokensFromMessages += roughTokenCountEstimationForMessage(entry) + if (!transcriptHasMediaInput && hasMediaInput([entry])) { + transcriptHasMediaInput = true + } + } + + const usage = entry.message?.usage + const model = entry.message?.model + if (!usage || typeof model !== 'string') continue + + const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0 + const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0 + const cacheReadInputTokens = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0 + const cacheCreationInputTokens = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0 + const promptTokens = inputTokens + cacheReadInputTokens + cacheCreationInputTokens + if (promptTokens === 0 && outputTokens === 0) continue + + latest = { + model, + inputTokens, + outputTokens, + cacheReadInputTokens, + cacheCreationInputTokens, + } + } + + if (!latest) return null + + return await this.buildTranscriptContextEstimate( + sessionId, + latest, + estimatedTokensFromMessages, + transcriptHasMediaInput, + ) + } + async getTranscriptUsage(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return null @@ -1800,6 +1878,273 @@ export class SessionService { } } + async getInspectionTranscriptSnapshot(sessionId: string): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) return null + + let latestWorkDir: string | null = null + let latestCwd: string | null = null + let repository: PreparedSessionWorkspace['repository'] | undefined + let worktreeSession: PersistedWorktreeSession | null | undefined + let permissionMode: string | undefined + let runtimeProviderId: string | null | undefined + let runtimeModelId: string | undefined + let effortLevel: string | undefined + let customTitle: string | null = null + let transcriptMessageCount = 0 + const metadata: TranscriptMetadataSnapshot = {} + + const models = new Map() + let totalCostUSD = 0 + let totalInputTokens = 0 + let totalOutputTokens = 0 + let totalCacheReadInputTokens = 0 + let totalCacheCreationInputTokens = 0 + let totalWebSearchRequests = 0 + let hasUnknownModelCost = false + let firstUsageAt: number | null = null + let lastUsageAt: number | null = null + + let latestContextUsage: { + model: string + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number + } | null = null + let estimatedTokensFromMessages = 0 + let transcriptHasMediaInput = false + + await this.streamJsonlFile(found.filePath, (entry) => { + if (typeof entry.message?.model === 'string') { + metadata.model = entry.message.model + } + if (typeof entry.cwd === 'string') { + metadata.cwd = entry.cwd + latestCwd = normalizeDriveRootPathForPlatform(entry.cwd) + } + if (typeof entry.version === 'string') { + metadata.version = entry.version + } + + if (entry.type === 'session-meta') { + const record = entry as Record + if (typeof record.workDir === 'string') { + latestWorkDir = normalizeDriveRootPathForPlatform(record.workDir) + } + if ( + typeof entry.permissionMode === 'string' && + VALID_SESSION_PERMISSION_MODES.has(entry.permissionMode) + ) { + permissionMode = entry.permissionMode + } + if (record.runtimeProviderId === null || typeof record.runtimeProviderId === 'string') { + runtimeProviderId = record.runtimeProviderId as string | null + } + if (typeof record.runtimeModelId === 'string') { + runtimeModelId = record.runtimeModelId + } + if ( + typeof record.effortLevel === 'string' && + VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel) + ) { + effortLevel = record.effortLevel + } + } + + const candidateRepository = (entry as Record)?.repository + if (candidateRepository && typeof candidateRepository === 'object') { + repository = candidateRepository as PreparedSessionWorkspace['repository'] + } + + if (entry.type === 'worktree-state') { + if (entry.worktreeSession === null) { + worktreeSession = null + } else if ( + entry.worktreeSession && + typeof entry.worktreeSession === 'object' && + typeof entry.worktreeSession.worktreePath === 'string' && + typeof entry.worktreeSession.worktreeName === 'string' + ) { + worktreeSession = entry.worktreeSession + } + } + + if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { + customTitle = entry.customTitle + } + + if ( + !entry.isMeta && + !!entry.message?.role && + (entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system') + ) { + transcriptMessageCount += 1 + } + + if ( + entry.type === 'user' || + entry.type === 'assistant' || + entry.type === 'attachment' + ) { + estimatedTokensFromMessages += roughTokenCountEstimationForMessage(entry) + if (!transcriptHasMediaInput && hasMediaInput([entry])) { + transcriptHasMediaInput = true + } + } + + const usage = entry.message?.usage + const model = entry.message?.model + if (!usage || typeof model !== 'string') return + + const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0 + const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0 + const cacheReadInputTokens = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0 + const cacheCreationInputTokens = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0 + const webSearchRequests = typeof usage.server_tool_use?.web_search_requests === 'number' + ? usage.server_tool_use.web_search_requests + : 0 + const promptTokens = inputTokens + cacheReadInputTokens + cacheCreationInputTokens + + if (promptTokens !== 0 || outputTokens !== 0) { + latestContextUsage = { + model, + inputTokens, + outputTokens, + cacheReadInputTokens, + cacheCreationInputTokens, + } + } + + if ( + inputTokens === 0 && + outputTokens === 0 && + cacheReadInputTokens === 0 && + cacheCreationInputTokens === 0 && + webSearchRequests === 0 + ) { + return + } + + const canonical = getCanonicalName(model) + if (!Object.prototype.hasOwnProperty.call(MODEL_COSTS, canonical)) { + hasUnknownModelCost = true + } + + const costUsage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_read_input_tokens: cacheReadInputTokens, + cache_creation_input_tokens: cacheCreationInputTokens, + server_tool_use: { web_search_requests: webSearchRequests }, + speed: usage.speed, + } as Parameters[1] + const costUSD = calculateUSDCost(model, costUsage) + + let modelUsage = models.get(model) + if (!modelUsage) { + modelUsage = { + model, + displayName: canonical, + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0, + costDisplay: '$0.0000', + contextWindow: 0, + maxOutputTokens: getModelMaxOutputTokens(model).default, + } + models.set(model, modelUsage) + } + + modelUsage.inputTokens += inputTokens + modelUsage.outputTokens += outputTokens + modelUsage.cacheReadInputTokens += cacheReadInputTokens + modelUsage.cacheCreationInputTokens += cacheCreationInputTokens + modelUsage.webSearchRequests += webSearchRequests + modelUsage.costUSD += costUSD + modelUsage.costDisplay = this.formatCost(modelUsage.costUSD) + + totalCostUSD += costUSD + totalInputTokens += inputTokens + totalOutputTokens += outputTokens + totalCacheReadInputTokens += cacheReadInputTokens + totalCacheCreationInputTokens += cacheCreationInputTokens + totalWebSearchRequests += webSearchRequests + + if (entry.timestamp) { + const time = Date.parse(entry.timestamp) + if (!Number.isNaN(time)) { + firstUsageAt = firstUsageAt === null ? time : Math.min(firstUsageAt, time) + lastUsageAt = lastUsageAt === null ? time : Math.max(lastUsageAt, time) + } + } + }) + + const workDir = latestWorkDir || latestCwd || this.desanitizePath(found.projectDir) || process.cwd() + const launchInfo: SessionLaunchInfo = { + filePath: found.filePath, + projectDir: found.projectDir, + workDir, + repository, + worktreeSession, + transcriptMessageCount, + customTitle, + permissionMode, + ...(runtimeProviderId !== undefined ? { runtimeProviderId } : {}), + ...(runtimeModelId ? { runtimeModelId } : {}), + ...(effortLevel ? { effortLevel } : {}), + } + + for (const modelUsage of models.values()) { + modelUsage.contextWindow = await this.getTranscriptContextWindow( + sessionId, + modelUsage.model, + launchInfo, + ) + } + + const usage = models.size === 0 + ? null + : { + source: 'transcript' as const, + totalCostUSD, + costDisplay: this.formatCost(totalCostUSD), + hasUnknownModelCost, + totalAPIDuration: 0, + totalDuration: + firstUsageAt !== null && lastUsageAt !== null + ? Math.max(0, Math.round((lastUsageAt - firstUsageAt) / 1000)) + : 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalInputTokens, + totalOutputTokens, + totalCacheReadInputTokens, + totalCacheCreationInputTokens, + totalWebSearchRequests, + models: Array.from(models.values()), + } + const contextEstimate = latestContextUsage + ? await this.buildTranscriptContextEstimate( + sessionId, + latestContextUsage, + estimatedTokensFromMessages, + transcriptHasMediaInput, + launchInfo, + ) + : null + + return { + launchInfo, + metadata, + usage, + contextEstimate, + } + } + // -------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------