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
This commit is contained in:
程序员阿江(Relakkes) 2026-07-02 21:00:07 +08:00
parent c15c465b0b
commit 2228524462
8 changed files with 587 additions and 149 deletions

View File

@ -17,6 +17,9 @@ const deleteSessionMock = vi.hoisted(() => vi.fn())
const openProjectMenuMock = vi.hoisted(() => ({
paths: [] as Array<string | null | undefined>,
}))
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()
})

View File

@ -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,

View File

@ -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,
})),
})
}

View File

@ -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`, {

View File

@ -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([])
})
})

View File

@ -267,24 +267,39 @@ async function getSessionMessages(sessionId: string): Promise<Response> {
}
async function getSessionTrace(sessionId: string): Promise<Response> {
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<Response> {
if (!callId || callId.trim().length === 0) {
throw ApiError.badRequest('callId is required')
@ -541,16 +556,23 @@ async function getSessionSlashCommands(sessionId: string): Promise<Response> {
async function getSessionInspection(sessionId: string, url: URL): Promise<Response> {
const includeContext = url.searchParams.get('includeContext') !== '0'
const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1'
let transcriptSnapshot: Awaited<ReturnType<typeof sessionService.getInspectionTranscriptSnapshot>> | 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<Respon
[...conversationService.getRecentSdkMessages(sessionId)]
.reverse()
.find((message) => 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<Respon
},
errors: {},
}
const transcriptUsage = await sessionService.getTranscriptUsage(sessionId)
const transcriptContextEstimate = await sessionService.getTranscriptContextEstimate(sessionId)
if (transcriptContextEstimate) {
response.contextEstimate = transcriptContextEstimate
}
if (!active) {
const snapshot = await getTranscriptSnapshot()
const transcriptUsage = snapshot?.usage ?? null
const transcriptContextEstimate = snapshot?.contextEstimate ?? null
if (transcriptContextEstimate) {
response.contextEstimate = transcriptContextEstimate
}
if (transcriptUsage) {
response.usage = transcriptUsage
}
@ -614,6 +639,12 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
} catch (error) {
errors.context = error instanceof Error ? error.message : String(error)
}
if (!response.context) {
const transcriptContextEstimate = (await getTranscriptSnapshot())?.contextEstimate ?? null
if (transcriptContextEstimate) {
response.contextEstimate = transcriptContextEstimate
}
}
} else {
const basicControlTimeoutMs = includeContext ? 10_000 : 4_000
const [usageResult, contextResult, mcpResult] = await Promise.allSettled([
@ -629,11 +660,13 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
])
if (usageResult.status === 'fulfilled') {
const transcriptUsage = (await getTranscriptSnapshot())?.usage ?? null
response.usage = chooseRicherUsage(
{ ...usageResult.value, source: 'current_process' },
transcriptUsage,
)
} else {
const transcriptUsage = (await getTranscriptSnapshot())?.usage ?? null
if (transcriptUsage) {
response.usage = transcriptUsage
} else {
@ -648,6 +681,10 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
response.context = contextResult.value
} else {
errors.context = contextResult.reason instanceof Error ? contextResult.reason.message : String(contextResult.reason)
const transcriptContextEstimate = (await getTranscriptSnapshot())?.contextEstimate ?? null
if (transcriptContextEstimate) {
response.contextEstimate = transcriptContextEstimate
}
}
if (mcpResult.status === 'fulfilled' && response.status && typeof response.status === 'object') {

View File

@ -13,7 +13,7 @@ export type TraceSessionListApiItem = TraceSessionListItem & {
id: string
title: string
projectPath: string
workDir: string
workDir: string | null
} | null
}
@ -135,12 +135,12 @@ async function listSearchedTraces(query: string, limit: number, offset: number):
}
async function decorateTraceListItem(item: TraceSessionListItem): Promise<TraceSessionListApiItem> {
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<TraceS
}
async function decorateTraceFileCandidate(item: TraceSessionFileItem): Promise<Pick<TraceSessionListApiItem, 'sessionId' | 'session'>> {
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<P
}
}
async function getTraceSessionMeta(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,
}
}
function traceListItemMatchesQuery(item: Pick<TraceSessionListApiItem, 'sessionId' | 'session'>, query: string): boolean {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return true

View File

@ -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<void> {
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<SessionLaunchInfo, 'runtimeProviderId'> | null,
): Promise<number | undefined> {
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<number> {
const providerContextWindow = await this.getProviderContextWindowForSession(sessionId, model)
private async getTranscriptContextWindow(
sessionId: string,
model: string,
launchInfo?: Pick<SessionLaunchInfo, 'runtimeProviderId'> | null,
): Promise<number> {
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<TranscriptContextEstimate | null> {
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<SessionLaunchInfo, 'runtimeProviderId'> | null,
): Promise<TranscriptContextEstimate> {
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<typeof roughTokenCountEstimationForMessages>[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<TranscriptContextEstimate | null> {
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<TranscriptUsageSnapshot | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
@ -1800,6 +1878,273 @@ export class SessionService {
}
}
async getInspectionTranscriptSnapshot(sessionId: string): Promise<SessionInspectionTranscriptSnapshot | null> {
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<string, TranscriptUsageSnapshot['models'][number]>()
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<string, unknown>
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<string, unknown>)?.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<typeof calculateUSDCost>[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
// --------------------------------------------------------------------------