mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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(() => ({
|
const openProjectMenuMock = vi.hoisted(() => ({
|
||||||
paths: [] as Array<string | null | undefined>,
|
paths: [] as Array<string | null | undefined>,
|
||||||
}))
|
}))
|
||||||
|
const sessionsApiMock = vi.hoisted(() => ({
|
||||||
|
delete: vi.fn(() => Promise.resolve()),
|
||||||
|
}))
|
||||||
|
|
||||||
function makeChatSession(chatState: ChatState): PerSessionState {
|
function makeChatSession(chatState: ChatState): PerSessionState {
|
||||||
return {
|
return {
|
||||||
@ -96,6 +99,10 @@ vi.mock('../../i18n', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../api/sessions', () => ({
|
||||||
|
sessionsApi: sessionsApiMock,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('./OpenProjectMenu', () => ({
|
vi.mock('./OpenProjectMenu', () => ({
|
||||||
OpenProjectMenu: ({ path }: { path: string | null | undefined }) => {
|
OpenProjectMenu: ({ path }: { path: string | null | undefined }) => {
|
||||||
if (!path) return null
|
if (!path) return null
|
||||||
@ -157,6 +164,8 @@ describe('TabBar', () => {
|
|||||||
deleteSessionMock.mockReset()
|
deleteSessionMock.mockReset()
|
||||||
deleteSessionMock.mockResolvedValue(undefined)
|
deleteSessionMock.mockResolvedValue(undefined)
|
||||||
openProjectMenuMock.paths = []
|
openProjectMenuMock.paths = []
|
||||||
|
sessionsApiMock.delete.mockClear()
|
||||||
|
sessionsApiMock.delete.mockResolvedValue(undefined)
|
||||||
windowControlsMock.show = true
|
windowControlsMock.show = true
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -410,6 +410,15 @@ describe('TraceSession', () => {
|
|||||||
expect(detail.getByRole('heading', { level: 2, name: 'claude-sonnet-4-5' })).toBeInTheDocument()
|
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 () => {
|
it('applies poll updates when a call changes without changing row counts', async () => {
|
||||||
const pendingTrace: TraceSessionData = {
|
const pendingTrace: TraceSessionData = {
|
||||||
...baseTrace,
|
...baseTrace,
|
||||||
|
|||||||
@ -64,16 +64,15 @@ export function TraceSession({
|
|||||||
if (!silent) setState({ status: 'loading' })
|
if (!silent) setState({ status: 'loading' })
|
||||||
if (silent) setRefreshing(true)
|
if (silent) setRefreshing(true)
|
||||||
try {
|
try {
|
||||||
const [trace, messageResponse] = await Promise.all([
|
const trace = await sessionsApi.getTrace(sessionId)
|
||||||
sessionsApi.getTrace(sessionId),
|
|
||||||
sessionsApi.getMessages(sessionId).catch(() => ({ messages: [] })),
|
|
||||||
])
|
|
||||||
if (!isTraceSessionData(trace)) {
|
if (!isTraceSessionData(trace)) {
|
||||||
throw new Error(t('trace.snapshotEmpty'))
|
throw new Error(t('trace.snapshotEmpty'))
|
||||||
}
|
}
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const signature = traceSnapshotSignature(trace, messageResponse.messages)
|
const signature = traceSnapshotSignature(trace)
|
||||||
if (silent && snapshotSignatureRef.current === signature) return
|
if (silent && snapshotSignatureRef.current === signature) return
|
||||||
|
const messageResponse = await sessionsApi.getMessages(sessionId).catch(() => ({ messages: [] }))
|
||||||
|
if (cancelled) return
|
||||||
snapshotSignatureRef.current = signature
|
snapshotSignatureRef.current = signature
|
||||||
setState({ status: 'ready', trace, messages: messageResponse.messages })
|
setState({ status: 'ready', trace, messages: messageResponse.messages })
|
||||||
setClockNowMs(Date.now())
|
setClockNowMs(Date.now())
|
||||||
@ -456,7 +455,7 @@ function TraceEmpty() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function traceSnapshotSignature(trace: TraceSessionData, messages: MessageEntry[]): string {
|
function traceSnapshotSignature(trace: TraceSessionData): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
summary: trace.summary,
|
summary: trace.summary,
|
||||||
calls: trace.calls.map((call) => ({
|
calls: trace.calls.map((call) => ({
|
||||||
@ -478,12 +477,6 @@ function traceSnapshotSignature(trace: TraceSessionData, messages: MessageEntry[
|
|||||||
callId: event.callId,
|
callId: event.callId,
|
||||||
message: event.message,
|
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 的完整流转。
|
* 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 fs from 'fs/promises'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as os from 'os'
|
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 () => {
|
it('should return initial context for a prewarmed empty session on the first inspection request', async () => {
|
||||||
await withMockInitDelay(500, async () => {
|
await withMockInitDelay(500, async () => {
|
||||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
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 { createHash } from 'crypto'
|
||||||
import * as fs from 'fs/promises'
|
import * as fs from 'fs/promises'
|
||||||
import * as os from 'os'
|
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 () => {
|
test('deletes a trace session file and invalidates cached reads', async () => {
|
||||||
await traceCaptureService.recordCall({
|
await traceCaptureService.recordCall({
|
||||||
sessionId: 'session-delete-trace',
|
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 () => {
|
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({
|
await traceCaptureService.recordCall({
|
||||||
sessionId: 'session-title-alpha',
|
sessionId: alpha.sessionId,
|
||||||
source: 'proxy',
|
source: 'proxy',
|
||||||
model: 'gpt-5.5',
|
model: 'gpt-5.5',
|
||||||
startedAt: '2026-06-09T08:00:00.000Z',
|
startedAt: '2026-06-09T08:00:00.000Z',
|
||||||
@ -1209,7 +1254,7 @@ describe('session trace API', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
await traceCaptureService.recordCall({
|
await traceCaptureService.recordCall({
|
||||||
sessionId: 'session-title-beta',
|
sessionId: beta.sessionId,
|
||||||
source: 'proxy',
|
source: 'proxy',
|
||||||
model: 'gpt-5.5',
|
model: 'gpt-5.5',
|
||||||
startedAt: '2026-06-09T08:00:01.000Z',
|
startedAt: '2026-06-09T08:00:01.000Z',
|
||||||
@ -1226,77 +1271,40 @@ describe('session trace API', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const originalGetSession = sessionService.getSession
|
const titleReq = new Request('http://localhost:3456/api/traces?q=stuck%20agent&limit=10&offset=0')
|
||||||
sessionService.getSession = (async (sessionId: string) => {
|
const titleRes = await handleApiRequest(titleReq, new URL(titleReq.url))
|
||||||
if (sessionId === 'session-title-alpha') {
|
const titleBody = await titleRes.json() as {
|
||||||
return {
|
traces: Array<{ sessionId: string; session: { title: string; projectPath: string } | null }>
|
||||||
id: sessionId,
|
total: number
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
async function getSessionTrace(sessionId: string): Promise<Response> {
|
||||||
const [trace, session] = await Promise.all([
|
const [trace, sessionMeta] = await Promise.all([
|
||||||
traceCaptureService.getSessionTrace(sessionId),
|
traceCaptureService.getSessionTrace(sessionId),
|
||||||
sessionService.getSession(sessionId).catch(() => null),
|
getSessionTraceMeta(sessionId),
|
||||||
])
|
])
|
||||||
return Response.json({
|
return Response.json({
|
||||||
...trace,
|
...trace,
|
||||||
calls: trace.calls.map((call) => trimTraceCallPreviews(call)),
|
calls: trace.calls.map((call) => trimTraceCallPreviews(call)),
|
||||||
session: session
|
session: sessionMeta
|
||||||
? {
|
? {
|
||||||
id: session.id,
|
id: sessionId,
|
||||||
title: session.title,
|
title: sessionMeta.title,
|
||||||
projectPath: session.projectPath,
|
projectPath: sessionMeta.projectPath,
|
||||||
workDir: session.workDir,
|
workDir: sessionMeta.workDir,
|
||||||
}
|
}
|
||||||
: null,
|
: 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> {
|
async function getSessionTraceCall(sessionId: string, callId: string | undefined): Promise<Response> {
|
||||||
if (!callId || callId.trim().length === 0) {
|
if (!callId || callId.trim().length === 0) {
|
||||||
throw ApiError.badRequest('callId is required')
|
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> {
|
async function getSessionInspection(sessionId: string, url: URL): Promise<Response> {
|
||||||
const includeContext = url.searchParams.get('includeContext') !== '0'
|
const includeContext = url.searchParams.get('includeContext') !== '0'
|
||||||
const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1'
|
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 =
|
const workDir =
|
||||||
conversationService.getSessionWorkDir(sessionId) ||
|
conversationService.getSessionWorkDir(sessionId) ||
|
||||||
await sessionService.getSessionWorkDir(sessionId)
|
(await getTranscriptSnapshot())?.launchInfo.workDir
|
||||||
|
|
||||||
if (!workDir) {
|
if (!workDir) {
|
||||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = conversationService.hasSession(sessionId)
|
const launchInfo = !active ? (await getTranscriptSnapshot())?.launchInfo ?? null : null
|
||||||
const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null)
|
|
||||||
const permissionMode = active
|
const permissionMode = active
|
||||||
? conversationService.getSessionPermissionMode(sessionId)
|
? conversationService.getSessionPermissionMode(sessionId)
|
||||||
: launchInfo?.permissionMode ?? 'default'
|
: launchInfo?.permissionMode ?? 'default'
|
||||||
@ -558,7 +580,9 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
|
|||||||
[...conversationService.getRecentSdkMessages(sessionId)]
|
[...conversationService.getRecentSdkMessages(sessionId)]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message?.type === 'system' && message.subtype === 'init')
|
.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 cachedSlashCommands = getSlashCommands(sessionId)
|
||||||
const skillSlashCommands = await listSkillSlashCommands(workDir)
|
const skillSlashCommands = await listSkillSlashCommands(workDir)
|
||||||
const fallbackSlashCommands = cachedSlashCommands.length > 0
|
const fallbackSlashCommands = cachedSlashCommands.length > 0
|
||||||
@ -586,13 +610,14 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
|
|||||||
},
|
},
|
||||||
errors: {},
|
errors: {},
|
||||||
}
|
}
|
||||||
const transcriptUsage = await sessionService.getTranscriptUsage(sessionId)
|
|
||||||
const transcriptContextEstimate = await sessionService.getTranscriptContextEstimate(sessionId)
|
|
||||||
if (transcriptContextEstimate) {
|
|
||||||
response.contextEstimate = transcriptContextEstimate
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
|
const snapshot = await getTranscriptSnapshot()
|
||||||
|
const transcriptUsage = snapshot?.usage ?? null
|
||||||
|
const transcriptContextEstimate = snapshot?.contextEstimate ?? null
|
||||||
|
if (transcriptContextEstimate) {
|
||||||
|
response.contextEstimate = transcriptContextEstimate
|
||||||
|
}
|
||||||
if (transcriptUsage) {
|
if (transcriptUsage) {
|
||||||
response.usage = transcriptUsage
|
response.usage = transcriptUsage
|
||||||
}
|
}
|
||||||
@ -614,6 +639,12 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.context = error instanceof Error ? error.message : String(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 {
|
} else {
|
||||||
const basicControlTimeoutMs = includeContext ? 10_000 : 4_000
|
const basicControlTimeoutMs = includeContext ? 10_000 : 4_000
|
||||||
const [usageResult, contextResult, mcpResult] = await Promise.allSettled([
|
const [usageResult, contextResult, mcpResult] = await Promise.allSettled([
|
||||||
@ -629,11 +660,13 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
|
|||||||
])
|
])
|
||||||
|
|
||||||
if (usageResult.status === 'fulfilled') {
|
if (usageResult.status === 'fulfilled') {
|
||||||
|
const transcriptUsage = (await getTranscriptSnapshot())?.usage ?? null
|
||||||
response.usage = chooseRicherUsage(
|
response.usage = chooseRicherUsage(
|
||||||
{ ...usageResult.value, source: 'current_process' },
|
{ ...usageResult.value, source: 'current_process' },
|
||||||
transcriptUsage,
|
transcriptUsage,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
const transcriptUsage = (await getTranscriptSnapshot())?.usage ?? null
|
||||||
if (transcriptUsage) {
|
if (transcriptUsage) {
|
||||||
response.usage = transcriptUsage
|
response.usage = transcriptUsage
|
||||||
} else {
|
} else {
|
||||||
@ -648,6 +681,10 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
|
|||||||
response.context = contextResult.value
|
response.context = contextResult.value
|
||||||
} else {
|
} else {
|
||||||
errors.context = contextResult.reason instanceof Error ? contextResult.reason.message : String(contextResult.reason)
|
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') {
|
if (mcpResult.status === 'fulfilled' && response.status && typeof response.status === 'object') {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export type TraceSessionListApiItem = TraceSessionListItem & {
|
|||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
projectPath: string
|
projectPath: string
|
||||||
workDir: string
|
workDir: string | null
|
||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,12 +135,12 @@ async function listSearchedTraces(query: string, limit: number, offset: number):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function decorateTraceListItem(item: TraceSessionListItem): Promise<TraceSessionListApiItem> {
|
async function decorateTraceListItem(item: TraceSessionListItem): Promise<TraceSessionListApiItem> {
|
||||||
const session = await sessionService.getSession(item.sessionId).catch(() => null)
|
const session = await getTraceSessionMeta(item.sessionId)
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
session: session
|
session: session
|
||||||
? {
|
? {
|
||||||
id: session.id,
|
id: item.sessionId,
|
||||||
title: session.title,
|
title: session.title,
|
||||||
projectPath: session.projectPath,
|
projectPath: session.projectPath,
|
||||||
workDir: session.workDir,
|
workDir: session.workDir,
|
||||||
@ -150,12 +150,12 @@ async function decorateTraceListItem(item: TraceSessionListItem): Promise<TraceS
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function decorateTraceFileCandidate(item: TraceSessionFileItem): Promise<Pick<TraceSessionListApiItem, 'sessionId' | 'session'>> {
|
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 {
|
return {
|
||||||
sessionId: item.sessionId,
|
sessionId: item.sessionId,
|
||||||
session: session
|
session: session
|
||||||
? {
|
? {
|
||||||
id: session.id,
|
id: item.sessionId,
|
||||||
title: session.title,
|
title: session.title,
|
||||||
projectPath: session.projectPath,
|
projectPath: session.projectPath,
|
||||||
workDir: session.workDir,
|
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 {
|
function traceListItemMatchesQuery(item: Pick<TraceSessionListApiItem, 'sessionId' | 'session'>, query: string): boolean {
|
||||||
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
|
||||||
if (terms.length === 0) return true
|
if (terms.length === 0) return true
|
||||||
|
|||||||
@ -40,7 +40,9 @@ import {
|
|||||||
import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js'
|
import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js'
|
||||||
import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js'
|
import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js'
|
||||||
import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js'
|
import { cleanSessionTitleSource } from '../../utils/sessionTitleText.js'
|
||||||
import { roughTokenCountEstimationForMessages } from '../../services/tokenEstimation.js'
|
import {
|
||||||
|
roughTokenCountEstimationForMessage,
|
||||||
|
} from '../../services/tokenEstimation.js'
|
||||||
import { ProviderService } from './providerService.js'
|
import { ProviderService } from './providerService.js'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -89,6 +91,13 @@ export type SessionLaunchInfo = {
|
|||||||
effortLevel?: string
|
effortLevel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionInspectionTranscriptSnapshot = {
|
||||||
|
launchInfo: SessionLaunchInfo
|
||||||
|
metadata: TranscriptMetadataSnapshot
|
||||||
|
usage: TranscriptUsageSnapshot | null
|
||||||
|
contextEstimate: TranscriptContextEstimate | null
|
||||||
|
}
|
||||||
|
|
||||||
export type TrimSessionResult = {
|
export type TrimSessionResult = {
|
||||||
removedCount: number
|
removedCount: number
|
||||||
removedMessageIds: string[]
|
removedMessageIds: string[]
|
||||||
@ -426,6 +435,36 @@ export class SessionService {
|
|||||||
return entries
|
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(
|
private async scanSessionListSummary(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
@ -1401,8 +1440,9 @@ export class SessionService {
|
|||||||
private async getProviderContextWindowForSession(
|
private async getProviderContextWindowForSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
model: string,
|
model: string,
|
||||||
|
launchInfoOverride?: Pick<SessionLaunchInfo, 'runtimeProviderId'> | null,
|
||||||
): Promise<number | undefined> {
|
): Promise<number | undefined> {
|
||||||
const launchInfo = await this.getSessionLaunchInfo(sessionId).catch(() => null)
|
const launchInfo = launchInfoOverride ?? await this.getSessionLaunchInfo(sessionId).catch(() => null)
|
||||||
const providerIds: string[] = []
|
const providerIds: string[] = []
|
||||||
const allowSavedProviderInference = launchInfo?.runtimeProviderId === undefined
|
const allowSavedProviderInference = launchInfo?.runtimeProviderId === undefined
|
||||||
|
|
||||||
@ -1528,8 +1568,16 @@ export class SessionService {
|
|||||||
return contextWindow
|
return contextWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getTranscriptContextWindow(sessionId: string, model: string): Promise<number> {
|
private async getTranscriptContextWindow(
|
||||||
const providerContextWindow = await this.getProviderContextWindowForSession(sessionId, model)
|
sessionId: string,
|
||||||
|
model: string,
|
||||||
|
launchInfo?: Pick<SessionLaunchInfo, 'runtimeProviderId'> | null,
|
||||||
|
): Promise<number> {
|
||||||
|
const providerContextWindow = await this.getProviderContextWindowForSession(
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
launchInfo,
|
||||||
|
)
|
||||||
if (providerContextWindow !== undefined) {
|
if (providerContextWindow !== undefined) {
|
||||||
return providerContextWindow
|
return providerContextWindow
|
||||||
}
|
}
|
||||||
@ -1571,49 +1619,22 @@ export class SessionService {
|
|||||||
return metadata
|
return metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTranscriptContextEstimate(sessionId: string): Promise<TranscriptContextEstimate | null> {
|
private async buildTranscriptContextEstimate(
|
||||||
const found = await this.findSessionFile(sessionId)
|
sessionId: string,
|
||||||
if (!found) return null
|
latest: {
|
||||||
|
|
||||||
const entries = await this.readJsonlFile(found.filePath)
|
|
||||||
let latest: {
|
|
||||||
model: string
|
model: string
|
||||||
inputTokens: number
|
inputTokens: number
|
||||||
outputTokens: number
|
outputTokens: number
|
||||||
cacheReadInputTokens: number
|
cacheReadInputTokens: number
|
||||||
cacheCreationInputTokens: number
|
cacheCreationInputTokens: number
|
||||||
} | null = null
|
},
|
||||||
|
estimatedTokensFromMessages: number,
|
||||||
for (const entry of entries) {
|
transcriptHasMediaInput: boolean,
|
||||||
const usage = entry.message?.usage
|
launchInfo?: Pick<SessionLaunchInfo, 'runtimeProviderId'> | null,
|
||||||
const model = entry.message?.model
|
): Promise<TranscriptContextEstimate> {
|
||||||
if (!usage || typeof model !== 'string') continue
|
const rawMaxTokens = await this.getTranscriptContextWindow(sessionId, latest.model, launchInfo)
|
||||||
|
|
||||||
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)
|
|
||||||
const promptTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens
|
const promptTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens
|
||||||
const transcriptMessages = entries.filter(entry =>
|
const estimatedTokens = estimatedTokensFromMessages || promptTokens
|
||||||
entry.type === 'user' || entry.type === 'assistant' || entry.type === 'attachment',
|
|
||||||
) as Parameters<typeof roughTokenCountEstimationForMessages>[0]
|
|
||||||
const estimatedTokens =
|
|
||||||
roughTokenCountEstimationForMessages(transcriptMessages) || promptTokens
|
|
||||||
const contextBudget = calculateContextBudget({
|
const contextBudget = calculateContextBudget({
|
||||||
estimatedTokens,
|
estimatedTokens,
|
||||||
contextWindow: rawMaxTokens,
|
contextWindow: rawMaxTokens,
|
||||||
@ -1626,7 +1647,7 @@ export class SessionService {
|
|||||||
usageTrust: getProviderUsageTrust({
|
usageTrust: getProviderUsageTrust({
|
||||||
isFirstPartyAnthropic: isFirstPartyAnthropicBaseUrl(),
|
isFirstPartyAnthropic: isFirstPartyAnthropicBaseUrl(),
|
||||||
}),
|
}),
|
||||||
hasMediaInput: hasMediaInput(transcriptMessages),
|
hasMediaInput: transcriptHasMediaInput,
|
||||||
})
|
})
|
||||||
const totalTokens = contextBudget.usedTokens
|
const totalTokens = contextBudget.usedTokens
|
||||||
const percentage = rawMaxTokens > 0 ? Math.round((totalTokens / rawMaxTokens) * 100) : 0
|
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> {
|
async getTranscriptUsage(sessionId: string): Promise<TranscriptUsageSnapshot | null> {
|
||||||
const found = await this.findSessionFile(sessionId)
|
const found = await this.findSessionFile(sessionId)
|
||||||
if (!found) return null
|
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
|
// Public API
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user