Compare commits
7 Commits
main
...
2163ea45d2
| Author | SHA1 | Date | |
|---|---|---|---|
| 2163ea45d2 | |||
| e4790ac77e | |||
| f642e58070 | |||
| d666f7e08b | |||
| 9e7989671a | |||
| a8578beacd | |||
| 823333a4f5 |
@@ -18,11 +18,13 @@ Multi-AI adversarial code review tool. Multiple AI models independently review y
|
||||
| `claude-code` | CLI | Claude Code CLI (uses your subscription, no API key) |
|
||||
| `codex-cli` | CLI | OpenAI Codex CLI (uses your subscription, no API key) |
|
||||
| `gemini-cli` | CLI | Gemini CLI (uses Google account login, no API key) |
|
||||
| `opencode-cli` | CLI | OpenCode CLI — runs any model (typically via OpenRouter) as a code-aware agent (requires backing provider's API key) |
|
||||
| `qwen-code` | CLI | Alibaba Qwen Code CLI (uses OAuth login, no API key) |
|
||||
| `claude-*` | API | Anthropic API (requires ANTHROPIC_API_KEY) |
|
||||
| `gpt-*` | API | OpenAI API (requires OPENAI_API_KEY) |
|
||||
| `gemini-*` | API | Google Gemini API (requires GOOGLE_API_KEY) |
|
||||
| `minimax` | API | MiniMax API (requires MINIMAX_API_KEY) |
|
||||
| `openrouter/*` | API | OpenRouter API, OpenAI-compatible (requires OPENROUTER_API_KEY) |
|
||||
| `mock` | Debug | Mock provider for testing (no API key, see [Debug Mode](#debug-mode)) |
|
||||
|
||||
**Recommended**: Use CLI providers (claude-code, codex-cli, gemini-cli, qwen-code) - they're free with your subscriptions and don't require API keys.
|
||||
@@ -41,6 +43,47 @@ providers:
|
||||
base_url: https://my-proxy.example.com
|
||||
```
|
||||
|
||||
### OpenRouter
|
||||
|
||||
OpenRouter exposes hundreds of models through a single OpenAI-compatible API. Magpie routes any model whose ID starts with `openrouter/` through OpenRouter:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
openrouter:
|
||||
api_key: ${OPENROUTER_API_KEY}
|
||||
# base_url: https://openrouter.ai/api/v1 # optional, this is the default
|
||||
|
||||
reviewers:
|
||||
sonnet:
|
||||
model: openrouter/anthropic/claude-3.5-sonnet
|
||||
prompt: |
|
||||
...
|
||||
llama:
|
||||
model: openrouter/meta-llama/llama-3-70b-instruct
|
||||
prompt: |
|
||||
...
|
||||
```
|
||||
|
||||
The portion after `openrouter/` is sent to OpenRouter verbatim, so use any model ID listed at https://openrouter.ai/models.
|
||||
|
||||
### OpenCode CLI
|
||||
|
||||
Models routed through `openrouter/*` reach the model purely as a chat completion — the reviewer sees only the diff and prompt and cannot read source files. To get a code-aware agent on top of OpenRouter (or any other backing provider), use the `opencode-cli` provider, which wraps the [OpenCode](https://opencode.ai/) CLI:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
openrouter:
|
||||
api_key: ${OPENROUTER_API_KEY}
|
||||
|
||||
reviewers:
|
||||
sonnet-agent:
|
||||
model: opencode-cli:openrouter/anthropic/claude-sonnet-4
|
||||
prompt: |
|
||||
...
|
||||
```
|
||||
|
||||
The portion after `opencode-cli:` is passed verbatim to opencode's `-m provider/model` flag. Reviewers run with a read-only tool allowlist (Read, Grep, Glob, plus `gh`/`git`/`rg`) — matching the claude-code provider's permissions. API keys from `providers.openrouter.api_key` (and `anthropic`/`openai`/`google` if configured) are forwarded into opencode's environment, so you don't need a second copy of your keys.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
@@ -486,6 +529,13 @@ While waiting for AI reviewers, enjoy programmer jokes:
|
||||
⠋ claude is thinking... | Why do programmers confuse Halloween and Christmas? Because Oct 31 = Dec 25
|
||||
```
|
||||
|
||||
Disable them via config if you prefer a quieter spinner:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
show_jokes: false
|
||||
```
|
||||
|
||||
### Post-Review Discussion Phase (Interactive Mode)
|
||||
|
||||
In interactive mode (`-i`), after the debate concludes, you can enter a **discussion phase** to chat with any role (reviewers, analyzer, or summarizer) before the comment posting step:
|
||||
|
||||
@@ -231,6 +231,7 @@ async function runDiscussion(
|
||||
const isSoloDiscussion = reviewers.length === 1
|
||||
const maxRounds = isSoloDiscussion ? 1 : parseInt(options.rounds, 10)
|
||||
const checkConvergence = !isSoloDiscussion && options.converge !== false && (config.defaults.check_convergence !== false)
|
||||
const showJokes = config.defaults.show_jokes !== false
|
||||
|
||||
const summarizer: Reviewer = {
|
||||
id: 'summarizer',
|
||||
@@ -322,29 +323,30 @@ async function runDiscussion(
|
||||
`${reviewerId} is thinking`
|
||||
|
||||
const updateSpinner = () => {
|
||||
const joke = getRandomJoke()
|
||||
if (spinnerRef.spinner) {
|
||||
if (!spinnerRef.spinner) return
|
||||
const jokeSuffix = showJokes ? ` ${chalk.dim(`| ${getRandomJoke()}`)}` : ''
|
||||
if (spinnerRef.parallelStatuses && isParallelRound) {
|
||||
const round = parseInt(reviewerId.split('-')[1])
|
||||
const statusLine = formatParallelStatus(round, spinnerRef.parallelStatuses)
|
||||
spinnerRef.spinner.text = `${statusLine} ${chalk.dim(`| ${joke}`)}`
|
||||
spinnerRef.spinner.text = `${statusLine}${jokeSuffix}`
|
||||
} else {
|
||||
spinnerRef.spinner.text = `${baseLabel}... ${chalk.dim(`| ${joke}`)}`
|
||||
}
|
||||
spinnerRef.spinner.text = `${baseLabel}...${jokeSuffix}`
|
||||
}
|
||||
}
|
||||
|
||||
spinnerRef.parallelStatuses = null
|
||||
spinnerRef.spinner = ora({ text: `${baseLabel}...`, discardStdin: false }).start()
|
||||
updateSpinner()
|
||||
if (showJokes) {
|
||||
spinnerRef.interval = setInterval(updateSpinner, 8000)
|
||||
}
|
||||
},
|
||||
onParallelStatus: (round, statuses) => {
|
||||
spinnerRef.parallelStatuses = statuses
|
||||
if (spinnerRef.spinner) {
|
||||
const joke = getRandomJoke()
|
||||
const jokeSuffix = showJokes ? ` ${chalk.dim(`| ${getRandomJoke()}`)}` : ''
|
||||
const statusLine = formatParallelStatus(round, statuses)
|
||||
spinnerRef.spinner.text = `${statusLine} ${chalk.dim(`| ${joke}`)}`
|
||||
spinnerRef.spinner.text = `${statusLine}${jokeSuffix}`
|
||||
}
|
||||
},
|
||||
onMessage: (reviewerId, chunk) => {
|
||||
|
||||
@@ -79,6 +79,7 @@ export const initCommand = new Command('init')
|
||||
if (r.provider === 'anthropic') envVars.add('ANTHROPIC_API_KEY')
|
||||
if (r.provider === 'openai') envVars.add('OPENAI_API_KEY')
|
||||
if (r.provider === 'google') envVars.add('GOOGLE_API_KEY')
|
||||
if (r.provider === 'openrouter') envVars.add('OPENROUTER_API_KEY')
|
||||
})
|
||||
envVars.forEach(v => console.log(` - ${v}`))
|
||||
}
|
||||
|
||||
@@ -413,6 +413,7 @@ export const reviewCommand = new Command('review')
|
||||
const maxRounds = isSoloReview ? 1 : parseInt(options.rounds, 10)
|
||||
// Convergence: disable for solo review; otherwise default from config, CLI can override with --no-converge
|
||||
const checkConvergence = !isSoloReview && options.converge !== false && (config.defaults.check_convergence !== false)
|
||||
const showJokes = config.defaults.show_jokes !== false
|
||||
|
||||
console.log()
|
||||
console.log(chalk.bgBlue.white.bold(` ${target.label} Review `))
|
||||
@@ -493,31 +494,31 @@ export const reviewCommand = new Command('review')
|
||||
|
||||
// Show spinner with a joke (and parallel status if available)
|
||||
const updateSpinner = () => {
|
||||
const joke = getRandomJoke()
|
||||
if (spinnerRef.spinner) {
|
||||
if (!spinnerRef.spinner) return
|
||||
const jokeSuffix = showJokes ? ` ${chalk.dim(`| ${getRandomJoke()}`)}` : ''
|
||||
if (spinnerRef.parallelStatuses && isParallelRound) {
|
||||
const round = parseInt(reviewerId.split('-')[1])
|
||||
const statusLine = formatParallelStatus(round, spinnerRef.parallelStatuses)
|
||||
spinnerRef.spinner.text = `${statusLine} ${chalk.dim(`| ${joke}`)}`
|
||||
spinnerRef.spinner.text = `${statusLine}${jokeSuffix}`
|
||||
} else {
|
||||
spinnerRef.spinner.text = `${baseLabel}... ${chalk.dim(`| ${joke}`)}`
|
||||
}
|
||||
spinnerRef.spinner.text = `${baseLabel}...${jokeSuffix}`
|
||||
}
|
||||
}
|
||||
|
||||
spinnerRef.parallelStatuses = null // Reset for new waiting phase
|
||||
spinnerRef.spinner = ora({ text: `${baseLabel}...`, discardStdin: false }).start()
|
||||
updateSpinner()
|
||||
// Update joke every 15 seconds
|
||||
if (showJokes) {
|
||||
spinnerRef.interval = setInterval(updateSpinner, 15000)
|
||||
}
|
||||
},
|
||||
onParallelStatus: (round, statuses) => {
|
||||
spinnerRef.parallelStatuses = statuses
|
||||
// Immediately update spinner to show new status
|
||||
if (spinnerRef.spinner) {
|
||||
const joke = getRandomJoke()
|
||||
const jokeSuffix = showJokes ? ` ${chalk.dim(`| ${getRandomJoke()}`)}` : ''
|
||||
const statusLine = formatParallelStatus(round, statuses)
|
||||
spinnerRef.spinner.text = `${statusLine} ${chalk.dim(`| ${joke}`)}`
|
||||
spinnerRef.spinner.text = `${statusLine}${jokeSuffix}`
|
||||
}
|
||||
},
|
||||
onMessage: (reviewerId, chunk) => {
|
||||
|
||||
+26
-2
@@ -9,7 +9,7 @@ export interface ReviewerOption {
|
||||
model: string
|
||||
description: string
|
||||
needsApiKey: boolean
|
||||
provider?: 'anthropic' | 'openai' | 'google'
|
||||
provider?: 'anthropic' | 'openai' | 'google' | 'openrouter'
|
||||
}
|
||||
|
||||
export const AVAILABLE_REVIEWERS: ReviewerOption[] = [
|
||||
@@ -34,6 +34,14 @@ export const AVAILABLE_REVIEWERS: ReviewerOption[] = [
|
||||
description: 'Uses your Gemini CLI (Google account, no API key needed)',
|
||||
needsApiKey: false
|
||||
},
|
||||
{
|
||||
id: 'opencode-cli',
|
||||
name: 'OpenCode (via OpenRouter)',
|
||||
model: 'opencode-cli:openrouter/anthropic/claude-3.5-sonnet',
|
||||
description: 'Runs any OpenRouter model as a code-aware agent via the OpenCode CLI (requires OPENROUTER_API_KEY)',
|
||||
needsApiKey: true,
|
||||
provider: 'openrouter'
|
||||
},
|
||||
{
|
||||
id: 'claude-api',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
@@ -57,6 +65,14 @@ export const AVAILABLE_REVIEWERS: ReviewerOption[] = [
|
||||
description: 'Uses Google AI API (requires GOOGLE_API_KEY)',
|
||||
needsApiKey: true,
|
||||
provider: 'google'
|
||||
},
|
||||
{
|
||||
id: 'openrouter',
|
||||
name: 'OpenRouter (Claude 3.5 Sonnet)',
|
||||
model: 'openrouter/anthropic/claude-3.5-sonnet',
|
||||
description: 'Uses OpenRouter API (requires OPENROUTER_API_KEY). Change the model field to any OpenRouter-supported ID.',
|
||||
needsApiKey: true,
|
||||
provider: 'openrouter'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -98,6 +114,7 @@ export function generateConfig(selectedReviewerIds: string[]): string {
|
||||
const needsAnthropic = selectedReviewers.some(r => r.provider === 'anthropic')
|
||||
const needsOpenai = selectedReviewers.some(r => r.provider === 'openai')
|
||||
const needsGoogle = selectedReviewers.some(r => r.provider === 'google')
|
||||
const needsOpenrouter = selectedReviewers.some(r => r.provider === 'openrouter')
|
||||
|
||||
// Build providers section
|
||||
let providersSection = '# AI Provider API Keys (use environment variables)\nproviders:'
|
||||
@@ -116,7 +133,13 @@ export function generateConfig(selectedReviewerIds: string[]): string {
|
||||
google:
|
||||
api_key: \${GOOGLE_API_KEY}`
|
||||
}
|
||||
if (!needsAnthropic && !needsOpenai && !needsGoogle) {
|
||||
if (needsOpenrouter) {
|
||||
providersSection += `
|
||||
openrouter:
|
||||
api_key: \${OPENROUTER_API_KEY}
|
||||
# base_url: https://openrouter.ai/api/v1 # optional, this is the default`
|
||||
}
|
||||
if (!needsAnthropic && !needsOpenai && !needsGoogle && !needsOpenrouter) {
|
||||
providersSection += ' {}' // Empty providers if only CLI tools are used
|
||||
}
|
||||
|
||||
@@ -142,6 +165,7 @@ defaults:
|
||||
max_rounds: 5
|
||||
output_format: markdown
|
||||
check_convergence: true # Stop early when reviewers reach consensus
|
||||
show_jokes: true # Show rotating programmer jokes in the spinner while waiting
|
||||
|
||||
${reviewersSection}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface DefaultsConfig {
|
||||
check_convergence: boolean
|
||||
language?: string // Output language (e.g., 'zh', 'en', 'ja')
|
||||
diff_exclude?: string[] // Glob patterns for files to exclude from diff (e.g., '*.pb.go', '*generated*')
|
||||
show_jokes?: boolean // Show rotating programmer jokes in spinner text while waiting (default: true)
|
||||
}
|
||||
|
||||
export interface ContextGathererConfigOptions {
|
||||
@@ -41,8 +42,10 @@ export interface MagpieConfig {
|
||||
google?: ProviderConfig
|
||||
'claude-code'?: { enabled: boolean }
|
||||
'codex-cli'?: { enabled: boolean }
|
||||
'opencode-cli'?: { enabled: boolean }
|
||||
'qwen-code'?: { enabled: boolean }
|
||||
minimax?: ProviderConfig
|
||||
openrouter?: ProviderConfig
|
||||
}
|
||||
mock?: boolean
|
||||
defaults: DefaultsConfig
|
||||
|
||||
@@ -4,6 +4,12 @@ import { CliSessionHelper } from './session-helper.js'
|
||||
import { preparePromptForCli } from '../utils/prompt-file.js'
|
||||
import { withRetry } from '../utils/retry.js'
|
||||
|
||||
// Tools magpie reviewers are pre-approved to use without prompting.
|
||||
// Read-only file/code access plus the specific Bash commands needed
|
||||
// to inspect PRs (gh), git history, and search (rg). General Bash,
|
||||
// Edit, and Write are intentionally NOT included.
|
||||
const ALLOWED_TOOLS = 'Read,Grep,Glob,Bash(gh:*),Bash(git:*),Bash(rg:*)'
|
||||
|
||||
export class ClaudeCodeProvider implements AIProvider {
|
||||
name = 'claude-code'
|
||||
private cwd: string
|
||||
@@ -74,8 +80,7 @@ export class ClaudeCodeProvider implements AIProvider {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Build args based on session state
|
||||
// Use --dangerously-skip-permissions to allow network access (e.g., gh commands)
|
||||
const args = ['-p', '-', '--dangerously-skip-permissions', '--effort', 'max']
|
||||
const args = ['-p', '-', '--effort', 'xhigh', '--allowed-tools', ALLOWED_TOOLS]
|
||||
if (this.cliModel) {
|
||||
args.push('--model', this.cliModel)
|
||||
}
|
||||
@@ -137,12 +142,11 @@ export class ClaudeCodeProvider implements AIProvider {
|
||||
private async *runClaudeStream(prompt: string, systemPrompt?: string): AsyncGenerator<string, void, unknown> {
|
||||
const { prompt: stdinPrompt, cleanup } = preparePromptForCli(prompt)
|
||||
|
||||
// Build args based on session state
|
||||
// Use --dangerously-skip-permissions to allow network access (e.g., gh commands)
|
||||
// Build args based on session state.
|
||||
// Use --output-format stream-json --verbose so that tool activity (Read, Bash, etc.)
|
||||
// produces stdout events, preventing the inactivity timeout from killing Claude
|
||||
// while it's actively investigating code.
|
||||
const args = ['-p', '-', '--dangerously-skip-permissions', '--effort', 'max', '--output-format', 'stream-json', '--verbose']
|
||||
const args = ['-p', '-', '--allowed-tools', ALLOWED_TOOLS, '--effort', 'xhigh', '--output-format', 'stream-json', '--verbose']
|
||||
if (this.cliModel) {
|
||||
args.push('--model', this.cliModel)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,14 @@ export class CodexCliProvider implements AIProvider {
|
||||
}
|
||||
|
||||
private buildArgs(): string[] {
|
||||
const baseArgs = ['--json', '--dangerously-bypass-approvals-and-sandbox']
|
||||
// workspace-write (not read-only) because codex's read-only sandbox
|
||||
// also blocks network, which breaks `gh pr diff` for reviewers.
|
||||
const baseArgs = [
|
||||
'--json',
|
||||
'--sandbox', 'workspace-write',
|
||||
'-c', 'approval_policy="never"',
|
||||
'-c', 'sandbox_workspace_write.network_access=true',
|
||||
]
|
||||
if (this.cliModel) {
|
||||
baseArgs.push('--model', this.cliModel)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ClaudeCodeProvider } from './claude-code.js'
|
||||
import { CodexCliProvider } from './codex-cli.js'
|
||||
import { GeminiCliProvider } from './gemini-cli.js'
|
||||
import { GeminiProvider } from './gemini.js'
|
||||
import { OpencodeCliProvider } from './opencode-cli.js'
|
||||
import { QwenCodeProvider } from './qwen-code.js'
|
||||
import { MiniMaxProvider } from './minimax.js'
|
||||
import { MockProvider } from './mock.js'
|
||||
@@ -14,9 +15,20 @@ import { checkCliBinary } from './cli-check.js'
|
||||
|
||||
// Parse CLI model string: 'gemini-cli:gemini-2.5-pro' → { provider: 'gemini-cli', cliModel: 'gemini-2.5-pro' }
|
||||
// Plain 'gemini-cli' → { provider: 'gemini-cli', cliModel: undefined }
|
||||
const CLI_PROVIDERS = ['claude-code', 'codex-cli', 'gemini-cli', 'qwen-code'] as const
|
||||
const CLI_PROVIDERS = ['claude-code', 'codex-cli', 'gemini-cli', 'opencode-cli', 'qwen-code'] as const
|
||||
type CliProviderName = typeof CLI_PROVIDERS[number]
|
||||
|
||||
const OPENROUTER_PREFIX = 'openrouter/'
|
||||
const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'
|
||||
|
||||
// OpenRouter model IDs look like 'openrouter/<vendor>/<model>',
|
||||
// e.g. 'openrouter/anthropic/claude-3.5-sonnet'. The prefix routes to
|
||||
// the OpenAI client (OpenRouter is OpenAI-compatible); the rest is the
|
||||
// model ID the OpenRouter API expects.
|
||||
function stripOpenRouterPrefix(model: string): string {
|
||||
return model.slice(OPENROUTER_PREFIX.length)
|
||||
}
|
||||
|
||||
export function parseCliModel(model: string): { provider: string; cliModel?: string } {
|
||||
for (const cli of CLI_PROVIDERS) {
|
||||
if (model === cli) {
|
||||
@@ -35,7 +47,10 @@ export function isCliModel(model: string): boolean {
|
||||
return (CLI_PROVIDERS as readonly string[]).includes(provider)
|
||||
}
|
||||
|
||||
export function getProviderForModel(model: string): 'anthropic' | 'openai' | 'google' | 'claude-code' | 'codex-cli' | 'gemini-cli' | 'qwen-code' | 'minimax' | 'mock' {
|
||||
export function getProviderForModel(model: string): 'anthropic' | 'openai' | 'google' | 'claude-code' | 'codex-cli' | 'gemini-cli' | 'opencode-cli' | 'qwen-code' | 'minimax' | 'mock' | 'openrouter' {
|
||||
if (model.startsWith(OPENROUTER_PREFIX)) {
|
||||
return 'openrouter'
|
||||
}
|
||||
const { provider } = parseCliModel(model)
|
||||
if ((CLI_PROVIDERS as readonly string[]).includes(provider)) {
|
||||
return provider as CliProviderName
|
||||
@@ -92,11 +107,41 @@ export function createProvider(model: string, config: MagpieConfig): AIProvider
|
||||
return new QwenCodeProvider({ cliModel })
|
||||
}
|
||||
|
||||
// OpenCode CLI is the one CLI provider that needs upstream API keys —
|
||||
// it routes to OpenRouter (or another provider) for the actual model call.
|
||||
// We forward whatever keys magpie already has configured.
|
||||
if (providerName === 'opencode-cli') {
|
||||
checkCliBinary('opencode', 'OpenCode')
|
||||
return new OpencodeCliProvider({ cliModel, config })
|
||||
}
|
||||
|
||||
// Mock provider for debug mode — no API key needed
|
||||
if (providerName === 'mock') {
|
||||
return new MockProvider()
|
||||
}
|
||||
|
||||
// OpenRouter is OpenAI-compatible: route through the OpenAI client,
|
||||
// strip the 'openrouter/' prefix from the model, and point at OpenRouter's API.
|
||||
if (providerName === 'openrouter') {
|
||||
const openRouterModel = stripOpenRouterPrefix(model).trim()
|
||||
if (!openRouterModel) {
|
||||
throw new Error(`Invalid OpenRouter model "${model}": must include a model ID after "${OPENROUTER_PREFIX}" (e.g. "openrouter/anthropic/claude-3.5-sonnet").`)
|
||||
}
|
||||
const providerConfig = config.providers['openrouter']
|
||||
const apiKey = providerConfig?.api_key || process.env.OPENROUTER_API_KEY || ''
|
||||
if (!apiKey) {
|
||||
throw new Error('OpenRouter API key is required. Set OPENROUTER_API_KEY env var or providers.openrouter.api_key in config.')
|
||||
}
|
||||
// NOTE: the returned provider's `.name` will be 'openai', not 'openrouter',
|
||||
// because OpenRouter requests are dispatched through the OpenAI client.
|
||||
// Logs/UI keyed on provider name will show 'openai' for OpenRouter traffic.
|
||||
return new OpenAIProvider({
|
||||
apiKey,
|
||||
model: openRouterModel,
|
||||
baseURL: providerConfig?.base_url || DEFAULT_OPENROUTER_BASE_URL,
|
||||
})
|
||||
}
|
||||
|
||||
// MiniMax uses API key from config or env
|
||||
if (providerName === 'minimax') {
|
||||
const providerConfig = config.providers['minimax']
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { spawn } from 'child_process'
|
||||
import type { AIProvider, Message, CliProviderOptions, ChatOptions } from './types.js'
|
||||
import type { MagpieConfig } from '../config/types.js'
|
||||
import { CliSessionHelper } from './session-helper.js'
|
||||
import { preparePromptForCli } from '../utils/prompt-file.js'
|
||||
import { withRetry } from '../utils/retry.js'
|
||||
|
||||
// Read-only tool allowlist for opencode reviewers, mirroring claude-code's
|
||||
// ALLOWED_TOOLS. Injected via the OPENCODE_CONFIG_CONTENT env var so we don't
|
||||
// touch the user's own opencode.json. With --dangerously-skip-permissions,
|
||||
// explicit "deny" entries still block — unspecified categories auto-allow,
|
||||
// which keeps us forward-compatible with new opencode tools.
|
||||
//
|
||||
// IMPORTANT — bash rule order: opencode applies the LAST matching pattern,
|
||||
// not the most specific one. The catch-all `'*': 'deny'` MUST come first,
|
||||
// followed by the specific allows, or every gh/git/rg call gets denied and
|
||||
// opencode drops the bash tool from the model's available tool list entirely.
|
||||
const PERMISSION_CONFIG = JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
permission: {
|
||||
read: 'allow',
|
||||
grep: 'allow',
|
||||
glob: 'allow',
|
||||
list: 'allow',
|
||||
todowrite: 'allow',
|
||||
edit: 'deny',
|
||||
task: 'deny',
|
||||
webfetch: 'deny',
|
||||
websearch: 'deny',
|
||||
// Large prompts (>100KB) are materialized to a file via preparePromptForCli
|
||||
// and we pass tmpDir: this.cwd so that file lives inside --dir <cwd>.
|
||||
// That keeps external_directory denied: a prompt injection cannot trick
|
||||
// the reviewer into reading ~/.ssh, /etc/passwd, or anything else outside
|
||||
// the repo.
|
||||
external_directory: 'deny',
|
||||
bash: {
|
||||
'*': 'deny',
|
||||
'gh *': 'allow',
|
||||
'git *': 'allow',
|
||||
'rg *': 'allow',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Magpie provider key → opencode env var. Forwarded so the user only needs
|
||||
// to configure each key once (in magpie's config) rather than also exporting
|
||||
// it to opencode's environment.
|
||||
const API_KEY_FORWARDS: Array<{ env: string; providerKey: 'openrouter' | 'anthropic' | 'openai' | 'google' }> = [
|
||||
{ env: 'OPENROUTER_API_KEY', providerKey: 'openrouter' },
|
||||
{ env: 'ANTHROPIC_API_KEY', providerKey: 'anthropic' },
|
||||
{ env: 'OPENAI_API_KEY', providerKey: 'openai' },
|
||||
{ env: 'GOOGLE_API_KEY', providerKey: 'google' },
|
||||
]
|
||||
|
||||
export interface OpencodeCliProviderOptions extends CliProviderOptions {
|
||||
/** MagpieConfig is needed so we can forward API keys to opencode's env. */
|
||||
config?: MagpieConfig
|
||||
}
|
||||
|
||||
export class OpencodeCliProvider implements AIProvider {
|
||||
name = 'opencode-cli'
|
||||
private cwd: string
|
||||
private timeout: number // ms, 0 = no timeout
|
||||
private cliModel?: string
|
||||
private config?: MagpieConfig
|
||||
private session = new CliSessionHelper()
|
||||
// Like codex-cli: opencode generates its own session id and returns it in
|
||||
// the first response's event stream. We never pre-generate one — that
|
||||
// would risk telling opencode to "continue" a session it has never seen.
|
||||
private sessionEnabled = false
|
||||
|
||||
get sessionId() { return this.session.sessionId }
|
||||
|
||||
constructor(options?: OpencodeCliProviderOptions) {
|
||||
this.cwd = process.cwd()
|
||||
this.timeout = 15 * 60 * 1000 // 15 minutes
|
||||
this.cliModel = options?.cliModel
|
||||
this.config = options?.config
|
||||
}
|
||||
|
||||
setCwd(cwd: string) {
|
||||
this.cwd = cwd
|
||||
}
|
||||
|
||||
startSession(name?: string): void {
|
||||
this.sessionEnabled = true
|
||||
this.session.start(name)
|
||||
this.session.sessionId = undefined // Captured from the first response, not pre-generated
|
||||
}
|
||||
|
||||
endSession(): void {
|
||||
this.sessionEnabled = false
|
||||
this.session.end()
|
||||
}
|
||||
|
||||
async chat(messages: Message[], systemPrompt?: string, _options?: ChatOptions): Promise<string> {
|
||||
const prompt = this.session.shouldSendFullHistory()
|
||||
? this.session.buildPrompt(messages, systemPrompt)
|
||||
: this.session.buildPromptLastOnly(messages)
|
||||
try {
|
||||
const result = await withRetry(() => this.runOpencode(prompt))
|
||||
this.session.markMessageSent()
|
||||
return result
|
||||
} catch (err) {
|
||||
if (this.sessionEnabled) this.startSession(this.session.sessionName)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async *chatStream(messages: Message[], systemPrompt?: string): AsyncGenerator<string, void, unknown> {
|
||||
const prompt = this.session.shouldSendFullHistory()
|
||||
? this.session.buildPrompt(messages, systemPrompt)
|
||||
: this.session.buildPromptLastOnly(messages)
|
||||
try {
|
||||
yield* this.runOpencodeStream(prompt)
|
||||
this.session.markMessageSent()
|
||||
} catch (err) {
|
||||
if (this.sessionEnabled) this.startSession(this.session.sessionName)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private spawnEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, OPENCODE_CONFIG_CONTENT: PERMISSION_CONFIG }
|
||||
if (this.config) {
|
||||
for (const { env: envKey, providerKey } of API_KEY_FORWARDS) {
|
||||
const pc = this.config.providers[providerKey] as { api_key?: string } | undefined
|
||||
if (pc?.api_key) {
|
||||
env[envKey] = pc.api_key
|
||||
}
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
private buildArgs(): string[] {
|
||||
// opencode run reads stdin and concatenates with positional args, so we
|
||||
// can deliver the prompt via stdin like the other CLI providers.
|
||||
// --dangerously-skip-permissions auto-allows unspecified categories;
|
||||
// explicit "deny" entries in PERMISSION_CONFIG still block.
|
||||
const args = [
|
||||
'run',
|
||||
'--format', 'json',
|
||||
'--dir', this.cwd,
|
||||
'--dangerously-skip-permissions',
|
||||
]
|
||||
if (this.cliModel) {
|
||||
args.push('-m', this.cliModel)
|
||||
}
|
||||
// Pass the captured session id on follow-up turns. We never use
|
||||
// --continue (which resumes opencode's globally-last session and would
|
||||
// race when multiple magpie reviewers run concurrently), and we never
|
||||
// pass an unseen id on the first turn (opencode generates the id).
|
||||
if (this.sessionEnabled && this.session.sessionId && !this.session.isFirstMessage) {
|
||||
args.push('--session', this.session.sessionId)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// Event schema (verified against opencode 1.15.11):
|
||||
// {type:"step_start", sessionID:"ses_...", part:{...}}
|
||||
// {type:"text", sessionID:"ses_...", part:{type:"text", text:"..."}}
|
||||
// Each model turn emits one consolidated `text` event — no streaming deltas.
|
||||
// Tool-use events are ignored for text extraction.
|
||||
private extractEventText(event: unknown): string {
|
||||
if (!event || typeof event !== 'object') return ''
|
||||
const e = event as { type?: unknown; sessionID?: unknown; part?: { type?: unknown; text?: unknown } }
|
||||
|
||||
if (this.sessionEnabled && !this.session.sessionId && typeof e.sessionID === 'string') {
|
||||
this.session.sessionId = e.sessionID
|
||||
}
|
||||
|
||||
if (e.type === 'text' && e.part?.type === 'text' && typeof e.part.text === 'string') {
|
||||
return e.part.text
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private parseJsonOutput(output: string): string {
|
||||
let text = ''
|
||||
for (const line of output.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
try {
|
||||
text += this.extractEventText(JSON.parse(trimmed))
|
||||
} catch {
|
||||
// not JSON — ignore
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private runOpencode(prompt: string): Promise<string> {
|
||||
// Write the spilled prompt file inside --dir <cwd> so the read tool can
|
||||
// reach it; external_directory: 'deny' would otherwise block /tmp paths.
|
||||
const { prompt: stdinPrompt, cleanup } = preparePromptForCli(prompt, { tmpDir: this.cwd })
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = this.buildArgs()
|
||||
const child = spawn('opencode', args, {
|
||||
cwd: this.cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: this.spawnEnv(),
|
||||
})
|
||||
|
||||
let output = ''
|
||||
let error = ''
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data.toString()
|
||||
})
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
error += data.toString()
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
cleanup()
|
||||
if (code !== 0) {
|
||||
reject(new Error(`OpenCode CLI exited with code ${code}: ${error}`))
|
||||
} else {
|
||||
resolve(this.parseJsonOutput(output).trim())
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
cleanup()
|
||||
reject(new Error(`Failed to run opencode CLI: ${err.message}`))
|
||||
})
|
||||
|
||||
child.stdin.on('error', () => {})
|
||||
child.stdin.write(stdinPrompt)
|
||||
child.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
private async *runOpencodeStream(prompt: string): AsyncGenerator<string, void, unknown> {
|
||||
// Write the spilled prompt file inside --dir <cwd> so the read tool can
|
||||
// reach it; external_directory: 'deny' would otherwise block /tmp paths.
|
||||
const { prompt: stdinPrompt, cleanup } = preparePromptForCli(prompt, { tmpDir: this.cwd })
|
||||
|
||||
const args = this.buildArgs()
|
||||
const child = spawn('opencode', args, {
|
||||
cwd: this.cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: this.spawnEnv(),
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
let resolveNext: ((value: { chunk: string | null }) => void) | null = null
|
||||
let done = false
|
||||
let error: Error | null = null
|
||||
let lastActivity = Date.now()
|
||||
let lineBuf = ''
|
||||
let stderrOutput = ''
|
||||
|
||||
const timeoutChecker = this.timeout > 0 ? setInterval(() => {
|
||||
if (Date.now() - lastActivity > this.timeout) {
|
||||
child.kill('SIGTERM')
|
||||
const forceKill = setTimeout(() => {
|
||||
try { child.kill('SIGKILL') } catch {}
|
||||
}, 5000)
|
||||
forceKill.unref()
|
||||
done = true
|
||||
error = new Error(`OpenCode CLI timed out after ${this.timeout / 1000}s of inactivity`)
|
||||
if (resolveNext) {
|
||||
resolveNext({ chunk: null })
|
||||
}
|
||||
}
|
||||
}, 10000) : null
|
||||
|
||||
const pushChunk = (chunk: string) => {
|
||||
if (!chunk) return
|
||||
if (resolveNext) {
|
||||
resolveNext({ chunk })
|
||||
resolveNext = null
|
||||
} else {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
lastActivity = Date.now()
|
||||
lineBuf += data.toString()
|
||||
let idx
|
||||
while ((idx = lineBuf.indexOf('\n')) !== -1) {
|
||||
const line = lineBuf.slice(0, idx).trim()
|
||||
lineBuf = lineBuf.slice(idx + 1)
|
||||
if (!line) continue
|
||||
try {
|
||||
const event = JSON.parse(line) as Record<string, unknown>
|
||||
const piece = this.extractEventText(event)
|
||||
if (piece) pushChunk(piece)
|
||||
} catch {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
lastActivity = Date.now()
|
||||
stderrOutput += data.toString()
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
cleanup()
|
||||
if (timeoutChecker) clearInterval(timeoutChecker)
|
||||
if (lineBuf.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(lineBuf.trim()) as Record<string, unknown>
|
||||
const piece = this.extractEventText(event)
|
||||
if (piece) pushChunk(piece)
|
||||
} catch {}
|
||||
}
|
||||
done = true
|
||||
if (code !== 0 && !error) {
|
||||
error = new Error(`OpenCode CLI exited with code ${code}${stderrOutput ? ': ' + stderrOutput.slice(0, 500) : ''}`)
|
||||
}
|
||||
if (resolveNext) {
|
||||
resolveNext({ chunk: null })
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
cleanup()
|
||||
if (timeoutChecker) clearInterval(timeoutChecker)
|
||||
done = true
|
||||
error = new Error(`Failed to run opencode CLI: ${err.message}`)
|
||||
if (resolveNext) {
|
||||
resolveNext({ chunk: null })
|
||||
}
|
||||
})
|
||||
|
||||
child.stdin.on('error', () => {})
|
||||
child.stdin.write(stdinPrompt)
|
||||
child.stdin.end()
|
||||
|
||||
while (!done || chunks.length > 0) {
|
||||
if (chunks.length > 0) {
|
||||
yield chunks.shift()!
|
||||
} else if (!done) {
|
||||
const result = await new Promise<{ chunk: string | null }>((resolve) => {
|
||||
resolveNext = resolve
|
||||
})
|
||||
if (result.chunk !== null) {
|
||||
yield result.chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,25 @@ export interface PreparedPrompt {
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
export function preparePromptForCli(prompt: string): PreparedPrompt {
|
||||
export interface PreparePromptOptions {
|
||||
/**
|
||||
* Directory to materialize the prompt file in when it exceeds the size
|
||||
* threshold. Defaults to os.tmpdir(). Override when the consuming CLI
|
||||
* cannot read outside a specific root — e.g. opencode-cli denies reads
|
||||
* outside its --dir, so the prompt file must live inside the repo.
|
||||
*/
|
||||
tmpDir?: string
|
||||
}
|
||||
|
||||
export function preparePromptForCli(prompt: string, options?: PreparePromptOptions): PreparedPrompt {
|
||||
if (Buffer.byteLength(prompt, 'utf-8') <= PROMPT_SIZE_THRESHOLD) {
|
||||
return { prompt, cleanup: () => {} }
|
||||
}
|
||||
|
||||
registerExitHandler()
|
||||
|
||||
const tmpFile = join(tmpdir(), `magpie_prompt_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`)
|
||||
const dir = options?.tmpDir ?? tmpdir()
|
||||
const tmpFile = join(dir, `magpie_prompt_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`)
|
||||
writeFileSync(tmpFile, prompt, 'utf-8')
|
||||
activeTempFiles.add(tmpFile)
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// tests/providers/factory.test.ts
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { createProvider, getProviderForModel } from '../../src/providers/factory.js'
|
||||
import type { MagpieConfig } from '../../src/config/types.js'
|
||||
|
||||
describe('Provider Factory', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
const mockConfig: MagpieConfig = {
|
||||
providers: {
|
||||
anthropic: { api_key: 'ant-key' },
|
||||
@@ -39,6 +43,17 @@ describe('Provider Factory', () => {
|
||||
it('should return codex-cli for codex-cli model', () => {
|
||||
expect(getProviderForModel('codex-cli')).toBe('codex-cli')
|
||||
})
|
||||
|
||||
it('should return opencode-cli for opencode-cli model (with and without :model suffix)', () => {
|
||||
expect(getProviderForModel('opencode-cli')).toBe('opencode-cli')
|
||||
expect(getProviderForModel('opencode-cli:openrouter/anthropic/claude-sonnet-4')).toBe('opencode-cli')
|
||||
})
|
||||
|
||||
it('should return openrouter for openrouter/ prefixed models', () => {
|
||||
expect(getProviderForModel('openrouter/anthropic/claude-3.5-sonnet')).toBe('openrouter')
|
||||
expect(getProviderForModel('openrouter/meta-llama/llama-3-70b-instruct')).toBe('openrouter')
|
||||
expect(getProviderForModel('openrouter/openai/gpt-4o')).toBe('openrouter')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProvider', () => {
|
||||
@@ -76,6 +91,16 @@ describe('Provider Factory', () => {
|
||||
expect(provider.name).toBe('codex-cli')
|
||||
})
|
||||
|
||||
it('should create opencode-cli provider with no extra config', () => {
|
||||
const provider = createProvider('opencode-cli', mockConfig)
|
||||
expect(provider.name).toBe('opencode-cli')
|
||||
})
|
||||
|
||||
it('should create opencode-cli provider with a model suffix', () => {
|
||||
const provider = createProvider('opencode-cli:openrouter/anthropic/claude-sonnet-4', mockConfig)
|
||||
expect(provider.name).toBe('opencode-cli')
|
||||
})
|
||||
|
||||
it('should pass base_url through to API providers', () => {
|
||||
const configWithBaseUrl: MagpieConfig = {
|
||||
...mockConfig,
|
||||
@@ -95,5 +120,28 @@ describe('Provider Factory', () => {
|
||||
const provider = createProvider('claude-sonnet-4-20250514', mockConfig)
|
||||
expect(provider.name).toBe('anthropic')
|
||||
})
|
||||
|
||||
it('should create openrouter provider (via openai client) with api key from config', () => {
|
||||
const configWithOpenrouter: MagpieConfig = {
|
||||
...mockConfig,
|
||||
providers: { ...mockConfig.providers, openrouter: { api_key: 'or-key' } }
|
||||
}
|
||||
const provider = createProvider('openrouter/anthropic/claude-3.5-sonnet', configWithOpenrouter)
|
||||
// OpenRouter is routed through the OpenAI client, so .name === 'openai'
|
||||
expect(provider.name).toBe('openai')
|
||||
})
|
||||
|
||||
it('should pick up OPENROUTER_API_KEY env var when config is absent', () => {
|
||||
vi.stubEnv('OPENROUTER_API_KEY', 'env-or-key')
|
||||
const provider = createProvider('openrouter/anthropic/claude-3.5-sonnet', mockConfig)
|
||||
expect(provider.name).toBe('openai')
|
||||
})
|
||||
|
||||
it('should throw when OpenRouter has no api key configured', () => {
|
||||
vi.stubEnv('OPENROUTER_API_KEY', '')
|
||||
expect(() =>
|
||||
createProvider('openrouter/anthropic/claude-3.5-sonnet', mockConfig)
|
||||
).toThrow(/OpenRouter API key/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { OpenAIProvider } from '../../src/providers/openai'
|
||||
import { createProvider } from '../../src/providers/factory'
|
||||
import type { MagpieConfig } from '../../src/config/types'
|
||||
|
||||
let lastConstructorOptions: Record<string, unknown> = {}
|
||||
let lastCreateOptions: Record<string, unknown> = {}
|
||||
|
||||
vi.mock('openai', () => ({
|
||||
default: class MockOpenAI {
|
||||
chat = {
|
||||
completions: {
|
||||
create: vi.fn().mockResolvedValue({
|
||||
create: vi.fn().mockImplementation((opts: Record<string, unknown>) => {
|
||||
lastCreateOptions = opts
|
||||
return Promise.resolve({
|
||||
choices: [{ message: { content: 'Mock response' } }]
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
constructor(options: Record<string, unknown>) {
|
||||
@@ -40,3 +46,49 @@ describe('OpenAIProvider', () => {
|
||||
expect(lastConstructorOptions.baseURL).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenRouter via OpenAI client', () => {
|
||||
const baseConfig: MagpieConfig = {
|
||||
providers: {},
|
||||
defaults: { max_rounds: 3, output_format: 'markdown' },
|
||||
reviewers: {},
|
||||
summarizer: { model: 'openrouter/anthropic/claude-3.5-sonnet', prompt: '' },
|
||||
analyzer: { model: 'openrouter/anthropic/claude-3.5-sonnet', prompt: '' }
|
||||
}
|
||||
|
||||
it('strips the openrouter/ prefix from the model and defaults baseURL to OpenRouter', async () => {
|
||||
const config: MagpieConfig = {
|
||||
...baseConfig,
|
||||
providers: { openrouter: { api_key: 'or-key' } }
|
||||
}
|
||||
const provider = createProvider('openrouter/anthropic/claude-3.5-sonnet', config)
|
||||
expect(lastConstructorOptions.apiKey).toBe('or-key')
|
||||
expect(lastConstructorOptions.baseURL).toBe('https://openrouter.ai/api/v1')
|
||||
|
||||
// Invoke chat() so the stripped model reaches chat.completions.create
|
||||
await provider.chat([{ role: 'user', content: 'hi' }])
|
||||
expect(lastCreateOptions.model).toBe('anthropic/claude-3.5-sonnet')
|
||||
})
|
||||
|
||||
it('honors a custom base_url from config and forwards the stripped model', async () => {
|
||||
const config: MagpieConfig = {
|
||||
...baseConfig,
|
||||
providers: {
|
||||
openrouter: { api_key: 'or-key', base_url: 'https://my-openrouter-proxy.example.com/v1' }
|
||||
}
|
||||
}
|
||||
const provider = createProvider('openrouter/meta-llama/llama-3-70b-instruct', config)
|
||||
expect(lastConstructorOptions.baseURL).toBe('https://my-openrouter-proxy.example.com/v1')
|
||||
|
||||
await provider.chat([{ role: 'user', content: 'hi' }])
|
||||
expect(lastCreateOptions.model).toBe('meta-llama/llama-3-70b-instruct')
|
||||
})
|
||||
|
||||
it('throws when the model is just "openrouter/" with no ID after it', () => {
|
||||
const config: MagpieConfig = {
|
||||
...baseConfig,
|
||||
providers: { openrouter: { api_key: 'or-key' } }
|
||||
}
|
||||
expect(() => createProvider('openrouter/', config)).toThrow(/must include a model ID/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Verifies the JSON event parser against captured opencode 1.15.11 output.
|
||||
// The schema is internal to opencode; if it changes, these tests fail loudly
|
||||
// rather than the provider silently returning empty reviewer responses.
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { OpencodeCliProvider } from '../../src/providers/opencode-cli.js'
|
||||
|
||||
// Real captures from `opencode run --format json -m openrouter/openai/gpt-4o-mini`.
|
||||
const STEP_START_EVENT = '{"type":"step_start","timestamp":1780089625130,"sessionID":"ses_abc","part":{"id":"prt_1","messageID":"msg_1","sessionID":"ses_abc","type":"step-start"}}'
|
||||
const TEXT_EVENT = '{"type":"text","timestamp":1780089625396,"sessionID":"ses_abc","part":{"id":"prt_2","messageID":"msg_1","sessionID":"ses_abc","type":"text","text":"ok","time":{"start":1780089625131,"end":1780089625393}}}'
|
||||
|
||||
// Access private parser methods. They're pure logic and worth testing directly;
|
||||
// extracting them into a separate module just for visibility would be churn.
|
||||
type ParserHandle = {
|
||||
extractEventText(event: unknown): string
|
||||
parseJsonOutput(output: string): string
|
||||
}
|
||||
function asParser(p: OpencodeCliProvider): ParserHandle {
|
||||
return p as unknown as ParserHandle
|
||||
}
|
||||
|
||||
describe('OpencodeCliProvider parser', () => {
|
||||
describe('extractEventText', () => {
|
||||
it('returns the text from a text-part event', () => {
|
||||
const parser = asParser(new OpencodeCliProvider())
|
||||
expect(parser.extractEventText(JSON.parse(TEXT_EVENT))).toBe('ok')
|
||||
})
|
||||
|
||||
it('returns empty for a step_start event', () => {
|
||||
const parser = asParser(new OpencodeCliProvider())
|
||||
expect(parser.extractEventText(JSON.parse(STEP_START_EVENT))).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty for an unknown event type', () => {
|
||||
const parser = asParser(new OpencodeCliProvider())
|
||||
expect(parser.extractEventText({ type: 'tool.use', sessionID: 'ses_abc', tool: 'read' })).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty for non-object inputs', () => {
|
||||
const parser = asParser(new OpencodeCliProvider())
|
||||
expect(parser.extractEventText(null)).toBe('')
|
||||
expect(parser.extractEventText('text')).toBe('')
|
||||
expect(parser.extractEventText(42)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseJsonOutput', () => {
|
||||
it('concatenates text across multiple events, ignoring others', () => {
|
||||
const provider = new OpencodeCliProvider()
|
||||
const output = [
|
||||
STEP_START_EVENT,
|
||||
'{"type":"text","sessionID":"ses_abc","part":{"type":"text","text":"hello "}}',
|
||||
'{"type":"tool.use","sessionID":"ses_abc"}',
|
||||
'{"type":"text","sessionID":"ses_abc","part":{"type":"text","text":"world"}}',
|
||||
].join('\n')
|
||||
expect(asParser(provider).parseJsonOutput(output)).toBe('hello world')
|
||||
})
|
||||
|
||||
it('skips blank lines and malformed JSON', () => {
|
||||
const provider = new OpencodeCliProvider()
|
||||
const output = [
|
||||
'',
|
||||
'not valid json',
|
||||
TEXT_EVENT,
|
||||
'{ partial',
|
||||
'',
|
||||
].join('\n')
|
||||
expect(asParser(provider).parseJsonOutput(output)).toBe('ok')
|
||||
})
|
||||
|
||||
it('returns empty when no text events are present', () => {
|
||||
const provider = new OpencodeCliProvider()
|
||||
expect(asParser(provider).parseJsonOutput(STEP_START_EVENT)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session id capture', () => {
|
||||
it('does not capture sessionID when sessions are disabled', () => {
|
||||
const provider = new OpencodeCliProvider()
|
||||
asParser(provider).parseJsonOutput(TEXT_EVENT)
|
||||
expect(provider.sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('captures sessionID from the first event after startSession', () => {
|
||||
const provider = new OpencodeCliProvider()
|
||||
provider.startSession('reviewer-1')
|
||||
expect(provider.sessionId).toBeUndefined() // not pre-generated
|
||||
asParser(provider).parseJsonOutput(STEP_START_EVENT)
|
||||
expect(provider.sessionId).toBe('ses_abc')
|
||||
})
|
||||
|
||||
it('does not overwrite a captured sessionID with a later event', () => {
|
||||
const provider = new OpencodeCliProvider()
|
||||
provider.startSession('reviewer-1')
|
||||
asParser(provider).parseJsonOutput(STEP_START_EVENT)
|
||||
const laterEvent = '{"type":"text","sessionID":"ses_different","part":{"type":"text","text":"x"}}'
|
||||
asParser(provider).parseJsonOutput(laterEvent)
|
||||
expect(provider.sessionId).toBe('ses_abc')
|
||||
})
|
||||
|
||||
it('clears sessionID on endSession', () => {
|
||||
const provider = new OpencodeCliProvider()
|
||||
provider.startSession('reviewer-1')
|
||||
asParser(provider).parseJsonOutput(TEXT_EVENT)
|
||||
expect(provider.sessionId).toBe('ses_abc')
|
||||
provider.endSession()
|
||||
expect(provider.sessionId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { existsSync } from 'fs'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join, dirname } from 'path'
|
||||
import { preparePromptForCli } from '../../src/utils/prompt-file.js'
|
||||
|
||||
describe('preparePromptForCli', () => {
|
||||
@@ -23,4 +25,23 @@ describe('preparePromptForCli', () => {
|
||||
result.cleanup()
|
||||
expect(existsSync(tmpPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('writes the spilled prompt into the supplied tmpDir', () => {
|
||||
const customDir = mkdtempSync(join(tmpdir(), 'magpie-tmpdir-test-'))
|
||||
try {
|
||||
const largePrompt = 'y'.repeat(200 * 1024)
|
||||
const result = preparePromptForCli(largePrompt, { tmpDir: customDir })
|
||||
|
||||
const pathMatch = result.prompt.match(/\/.*magpie_prompt_\S+/)
|
||||
expect(pathMatch).toBeTruthy()
|
||||
const tmpPath = pathMatch![0]
|
||||
expect(dirname(tmpPath)).toBe(customDir)
|
||||
expect(existsSync(tmpPath)).toBe(true)
|
||||
|
||||
result.cleanup()
|
||||
expect(existsSync(tmpPath)).toBe(false)
|
||||
} finally {
|
||||
rmSync(customDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user