Files
hapi/cli/src/claude/utils/sdkToLogConverter.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

1362 lines
56 KiB
TypeScript

/**
* Tests for SDK to Log converter
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { SDKToLogConverter, convertSDKToLog } from './sdkToLogConverter'
import type { SDKMessage, SDKUserMessage, SDKAssistantMessage, SDKSystemMessage, SDKResultMessage } from '@/claude/sdk'
import type { ClaudePermissionMode } from '@hapi/protocol/types'
describe('SDKToLogConverter', () => {
let converter: SDKToLogConverter
const context = {
sessionId: 'test-session-123',
cwd: '/test/project',
version: '1.0.0',
gitBranch: 'main'
}
beforeEach(() => {
converter = new SDKToLogConverter(context)
})
describe('User messages', () => {
it('should convert SDK user message to log format', () => {
const sdkMessage: SDKUserMessage = {
type: 'user',
message: {
role: 'user',
content: 'Hello Claude'
}
}
const logMessage = converter.convert(sdkMessage)
expect(logMessage).toBeTruthy()
expect(logMessage?.type).toBe('user')
expect(logMessage).toMatchObject({
type: 'user',
sessionId: context.sessionId,
cwd: context.cwd,
version: context.version,
gitBranch: context.gitBranch,
parentUuid: null,
isSidechain: false,
userType: 'external',
message: {
role: 'user',
content: 'Hello Claude'
}
})
expect(logMessage?.uuid).toBeTruthy()
expect(logMessage?.timestamp).toBeTruthy()
})
it('should handle user message with complex content', () => {
const sdkMessage: SDKUserMessage = {
type: 'user',
message: {
role: 'user',
content: [
{ type: 'text', text: 'Check this out' },
{ type: 'tool_result', tool_use_id: 'tool123', content: 'Result data' }
]
}
}
const logMessage = converter.convert(sdkMessage)
expect(logMessage?.type).toBe('user')
expect((logMessage as any).message.content).toHaveLength(2)
})
it('should mark synthetic user messages as meta', () => {
// Skill injections arrive over stream-json as plain-text user messages
// flagged with isSynthetic. The on-disk transcript flags the same event
// with isMeta, which is what every downstream filter looks for.
const sdkMessage: SDKUserMessage = {
type: 'user',
isSynthetic: true,
message: {
role: 'user',
content: [
{ type: 'text', text: 'Base directory for this skill: /home/user/.claude/skills/foo' }
]
}
}
const logMessage = converter.convert(sdkMessage)
expect(logMessage?.type).toBe('user')
expect(logMessage?.isMeta).toBe(true)
})
it('should preserve isMeta on user messages', () => {
const sdkMessage: SDKUserMessage = {
type: 'user',
isMeta: true,
message: {
role: 'user',
content: 'injected context'
}
}
expect(converter.convert(sdkMessage)?.isMeta).toBe(true)
})
it('should not mark genuine user messages as meta', () => {
const sdkMessage: SDKUserMessage = {
type: 'user',
message: {
role: 'user',
content: 'Hello Claude'
}
}
expect(converter.convert(sdkMessage)?.isMeta).toBeUndefined()
})
})
describe('Assistant messages', () => {
it('should convert SDK assistant message to log format', () => {
const sdkMessage: SDKAssistantMessage = {
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'Hello! How can I help?' }
]
}
}
const logMessage = converter.convert(sdkMessage)
expect(logMessage).toBeTruthy()
expect(logMessage?.type).toBe('assistant')
expect(logMessage).toMatchObject({
type: 'assistant',
sessionId: context.sessionId,
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'Hello! How can I help?' }
]
}
})
})
it('should include requestId if present', () => {
const sdkMessage: any = {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Response' }]
},
requestId: 'req_123'
}
const logMessage = converter.convert(sdkMessage)
expect((logMessage as any).requestId).toBe('req_123')
})
it('keeps the top-level error on synthetic API error messages (#1857)', () => {
const sdkMessage: any = {
type: 'assistant',
message: {
model: '<synthetic>',
role: 'assistant',
content: [{ type: 'text', text: 'Prompt is too long' }]
},
error: 'invalid_request',
is_api_error_message: true
}
const logMessage = converter.convert(sdkMessage)
expect((logMessage as any).error).toBe('invalid_request')
})
})
describe('System messages', () => {
it('should convert SDK system message to log format', () => {
const sdkMessage: SDKSystemMessage = {
type: 'system',
subtype: 'init',
session_id: 'new-session-456',
model: 'claude-opus-4',
cwd: '/project',
tools: ['bash', 'edit']
}
const logMessage = converter.convert(sdkMessage)
expect(logMessage).toBeTruthy()
expect(logMessage?.type).toBe('system')
expect(logMessage).toMatchObject({
type: 'system',
subtype: 'init',
model: 'claude-opus-4',
tools: ['bash', 'edit']
})
})
it('should update session ID on init system message', () => {
const sdkMessage: SDKSystemMessage = {
type: 'system',
subtype: 'init',
session_id: 'updated-session-789'
}
converter.convert(sdkMessage)
// Next message should have updated session ID
const userMessage: SDKUserMessage = {
type: 'user',
message: { role: 'user', content: 'Test' }
}
const logMessage = converter.convert(userMessage)
expect(logMessage?.sessionId).toBe('updated-session-789')
})
it('keeps the task list on background_tasks_changed snapshots', () => {
const tasks = [{ task_id: 'b1', task_type: 'local_bash' }]
const logMessage = converter.convert({
type: 'system',
subtype: 'background_tasks_changed',
tasks
} as SDKSystemMessage)
expect(logMessage).toMatchObject({ type: 'system', subtype: 'background_tasks_changed', tasks })
})
})
describe('Result messages', () => {
it('converts successful results into a round-summary carrier without advancing the parent chain', () => {
const preceding = converter.convert({
type: 'user',
message: { role: 'user', content: 'first request' }
} as SDKUserMessage)
const sdkMessage: SDKResultMessage = {
type: 'result',
subtype: 'success',
result: 'Task completed',
num_turns: 5,
usage: {
input_tokens: 100,
output_tokens: 200
},
total_cost_usd: 0.05,
duration_ms: 3000,
duration_api_ms: 2500,
is_error: false,
session_id: 'result-session',
modelUsage: {
'claude-opus-5': {
inputTokens: 80,
cacheCreationInputTokens: 20,
cacheReadInputTokens: 100_000,
outputTokens: 500
}
}
}
const logMessage = converter.convert(sdkMessage)
expect(logMessage).toMatchObject({
type: 'system',
subtype: 'turn_duration',
parentUuid: preceding?.uuid,
durationMs: 3000,
resultSummary: {
usage: sdkMessage.usage,
modelUsage: sdkMessage.modelUsage,
total_cost_usd: 0.05,
num_turns: 5,
duration_ms: 3000
}
})
const following = converter.convert({
type: 'user',
message: { role: 'user', content: 'second request' }
} as SDKUserMessage)
expect(following?.parentUuid).toBe(preceding?.uuid)
})
it('converts error results into the same carrier shape', () => {
const sdkMessage: SDKResultMessage = {
type: 'result',
subtype: 'error_max_turns',
num_turns: 10,
total_cost_usd: 0.1,
duration_ms: 5000,
duration_api_ms: 4500,
is_error: true,
session_id: 'error-session'
}
const logMessage = converter.convert(sdkMessage)
expect(logMessage).toMatchObject({
type: 'system',
subtype: 'turn_duration',
durationMs: 5000,
resultSummary: {
total_cost_usd: 0.1,
num_turns: 10,
duration_ms: 5000
}
})
})
it('does not let a sidechain result carrier advance the sidechain parent chain', () => {
const parentToolUseId = 'toolu_sidechain_result'
const firstSidechainAssistant = converter.convert({
type: 'assistant',
parent_tool_use_id: parentToolUseId,
message: { role: 'assistant', content: [{ type: 'text', text: 'working' }] }
} as unknown as SDKAssistantMessage)
const result = converter.convert({
type: 'result',
subtype: 'success',
parent_tool_use_id: parentToolUseId,
num_turns: 1,
total_cost_usd: 0,
duration_ms: 100,
duration_api_ms: 100,
is_error: false,
session_id: 'sidechain-result'
} as unknown as SDKResultMessage)
const followingSidechainAssistant = converter.convert({
type: 'assistant',
parent_tool_use_id: parentToolUseId,
message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] }
} as unknown as SDKAssistantMessage)
expect(result).toMatchObject({
isSidechain: true,
parentUuid: firstSidechainAssistant?.uuid
})
expect(followingSidechainAssistant?.parentUuid).toBe(firstSidechainAssistant?.uuid)
})
})
describe('Context window propagation', () => {
function makeAssistantMessage(): SDKAssistantMessage {
return {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
usage: {
input_tokens: 10,
output_tokens: 20,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
service_tier: 'standard'
}
} as any
}
}
it('infers 1M contextWindow from [1m] suffix on system.init', () => {
const initMsg: SDKSystemMessage = {
type: 'system',
subtype: 'init',
session_id: 'session-1',
model: 'claude-opus-4-7[1m]'
}
converter.convert(initMsg)
const assistantLog = converter.convert(makeAssistantMessage()) as any
expect(assistantLog?.message?.usage?.context_window).toBe(1_000_000)
})
it('infers 200k contextWindow when [1m] suffix is absent', () => {
const initMsg: SDKSystemMessage = {
type: 'system',
subtype: 'init',
session_id: 'session-2',
model: 'claude-sonnet-5'
}
converter.convert(initMsg)
const assistantLog = converter.convert(makeAssistantMessage()) as any
expect(assistantLog?.message?.usage?.context_window).toBe(200_000)
})
it('refines contextWindow from result.modelUsage and applies to later assistants', () => {
const initMsg: SDKSystemMessage = {
type: 'system',
subtype: 'init',
session_id: 'session-3',
model: 'claude-opus-4-7[1m]'
}
converter.convert(initMsg)
// First assistant gets the 1M estimate from the [1m] suffix
const first = converter.convert(makeAssistantMessage()) as any
expect(first?.message?.usage?.context_window).toBe(1_000_000)
// Result message reports authoritative contextWindow (say, 500k)
const resultMsg: SDKResultMessage = {
type: 'result',
subtype: 'success',
num_turns: 1,
total_cost_usd: 0,
duration_ms: 1,
duration_api_ms: 1,
is_error: false,
session_id: 'session-3',
modelUsage: {
'claude-opus-4-7[1m]': { contextWindow: 500_000 }
}
}
converter.convert(resultMsg)
// Subsequent assistant message uses the refined value
const second = converter.convert(makeAssistantMessage()) as any
expect(second?.message?.usage?.context_window).toBe(500_000)
})
it('does not overwrite an explicit context_window already set by upstream', () => {
const initMsg: SDKSystemMessage = {
type: 'system',
subtype: 'init',
session_id: 'session-4',
model: 'claude-opus-4-7[1m]'
}
converter.convert(initMsg)
const assistantMsg: SDKAssistantMessage = {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
usage: {
input_tokens: 10,
output_tokens: 20,
context_window: 42
}
} as any
}
const log = converter.convert(assistantMsg) as any
expect(log?.message?.usage?.context_window).toBe(42)
})
it('leaves usage untouched when no system.init was seen', () => {
const log = converter.convert(makeAssistantMessage()) as any
expect(log?.message?.usage?.context_window).toBeUndefined()
})
it('does not downgrade to the 200k heuristic on a same-model re-init after result refined it (sticky, per-model)', () => {
// Turn 1: new-CLI-style init with no [1m] suffix (the actual regression trigger).
converter.convert({
type: 'system',
subtype: 'init',
session_id: 'session-5',
model: 'claude-opus-4-8'
} as SDKSystemMessage)
const turn1 = converter.convert({
type: 'assistant',
message: {
role: 'assistant',
model: 'claude-opus-4-8',
content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 10, output_tokens: 20 }
}
} as any) as any
expect(turn1?.message?.usage?.context_window).toBe(200_000)
// Result arrives with the authoritative window for this model.
converter.convert({
type: 'result',
subtype: 'success',
num_turns: 1,
total_cost_usd: 0,
duration_ms: 1,
duration_api_ms: 1,
is_error: false,
session_id: 'session-5',
modelUsage: {
'claude-opus-4-8': { contextWindow: 1_000_000 }
}
} as SDKResultMessage)
// Turn 2: the CLI re-emits system/init for the *same* model (this happens on
// every turn in the remote launcher's while-loop). The stale 200k heuristic
// must NOT clobber the value we already learned from result.modelUsage.
converter.convert({
type: 'system',
subtype: 'init',
session_id: 'session-5',
model: 'claude-opus-4-8'
} as SDKSystemMessage)
const turn2 = converter.convert({
type: 'assistant',
message: {
role: 'assistant',
model: 'claude-opus-4-8',
content: [{ type: 'text', text: 'hi again' }],
usage: { input_tokens: 10, output_tokens: 20 }
}
} as any) as any
expect(turn2?.message?.usage?.context_window).toBe(1_000_000)
})
it('gives a switched-to model its own seed instead of inheriting the previous model\'s cached window (per-model cache, not globally sticky)', () => {
// Learn opus's real 1M window first.
converter.convert({
type: 'system',
subtype: 'init',
session_id: 'session-6',
model: 'claude-opus-4-8'
} as SDKSystemMessage)
converter.convert({
type: 'result',
subtype: 'success',
num_turns: 1,
total_cost_usd: 0,
duration_ms: 1,
duration_api_ms: 1,
is_error: false,
session_id: 'session-6',
modelUsage: {
'claude-opus-4-8': { contextWindow: 1_000_000 }
}
} as SDKResultMessage)
// User switches to a different model mid-session. It has no cached value and no
// 1M seed signal here (bare init, no selectedModel), so it gets its own
// conservative 200k seed rather than inheriting opus's cached 1M. (Its real
// window would arrive with its own first result; this asserts cache isolation,
// i.e. the value is keyed per model rather than a single global sticky number.)
converter.convert({
type: 'system',
subtype: 'init',
session_id: 'session-6',
model: 'claude-sonnet-5'
} as SDKSystemMessage)
const afterSwitch = converter.convert({
type: 'assistant',
message: {
role: 'assistant',
model: 'claude-sonnet-5',
content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 10, output_tokens: 20 }
}
} as any) as any
// The switched-to model gets its own seed, not opus's cached 1M value.
expect(afterSwitch?.message?.usage?.context_window).toBe(200_000)
})
it('seeds a 1M turn-1 estimate from selectedModel for an [1m] preset whose init model arrives bare (fable[1m] shape)', () => {
// Real claude 2.1.200 shape for "fable[1m]": init.model and result keys are
// BARE ("claude-fable-5", no suffix), unlike opus[1m]/sonnet[1m] which keep it.
// So systemMsg.model.endsWith('[1m]') is false here — the selectedModel hint is
// the only thing that lets turn 1 seed 1M instead of flashing 200k until the
// first result lands. This is why the selectedModel seed is load-bearing.
const seededConverter = new SDKToLogConverter({
...context,
selectedModel: 'fable[1m]'
} as any)
seededConverter.convert({
type: 'system',
subtype: 'init',
session_id: 'session-7',
model: 'claude-fable-5'
} as SDKSystemMessage)
const turn1 = seededConverter.convert({
type: 'assistant',
message: {
role: 'assistant',
model: 'claude-fable-5',
content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 10, output_tokens: 20 }
}
} as any) as any
expect(turn1?.message?.usage?.context_window).toBe(1_000_000)
})
it('seeds a conservative 200k when neither the bare init model nor a selectedModel hint indicates 1M, until result confirms it', () => {
// Bare init model + no selectedModel hint: neither signal says "1M", so we can't
// know the real window on turn 1 and seed 200k conservatively rather than
// guessing high. The authoritative value arrives with the first result. (A real
// 1M account whose init keeps the suffix, e.g. "claude-opus-4-8[1m]", or that
// carries a selectedModel hint, seeds 1M immediately instead — covered above.)
const defaultConverter = new SDKToLogConverter({ ...context } as any)
defaultConverter.convert({
type: 'system',
subtype: 'init',
session_id: 'session-8',
model: 'claude-fable-5'
} as SDKSystemMessage)
const turn1 = defaultConverter.convert({
type: 'assistant',
message: {
role: 'assistant',
model: 'claude-fable-5',
content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 10, output_tokens: 20 }
}
} as any) as any
expect(turn1?.message?.usage?.context_window).toBe(200_000)
})
it('seeds correctly after a live mid-session switch TO an [1m] preset (updateSelectedModel), not the stale construction-time value', () => {
// Session started on Default (no selectedModel) -- as HAPI's remote launcher
// does for every turn via updateSelectedModel(), not just turn 1.
const liveConverter = new SDKToLogConverter({ ...context } as any)
liveConverter.convert({
type: 'system',
subtype: 'init',
session_id: 'session-9',
model: 'claude-sonnet-5'
} as SDKSystemMessage)
const beforeSwitch = liveConverter.convert({
type: 'assistant',
message: {
role: 'assistant',
model: 'claude-sonnet-5',
content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 10, output_tokens: 20 }
}
} as any) as any
expect(beforeSwitch?.message?.usage?.context_window).toBe(200_000)
// User switches to an explicit 1M preset (fable[1m], whose init model arrives
// bare so the selectedModel hint is what carries the 1M signal). Without a live
// update, the converter would still be seeding from the session-start snapshot
// (none) and would under-seed 200k for this new model's first turn too.
liveConverter.updateSelectedModel('fable[1m]')
liveConverter.convert({
type: 'system',
subtype: 'init',
session_id: 'session-9',
model: 'claude-fable-5'
} as SDKSystemMessage)
const afterSwitch = liveConverter.convert({
type: 'assistant',
message: {
role: 'assistant',
model: 'claude-fable-5',
content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 10, output_tokens: 20 }
}
} as any) as any
expect(afterSwitch?.message?.usage?.context_window).toBe(1_000_000)
})
it('does not over-seed after a live mid-session switch AWAY FROM an [1m] preset (updateSelectedModel)', () => {
// Session started on an explicit 1M preset (fable[1m]; its init model arrives
// bare, so the selectedModel hint carries the 1M signal on turn 1).
const liveConverter = new SDKToLogConverter({
...context,
selectedModel: 'fable[1m]'
} as any)
liveConverter.convert({
type: 'system',
subtype: 'init',
session_id: 'session-10',
model: 'claude-fable-5'
} as SDKSystemMessage)
const beforeSwitch = liveConverter.convert({
type: 'assistant',
message: {
role: 'assistant',
model: 'claude-fable-5',
content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 10, output_tokens: 20 }
}
} as any) as any
expect(beforeSwitch?.message?.usage?.context_window).toBe(1_000_000)
// User switches away to a model with no 1M signal (no "[1m]" on the updated
// selectedModel and none on the bare init model). Without a live update, the
// converter would still be seeding from the stale "fable[1m]" snapshot and
// would over-seed 1,000,000 for the newly-selected model's first turn.
liveConverter.updateSelectedModel('sonnet')
liveConverter.convert({
type: 'system',
subtype: 'init',
session_id: 'session-10',
model: 'claude-sonnet-5'
} as SDKSystemMessage)
const afterSwitch = liveConverter.convert({
type: 'assistant',
message: {
role: 'assistant',
model: 'claude-sonnet-5',
content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 10, output_tokens: 20 }
}
} as any) as any
expect(afterSwitch?.message?.usage?.context_window).toBe(200_000)
})
it('injects 1M for an explicit [1m] preset whose assistant messages report a bare model id (real opus[1m] shape)', () => {
// Real claude 2.1.200 shape for an explicit "opus[1m]" session: system/init and
// result.modelUsage both use the suffixed id "claude-opus-4-8[1m]", while each
// assistant message reports the bare "claude-opus-4-8". Because init and result
// agree, the cache stores 1M under the suffixed key and the assistant lookup —
// which goes through the resolved cache key (= the suffixed init id here), not
// the bare message.model — finds it. (A lookup keyed on the bare message.model
// would miss.)
const seededConverter = new SDKToLogConverter({
...context,
selectedModel: 'opus[1m]'
} as any)
seededConverter.convert({
type: 'system',
subtype: 'init',
session_id: 'session-11',
model: 'claude-opus-4-8[1m]'
} as SDKSystemMessage)
// result reports the authoritative 1M under the SUFFIXED key...
seededConverter.convert({
type: 'result',
subtype: 'success',
num_turns: 1,
total_cost_usd: 0,
duration_ms: 1,
duration_api_ms: 1,
is_error: false,
session_id: 'session-11',
modelUsage: {
'claude-opus-4-8[1m]': { contextWindow: 1_000_000 }
}
} as SDKResultMessage)
// ...but the assistant message reports the BARE model id.
const assistant = seededConverter.convert({
type: 'assistant',
message: {
role: 'assistant',
model: 'claude-opus-4-8',
content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 10, output_tokens: 20 }
}
} as any) as any
expect(assistant?.message?.usage?.context_window).toBe(1_000_000)
})
it('injects the MAIN session window into a sidechain (subagent) message, not the subagent model\'s own window', () => {
// Main session is opus[1m] (1M). A Task subagent runs on haiku (200k). Because
// the web status bar picks the most recent usage message without filtering
// sidechains, the subagent's assistant message must carry the main 1M window,
// or the footer denominator would visibly drop to 200k while the subagent runs.
// The subagent emits no system/init of its own, so the resolved cache key stays
// on the main model — and since lookups always go through that key, the sidechain
// message inherits the main window automatically. (The subagent's own 200k is
// still cached under its own id, but it is never the resolved lookup key here.)
const liveConverter = new SDKToLogConverter({
...context,
selectedModel: 'opus[1m]'
} as any)
liveConverter.convert({
type: 'system',
subtype: 'init',
session_id: 'session-12',
model: 'claude-opus-4-8[1m]'
} as SDKSystemMessage)
liveConverter.convert({
type: 'result',
subtype: 'success',
num_turns: 1,
total_cost_usd: 0,
duration_ms: 1,
duration_api_ms: 1,
is_error: false,
session_id: 'session-12',
modelUsage: {
'claude-opus-4-8[1m]': { contextWindow: 1_000_000 },
'claude-haiku-4-5-20251001': { contextWindow: 200_000 }
}
} as SDKResultMessage)
// Sidechain assistant message from the haiku subagent (carries parent_tool_use_id).
const sidechain = liveConverter.convert({
type: 'assistant',
parent_tool_use_id: 'toolu_task_1',
message: {
role: 'assistant',
model: 'claude-haiku-4-5-20251001',
content: [{ type: 'text', text: 'subagent reply' }],
usage: { input_tokens: 10, output_tokens: 20 }
}
} as any) as any
expect(sidechain?.message?.usage?.context_window).toBe(1_000_000)
})
it('keeps plain vs [1m] variants of the same base model on distinct cache keys (multi-tier: no collision)', () => {
// On some tiers plain "sonnet" is 200k while "sonnet[1m]" is 1M. For sonnet the
// CLI already reports the "[1m]" on system/init.model and the result key, so the
// two variants land on DISTINCT cache keys on their own (only the per-turn
// assistant message.model is bare/lossy, which is why lookups go through the
// resolved cache key, not message.model). fable is the case where the CLI does
// NOT suffix the id and the key has to be folded — covered by the next test.
const conv = new SDKToLogConverter({ ...context, selectedModel: 'sonnet[1m]' } as any)
// Turn 1 on sonnet[1m]: learns 1M under the suffixed key.
conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-sonnet-5[1m]' } as SDKSystemMessage)
conv.convert({
type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0,
duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 's',
modelUsage: { 'claude-sonnet-5[1m]': { contextWindow: 1_000_000 } }
} as SDKResultMessage)
const t1 = conv.convert({
type: 'assistant',
message: { role: 'assistant', model: 'claude-sonnet-5', content: [{ type: 'text', text: 'a' }], usage: { input_tokens: 10, output_tokens: 20 } }
} as any) as any
expect(t1?.message?.usage?.context_window).toBe(1_000_000)
// Switch to plain sonnet (200k on this tier): learns 200k under the bare key.
conv.updateSelectedModel('sonnet')
conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-sonnet-5' } as SDKSystemMessage)
conv.convert({
type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0,
duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 's',
modelUsage: { 'claude-sonnet-5': { contextWindow: 200_000 } }
} as SDKResultMessage)
const t2 = conv.convert({
type: 'assistant',
message: { role: 'assistant', model: 'claude-sonnet-5', content: [{ type: 'text', text: 'b' }], usage: { input_tokens: 10, output_tokens: 20 } }
} as any) as any
expect(t2?.message?.usage?.context_window).toBe(200_000)
// Switch back to sonnet[1m], turn-1 before its result re-arrives: must still read
// 1M from the suffixed key, NOT the 200k that plain sonnet just cached under the
// bare key (that cross-contamination is exactly what suffix-stripping would cause).
conv.updateSelectedModel('sonnet[1m]')
conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-sonnet-5[1m]' } as SDKSystemMessage)
const t3 = conv.convert({
type: 'assistant',
message: { role: 'assistant', model: 'claude-sonnet-5', content: [{ type: 'text', text: 'c' }], usage: { input_tokens: 10, output_tokens: 20 } }
} as any) as any
expect(t3?.message?.usage?.context_window).toBe(1_000_000)
})
it('distinguishes fable vs fable[1m] even though the CLI reports both with the bare id', () => {
// Unlike opus[1m]/sonnet[1m], the CLI reports BOTH "fable" and "fable[1m]" with
// the bare id "claude-fable-5" on system/init and in result.modelUsage. A cache
// keyed on that raw id alone can't tell the two apart, so switching fable[1m]
// (1M) -> fable (200k) would keep showing the stale 1M until fable's result
// lands. Folding the selectedModel's "[1m]" into the cache key keeps them
// distinct. selectedModel is the ONLY turn-1 signal that separates them here.
const conv = new SDKToLogConverter({ ...context, selectedModel: 'fable[1m]' } as any)
// fable[1m]: seeds 1M from selectedModel, result confirms 1M.
conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-fable-5' } as SDKSystemMessage)
conv.convert({
type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0,
duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 's',
modelUsage: { 'claude-fable-5': { contextWindow: 1_000_000 } }
} as SDKResultMessage)
const t1 = conv.convert({
type: 'assistant',
message: { role: 'assistant', model: 'claude-fable-5', content: [{ type: 'text', text: 'a' }], usage: { input_tokens: 10, output_tokens: 20 } }
} as any) as any
expect(t1?.message?.usage?.context_window).toBe(1_000_000)
// Switch to plain fable (200k): must re-seed 200k, NOT keep the stale 1M.
conv.updateSelectedModel('fable')
conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-fable-5' } as SDKSystemMessage)
const t2 = conv.convert({
type: 'assistant',
message: { role: 'assistant', model: 'claude-fable-5', content: [{ type: 'text', text: 'b' }], usage: { input_tokens: 10, output_tokens: 20 } }
} as any) as any
expect(t2?.message?.usage?.context_window).toBe(200_000)
// Switch back to fable[1m]: its 1M entry was never overwritten by plain fable's
// 200k (distinct keys), so turn-1 before the next result still reads 1M.
conv.updateSelectedModel('fable[1m]')
conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-fable-5' } as SDKSystemMessage)
const t3 = conv.convert({
type: 'assistant',
message: { role: 'assistant', model: 'claude-fable-5', content: [{ type: 'text', text: 'c' }], usage: { input_tokens: 10, output_tokens: 20 } }
} as any) as any
expect(t3?.message?.usage?.context_window).toBe(1_000_000)
})
})
describe('Parent-child relationships', () => {
it('should track parent UUIDs across messages', () => {
const msg1: SDKUserMessage = {
type: 'user',
message: { role: 'user', content: 'First' }
}
const msg2: SDKAssistantMessage = {
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'Second' }] }
}
const msg3: SDKUserMessage = {
type: 'user',
message: { role: 'user', content: 'Third' }
}
const log1 = converter.convert(msg1)
const log2 = converter.convert(msg2)
const log3 = converter.convert(msg3)
expect(log1?.parentUuid).toBeNull()
expect(log2?.parentUuid).toBe(log1?.uuid)
expect(log3?.parentUuid).toBe(log2?.uuid)
})
it('should reset parent chain when requested', () => {
const msg1: SDKUserMessage = {
type: 'user',
message: { role: 'user', content: 'First' }
}
const log1 = converter.convert(msg1)
converter.resetParentChain()
const msg2: SDKUserMessage = {
type: 'user',
message: { role: 'user', content: 'Second' }
}
const log2 = converter.convert(msg2)
expect(log2?.parentUuid).toBeNull()
})
})
describe('Batch conversion', () => {
it('should convert multiple messages maintaining relationships', () => {
const messages: SDKMessage[] = [
{
type: 'user',
message: { role: 'user', content: 'Hello' }
} as SDKUserMessage,
{
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'Hi there!' }] }
} as SDKAssistantMessage,
{
type: 'user',
message: { role: 'user', content: 'How are you?' }
} as SDKUserMessage
]
const logMessages = converter.convertMany(messages)
expect(logMessages).toHaveLength(3)
expect(logMessages[0].parentUuid).toBeNull()
expect(logMessages[1].parentUuid).toBe(logMessages[0].uuid)
expect(logMessages[2].parentUuid).toBe(logMessages[1].uuid)
})
})
describe('Internal event filtering', () => {
it('should suppress rate_limit_event with allowed status', () => {
const sdkMessage = {
type: 'rate_limit_event',
rate_limit_info: {
status: 'allowed',
resetsAt: 1775559600,
rateLimitType: 'five_hour'
}
} as unknown as SDKMessage
expect(converter.convert(sdkMessage)).toBeNull()
})
it('should convert allowed_warning to pipe-delimited text', () => {
const sdkMessage = {
type: 'rate_limit_event',
rate_limit_info: {
status: 'allowed_warning',
resetsAt: 1775559600,
utilization: 0.85,
rateLimitType: 'five_hour'
}
} as unknown as SDKMessage
const logMessage = converter.convert(sdkMessage)
expect(logMessage).not.toBeNull()
expect(logMessage!.type).toBe('assistant')
expect((logMessage as any).message.content[0].text).toBe(
'Claude AI usage limit warning|1775559600|85|five_hour'
)
})
it('should convert rejected to pipe-delimited text', () => {
const sdkMessage = {
type: 'rate_limit_event',
rate_limit_info: {
status: 'rejected',
resetsAt: 1775559600,
rateLimitType: 'five_hour'
}
} as unknown as SDKMessage
const logMessage = converter.convert(sdkMessage)
expect(logMessage).not.toBeNull()
expect(logMessage!.type).toBe('assistant')
expect((logMessage as any).message.content[0].text).toBe(
'Claude AI usage limit reached|1775559600|five_hour'
)
})
it('should not break parent chain when rate_limit_event is suppressed', () => {
const user = converter.convert({
type: 'user',
message: { role: 'user', content: 'hi' }
} as SDKUserMessage)
converter.convert({
type: 'rate_limit_event',
rate_limit_info: { status: 'allowed' }
} as unknown as SDKMessage)
const assistant = converter.convert({
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] }
} as SDKAssistantMessage)
expect(assistant!.parentUuid).toBe(user!.uuid)
})
it('should chain parent correctly when rate_limit_event is converted', () => {
const user = converter.convert({
type: 'user',
message: { role: 'user', content: 'hi' }
} as SDKUserMessage)
const warning = converter.convert({
type: 'rate_limit_event',
rate_limit_info: {
status: 'allowed_warning',
resetsAt: 1775559600,
utilization: 0.8,
rateLimitType: 'five_hour'
}
} as unknown as SDKMessage)
const assistant = converter.convert({
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] }
} as SDKAssistantMessage)
expect(warning!.parentUuid).toBe(user!.uuid)
expect(assistant!.parentUuid).toBe(warning!.uuid)
})
})
describe('Unknown message types', () => {
it('should drop tool_progress heartbeat events instead of passing them through', () => {
const logMessage = converter.convert({
type: 'tool_progress',
tool_use_id: 'toolu_011qMV3YCgDP89zcjHbC4rd2-heartbeat-0',
tool_name: 'Bash',
parent_tool_use_id: 'toolu_011qMV3YCgDP89zcjHbC4rd2',
elapsed_time_seconds: 30,
heartbeat: true,
session_id: context.sessionId
} as unknown as SDKMessage)
expect(logMessage).toBeNull()
})
it('should drop arbitrary unknown SDK message types', () => {
for (const type of ['stream_event', 'control_response', 'log', 'some_future_event']) {
expect(converter.convert({ type, payload: { foo: 'bar' } } as unknown as SDKMessage)).toBeNull()
}
})
it('should drop command_lifecycle events', () => {
// Second unknown type observed leaking in the wild, after
// tool_progress. It needed no code change to cover — which is the
// point of gating on an allowlist rather than naming each offender.
const logMessage = converter.convert({
type: 'command_lifecycle',
command_uuid: 'a0c15039-fb30-4ba3-bf1b-6afc3196cbeb',
state: 'started',
session_id: context.sessionId
} as unknown as SDKMessage)
expect(logMessage).toBeNull()
})
it('should not break parent chain when an unknown type is dropped', () => {
const user = converter.convert({
type: 'user',
message: { role: 'user', content: 'hi' }
} as SDKUserMessage)
converter.convert({
type: 'tool_progress',
parent_tool_use_id: 'toolu_abc',
heartbeat: true
} as unknown as SDKMessage)
const assistant = converter.convert({
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] }
} as SDKAssistantMessage)
expect(assistant!.parentUuid).toBe(user!.uuid)
})
it('should not let repeated tool_progress heartbeats hijack sidechain parent tracking', () => {
// A long-running Bash inside a Task subagent emits a tool_progress
// heartbeat every 30s, all sharing the subagent's parent_tool_use_id.
// Converting them would overwrite sidechainLastUUID on every tick, so
// the subagent's next real message would be parented to a heartbeat
// rather than to its own previous message.
const parentToolUseId = 'toolu_011qMV3YCgDP89zcjHbC4rd2'
const firstSidechainMessage = converter.convert({
type: 'assistant',
parent_tool_use_id: parentToolUseId,
message: { role: 'assistant', content: [{ type: 'text', text: 'working' }] }
} as unknown as SDKAssistantMessage)
for (let tick = 0; tick < 3; tick++) {
const heartbeat = converter.convert({
type: 'tool_progress',
tool_use_id: `${parentToolUseId}-heartbeat-${tick}`,
tool_name: 'Bash',
parent_tool_use_id: parentToolUseId,
elapsed_time_seconds: (tick + 1) * 30,
heartbeat: true,
session_id: context.sessionId
} as unknown as SDKMessage)
expect(heartbeat).toBeNull()
}
const nextSidechainMessage = converter.convert({
type: 'assistant',
parent_tool_use_id: parentToolUseId,
message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] }
} as unknown as SDKAssistantMessage)
expect(nextSidechainMessage!.isSidechain).toBe(true)
expect(nextSidechainMessage!.parentUuid).toBe(firstSidechainMessage!.uuid)
})
it('should still convert every known type', () => {
expect(converter.convert({
type: 'user',
message: { role: 'user', content: 'hi' }
} as SDKUserMessage)).toBeTruthy()
expect(converter.convert({
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'yo' }] }
} as SDKAssistantMessage)).toBeTruthy()
expect(converter.convert({
type: 'system',
subtype: 'init',
model: 'claude-opus-4-8'
} as SDKSystemMessage)).toBeTruthy()
expect(converter.convert({
type: 'tool_result',
tool_use_id: 'toolu_x',
content: 'done'
} as unknown as SDKMessage)).toBeTruthy()
})
})
describe('Sidechain parentToolUseId preservation (subagent trace grouping fix)', () => {
it('preserves parent_tool_use_id as parentToolUseId on sidechain user messages', () => {
const sdkMessage = {
type: 'user',
parent_tool_use_id: 'toolu_abc123',
message: { role: 'user', content: 'sidechain prompt' }
} as unknown as SDKUserMessage
const logMessage = converter.convert(sdkMessage) as any
expect(logMessage?.isSidechain).toBe(true)
expect(logMessage?.parentToolUseId).toBe('toolu_abc123')
})
it('preserves parent_tool_use_id on sidechain assistant messages (subagent turns)', () => {
const sdkMessage = {
type: 'assistant',
parent_tool_use_id: 'toolu_abc123',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'subagent reply' }]
}
} as unknown as SDKAssistantMessage
const logMessage = converter.convert(sdkMessage) as any
expect(logMessage?.isSidechain).toBe(true)
expect(logMessage?.parentToolUseId).toBe('toolu_abc123')
})
it('does not set parentToolUseId on non-sidechain (top-level) messages', () => {
const sdkMessage: SDKUserMessage = {
type: 'user',
message: { role: 'user', content: 'top-level message' }
}
const logMessage = converter.convert(sdkMessage) as any
expect(logMessage?.isSidechain).toBe(false)
expect(logMessage?.parentToolUseId).toBeUndefined()
})
it('preserves parentToolUseId on interrupted sidechain tool results', () => {
const logMessage = converter.generateInterruptedToolResult('toolu_child', 'toolu_parent') as any
expect(logMessage?.isSidechain).toBe(true)
expect(logMessage?.parentToolUseId).toBe('toolu_parent')
})
it('does not set parentToolUseId on interrupted top-level tool results', () => {
const logMessage = converter.generateInterruptedToolResult('toolu_child') as any
expect(logMessage?.isSidechain).toBe(false)
expect(logMessage?.parentToolUseId).toBeUndefined()
})
})
describe('Convenience function', () => {
it('should convert single message without state', () => {
const sdkMessage: SDKUserMessage = {
type: 'user',
message: { role: 'user', content: 'Test message' }
}
const logMessage = convertSDKToLog(sdkMessage, context)
expect(logMessage).toBeTruthy()
expect(logMessage?.type).toBe('user')
expect(logMessage?.parentUuid).toBeNull()
})
})
describe('Tool results with mode', () => {
it('should add mode to tool result when available in responses', () => {
const responses = new Map<string, { approved: boolean; mode?: ClaudePermissionMode; reason?: string }>()
responses.set('tool_123', { approved: true, mode: 'acceptEdits' })
const converterWithResponses = new SDKToLogConverter(context, responses)
const sdkMessage: SDKUserMessage = {
type: 'user',
message: {
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: 'tool_123',
content: 'Tool executed successfully'
}]
}
}
const logMessage = converterWithResponses.convert(sdkMessage)
expect(logMessage).toBeTruthy()
expect((logMessage as any).mode).toBe('acceptEdits')
expect((logMessage as any).toolUseResult).toBeUndefined() // toolUseResult is not added when using array content
})
it('should not add mode when not in responses', () => {
const responses = new Map<string, { approved: boolean; mode?: ClaudePermissionMode; reason?: string }>()
const converterWithResponses = new SDKToLogConverter(context, responses)
const sdkMessage: SDKUserMessage = {
type: 'user',
message: {
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: 'tool_456',
content: 'Tool result'
}]
}
}
const logMessage = converterWithResponses.convert(sdkMessage)
expect(logMessage).toBeTruthy()
expect((logMessage as any).mode).toBeUndefined()
expect((logMessage as any).toolUseResult).toBeUndefined() // toolUseResult is not added when using array content
})
it('should handle mixed content with tool results', () => {
const responses = new Map<string, { approved: boolean; mode?: ClaudePermissionMode; reason?: string }>()
responses.set('tool_789', { approved: true, mode: 'bypassPermissions' })
const converterWithResponses = new SDKToLogConverter(context, responses)
const sdkMessage: SDKUserMessage = {
type: 'user',
message: {
role: 'user',
content: [
{ type: 'text', text: 'Here is the result:' },
{
type: 'tool_result',
tool_use_id: 'tool_789',
content: 'Tool output'
}
]
}
}
const logMessage = converterWithResponses.convert(sdkMessage)
expect(logMessage).toBeTruthy()
expect((logMessage as any).mode).toBe('bypassPermissions')
expect((logMessage as any).toolUseResult).toBeUndefined() // toolUseResult is not added when using array content
})
it('should work with convenience function', () => {
const responses = new Map<string, { approved: boolean; mode?: ClaudePermissionMode; reason?: string }>()
responses.set('tool_abc', { approved: false, mode: 'plan', reason: 'User rejected' })
const sdkMessage: SDKUserMessage = {
type: 'user',
message: {
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: 'tool_abc',
content: 'Permission denied'
}]
}
}
const logMessage = convertSDKToLog(sdkMessage, context, responses)
expect(logMessage).toBeTruthy()
expect((logMessage as any).mode).toBe('plan')
})
})
})