949 lines
36 KiB
TypeScript
949 lines
36 KiB
TypeScript
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 { kiro, createKiroProvider } from '../../src/providers/kiro.js'
|
|
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
|
|
|
let tmpDir: string
|
|
|
|
function makeChatFile(opts: {
|
|
executionId?: string
|
|
modelId?: string
|
|
workflowId?: string
|
|
startTime?: number
|
|
endTime?: number
|
|
userPrompt?: string
|
|
botResponses?: string[]
|
|
}) {
|
|
const chat = [
|
|
{ role: 'human', content: '<identity>\nYou are Kiro.\n</identity>' },
|
|
{ role: 'bot', content: '' },
|
|
{ role: 'tool', content: 'workspace tree...' },
|
|
{ role: 'bot', content: 'I will follow these instructions.' },
|
|
]
|
|
|
|
if (opts.userPrompt) {
|
|
chat.push({ role: 'human', content: opts.userPrompt })
|
|
}
|
|
|
|
for (const resp of opts.botResponses ?? ['Done.']) {
|
|
chat.push({ role: 'bot', content: resp })
|
|
}
|
|
|
|
return JSON.stringify({
|
|
executionId: opts.executionId ?? 'exec-001',
|
|
actionId: 'act',
|
|
context: [],
|
|
validations: {},
|
|
chat,
|
|
metadata: {
|
|
modelId: opts.modelId ?? 'claude-haiku-4-5',
|
|
modelProvider: 'qdev',
|
|
workflow: 'act',
|
|
workflowId: opts.workflowId ?? 'wf-001',
|
|
startTime: opts.startTime ?? 1777333000000,
|
|
endTime: opts.endTime ?? 1777333010000,
|
|
},
|
|
})
|
|
}
|
|
|
|
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-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
it('parses a basic chat file', async () => {
|
|
const wsHash = 'a'.repeat(32)
|
|
const wsDir = join(tmpDir, wsHash)
|
|
await mkdir(wsDir, { recursive: true })
|
|
const chatPath = join(wsDir, 'abc123.chat')
|
|
await writeFile(chatPath, makeChatFile({
|
|
modelId: 'claude-haiku-4-5',
|
|
userPrompt: 'explain the code',
|
|
botResponses: ['Here is an explanation of the code structure.'],
|
|
}))
|
|
|
|
const source = { path: chatPath, project: 'myproject', 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-haiku-4-5')
|
|
expect(call.outputTokens).toBeGreaterThan(0)
|
|
expect(call.userMessage).toBe('explain the code')
|
|
expect(call.bashCommands).toEqual([])
|
|
expect(call.costUSD).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('stores kiro-auto when model is auto', async () => {
|
|
const wsHash = 'b'.repeat(32)
|
|
const wsDir = join(tmpDir, wsHash)
|
|
await mkdir(wsDir, { recursive: true })
|
|
const chatPath = join(wsDir, 'abc.chat')
|
|
await writeFile(chatPath, makeChatFile({
|
|
modelId: 'auto',
|
|
botResponses: ['some output'],
|
|
}))
|
|
|
|
const source = { path: chatPath, 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]!.costUSD).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('skips chat files with no bot output', async () => {
|
|
const wsHash = 'c'.repeat(32)
|
|
const wsDir = join(tmpDir, wsHash)
|
|
await mkdir(wsDir, { recursive: true })
|
|
const chatPath = join(wsDir, 'empty.chat')
|
|
await writeFile(chatPath, JSON.stringify({
|
|
executionId: 'exec-empty',
|
|
actionId: 'act',
|
|
context: [],
|
|
validations: {},
|
|
chat: [
|
|
{ role: 'human', content: '<identity>\nYou are Kiro.\n</identity>' },
|
|
{ role: 'bot', content: '' },
|
|
{ role: 'human', content: 'do something' },
|
|
{ role: 'bot', content: '' },
|
|
],
|
|
metadata: {
|
|
modelId: 'claude-haiku-4-5',
|
|
modelProvider: 'qdev',
|
|
workflow: 'act',
|
|
workflowId: 'wf-empty',
|
|
startTime: 1777333000000,
|
|
endTime: 1777333010000,
|
|
},
|
|
}))
|
|
|
|
const source = { path: chatPath, 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('deduplicates across parser runs', async () => {
|
|
const wsHash = 'd'.repeat(32)
|
|
const wsDir = join(tmpDir, wsHash)
|
|
await mkdir(wsDir, { recursive: true })
|
|
const chatPath = join(wsDir, 'dup.chat')
|
|
await writeFile(chatPath, makeChatFile({ botResponses: ['hello'] }))
|
|
|
|
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
|
const seenKeys = new Set<string>()
|
|
|
|
const calls1: ParsedProviderCall[] = []
|
|
for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls1.push(call)
|
|
|
|
const calls2: ParsedProviderCall[] = []
|
|
for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls2.push(call)
|
|
|
|
expect(calls1).toHaveLength(1)
|
|
expect(calls2).toHaveLength(0)
|
|
})
|
|
|
|
it('returns empty for missing file', async () => {
|
|
const source = { path: '/nonexistent/test.chat', 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('returns empty for invalid JSON', async () => {
|
|
const wsHash = 'e'.repeat(32)
|
|
const wsDir = join(tmpDir, wsHash)
|
|
await mkdir(wsDir, { recursive: true })
|
|
const chatPath = join(wsDir, 'bad.chat')
|
|
await writeFile(chatPath, 'not json at all')
|
|
|
|
const source = { path: chatPath, 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('estimates tokens from text length', async () => {
|
|
const wsHash = 'f'.repeat(32)
|
|
const wsDir = join(tmpDir, wsHash)
|
|
await mkdir(wsDir, { recursive: true })
|
|
const chatPath = join(wsDir, 'tokens.chat')
|
|
const longResponse = 'x'.repeat(400)
|
|
await writeFile(chatPath, makeChatFile({ botResponses: [longResponse] }))
|
|
|
|
const source = { path: chatPath, 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]!.outputTokens).toBe(109)
|
|
})
|
|
|
|
it('normalizes dot-versioned model IDs to dashes', async () => {
|
|
const wsHash = 'h'.repeat(32)
|
|
const wsDir = join(tmpDir, wsHash)
|
|
await mkdir(wsDir, { recursive: true })
|
|
const chatPath = join(wsDir, 'dot.chat')
|
|
await writeFile(chatPath, makeChatFile({
|
|
modelId: 'claude-haiku-4.5',
|
|
botResponses: ['response text here'],
|
|
}))
|
|
|
|
const source = { path: chatPath, 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('claude-haiku-4-5')
|
|
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('uses workflowId as sessionId', async () => {
|
|
const wsHash = 'g'.repeat(32)
|
|
const wsDir = join(tmpDir, wsHash)
|
|
await mkdir(wsDir, { recursive: true })
|
|
const chatPath = join(wsDir, 'sess.chat')
|
|
await writeFile(chatPath, makeChatFile({
|
|
workflowId: 'my-workflow-id',
|
|
botResponses: ['ok'],
|
|
}))
|
|
|
|
const source = { path: chatPath, 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]!.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', () => {
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'kiro-test-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
it('discovers chat files from workspace hash directories', async () => {
|
|
const wsHash = 'a1b2c3d4e5f6'.padEnd(32, '0')
|
|
const wsDir = join(tmpDir, wsHash)
|
|
await mkdir(wsDir, { recursive: true })
|
|
await writeFile(join(wsDir, 'session1.chat'), makeChatFile({}))
|
|
await writeFile(join(wsDir, 'session2.chat'), makeChatFile({}))
|
|
|
|
const provider = createKiroProvider(tmpDir, '/nonexistent/ws')
|
|
const sessions = await provider.discoverSessions()
|
|
|
|
expect(sessions).toHaveLength(2)
|
|
expect(sessions.every(s => s.provider === 'kiro')).toBe(true)
|
|
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)
|
|
await mkdir(agentWsDir, { recursive: true })
|
|
await writeFile(join(agentWsDir, 'test.chat'), makeChatFile({}))
|
|
|
|
const workspaceStorageDir = join(tmpDir, 'ws-storage')
|
|
const wsStorageEntry = join(workspaceStorageDir, wsHash)
|
|
await mkdir(wsStorageEntry, { recursive: true })
|
|
await writeFile(join(wsStorageEntry, 'workspace.json'), JSON.stringify({ folder: 'file:///home/user/myapp' }))
|
|
|
|
const provider = createKiroProvider(tmpDir, workspaceStorageDir)
|
|
const sessions = await provider.discoverSessions()
|
|
|
|
expect(sessions).toHaveLength(1)
|
|
expect(sessions[0]!.project).toBe('myapp')
|
|
})
|
|
|
|
it('returns empty when directory does not exist', async () => {
|
|
const provider = createKiroProvider('/nonexistent/agent', '/nonexistent/ws')
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toHaveLength(0)
|
|
})
|
|
|
|
it('skips non-32-char directories', async () => {
|
|
const shortDir = join(tmpDir, 'short')
|
|
await mkdir(shortDir, { recursive: true })
|
|
await writeFile(join(shortDir, 'test.chat'), makeChatFile({}))
|
|
|
|
const provider = createKiroProvider(tmpDir, '/nonexistent/ws')
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toHaveLength(0)
|
|
})
|
|
|
|
it('skips files with unsupported extensions', async () => {
|
|
const wsHash = 'c'.repeat(32)
|
|
const wsDir = join(tmpDir, wsHash)
|
|
await mkdir(wsDir, { recursive: true })
|
|
await writeFile(join(wsDir, 'index.json'), '{}')
|
|
await writeFile(join(wsDir, 'notes.txt'), 'hello')
|
|
|
|
const provider = createKiroProvider(tmpDir, '/nonexistent/ws')
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('kiro provider - metadata', () => {
|
|
it('has correct name and displayName', () => {
|
|
expect(kiro.name).toBe('kiro')
|
|
expect(kiro.displayName).toBe('Kiro')
|
|
})
|
|
|
|
it('normalizes model display names', () => {
|
|
expect(kiro.modelDisplayName('claude-haiku-4-5')).toBe('Haiku 4.5')
|
|
expect(kiro.modelDisplayName('claude-sonnet-4-5')).toBe('Sonnet 4.5')
|
|
expect(kiro.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6')
|
|
expect(kiro.modelDisplayName('unknown-model')).toBe('unknown-model')
|
|
})
|
|
|
|
it('normalizes tool display names', () => {
|
|
expect(kiro.toolDisplayName('readFile')).toBe('Read')
|
|
expect(kiro.toolDisplayName('writeFile')).toBe('Edit')
|
|
expect(kiro.toolDisplayName('runCommand')).toBe('Bash')
|
|
expect(kiro.toolDisplayName('searchFiles')).toBe('Grep')
|
|
expect(kiro.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
|
})
|
|
|
|
it('normalizes CLI-specific tool names', () => {
|
|
expect(kiro.toolDisplayName('code')).toBe('Read')
|
|
expect(kiro.toolDisplayName('subagent')).toBe('Agent')
|
|
expect(kiro.toolDisplayName('web_fetch')).toBe('WebFetch')
|
|
})
|
|
|
|
it('passes through MCP tool names unchanged', () => {
|
|
expect(kiro.toolDisplayName('mcp__server__searchJira')).toBe('mcp__server__searchJira')
|
|
expect(kiro.toolDisplayName('mcp__atlassian__getIssue')).toBe('mcp__atlassian__getIssue')
|
|
})
|
|
|
|
it('longest-prefix match for versioned model IDs', () => {
|
|
expect(kiro.modelDisplayName('claude-sonnet-4-5-20260101')).toBe('Sonnet 4.5')
|
|
expect(kiro.modelDisplayName('claude-haiku-4-5-20260101')).toBe('Haiku 4.5')
|
|
})
|
|
})
|
|
|
|
describe('kiro provider - CLI session discovery', () => {
|
|
let cliDir: string
|
|
|
|
beforeEach(async () => {
|
|
cliDir = await mkdtemp(join(tmpdir(), 'kiro-cli-test-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await rm(cliDir, { recursive: true, force: true })
|
|
})
|
|
|
|
it('discovers .jsonl files from CLI sessions directory', async () => {
|
|
const sessionId = '11111111-1111-1111-1111-111111111111'
|
|
await writeFile(join(cliDir, `${sessionId}.jsonl`), '')
|
|
await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({
|
|
session_id: sessionId,
|
|
cwd: '/home/user/my-project',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:01:00Z',
|
|
}))
|
|
|
|
const provider = createKiroProvider('/nonexistent', '/nonexistent', cliDir)
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toHaveLength(1)
|
|
expect(sessions[0]!.project).toBe('my-project')
|
|
expect(sessions[0]!.path).toContain('.jsonl')
|
|
expect(sessions[0]!.provider).toBe('kiro')
|
|
})
|
|
|
|
it('parses CLI session JSONL into calls', async () => {
|
|
const sessionId = '22222222-2222-2222-2222-222222222222'
|
|
const jsonl = [
|
|
JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hello world' }], meta: { timestamp: 1700000000 } } }),
|
|
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'Hello! How can I help you today?' }, { kind: 'toolUse', data: { toolUseId: 't1', name: 'read', input: {} } }] } }),
|
|
JSON.stringify({ version: '1', kind: 'ToolResults', data: { message_id: 'm3', content: [{ kind: 'text', data: 'file contents here' }], results: { t1: { output: 'ok' } } } }),
|
|
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm4', content: [{ kind: 'text', data: 'I read the file for you.' }] } }),
|
|
].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: 'auto' } },
|
|
conversation_metadata: {
|
|
user_turn_metadatas: [{
|
|
end_timestamp: '2026-01-01T00:00:30Z',
|
|
metering_usage: [{ value: 0.05, unit: 'credit' }, { value: 0.08, unit: 'credit' }],
|
|
}],
|
|
},
|
|
},
|
|
}))
|
|
|
|
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)
|
|
const call = calls[0]!
|
|
expect(call.provider).toBe('kiro')
|
|
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.deduplicationKey).toBe(`kiro-cli:${sessionId}:0`)
|
|
expect(call.timestamp).toBe('2026-01-01T00:00:30.000Z')
|
|
expect(call.project).toBe('test-project')
|
|
})
|
|
|
|
it('parses multiple turns from a CLI session', async () => {
|
|
const sessionId = '33333333-3333-3333-3333-333333333333'
|
|
const jsonl = [
|
|
JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'first question' }], meta: { timestamp: 1700000000 } } }),
|
|
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'first answer' }] } }),
|
|
JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm3', content: [{ kind: 'text', data: 'second question' }], meta: { timestamp: 1700000060 } } }),
|
|
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm4', content: [{ kind: 'text', data: 'second answer' }] } }),
|
|
].join('\n')
|
|
|
|
await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl)
|
|
await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({
|
|
session_id: sessionId,
|
|
cwd: '/tmp/multi',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:02: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.04, unit: 'credit' }] },
|
|
{ end_timestamp: '2026-01-01T00:01:30Z', metering_usage: [{ value: 0.06, unit: 'credit' }] },
|
|
],
|
|
},
|
|
},
|
|
}))
|
|
|
|
const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'multi', provider: 'kiro' }
|
|
const calls: ParsedProviderCall[] = []
|
|
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
|
|
|
expect(calls).toHaveLength(2)
|
|
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)
|
|
})
|
|
|
|
it('skips non-jsonl files in CLI directory', async () => {
|
|
await writeFile(join(cliDir, 'something.json'), '{}')
|
|
await writeFile(join(cliDir, 'something.lock'), '')
|
|
|
|
const provider = createKiroProvider('/nonexistent', '/nonexistent', cliDir)
|
|
const sessions = await provider.discoverSessions()
|
|
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/<base64>/<sessionId>.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)
|
|
})
|
|
})
|