Files
hapi/cli/src/claude/utils/sessionScanner.test.ts
T
d3473f9ed4 fix(cli): forward Claude API error records with null service_tier (#1857) (#2087)
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>
2026-10-26 10:00:23 +08:00

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']
])
})
})