359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
import { mkdtemp, rm } from 'fs/promises'
|
|
import { mkdirSync } from 'fs'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
import { createRequire } from 'node:module'
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { createWarpProvider } from '../../src/providers/warp.js'
|
|
import { isSqliteAvailable } from '../../src/sqlite.js'
|
|
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
|
|
|
const requireForTest = createRequire(import.meta.url)
|
|
|
|
type TestDb = {
|
|
exec(sql: string): void
|
|
prepare(sql: string): { run(...params: unknown[]): void }
|
|
close(): void
|
|
}
|
|
|
|
type QueryFixture = {
|
|
exchangeId: string
|
|
conversationId: string
|
|
startTs: string
|
|
input: string
|
|
outputStatus?: string
|
|
modelId?: string
|
|
workingDirectory?: string | null
|
|
}
|
|
|
|
type BlockFixture = {
|
|
blockId: string
|
|
conversationId: string
|
|
startTs: string
|
|
completedTs: string
|
|
exitCode: number
|
|
command: string
|
|
}
|
|
|
|
let tmpDir: string
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'warp-provider-test-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
function createWarpDb(dir: string): string {
|
|
mkdirSync(dir, { recursive: true })
|
|
const dbPath = join(dir, 'warp.sqlite')
|
|
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
|
const db = new Database(dbPath)
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS agent_conversations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
conversation_id TEXT NOT NULL,
|
|
conversation_data TEXT NOT NULL,
|
|
last_modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`)
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS ai_queries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
exchange_id TEXT NOT NULL,
|
|
conversation_id TEXT NOT NULL,
|
|
start_ts DATETIME NOT NULL,
|
|
input TEXT NOT NULL,
|
|
working_directory TEXT,
|
|
output_status TEXT NOT NULL,
|
|
model_id TEXT NOT NULL DEFAULT '',
|
|
planning_model_id TEXT NOT NULL DEFAULT '',
|
|
coding_model_id TEXT NOT NULL DEFAULT ''
|
|
)
|
|
`)
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS blocks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
pane_leaf_uuid BLOB NOT NULL,
|
|
stylized_command BLOB NOT NULL,
|
|
stylized_output BLOB NOT NULL,
|
|
pwd TEXT,
|
|
git_branch TEXT,
|
|
virtual_env TEXT,
|
|
conda_env TEXT,
|
|
exit_code INTEGER NOT NULL,
|
|
did_execute BOOLEAN NOT NULL,
|
|
completed_ts DATETIME,
|
|
start_ts DATETIME,
|
|
ps1 TEXT,
|
|
honor_ps1 BOOLEAN NOT NULL DEFAULT 0,
|
|
shell TEXT,
|
|
user TEXT,
|
|
host TEXT,
|
|
is_background BOOLEAN NOT NULL DEFAULT 0,
|
|
rprompt TEXT,
|
|
prompt_snapshot TEXT,
|
|
block_id TEXT NOT NULL DEFAULT '',
|
|
ai_metadata TEXT,
|
|
is_local BOOLEAN,
|
|
agent_view_visibility TEXT,
|
|
git_branch_name TEXT
|
|
)
|
|
`)
|
|
db.close()
|
|
return dbPath
|
|
}
|
|
|
|
function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
|
|
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
|
const db = new Database(dbPath)
|
|
try {
|
|
fn(db)
|
|
} finally {
|
|
db.close()
|
|
}
|
|
}
|
|
|
|
function insertConversation(
|
|
db: TestDb,
|
|
conversationId: string,
|
|
conversationData: unknown,
|
|
lastModifiedAt = '2026-05-18 10:10:00',
|
|
): void {
|
|
db.prepare(
|
|
'INSERT INTO agent_conversations (conversation_id, conversation_data, last_modified_at) VALUES (?, ?, ?)',
|
|
).run(conversationId, JSON.stringify(conversationData), lastModifiedAt)
|
|
}
|
|
|
|
function insertQuery(db: TestDb, q: QueryFixture): void {
|
|
db.prepare(
|
|
`INSERT INTO ai_queries (
|
|
exchange_id, conversation_id, start_ts, input, working_directory, output_status, model_id, planning_model_id, coding_model_id
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, '', '')`,
|
|
).run(
|
|
q.exchangeId,
|
|
q.conversationId,
|
|
q.startTs,
|
|
q.input,
|
|
q.workingDirectory ?? null,
|
|
q.outputStatus ?? '"Completed"',
|
|
q.modelId ?? 'auto-efficient',
|
|
)
|
|
}
|
|
|
|
function insertBlock(db: TestDb, b: BlockFixture): void {
|
|
db.prepare(
|
|
`INSERT INTO blocks (
|
|
pane_leaf_uuid, stylized_command, stylized_output, exit_code, did_execute,
|
|
completed_ts, start_ts, block_id, ai_metadata
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
Buffer.from([0]),
|
|
b.command,
|
|
'',
|
|
b.exitCode,
|
|
1,
|
|
b.completedTs,
|
|
b.startTs,
|
|
b.blockId,
|
|
JSON.stringify({
|
|
requested_command_action_id: `call-${b.blockId}`,
|
|
conversation_id: b.conversationId,
|
|
}),
|
|
)
|
|
}
|
|
|
|
async function collectCalls(
|
|
dbPath: string,
|
|
conversationId: string,
|
|
seenKeys = new Set<string>(),
|
|
): Promise<ParsedProviderCall[]> {
|
|
const provider = createWarpProvider(dbPath)
|
|
const source = {
|
|
path: `${dbPath}:${conversationId}`,
|
|
project: 'warp',
|
|
provider: 'warp',
|
|
}
|
|
const calls: ParsedProviderCall[] = []
|
|
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
|
calls.push(call)
|
|
}
|
|
return calls
|
|
}
|
|
|
|
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
|
|
|
|
skipUnlessSqlite('warp provider', () => {
|
|
it('discovers sessions and sanitizes project names from working_directory', async () => {
|
|
const dbPath = createWarpDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertConversation(db, 'conv-1', { conversation_usage_metadata: { token_usage: [] } })
|
|
insertQuery(db, {
|
|
exchangeId: 'ex-1',
|
|
conversationId: 'conv-1',
|
|
startTs: '2026-05-18 10:00:00.000000',
|
|
input: '[]',
|
|
workingDirectory: '/Users/test/project-a',
|
|
})
|
|
})
|
|
|
|
const provider = createWarpProvider(dbPath)
|
|
const sessions = await provider.discoverSessions()
|
|
expect(sessions).toHaveLength(1)
|
|
expect(sessions[0]!.provider).toBe('warp')
|
|
expect(sessions[0]!.path).toBe(`${dbPath}:conv-1`)
|
|
expect(sessions[0]!.project).toBe('Users-test-project-a')
|
|
})
|
|
|
|
it('parses one call per completed exchange and estimates tokens from primary-agent totals', async () => {
|
|
const dbPath = createWarpDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertConversation(db, 'conv-1', {
|
|
conversation_usage_metadata: {
|
|
token_usage: [
|
|
{
|
|
model_id: 'GPT-5.3 Codex (medium reasoning)',
|
|
warp_tokens: 300,
|
|
byok_tokens: 0,
|
|
warp_token_usage_by_category: { primary_agent: 300 },
|
|
byok_token_usage_by_category: {},
|
|
},
|
|
{
|
|
model_id: 'Claude Haiku 4.5',
|
|
warp_tokens: 90,
|
|
byok_tokens: 0,
|
|
warp_token_usage_by_category: { full_terminal_use: 90 },
|
|
byok_token_usage_by_category: {},
|
|
},
|
|
],
|
|
},
|
|
})
|
|
insertQuery(db, {
|
|
exchangeId: 'ex-1',
|
|
conversationId: 'conv-1',
|
|
startTs: '2026-05-18 10:00:00.000000',
|
|
input: JSON.stringify([{ Query: { text: 'short prompt' } }]),
|
|
modelId: 'auto-efficient',
|
|
workingDirectory: '/Users/test/project-a',
|
|
})
|
|
insertQuery(db, {
|
|
exchangeId: 'ex-2',
|
|
conversationId: 'conv-1',
|
|
startTs: '2026-05-18 10:03:00.000000',
|
|
input: JSON.stringify([{ Query: { text: 'longer prompt with substantially more detail for weighting' } }]),
|
|
modelId: 'auto-efficient',
|
|
workingDirectory: '/Users/test/project-a',
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(dbPath, 'conv-1')
|
|
expect(calls).toHaveLength(2)
|
|
expect(calls.map(call => call.deduplicationKey)).toEqual([
|
|
'warp:conv-1:ex-1',
|
|
'warp:conv-1:ex-2',
|
|
])
|
|
expect(calls.map(call => call.userMessage)).toEqual([
|
|
'short prompt',
|
|
'longer prompt with substantially more detail for weighting',
|
|
])
|
|
expect(calls.every(call => call.model === 'gpt-5.3-codex')).toBe(true)
|
|
expect(calls.every(call => call.costIsEstimated === true)).toBe(true)
|
|
expect(calls.reduce((sum, call) => sum + call.inputTokens, 0)).toBe(300)
|
|
expect(calls[1]!.inputTokens).toBeGreaterThan(calls[0]!.inputTokens)
|
|
expect(calls[0]!.projectPath).toBe('/Users/test/project-a')
|
|
expect(calls[0]!.project).toBe('Users-test-project-a')
|
|
})
|
|
|
|
it('attributes command blocks to the nearest preceding exchange and extracts Bash commands', async () => {
|
|
const dbPath = createWarpDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertConversation(db, 'conv-2', {
|
|
conversation_usage_metadata: {
|
|
token_usage: [
|
|
{
|
|
model_id: 'GPT-5.3 Codex (medium reasoning)',
|
|
warp_tokens: 120,
|
|
byok_tokens: 0,
|
|
warp_token_usage_by_category: { primary_agent: 120 },
|
|
byok_token_usage_by_category: {},
|
|
},
|
|
],
|
|
},
|
|
})
|
|
insertQuery(db, {
|
|
exchangeId: 'ex-a',
|
|
conversationId: 'conv-2',
|
|
startTs: '2026-05-18 11:00:00.000000',
|
|
input: JSON.stringify([{ Query: { text: 'run tests' } }]),
|
|
})
|
|
insertQuery(db, {
|
|
exchangeId: 'ex-b',
|
|
conversationId: 'conv-2',
|
|
startTs: '2026-05-18 11:05:00.000000',
|
|
input: JSON.stringify([{ Query: { text: 'summarize results' } }]),
|
|
})
|
|
insertBlock(db, {
|
|
blockId: 'block-1',
|
|
conversationId: 'conv-2',
|
|
startTs: '2026-05-18 11:01:00.000000',
|
|
completedTs: '2026-05-18 11:01:04.000000',
|
|
exitCode: 0,
|
|
command: 'npm test && git status',
|
|
})
|
|
})
|
|
|
|
const calls = await collectCalls(dbPath, 'conv-2')
|
|
expect(calls).toHaveLength(2)
|
|
expect(calls[0]!.tools).toEqual(['Bash'])
|
|
expect(calls[0]!.bashCommands).toEqual(['npm', 'git'])
|
|
expect(calls[1]!.tools).toEqual([])
|
|
expect(calls[1]!.bashCommands).toEqual([])
|
|
})
|
|
|
|
it('skips pending or invalid exchanges and does not poison seenKeys for skipped rows', async () => {
|
|
const dbPath = createWarpDb(tmpDir)
|
|
withTestDb(dbPath, (db) => {
|
|
insertConversation(db, 'conv-3', {
|
|
conversation_usage_metadata: {
|
|
token_usage: [
|
|
{
|
|
model_id: 'GPT-5.3 Codex (medium reasoning)',
|
|
warp_tokens: 42,
|
|
byok_tokens: 0,
|
|
warp_token_usage_by_category: { primary_agent: 42 },
|
|
byok_token_usage_by_category: {},
|
|
},
|
|
],
|
|
},
|
|
})
|
|
insertQuery(db, {
|
|
exchangeId: 'ex-skip-seen',
|
|
conversationId: 'conv-3',
|
|
startTs: '2026-05-18 12:00:00.000000',
|
|
input: JSON.stringify([{ Query: { text: 'already seen' } }]),
|
|
})
|
|
insertQuery(db, {
|
|
exchangeId: 'ex-pending',
|
|
conversationId: 'conv-3',
|
|
startTs: '2026-05-18 12:01:00.000000',
|
|
input: JSON.stringify([{ Query: { text: 'still running' } }]),
|
|
outputStatus: '"Pending"',
|
|
})
|
|
insertQuery(db, {
|
|
exchangeId: 'ex-invalid-ts',
|
|
conversationId: 'conv-3',
|
|
startTs: 'not-a-timestamp',
|
|
input: JSON.stringify([{ Query: { text: 'bad timestamp' } }]),
|
|
})
|
|
})
|
|
|
|
const seen = new Set<string>(['warp:conv-3:ex-skip-seen'])
|
|
const calls = await collectCalls(dbPath, 'conv-3', seen)
|
|
expect(calls).toEqual([])
|
|
expect(seen.has('warp:conv-3:ex-invalid-ts')).toBe(false)
|
|
expect(seen.has('warp:conv-3:ex-pending')).toBe(false)
|
|
})
|
|
})
|