mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-16 20:14:27 +00:00
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:
commit
405489f460
7 changed files with 969 additions and 119 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue