mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
Claude Code writes failed API calls ("Prompt is too long", usage limits,
logged out) as synthetic assistant records whose usage carries
`service_tier: null`. The schema only accepted undefined, so the local
session scanner dropped every one and the web session just looked stalled.
- Accept null for the usage fields the Messages API types as nullable.
- Keep the top-level `error`/`isApiErrorMessage` on assistant records in
local mode, and `error` in the SDK converter for remote mode.
- Render assistant records carrying `error` as the existing error event,
so the all-zero synthetic usage no longer resets the context gauge.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { createSessionScanner, readSessionLog } from './sessionScanner'
|
|
import { RawJSONLines } from '../types'
|
|
import { mkdir, writeFile, appendFile, rm, readFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { tmpdir, homedir } from 'node:os'
|
|
import { existsSync } from 'node:fs'
|
|
import { getProjectPath } from './path'
|
|
|
|
describe('sessionScanner', () => {
|
|
let testDir: string
|
|
let projectDir: string
|
|
let collectedMessages: RawJSONLines[]
|
|
let scanner: Awaited<ReturnType<typeof createSessionScanner>> | null = null
|
|
|
|
beforeEach(async () => {
|
|
testDir = join(tmpdir(), `scanner-test-${Date.now()}`)
|
|
await mkdir(testDir, { recursive: true })
|
|
|
|
projectDir = getProjectPath(testDir)
|
|
await mkdir(projectDir, { recursive: true })
|
|
|
|
collectedMessages = []
|
|
})
|
|
|
|
afterEach(async () => {
|
|
// Clean up scanner
|
|
if (scanner) {
|
|
await scanner.cleanup()
|
|
scanner = null
|
|
}
|
|
|
|
if (existsSync(testDir)) {
|
|
await rm(testDir, { recursive: true, force: true })
|
|
}
|
|
if (existsSync(projectDir)) {
|
|
await rm(projectDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('should process initial session and resumed session correctly', async () => {
|
|
// TEST SCENARIO:
|
|
// Phase 1: User says "lol" → Assistant responds "lol" → Session closes
|
|
// Phase 2: User resumes with NEW session ID → User says "run ls tool" → Assistant runs LS tool → Shows files
|
|
//
|
|
// Key point: When resuming, Claude creates a NEW session file with:
|
|
// - Summary line
|
|
// - Complete history from previous session (with NEW session ID)
|
|
// - New messages
|
|
scanner = await createSessionScanner({
|
|
sessionId: null,
|
|
workingDirectory: testDir,
|
|
onMessage: (msg) => collectedMessages.push(msg)
|
|
})
|
|
|
|
// PHASE 1: Initial session (0-say-lol-session.jsonl)
|
|
const fixture1 = await readFile(join(__dirname, '__fixtures__', '0-say-lol-session.jsonl'), 'utf-8')
|
|
const lines1 = fixture1.split('\n').filter(line => line.trim())
|
|
|
|
const sessionId1 = '93a9705e-bc6a-406d-8dce-8acc014dedbd'
|
|
const sessionFile1 = join(projectDir, `${sessionId1}.jsonl`)
|
|
|
|
// Write first line
|
|
await writeFile(sessionFile1, lines1[0] + '\n')
|
|
scanner.onNewSession(sessionId1)
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
|
|
expect(collectedMessages).toHaveLength(1)
|
|
expect(collectedMessages[0].type).toBe('user')
|
|
if (collectedMessages[0].type === 'user') {
|
|
const content = collectedMessages[0].message.content
|
|
const text = typeof content === 'string' ? content : (content as any)[0].text
|
|
expect(text).toBe('say lol')
|
|
}
|
|
|
|
// Write second line with delay
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
await appendFile(sessionFile1, lines1[1] + '\n')
|
|
await new Promise(resolve => setTimeout(resolve, 200))
|
|
|
|
expect(collectedMessages).toHaveLength(2)
|
|
expect(collectedMessages[1].type).toBe('assistant')
|
|
if (collectedMessages[1].type === 'assistant' && collectedMessages[1].message) {
|
|
expect((collectedMessages[1].message.content as any)[0].text).toBe('lol')
|
|
}
|
|
|
|
// PHASE 2: Resumed session (1-continue-run-ls-tool.jsonl)
|
|
const fixture2 = await readFile(join(__dirname, '__fixtures__', '1-continue-run-ls-tool.jsonl'), 'utf-8')
|
|
const lines2 = fixture2.split('\n').filter(line => line.trim())
|
|
|
|
const sessionId2 = '789e105f-ae33-486d-9271-0696266f072d'
|
|
const sessionFile2 = join(projectDir, `${sessionId2}.jsonl`)
|
|
|
|
// Reset collected messages count for clarity
|
|
const phase1Count = collectedMessages.length
|
|
|
|
// Write summary + historical messages (lines 0-2) - NOT line 3 which is new
|
|
let initialContent = ''
|
|
for (let i = 0; i <= 2; i++) {
|
|
initialContent += lines2[i] + '\n'
|
|
}
|
|
await writeFile(sessionFile2, initialContent)
|
|
|
|
scanner.onNewSession(sessionId2)
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
|
|
// Should have added only 1 new message (summary)
|
|
// The historical user + assistant messages (lines 1-2) are deduplicated because they have same UUIDs
|
|
expect(collectedMessages).toHaveLength(phase1Count + 1)
|
|
expect(collectedMessages[phase1Count].type).toBe('summary')
|
|
|
|
// Write new messages (user asks for ls tool) - this is line 3
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
await appendFile(sessionFile2, lines2[3] + '\n')
|
|
await new Promise(resolve => setTimeout(resolve, 200))
|
|
|
|
// Find the user message we just added
|
|
const userMessages = collectedMessages.filter(m => m.type === 'user')
|
|
const lastUserMsg = userMessages[userMessages.length - 1]
|
|
expect(lastUserMsg).toBeDefined()
|
|
if (lastUserMsg && lastUserMsg.type === 'user') {
|
|
expect(lastUserMsg.message.content).toBe('run ls tool ')
|
|
}
|
|
|
|
// Write remaining lines (assistant tool use, tool result, final assistant message) - starting from line 4
|
|
for (let i = 4; i < lines2.length; i++) {
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
await appendFile(sessionFile2, lines2[i] + '\n')
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 300))
|
|
|
|
// Final count check
|
|
const finalMessages = collectedMessages.slice(phase1Count)
|
|
|
|
// Should have: 1 summary + 0 history (deduplicated) + 4 new messages = 5 total for session 2
|
|
expect(finalMessages.length).toBeGreaterThanOrEqual(5)
|
|
|
|
// Verify last message is assistant with the file listing
|
|
const lastAssistantMsg = collectedMessages[collectedMessages.length - 1]
|
|
expect(lastAssistantMsg.type).toBe('assistant')
|
|
if (lastAssistantMsg.type === 'assistant' && lastAssistantMsg.message?.content) {
|
|
const content = (lastAssistantMsg.message.content as any)[0].text
|
|
expect(content).toContain('0-say-lol-session.jsonl')
|
|
expect(content).toContain('readme.md')
|
|
}
|
|
})
|
|
|
|
it('reads only new bytes on a subsequent scan (incremental, no reparse of prior region)', async () => {
|
|
const filePath = join(testDir, 'incremental.jsonl')
|
|
const line1 = JSON.stringify({ type: 'user', uuid: 'u1', message: { content: 'first' } })
|
|
const line2 = JSON.stringify({ type: 'user', uuid: 'u2', message: { content: 'second' } })
|
|
await writeFile(filePath, line1 + '\n')
|
|
|
|
const first = await readSessionLog(filePath, 0)
|
|
expect(first.events).toHaveLength(1)
|
|
expect(first.nextCursor).toBe(Buffer.byteLength(line1 + '\n'))
|
|
|
|
await appendFile(filePath, line2 + '\n')
|
|
const second = await readSessionLog(filePath, first.nextCursor)
|
|
|
|
// Starting from the cursor returned by the first scan, only the newly
|
|
// appended line comes back — the already-seen line is not re-parsed.
|
|
expect(second.events).toHaveLength(1)
|
|
expect(second.events[0].event.type).toBe('user')
|
|
if (second.events[0].event.type === 'user') {
|
|
expect(second.events[0].event.uuid).toBe('u2')
|
|
}
|
|
expect(second.nextCursor).toBe(Buffer.byteLength(line1 + '\n' + line2 + '\n'))
|
|
})
|
|
|
|
it('holds back an unterminated trailing line until its newline arrives', async () => {
|
|
const filePath = join(testDir, 'partial.jsonl')
|
|
const complete = JSON.stringify({ type: 'user', uuid: 'u1', message: { content: 'complete' } })
|
|
const partial = JSON.stringify({ type: 'user', uuid: 'u2', message: { content: 'partial' } })
|
|
|
|
// Simulate a write in progress: a full line followed by a truncated one
|
|
// with no trailing newline yet.
|
|
await writeFile(filePath, complete + '\n' + partial.slice(0, 10))
|
|
|
|
const result = await readSessionLog(filePath, 0)
|
|
expect(result.events).toHaveLength(1)
|
|
if (result.events[0].event.type === 'user') {
|
|
expect(result.events[0].event.uuid).toBe('u1')
|
|
}
|
|
// Cursor sits right after the complete line's newline, not at EOF — the
|
|
// partial trailing bytes are left unconsumed.
|
|
expect(result.nextCursor).toBe(Buffer.byteLength(complete + '\n'))
|
|
|
|
// Completing the line makes it available on the next scan.
|
|
await appendFile(filePath, partial.slice(10) + '\n')
|
|
const followUp = await readSessionLog(filePath, result.nextCursor)
|
|
expect(followUp.events).toHaveLength(1)
|
|
if (followUp.events[0].event.type === 'user') {
|
|
expect(followUp.events[0].event.uuid).toBe('u2')
|
|
}
|
|
})
|
|
|
|
it('forwards a complete final record written without a trailing newline', async () => {
|
|
const filePath = join(testDir, 'no-trailing-newline.jsonl')
|
|
const line1 = JSON.stringify({ type: 'user', uuid: 'u1', message: { content: 'first' } })
|
|
const line2 = JSON.stringify({ type: 'user', uuid: 'u2', message: { content: 'last' } })
|
|
|
|
// A prior line terminated by a newline, then a complete final record that
|
|
// was flushed without its terminating newline (shutdown/import).
|
|
await writeFile(filePath, line1 + '\n' + line2)
|
|
|
|
const result = await readSessionLog(filePath, 0)
|
|
expect(result.events).toHaveLength(2)
|
|
expect(result.events.map((e) => e.event.type === 'user' ? e.event.uuid : null)).toEqual(['u1', 'u2'])
|
|
// The whole file is consumed even though it does not end in a newline.
|
|
expect(result.nextCursor).toBe(Buffer.byteLength(line1 + '\n' + line2))
|
|
})
|
|
|
|
it('forwards a Claude API error record with null usage fields and keeps its error flags (#1857)', async () => {
|
|
const filePath = join(testDir, 'api-error.jsonl')
|
|
const line = JSON.stringify({
|
|
type: 'assistant',
|
|
uuid: 'a1',
|
|
isSidechain: false,
|
|
isApiErrorMessage: true,
|
|
error: 'invalid_request',
|
|
message: {
|
|
model: '<synthetic>',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Prompt is too long' }],
|
|
usage: {
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
cache_creation_input_tokens: 0,
|
|
cache_read_input_tokens: 0,
|
|
service_tier: null,
|
|
inference_geo: null,
|
|
speed: null
|
|
}
|
|
}
|
|
})
|
|
await writeFile(filePath, line + '\n')
|
|
|
|
const result = await readSessionLog(filePath, 0)
|
|
expect(result.events).toHaveLength(1)
|
|
expect(result.events[0].event).toMatchObject({
|
|
type: 'assistant',
|
|
uuid: 'a1',
|
|
isApiErrorMessage: true,
|
|
error: 'invalid_request'
|
|
})
|
|
})
|
|
|
|
it('forwards a single complete record with no newline at all', async () => {
|
|
const filePath = join(testDir, 'single-no-newline.jsonl')
|
|
const only = JSON.stringify({ type: 'user', uuid: 'u1', message: { content: 'only' } })
|
|
await writeFile(filePath, only)
|
|
|
|
const result = await readSessionLog(filePath, 0)
|
|
expect(result.events).toHaveLength(1)
|
|
if (result.events[0].event.type === 'user') {
|
|
expect(result.events[0].event.uuid).toBe('u1')
|
|
}
|
|
expect(result.nextCursor).toBe(Buffer.byteLength(only))
|
|
})
|
|
it('forwards Claude Code 2.x subagent transcripts tagged with the spawning tool_use id', async () => {
|
|
const sessionId = 'c0ffee00-0000-4000-8000-000000000001'
|
|
const subagentDir = join(projectDir, sessionId, 'subagents')
|
|
await mkdir(subagentDir, { recursive: true })
|
|
// Already on disk when the scanner starts → treated as seen, like the main log.
|
|
await writeFile(join(subagentDir, 'agent-old.jsonl'), JSON.stringify({ type: 'user', uuid: 'old', isSidechain: true, parentUuid: null, message: { content: 'old prompt' } }) + '\n')
|
|
await writeFile(join(projectDir, `${sessionId}.jsonl`), '')
|
|
|
|
scanner = await createSessionScanner({
|
|
sessionId,
|
|
workingDirectory: testDir,
|
|
onMessage: (msg) => collectedMessages.push(msg)
|
|
})
|
|
|
|
await writeFile(join(subagentDir, 'agent-a1.meta.json'), JSON.stringify({ agentType: 'general-purpose', toolUseId: 'toolu_parent' }))
|
|
await writeFile(join(subagentDir, 'agent-a1.jsonl'), [
|
|
{ type: 'user', uuid: 's1', isSidechain: true, parentUuid: null, message: { role: 'user', content: 'sub prompt' } },
|
|
{ type: 'assistant', uuid: 's2', isSidechain: true, parentUuid: 's1', message: { role: 'assistant', content: [{ type: 'text', text: 'sub reply' }] } }
|
|
].map((line) => JSON.stringify(line)).join('\n') + '\n')
|
|
await new Promise(resolve => setTimeout(resolve, 3500))
|
|
|
|
expect(collectedMessages.map((m) => m.type === 'user' || m.type === 'assistant' ? [m.uuid, m.parentToolUseId] : null)).toEqual([
|
|
['s1', 'toolu_parent'],
|
|
['s2', 'toolu_parent']
|
|
])
|
|
})
|
|
})
|