mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-09 17:19:15 +00:00
feat(providers): add Grok Build provider (#521)
Adds Grok Build (xAI coding CLI) as an eager provider with caching- and compaction-aware token estimation, real tool/shell extraction, Skills & Agents from spawn_subagent, grok-build pricing, and SuperGrok plan presets. Tested against real sessions; full suite green.
This commit is contained in:
parent
81ada9c9b8
commit
ebfb1de0cb
11 changed files with 542 additions and 9 deletions
|
|
@ -28,6 +28,7 @@ For the architectural picture, see `../architecture.md`.
|
|||
| [Qwen](qwen.md) | JSONL | `src/providers/qwen.ts` | none |
|
||||
| [Roo Code](roo-code.md) | JSON | `src/providers/roo-code.ts` | `tests/providers/roo-code.test.ts` |
|
||||
| [Zerostack](zerostack.md) | JSON | `src/providers/zerostack.ts` | `tests/providers/zerostack.test.ts` |
|
||||
| [Grok Build](grok.md) | JSON/JSONL | `src/providers/grok.ts` | `tests/providers/grok.test.ts` |
|
||||
|
||||
### Lazy (loaded on first call)
|
||||
|
||||
|
|
|
|||
45
docs/providers/grok.md
Normal file
45
docs/providers/grok.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Grok Build
|
||||
|
||||
Grok Build, xAI's coding CLI. Sessions use the `grok-build` model by default.
|
||||
|
||||
- **Source:** `src/providers/grok.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/grok.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$GROK_HOME/sessions/` (or `~/.grok/sessions/`), one directory per session:
|
||||
`sessions/<url-encoded-cwd>/<uuid>/`. The parser reads `summary.json`, `signals.json`, and `updates.jsonl` from each session directory.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSON + JSONL. `summary.json` holds the session id, cwd, timestamps, and `current_model_id`. `signals.json` holds `modelsUsed`, `toolsUsed`, and `contextTokensUsed`. `updates.jsonl` is the ACP log: each streamed chunk carries `params._meta.totalTokens` (running context size) and `params._meta.promptId` (one per turn).
|
||||
|
||||
## Token model
|
||||
|
||||
**Estimated.** Grok does not log billable input/output tokens. It only records the running context fill (`totalTokens` per chunk, and `contextTokensUsed` in signals). The parser reconstructs a rough estimate from the per-turn `totalTokens` curve: input is the context entering each turn, output is the context growth during it. The result is flagged `costIsEstimated` and re-priced with `calculateCost`.
|
||||
|
||||
## Pricing
|
||||
|
||||
`grok-build` is aliased to `grok-build-0.1` in `src/models.ts`, so it prices off the bundled LiteLLM fallback. Note that xAI's published API rate and the LiteLLM fallback figure differ, so treat the cost as an estimate and verify against your xAI usage console.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `grok:<session-dir>:<updated_at>:<id>`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **No cache or output/tool-token split.** Only context fill is available, so cache fields are `0` and the cost is an estimate (likely an upper bound, since re-sent context is cached server-side and not exposed in the session files).
|
||||
- **No bash-command capture.** Tool names come from `signals.toolsUsed`; per-command bash text is not extracted, so `bashCommands` is empty.
|
||||
- **Whole-session timestamp.** Spend is attributed to `updated_at`, since the context curve is cumulative.
|
||||
- **Subscription vs API.** Grok Build runs via either a metered xAI API account (tiered) or a SuperGrok subscription; the session files do not record which.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Discovery: check the `sessions/<cwd>/<uuid>/` walk and the `GROK_HOME` resolution.
|
||||
2. Token estimate: see `estimateTokens` (groups `updates.jsonl` by `promptId`).
|
||||
3. Add a fixture-format session under `tests/providers/grok.test.ts`; do not mock the filesystem.
|
||||
|
|
@ -4,8 +4,8 @@ import { homedir } from 'os'
|
|||
import { randomBytes } from 'crypto'
|
||||
import { PLAN_PROVIDERS } from './plans.js'
|
||||
|
||||
export type PlanId = 'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'custom' | 'none'
|
||||
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'all'
|
||||
export type PlanId = 'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'supergrok' | 'supergrok-heavy' | 'custom' | 'none'
|
||||
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'grok' | 'all'
|
||||
|
||||
export type Plan = {
|
||||
id: PlanId
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ const BUILTIN_ALIASES: Record<string, string> = {
|
|||
'openclaw-auto': 'claude-sonnet-4-5',
|
||||
'warp-auto-efficient': 'gpt-5.3-codex',
|
||||
'warp-auto-powerful': 'claude-opus-4-6',
|
||||
'grok-build': 'grok-build-0.1',
|
||||
'GPT-5.3 Codex (low reasoning)': 'gpt-5.3-codex',
|
||||
'GPT-5.3 Codex (medium reasoning)': 'gpt-5.3-codex',
|
||||
'GPT-5.3 Codex (high reasoning)': 'gpt-5.3-codex',
|
||||
|
|
|
|||
|
|
@ -1696,7 +1696,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
|
|||
tools,
|
||||
mcpTools: extractMcpTools(tools),
|
||||
skills: [],
|
||||
subagentTypes: [],
|
||||
subagentTypes: call.subagentTypes ?? [],
|
||||
hasAgentSpawn: tools.includes('Agent'),
|
||||
hasPlanMode: tools.includes('EnterPlanMode'),
|
||||
speed: call.speed,
|
||||
|
|
@ -1735,7 +1735,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
|
|||
tools: call.tools,
|
||||
bashCommands: call.bashCommands,
|
||||
skills: [],
|
||||
subagentTypes: [],
|
||||
subagentTypes: call.subagentTypes ?? [],
|
||||
deduplicationKey: call.deduplicationKey,
|
||||
project: call.project,
|
||||
projectPath: call.projectPath,
|
||||
|
|
|
|||
22
src/plans.ts
22
src/plans.ts
|
|
@ -1,9 +1,9 @@
|
|||
import type { Plan, PlanId, PlanProvider } from './config.js'
|
||||
|
||||
export const PLAN_PROVIDERS: PlanProvider[] = ['all', 'claude', 'codex', 'cursor']
|
||||
export const PLAN_IDS: PlanId[] = ['claude-pro', 'claude-max', 'claude-max-5x', 'cursor-pro', 'custom', 'none']
|
||||
export const PLAN_PROVIDERS: PlanProvider[] = ['all', 'claude', 'codex', 'cursor', 'grok']
|
||||
export const PLAN_IDS: PlanId[] = ['claude-pro', 'claude-max', 'claude-max-5x', 'cursor-pro', 'supergrok', 'supergrok-heavy', 'custom', 'none']
|
||||
|
||||
export const PRESET_PLANS: Record<'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro', Omit<Plan, 'setAt'>> = {
|
||||
export const PRESET_PLANS: Record<'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'supergrok' | 'supergrok-heavy', Omit<Plan, 'setAt'>> = {
|
||||
'claude-pro': {
|
||||
id: 'claude-pro',
|
||||
monthlyUsd: 20,
|
||||
|
|
@ -28,6 +28,18 @@ export const PRESET_PLANS: Record<'claude-pro' | 'claude-max' | 'claude-max-5x'
|
|||
provider: 'cursor',
|
||||
resetDay: 1,
|
||||
},
|
||||
'supergrok': {
|
||||
id: 'supergrok',
|
||||
monthlyUsd: 30,
|
||||
provider: 'grok',
|
||||
resetDay: 1,
|
||||
},
|
||||
'supergrok-heavy': {
|
||||
id: 'supergrok-heavy',
|
||||
monthlyUsd: 300,
|
||||
provider: 'grok',
|
||||
resetDay: 1,
|
||||
},
|
||||
}
|
||||
|
||||
export function isPlanProvider(value: string): value is PlanProvider {
|
||||
|
|
@ -55,6 +67,10 @@ export function planDisplayName(id: PlanId): string {
|
|||
return 'Claude Max 5x'
|
||||
case 'cursor-pro':
|
||||
return 'Cursor Pro'
|
||||
case 'supergrok':
|
||||
return 'SuperGrok'
|
||||
case 'supergrok-heavy':
|
||||
return 'SuperGrok Heavy'
|
||||
case 'custom':
|
||||
return 'Custom'
|
||||
case 'none':
|
||||
|
|
|
|||
279
src/providers/grok.ts
Normal file
279
src/providers/grok.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { readdir, stat } from 'fs/promises'
|
||||
import { basename, dirname, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { readSessionFile } from '../fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
// Grok Build (xAI's coding CLI) stores one session per directory at
|
||||
// <grok-home>/sessions/<url-encoded-cwd>/<uuid>/, where grok-home is $GROK_HOME
|
||||
// or ~/.grok. Each session dir holds summary.json, signals.json, and the ACP
|
||||
// log updates.jsonl.
|
||||
//
|
||||
// Grok does NOT record billable input/output tokens. signals.json carries
|
||||
// `contextTokensUsed` (current context fill) and updates.jsonl carries a running
|
||||
// `_meta.totalTokens` per streamed chunk; there is no per-call input/output
|
||||
// split. We reconstruct an ESTIMATE from the per-turn totalTokens curve. Agentic
|
||||
// turns re-send the growing context every call, and that re-sent context is
|
||||
// cached server-side, so we bill the unique context (summed per compaction segment) as fresh input,
|
||||
// the re-sent remainder as cache reads, and the per-turn growth as output. Cost
|
||||
// is flagged estimated; grok-build is priced via its grok-build-0.1 alias.
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
bash: 'Bash',
|
||||
run_terminal_command: 'Bash',
|
||||
read_file: 'Read',
|
||||
read: 'Read',
|
||||
write_file: 'Write',
|
||||
edit_file: 'Edit',
|
||||
edit: 'Edit',
|
||||
list_dir: 'Glob',
|
||||
glob: 'Glob',
|
||||
grep: 'Grep',
|
||||
search: 'WebSearch',
|
||||
web_search: 'WebSearch',
|
||||
fetch: 'WebFetch',
|
||||
task: 'Agent',
|
||||
search_replace: 'Edit',
|
||||
todo_write: 'TodoWrite',
|
||||
spawn_subagent: 'Agent',
|
||||
}
|
||||
|
||||
function defaultSessionsDir(): string {
|
||||
const home = process.env['GROK_HOME'] ?? join(homedir(), '.grok')
|
||||
return join(home, 'sessions')
|
||||
}
|
||||
|
||||
type GrokSummary = {
|
||||
info?: { id?: string; cwd?: string }
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
last_active_at?: string
|
||||
current_model_id?: string
|
||||
session_summary?: string
|
||||
generated_title?: string
|
||||
}
|
||||
|
||||
type GrokSignals = {
|
||||
primaryModelId?: string
|
||||
modelsUsed?: string[]
|
||||
toolsUsed?: string[]
|
||||
}
|
||||
|
||||
async function readJson<T>(path: string): Promise<T | null> {
|
||||
const content = await readSessionFile(path)
|
||||
if (content === null) return null
|
||||
try {
|
||||
return JSON.parse(content) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeDecode(name: string): string {
|
||||
try {
|
||||
return decodeURIComponent(name)
|
||||
} catch {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// updates.jsonl is one ACP JSON-RPC notification per line; streamed chunks carry
|
||||
// params._meta.{totalTokens, promptId}. totalTokens is the running context size,
|
||||
// so grouping by promptId (one per turn) gives each turn's first/last value.
|
||||
type GrokUpdate = {
|
||||
params?: {
|
||||
_meta?: { totalTokens?: number; promptId?: string }
|
||||
update?: { sessionUpdate?: string; title?: string; rawInput?: { command?: unknown; subagent_type?: unknown } }
|
||||
}
|
||||
}
|
||||
|
||||
// Single pass over updates.jsonl: per-turn totalTokens for the cost estimate,
|
||||
// plus the real tool calls (each tool_call's title -> a tool, and
|
||||
// run_terminal_command's rawInput.command -> shell commands).
|
||||
function parseUpdates(updates: string): {
|
||||
input: number
|
||||
cacheRead: number
|
||||
output: number
|
||||
tools: string[]
|
||||
bashCommands: string[]
|
||||
subagentTypes: string[]
|
||||
} {
|
||||
const turns = new Map<string, { first: number; last: number }>()
|
||||
const tools: string[] = []
|
||||
const bashCommands: string[] = []
|
||||
const subagentTypes: string[] = []
|
||||
// Compaction-aware fresh input: a large drop in totalTokens means the context
|
||||
// was compacted and rebuilt, so we sum each segment's peak rather than the
|
||||
// single global peak (which would lose everything before the last compaction).
|
||||
let prevTotal = -1
|
||||
let segmentPeak = 0
|
||||
let inputFresh = 0
|
||||
|
||||
for (const line of updates.split('\n')) {
|
||||
if (!line.trim()) continue
|
||||
let params: GrokUpdate['params']
|
||||
try {
|
||||
params = (JSON.parse(line) as GrokUpdate).params
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!params) continue
|
||||
|
||||
const total = params._meta?.totalTokens
|
||||
if (typeof total === 'number') {
|
||||
if (prevTotal >= 0 && total < prevTotal * 0.5) {
|
||||
inputFresh += segmentPeak // close the segment a compaction just ended
|
||||
segmentPeak = 0
|
||||
}
|
||||
if (total > segmentPeak) segmentPeak = total
|
||||
prevTotal = total
|
||||
|
||||
const promptId = params._meta?.promptId
|
||||
if (promptId) {
|
||||
const turn = turns.get(promptId)
|
||||
if (!turn) turns.set(promptId, { first: total, last: total })
|
||||
else turn.last = total
|
||||
}
|
||||
}
|
||||
|
||||
const update = params.update
|
||||
if (update?.sessionUpdate === 'tool_call' && typeof update.title === 'string') {
|
||||
tools.push(toolNameMap[update.title] ?? update.title)
|
||||
if (update.title === 'run_terminal_command' && typeof update.rawInput?.command === 'string') {
|
||||
bashCommands.push(...extractBashCommands(update.rawInput.command))
|
||||
}
|
||||
if (update.title === 'spawn_subagent' && typeof update.rawInput?.subagent_type === 'string') {
|
||||
subagentTypes.push(update.rawInput.subagent_type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inputFresh += segmentPeak // close the final segment
|
||||
let sumFirst = 0
|
||||
let output = 0
|
||||
for (const { first, last } of turns.values()) {
|
||||
sumFirst += first
|
||||
output += Math.max(0, last - first)
|
||||
}
|
||||
// Fresh input (summed segment peaks) is billed once; the rest of the per-turn
|
||||
// re-sends are cache reads (Grok caches them, even though it reports nothing).
|
||||
const cacheRead = Math.max(0, sumFirst - inputFresh)
|
||||
return { input: inputFresh, cacheRead, output, tools, bashCommands, subagentTypes }
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const dir = dirname(source.path)
|
||||
const summary = await readJson<GrokSummary>(join(dir, 'summary.json'))
|
||||
const updates = await readSessionFile(source.path)
|
||||
if (!summary || updates === null) return
|
||||
|
||||
const { input, cacheRead, output, tools, bashCommands, subagentTypes } = parseUpdates(updates)
|
||||
if (input === 0 && output === 0) return
|
||||
|
||||
const signals = await readJson<GrokSignals>(join(dir, 'signals.json'))
|
||||
const model =
|
||||
summary.current_model_id ?? signals?.primaryModelId ?? signals?.modelsUsed?.[0] ?? 'grok-build'
|
||||
const timestamp = summary.updated_at ?? summary.last_active_at ?? summary.created_at ?? ''
|
||||
const sessionId = summary.info?.id ?? basename(dir)
|
||||
|
||||
const dedupKey = `${source.provider}:${dir}:${timestamp}:${sessionId}`
|
||||
if (seenKeys.has(dedupKey)) return
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
yield {
|
||||
provider: source.provider,
|
||||
model,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cachedInputTokens: cacheRead,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: calculateCost(model, input, output, 0, cacheRead, 0),
|
||||
costIsEstimated: true,
|
||||
tools,
|
||||
bashCommands,
|
||||
subagentTypes,
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: summary.session_summary ?? summary.generated_title ?? '',
|
||||
sessionId,
|
||||
project: source.project,
|
||||
projectPath: summary.info?.cwd,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverSessions(sessionsDir: string): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
|
||||
let cwdDirs: string[]
|
||||
try {
|
||||
cwdDirs = await readdir(sessionsDir)
|
||||
} catch {
|
||||
return sources
|
||||
}
|
||||
|
||||
for (const cwdName of cwdDirs) {
|
||||
const cwdPath = join(sessionsDir, cwdName)
|
||||
const cwdStat = await stat(cwdPath).catch(() => null)
|
||||
if (!cwdStat?.isDirectory()) continue
|
||||
|
||||
let sessionDirs: string[]
|
||||
try {
|
||||
sessionDirs = await readdir(cwdPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const sessionName of sessionDirs) {
|
||||
const sessionPath = join(cwdPath, sessionName)
|
||||
const sessionStat = await stat(sessionPath).catch(() => null)
|
||||
if (!sessionStat?.isDirectory()) continue
|
||||
|
||||
const summary = await readJson<GrokSummary>(join(sessionPath, 'summary.json'))
|
||||
if (!summary) continue
|
||||
|
||||
const cwd = summary.info?.cwd ?? safeDecode(cwdName)
|
||||
sources.push({ path: join(sessionPath, 'updates.jsonl'), project: basename(cwd), provider: 'grok' })
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
export function createGrokProvider(sessionsDir?: string): Provider {
|
||||
const dir = sessionsDir ?? defaultSessionsDir()
|
||||
|
||||
return {
|
||||
name: 'grok',
|
||||
displayName: 'Grok Build',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
if (model.startsWith('grok-build')) return 'Grok Build'
|
||||
return getShortModelName(model)
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return toolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSessions(dir)
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const grok = createGrokProvider()
|
||||
|
|
@ -17,6 +17,7 @@ import { pi, omp } from './pi.js'
|
|||
import { qwen } from './qwen.js'
|
||||
import { rooCode } from './roo-code.js'
|
||||
import { zerostack } from './zerostack.js'
|
||||
import { grok } from './grok.js'
|
||||
import type { Provider, SessionSource } from './types.js'
|
||||
|
||||
let antigravityProvider: Provider | null = null
|
||||
|
|
@ -153,7 +154,7 @@ async function loadCrush(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode, zerostack]
|
||||
const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode, zerostack, grok]
|
||||
|
||||
// Lazily loaded providers, listed by name so --provider validation works even
|
||||
// when an optional module fails to load. Must stay in sync with getAllProviders.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ export type ParsedProviderCall = {
|
|||
costIsEstimated?: boolean
|
||||
tools: string[]
|
||||
bashCommands: string[]
|
||||
// Subagent types spawned in this call (e.g. 'general-purpose'). Feeds the
|
||||
// Skills & Agents breakdown; optional since most providers don't expose it.
|
||||
subagentTypes?: string[]
|
||||
timestamp: string
|
||||
speed: 'standard' | 'fast'
|
||||
deduplicationKey: string
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { providers, getAllProviders, getProvider } from '../src/providers/index.
|
|||
|
||||
describe('provider registry', () => {
|
||||
it('has core providers registered synchronously', () => {
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'devin','droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code', 'zerostack'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'devin','droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code', 'zerostack', 'grok'])
|
||||
})
|
||||
|
||||
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {
|
||||
|
|
|
|||
187
tests/providers/grok.test.ts
Normal file
187
tests/providers/grok.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
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 { createGrokProvider } from '../../src/providers/grok.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'grok-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Mirrors the real on-disk layout:
|
||||
// <sessionsDir>/<url-encoded-cwd>/<uuid>/{summary.json, signals.json, updates.jsonl}
|
||||
async function writeSession(opts: {
|
||||
cwdEncoded?: string
|
||||
uuid?: string
|
||||
cwd?: string
|
||||
model?: string
|
||||
turns?: Array<{ promptId: string; totals: number[] }>
|
||||
toolCalls?: Array<{ title: string; rawInput: Record<string, unknown> }>
|
||||
toolsUsed?: string[]
|
||||
} = {}) {
|
||||
const cwdEncoded = opts.cwdEncoded ?? '%2FUsers%2Ftest'
|
||||
const uuid = opts.uuid ?? '019edf9c-0000-7000-8000-000000000001'
|
||||
const cwd = opts.cwd ?? '/Users/test/myproject'
|
||||
const model = opts.model ?? 'grok-build'
|
||||
const dir = join(tmpDir, cwdEncoded, uuid)
|
||||
await mkdir(dir, { recursive: true })
|
||||
|
||||
await writeFile(join(dir, 'summary.json'), JSON.stringify({
|
||||
info: { id: uuid, cwd },
|
||||
created_at: '2026-06-19T11:20:40.686261Z',
|
||||
updated_at: '2026-06-19T11:31:12.282793Z',
|
||||
last_active_at: '2026-06-19T11:31:12.222328Z',
|
||||
num_messages: 42,
|
||||
current_model_id: model,
|
||||
session_summary: 'User asks about the repo',
|
||||
generated_title: 'User asks about the repo',
|
||||
}))
|
||||
|
||||
await writeFile(join(dir, 'signals.json'), JSON.stringify({
|
||||
primaryModelId: model,
|
||||
modelsUsed: [model],
|
||||
toolsUsed: opts.toolsUsed ?? ['read_file', 'run_terminal_command', 'grep'],
|
||||
contextTokensUsed: 40000,
|
||||
contextWindowTokens: 512000,
|
||||
}))
|
||||
|
||||
const turns = opts.turns ?? [
|
||||
{ promptId: 'p1', totals: [20000, 25000] },
|
||||
{ promptId: 'p2', totals: [30000, 35000] },
|
||||
{ promptId: 'p3', totals: [40000, 45000] },
|
||||
]
|
||||
const lines: string[] = []
|
||||
for (const turn of turns) {
|
||||
for (const total of turn.totals) {
|
||||
lines.push(JSON.stringify({
|
||||
timestamp: '2026-06-19T11:30:00.000Z',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId: uuid,
|
||||
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } },
|
||||
_meta: { totalTokens: total, promptId: turn.promptId, updateType: 'AgentMessageChunk', modelId: model },
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
for (const tc of opts.toolCalls ?? [
|
||||
{ title: 'read_file', rawInput: { target_directory: '.' } },
|
||||
{ title: 'grep', rawInput: { pattern: 'x' } },
|
||||
{ title: 'run_terminal_command', rawInput: { command: 'git status' } },
|
||||
{ title: 'spawn_subagent', rawInput: { subagent_type: 'general-purpose', prompt: 'x' } },
|
||||
]) {
|
||||
lines.push(JSON.stringify({
|
||||
timestamp: '2026-06-19T11:30:05.000Z',
|
||||
method: 'session/update',
|
||||
params: { sessionId: uuid, update: { sessionUpdate: 'tool_call', toolCallId: 'c1', title: tc.title, rawInput: tc.rawInput } },
|
||||
}))
|
||||
}
|
||||
await writeFile(join(dir, 'updates.jsonl'), lines.join('\n') + '\n')
|
||||
|
||||
return { dir, uuid }
|
||||
}
|
||||
|
||||
describe('grok provider - discovery', () => {
|
||||
it('discovers each session dir and derives project from cwd', async () => {
|
||||
await writeSession({ cwd: '/Users/test/myproject' })
|
||||
const sessions = await createGrokProvider(tmpDir).discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('grok')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
expect(sessions[0]!.path).toMatch(/updates\.jsonl$/)
|
||||
})
|
||||
|
||||
it('returns empty for a non-existent sessions dir', async () => {
|
||||
const sessions = await createGrokProvider('/nope/does/not/exist').discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('skips directories without a summary.json', async () => {
|
||||
await mkdir(join(tmpDir, '%2Ftmp', 'not-a-session'), { recursive: true })
|
||||
const sessions = await createGrokProvider(tmpDir).discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('grok provider - parsing', () => {
|
||||
async function parse(seen = new Set<string>()) {
|
||||
const provider = createGrokProvider(tmpDir)
|
||||
const [source] = await provider.discoverSessions()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
if (!source) return calls
|
||||
for await (const call of provider.createSessionParser(source, seen).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
it('emits one estimated call per session from the totalTokens curve', async () => {
|
||||
await writeSession()
|
||||
const calls = await parse()
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.model).toBe('grok-build')
|
||||
// input = peak context (max totalTokens across the session)
|
||||
expect(call.inputTokens).toBe(45000)
|
||||
// cache reads = re-sent context (sum of per-turn starts 90000 minus peak 45000)
|
||||
expect(call.cacheReadInputTokens).toBe(45000)
|
||||
// output = sum of per-turn growth (3 turns x 5000)
|
||||
expect(call.outputTokens).toBe(15000)
|
||||
expect(call.costIsEstimated).toBe(true)
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
expect(call.tools).toEqual(['Read', 'Grep', 'Bash', 'Agent'])
|
||||
expect(call.bashCommands).toContain('git')
|
||||
expect(call.subagentTypes).toEqual(['general-purpose'])
|
||||
expect(call.project).toBe('myproject')
|
||||
expect(call.deduplicationKey).toContain('grok:')
|
||||
})
|
||||
|
||||
it('skips a session with no token growth', async () => {
|
||||
await writeSession({ turns: [{ promptId: 'p1', totals: [0, 0] }] })
|
||||
expect(await parse()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates across repeated parses', async () => {
|
||||
await writeSession()
|
||||
const seen = new Set<string>()
|
||||
expect(await parse(seen)).toHaveLength(1)
|
||||
expect(await parse(seen)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sums fresh input across a compaction instead of only the last peak', async () => {
|
||||
await writeSession({ turns: [
|
||||
{ promptId: 'p1', totals: [100000, 400000] },
|
||||
{ promptId: 'p2', totals: [20000, 50000] },
|
||||
] })
|
||||
const calls = await parse()
|
||||
expect(calls).toHaveLength(1)
|
||||
// 400k (segment 1 peak) + 50k (post-compaction segment), not just the 400k global peak
|
||||
expect(calls[0]!.inputTokens).toBe(450000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('grok provider - display names', () => {
|
||||
const provider = createGrokProvider('/tmp')
|
||||
|
||||
it('has the right name and displayName', () => {
|
||||
expect(provider.name).toBe('grok')
|
||||
expect(provider.displayName).toBe('Grok Build')
|
||||
})
|
||||
|
||||
it('labels grok-build', () => {
|
||||
expect(provider.modelDisplayName('grok-build')).toBe('Grok Build')
|
||||
})
|
||||
|
||||
it('normalizes tool names', () => {
|
||||
expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('mystery_tool')).toBe('mystery_tool')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue