File-aware retry detection with typed ToolCall (#379)

* Fix Antigravity provider detection, Codex fork double-counting, and tab ordering

Antigravity:
- Handle ephemeral port (--https_server_port 0) via lsof fallback
- Force reparse when cached turns are 0 (server may have been unavailable)
- Persist precomputed costUSD in session cache for correct pricing
- Map gemini-pro-agent to gemini-3.1-pro pricing
- Add display names for gemini-pro-agent and gemini-3.5-flash-low

Codex:
- Detect forked sessions via forked_from_id in session_meta
- Skip replayed parent events within 5s of fork creation time
- Use parent session ID in dedup key so parent+fork don't double-count

Menubar:
- Sort provider tabs by cost descending instead of enum declaration order

* Add tooling breakdowns to CLI dashboard and menubar

Surface skills, subagents, tools, and MCP server usage in both the
text dashboard and menubar app. Parse subagent types from Agent/Task
tool calls, aggregate with skills into a merged "Skills & Agents"
panel. Remove Top Sessions panel from CLI dashboard.

* Fix Codex always reporting 100% one-shot rate

Codex parser never set turnId or toolSequence, so retry detection
could not see Edit→Bash→Edit patterns across API calls. Now each
tool event builds a toolSequence step, and user messages generate
a new turnId to group calls into logical turns. Bump Codex cache
version to force re-parse of existing sessions.

* File-aware retry detection with typed ToolCall

Replace name-only Edit→Bash→Edit retry detection with file-aware
tracking. A retry now requires the SAME file to be re-edited after
a bash step, not just any edit after any bash.

- Add ToolCall type ({ tool, file?, command? }) replacing string[][]
  for toolSequence across all types, providers, and cache
- Generate per-tool toolSequence in Claude parser with file paths
  from Edit/Write tool inputs
- Extract file_path for Edit/Write tools in all three parser paths
  (string, buffer, compact)
- Fix apiCallToCachedCall missing toolSequence (Claude data was never
  cached with tool sequence info)
- Update Codex, Goose, Kiro parsers for typed ToolCall
- Bump session cache version to 3, codex cache version to 3

* Extract file paths from Codex patch_apply_end and function_call args

Codex stores file paths in patch_apply_end.changes keys and function
call arguments as a JSON string. Parse both to populate ToolCall.file,
enabling file-aware retry detection for Codex sessions.

* Fix retry detection fallback for file-less providers, update docs

- Use __no_file__ sentinel in countRetries so providers without file
  paths (Kiro, Gemini, Vibe) fall back to name-based retry detection
- Reset pendingToolSequence on Codex dedup skip to prevent leak
- Update classifier tests to ToolCall[][] format
- Document file-aware one-shot rate in README
- Add unreleased changelog entries for tooling breakdowns and fixes
This commit is contained in:
Resham Joshi 2026-05-22 01:21:42 -07:00 committed by GitHub
parent 33b1abbbef
commit e472e37efd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 143 additions and 42 deletions

View file

@ -2,6 +2,24 @@
## Unreleased
### Added (CLI)
- **Tooling breakdowns in dashboard and menubar.** New panels showing core
tools, MCP servers, and shell command usage per session and across periods.
- **File-aware retry detection with typed ToolCall.** One-shot rate now tracks
which file was edited, so editing file A then file B after a shell step no
longer counts as a retry. Claude and Codex extract file paths from tool
inputs; Codex also parses `patch_apply_end` changes and JSON-encoded
`function_call` arguments. Providers without file path data fall back to
tool-name-based detection.
### Fixed (CLI)
- **Codex 100% one-shot rate.** Codex function_call arguments are JSON strings,
not objects, and `patch_apply_end` stores file paths in `changes` object keys.
Both are now parsed correctly.
- **Claude toolSequence missing from session cache.** `apiCallToCachedCall` was
not forwarding the `toolSequence` field, so all cached Claude sessions lost
their tool ordering data.
## 0.9.10 - 2026-05-20
### Added (CLI)

View file

@ -184,7 +184,7 @@ Daily cost chart, per-project, per-model (Opus, Sonnet, Haiku, GPT-5, GPT-4o, Ge
### One-Shot Rate
For categories that involve code edits, CodeBurn detects edit/test/fix retry cycles (Edit, Bash, Edit patterns). The one-shot column shows the percentage of edit turns that succeeded without retries. Coding at 90% means the AI got it right first try 9 out of 10 times.
For categories that involve code edits, CodeBurn tracks file-aware retry cycles. A retry is when the same file is re-edited after a shell command in between (Edit foo.ts, Bash, Edit foo.ts). Editing different files across shell steps is not a retry. The one-shot column shows the percentage of edit turns that succeeded without retries. Coding at 90% means the AI got it right first try 9 out of 10 times. File-level tracking is available for Claude, Codex, and Goose; other providers fall back to tool-name-based detection.
### Pricing

View file

@ -1,4 +1,4 @@
import type { ClassifiedTurn, ParsedTurn, TaskCategory } from './types.js'
import type { ClassifiedTurn, ParsedTurn, TaskCategory, ToolCall } from './types.js'
const TEST_PATTERNS = /\b(test|pytest|vitest|jest|mocha|spec|coverage|npm\s+test|npx\s+vitest|npx\s+jest)\b/i
const GIT_PATTERNS = /\bgit\s+(push|pull|commit|merge|rebase|checkout|branch|stash|log|diff|status|add|reset|cherry-pick|tag)\b/i
@ -15,7 +15,7 @@ const FILE_PATTERNS = /\.(py|js|ts|tsx|jsx|json|yaml|yml|toml|sql|sh|go|rs|java|
const SCRIPT_PATTERNS = /\b(run\s+\S+\.\w+|execute|scrip?t|curl|api\s+\S+|endpoint|request\s+url|fetch\s+\S+|query|database|db\s+\S+)\b/i
const URL_PATTERN = /https?:\/\/\S+/i
const EDIT_TOOLS = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit', 'cursor:edit'])
export const EDIT_TOOLS = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit', 'cursor:edit'])
const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool'])
export const BASH_TOOLS = new Set(['Bash', 'BashTool', 'PowerShellTool'])
const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TodoWrite'])
@ -154,32 +154,34 @@ function classifyConversation(userMessage: string): TaskCategory {
}
function countRetries(turn: ParsedTurn): number {
const steps: string[][] = []
const steps: ToolCall[][] = []
for (const call of turn.assistantCalls) {
if (call.toolSequence && call.toolSequence.length > 0) {
steps.push(...call.toolSequence)
} else if (call.tools.length > 0) {
steps.push(call.tools)
steps.push(call.tools.map(t => ({ tool: t })))
}
}
let sawEditBeforeBash = false
let sawBashAfterEdit = false
const lastEditStep = new Map<string, number>()
let lastVerifyStep = -1
let retries = 0
for (const tools of steps) {
const hasEdit = tools.some(t => EDIT_TOOLS.has(t))
const hasBash = tools.some(t => BASH_TOOLS.has(t))
if (hasEdit) {
if (sawBashAfterEdit) retries++
sawEditBeforeBash = true
sawBashAfterEdit = false
steps.forEach((step, i) => {
for (const call of step) {
if (BASH_TOOLS.has(call.tool)) {
lastVerifyStep = i
}
if (EDIT_TOOLS.has(call.tool)) {
const fileKey = call.file ?? '__no_file__'
const prevStep = lastEditStep.get(fileKey)
if (prevStep !== undefined && lastVerifyStep > prevStep && lastVerifyStep < i) {
retries++
}
lastEditStep.set(fileKey, i)
}
}
if (hasBash && sawEditBeforeBash) {
sawBashAfterEdit = true
}
}
})
return retries
}

View file

@ -6,7 +6,7 @@ import { homedir } from 'os'
import type { ParsedProviderCall } from './providers/types.js'
const CODEX_CACHE_VERSION = 1
const CODEX_CACHE_VERSION = 3
const CACHE_FILE = 'codex-results.json'
type FileFingerprint = { mtimeMs: number; sizeBytes: number }

View file

@ -31,9 +31,10 @@ import type {
ProjectSummary,
SessionSummary,
TokenUsage,
ToolCall,
ToolUseBlock,
} from './types.js'
import { classifyTurn, BASH_TOOLS } from './classifier.js'
import { classifyTurn, BASH_TOOLS, EDIT_TOOLS } from './classifier.js'
import { extractBashCommands } from './bash-utils.js'
function unsanitizePath(dirName: string): string {
@ -263,7 +264,7 @@ function extractLargeToolBlocks(source: string, contentBounds: JsonValueBounds |
const skillName = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'name'), 200)
if (skill !== undefined) input['skill'] = skill
if (skillName !== undefined) input['name'] = skillName
} else if (name === 'Read' || name === 'FileReadTool') {
} else if (name === 'Read' || name === 'FileReadTool' || EDIT_TOOLS.has(name)) {
const filePath = readJsonString(source, findObjectFieldValue(source, inputBounds.start, inputBounds.end, 'file_path'), BASH_COMMAND_CAP)
if (filePath !== undefined) input['file_path'] = filePath
} else if (name === 'Agent' || name === 'Task') {
@ -608,7 +609,7 @@ function extractLargeToolBlocksBuffer(source: Buffer, contentBounds: BufferJsonV
const skillName = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, inputBounds.start, inputBounds.end, 'name'), 200)
if (skill !== undefined) input['skill'] = skill
if (skillName !== undefined) input['name'] = skillName
} else if (name === 'Read' || name === 'FileReadTool') {
} else if (name === 'Read' || name === 'FileReadTool' || EDIT_TOOLS.has(name)) {
const filePath = readJsonStringBuffer(source, findObjectFieldValueBuffer(source, inputBounds.start, inputBounds.end, 'file_path'), BASH_COMMAND_CAP)
if (filePath !== undefined) input['file_path'] = filePath
} else if (name === 'Agent' || name === 'Task') {
@ -839,7 +840,7 @@ export function compactEntry(raw: JournalEntry): JournalEntry {
const ri = (tb.input ?? {}) as Record<string, unknown>
if (typeof ri['skill'] === 'string') input['skill'] = (ri['skill'] as string).slice(0, 200)
if (typeof ri['name'] === 'string') input['name'] = (ri['name'] as string).slice(0, 200)
} else if (tb.name === 'Read' || tb.name === 'FileReadTool') {
} else if (tb.name === 'Read' || tb.name === 'FileReadTool' || EDIT_TOOLS.has(tb.name)) {
const ri = (tb.input ?? {}) as Record<string, unknown>
if (typeof ri['file_path'] === 'string') input['file_path'] = (ri['file_path'] as string).slice(0, BASH_COMMAND_CAP)
} else if (tb.name === 'Agent' || tb.name === 'Task') {
@ -1006,6 +1007,16 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
const bashCmds = extractBashCommandsFromContent(msg.content ?? [])
const toolSeq: ToolCall[][] = (msg.content ?? [])
.filter((b): b is ToolUseBlock => b.type === 'tool_use')
.map(b => {
const call: ToolCall = { tool: b.name }
const inp = (b.input ?? {}) as Record<string, unknown>
if (typeof inp['file_path'] === 'string') call.file = inp['file_path'] as string
if (typeof inp['command'] === 'string') call.command = inp['command'] as string
return [call]
})
return {
provider: 'claude',
model: msg.model,
@ -1022,6 +1033,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
bashCommands: bashCmds,
deduplicationKey: msg.id ?? `claude:${entry.timestamp}`,
cacheCreationOneHourTokens: cacheCreation.oneHourTokens || undefined,
toolSequence: toolSeq.length > 0 ? toolSeq : undefined,
}
}
@ -1578,6 +1590,7 @@ function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
skills: call.skills,
subagentTypes: call.subagentTypes,
deduplicationKey: call.deduplicationKey,
toolSequence: call.toolSequence,
}
}

View file

@ -7,6 +7,7 @@ import { homedir } from 'os'
import { readSessionLines } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import { readCachedCodexResults, writeCachedCodexResults, getCachedCodexProject, fingerprintFile } from '../codex-cache.js'
import type { ToolCall } from '../types.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
const modelDisplayNames: Record<string, string> = {
@ -331,9 +332,12 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
let prevOutput = 0
let prevReasoning = 0
let pendingTools: string[] = []
let pendingToolSequence: ToolCall[][] = []
let pendingUserMessage = ''
let pendingOutputChars = 0
let estCounter = 0
let turnCounter = 0
let currentTurnId = `${sessionId}:t0`
let sawAnyLine = false
const results: ParsedProviderCall[] = []
@ -364,12 +368,35 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
const rawName = entry.payload.name ?? ''
pendingTools.push(toolNameMap[rawName] ?? rawName)
const mapped = toolNameMap[rawName] ?? rawName
pendingTools.push(mapped)
const call: ToolCall = { tool: mapped }
const rawArgs = (entry.payload as Record<string, unknown>)['arguments']
const args = typeof rawArgs === 'string'
? (() => { try { return JSON.parse(rawArgs) as Record<string, unknown> } catch { return null } })()
: typeof rawArgs === 'object' && rawArgs ? rawArgs as Record<string, unknown> : null
if (args) {
const fp = args['file_path'] ?? args['path']
if (typeof fp === 'string') call.file = fp
const cmd = args['command'] ?? args['cmd']
if (typeof cmd === 'string') call.command = cmd
}
pendingToolSequence.push([call])
continue
}
if (entry.type === 'event_msg' && entry.payload?.type === 'patch_apply_end') {
pendingTools.push('Edit')
const p = entry.payload as Record<string, unknown>
const changes = p['changes']
const filePaths = typeof changes === 'object' && changes ? Object.keys(changes as object) : []
if (filePaths.length > 0) {
for (const fp of filePaths) {
pendingToolSequence.push([{ tool: 'Edit', file: fp }])
}
} else {
pendingToolSequence.push([{ tool: 'Edit' }])
}
continue
}
@ -378,7 +405,10 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
.filter(c => c.type === 'input_text')
.map(c => c.text ?? '')
.filter(Boolean)
if (texts.length > 0) pendingUserMessage = texts.join(' ').slice(0, 500)
if (texts.length > 0) {
pendingUserMessage = texts.join(' ').slice(0, 500)
currentTurnId = `${sessionId}:t${++turnCounter}`
}
continue
}
@ -406,7 +436,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
const timestamp = entry.timestamp ?? ''
const dedupKey = `codex:${sessionId}:${timestamp}:est${estCounter++}`
if (seenKeys.has(dedupKey)) { pendingTools = []; pendingUserMessage = ''; pendingOutputChars = 0; continue }
if (seenKeys.has(dedupKey)) { pendingTools = []; pendingToolSequence = []; pendingUserMessage = ''; pendingOutputChars = 0; continue }
seenKeys.add(dedupKey)
const costUSD = calculateCost(model, estInput, estOutput, 0, 0, 0)
@ -428,11 +458,14 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
timestamp,
speed: 'standard',
deduplicationKey: dedupKey,
turnId: currentTurnId,
toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined,
userMessage: pendingUserMessage,
sessionId,
})
pendingTools = []
pendingToolSequence = []
pendingUserMessage = ''
pendingOutputChars = 0
continue
@ -521,11 +554,14 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
timestamp,
speed: 'standard',
deduplicationKey: dedupKey,
turnId: currentTurnId,
toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined,
userMessage: pendingUserMessage,
sessionId,
})
pendingTools = []
pendingToolSequence = []
pendingUserMessage = ''
pendingOutputChars = 0
}

