mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-16 20:14:27 +00:00
Fix Kiro post-February storage discovery (#339)
This commit is contained in:
commit
82d6c7fa4d
4 changed files with 537 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
- `<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
|
||||
|
||||
## 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 `<tool_use><name>...</name>` (`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 `<tool_use><name>...</name>` 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.
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
'claude-sonnet-4-6': 'Sonnet 4.6',
|
||||
|
|
@ -63,6 +66,8 @@ type KiroChatFile = {
|
|||
}
|
||||
}
|
||||
|
||||
type KiroModernExecution = Record<string, unknown>
|
||||
|
||||
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<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string>): 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<string>): 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<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. <tool_use><name>runCommand</name></tool_use>',
|
||||
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. <tool_use><name>runCommand</name></tool_use>',
|
||||
}))
|
||||
|
||||
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. <tool_use><name>writeFile</name></tool_use>',
|
||||
}))
|
||||
|
||||
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<string>()
|
||||
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 })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue