From a1f8f4c4ffd5e206c4172e4a03efafdff839177d Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 14 Jul 2026 20:50:11 +0000 Subject: [PATCH 1/3] kiro: add IDE v2 session store support with credit-based pricing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kiro's new IDE builds write sessions to ~/.kiro/sessions//sess_/ (session.json + messages.jsonl event log) instead of globalStorage. Add a v2 parser and discovery, plus fixes surfaced while validating against a real upgraded machine: - Parse v2 event-sourced turns (user/turn_start/assistant/tool_call/ tool_result/usage_summary/turn_end) with per-execution dedup keys (kiro-v2::) and defensive flushes for out-of-order events and in-progress sessions - Price turns from real metered credits at the public overage rate (USD_PER_KIRO_CREDIT = $0.04/credit, individual plan: $20/mo for 1,000 credits), falling back to token estimation only when a turn has no usage_summary (aborted/in-flight); costIsEstimated distinguishes the two - Fix pre-existing CLI parser bug: metering_usage values are credits, not dollars — costs were overstated 25x at the real rate - Count v2 tool_result content as input context (matching the CLI parser's ToolResults treatment); previously only the user prompt was counted, undercounting input ~25x on agentic turns - Keep reasoningTokens disjoint from outputTokens: downstream aggregation (models-report, audit-report, parser) sums the two fields, so folding reasoning into output would double-count. Combine them only for the token-pricing fallback, matching codex/gemini - Take the real modelId from session.json, so v2 sessions are not mislabeled kiro-auto - Extract parseWorkspaceSession from the inline createParser block for parity with the other four format parsers - Add v2SessionsRootOverride and stop deriving the v2 scan root from the cliDir parent when only agentDirOverride is set (tests were scanning the system tmpdir) - Tests: v2 parsing/discovery/dedup/routing, credit vs fallback pricing, tool_result turn scoping, reasoning disjointness, cli-dir skip guard, and a mixed-format coexistence suite (legacy .chat + v1 executions + workspace-sessions + CLI + v2 on one machine) asserting exact aggregate counts and no double counting - Docs: v2 store layout, credit pricing, dedup namespaces, and the disjoint-store verification (v1->v2 is a clean cutover, no migration/dual-write observed) AI-Origin: human --- docs/providers/kiro.md | 25 +- src/providers/kiro.ts | 436 ++++++++++++++++++++++++++++------- tests/providers/kiro.test.ts | 420 ++++++++++++++++++++++++++++++++- 3 files changed, 776 insertions(+), 105 deletions(-) diff --git a/docs/providers/kiro.md b/docs/providers/kiro.md index 6fe2819c..b332fe49 100644 --- a/docs/providers/kiro.md +++ b/docs/providers/kiro.md @@ -14,20 +14,30 @@ VS Code-style globalStorage at `kiro.kiroagent`: |---|---| | macOS | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent` | | Windows | `%APPDATA%/Kiro/User/globalStorage/kiro.kiroagent` | -| Linux | `~/.config/Kiro/User/globalStorage/kiro.kiroagent` | +| Linux | `~/.config/Kiro/User/globalStorage/kiro.kiroagent` and `~/.kiro-server/data/User/globalStorage/kiro.kiroagent` (remote dev boxes / Cloud Desktop) | -Sessions are under hash-named workspace subdirectories. Discovery keeps backward compatibility with legacy `.chat` files and also scans the post-February 2026 extensionless format: +On Linux both the local and remote-server globalStorage paths are scanned when present. + +Two more stores live outside globalStorage, under `~/.kiro/sessions` (honors `KIRO_HOME`): + +- **CLI store:** `~/.kiro/sessions/cli/.jsonl` (+ companion `.json`) +- **IDE v2 store:** `~/.kiro/sessions//sess_/` (see below) + +Sessions in globalStorage are under hash-named workspace subdirectories. Discovery keeps backward compatibility with legacy `.chat` files and also scans the post-February 2026 extensionless format: - `/.chat` legacy session files - `/` extensionless session index files - `//` extensionless execution files inside session directories +- `workspace-sessions//.json` v1 session records, a sibling of the workspace-hash directories at the `kiro.kiroagent` root (conversation spine; execution-backed stubs are skipped to avoid double-counting the execution files) ## Storage format -Kiro has two known JSON formats: +Kiro has several known formats across generations: -- Legacy `.chat` files with `{ chat, metadata, executionId }` -- Modern extensionless execution files with identifiers/timestamps at the top level plus conversation fields such as `messages`, `conversation`, `chat`, `transcript`, `entries`, `events`, or direct prompt/response fields +- **Legacy `.chat`** files with `{ chat, metadata, executionId }` +- **Modern extensionless execution files** with identifiers/timestamps at the top level plus conversation fields such as `messages`, `conversation`, `chat`, `transcript`, `entries`, `events`, `context.messages`, or direct prompt/response fields. In v1 these are the *content* half; the paired `workspace-sessions/.json` record is the *spine* (linked by `executionId` ⇄ `chatSessionId`). +- **CLI** JSONL: one `{ version, kind, data }` event per line + a companion `.json` with `session_state` metering (`metering_usage` gives real per-turn cost in credits). +- **IDE v2** (`sess_/`): a self-contained, event-sourced session directory — `session.json` (metadata incl. the real `modelId`, `title`, `workspacePaths`) + `messages.jsonl` (append-only `{ id, timestamp, payload:{ type, ... } }` events) + `publish.cursor` + `snapshots/`. Assistant content is inline (no separate execution files). Usage is billed in **credits** via `usage_summary.promptTurnSummaries` (no token counts): cost comes from credits at the public overage rate ($0.04/credit, `USD_PER_KIRO_CREDIT`), while token counts are estimated from transcript text. Session index files with `{ executions: [...] }` are discovered but skipped during parsing because they do not contain conversation content. @@ -37,7 +47,9 @@ None. ## Deduplication -Modern files deduplicate per session/execution pair. Legacy `.chat` files deduplicate per workflow/execution pair. +Modern files deduplicate per session/execution pair. Legacy `.chat` files deduplicate per workflow/execution pair. CLI turns key on `kiro-cli::`. v2 turns key on `kiro-v2::`. + +The stores are disjoint (v2 sessions use `sess_`-prefixed IDs in a separate directory), so no session is counted twice today. Migration from v1 workspace-sessions to v2 is currently detect-only; if it begins moving records, add a guard preferring v2 when the same logical session appears in both. ## Quirks @@ -45,6 +57,7 @@ Modern files deduplicate per session/execution pair. Legacy `.chat` files dedupl - **Model ID normalization.** Kiro stores models like `claude-1.2`; the parser rewrites the dot to a hyphen so they match `claude-1-2` in the pricing snapshot. Add new versions here when Kiro ships them. - **Tool name extraction accepts text and structured calls.** Kiro can embed tool calls inside message text as `...` or expose structured `toolCalls` / `tool_calls` / `tools` entries. - Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`). +- **Credits are the cost source; tokens stay estimated.** Kiro bills in credits ($20/mo for 1,000; overage $0.04/credit). CLI (`metering_usage`) and v2 (`usage_summary.promptTurnSummaries[].usage`) turns record real credits, converted to USD at `USD_PER_KIRO_CREDIT = 0.04` (the public overage rate — the same never-understate approach as Codebuff). Turns without credit data fall back to token-estimated cost (`costIsEstimated: true`). Note: an earlier CLI implementation summed credit values directly as dollars, overstating cost 25×. Token *counts* remain char-estimated everywhere (input undercounts: only visible transcript text is seen, not the full resent context; v2's `session_metadata.contextUsage.usagePercentage` × context window is a better input proxy if ever needed). v2 does keep the real `modelId`, so unlike the v1 execution-file path it is not mislabeled `kiro-auto`. ## When fixing a bug here diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts index df63ca2f..eaeba3bf 100644 --- a/src/providers/kiro.ts +++ b/src/providers/kiro.ts @@ -10,6 +10,11 @@ import type { ToolCall } from '../types.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' const CHARS_PER_TOKEN = 4 +// Kiro bills in credits: individual plans are $20/mo for 1,000 credits and +// overage is billed at $0.04 per additional credit. We price credits at the +// public overage rate (the marginal price), mirroring the Codebuff provider's +// USD_PER_CREDIT approach, so we never understate real cost. +const USD_PER_KIRO_CREDIT = 0.04 const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000 const MODERN_CONVERSATION_KEYS = ['messages', 'conversation', 'chat', 'transcript', 'entries', 'events'] @@ -445,8 +450,10 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen const tsDate = parseKiroTimestamp(timestamp) if (!tsDate) { turnIndex++; return } + // metering_usage values are credits (unit: "credit"), not dollars — + // convert at the public overage rate. const costUSD = turnMeta?.metering_usage - ? turnMeta.metering_usage.reduce((sum, m) => sum + m.value, 0) + ? turnMeta.metering_usage.reduce((sum, m) => sum + m.value, 0) * USD_PER_KIRO_CREDIT : 0 const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) @@ -537,9 +544,289 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen return results } +// --- Kiro IDE workspace-session parser (workspace-sessions//.json) --- +// Newer v1-era Kiro builds store session state here: history[] carries user prompts +// and assistant messages, some of which are stubs referencing per-execution files +// (parsed separately by parseModernExecution). +async function parseWorkspaceSession(record: Record, source: SessionSource, seenKeys: Set): Promise { + const results: ParsedProviderCall[] = [] + const historyArr = record['history'] + if (!Array.isArray(historyArr) || typeof record['sessionId'] !== 'string') return results + + const sessionId = record['sessionId'] + const modelRaw = stringField(record, ['selectedModel']) + let modelId = normalizeModelId(modelRaw) + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + + let inputChars = 0 + let outputChars = 0 + let pendingUserMessage = '' + const allTools: string[] = [] + let hasExecutionRefs = false + let hasRealAssistantContent = false + + for (const item of historyArr) { + const rec = asRecord(item) + if (!rec) continue + + // Track if this session references execution files (which are parsed separately) + const execBacked = typeof rec['executionId'] === 'string' + if (execBacked) hasExecutionRefs = true + + const msg = asRecord(rec['message']) + if (!msg) continue + const role = stringField(msg, ['role']) + const text = extractText(msg['content']) + if (role === 'user' && text) { + inputChars += text.length + pendingUserMessage = text.slice(0, 500) + } else if (role === 'assistant' && !execBacked && text && text !== 'On it.') { + // An item carrying an executionId is execution-backed: its content is + // counted from the execution file, so counting it here would double-count. + // 'On it.' is the observed placeholder text Kiro writes for such stubs + // when the executionId rides a separate history item. + outputChars += text.length + hasRealAssistantContent = true + } + } + + // Skip workspace-session entries that are pure execution stubs: + // they reference executionIds (parsed separately as execution files) + // and have no real assistant content beyond "On it." placeholders. + // This avoids double-counting input tokens from both paths. + if (hasExecutionRefs && !hasRealAssistantContent) return results + + // Skip sessions with no meaningful content + if (inputChars === 0 && outputChars === 0) return results + + // Use file mtime as timestamp (workspace-session files don't carry startTime). + // No stat means no usable timestamp: drop the call like the other parse paths. + let timestamp: string + try { + const s = await stat(source.path) + timestamp = new Date(s.mtimeMs).toISOString() + } catch { + return results + } + + const dedupKey = `kiro:ws-session:${sessionId}` + if (seenKeys.has(dedupKey)) return results + seenKeys.add(dedupKey) + + const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) + const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) + const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) + + results.push({ + provider: 'kiro', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + costIsEstimated: true, + tools: [...new Set(allTools)], + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + }) + + return results +} + +// --- Kiro IDE v2 session types & parser (~/.kiro/sessions//sess_*/) --- +// v2 is a self-contained, event-sourced store: one directory per session holding +// session.json (metadata incl. the real modelId) + messages.jsonl (append-only +// event log). Unlike v1 it does NOT use separate execution files — assistant +// content is inline. Usage is billed in credits with no token counts, so tokens +// and cost are estimated from transcript text priced at the real model. + +type KiroV2SessionMeta = { + id?: string + title?: string + modelId?: string + workspacePaths?: string[] + createdAt?: string + lastModifiedAt?: string +} + +async function parseV2Session(source: SessionSource, seenKeys: Set): Promise { + const results: ParsedProviderCall[] = [] + + const content = await readSessionFile(source.path) + if (content === null) return results + + // Companion session.json carries the real model + session metadata. + let meta: KiroV2SessionMeta = {} + try { + const raw = await readFile(join(dirname(source.path), 'session.json'), 'utf-8') + meta = JSON.parse(raw) as KiroV2SessionMeta + } catch { /* fall back to defaults below */ } + + const sessionId = (typeof meta.id === 'string' && meta.id) || basename(dirname(source.path)) + let modelId = normalizeModelId(meta.modelId ?? '') + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + + // In-flight turn state. A turn spans turn_start..turn_end sharing an + // executionId; the preceding `user` event carries its prompt. + let pendingUserMessage = '' + let pendingUserChars = 0 + + let inTurn = false + let execId = '' + let turnStartTs: string | undefined + let outputChars = 0 + let reasoningChars = 0 + let toolResultChars = 0 + let turnCredits = 0 + let turnUserMessage = '' + let turnUserChars = 0 + const tools: string[] = [] + + const resetTurn = () => { + inTurn = false; execId = ''; turnStartTs = undefined + outputChars = 0; reasoningChars = 0; toolResultChars = 0; turnCredits = 0 + turnUserMessage = ''; turnUserChars = 0 + tools.length = 0 + } + + const pushTool = (raw: string) => { + if (!raw) return + // Strip mcp_ prefix for cleaner display, matching the modern-execution parser. + const cleaned = raw.startsWith('mcp_') ? raw.slice(4) : raw + tools.push(toolNameMap[cleaned] ?? cleaned) + } + + const flushTurn = () => { + if (!inTurn) { resetTurn(); return } + const hasActivity = outputChars > 0 || reasoningChars > 0 || tools.length > 0 + if (hasActivity) { + const dedupKey = `kiro-v2:${sessionId}:${execId || String(results.length)}` + const tsDate = parseKiroTimestamp(turnStartTs) + if (tsDate && !seenKeys.has(dedupKey)) { + seenKeys.add(dedupKey) + // Tool results are fed back to the model as input context, so count them + // toward input tokens — same treatment as the CLI parser's ToolResults. + const inputTokens = Math.ceil((turnUserChars + toolResultChars) / CHARS_PER_TOKEN) + // outputTokens and reasoningTokens are kept disjoint — downstream + // aggregation (models-report, audit-report, parser) sums the two + // fields, so folding reasoning into outputTokens would double-count. + const reasoningTokens = Math.ceil(reasoningChars / CHARS_PER_TOKEN) + const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) + // Prefer real metered credits (converted at the public overage rate) + // over token estimation; fall back to token pricing when the turn has + // no usage_summary (e.g. still in progress or null usage). Reasoning + // text is billed as output, so combine it for pricing only (same as + // the codex provider). + const costUSD = turnCredits > 0 + ? turnCredits * USD_PER_KIRO_CREDIT + : calculateCost(modelId, inputTokens, outputTokens + reasoningTokens, 0, 0, 0) + results.push({ + provider: 'kiro', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens, + webSearchRequests: 0, + costUSD, + costIsEstimated: turnCredits === 0, + tools: [...new Set(tools)], + bashCommands: [], + timestamp: tsDate.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: turnUserMessage, + sessionId, + project: source.project, + }) + } + } + resetTurn() + } + + for (const line of content.split('\n')) { + if (!line.trim()) continue + let evt: Record + try { evt = JSON.parse(line) as Record } catch { continue } + const payload = asRecord(evt['payload']) + if (!payload) continue + const type = stringField(payload, ['type']) + const ts = typeof evt['timestamp'] === 'string' ? evt['timestamp'] as string : undefined + + if (type === 'user') { + // New prompt: close any in-flight turn defensively, then stash the text + // for the upcoming turn_start. + if (inTurn) flushTurn() + const text = typeof payload['content'] === 'string' ? payload['content'] as string : extractText(payload['content']) + pendingUserMessage = text.slice(0, 500) + pendingUserChars = text.length + } else if (type === 'turn_start') { + if (inTurn) flushTurn() + inTurn = true + execId = stringField(payload, ['executionId']) + turnStartTs = ts + turnUserMessage = pendingUserMessage + turnUserChars = pendingUserChars + pendingUserMessage = '' + pendingUserChars = 0 + } else if (type === 'assistant') { + const text = typeof payload['content'] === 'string' ? payload['content'] as string : extractText(payload['content']) + if (stringField(payload, ['operationType']) === 'Reasoning') reasoningChars += text.length + else outputChars += text.length + if (!inTurn && text.length > 0) inTurn = true + if (!turnStartTs && ts) turnStartTs = ts + } else if (type === 'tool_call') { + pushTool(stringField(payload, ['toolName', 'name'])) + } else if (type === 'tool_result') { + // Tool output re-enters the model as context on the next inference call. + // content is a plain string in observed logs; extractText covers nesting. + const text = typeof payload['content'] === 'string' ? payload['content'] as string : extractText(payload['content']) + toolResultChars += text.length + } else if (type === 'usage_summary') { + // usedTools is a reliable per-turn tool list. Credits are the real billed + // usage (promptTurnSummaries[].usage with unit "credit") — harvest them + // for credit-based cost. usage can be null on some turns. + const summaries = payload['promptTurnSummaries'] + if (Array.isArray(summaries)) { + for (const s of summaries) { + const rec = asRecord(s) + const used = rec?.['usedTools'] + if (Array.isArray(used)) for (const u of used) if (typeof u === 'string') pushTool(u) + const usage = rec?.['usage'] + if (typeof usage === 'number' && Number.isFinite(usage)) turnCredits += usage + } + } + } else if (type === 'turn_end') { + flushTurn() + } + } + // Flush a trailing in-progress turn (session still active, no turn_end yet). + flushTurn() + + return results +} + function createParser(source: SessionSource, seenKeys: Set): SessionParser { return { async *parse(): AsyncGenerator { + // v2 IDE store: ~/.kiro/sessions//sess_/messages.jsonl — a + // self-contained event log. Must be checked BEFORE the generic .jsonl + // (CLI) branch, since it also ends in .jsonl but has a different schema. + if (/[/\\]sess_[^/\\]+[/\\]messages\.jsonl$/.test(source.path)) { + for (const call of await parseV2Session(source, seenKeys)) yield call + return + } + // CLI session: path points to a .jsonl file if (source.path.endsWith('.jsonl')) { const jsonlContent = await readSessionFile(source.path) @@ -585,92 +872,8 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars // Workspace-session files (newer Kiro builds): have history[] with message.role/content // and a top-level sessionId/selectedModel/workspaceDirectory. - const historyArr = record['history'] - if (Array.isArray(historyArr) && typeof record['sessionId'] === 'string') { - const sessionId = record['sessionId'] as string - const modelRaw = stringField(record, ['selectedModel']) - let modelId = normalizeModelId(modelRaw) - if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' - - let inputChars = 0 - let outputChars = 0 - let pendingUserMessage = '' - const allTools: string[] = [] - let hasExecutionRefs = false - let hasRealAssistantContent = false - - for (const item of historyArr) { - const rec = asRecord(item) - if (!rec) continue - - // Track if this session references execution files (which are parsed separately) - const execBacked = typeof rec['executionId'] === 'string' - if (execBacked) hasExecutionRefs = true - - const msg = asRecord(rec['message']) - if (!msg) continue - const role = stringField(msg, ['role']) - const text = extractText(msg['content']) - if (role === 'user' && text) { - inputChars += text.length - pendingUserMessage = text.slice(0, 500) - } else if (role === 'assistant' && !execBacked && text && text !== 'On it.') { - // An item carrying an executionId is execution-backed: its content is - // counted from the execution file, so counting it here would double-count. - // 'On it.' is the observed placeholder text Kiro writes for such stubs - // when the executionId rides a separate history item. - outputChars += text.length - hasRealAssistantContent = true - } - } - - // Skip workspace-session entries that are pure execution stubs: - // they reference executionIds (parsed separately as execution files) - // and have no real assistant content beyond "On it." placeholders. - // This avoids double-counting input tokens from both paths. - if (hasExecutionRefs && !hasRealAssistantContent) return - - // Skip sessions with no meaningful content - if (inputChars === 0 && outputChars === 0) return - - // Use file mtime as timestamp (workspace-session files don't carry startTime). - // No stat means no usable timestamp: drop the call like the other parse paths. - let timestamp: string - try { - const s = await stat(source.path) - timestamp = new Date(s.mtimeMs).toISOString() - } catch { - return - } - - const dedupKey = `kiro:ws-session:${sessionId}` - if (seenKeys.has(dedupKey)) return - seenKeys.add(dedupKey) - - const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) - const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) - const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) - - yield { - provider: 'kiro', - model: modelId, - inputTokens, - outputTokens, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens: 0, - webSearchRequests: 0, - costUSD, - costIsEstimated: true, - tools: [...new Set(allTools)], - bashCommands: [], - timestamp, - speed: 'standard', - deduplicationKey: dedupKey, - userMessage: pendingUserMessage, - sessionId, - } + if (Array.isArray(record['history']) && typeof record['sessionId'] === 'string') { + for (const call of await parseWorkspaceSession(record, source, seenKeys)) yield call return } @@ -837,11 +1040,66 @@ async function discoverSessions(agentDir: string, workspaceStorageDir: string, c return sources } -export function createKiroProvider(agentDirOverride?: string, workspaceStorageDirOverride?: string, cliSessionsDirOverride?: string): Provider { +// Discover v2 IDE sessions under ~/.kiro/sessions//sess_*/. +// Each session is a self-contained directory (session.json + messages.jsonl); +// the emitted source points at messages.jsonl and routes to parseV2Session. +async function discoverV2Sessions(sessionsRoot: string): Promise { + const sources: SessionSource[] = [] + let hashDirs: Dirent[] + try { + hashDirs = await readdir(sessionsRoot, { withFileTypes: true }) + } catch { + return sources + } + + for (const hd of hashDirs) { + // Skip the CLI store (scanned separately) and any stray files. + if (!hd.isDirectory() || hd.name === 'cli') continue + const hashPath = join(sessionsRoot, hd.name) + + let sessDirs: Dirent[] + try { + sessDirs = await readdir(hashPath, { withFileTypes: true }) + } catch { + continue + } + + for (const sd of sessDirs) { + if (!sd.isDirectory() || !sd.name.startsWith('sess_')) continue + const sessDir = join(hashPath, sd.name) + const messagesPath = join(sessDir, 'messages.jsonl') + if (!existsSync(messagesPath)) continue + + // Project from session.json workspacePaths; fall back to a generic label. + let project = 'kiro-ide' + try { + const raw = await readFile(join(sessDir, 'session.json'), 'utf-8') + const sj = JSON.parse(raw) as { workspacePaths?: string[] } + const wp = sj.workspacePaths?.[0] + if (wp) project = basename(wp) + } catch {} + if (project === basename(homedir())) project = 'kiro-ide' + + sources.push({ path: messagesPath, project, provider: 'kiro' }) + } + } + + return sources +} + +export function createKiroProvider(agentDirOverride?: string, workspaceStorageDirOverride?: string, cliSessionsDirOverride?: string, v2SessionsRootOverride?: string): Provider { const agentDirs = getKiroAgentDir(agentDirOverride) const wsDir = getKiroWorkspaceStorageDir(workspaceStorageDirOverride) // When overrides are provided (tests), don't scan real CLI sessions unless explicitly given const cliDir = cliSessionsDirOverride ?? (agentDirOverride ? join(agentDirOverride, '..', 'cli-sessions') : join(process.env['KIRO_HOME'] || join(homedir(), '.kiro'), 'sessions', 'cli')) + // v2 IDE sessions live under ~/.kiro/sessions//sess_*/, a sibling of the + // CLI store (.../sessions/cli). Derive the root from cliDir ONLY when cliDir was + // itself explicit (default path or cliSessionsDirOverride). When only + // agentDirOverride is set (tests), the derived cliDir parent would point at an + // arbitrary directory (e.g. the system tmpdir) — scan nothing in that case. + const v2Root = v2SessionsRootOverride ?? + (cliSessionsDirOverride ? dirname(cliSessionsDirOverride) : + agentDirOverride ? undefined : dirname(cliDir)) return { name: 'kiro', @@ -866,6 +1124,8 @@ export function createKiroProvider(agentDirOverride?: string, workspaceStorageDi const sources = await discoverSessions(agentDir, wsDir, cliDir) allSources.push(...sources) } + // v2 IDE sessions: scan the resolved v2 root (undefined = skip, see above). + if (v2Root) allSources.push(...await discoverV2Sessions(v2Root)) // CLI sessions are only scanned once (first agentDir pass includes them); // deduplicate by path in case multiple agentDirs share the same CLI dir. const seen = new Set() diff --git a/tests/providers/kiro.test.ts b/tests/providers/kiro.test.ts index 59286702..b16ce830 100644 --- a/tests/providers/kiro.test.ts +++ b/tests/providers/kiro.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' import { join } from 'path' -import { tmpdir } from 'os' +import { tmpdir, homedir } from 'os' import { kiro, createKiroProvider } from '../../src/providers/kiro.js' import type { ParsedProviderCall } from '../../src/providers/types.js' @@ -617,14 +617,18 @@ describe('kiro provider - metadata', () => { }) describe('kiro provider - CLI session discovery', () => { + let cliRoot: string let cliDir: string beforeEach(async () => { - cliDir = await mkdtemp(join(tmpdir(), 'kiro-cli-test-')) + // cliDir sits at /cli so v2 discovery derives (not the system tmpdir). + cliRoot = await mkdtemp(join(tmpdir(), 'kiro-cli-test-')) + cliDir = join(cliRoot, 'cli') + await mkdir(cliDir, { recursive: true }) }) afterEach(async () => { - await rm(cliDir, { recursive: true, force: true }) + await rm(cliRoot, { recursive: true, force: true }) }) it('discovers .jsonl files from CLI sessions directory', async () => { @@ -681,7 +685,7 @@ describe('kiro provider - CLI session discovery', () => { expect(call.model).toBe('kiro-auto') expect(call.tools).toContain('Read') expect(call.userMessage).toBe('hello world') - expect(call.costUSD).toBeCloseTo(0.13, 2) + expect(call.costUSD).toBeCloseTo(0.13 * 0.04, 4) // 0.13 credits × $0.04/credit expect(call.deduplicationKey).toBe(`kiro-cli:${sessionId}:0`) expect(call.timestamp).toBe('2026-01-01T00:00:30.000Z') expect(call.project).toBe('test-project') @@ -721,7 +725,7 @@ describe('kiro provider - CLI session discovery', () => { expect(calls[0]!.userMessage).toBe('first question') expect(calls[0]!.model).toBe('claude-sonnet-4') expect(calls[1]!.userMessage).toBe('second question') - expect(calls[1]!.costUSD).toBeCloseTo(0.06, 2) + expect(calls[1]!.costUSD).toBeCloseTo(0.06 * 0.04, 4) // 0.06 credits × $0.04/credit }) it('skips non-jsonl files in CLI directory', async () => { @@ -766,7 +770,7 @@ describe('kiro provider - context.messages with entries', () => { await mkdir(join(tmpDir, wsHash, subDir), { recursive: true }) await writeFile(join(tmpDir, wsHash, subDir, 'exec-ctx-001'), file) - const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') const sessions = await provider.discoverSessions() expect(sessions.length).toBeGreaterThan(0) @@ -808,7 +812,7 @@ describe('kiro provider - context.messages with entries', () => { await mkdir(join(tmpDir, wsHash, subDir), { recursive: true }) await writeFile(join(tmpDir, wsHash, subDir, 'exec-tools-001'), file) - const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') const sessions = await provider.discoverSessions() const calls: ParsedProviderCall[] = [] for (const source of sessions) { @@ -837,7 +841,7 @@ describe('kiro provider - context.messages with entries', () => { await mkdir(join(tmpDir, wsHash), { recursive: true }) await writeFile(join(tmpDir, wsHash, 'f'.repeat(32)), indexFile) - const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') const sessions = await provider.discoverSessions() const calls: ParsedProviderCall[] = [] for (const source of sessions) { @@ -881,7 +885,7 @@ describe('kiro provider - workspace-sessions format', () => { // Also need sessions.json (should be skipped) await writeFile(join(wsSessionsDir, 'sessions.json'), '[]') - const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') const sessions = await provider.discoverSessions() const wsSessions = sessions.filter(s => s.path.includes('workspace-sessions')) @@ -922,7 +926,7 @@ describe('kiro provider - workspace-sessions format', () => { await writeFile(join(wsSessionsDir, 'ws-session-stub.json'), sessionFile) - const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') const sessions = await provider.discoverSessions() const calls: ParsedProviderCall[] = [] for (const source of sessions) { @@ -940,9 +944,403 @@ describe('kiro provider - workspace-sessions format', () => { await mkdir(wsSessionsDir, { recursive: true }) await writeFile(join(wsSessionsDir, 'sessions.json'), '[]') - const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') const sessions = await provider.discoverSessions() const wsSessions = sessions.filter(s => s.path.includes('workspace-sessions')) expect(wsSessions).toHaveLength(0) }) }) + +type V2Turn = { + exec: string + ts: string + user: string + say?: string + reasoning?: string + tools?: string[] + toolResults?: string[] + credits?: number +} + +function makeV2Messages(turns: V2Turn[]): string { + const lines: string[] = [] + lines.push(JSON.stringify({ id: 'sess-start', timestamp: turns[0]?.ts ?? '2026-07-14T13:39:00.000Z', payload: { type: 'session_start', agentType: 'vibe' } })) + for (const t of turns) { + lines.push(JSON.stringify({ id: `${t.exec}-u`, timestamp: t.ts, payload: { type: 'user', content: t.user, images: [], documents: [] } })) + lines.push(JSON.stringify({ id: `${t.exec}-ts`, timestamp: t.ts, payload: { type: 'turn_start', executionId: t.exec } })) + if (t.reasoning) { + lines.push(JSON.stringify({ id: `${t.exec}-r`, timestamp: t.ts, payload: { type: 'assistant', operationType: 'Reasoning', content: t.reasoning, executionId: t.exec } })) + } + lines.push(JSON.stringify({ id: `${t.exec}-a`, timestamp: t.ts, payload: { type: 'assistant', operationType: 'Say', content: t.say ?? 'Done.', executionId: t.exec } })) + for (const tool of t.tools ?? []) { + lines.push(JSON.stringify({ id: `${t.exec}-tc`, timestamp: t.ts, payload: { type: 'tool_call', toolName: tool, toolCallId: `${t.exec}-${tool}`, executionId: t.exec } })) + } + for (const [i, result] of (t.toolResults ?? []).entries()) { + lines.push(JSON.stringify({ id: `${t.exec}-tr${i}`, timestamp: t.ts, payload: { type: 'tool_result', toolCallId: `${t.exec}-tr${i}`, content: result, success: true, executionId: t.exec } })) + } + lines.push(JSON.stringify({ id: `${t.exec}-cm`, timestamp: t.ts, payload: { type: 'session_metadata', key: 'contextUsage', value: { usagePercentage: 12.5 }, executionId: t.exec } })) + lines.push(JSON.stringify({ id: `${t.exec}-us`, timestamp: t.ts, payload: { type: 'usage_summary', promptTurnSummaries: [{ unit: 'credit', unitPlural: 'credits', usage: t.credits ?? 1, usedTools: t.tools ?? [] }], status: 'success', executionId: t.exec } })) + lines.push(JSON.stringify({ id: `${t.exec}-te`, timestamp: t.ts, payload: { type: 'turn_end', stopReason: 'end_turn', executionId: t.exec } })) + } + return lines.join('\n') +} + +async function makeV2Session(sessionsRoot: string, opts: { + wsHash?: string + sessionId?: string + modelId?: string + workspacePaths?: string[] + turns: V2Turn[] +}): Promise { + const wsHash = opts.wsHash ?? '4748323122002acb' + const sessionId = opts.sessionId ?? 'sess_test-001' + const sessDir = join(sessionsRoot, wsHash, sessionId) + await mkdir(sessDir, { recursive: true }) + await writeFile(join(sessDir, 'session.json'), JSON.stringify({ + schemaVersion: '1.0.0', + dataModelVersion: 1, + id: sessionId, + title: 'Test v2 session', + agentMode: 'vibe', + workspacePaths: opts.workspacePaths ?? ['/tmp/test-proj'], + modelId: opts.modelId ?? 'claude-opus-4.8', + status: 'in_progress', + })) + await writeFile(join(sessDir, 'messages.jsonl'), makeV2Messages(opts.turns)) + return join(sessDir, 'messages.jsonl') +} + +describe('kiro provider - v2 sess_ format', () => { + let sessionsRoot: string + + beforeEach(async () => { + sessionsRoot = await mkdtemp(join(tmpdir(), 'kiro-v2-')) + }) + + afterEach(async () => { + await rm(sessionsRoot, { recursive: true, force: true }) + }) + + it('discovers and parses a v2 session with the real model and project', async () => { + await makeV2Session(sessionsRoot, { + turns: [ + { exec: 'exec-1', ts: '2026-07-14T13:39:40.000Z', user: 'What is TypeScript?', say: 'TypeScript is a typed superset of JavaScript.', tools: ['readFile'] }, + { exec: 'exec-2', ts: '2026-07-14T13:41:00.000Z', user: 'Run the build', say: 'Building now.', tools: ['executeBash'] }, + ], + }) + + // cliDir override sits at /cli, so v2 root = dirname(cliDir) = sessionsRoot. + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const v2 = sources.filter(s => /\/sess_[^/]+\/messages\.jsonl$/.test(s.path)) + expect(v2).toHaveLength(1) + expect(v2[0]!.project).toBe('test-proj') + + const calls: ParsedProviderCall[] = [] + for (const source of v2) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + } + + expect(calls).toHaveLength(2) + const first = calls[0]! + expect(first.provider).toBe('kiro') + expect(first.model).toBe('claude-opus-4-8') + expect(first.sessionId).toBe('sess_test-001') + expect(first.inputTokens).toBeGreaterThan(0) + expect(first.outputTokens).toBeGreaterThan(0) + expect(first.tools).toEqual(['Read']) + expect(first.userMessage).toBe('What is TypeScript?') + expect(first.timestamp).toBe('2026-07-14T13:39:40.000Z') + expect(first.deduplicationKey).toBe('kiro-v2:sess_test-001:exec-1') + // Fixture turns carry usage_summary credits (default 1 credit), so cost is + // metered: 1 credit × $0.04/credit, not token-estimated. + expect(first.costIsEstimated).toBe(false) + expect(first.costUSD).toBeCloseTo(0.04, 4) + expect(calls[1]!.tools).toEqual(['Bash']) + }) + + it('prices turns from metered credits at $0.04/credit, falling back to token estimation without credits', async () => { + await makeV2Session(sessionsRoot, { + turns: [ + { exec: 'exec-c', ts: '2026-07-14T13:39:40.000Z', user: 'hi', say: 'metered answer', credits: 5.25 }, + // credits: 0 -> fixture emits usage_summary with usage 0 -> token fallback + { exec: 'exec-e', ts: '2026-07-14T13:41:00.000Z', user: 'hi again', say: 'x'.repeat(4000), credits: 0 }, + ], + }) + + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + } + + expect(calls).toHaveLength(2) + // Metered: 5.25 credits × $0.04 = $0.21, marked as real cost + expect(calls[0]!.costUSD).toBeCloseTo(0.21, 4) + expect(calls[0]!.costIsEstimated).toBe(false) + // No credits: token-estimated at the session's real model, marked estimated + expect(calls[1]!.costIsEstimated).toBe(true) + expect(calls[1]!.costUSD).toBeGreaterThan(0) + }) + + it('counts tool_result content as input context, scoped to its own turn', async () => { + await makeV2Session(sessionsRoot, { + turns: [ + // Turn 1: 8-char prompt + 400 chars of tool results -> ceil(408/4) = 102 + { exec: 'exec-1', ts: '2026-07-14T13:39:40.000Z', user: 'x'.repeat(8), say: 'done', tools: ['readFile'], toolResults: ['r'.repeat(150), 's'.repeat(250)] }, + // Turn 2: 8-char prompt, no tool results -> ceil(8/4) = 2 (no leak from turn 1) + { exec: 'exec-2', ts: '2026-07-14T13:41:00.000Z', user: 'y'.repeat(8), say: 'ok' }, + ], + }) + + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + } + + expect(calls).toHaveLength(2) + expect(calls[0]!.inputTokens).toBe(102) + expect(calls[1]!.inputTokens).toBe(2) + }) + + it('keeps reasoningTokens disjoint from outputTokens (no double-count in aggregation)', async () => { + await makeV2Session(sessionsRoot, { + turns: [ + { exec: 'exec-r', ts: '2026-07-14T13:39:40.000Z', user: 'hi', say: 'short', reasoning: 'x'.repeat(400) }, + ], + }) + + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.reasoningTokens).toBe(100) // 400 chars / 4 + // output holds only assistant text ('short' = 5 chars -> 2 tokens); + // downstream aggregation sums outputTokens + reasoningTokens, so reasoning + // must NOT be folded into outputTokens here. + expect(call.outputTokens).toBe(2) + }) + + it('deduplicates v2 turns across parser runs', async () => { + const msgs = await makeV2Session(sessionsRoot, { + turns: [{ exec: 'exec-1', ts: '2026-07-14T13:39:40.000Z', user: 'hello', say: 'hi there' }], + }) + const source = { path: msgs, project: 'test-proj', provider: 'kiro' } + const seen = new Set() + + const run1: ParsedProviderCall[] = [] + for await (const c of kiro.createSessionParser(source, seen).parse()) run1.push(c) + const run2: ParsedProviderCall[] = [] + for await (const c of kiro.createSessionParser(source, seen).parse()) run2.push(c) + + expect(run1).toHaveLength(1) + expect(run2).toHaveLength(0) + }) + + it('routes a sess_/messages.jsonl path to the v2 parser (not the CLI parser)', async () => { + const msgs = await makeV2Session(sessionsRoot, { + turns: [{ exec: 'exec-1', ts: '2026-07-14T13:39:40.000Z', user: 'hello', say: 'hi', tools: ['fsWrite'] }], + }) + const source = { path: msgs, project: 'test-proj', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const c of kiro.createSessionParser(source, new Set()).parse()) calls.push(c) + + expect(calls).toHaveLength(1) + // A CLI-parser misroute would yield model 'kiro-auto' with no meta; v2 gives the real model. + expect(calls[0]!.model).toBe('claude-opus-4-8') + expect(calls[0]!.tools).toEqual(['Edit']) + }) + + it('maps a bare-homedir workspace to a generic project label', async () => { + await makeV2Session(sessionsRoot, { + workspacePaths: [homedir()], + turns: [{ exec: 'exec-1', ts: '2026-07-14T13:39:40.000Z', user: 'hello', say: 'hi' }], + }) + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const v2 = sources.filter(s => /\/sess_[^/]+\/messages\.jsonl$/.test(s.path)) + expect(v2).toHaveLength(1) + expect(v2[0]!.project).toBe('kiro-ide') + }) + + it('ignores a sess_ directory with no messages.jsonl', async () => { + const sessDir = join(sessionsRoot, '4748323122002acb', 'sess_empty') + await mkdir(sessDir, { recursive: true }) + await writeFile(join(sessDir, 'session.json'), JSON.stringify({ id: 'sess_empty', modelId: 'auto' })) + + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const v2 = sources.filter(s => /\/sess_[^/]+\/messages\.jsonl$/.test(s.path)) + expect(v2).toHaveLength(0) + }) + + it('does not descend into the cli directory when discovering v2 sessions', async () => { + // A sess_-shaped directory inside the CLI store must not be picked up as v2. + const trap = join(sessionsRoot, 'cli', 'sess_trap') + await mkdir(trap, { recursive: true }) + await writeFile(join(trap, 'messages.jsonl'), makeV2Messages([ + { exec: 'exec-trap', ts: '2026-07-14T13:39:40.000Z', user: 'trap', say: 'should not appear' }, + ])) + + const provider = createKiroProvider('/nonexistent', '/nonexistent', join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const v2 = sources.filter(s => /\/sess_[^/]+\/messages\.jsonl$/.test(s.path)) + expect(v2).toHaveLength(0) + }) +}) + +describe('kiro provider - mixed-format coexistence (legacy + v1 + workspace-sessions + CLI + v2)', () => { + // Simulates a machine that upgraded through Kiro versions: every historical + // storage format has files on disk simultaneously. Verifies each source routes + // to the right parser, the aggregate call count is exact (no double counting), + // and dedup keys stay in their own namespaces. + let root: string + let agentDir: string + let sessionsRoot: string + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'kiro-mixed-')) + agentDir = join(root, 'agent') + sessionsRoot = join(root, 'sessions') + await mkdir(agentDir, { recursive: true }) + await mkdir(join(sessionsRoot, 'cli'), { recursive: true }) + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + async function parseAll(): Promise { + const provider = createKiroProvider(agentDir, join(root, 'ws-storage'), join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const seenKeys = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call) + } + return calls + } + + async function buildFullTree() { + const wsHash = 'a1b2c3d4'.repeat(4) + + // 1. Legacy .chat file + const wsDir = join(agentDir, wsHash) + await mkdir(wsDir, { recursive: true }) + await writeFile(join(wsDir, 'legacy.chat'), makeChatFile({ + executionId: 'exec-legacy', workflowId: 'wf-legacy', + userPrompt: 'legacy question', botResponses: ['legacy answer'], + })) + + // 2. v1 modern execution file (nested session dir) + const sessDir = join(wsDir, 'session-v1') + await mkdir(sessDir, { recursive: true }) + await writeFile(join(sessDir, 'execution-v1'), makeModernExecutionFile({ + executionId: 'exec-v1', sessionId: 'session-v1', + userPrompt: 'v1 question', assistantResponse: 'v1 answer', + })) + + // 3a. Workspace-session with real assistant content (counted) + const wssDir = join(agentDir, 'workspace-sessions', 'L3RtcC9taXhlZA__') + await mkdir(wssDir, { recursive: true }) + await writeFile(join(wssDir, 'ws-real.json'), JSON.stringify({ + sessionId: 'ws-real', selectedModel: 'claude-sonnet-4.5', + history: [ + { message: { role: 'user', content: [{ type: 'text', text: 'ws question' }] } }, + { message: { role: 'assistant', content: 'a real standalone workspace-session answer' } }, + ], + })) + + // 3b. Workspace-session stub referencing the v1 execution (must be skipped) + await writeFile(join(wssDir, 'ws-stub.json'), JSON.stringify({ + sessionId: 'ws-stub', selectedModel: 'auto', + history: [ + { message: { role: 'user', content: [{ type: 'text', text: 'v1 question' }] } }, + { message: { role: 'assistant', content: 'On it.' }, executionId: 'exec-v1' }, + ], + })) + + // 4. CLI session + const cliSessionId = '44444444-4444-4444-4444-444444444444' + await writeFile(join(sessionsRoot, 'cli', `${cliSessionId}.jsonl`), [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'cli question' }], meta: { timestamp: 1700000000 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'cli answer' }] } }), + ].join('\n')) + await writeFile(join(sessionsRoot, 'cli', `${cliSessionId}.json`), JSON.stringify({ + session_id: cliSessionId, cwd: '/tmp/mixed-proj', + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:01:00Z', + session_state: { + rts_model_state: { model_info: { model_id: 'claude-sonnet-4' } }, + conversation_metadata: { user_turn_metadatas: [{ end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [{ value: 0.05, unit: 'credit' }] }] }, + }, + })) + + // 5. v2 session (2 turns) + await makeV2Session(sessionsRoot, { + wsHash: 'a1b2c3d4e5f60718', sessionId: 'sess_mixed-001', + turns: [ + { exec: 'exec-v2-1', ts: '2026-07-14T13:39:40.000Z', user: 'v2 first', say: 'v2 first answer', tools: ['readFile'] }, + { exec: 'exec-v2-2', ts: '2026-07-14T13:41:00.000Z', user: 'v2 second', say: 'v2 second answer' }, + ], + }) + } + + it('routes every format to the right parser and counts each conversation exactly once', async () => { + await buildFullTree() + const calls = await parseAll() + + // Exact aggregate: 1 legacy + 1 v1 exec + 1 ws-session (stub skipped) + 1 CLI + 2 v2 turns + expect(calls).toHaveLength(6) + + const byPrefix = (p: string) => calls.filter(c => c.deduplicationKey!.startsWith(p)) + expect(byPrefix('kiro-v2:')).toHaveLength(2) + expect(byPrefix('kiro-cli:')).toHaveLength(1) + expect(byPrefix('kiro:ws-session:')).toHaveLength(1) + // Legacy + v1 executions share the plain kiro: namespace + expect(byPrefix('kiro:').filter(c => !c.deduplicationKey!.startsWith('kiro:ws-session:'))).toHaveLength(2) + + // The ws-session stub for exec-v1 must not have been emitted + expect(calls.some(c => c.deduplicationKey === 'kiro:ws-session:ws-stub')).toBe(false) + // Each conversation's content is attributed once + expect(calls.filter(c => c.userMessage === 'v1 question')).toHaveLength(1) + + // Dedup keys are globally unique + const keys = calls.map(c => c.deduplicationKey) + expect(new Set(keys).size).toBe(keys.length) + }) + + it('emits nothing on a second pass with shared seenKeys (cross-run dedup across all formats)', async () => { + await buildFullTree() + const provider = createKiroProvider(agentDir, join(root, 'ws-storage'), join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const seenKeys = new Set() + + const run1: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) run1.push(call) + } + const run2: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) run2.push(call) + } + + expect(run1).toHaveLength(6) + expect(run2).toHaveLength(0) + }) + + it('discovery paths are unique across all stores', async () => { + await buildFullTree() + const provider = createKiroProvider(agentDir, join(root, 'ws-storage'), join(sessionsRoot, 'cli')) + const sources = await provider.discoverSessions() + const paths = sources.map(s => s.path) + expect(new Set(paths).size).toBe(paths.length) + }) +}) From 7dcd61889823284c0de9f615f15cc7e8707533ca Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 14 Jul 2026 21:50:32 +0000 Subject: [PATCH 2/3] kiro: preserve credit-based cost through the session cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit providerCallToCachedCall drops provider-computed costUSD unless the provider is allowlisted, and cachedCallToApiCall then re-prices from token counts. Kiro's cost now comes from metered credits, which token pricing cannot reproduce — reports were understating real kiro spend ~18x (e.g. $130.72 of metered CLI credits reported as $7.20 of token estimates). Add kiro to the pass-through allowlist, same as the other platform-billed providers (mistral-vibe, devin, hermes, ...). Both persistence layers froze pre-fix dollar values and neither self-invalidates (historical session files never change), so bump: - session cache: CACHE_VERSION 4 -> 5 (cached kiro entries carry costUSD: undefined and would keep being token re-priced forever) - daily cache: DAILY_CACHE_VERSION 10 -> 11 with MIN_SUPPORTED_VERSION raised (finalized days carry token-estimated kiro costs off by up to 16x per model), same as the v10 cursor precedent Test: end-to-end through parseAllSessions — a CLI fixture with 2.5 metered credits must report $0.10; fails with the token re-price ($0.000048) when the allowlist entry is removed. Docs: document the pricing contract in docs/providers/kiro.md — metered credit cost is frozen at parse time, so price-override / model-alias do not affect kiro dollar amounts (shared tradeoff of all allowlisted providers). AI-Origin: ai-generated AI-Tool: kiro --- docs/providers/kiro.md | 1 + src/daily-cache.ts | 15 ++++++---- src/parser.ts | 2 +- src/session-cache.ts | 6 +++- tests/provider-turn-grouping.test.ts | 41 ++++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/docs/providers/kiro.md b/docs/providers/kiro.md index b332fe49..26cf5430 100644 --- a/docs/providers/kiro.md +++ b/docs/providers/kiro.md @@ -58,6 +58,7 @@ The stores are disjoint (v2 sessions use `sess_`-prefixed IDs in a separate dire - **Tool name extraction accepts text and structured calls.** Kiro can embed tool calls inside message text as `...` or expose structured `toolCalls` / `tool_calls` / `tools` entries. - Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`). - **Credits are the cost source; tokens stay estimated.** Kiro bills in credits ($20/mo for 1,000; overage $0.04/credit). CLI (`metering_usage`) and v2 (`usage_summary.promptTurnSummaries[].usage`) turns record real credits, converted to USD at `USD_PER_KIRO_CREDIT = 0.04` (the public overage rate — the same never-understate approach as Codebuff). Turns without credit data fall back to token-estimated cost (`costIsEstimated: true`). Note: an earlier CLI implementation summed credit values directly as dollars, overstating cost 25×. Token *counts* remain char-estimated everywhere (input undercounts: only visible transcript text is seen, not the full resent context; v2's `session_metadata.contextUsage.usagePercentage` × context window is a better input proxy if ever needed). v2 does keep the real `modelId`, so unlike the v1 execution-file path it is not mislabeled `kiro-auto`. +- **Cost is frozen at parse time.** Kiro is on the `costUSD` pass-through allowlist in `providerCallToCachedCall` (alongside mistral-vibe, devin, hermes, …), so its credit-based cost survives the session cache instead of being re-priced from estimated tokens — token re-pricing understated/overstated real kiro spend by up to 16× per model. The tradeoff, shared with all allowlisted providers: `codeburn price-override` and `model-alias` do not affect kiro dollar amounts (token *counts* are unaffected). Historical caches from before this change re-parse via the `CACHE_VERSION` bump to 5. ## When fixing a bug here diff --git a/src/daily-cache.ts b/src/daily-cache.ts index c697cfbd..638a75c6 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -5,18 +5,23 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' -// Bumped to 10: cursor accounting changed (real composer context tokens on +// Bumped to 11: kiro cost accounting changed (metered credits pass through +// the session cache instead of being re-priced from estimated tokens), so +// days finalized at v10 carry token-estimated kiro costs that were off by up +// to 16× per model. Raising MIN_SUPPORTED_VERSION forces the one-time full +// re-hydration that backfills history under credit-based pricing. +// +// v10: cursor accounting changed (real composer context tokens on // conversation-anchored records, Cursor-published composer pricing), so days // finalized at v9 carry the old double-counted agentKv estimates and -// sonnet-proxy composer costs. Raising MIN_SUPPORTED_VERSION forces the -// one-time full re-hydration that backfills history under the new accounting. +// sonnet-proxy composer costs. // // v9: providers added since the v8 rollup (Grok, Hermes, ZCode) parse usage // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 10 -const MIN_SUPPORTED_VERSION = 10 +export const DAILY_CACHE_VERSION = 11 +const MIN_SUPPORTED_VERSION = 11 const DAILY_CACHE_FILENAME = 'daily-cache.json' export type DailyEntry = { diff --git a/src/parser.ts b/src/parser.ts index 2f5de981..64379cb9 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1754,7 +1754,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { webSearchRequests: call.webSearchRequests, cacheCreationOneHourTokens: 0, }, - costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes') ? call.costUSD : undefined, + costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro') ? call.costUSD : undefined, speed: call.speed, timestamp: call.timestamp, tools: call.tools, diff --git a/src/session-cache.ts b/src/session-cache.ts index 7d02e9cd..ab6d5a2a 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -82,7 +82,11 @@ export type SessionCache = { // ── Constants ────────────────────────────────────────────────────────── -export const CACHE_VERSION = 4 +// v5: kiro joined the costUSD pass-through allowlist (credit-based pricing). +// Cached kiro entries from v4 carry costUSD: undefined and would keep being +// re-priced from estimated tokens forever, since historical session files +// never change. Bump forces a one-time re-parse so metered credit costs land. +export const CACHE_VERSION = 5 const CACHE_FILE = 'session-cache.json' const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000 diff --git a/tests/provider-turn-grouping.test.ts b/tests/provider-turn-grouping.test.ts index d9dd0d65..bee7585d 100644 --- a/tests/provider-turn-grouping.test.ts +++ b/tests/provider-turn-grouping.test.ts @@ -155,4 +155,45 @@ describe('provider turn grouping', () => { expect(session.totalOutputTokens).toBe(90) expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0) }) + + it('preserves Kiro credit-based cost through cache conversion instead of re-pricing from tokens', async () => { + const kiroHome = join(home, '.kiro') + const cliDir = join(kiroHome, 'sessions', 'cli') + await mkdir(cliDir, { recursive: true }) + process.env['KIRO_HOME'] = kiroHome + + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + await writeFile(join(cliDir, `${sessionId}.jsonl`), [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hi' }], meta: { timestamp: 1778925600 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'short reply' }] } }), + ].join('\n') + '\n') + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/Users/test/project-a', + created_at: '2026-05-16T10:00:00Z', + updated_at: '2026-05-16T10:01:00Z', + session_state: { + rts_model_state: { model_info: { model_id: 'claude-sonnet-4.6' } }, + conversation_metadata: { + user_turn_metadatas: [{ + end_timestamp: '2026-05-16T10:00:30Z', + // 2.5 credits × $0.04/credit = $0.10 — far from any token estimate + // of this tiny transcript, so passing means the metered cost was + // preserved through providerCallToCachedCall/cachedCallToApiCall. + metering_usage: [{ value: 2.5, unit: 'credit' }], + }], + }, + }, + })) + + try { + const parseAllSessions = await loadParser() + const projects = await parseAllSessions(dayRange(), 'kiro') + const session = projects[0]!.sessions[0]! + + expect(session.totalCostUSD).toBeCloseTo(2.5 * 0.04, 8) + } finally { + delete process.env['KIRO_HOME'] + } + }) }) From bdf4f3fe24e9690732b22bf1a6d6fea2a8a8d7bf Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 14 Jul 2026 22:19:14 +0000 Subject: [PATCH 3/3] kiro: price v1 executions from credits; unify fallback across parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 modern execution files carry the same metered credits as v2 (usageSummary[].usage, unit "credit" — the predecessor of v2's promptTurnSummaries), but the parser only harvested usedTools from that array and priced executions from estimated tokens. Since v2 only ships in brand-new IDE builds, v1 is the format nearly all Kiro IDE users are on today. Sum usage across usageSummary entries and price at the public overage rate. Align all three credit parsers (CLI, v1, v2) on one fallback contract — gate on summed credits > 0 and fall back to token-estimated pricing with costIsEstimated: true: - CLI turns without metering_usage were priced at a frozen $0 - CLI turns with an EMPTY metering_usage array (75 of 10,105 real turn metadatas — meta written before metering lands) passed the truthy presence check and froze $0 marked as real cost - legacy .chat calls now carry costIsEstimated: true, which was always the reality but never stated Validated against a real machine: 221 of 277 v1/legacy calls now carry metered cost ($20.11) instead of token estimates that had overstated them ~5x; verified usageSummary values are per-turn amounts, not cumulative counters (sequences fluctuate). Call counts unchanged before and after — nothing gained, lost, or double-counted. Also document the companion-file fingerprint blind spot at fingerprintFile: kiro CLI credits and v2 modelId live in companion files the single-file fingerprint never sees, so a parse racing the companion write can cache fallback values that only self-heal while the transcript keeps changing. AI-Origin: ai-generated AI-Tool: kiro --- docs/providers/kiro.md | 2 +- src/providers/kiro.ts | 38 +++++++++---- src/session-cache.ts | 9 ++++ tests/providers/kiro.test.ts | 101 +++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 11 deletions(-) diff --git a/docs/providers/kiro.md b/docs/providers/kiro.md index 26cf5430..0252e901 100644 --- a/docs/providers/kiro.md +++ b/docs/providers/kiro.md @@ -57,7 +57,7 @@ The stores are disjoint (v2 sessions use `sess_`-prefixed IDs in a separate dire - **Model ID normalization.** Kiro stores models like `claude-1.2`; the parser rewrites the dot to a hyphen so they match `claude-1-2` in the pricing snapshot. Add new versions here when Kiro ships them. - **Tool name extraction accepts text and structured calls.** Kiro can embed tool calls inside message text as `...` or expose structured `toolCalls` / `tool_calls` / `tools` entries. - Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`). -- **Credits are the cost source; tokens stay estimated.** Kiro bills in credits ($20/mo for 1,000; overage $0.04/credit). CLI (`metering_usage`) and v2 (`usage_summary.promptTurnSummaries[].usage`) turns record real credits, converted to USD at `USD_PER_KIRO_CREDIT = 0.04` (the public overage rate — the same never-understate approach as Codebuff). Turns without credit data fall back to token-estimated cost (`costIsEstimated: true`). Note: an earlier CLI implementation summed credit values directly as dollars, overstating cost 25×. Token *counts* remain char-estimated everywhere (input undercounts: only visible transcript text is seen, not the full resent context; v2's `session_metadata.contextUsage.usagePercentage` × context window is a better input proxy if ever needed). v2 does keep the real `modelId`, so unlike the v1 execution-file path it is not mislabeled `kiro-auto`. +- **Credits are the cost source; tokens stay estimated.** Kiro bills in credits ($20/mo for 1,000; overage $0.04/credit). CLI (`metering_usage`), v1 executions (`usageSummary[].usage`), and v2 (`usage_summary.promptTurnSummaries[].usage`) turns record real credits, converted to USD at `USD_PER_KIRO_CREDIT = 0.04` (the public overage rate — the same never-understate approach as Codebuff). Turns without credit data fall back to token-estimated cost (`costIsEstimated: true`); legacy `.chat` and workspace-session records carry no usage data, so they are always token-estimated. Note: an earlier CLI implementation summed credit values directly as dollars, overstating cost 25×. Token *counts* remain char-estimated everywhere (input undercounts: only visible transcript text is seen, not the full resent context; v2's `session_metadata.contextUsage.usagePercentage` × context window is a better input proxy if ever needed). v2 does keep the real `modelId`, so unlike the v1 execution-file path it is not mislabeled `kiro-auto`. - **Cost is frozen at parse time.** Kiro is on the `costUSD` pass-through allowlist in `providerCallToCachedCall` (alongside mistral-vibe, devin, hermes, …), so its credit-based cost survives the session cache instead of being re-priced from estimated tokens — token re-pricing understated/overstated real kiro spend by up to 16× per model. The tradeoff, shared with all allowlisted providers: `codeburn price-override` and `model-alias` do not affect kiro dollar amounts (token *counts* are unaffected). Historical caches from before this change re-parse via the `CACHE_VERSION` bump to 5. ## When fixing a bug here diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts index eaeba3bf..bc91e1e9 100644 --- a/src/providers/kiro.ts +++ b/src/providers/kiro.ts @@ -244,6 +244,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s reasoningTokens: 0, webSearchRequests: 0, costUSD, + costIsEstimated: true, tools: [...new Set(allTools)], bashCommands: [], toolSequence: toolSequence.length > 1 ? toolSequence : undefined, @@ -335,13 +336,18 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see if (found) break } - // Extract tools from usageSummary (reliable structured tool list in current Kiro builds). - // usageSummary is an array of per-turn entries with optional usedTools field. + // Extract tools and metered credits from usageSummary (reliable structured + // data in current Kiro builds). usageSummary is an array of per-turn entries + // with optional usedTools and usage (credits, unit "credit") fields — the + // v1 predecessor of v2's usage_summary.promptTurnSummaries. + let executionCredits = 0 const usageSummary = data['usageSummary'] if (Array.isArray(usageSummary)) { for (const entry of usageSummary) { const rec = asRecord(entry) if (!rec) continue + const usage = rec['usage'] + if (typeof usage === 'number' && Number.isFinite(usage)) executionCredits += usage const usedTools = rec['usedTools'] if (Array.isArray(usedTools)) { for (const tool of usedTools) { @@ -368,7 +374,12 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) - const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) + // Prefer real metered credits at the public overage rate; fall back to + // token-estimated pricing when the execution has no usage data — same + // contract as the CLI and v2 parsers. + const costUSD = executionCredits > 0 + ? executionCredits * USD_PER_KIRO_CREDIT + : calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) seenKeys.add(dedupKey) results.push({ @@ -382,6 +393,7 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see reasoningTokens: 0, webSearchRequests: 0, costUSD, + costIsEstimated: executionCredits === 0, tools: [...new Set(allTools)], bashCommands: [], timestamp: tsDate.toISOString(), @@ -450,14 +462,20 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen const tsDate = parseKiroTimestamp(timestamp) if (!tsDate) { turnIndex++; return } - // metering_usage values are credits (unit: "credit"), not dollars — - // convert at the public overage rate. - const costUSD = turnMeta?.metering_usage - ? turnMeta.metering_usage.reduce((sum, m) => sum + m.value, 0) * USD_PER_KIRO_CREDIT - : 0 - const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) + // metering_usage values are credits (unit: "credit"), not dollars — + // convert at the public overage rate. Gate on credits > 0, not array + // presence: real sessions carry empty metering_usage arrays (turn still + // in flight when the meta was written), which must fall back to + // token-estimated pricing — same contract as the v1-execution and v2 + // parsers. + const turnCredits = turnMeta?.metering_usage + ? turnMeta.metering_usage.reduce((sum, m) => sum + m.value, 0) + : 0 + const costUSD = turnCredits > 0 + ? turnCredits * USD_PER_KIRO_CREDIT + : calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) seenKeys.add(dedupKey) results.push({ @@ -471,7 +489,7 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen reasoningTokens: 0, webSearchRequests: 0, costUSD, - costIsEstimated: !turnMeta?.metering_usage, + costIsEstimated: turnCredits === 0, tools: [...new Set(allTools)], bashCommands: [], timestamp: tsDate.toISOString(), diff --git a/src/session-cache.ts b/src/session-cache.ts index ab6d5a2a..7f7b79ed 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -296,6 +296,15 @@ export async function saveCache(cache: SessionCache): Promise { } // ── File Fingerprinting ──────────────────────────────────────────────── +// +// Fingerprints cover the source's transcript file only. Providers that keep +// metadata in a companion file (kiro CLI: credits in `.json` next to the +// `.jsonl`; kiro v2: modelId in `session.json` next to `messages.jsonl`) have +// a blind spot: a parse that races the companion write caches the turn with +// fallback values, and if the transcript never changes again (a session's +// final turn) the entry never invalidates. Mid-session turns self-heal since +// append-only transcripts keep changing. Fixing this properly means +// multi-file fingerprints per source. export async function fingerprintFile(filePath: string): Promise { try { diff --git a/tests/providers/kiro.test.ts b/tests/providers/kiro.test.ts index b16ce830..a12ae4b4 100644 --- a/tests/providers/kiro.test.ts +++ b/tests/providers/kiro.test.ts @@ -728,6 +728,68 @@ describe('kiro provider - CLI session discovery', () => { expect(calls[1]!.costUSD).toBeCloseTo(0.06 * 0.04, 4) // 0.06 credits × $0.04/credit }) + it('falls back to token-estimated cost for CLI turns without metering_usage', async () => { + const sessionId = '44444444-4444-4444-4444-444444444444' + const jsonl = [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hi' }], meta: { timestamp: 1700000000 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'x'.repeat(4000) }] } }), + ].join('\n') + + await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl) + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/tmp/test-project', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:01:00Z', + session_state: { + rts_model_state: { model_info: { model_id: 'claude-sonnet-4.6' } }, + // no conversation_metadata / metering_usage (e.g. turn still in flight) + }, + })) + + const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'test-project', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + // Token-priced at the session's real model, not $0 — same fallback + // contract as the v1-execution and v2 parsers. + expect(calls[0]!.costIsEstimated).toBe(true) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('treats an empty metering_usage array as no metering (token fallback, not frozen $0)', async () => { + const sessionId = '55555555-5555-5555-5555-555555555555' + const jsonl = [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hi' }], meta: { timestamp: 1700000000 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'x'.repeat(4000) }] } }), + ].join('\n') + + await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl) + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/tmp/test-project', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:01:00Z', + session_state: { + rts_model_state: { model_info: { model_id: 'claude-sonnet-4.6' } }, + conversation_metadata: { + // Observed in real sessions: the turn metadata exists but metering + // hasn't landed yet — an empty array, which is truthy. + user_turn_metadatas: [{ end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [] }], + }, + }, + })) + + const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'test-project', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costIsEstimated).toBe(true) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + it('skips non-jsonl files in CLI directory', async () => { await writeFile(join(cliDir, 'something.json'), '{}') await writeFile(join(cliDir, 'something.lock'), '') @@ -826,6 +888,45 @@ describe('kiro provider - context.messages with entries', () => { expect(call.tools).toContain('aws_sentral_mcp_search_accounts') expect(call.tools).toContain('Bash') expect(call.tools).toContain('Read') + // usageSummary credits (0.5 + 1.0) price the execution at $0.04/credit + expect(call.costUSD).toBeCloseTo(1.5 * 0.04, 8) + expect(call.costIsEstimated).toBe(false) + }) + + it('falls back to token-estimated cost when a modern execution has no usageSummary credits', async () => { + const file = JSON.stringify({ + executionId: 'exec-nocredits-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + chatSessionId: 'session-nocredits-001', + modelId: 'claude-sonnet-4.6', + context: { + messages: [ + { role: 'human', entries: ['hello'] }, + { role: 'bot', entries: ['x'.repeat(4000)] }, + ], + }, + }) + + const wsHash = 'c'.repeat(32) + const subDir = 'e'.repeat(32) + await mkdir(join(tmpDir, wsHash, subDir), { recursive: true }) + await writeFile(join(tmpDir, wsHash, subDir, 'exec-nocredits-001'), file) + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') + const sessions = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sessions) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.costIsEstimated).toBe(true) + expect(call.costUSD).toBeGreaterThan(0) // token-estimated at the real model }) it('skips execution index files with executions array', async () => {