From 6e24f826bd536e6b1185f3cd7e441102bd824ef8 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sun, 24 May 2026 02:25:09 -0700 Subject: [PATCH] Fix Kiro post-February storage discovery (#339) Adds extensionless session index and nested execution file discovery while preserving legacy .chat support. Modern execution JSON is parsed for identifiers, timestamps, model IDs, conversation text, structured tools, and token estimates. Also fixes dedup key poisoning on invalid timestamps in the legacy parser. --- CHANGELOG.md | 5 + docs/providers/kiro.md | 29 ++-- src/providers/kiro.ts | 251 ++++++++++++++++++++++++++++--- tests/providers/kiro.test.ts | 280 ++++++++++++++++++++++++++++++++++- 4 files changed, 537 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8312d30e..891c9aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,11 @@ because the cache shortcut only merged cost/calls. Per-provider periods now always do a full parse. Also floors `maxCost` at 0.01 to avoid NaN bar widths in ActivitySection and ModelsSection. (#362) +- **Kiro post-February 2026 storage discovery.** The Kiro provider now keeps + legacy `.chat` support while also discovering extensionless session index + files and nested execution files. Modern execution JSON is parsed for + identifiers, timestamps, model IDs, conversation text, structured tools, and + estimated token usage. Thanks @ozymandiashh. Closes #329. (#339) ### Fixed (macOS menubar) - **Per-provider refresh latency.** Switching provider tabs took ~24s on heavy diff --git a/docs/providers/kiro.md b/docs/providers/kiro.md index 0c450fb6..6fe2819c 100644 --- a/docs/providers/kiro.md +++ b/docs/providers/kiro.md @@ -4,7 +4,7 @@ Kiro IDE chat history. - **Source:** `src/providers/kiro.ts` - **Loading:** eager (`src/providers/index.ts:7`) -- **Test:** `tests/providers/kiro.test.ts` (328 lines) +- **Test:** `tests/providers/kiro.test.ts` ## Where it reads from @@ -16,11 +16,20 @@ VS Code-style globalStorage at `kiro.kiroagent`: | Windows | `%APPDATA%/Kiro/User/globalStorage/kiro.kiroagent` | | Linux | `~/.config/Kiro/User/globalStorage/kiro.kiroagent` | -Sessions are `.chat` files under hash-named subdirectories. Discovery is in `kiro.ts:215-247`; the path-resolution helpers it uses start at `kiro.ts:164`. +Sessions are under hash-named workspace subdirectories. Discovery keeps backward compatibility with legacy `.chat` files and also scans the post-February 2026 extensionless format: + +- `/.chat` legacy session files +- `/` extensionless session index files +- `//` extensionless execution files inside session directories ## Storage format -JSON `.chat` files (`kiro.ts:153`). +Kiro has two known JSON formats: + +- 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 + +Session index files with `{ executions: [...] }` are discovered but skipped during parsing because they do not contain conversation content. ## Caching @@ -28,17 +37,17 @@ None. ## Deduplication -Per `executionId` (`kiro.ts:104`). +Modern files deduplicate per session/execution pair. Legacy `.chat` files deduplicate per workflow/execution pair. ## Quirks -- **Workspace hash resolution** is non-trivial. The parser tries `workspace.json` first; if that fails, it base64-decodes the directory name to recover the workspace path (`kiro.ts:198-213`). -- **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 (`kiro.ts:65-67`). Add new versions here when Kiro ships them. -- **Tool name extraction is regex-driven.** Kiro embeds tool calls inside the message text as `...` (`kiro.ts:69-78`). Brittle but unavoidable until Kiro emits structured tool data. -- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`, `kiro.ts:9`, `:108-109`). +- **Workspace hash resolution** is non-trivial. The parser tries `workspace.json` first; if that fails, it base64-decodes the directory name to recover the workspace path. +- **Model ID normalization.** Kiro stores models like `claude-1.2`; the parser rewrites the dot to a hyphen so they match `claude-1-2` in the pricing snapshot. Add new versions here when Kiro ships them. +- **Tool name extraction accepts text and structured calls.** Kiro can embed tool calls inside message text as `...` or expose structured `toolCalls` / `tool_calls` / `tools` entries. +- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`). ## When fixing a bug here 1. If the bug is "wrong workspace", check the base64 fallback path. Some users name their workspaces with characters that are not valid base64. -2. If the bug is "missing model in pricing", add the model to the normalization map at `kiro.ts:65-67` and verify against `tests/providers/kiro.test.ts`. -3. If the bug is "tools missing", look at the regex at `kiro.ts:69-78`. Kiro changes its envelope occasionally. +2. If the bug is "missing model in pricing", add the model to the normalization map and verify against `tests/providers/kiro.test.ts`. +3. If the bug is "tools missing", check both text-envelope extraction and structured tool-call extraction. Kiro changes its envelope occasionally. diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts index cc9445db..7676bfe8 100644 --- a/src/providers/kiro.ts +++ b/src/providers/kiro.ts @@ -1,5 +1,6 @@ -import { readdir, readFile, stat } from 'fs/promises' -import { basename, join } from 'path' +import type { Dirent } from 'fs' +import { readdir, readFile } from 'fs/promises' +import { basename, dirname, extname, join } from 'path' import { homedir } from 'os' import { readSessionFile } from '../fs-utils.js' @@ -8,6 +9,8 @@ import type { ToolCall } from '../types.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' const CHARS_PER_TOKEN = 4 +const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000 +const MODERN_CONVERSATION_KEYS = ['messages', 'conversation', 'chat', 'transcript', 'entries', 'events'] const modelDisplayNames: Record = { 'claude-sonnet-4-6': 'Sonnet 4.6', @@ -63,6 +66,8 @@ type KiroChatFile = { } } +type KiroModernExecution = Record + function normalizeModelId(raw: string): string { return raw.replace(/(\d+)\.(\d+)/g, '$1-$2') } @@ -78,6 +83,98 @@ function extractToolNames(content: string): string[] { return tools } +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null +} + +function stringField(record: Record | null, names: string[]): string { + if (!record) return '' + for (const name of names) { + const value = record[name] + if (typeof value === 'string' && value.trim()) return value.trim() + } + return '' +} + +function timeField(record: Record | null, names: string[]): number | string | undefined { + if (!record) return undefined + for (const name of names) { + const value = record[name] + if (typeof value === 'number' || typeof value === 'string') return value + } + return undefined +} + +function parseKiroTimestamp(value: number | string | undefined): Date | null { + if (value === undefined) return null + + let parsed: number | string = value + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return null + parsed = /^-?\d+(\.\d+)?$/.test(trimmed) ? Number(trimmed) : trimmed + } + + if (typeof parsed === 'number') { + if (!Number.isFinite(parsed)) return null + const ms = parsed < MIN_REASONABLE_TIMESTAMP_MS ? parsed * 1000 : parsed + const date = new Date(ms) + return Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS ? null : date + } + + const date = new Date(parsed) + return Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS ? null : date +} + +function textField(record: Record | null, names: string[]): string { + if (!record) return '' + for (const name of names) { + const text = extractText(record[name]) + if (text) return text + } + return '' +} + +function extractText(value: unknown): string { + if (typeof value === 'string') return value + 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']) { + const text = extractText(record[key]) + if (text) return text + } + return '' +} + +function messageRole(value: unknown): string { + const record = asRecord(value) + if (!record) return '' + return stringField(record, ['role', 'type', 'author']).toLowerCase() +} + +function extractStructuredToolNames(value: unknown, text: string, options: { includeDirectName?: boolean } = {}): string[] { + const tools = extractToolNames(text) + const record = asRecord(value) + if (!record) return tools + + if (options.includeDirectName ?? true) { + const directName = stringField(record, ['toolName', 'name']) + if (directName) tools.push(toolNameMap[directName] ?? directName) + } + + for (const key of ['toolCalls', 'tool_calls', 'tools']) { + const entries = record[key] + if (!Array.isArray(entries)) continue + for (const entry of entries) { + const name = stringField(asRecord(entry), ['name', 'toolName', 'tool_name']) + if (name) tools.push(toolNameMap[name] ?? name) + } + } + + return tools +} + function parseChatFile(data: KiroChatFile, sessionId: string, project: string, seenKeys: Set): ParsedProviderCall[] { const results: ParsedProviderCall[] = [] const { chat, metadata } = data @@ -107,14 +204,14 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s const dedupKey = `kiro:${sessionId}:${data.executionId}` if (seenKeys.has(dedupKey)) return results - seenKeys.add(dedupKey) const outputTokens = Math.ceil(totalOutputChars / CHARS_PER_TOKEN) const inputTokens = Math.ceil(pendingUserMessage.length / CHARS_PER_TOKEN) const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) - const tsDate = metadata.startTime ? new Date(metadata.startTime) : null - if (!tsDate || isNaN(tsDate.getTime()) || tsDate.getTime() < 1_000_000_000_000) return results + const tsDate = parseKiroTimestamp(metadata.startTime) + if (!tsDate) return results const timestamp = tsDate.toISOString() + seenKeys.add(dedupKey) results.push({ provider: 'kiro', @@ -140,23 +237,132 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s return results } +function parseModernExecution(data: KiroModernExecution, sourcePath: string, seenKeys: Set): ParsedProviderCall[] { + const results: ParsedProviderCall[] = [] + if (Array.isArray(data['executions'])) return results + + const metadata = asRecord(data['metadata']) + const modelObj = asRecord(data['model']) + let modelId = normalizeModelId( + stringField(data, ['modelId', 'modelID', 'modelName', 'model']) || + stringField(modelObj, ['id', 'name']) || + stringField(metadata, ['modelId', 'modelID', 'modelName']), + ) + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + + const executionId = stringField(data, ['executionId', 'id']) || basename(sourcePath) + const sessionId = stringField(data, ['sessionId', 'conversationId', 'workflowId']) || + stringField(metadata, ['workflowId', 'sessionId']) || + basename(dirname(sourcePath)) || + executionId + + let inputChars = 0 + let outputChars = 0 + let pendingUserMessage = '' + const allTools: string[] = [] + let hasOutputActivity = false + const directInput = textField(data, ['prompt', 'input', 'userMessage', 'user_message', 'request']) + const directOutput = textField(data, ['response', 'output', 'assistantMessage', 'assistant_message', 'result']) + const directTools = extractStructuredToolNames(data, directOutput, { includeDirectName: false }) + + if (directInput) { + inputChars += directInput.length + pendingUserMessage = directInput.slice(0, 500) + } + + if (directOutput) { + outputChars += directOutput.length + hasOutputActivity = true + } + + if (directTools.length > 0) { + hasOutputActivity = true + 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) + } + } + break + } + + if (!hasOutputActivity) return results + + const dedupKey = `kiro:${sessionId}:${executionId}` + if (seenKeys.has(dedupKey)) return results + + const rawStartTime = timeField(data, ['startTime', 'createdAt', 'timestamp']) ?? + timeField(metadata, ['startTime', 'createdAt', 'timestamp']) + const tsDate = parseKiroTimestamp(rawStartTime) + if (!tsDate) return results + + 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) + seenKeys.add(dedupKey) + + results.push({ + provider: 'kiro', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [...new Set(allTools)], + bashCommands: [], + timestamp: tsDate.toISOString(), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + }) + + return results +} + function createParser(source: SessionSource, seenKeys: Set): SessionParser { return { async *parse(): AsyncGenerator { const content = await readSessionFile(source.path) if (content === null) return - let data: KiroChatFile + let data: unknown try { data = JSON.parse(content) } catch { return } - if (!data.chat || !data.metadata) return + const record = asRecord(data) + if (!record) return - const sessionId = data.metadata.workflowId ?? basename(source.path, '.chat') - const calls = parseChatFile(data, sessionId, source.project, seenKeys) + 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) + : parseModernExecution(record, source.path, seenKeys) for (const call of calls) { yield call } @@ -232,19 +438,30 @@ async function discoverSessions(agentDir: string, workspaceStorageDir: string): const wsPath = join(agentDir, wsHash) const project = await resolveWorkspaceProject(agentDir, workspaceStorageDir, wsHash) - let files: string[] + let entries: Dirent[] try { - const entries = await readdir(wsPath) - files = entries.filter(f => f.endsWith('.chat')) + entries = await readdir(wsPath, { withFileTypes: true }) } catch { continue } - for (const file of files) { - const filePath = join(wsPath, file) - const s = await stat(filePath).catch(() => null) - if (!s?.isFile()) continue - sources.push({ path: filePath, project, provider: 'kiro' }) + for (const entry of entries) { + if (entry.name.startsWith('.')) continue + const entryPath = join(wsPath, entry.name) + if (entry.isFile() && (entry.name.endsWith('.chat') || extname(entry.name) === '')) { + sources.push({ path: entryPath, project, provider: 'kiro' }) + continue + } + + if (!entry.isDirectory()) continue + + const childEntries = await readdir(entryPath, { withFileTypes: true }).catch(() => []) + for (const child of childEntries) { + if (child.name.startsWith('.')) continue + if (!child.isFile()) continue + if (extname(child.name) !== '') continue + sources.push({ path: join(entryPath, child.name), project, provider: 'kiro' }) + } } } diff --git a/tests/providers/kiro.test.ts b/tests/providers/kiro.test.ts index a157d4e9..8826de87 100644 --- a/tests/providers/kiro.test.ts +++ b/tests/providers/kiro.test.ts @@ -49,6 +49,34 @@ function makeChatFile(opts: { }) } +function makeModernExecutionFile(opts: { + executionId?: string + sessionId?: string + modelId?: string + startTime?: number | string + userPrompt?: string + assistantResponse?: string +}) { + const startTime = opts.startTime ?? 1777333000000 + return JSON.stringify({ + executionId: opts.executionId ?? 'exec-modern-001', + sessionId: opts.sessionId ?? 'session-modern-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime, + endTime: typeof startTime === 'number' ? startTime + 10000 : 1777333010000, + modelId: opts.modelId ?? 'claude-sonnet-4.5', + messages: [ + { role: 'user', content: opts.userPrompt ?? 'explain the new kiro storage layout' }, + { + role: 'assistant', + content: opts.assistantResponse ?? 'Done. runCommand', + toolCalls: [{ name: 'readFile' }], + }, + ], + }) +} + describe('kiro provider - chat file parsing', () => { beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'kiro-test-')) @@ -227,6 +255,232 @@ describe('kiro provider - chat file parsing', () => { expect(calls).toHaveLength(1) expect(calls[0]!.sessionId).toBe('my-workflow-id') }) + + it('parses a post-February extensionless execution file', async () => { + const wsHash = 'i'.repeat(32) + const sessionHash = 'session-modern' + const wsDir = join(tmpDir, wsHash, sessionHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-modern') + await writeFile(executionPath, makeModernExecutionFile({ + executionId: 'exec-modern', + sessionId: 'session-modern', + modelId: 'claude-sonnet-4.5', + userPrompt: 'summarize this workspace', + assistantResponse: 'I reviewed it. runCommand', + })) + + const source = { path: executionPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('kiro') + expect(call.model).toBe('claude-sonnet-4-5') + expect(call.sessionId).toBe('session-modern') + expect(call.userMessage).toBe('summarize this workspace') + expect(call.inputTokens).toBeGreaterThan(0) + expect(call.outputTokens).toBeGreaterThan(0) + expect(call.tools).toEqual(['Bash', 'Read']) + expect(call.costUSD).toBeGreaterThan(0) + }) + + it('skips session index files without conversation content', async () => { + const wsHash = 'j'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const indexPath = join(wsDir, 'session-index') + await writeFile(indexPath, JSON.stringify({ + executions: [{ + executionId: 'exec-indexed', + type: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + endTime: 1777333010000, + }], + })) + + const source = { path: indexPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(0) + }) + + it('parses direct prompt and response fields from modern execution files', async () => { + const wsHash = 'k'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-direct') + await writeFile(executionPath, JSON.stringify({ + executionId: 'exec-direct', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + model: { id: 'auto' }, + prompt: 'make a small change', + response: 'Changed it. writeFile', + })) + + const source = { path: executionPath, project: 'test', 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]!.model).toBe('kiro-auto') + expect(calls[0]!.userMessage).toBe('make a small change') + expect(calls[0]!.tools).toEqual(['Edit']) + }) + + it('accepts second-based modern timestamps', async () => { + const wsHash = 'n'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-seconds') + await writeFile(executionPath, makeModernExecutionFile({ + executionId: 'exec-seconds', + startTime: 1777333000, + })) + + const source = { path: executionPath, project: 'test', 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]!.timestamp).toBe('2026-04-27T23:36:40.000Z') + }) + + it('accepts numeric-string modern timestamps', async () => { + const wsHash = 'o'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-string-time') + await writeFile(executionPath, makeModernExecutionFile({ + executionId: 'exec-string-time', + startTime: '1777333000000', + })) + + const source = { path: executionPath, project: 'test', 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]!.timestamp).toBe('2026-04-27T23:36:40.000Z') + }) + + it('does not poison dedup keys when a modern execution has an invalid timestamp', async () => { + const wsHash = 'p'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const invalidPath = join(wsDir, 'execution-invalid-time') + const validPath = join(wsDir, 'execution-valid-time') + const shared = { + executionId: 'exec-recovered', + sessionId: 'session-recovered', + } + await writeFile(invalidPath, makeModernExecutionFile({ + ...shared, + startTime: 'not-a-timestamp', + })) + await writeFile(validPath, makeModernExecutionFile({ + ...shared, + startTime: 1777333000000, + })) + + const seenKeys = new Set() + const invalidCalls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser({ path: invalidPath, project: 'test', provider: 'kiro' }, seenKeys).parse()) { + invalidCalls.push(call) + } + const validCalls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser({ path: validPath, project: 'test', provider: 'kiro' }, seenKeys).parse()) { + validCalls.push(call) + } + + expect(invalidCalls).toHaveLength(0) + expect(validCalls).toHaveLength(1) + }) + + it.each(['conversation', 'chat', 'transcript', 'entries', 'events'])('parses modern execution conversation arrays from %s', async (key) => { + const wsHash = 'q'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, `execution-${key}`) + await writeFile(executionPath, JSON.stringify({ + executionId: `exec-${key}`, + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + modelId: 'claude-sonnet-4.5', + [key]: [ + { role: 'user', content: `request from ${key}` }, + { role: 'assistant', content: `response from ${key}`, toolCalls: [{ name: 'readFile' }] }, + ], + })) + + const source = { path: executionPath, project: 'test', 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]!.userMessage).toBe(`request from ${key}`) + expect(calls[0]!.tools).toEqual(['Read']) + }) + + it('keeps modern executions with structured assistant tool calls and no assistant text', async () => { + const wsHash = 'l'.repeat(32) + const wsDir = join(tmpDir, wsHash, 'session-tools') + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-tools') + await writeFile(executionPath, JSON.stringify({ + executionId: 'exec-tools', + sessionId: 'session-tools', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + modelId: 'claude-sonnet-4.5', + messages: [ + { role: 'user', content: 'run the test suite' }, + { role: 'assistant', toolCalls: [{ name: 'runCommand' }] }, + ], + })) + + const source = { path: executionPath, project: 'test', 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]!.tools).toEqual(['Bash']) + expect(calls[0]!.inputTokens).toBeGreaterThan(0) + expect(calls[0]!.outputTokens).toBe(0) + }) + + it('keeps direct modern executions with root tool calls and no response text', async () => { + const wsHash = 'm'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const executionPath = join(wsDir, 'execution-root-tools') + await writeFile(executionPath, JSON.stringify({ + executionId: 'exec-root-tools', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + model: { id: 'auto' }, + name: 'workflow-name', + prompt: 'edit a file', + toolCalls: [{ name: 'writeFile' }], + })) + + const source = { path: executionPath, project: 'test', 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]!.tools).toEqual(['Edit']) + expect(calls[0]!.tools).not.toContain('workflow-name') + expect(calls[0]!.outputTokens).toBe(0) + }) }) describe('kiro provider - discoverSessions', () => { @@ -253,6 +507,30 @@ describe('kiro provider - discoverSessions', () => { expect(sessions.every(s => s.path.endsWith('.chat'))).toBe(true) }) + it('discovers extensionless session index files and nested execution files', async () => { + const wsHash = 'd'.repeat(32) + const wsDir = join(tmpDir, wsHash) + const sessionDir = join(wsDir, 'session-dir') + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(wsDir, 'session-index'), JSON.stringify({ executions: [] })) + await writeFile(join(wsDir, 'legacy.chat'), makeChatFile({})) + await writeFile(join(wsDir, 'ignored.json'), '{}') + await writeFile(join(wsDir, '.DS_Store'), 'ignored') + await writeFile(join(sessionDir, 'execution-1'), makeModernExecutionFile({})) + await writeFile(join(sessionDir, '.hidden'), 'ignored') + await writeFile(join(sessionDir, 'ignored.txt'), 'hello') + + const provider = createKiroProvider(tmpDir, '/nonexistent/ws') + const sessions = await provider.discoverSessions() + const paths = sessions.map(s => s.path).sort() + + expect(paths).toEqual([ + join(sessionDir, 'execution-1'), + join(wsDir, 'legacy.chat'), + join(wsDir, 'session-index'), + ].sort()) + }) + it('reads project name from workspace.json', async () => { const wsHash = 'b'.repeat(32) const agentWsDir = join(tmpDir, wsHash) @@ -287,7 +565,7 @@ describe('kiro provider - discoverSessions', () => { expect(sessions).toHaveLength(0) }) - it('skips files without .chat extension', async () => { + it('skips files with unsupported extensions', async () => { const wsHash = 'c'.repeat(32) const wsDir = join(tmpDir, wsHash) await mkdir(wsDir, { recursive: true })