View file

@ -4,6 +4,7 @@ import { homedir, platform } from 'os'
import { calculateCost, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js'
import type { ToolCall } from '../types.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
type SessionRow = {
@ -80,11 +81,11 @@ function parseModelConfig(raw: string | null): ModelConfig {
}
}
function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tools: string[]; bashCommands: string[]; toolSequence: string[][] } {
function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tools: string[]; bashCommands: string[]; toolSequence: ToolCall[][] } {
const tools: string[] = []
const bashCommands: string[] = []
const seen = new Set<string>()
const toolSequence: string[][] = []
const toolSequence: ToolCall[][] = []
try {
const rows = db.query<{ content_json: Uint8Array | string }>(
@ -99,7 +100,7 @@ function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tool
} catch {
continue
}
const msgTools: string[] = []
const msgCalls: ToolCall[] = []
for (const item of items) {
if (item.type !== 'toolRequest') continue
const rawName = item.toolCall?.value?.name ?? ''
@ -109,7 +110,15 @@ function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tool
seen.add(mapped)
tools.push(mapped)
}
msgTools.push(mapped)
const call: ToolCall = { tool: mapped }
const args = item.toolCall?.value?.arguments
if (args && typeof args === 'object') {
const fp = (args as Record<string, unknown>)['file_path']
if (typeof fp === 'string') call.file = fp
const cmd = (args as Record<string, unknown>)['command']
if (typeof cmd === 'string') call.command = cmd
}
msgCalls.push(call)
if (mapped === 'Bash') {
const cmd = item.toolCall?.value?.arguments?.command
if (typeof cmd === 'string') {
@ -119,7 +128,7 @@ function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tool
}
}
}
if (msgTools.length > 0) toolSequence.push(msgTools)
if (msgCalls.length > 0) toolSequence.push(msgCalls)
}
} catch { /* best-effort */ }

