mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
c15c465b0b
commit
2228524462
@ -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()
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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`, {
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user