Merge pull request #665 from Enclavet/kiro_v2

Kiro: IDE v2 session store support + credit-based pricing across all formats
This commit is contained in:
Resham Joshi 2026-07-16 00:18:11 -07:00 committed by GitHub
commit 405489f460
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 969 additions and 119 deletions

View file

@ -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/<id>.jsonl` (+ companion `<id>.json`)
- **IDE v2 store:** `~/.kiro/sessions/<workspace-hash>/sess_<id>/` (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:
- `<workspace-hash>/<execution-id>.chat` legacy session files
- `<workspace-hash>/<session-hash>` extensionless session index files
- `<workspace-hash>/<session-hash>/<execution-hash>` extensionless execution files inside session directories
- `workspace-sessions/<base64url(path)>/<uuid>.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/<uuid>.json` record is the *spine* (linked by `executionId``chatSessionId`).
- **CLI** JSONL: one `{ version, kind, data }` event per line + a companion `<id>.json` with `session_state` metering (`metering_usage` gives real per-turn cost in credits).
- **IDE v2** (`sess_<id>/`): 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:<sessionId>:<turnIndex>`. v2 turns key on `kiro-v2:<sessionId>:<executionId>`.
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,8 @@ 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 `<tool_use><name>...</name>` 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`), 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

View file

@ -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 = {

View file

@ -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,

View file

@ -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']
@ -239,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,
@ -330,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) {
@ -363,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({
@ -377,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(),
@ -445,12 +462,20 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen
const tsDate = parseKiroTimestamp(timestamp)
if (!tsDate) { turnIndex++; return }
const costUSD = turnMeta?.metering_usage
? turnMeta.metering_usage.reduce((sum, m) => sum + m.value, 0)
: 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({
@ -464,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(),
@ -537,9 +562,289 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen
return results
}
// --- Kiro IDE workspace-session parser (workspace-sessions/<b64>/<sessionId>.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<string, unknown>, source: SessionSource, seenKeys: Set<string>): Promise<ParsedProviderCall[]> {
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/<hash>/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<string>): Promise<ParsedProviderCall[]> {
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<string, unknown>
try { evt = JSON.parse(line) as Record<string, unknown> } 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<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
// v2 IDE store: ~/.kiro/sessions/<hash>/sess_<id>/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 +890,8 @@ function createParser(source: SessionSource, seenKeys: Set<string>): 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 +1058,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/<workspaceHash>/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<SessionSource[]> {
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/<hash>/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 +1142,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<string>()

View file

@ -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
@ -292,6 +296,15 @@ export async function saveCache(cache: SessionCache): Promise<void> {
}
// ── File Fingerprinting ────────────────────────────────────────────────
//
// Fingerprints cover the source's transcript file only. Providers that keep
// metadata in a companion file (kiro CLI: credits in `<id>.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<FileFingerprint | null> {
try {

View file

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

View file

@ -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 <root>/cli so v2 discovery derives <root> (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,69 @@ 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('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 () => {
@ -766,7 +832,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 +874,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) {
@ -822,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 () => {
@ -837,7 +942,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 +986,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 +1027,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 +1045,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<string> {
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 <sessionsRoot>/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<string>()
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<ParsedProviderCall[]> {
const provider = createKiroProvider(agentDir, join(root, 'ws-storage'), join(sessionsRoot, 'cli'))
const sources = await provider.discoverSessions()
const seenKeys = new Set<string>()
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<string>()
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)
})
})