View file

@ -4,6 +4,7 @@ import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import type { ToolCall } from '../types.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
const CHARS_PER_TOKEN = 4
@ -86,7 +87,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s
let pendingUserMessage = ''
const allTools: string[] = []
const toolSequence: string[][] = []
const toolSequence: ToolCall[][] = []
for (const msg of chat) {
if (msg.role === 'human') {
@ -96,7 +97,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s
if (msg.role === 'bot') {
const msgTools = extractToolNames(msg.content)
allTools.push(...msgTools)
if (msgTools.length > 0) toolSequence.push(msgTools)
if (msgTools.length > 0) toolSequence.push(msgTools.map(t => ({ tool: t })))
}
}

View file

@ -1,3 +1,5 @@
import type { ToolCall } from '../types.js'
export type SessionSource = {
path: string
project: string
@ -26,7 +28,7 @@ export type ParsedProviderCall = {
speed: 'standard' | 'fast'
deduplicationKey: string
turnId?: string
toolSequence?: string[][]
toolSequence?: ToolCall[][]
userMessage: string
sessionId: string
project?: string

View file

@ -4,6 +4,8 @@ import { createHash, randomBytes } from 'crypto'
import { join } from 'path'
import { homedir } from 'os'
import type { ToolCall } from './types.js'
// ── Types ──────────────────────────────────────────────────────────────
export type CachedUsage = {
@ -31,7 +33,7 @@ export type CachedCall = {
deduplicationKey: string
project?: string
projectPath?: string
toolSequence?: string[][]
toolSequence?: ToolCall[][]
}
export type CachedTurn = {
@ -68,7 +70,7 @@ export type SessionCache = {
// ── Constants ──────────────────────────────────────────────────────────
export const CACHE_VERSION = 2
export const CACHE_VERSION = 3
const CACHE_FILE = 'session-cache.json'
const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000
@ -128,6 +130,18 @@ function isOptionalNum(v: unknown): boolean {
return v === undefined || isNum(v)
}
function isToolCall(v: unknown): boolean {
if (!v || typeof v !== 'object') return false
const o = v as Record<string, unknown>
return typeof o['tool'] === 'string'
&& isOptionalString(o['file'])
&& isOptionalString(o['command'])
}
function isToolCallArray(v: unknown): boolean {
return Array.isArray(v) && (v as unknown[]).every(isToolCall)
}
function validateFingerprint(fp: unknown): fp is FileFingerprint {
if (!fp || typeof fp !== 'object') return false
const f = fp as Record<string, unknown>
@ -157,7 +171,7 @@ function validateCall(c: unknown): c is CachedCall {
&& isStringArray(o['skills'])
&& isOptionalString(o['project'])
&& isOptionalString(o['projectPath'])
&& (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isStringArray(s))))
&& (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isToolCallArray(s))))
&& validateUsage(o['usage'])
}

View file

@ -85,7 +85,13 @@ export type ParsedApiCall = {
bashCommands: string[]
deduplicationKey: string
cacheCreationOneHourTokens?: number
toolSequence?: string[][]
toolSequence?: ToolCall[][]
}
export type ToolCall = {
tool: string
file?: string
command?: string
}
export type TaskCategory =

View file

@ -165,7 +165,7 @@ describe('classifyTurn — retry detection via toolSequence', () => {
it('detects retries from toolSequence on a single call (Kiro/Goose-style)', () => {
const call = makeCall({ tools: ['Edit', 'Bash'] })
call.toolSequence = [['Edit'], ['Bash'], ['Edit']]
call.toolSequence = [[{ tool: 'Edit' }], [{ tool: 'Bash' }], [{ tool: 'Edit' }]]
const turn = makeTurn([call], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(1)
@ -180,7 +180,7 @@ describe('classifyTurn — retry detection via toolSequence', () => {
it('counts multiple retries from toolSequence', () => {
const call = makeCall({ tools: ['Edit', 'Bash'] })
call.toolSequence = [['Edit'], ['Bash'], ['Edit'], ['Bash'], ['Edit']]
call.toolSequence = [[{ tool: 'Edit' }], [{ tool: 'Bash' }], [{ tool: 'Edit' }], [{ tool: 'Bash' }], [{ tool: 'Edit' }]]
const turn = makeTurn([call], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(2)
@ -188,7 +188,7 @@ describe('classifyTurn — retry detection via toolSequence', () => {
it('ignores toolSequence with only one step', () => {
const call = makeCall({ tools: ['Edit', 'Bash'] })
call.toolSequence = [['Edit', 'Bash']]
call.toolSequence = [[{ tool: 'Edit' }, { tool: 'Bash' }]]
const turn = makeTurn([call], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(0)