diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts index 602889f9..1488f4b3 100644 --- a/src/providers/kiro.ts +++ b/src/providers/kiro.ts @@ -1,5 +1,6 @@ import type { Dirent } from 'fs' -import { readdir, readFile } from 'fs/promises' +import { existsSync } from 'fs' +import { readdir, readFile, stat } from 'fs/promises' import { basename, dirname, extname, join } from 'path' import { homedir } from 'os' @@ -42,14 +43,20 @@ const toolNameMap: Record = { runCommand: 'Bash', run_command: 'Bash', shell: 'Bash', + executeBash: 'Bash', searchFiles: 'Grep', search_files: 'Grep', grep: 'Grep', + grepSearch: 'Grep', findFiles: 'Glob', find_files: 'Glob', glob: 'Glob', + fileSearch: 'Glob', webSearch: 'WebSearch', web_search: 'WebSearch', + fsWrite: 'Edit', + strReplace: 'Edit', + listDirectory: 'LS', } type KiroChatMessage = { @@ -145,7 +152,7 @@ function extractText(value: unknown): string { if (Array.isArray(value)) return value.map(extractText).filter(Boolean).join('\n') const record = asRecord(value) if (!record) return '' - for (const key of ['content', 'text', 'message', 'value', 'parts']) { + for (const key of ['content', 'text', 'message', 'value', 'parts', 'entries']) { const text = extractText(record[key]) if (text) return text } @@ -256,7 +263,7 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' const executionId = stringField(data, ['executionId', 'id']) || basename(sourcePath) - const sessionId = stringField(data, ['sessionId', 'conversationId', 'workflowId']) || + const sessionId = stringField(data, ['sessionId', 'chatSessionId', 'conversationId', 'workflowId']) || stringField(metadata, ['workflowId', 'sessionId']) || basename(dirname(sourcePath)) || executionId @@ -285,29 +292,60 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see allTools.push(...directTools) } - for (const key of MODERN_CONVERSATION_KEYS) { - const messages = data[key] - if (!Array.isArray(messages)) continue - - for (const message of messages) { - const text = extractText(message) - const role = messageRole(message) - const tools = extractStructuredToolNames(message, text) - - if (role === 'human' || role === 'user') { - if (!text) continue - inputChars += text.length - pendingUserMessage = text.slice(0, 500) - } else if (role === 'bot' || role === 'assistant' || role === 'ai' || role === 'model') { - if (text) outputChars += text.length - if (text || tools.length > 0) hasOutputActivity = true - allTools.push(...tools) - } else if (role === 'tool' || role === 'system') { - if (text) inputChars += text.length - allTools.push(...tools) + // Check both data.context[key] and data[key] for conversation arrays. + // Kiro IDE stores messages at data.context.messages in current builds. + const context = asRecord(data['context']) + const conversationSources = context ? [context, data] : [data] + + for (const source of conversationSources) { + let found = false + for (const key of MODERN_CONVERSATION_KEYS) { + const messages = (source as Record)[key] + if (!Array.isArray(messages)) continue + + for (const message of messages) { + const text = extractText(message) + const role = messageRole(message) + const tools = extractStructuredToolNames(message, text) + + if (role === 'human' || role === 'user') { + if (!text) continue + inputChars += text.length + pendingUserMessage = text.slice(0, 500) + } else if (role === 'bot' || role === 'assistant' || role === 'ai' || role === 'model') { + if (text) outputChars += text.length + if (text || tools.length > 0) hasOutputActivity = true + allTools.push(...tools) + } else if (role === 'tool' || role === 'system') { + if (text) inputChars += text.length + allTools.push(...tools) + } + } + found = true + break + } + 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. + const usageSummary = data['usageSummary'] + if (Array.isArray(usageSummary)) { + for (const entry of usageSummary) { + const rec = asRecord(entry) + if (!rec) continue + const usedTools = rec['usedTools'] + if (Array.isArray(usedTools)) { + for (const tool of usedTools) { + if (typeof tool === 'string' && tool) { + // Strip mcp_ prefix for cleaner display (e.g. mcp_aws_sentral_mcp_search_accounts -> aws_sentral_mcp_search_accounts) + const cleaned = tool.startsWith('mcp_') ? tool.slice(4) : tool + allTools.push(toolNameMap[cleaned] ?? cleaned) + hasOutputActivity = true + } + } } } - break } if (!hasOutputActivity) return results @@ -542,6 +580,97 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const record = asRecord(data) if (!record) return + // 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, + } + return + } + const metadata = asRecord(record['metadata']) const calls = Array.isArray(record['chat']) && metadata ? parseChatFile(record as unknown as KiroChatFile, stringField(metadata, ['workflowId']) || basename(source.path, '.chat'), source.project, seenKeys) @@ -555,15 +684,25 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars // --- Discovery --- -function getKiroAgentDir(override?: string): string { - if (override) return override +function getKiroAgentDir(override?: string): string[] { + if (override) return [override] if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + return [join(homedir(), 'Library', 'Application Support', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')] } if (process.platform === 'win32') { - return join(homedir(), 'AppData', 'Roaming', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + return [join(homedir(), 'AppData', 'Roaming', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent')] } - return join(homedir(), '.config', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + // On Linux, scan both ~/.kiro-server/data/... (remote dev boxes) and + // ~/.config/Kiro/... (local installs). Both can have data simultaneously + // if the user switches between local and remote, or if .kiro-server exists + // but is stale while .config/Kiro has current sessions. + const paths: string[] = [] + const kiroServer = join(homedir(), '.kiro-server', 'data', 'User', 'globalStorage', 'kiro.kiroagent') + const kiroConfig = join(homedir(), '.config', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + if (existsSync(kiroServer)) paths.push(kiroServer) + if (existsSync(kiroConfig)) paths.push(kiroConfig) + // Fallback to config path if neither exists (will just find nothing) + return paths.length > 0 ? paths : [kiroConfig] } function getKiroWorkspaceStorageDir(override?: string): string { @@ -667,11 +806,36 @@ async function discoverSessions(agentDir: string, workspaceStorageDir: string, c } } + // --- Kiro IDE workspace-sessions (newer builds store session state here) --- + // These files contain history[].message with user prompts and assistant stubs + // plus executionId references. They capture sessions not written as per-execution files. + try { + const wsSessionsDir = join(agentDir, 'workspace-sessions') + const wsSessionDirs = await readdir(wsSessionsDir, { withFileTypes: true }) + for (const dir of wsSessionDirs) { + if (!dir.isDirectory()) continue + // Directory name is base64-encoded workspace path + let project = 'kiro-ide' + try { + const decoded = Buffer.from(dir.name.replace(/_/g, '='), 'base64').toString('utf-8') + if (decoded) project = basename(decoded) + } catch {} + // Skip bare homedir as project name + if (project === basename(homedir())) project = 'kiro-ide' + + const sessionFiles = await readdir(join(wsSessionsDir, dir.name), { withFileTypes: true }).catch(() => []) + for (const sf of sessionFiles) { + if (!sf.isFile() || !sf.name.endsWith('.json') || sf.name === 'sessions.json') continue + sources.push({ path: join(wsSessionsDir, dir.name, sf.name), project, provider: 'kiro' }) + } + } + } catch {} + return sources } export function createKiroProvider(agentDirOverride?: string, workspaceStorageDirOverride?: string, cliSessionsDirOverride?: string): Provider { - const agentDir = getKiroAgentDir(agentDirOverride) + 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')) @@ -693,7 +857,19 @@ export function createKiroProvider(agentDirOverride?: string, workspaceStorageDi }, async discoverSessions(): Promise { - return discoverSessions(agentDir, wsDir, cliDir) + const allSources: SessionSource[] = [] + for (const agentDir of agentDirs) { + const sources = await discoverSessions(agentDir, wsDir, cliDir) + allSources.push(...sources) + } + // 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() + return allSources.filter(s => { + if (seen.has(s.path)) return false + seen.add(s.path) + return true + }) }, createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { diff --git a/src/session-cache.ts b/src/session-cache.ts index abfb41c8..3ba5420f 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -115,6 +115,7 @@ const PROVIDER_PARSE_VERSIONS: Record = { copilot: 'otel-durable-v1', hermes: 'reasoning-output-accounting-v1', 'ibm-bob': 'worktree-project-grouping-v1', + kiro: 'ide-parsing-v1', 'kilo-code': 'worktree-project-grouping-v1', 'roo-code': 'worktree-project-grouping-v1', warp: 'worktree-project-grouping-v1', diff --git a/tests/kiro-cache-invalidation.test.ts b/tests/kiro-cache-invalidation.test.ts new file mode 100644 index 00000000..725ba5bd --- /dev/null +++ b/tests/kiro-cache-invalidation.test.ts @@ -0,0 +1,152 @@ +// Regression test for the Kiro stale-cache path (#618, #619). +// +// Before this fix the Kiro parser returned 0 turns for every IDE execution +// file that stores content under `context.messages[].entries`. Those empty +// results were cached in session-cache.json keyed by file fingerprint, so +// shipping a fixed parser alone is not enough: unchanged files would keep +// their cached `turns: []` forever. The fix registers kiro in +// PROVIDER_PARSE_VERSIONS, which changes the provider envFingerprint and +// makes `parseProviderSources` discard the stale section on first run. +// +// This test exercises the full `parseAllSessions` pipeline against a seeded +// session-cache.json, in both directions: +// - a cache seeded with the CURRENT fingerprint is honored (zero-turn entry +// stays, proving the seed is structurally valid and actually trusted) +// - a cache seeded with the PRE-FIX fingerprint is discarded and the file +// is re-parsed, recovering the calls the broken parser missed + +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest' +import { mkdir, rm, writeFile } from 'fs/promises' +import { createHash } from 'crypto' +import { join } from 'path' + +import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { + CACHE_VERSION, + computeEnvFingerprint, + fingerprintFile, + type SessionCache, +} from '../src/session-cache.js' + +// The kiro provider singleton captures homedir() when its module is first +// imported, so HOME must point at the test root before ../src/parser.js is +// evaluated. vi.hoisted runs ahead of the static imports above (but after +// tests/setup/env-isolation.ts, whose per-test beforeEach re-sandboxes env +// vars — anything read at *call* time, like CODEBURN_CACHE_DIR, must be +// re-asserted in this file's own beforeEach). +const testRoot = vi.hoisted(() => { + const root = `${process.env['TMPDIR'] || '/tmp'}/kiro-cache-inv-${process.pid}-${Date.now()}` + process.env['HOME'] = `${root}/home` + process.env['USERPROFILE'] = `${root}/home` + return root +}) + +const HOME = join(testRoot, 'home') +const CACHE_DIR = join(testRoot, 'cache') + +function kiroAgentDir(): string { + if (process.platform === 'darwin') { + return join(HOME, 'Library', 'Application Support', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + } + if (process.platform === 'win32') { + return join(HOME, 'AppData', 'Roaming', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + } + return join(HOME, '.config', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') +} + +// What computeEnvFingerprint('kiro') returned before kiro had an entry in +// PROVIDER_PARSE_VERSIONS: no env vars, no parser version, i.e. a hash of +// zero parts. This is the fingerprint sitting in every pre-fix cache. +function preFixFingerprint(): string { + return createHash('sha256').update([].join('\0')).digest('hex').slice(0, 16) +} + +// Writes one IDE execution file in the context.messages[].entries format that +// the pre-fix parser turned into 0 turns, and returns its path. +async function seedExecutionFile(): Promise { + const dir = join(kiroAgentDir(), 'a'.repeat(32), 'b'.repeat(32)) + await mkdir(dir, { recursive: true }) + const path = join(dir, 'exec-stale-001') + await writeFile(path, JSON.stringify({ + executionId: 'exec-stale-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1780000000000, + chatSessionId: 'session-stale-001', + context: { + messages: [ + { role: 'human', entries: [{ type: 'text', text: 'What is TypeScript?' }] }, + { role: 'bot', entries: [{ type: 'text', text: 'TypeScript is a typed superset of JavaScript.' }] }, + ], + }, + })) + return path +} + +async function seedCache(execPath: string, envFingerprint: string): Promise { + const fp = await fingerprintFile(execPath) + if (!fp) throw new Error('failed to fingerprint seeded execution file') + const cache: SessionCache = { + version: CACHE_VERSION, + providers: { + kiro: { + envFingerprint, + files: { + [execPath]: { fingerprint: fp, mcpInventory: [], turns: [] }, + }, + }, + }, + } + await mkdir(CACHE_DIR, { recursive: true }) + await writeFile(join(CACHE_DIR, 'session-cache.json'), JSON.stringify(cache)) +} + +async function parseKiroCalls() { + const projects = await parseAllSessions(undefined, 'kiro') + return projects + .flatMap(p => p.sessions) + .flatMap(s => s.turns) + .flatMap(t => t.assistantCalls) +} + +beforeEach(async () => { + // Runs after env-isolation's global beforeEach, which cleared this var. + process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR + clearSessionCache() + await rm(testRoot, { recursive: true, force: true }) +}) + +afterAll(async () => { + clearSessionCache() + await rm(testRoot, { recursive: true, force: true }) +}) + +describe('Kiro session cache invalidation', () => { + it('registers a kiro parser version in the env fingerprint', () => { + expect(computeEnvFingerprint('kiro')).not.toBe(preFixFingerprint()) + }) + + it('control: a zero-turn cache entry at the current fingerprint is honored', async () => { + const execPath = await seedExecutionFile() + await seedCache(execPath, computeEnvFingerprint('kiro')) + + const calls = await parseKiroCalls() + + // The seeded cache is structurally valid and trusted: the unchanged file + // is not re-parsed, so the stale zero-turn entry yields no calls. This + // guards the regression test below against passing for the wrong reason + // (an invalid or unread seed being silently ignored). + expect(calls).toHaveLength(0) + }) + + it('regression: a pre-fix cache fingerprint forces a re-parse that recovers the calls', async () => { + const execPath = await seedExecutionFile() + await seedCache(execPath, preFixFingerprint()) + + const calls = await parseKiroCalls() + + expect(calls).toHaveLength(1) + expect(calls[0]!.usage.inputTokens).toBeGreaterThan(0) + expect(calls[0]!.usage.outputTokens).toBeGreaterThan(0) + }) +}) diff --git a/tests/providers/kiro.test.ts b/tests/providers/kiro.test.ts index 08cce8a0..cf58bbbd 100644 --- a/tests/providers/kiro.test.ts +++ b/tests/providers/kiro.test.ts @@ -722,3 +722,216 @@ describe('kiro provider - CLI session discovery', () => { expect(sessions).toHaveLength(0) }) }) + +describe('kiro provider - context.messages with entries', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kiro-ctx-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('parses context.messages using entries field', async () => { + // Simulates the real Kiro IDE format where messages use "entries" not "content" + const file = JSON.stringify({ + executionId: 'exec-ctx-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + chatSessionId: 'session-ctx-001', + context: { + messages: [ + { role: 'human', entries: ['What is the meaning of life?'] }, + { role: 'bot', entries: ['The meaning of life is 42, according to Douglas Adams.'] }, + { role: 'human', entries: ['Tell me more'] }, + { role: 'bot', entries: ['The answer comes from The Hitchhiker\'s Guide to the Galaxy.'] }, + ], + }, + }) + + const wsHash = 'a'.repeat(32) + const subDir = 'b'.repeat(32) + 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 sessions = await provider.discoverSessions() + expect(sessions.length).toBeGreaterThan(0) + + const calls: ParsedProviderCall[] = [] + for (const source of sessions) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + } + + expect(calls.length).toBeGreaterThan(0) + const call = calls[0]! + expect(call.inputTokens).toBeGreaterThan(0) + expect(call.outputTokens).toBeGreaterThan(0) + expect(call.sessionId).toBe('session-ctx-001') + }) + + it('extracts tools from usageSummary', async () => { + const file = JSON.stringify({ + executionId: 'exec-tools-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + chatSessionId: 'session-tools-001', + context: { + messages: [ + { role: 'human', entries: ['Search for accounts'] }, + { role: 'bot', entries: ['Found 5 accounts.'] }, + ], + }, + usageSummary: [ + { usedTools: ['mcp_aws_sentral_mcp_search_accounts'], usage: 0.5, unit: 'credit' }, + { usedTools: ['executeBash', 'readFile'], usage: 1.0, unit: 'credit' }, + ], + }) + + const wsHash = 'c'.repeat(32) + const subDir = 'd'.repeat(32) + 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 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.length).toBeGreaterThan(0) + const call = calls[0]! + expect(call.tools).toContain('aws_sentral_mcp_search_accounts') + expect(call.tools).toContain('Bash') + expect(call.tools).toContain('Read') + }) + + it('skips execution index files with executions array', async () => { + // The session index file has {executions: [...], version: 2} + const indexFile = JSON.stringify({ + executions: [ + { executionId: 'exec-001', type: 'chat-agent', status: 'succeed', startTime: 1777333000000 }, + ], + version: 2, + }) + + const wsHash = 'e'.repeat(32) + await mkdir(join(tmpDir, wsHash), { recursive: true }) + await writeFile(join(tmpDir, wsHash, 'f'.repeat(32)), indexFile) + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + 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(0) + }) +}) + +describe('kiro provider - workspace-sessions format', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kiro-wss-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers and parses workspace-sessions files', async () => { + // Create workspace-sessions//.json + const wsSessionsDir = join(tmpDir, 'workspace-sessions', 'L3RtcC90ZXN0') + await mkdir(wsSessionsDir, { recursive: true }) + + const sessionFile = JSON.stringify({ + sessionId: 'ws-session-001', + title: 'Test session', + selectedModel: 'claude-opus-4.8', + workspaceDirectory: '/tmp/test', + history: [ + { message: { role: 'user', content: [{ type: 'text', text: 'What is TypeScript?' }] } }, + { message: { role: 'assistant', content: 'TypeScript is a typed superset of JavaScript.' } }, + { message: { role: 'user', content: [{ type: 'text', text: 'How do I use generics?' }] } }, + { message: { role: 'assistant', content: 'Generics allow you to create reusable components.' } }, + ], + }) + + await writeFile(join(wsSessionsDir, 'ws-session-001.json'), sessionFile) + // Also need sessions.json (should be skipped) + await writeFile(join(wsSessionsDir, 'sessions.json'), '[]') + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const sessions = await provider.discoverSessions() + + const wsSessions = sessions.filter(s => s.path.includes('workspace-sessions')) + expect(wsSessions).toHaveLength(1) + expect(wsSessions[0]!.path).toContain('ws-session-001.json') + + const calls: ParsedProviderCall[] = [] + for (const source of wsSessions) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.model).toBe('claude-opus-4-8') + expect(call.sessionId).toBe('ws-session-001') + expect(call.inputTokens).toBeGreaterThan(0) + expect(call.outputTokens).toBeGreaterThan(0) + expect(call.deduplicationKey).toBe('kiro:ws-session:ws-session-001') + }) + + it('skips workspace-sessions with only stub assistant replies referencing execution files', async () => { + const wsSessionsDir = join(tmpDir, 'workspace-sessions', 'L3RtcC90ZXN0') + await mkdir(wsSessionsDir, { recursive: true }) + + // Session where assistant only says "On it." with executionId refs + // (real output is in execution files — skip to avoid double-counting) + const sessionFile = JSON.stringify({ + sessionId: 'ws-session-stub', + selectedModel: 'auto', + workspaceDirectory: '/tmp/test', + history: [ + { message: { role: 'user', content: [{ type: 'text', text: 'Deploy the stack' }] } }, + { message: { role: 'assistant', content: 'On it.' }, executionId: 'exec-ref-001' }, + ], + }) + + await writeFile(join(wsSessionsDir, 'ws-session-stub.json'), sessionFile) + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + 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) + } + } + + // Should be skipped: has executionId refs but no real assistant content + expect(calls).toHaveLength(0) + }) + + it('skips sessions.json file in workspace-sessions', async () => { + const wsSessionsDir = join(tmpDir, 'workspace-sessions', 'L3RtcC90ZXN0') + await mkdir(wsSessionsDir, { recursive: true }) + await writeFile(join(wsSessionsDir, 'sessions.json'), '[]') + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent') + const sessions = await provider.discoverSessions() + const wsSessions = sessions.filter(s => s.path.includes('workspace-sessions')) + expect(wsSessions).toHaveLength(0) + }) +}) diff --git a/tests/session-cache.test.ts b/tests/session-cache.test.ts index b015322b..b14ab066 100644 --- a/tests/session-cache.test.ts +++ b/tests/session-cache.test.ts @@ -184,6 +184,7 @@ describe('computeEnvFingerprint', () => { it('includes parser versions in provider fingerprints', () => { expect(computeEnvFingerprint('claude')).not.toBe(computeEnvFingerprint('unknown-provider')) expect(computeEnvFingerprint('copilot')).not.toBe(computeEnvFingerprint('unknown-provider')) + expect(computeEnvFingerprint('kiro')).not.toBe(computeEnvFingerprint('unknown-provider')) expect(computeEnvFingerprint('warp')).not.toBe(computeEnvFingerprint('unknown-provider')) }) })