mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-05 02:30:26 +08:00
Compare commits
2 Commits
v6.3.0
...
feature_pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd735034e | ||
|
|
a08dd62b59 |
@@ -1,47 +1,209 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "myclaude",
|
||||
"version": "5.6.1",
|
||||
"description": "Professional multi-agent development workflows with OmO orchestration, Requirements-Driven and BMAD methodologies",
|
||||
"name": "claude-code-dev-workflows",
|
||||
"owner": {
|
||||
"name": "cexll",
|
||||
"email": "evanxian9@gmail.com"
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"email": "contact@example.com",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Professional multi-agent development workflows with Requirements-Driven and BMAD methodologies, featuring 16+ specialized agents and 12+ commands",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "omo",
|
||||
"description": "Multi-agent orchestration for code analysis, bug investigation, fix planning, and implementation with intelligent routing to specialized agents",
|
||||
"version": "5.6.1",
|
||||
"source": "./skills/omo",
|
||||
"category": "development"
|
||||
"name": "requirements-driven-development",
|
||||
"source": "./requirements-driven-workflow/",
|
||||
"description": "Streamlined requirements-driven development workflow with 90% quality gates for practical feature implementation",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"requirements",
|
||||
"workflow",
|
||||
"automation",
|
||||
"quality-gates",
|
||||
"feature-development",
|
||||
"agile",
|
||||
"specifications"
|
||||
],
|
||||
"category": "workflows",
|
||||
"strict": false,
|
||||
"commands": [
|
||||
"./commands/requirements-pilot.md"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/requirements-generate.md",
|
||||
"./agents/requirements-code.md",
|
||||
"./agents/requirements-testing.md",
|
||||
"./agents/requirements-review.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "requirements",
|
||||
"description": "Requirements-driven development workflow with quality gates for practical feature implementation",
|
||||
"version": "5.6.1",
|
||||
"source": "./agents/requirements",
|
||||
"category": "development"
|
||||
},
|
||||
{
|
||||
"name": "bmad",
|
||||
"name": "bmad-agile-workflow",
|
||||
"source": "./bmad-agile-workflow/",
|
||||
"description": "Full BMAD agile workflow with role-based agents (PO, Architect, SM, Dev, QA) and interactive approval gates",
|
||||
"version": "5.6.1",
|
||||
"source": "./agents/bmad",
|
||||
"category": "development"
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"bmad",
|
||||
"agile",
|
||||
"scrum",
|
||||
"product-owner",
|
||||
"architect",
|
||||
"developer",
|
||||
"qa",
|
||||
"workflow-orchestration"
|
||||
],
|
||||
"category": "workflows",
|
||||
"strict": false,
|
||||
"commands": [
|
||||
"./commands/bmad-pilot.md"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/bmad-po.md",
|
||||
"./agents/bmad-architect.md",
|
||||
"./agents/bmad-sm.md",
|
||||
"./agents/bmad-dev.md",
|
||||
"./agents/bmad-qa.md",
|
||||
"./agents/bmad-orchestrator.md",
|
||||
"./agents/bmad-review.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dev-kit",
|
||||
"name": "development-essentials",
|
||||
"source": "./development-essentials/",
|
||||
"description": "Essential development commands for coding, debugging, testing, optimization, and documentation",
|
||||
"version": "5.6.1",
|
||||
"source": "./agents/development-essentials",
|
||||
"category": "productivity"
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"code",
|
||||
"debug",
|
||||
"test",
|
||||
"optimize",
|
||||
"review",
|
||||
"bugfix",
|
||||
"refactor",
|
||||
"documentation"
|
||||
],
|
||||
"category": "essentials",
|
||||
"strict": false,
|
||||
"commands": [
|
||||
"./commands/code.md",
|
||||
"./commands/debug.md",
|
||||
"./commands/test.md",
|
||||
"./commands/optimize.md",
|
||||
"./commands/review.md",
|
||||
"./commands/bugfix.md",
|
||||
"./commands/refactor.md",
|
||||
"./commands/docs.md",
|
||||
"./commands/ask.md",
|
||||
"./commands/think.md"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/code.md",
|
||||
"./agents/bugfix.md",
|
||||
"./agents/bugfix-verify.md",
|
||||
"./agents/optimize.md",
|
||||
"./agents/debug.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "sparv",
|
||||
"description": "Minimal SPARV workflow (Specify→Plan→Act→Review→Vault) with 10-point spec gate, unified journal, 2-action saves, 3-failure protocol, and EHRB risk detection",
|
||||
"version": "1.1.0",
|
||||
"source": "./skills/sparv",
|
||||
"category": "development"
|
||||
"name": "codex-cli",
|
||||
"source": "./skills/codex/",
|
||||
"description": "Execute Codex CLI for code analysis, refactoring, and automated code changes with file references (@syntax) and structured output",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"codex",
|
||||
"code-analysis",
|
||||
"refactoring",
|
||||
"automation",
|
||||
"gpt-5",
|
||||
"ai-coding"
|
||||
],
|
||||
"category": "essentials",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./SKILL.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gemini-cli",
|
||||
"source": "./skills/gemini/",
|
||||
"description": "Execute Gemini CLI for AI-powered code analysis and generation with Google's latest Gemini models",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"gemini",
|
||||
"google-ai",
|
||||
"code-analysis",
|
||||
"code-generation",
|
||||
"ai-reasoning"
|
||||
],
|
||||
"category": "essentials",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./SKILL.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dev-workflow",
|
||||
"source": "./dev-workflow/",
|
||||
"description": "Minimal lightweight development workflow with requirements clarification, parallel codex execution, and mandatory 90% test coverage",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"dev",
|
||||
"workflow",
|
||||
"codex",
|
||||
"testing",
|
||||
"coverage",
|
||||
"concurrent",
|
||||
"lightweight"
|
||||
],
|
||||
"category": "workflows",
|
||||
"strict": false,
|
||||
"commands": [
|
||||
"./commands/dev.md"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/dev-plan-generator.md"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -8,10 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -24,13 +21,11 @@ jobs:
|
||||
run: |
|
||||
cd codeagent-wrapper
|
||||
go test -v -cover -coverprofile=coverage.out ./...
|
||||
shell: bash
|
||||
|
||||
- name: Check coverage
|
||||
run: |
|
||||
cd codeagent-wrapper
|
||||
go tool cover -func=coverage.out | grep total | awk '{print $3}'
|
||||
shell: bash
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
|
||||
25
.github/workflows/release.yml
vendored
25
.github/workflows/release.yml
vendored
@@ -74,7 +74,7 @@ jobs:
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
OUTPUT_NAME="${OUTPUT_NAME}.exe"
|
||||
fi
|
||||
go build -ldflags="-s -w -X main.version=${VERSION}" -o ${OUTPUT_NAME} ./cmd/codeagent-wrapper
|
||||
go build -ldflags="-s -w -X main.version=${VERSION}" -o ${OUTPUT_NAME} .
|
||||
chmod +x ${OUTPUT_NAME}
|
||||
echo "artifact_path=codeagent-wrapper/${OUTPUT_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -97,6 +97,11 @@ jobs:
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Prepare release files
|
||||
run: |
|
||||
mkdir -p release
|
||||
@@ -104,10 +109,26 @@ jobs:
|
||||
cp install.sh install.bat release/
|
||||
ls -la release/
|
||||
|
||||
- name: Generate release notes with git-cliff
|
||||
run: |
|
||||
# Install git-cliff via npx
|
||||
npx git-cliff@latest --current --strip all -o release_notes.md
|
||||
|
||||
# Fallback if generation failed
|
||||
if [ ! -s release_notes.md ]; then
|
||||
echo "⚠️ Failed to generate release notes with git-cliff" > release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "## What's Changed" >> release_notes.md
|
||||
echo "See commits in this release for details." >> release_notes.md
|
||||
fi
|
||||
|
||||
echo "--- Generated Release Notes ---"
|
||||
cat release_notes.md
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/*
|
||||
generate_release_notes: true
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,11 +1,7 @@
|
||||
.claude/
|
||||
.claude-trace
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
.venv
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
.coverage
|
||||
coverage.out
|
||||
references
|
||||
output/
|
||||
|
||||
445
CHANGELOG.md
445
CHANGELOG.md
@@ -2,451 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [6.0.0] - 2026-01-26
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- support `npx github:cexll/myclaude` for installation and execution
|
||||
- default module changed from `dev` to `do`
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- restructure: create `agents/` and move `bmad-agile-workflow` → `agents/bmad`, `requirements-driven-workflow` → `agents/requirements`, `development-essentials` → `agents/development-essentials`
|
||||
- remove legacy directories: `docs/`, `hooks/`, `dev-workflow/`
|
||||
- update references across `config.json`, `README.md`, `README_CN.md`, `marketplace.json`, etc.
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- add `skills/README.md` and `PLUGIN_README.md`
|
||||
|
||||
### 💼 Other
|
||||
|
||||
- add `package.json` and `bin/cli.js` for npx packaging
|
||||
|
||||
## [6.1.5] - 2026-01-25
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- correct gitignore to not exclude cmd/codeagent-wrapper
|
||||
|
||||
## [6.1.4] - 2026-01-25
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- support concurrent tasks with unique state files
|
||||
|
||||
## [6.1.3] - 2026-01-25
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- correct build path in release workflow
|
||||
|
||||
- increase stdoutDrainTimeout from 100ms to 500ms
|
||||
|
||||
## [6.1.2] - 2026-01-24
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- use ANTHROPIC_AUTH_TOKEN for Claude CLI env injection
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update codeagent version
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- restructure root READMEs with do as recommended workflow
|
||||
|
||||
- update do/omo/sparv module READMEs with detailed workflows
|
||||
|
||||
- add README for bmad and requirements modules
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
|
||||
- use prefix match for version flag tests
|
||||
|
||||
## [6.1.1] - 2026-01-23
|
||||
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
|
||||
- rename feature-dev to do workflow
|
||||
|
||||
## [6.1.0] - 2026-01-23
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
|
||||
- ignore references directory
|
||||
|
||||
- add go.work.sum for workspace dependencies
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- read GEMINI_MODEL from ~/.gemini/.env ([#131](https://github.com/cexll/myclaude/issues/131))
|
||||
|
||||
- validate non-empty output message before printing
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- add feature-dev skill with 7-phase workflow
|
||||
|
||||
- support \${CLAUDE_PLUGIN_ROOT} variable in hooks config
|
||||
|
||||
## [6.0.0-alpha1] - 2026-01-20
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- add missing cmd/codeagent/main.go entry point
|
||||
|
||||
- update release workflow build path for new directory structure
|
||||
|
||||
- write PATH config to both profile and rc files ([#128](https://github.com/cexll/myclaude/issues/128))
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- add course module with dev, product-requirements and test-cases skills
|
||||
|
||||
- add hooks management to install.py
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
|
||||
- restructure codebase to internal/ directory with modular architecture
|
||||
|
||||
## [5.6.7] - 2026-01-17
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- remove .sparv
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- update 'Agent Hierarchy' model for frontend-ui-ux-engineer and document-writer in README ([#127](https://github.com/cexll/myclaude/issues/127))
|
||||
|
||||
- update mappings for frontend-ui-ux-engineer and document-writer in README ([#126](https://github.com/cexll/myclaude/issues/126))
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- add sparv module and interactive plugin manager
|
||||
|
||||
- add sparv enhanced rules v1.1
|
||||
|
||||
- add sparv skill to claude-plugin v1.1.0
|
||||
|
||||
- feat sparv skill
|
||||
|
||||
## [5.6.6] - 2026-01-16
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- remove extraneous dash arg for opencode stdin mode ([#124](https://github.com/cexll/myclaude/issues/124))
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update readme
|
||||
|
||||
## [5.6.5] - 2026-01-16
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- correct default models for oracle and librarian agents ([#120](https://github.com/cexll/myclaude/issues/120))
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- feat dev skill
|
||||
|
||||
## [5.6.4] - 2026-01-15
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- filter codex 0.84.0 stderr noise logs ([#122](https://github.com/cexll/myclaude/issues/122))
|
||||
|
||||
- filter codex stderr noise logs
|
||||
|
||||
## [5.6.3] - 2026-01-14
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
|
||||
- bump codeagent-wrapper version to 5.6.3
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- update version tests to match 5.6.3
|
||||
|
||||
- use config override for codex reasoning effort
|
||||
|
||||
## [5.6.2] - 2026-01-14
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- propagate SkipPermissions to parallel tasks ([#113](https://github.com/cexll/myclaude/issues/113))
|
||||
|
||||
- add timeout for Windows process termination
|
||||
|
||||
- reject dash as workdir parameter ([#118](https://github.com/cexll/myclaude/issues/118))
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- add OmO workflow to README and fix plugin marketplace structure
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
|
||||
- remove sisyphus agent and unused code
|
||||
|
||||
## [5.6.1] - 2026-01-13
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- add sleep in fake script to prevent CI race condition
|
||||
|
||||
- fix gemini env load
|
||||
|
||||
- fix omo
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- add reasoning effort config for codex backend
|
||||
|
||||
## [5.6.0] - 2026-01-13
|
||||
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- update FAQ for default bypass/skip-permissions behavior
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- default to skip-permissions and bypass-sandbox
|
||||
|
||||
- add omo module for multi-agent orchestration
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
|
||||
- streamline agent documentation and remove sisyphus
|
||||
|
||||
## [5.5.0] - 2026-01-12
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 修复 Gemini init 事件 session_id 未提取的问题 ([#111](https://github.com/cexll/myclaude/issues/111))
|
||||
|
||||
- fix codeagent skill TaskOutput
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge branch 'master' of github.com:cexll/myclaude
|
||||
|
||||
- add test-cases skill
|
||||
|
||||
- add browser skill
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- add multi-agent support with yolo mode
|
||||
|
||||
## [5.4.4] - 2026-01-08
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- 修复 Windows 后端退出:taskkill 结束进程树 + turn.completed 支持 ([#108](https://github.com/cexll/myclaude/issues/108))
|
||||
|
||||
## [5.4.3] - 2026-01-06
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- support model parameter for all backends, auto-inject from settings ([#105](https://github.com/cexll/myclaude/issues/105))
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- add FAQ Q5 for permission/sandbox env vars
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- feat skill-install install script and security scan
|
||||
|
||||
- add uninstall scripts with selective module removal
|
||||
|
||||
## [5.4.2] - 2025-12-31
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- replace setx with reg add to avoid 1024-char PATH truncation ([#101](https://github.com/cexll/myclaude/issues/101))
|
||||
|
||||
## [5.4.1] - 2025-12-26
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 移除未知事件格式的日志噪声 ([#96](https://github.com/cexll/myclaude/issues/96))
|
||||
|
||||
- prevent duplicate PATH entries on reinstall ([#95](https://github.com/cexll/myclaude/issues/95))
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- 添加 FAQ 常见问题章节
|
||||
|
||||
- update troubleshooting with idempotent PATH commands ([#95](https://github.com/cexll/myclaude/issues/95))
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- Add intelligent backend selection based on task complexity ([#61](https://github.com/cexll/myclaude/issues/61))
|
||||
|
||||
## [5.4.0] - 2025-12-24
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- Minor issues #12 and #13 - ASCII mode and performance optimization
|
||||
|
||||
- code review fixes for PR #94 - all critical and major issues resolved
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- v5.4.0 structured execution report ([#94](https://github.com/cexll/myclaude/issues/94))
|
||||
|
||||
## [5.2.8] - 2025-12-22
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
|
||||
- simplify release workflow to use GitHub auto-generated notes
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- correct settings.json filename and bump version to v5.2.8
|
||||
|
||||
## [5.2.7] - 2025-12-21
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
|
||||
- bump version to v5.2.7
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- allow claude backend to read env from setting.json while preventing recursion ([#92](https://github.com/cexll/myclaude/issues/92))
|
||||
|
||||
- comprehensive security and quality improvements for PR #85 & #87 ([#90](https://github.com/cexll/myclaude/issues/90))
|
||||
|
||||
- Parser重复解析优化 + 严重bug修复 + PR #86兼容性 ([#88](https://github.com/cexll/myclaude/issues/88))
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Improve backend termination after message and extend timeout ([#86](https://github.com/cexll/myclaude/issues/86))
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- add millisecond-precision timestamps to all log entries ([#91](https://github.com/cexll/myclaude/issues/91))
|
||||
|
||||
## [5.2.6] - 2025-12-19
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- filter noisy stderr output from gemini backend ([#83](https://github.com/cexll/myclaude/issues/83))
|
||||
|
||||
- 修復 wsl install.sh 格式問題 ([#78](https://github.com/cexll/myclaude/issues/78))
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update all readme
|
||||
|
||||
- BMADh和Requirements-Driven支持根据语义生成对应的文档 ([#82](https://github.com/cexll/myclaude/issues/82))
|
||||
|
||||
## [5.2.5] - 2025-12-17
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 修复多 backend 并行日志 PID 混乱并移除包装格式 ([#74](https://github.com/cexll/myclaude/issues/74)) ([#76](https://github.com/cexll/myclaude/issues/76))
|
||||
|
||||
- replace "Codex" to "codeagent" in dev-plan-generator subagent
|
||||
|
||||
- 修復 win python install.py
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #71 from aliceric27/master
|
||||
|
||||
- Merge branch 'cexll:master' into master
|
||||
|
||||
- Merge pull request #72 from changxvv/master
|
||||
|
||||
- update changelog
|
||||
|
||||
- update codeagent skill backend select
|
||||
|
||||
## [5.2.4] - 2025-12-16
|
||||
|
||||
|
||||
|
||||
13
Makefile
13
Makefile
@@ -7,12 +7,12 @@
|
||||
help:
|
||||
@echo "Claude Code Multi-Agent Workflow - Quick Deployment"
|
||||
@echo ""
|
||||
@echo "Recommended installation: npx github:cexll/myclaude"
|
||||
@echo "Recommended installation: python3 install.py --install-dir ~/.claude"
|
||||
@echo ""
|
||||
@echo "Usage: make [target]"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " install - LEGACY: install all configurations (prefer npx github:cexll/myclaude)"
|
||||
@echo " install - LEGACY: install all configurations (prefer install.py)"
|
||||
@echo " deploy-bmad - Deploy BMAD workflow (bmad-pilot)"
|
||||
@echo " deploy-requirements - Deploy Requirements workflow (requirements-pilot)"
|
||||
@echo " deploy-essentials - Deploy Development Essentials workflow"
|
||||
@@ -31,16 +31,16 @@ CLAUDE_CONFIG_DIR = ~/.claude
|
||||
SPECS_DIR = .claude/specs
|
||||
|
||||
# Workflow directories
|
||||
BMAD_DIR = agents/bmad
|
||||
REQUIREMENTS_DIR = agents/requirements
|
||||
ESSENTIALS_DIR = agents/development-essentials
|
||||
BMAD_DIR = bmad-agile-workflow
|
||||
REQUIREMENTS_DIR = requirements-driven-workflow
|
||||
ESSENTIALS_DIR = development-essentials
|
||||
ADVANCED_DIR = advanced-ai-agents
|
||||
OUTPUT_STYLES_DIR = output-styles
|
||||
|
||||
# Install all configurations
|
||||
install: deploy-all
|
||||
@echo "⚠️ LEGACY PATH: make install will be removed in future versions."
|
||||
@echo " Prefer: npx github:cexll/myclaude"
|
||||
@echo " Prefer: python3 install.py --install-dir ~/.claude"
|
||||
@echo "✅ Installation complete!"
|
||||
|
||||
# Deploy BMAD workflow
|
||||
@@ -159,3 +159,4 @@ changelog:
|
||||
@echo ""
|
||||
@echo "Preview the changes:"
|
||||
@echo " git diff CHANGELOG.md"
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Plugin System
|
||||
|
||||
Claude Code plugins for this repo are defined in `.claude-plugin/marketplace.json`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
/plugin marketplace add cexll/myclaude
|
||||
/plugin list
|
||||
```
|
||||
|
||||
## Available Plugins
|
||||
|
||||
- `bmad` - BMAD workflow (`./agents/bmad`)
|
||||
- `requirements` - requirements-driven workflow (`./agents/requirements`)
|
||||
- `dev-kit` - development essentials (`./agents/development-essentials`)
|
||||
- `omo` - orchestration skill (`./skills/omo`)
|
||||
- `sparv` - SPARV workflow (`./skills/sparv`)
|
||||
375
README.md
375
README.md
@@ -3,107 +3,290 @@
|
||||
# Claude Code Multi-Agent Workflow System
|
||||
|
||||
[](https://smithery.ai/skills?ns=cexll&utm_source=github&utm_medium=badge)
|
||||
|
||||
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://claude.ai/code)
|
||||
[](https://github.com/cexll/myclaude)
|
||||
[](https://github.com/cexll/myclaude)
|
||||
|
||||
> AI-powered development automation with multi-backend execution (Codex/Claude/Gemini/OpenCode)
|
||||
> AI-powered development automation with multi-backend execution (Codex/Claude/Gemini)
|
||||
|
||||
## Quick Start
|
||||
## Core Concept: Multi-Backend Architecture
|
||||
|
||||
This system leverages a **dual-agent architecture** with pluggable AI backends:
|
||||
|
||||
| Role | Agent | Responsibility |
|
||||
|------|-------|----------------|
|
||||
| **Orchestrator** | Claude Code | Planning, context gathering, verification, user interaction |
|
||||
| **Executor** | codeagent-wrapper | Code editing, test execution (Codex/Claude/Gemini backends) |
|
||||
|
||||
**Why this separation?**
|
||||
- Claude Code excels at understanding context and orchestrating complex workflows
|
||||
- Specialized backends (Codex for code, Claude for reasoning, Gemini for prototyping) excel at focused execution
|
||||
- Backend selection via `--backend codex|claude|gemini` matches the model to the task
|
||||
|
||||
## Quick Start(Please execute in Powershell on Windows)
|
||||
|
||||
```bash
|
||||
npx github:cexll/myclaude
|
||||
git clone https://github.com/cexll/myclaude.git
|
||||
cd myclaude
|
||||
python3 install.py --install-dir ~/.claude
|
||||
```
|
||||
|
||||
## Modules Overview
|
||||
## Workflows Overview
|
||||
|
||||
| Module | Description | Documentation |
|
||||
|--------|-------------|---------------|
|
||||
| [do](skills/do/README.md) | **Recommended** - 7-phase feature development with codeagent orchestration | `/do` command |
|
||||
| [omo](skills/omo/README.md) | Multi-agent orchestration with intelligent routing | `/omo` command |
|
||||
| [bmad](agents/bmad/README.md) | BMAD agile workflow with 6 specialized agents | `/bmad-pilot` command |
|
||||
| [requirements](agents/requirements/README.md) | Lightweight requirements-to-code pipeline | `/requirements-pilot` command |
|
||||
| [essentials](agents/development-essentials/README.md) | Core development commands and utilities | `/code`, `/debug`, etc. |
|
||||
| [sparv](skills/sparv/README.md) | SPARV workflow (Specify→Plan→Act→Review→Vault) | `/sparv` command |
|
||||
| course | Course development (combines dev + product-requirements + test-cases) | Composite module |
|
||||
### 1. Dev Workflow (Recommended)
|
||||
|
||||
**The primary workflow for most development tasks.**
|
||||
|
||||
```bash
|
||||
/dev "implement user authentication with JWT"
|
||||
```
|
||||
|
||||
**6-Step Process:**
|
||||
1. **Requirements Clarification** - Interactive Q&A to clarify scope
|
||||
2. **Codex Deep Analysis** - Codebase exploration and architecture decisions
|
||||
3. **Dev Plan Generation** - Structured task breakdown with test requirements
|
||||
4. **Parallel Execution** - Codex executes tasks concurrently
|
||||
5. **Coverage Validation** - Enforce ≥90% test coverage
|
||||
6. **Completion Summary** - Report with file changes and coverage stats
|
||||
|
||||
**Key Features:**
|
||||
- Claude Code orchestrates, Codex executes all code changes
|
||||
- Automatic task parallelization for speed
|
||||
- Mandatory 90% test coverage gate
|
||||
- Rollback on failure
|
||||
|
||||
**Best For:** Feature development, refactoring, bug fixes with tests
|
||||
|
||||
---
|
||||
|
||||
### 2. BMAD Agile Workflow
|
||||
|
||||
**Full enterprise agile methodology with 6 specialized agents.**
|
||||
|
||||
```bash
|
||||
/bmad-pilot "build e-commerce checkout system"
|
||||
```
|
||||
|
||||
**Agents:**
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| Product Owner | Requirements & user stories |
|
||||
| Architect | System design & tech decisions |
|
||||
| Tech Lead | Sprint planning & task breakdown |
|
||||
| Developer | Implementation |
|
||||
| Code Reviewer | Quality assurance |
|
||||
| QA Engineer | Testing & validation |
|
||||
|
||||
**Process:**
|
||||
```
|
||||
Requirements → Architecture → Sprint Plan → Development → Review → QA
|
||||
↓ ↓ ↓ ↓ ↓ ↓
|
||||
PRD.md DESIGN.md SPRINT.md Code REVIEW.md TEST.md
|
||||
```
|
||||
|
||||
**Best For:** Large features, team coordination, enterprise projects
|
||||
|
||||
---
|
||||
|
||||
### 3. Requirements-Driven Workflow
|
||||
|
||||
**Lightweight requirements-to-code pipeline.**
|
||||
|
||||
```bash
|
||||
/requirements-pilot "implement API rate limiting"
|
||||
```
|
||||
|
||||
**Process:**
|
||||
1. Requirements generation with quality scoring
|
||||
2. Implementation planning
|
||||
3. Code generation
|
||||
4. Review and testing
|
||||
|
||||
**Best For:** Quick prototypes, well-defined features
|
||||
|
||||
---
|
||||
|
||||
### 4. Development Essentials
|
||||
|
||||
**Direct commands for daily coding tasks.**
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/code` | Implement a feature |
|
||||
| `/debug` | Debug an issue |
|
||||
| `/test` | Write tests |
|
||||
| `/review` | Code review |
|
||||
| `/optimize` | Performance optimization |
|
||||
| `/refactor` | Code refactoring |
|
||||
| `/docs` | Documentation |
|
||||
|
||||
**Best For:** Quick tasks, no workflow overhead needed
|
||||
|
||||
## Enterprise Workflow Features
|
||||
|
||||
- **Multi-backend execution:** `codeagent-wrapper --backend codex|claude|gemini` (default `codex`) so you can match the model to the task without changing workflows.
|
||||
- **GitHub workflow commands:** `/gh-create-issue "short need"` creates structured issues; `/gh-issue-implement 123` pulls issue #123, drives development, and prepares the PR.
|
||||
- **Skills + hooks activation:** .claude/hooks run automation (tests, reviews), while `.claude/skills/skill-rules.json` auto-suggests the right skills. Keep hooks enabled in `.claude/settings.json` to activate the enterprise workflow helpers.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Modular Installation (Recommended)
|
||||
|
||||
```bash
|
||||
# Interactive installer (recommended)
|
||||
npx github:cexll/myclaude
|
||||
# Install all enabled modules (dev + essentials by default)
|
||||
python3 install.py --install-dir ~/.claude
|
||||
|
||||
# List installable items (modules / skills / wrapper)
|
||||
npx github:cexll/myclaude --list
|
||||
# Install specific module
|
||||
python3 install.py --module dev
|
||||
|
||||
# Detect installed modules and update from GitHub
|
||||
npx github:cexll/myclaude --update
|
||||
# List available modules
|
||||
python3 install.py --list-modules
|
||||
|
||||
# Custom install directory / overwrite
|
||||
npx github:cexll/myclaude --install-dir ~/.claude --force
|
||||
# Force overwrite existing files
|
||||
python3 install.py --force
|
||||
```
|
||||
|
||||
`--update` detects already installed modules in the target install dir (defaults to `~/.claude`, via `installed_modules.json` when present) and updates them from GitHub (latest release) by overwriting the module files.
|
||||
### Available Modules
|
||||
|
||||
### Module Configuration
|
||||
| Module | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `dev` | ✓ Enabled | Dev workflow + Codex integration |
|
||||
| `essentials` | ✓ Enabled | Core development commands |
|
||||
| `bmad` | Disabled | Full BMAD agile workflow |
|
||||
| `requirements` | Disabled | Requirements-driven workflow |
|
||||
|
||||
Edit `config.json` to enable/disable modules:
|
||||
### What Gets Installed
|
||||
|
||||
```
|
||||
~/.claude/
|
||||
├── CLAUDE.md # Core instructions and role definition
|
||||
├── commands/ # Slash commands (/dev, /code, etc.)
|
||||
├── agents/ # Agent definitions
|
||||
├── skills/
|
||||
│ └── codex/
|
||||
│ └── SKILL.md # Codex integration skill
|
||||
└── installed_modules.json # Installation status
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Edit `config.json` to customize:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"install_dir": "~/.claude",
|
||||
"modules": {
|
||||
"bmad": { "enabled": false },
|
||||
"requirements": { "enabled": false },
|
||||
"essentials": { "enabled": false },
|
||||
"omo": { "enabled": false },
|
||||
"sparv": { "enabled": false },
|
||||
"do": { "enabled": true },
|
||||
"course": { "enabled": false }
|
||||
"dev": {
|
||||
"enabled": true,
|
||||
"operations": [
|
||||
{"type": "merge_dir", "source": "dev-workflow"},
|
||||
{"type": "copy_file", "source": "memorys/CLAUDE.md", "target": "CLAUDE.md"},
|
||||
{"type": "copy_file", "source": "skills/codex/SKILL.md", "target": "skills/codex/SKILL.md"},
|
||||
{"type": "run_command", "command": "bash install.sh"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Operation Types:**
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `merge_dir` | Merge subdirs (commands/, agents/) into install dir |
|
||||
| `copy_dir` | Copy entire directory |
|
||||
| `copy_file` | Copy single file to target path |
|
||||
| `run_command` | Execute shell command |
|
||||
|
||||
---
|
||||
|
||||
## Codex Integration
|
||||
|
||||
The `codex` skill enables Claude Code to delegate code execution to Codex CLI.
|
||||
|
||||
### Usage in Workflows
|
||||
|
||||
```bash
|
||||
# Codex is invoked via the skill
|
||||
codeagent-wrapper - <<'EOF'
|
||||
implement @src/auth.ts with JWT validation
|
||||
EOF
|
||||
```
|
||||
|
||||
### Parallel Execution
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: backend_api
|
||||
workdir: /project/backend
|
||||
---CONTENT---
|
||||
implement REST endpoints for /api/users
|
||||
|
||||
---TASK---
|
||||
id: frontend_ui
|
||||
workdir: /project/frontend
|
||||
dependencies: backend_api
|
||||
---CONTENT---
|
||||
create React components consuming the API
|
||||
EOF
|
||||
```
|
||||
|
||||
### Install Codex Wrapper
|
||||
|
||||
```bash
|
||||
# Automatic (via dev module)
|
||||
python3 install.py --module dev
|
||||
|
||||
# Manual
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
Windows installs place `codeagent-wrapper.exe` in `%USERPROFILE%\bin`.
|
||||
|
||||
```powershell
|
||||
# PowerShell (recommended)
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1
|
||||
|
||||
# Batch (cmd)
|
||||
install.bat
|
||||
```
|
||||
|
||||
**Add to PATH** (if installer doesn't detect it):
|
||||
|
||||
```powershell
|
||||
# PowerShell - persistent for current user
|
||||
[Environment]::SetEnvironmentVariable('PATH', "$HOME\bin;" + [Environment]::GetEnvironmentVariable('PATH','User'), 'User')
|
||||
|
||||
# PowerShell - current session only
|
||||
$Env:PATH = "$HOME\bin;$Env:PATH"
|
||||
```
|
||||
|
||||
```batch
|
||||
REM cmd.exe - persistent for current user
|
||||
setx PATH "%USERPROFILE%\bin;%PATH%"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow Selection Guide
|
||||
|
||||
| Scenario | Recommended |
|
||||
|----------|-------------|
|
||||
| Feature development (default) | `/do` |
|
||||
| Bug investigation + fix | `/omo` |
|
||||
| Large enterprise project | `/bmad-pilot` |
|
||||
| Quick prototype | `/requirements-pilot` |
|
||||
| Simple task | `/code`, `/debug` |
|
||||
| Scenario | Recommended Workflow |
|
||||
|----------|---------------------|
|
||||
| New feature with tests | `/dev` |
|
||||
| Quick bug fix | `/debug` or `/code` |
|
||||
| Large multi-sprint feature | `/bmad-pilot` |
|
||||
| Prototype or POC | `/requirements-pilot` |
|
||||
| Code review | `/review` |
|
||||
| Performance issue | `/optimize` |
|
||||
|
||||
## Core Architecture
|
||||
|
||||
| Role | Agent | Responsibility |
|
||||
|------|-------|----------------|
|
||||
| **Orchestrator** | Claude Code | Planning, context gathering, verification |
|
||||
| **Executor** | codeagent-wrapper | Code editing, test execution (Codex/Claude/Gemini/OpenCode) |
|
||||
|
||||
## Backend CLI Requirements
|
||||
|
||||
| Backend | Required Features |
|
||||
|---------|-------------------|
|
||||
| Codex | `codex e`, `--json`, `-C`, `resume` |
|
||||
| Claude | `--output-format stream-json`, `-r` |
|
||||
| Gemini | `-o stream-json`, `-y`, `-r` |
|
||||
|
||||
## Directory Structure After Installation
|
||||
|
||||
```
|
||||
~/.claude/
|
||||
├── bin/codeagent-wrapper
|
||||
├── CLAUDE.md
|
||||
├── commands/
|
||||
├── agents/
|
||||
├── skills/
|
||||
└── config.json
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [codeagent-wrapper](codeagent-wrapper/README.md)
|
||||
- [Plugin System](PLUGIN_README.md)
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -111,41 +294,49 @@ Edit `config.json` to enable/disable modules:
|
||||
|
||||
**Codex wrapper not found:**
|
||||
```bash
|
||||
# Select: codeagent-wrapper
|
||||
npx github:cexll/myclaude
|
||||
# Check PATH
|
||||
echo $PATH | grep -q "$HOME/bin" || echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc
|
||||
|
||||
# Reinstall
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
**Permission denied:**
|
||||
```bash
|
||||
python3 install.py --install-dir ~/.claude --force
|
||||
```
|
||||
|
||||
**Module not loading:**
|
||||
```bash
|
||||
# Check installation status
|
||||
cat ~/.claude/installed_modules.json
|
||||
npx github:cexll/myclaude --force
|
||||
|
||||
# Reinstall specific module
|
||||
python3 install.py --module dev --force
|
||||
```
|
||||
|
||||
**Backend CLI errors:**
|
||||
```bash
|
||||
which codex && codex --version
|
||||
which claude && claude --version
|
||||
which gemini && gemini --version
|
||||
```
|
||||
---
|
||||
|
||||
## FAQ
|
||||
## Documentation
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| "Unknown event format" | Logging display issue, can be ignored |
|
||||
| Gemini can't read .gitignore files | Remove from .gitignore or use different backend |
|
||||
| Codex permission denied | Set `approval_policy = "never"` in ~/.codex/config.yaml |
|
||||
### Core Guides
|
||||
- **[Codeagent-Wrapper Guide](docs/CODEAGENT-WRAPPER.md)** - Multi-backend execution wrapper
|
||||
- **[Hooks Documentation](docs/HOOKS.md)** - Custom hooks and automation
|
||||
|
||||
See [GitHub Issues](https://github.com/cexll/myclaude/issues) for more.
|
||||
### Additional Resources
|
||||
- **[Installation Log](install.log)** - Installation history and troubleshooting
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0 - see [LICENSE](LICENSE)
|
||||
|
||||
### Commercial Licensing
|
||||
|
||||
For commercial use without AGPL obligations, contact: evanxian9@gmail.com
|
||||
AGPL-3.0 License - see [LICENSE](LICENSE)
|
||||
|
||||
## Support
|
||||
|
||||
- [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||
- **Issues**: [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||
- **Documentation**: [docs/](docs/)
|
||||
|
||||
---
|
||||
|
||||
**Claude Code + Codex = Better Development** - Orchestration meets execution.
|
||||
|
||||
376
README_CN.md
376
README_CN.md
@@ -2,116 +2,63 @@
|
||||
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://claude.ai/code)
|
||||
[](https://github.com/cexll/myclaude)
|
||||
[](https://github.com/cexll/myclaude)
|
||||
|
||||
> AI 驱动的开发自动化 - 多后端执行架构 (Codex/Claude/Gemini/OpenCode)
|
||||
> AI 驱动的开发自动化 - 多后端执行架构 (Codex/Claude/Gemini)
|
||||
|
||||
## 快速开始
|
||||
## 核心概念:多后端架构
|
||||
|
||||
```bash
|
||||
npx github:cexll/myclaude
|
||||
```
|
||||
|
||||
## 模块概览
|
||||
|
||||
| 模块 | 描述 | 文档 |
|
||||
|------|------|------|
|
||||
| [do](skills/do/README.md) | **推荐** - 7 阶段功能开发 + codeagent 编排 | `/do` 命令 |
|
||||
| [omo](skills/omo/README.md) | 多智能体编排 + 智能路由 | `/omo` 命令 |
|
||||
| [bmad](agents/bmad/README.md) | BMAD 敏捷工作流 + 6 个专业智能体 | `/bmad-pilot` 命令 |
|
||||
| [requirements](agents/requirements/README.md) | 轻量级需求到代码流水线 | `/requirements-pilot` 命令 |
|
||||
| [essentials](agents/development-essentials/README.md) | 核心开发命令和工具 | `/code`, `/debug` 等 |
|
||||
| [sparv](skills/sparv/README.md) | SPARV 工作流 (Specify→Plan→Act→Review→Vault) | `/sparv` 命令 |
|
||||
| course | 课程开发(组合 dev + product-requirements + test-cases) | 组合模块 |
|
||||
|
||||
## 核心架构
|
||||
本系统采用**双智能体架构**与可插拔 AI 后端:
|
||||
|
||||
| 角色 | 智能体 | 职责 |
|
||||
|------|-------|------|
|
||||
| **编排者** | Claude Code | 规划、上下文收集、验证 |
|
||||
| **执行者** | codeagent-wrapper | 代码编辑、测试执行(Codex/Claude/Gemini/OpenCode 后端)|
|
||||
| **编排者** | Claude Code | 规划、上下文收集、验证、用户交互 |
|
||||
| **执行者** | codeagent-wrapper | 代码编辑、测试执行(Codex/Claude/Gemini 后端)|
|
||||
|
||||
## 工作流详解
|
||||
**为什么分离?**
|
||||
- Claude Code 擅长理解上下文和编排复杂工作流
|
||||
- 专业后端(Codex 擅长代码、Claude 擅长推理、Gemini 擅长原型)专注执行
|
||||
- 通过 `--backend codex|claude|gemini` 匹配模型与任务
|
||||
|
||||
### do 工作流(推荐)
|
||||
|
||||
7 阶段功能开发,通过 codeagent-wrapper 编排多个智能体。**大多数功能开发任务的首选工作流。**
|
||||
## 快速开始(windows上请在Powershell中执行)
|
||||
|
||||
```bash
|
||||
/do "添加用户登录功能"
|
||||
git clone https://github.com/cexll/myclaude.git
|
||||
cd myclaude
|
||||
python3 install.py --install-dir ~/.claude
|
||||
```
|
||||
|
||||
**7 阶段:**
|
||||
| 阶段 | 名称 | 目标 |
|
||||
|------|------|------|
|
||||
| 1 | Discovery | 理解需求 |
|
||||
| 2 | Exploration | 映射代码库模式 |
|
||||
| 3 | Clarification | 解决歧义(**强制**)|
|
||||
| 4 | Architecture | 设计实现方案 |
|
||||
| 5 | Implementation | 构建功能(**需审批**)|
|
||||
| 6 | Review | 捕获缺陷 |
|
||||
| 7 | Summary | 记录结果 |
|
||||
## 工作流概览
|
||||
|
||||
**智能体:**
|
||||
- `code-explorer` - 代码追踪、架构映射
|
||||
- `code-architect` - 设计方案、文件规划
|
||||
- `code-reviewer` - 代码审查、简化建议
|
||||
- `develop` - 实现代码、运行测试
|
||||
### 1. Dev 工作流(推荐)
|
||||
|
||||
**大多数开发任务的首选工作流。**
|
||||
|
||||
```bash
|
||||
/dev "实现 JWT 用户认证"
|
||||
```
|
||||
|
||||
**6 步流程:**
|
||||
1. **需求澄清** - 交互式问答明确范围
|
||||
2. **Codex 深度分析** - 代码库探索和架构决策
|
||||
3. **开发计划生成** - 结构化任务分解和测试要求
|
||||
4. **并行执行** - Codex 并发执行任务
|
||||
5. **覆盖率验证** - 强制 ≥90% 测试覆盖率
|
||||
6. **完成总结** - 文件变更和覆盖率报告
|
||||
|
||||
**核心特性:**
|
||||
- Claude Code 编排,Codex 执行所有代码变更
|
||||
- 自动任务并行化提升速度
|
||||
- 强制 90% 测试覆盖率门禁
|
||||
- 失败自动回滚
|
||||
|
||||
**适用场景:** 功能开发、重构、带测试的 bug 修复
|
||||
|
||||
---
|
||||
|
||||
### OmO 多智能体编排器
|
||||
### 2. BMAD 敏捷工作流
|
||||
|
||||
基于风险信号智能路由任务到专业智能体。
|
||||
|
||||
```bash
|
||||
/omo "分析并修复这个认证 bug"
|
||||
```
|
||||
|
||||
**智能体层级:**
|
||||
| 智能体 | 角色 | 后端 |
|
||||
|-------|------|------|
|
||||
| `oracle` | 技术顾问 | Claude |
|
||||
| `librarian` | 外部研究 | Claude |
|
||||
| `explore` | 代码库搜索 | OpenCode |
|
||||
| `develop` | 代码实现 | Codex |
|
||||
| `frontend-ui-ux-engineer` | UI/UX 专家 | Gemini |
|
||||
| `document-writer` | 文档撰写 | Gemini |
|
||||
|
||||
**常用配方:**
|
||||
- 解释代码:`explore`
|
||||
- 位置已知的小修复:直接 `develop`
|
||||
- Bug 修复(位置未知):`explore → develop`
|
||||
- 跨模块重构:`explore → oracle → develop`
|
||||
|
||||
---
|
||||
|
||||
### SPARV 工作流
|
||||
|
||||
极简 5 阶段工作流:Specify → Plan → Act → Review → Vault。
|
||||
|
||||
```bash
|
||||
/sparv "实现订单导出功能"
|
||||
```
|
||||
|
||||
**核心规则:**
|
||||
- **10 分规格门**:得分 0-10,必须 >=9 才能进入 Plan
|
||||
- **2 动作保存**:每 2 次工具调用写入 journal.md
|
||||
- **3 失败协议**:连续 3 次失败后停止并上报
|
||||
- **EHRB**:高风险操作需明确确认
|
||||
|
||||
**评分维度(各 0-2 分):**
|
||||
1. Value - 为什么做,可验证的收益
|
||||
2. Scope - MVP + 不在范围内的内容
|
||||
3. Acceptance - 可测试的验收标准
|
||||
4. Boundaries - 错误/性能/兼容/安全边界
|
||||
5. Risk - EHRB/依赖/未知 + 处理方式
|
||||
|
||||
---
|
||||
|
||||
### BMAD 敏捷工作流
|
||||
|
||||
完整企业敏捷方法论 + 6 个专业智能体。
|
||||
**包含 6 个专业智能体的完整企业敏捷方法论。**
|
||||
|
||||
```bash
|
||||
/bmad-pilot "构建电商结账系统"
|
||||
@@ -122,36 +69,43 @@ npx github:cexll/myclaude
|
||||
|-------|------|
|
||||
| Product Owner | 需求与用户故事 |
|
||||
| Architect | 系统设计与技术决策 |
|
||||
| Scrum Master | Sprint 规划与任务分解 |
|
||||
| Tech Lead | Sprint 规划与任务分解 |
|
||||
| Developer | 实现 |
|
||||
| Code Reviewer | 质量保证 |
|
||||
| QA Engineer | 测试与验证 |
|
||||
|
||||
**审批门:**
|
||||
- PRD 完成后(90+ 分)需用户审批
|
||||
- 架构完成后(90+ 分)需用户审批
|
||||
**流程:**
|
||||
```
|
||||
需求 → 架构 → Sprint计划 → 开发 → 审查 → QA
|
||||
↓ ↓ ↓ ↓ ↓ ↓
|
||||
PRD.md DESIGN.md SPRINT.md Code REVIEW.md TEST.md
|
||||
```
|
||||
|
||||
**适用场景:** 大型功能、团队协作、企业项目
|
||||
|
||||
---
|
||||
|
||||
### 需求驱动工作流
|
||||
### 3. 需求驱动工作流
|
||||
|
||||
轻量级需求到代码流水线。
|
||||
**轻量级需求到代码流水线。**
|
||||
|
||||
```bash
|
||||
/requirements-pilot "实现 API 限流"
|
||||
```
|
||||
|
||||
**100 分质量评分:**
|
||||
- 功能清晰度:30 分
|
||||
- 技术具体性:25 分
|
||||
- 实现完整性:25 分
|
||||
- 业务上下文:20 分
|
||||
**流程:**
|
||||
1. 带质量评分的需求生成
|
||||
2. 实现规划
|
||||
3. 代码生成
|
||||
4. 审查和测试
|
||||
|
||||
**适用场景:** 快速原型、明确定义的功能
|
||||
|
||||
---
|
||||
|
||||
### 开发基础命令
|
||||
### 4. 开发基础命令
|
||||
|
||||
日常编码任务的直接命令。
|
||||
**日常编码任务的直接命令。**
|
||||
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
@@ -163,94 +117,204 @@ npx github:cexll/myclaude
|
||||
| `/refactor` | 代码重构 |
|
||||
| `/docs` | 编写文档 |
|
||||
|
||||
**适用场景:** 快速任务,无需工作流开销
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
### 模块化安装(推荐)
|
||||
|
||||
```bash
|
||||
# 交互式安装器(推荐)
|
||||
npx github:cexll/myclaude
|
||||
# 安装所有启用的模块(默认:dev + essentials)
|
||||
python3 install.py --install-dir ~/.claude
|
||||
|
||||
# 列出可安装项(module:* / skill:* / codeagent-wrapper)
|
||||
npx github:cexll/myclaude --list
|
||||
# 安装特定模块
|
||||
python3 install.py --module dev
|
||||
|
||||
# 检测已安装 modules 并从 GitHub 更新
|
||||
npx github:cexll/myclaude --update
|
||||
# 列出可用模块
|
||||
python3 install.py --list-modules
|
||||
|
||||
# 指定安装目录 / 强制覆盖
|
||||
npx github:cexll/myclaude --install-dir ~/.claude --force
|
||||
# 强制覆盖现有文件
|
||||
python3 install.py --force
|
||||
```
|
||||
|
||||
`--update` 会在目标安装目录(默认 `~/.claude`,优先读取 `installed_modules.json`)检测已安装 modules,并从 GitHub 拉取最新发布版本覆盖更新。
|
||||
### 可用模块
|
||||
|
||||
### 模块配置
|
||||
| 模块 | 默认 | 描述 |
|
||||
|------|------|------|
|
||||
| `dev` | ✓ 启用 | Dev 工作流 + Codex 集成 |
|
||||
| `essentials` | ✓ 启用 | 核心开发命令 |
|
||||
| `bmad` | 禁用 | 完整 BMAD 敏捷工作流 |
|
||||
| `requirements` | 禁用 | 需求驱动工作流 |
|
||||
|
||||
编辑 `config.json` 启用/禁用模块:
|
||||
### 安装内容
|
||||
|
||||
```
|
||||
~/.claude/
|
||||
├── CLAUDE.md # 核心指令和角色定义
|
||||
├── commands/ # 斜杠命令 (/dev, /code 等)
|
||||
├── agents/ # 智能体定义
|
||||
├── skills/
|
||||
│ └── codex/
|
||||
│ └── SKILL.md # Codex 集成技能
|
||||
└── installed_modules.json # 安装状态
|
||||
```
|
||||
|
||||
### 配置
|
||||
|
||||
编辑 `config.json` 自定义:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"install_dir": "~/.claude",
|
||||
"modules": {
|
||||
"bmad": { "enabled": false },
|
||||
"requirements": { "enabled": false },
|
||||
"essentials": { "enabled": false },
|
||||
"omo": { "enabled": false },
|
||||
"sparv": { "enabled": false },
|
||||
"do": { "enabled": true },
|
||||
"course": { "enabled": false }
|
||||
"dev": {
|
||||
"enabled": true,
|
||||
"operations": [
|
||||
{"type": "merge_dir", "source": "dev-workflow"},
|
||||
{"type": "copy_file", "source": "memorys/CLAUDE.md", "target": "CLAUDE.md"},
|
||||
{"type": "copy_file", "source": "skills/codex/SKILL.md", "target": "skills/codex/SKILL.md"},
|
||||
{"type": "run_command", "command": "bash install.sh"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**操作类型:**
|
||||
| 类型 | 描述 |
|
||||
|------|------|
|
||||
| `merge_dir` | 合并子目录 (commands/, agents/) 到安装目录 |
|
||||
| `copy_dir` | 复制整个目录 |
|
||||
| `copy_file` | 复制单个文件到目标路径 |
|
||||
| `run_command` | 执行 shell 命令 |
|
||||
|
||||
---
|
||||
|
||||
## Codex 集成
|
||||
|
||||
`codex` 技能使 Claude Code 能够将代码执行委托给 Codex CLI。
|
||||
|
||||
### 工作流中的使用
|
||||
|
||||
```bash
|
||||
# 通过技能调用 Codex
|
||||
codeagent-wrapper - <<'EOF'
|
||||
在 @src/auth.ts 中实现 JWT 验证
|
||||
EOF
|
||||
```
|
||||
|
||||
### 并行执行
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: backend_api
|
||||
workdir: /project/backend
|
||||
---CONTENT---
|
||||
实现 /api/users 的 REST 端点
|
||||
|
||||
---TASK---
|
||||
id: frontend_ui
|
||||
workdir: /project/frontend
|
||||
dependencies: backend_api
|
||||
---CONTENT---
|
||||
创建消费 API 的 React 组件
|
||||
EOF
|
||||
```
|
||||
|
||||
### 安装 Codex Wrapper
|
||||
|
||||
```bash
|
||||
# 自动(通过 dev 模块)
|
||||
python3 install.py --module dev
|
||||
|
||||
# 手动
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
#### Windows 系统
|
||||
|
||||
Windows 系统会将 `codeagent-wrapper.exe` 安装到 `%USERPROFILE%\bin`。
|
||||
|
||||
```powershell
|
||||
# PowerShell(推荐)
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1
|
||||
|
||||
# 批处理(cmd)
|
||||
install.bat
|
||||
```
|
||||
|
||||
**添加到 PATH**(如果安装程序未自动检测):
|
||||
|
||||
```powershell
|
||||
# PowerShell - 永久添加(当前用户)
|
||||
[Environment]::SetEnvironmentVariable('PATH', "$HOME\bin;" + [Environment]::GetEnvironmentVariable('PATH','User'), 'User')
|
||||
|
||||
# PowerShell - 仅当前会话
|
||||
$Env:PATH = "$HOME\bin;$Env:PATH"
|
||||
```
|
||||
|
||||
```batch
|
||||
REM cmd.exe - 永久添加(当前用户)
|
||||
setx PATH "%USERPROFILE%\bin;%PATH%"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流选择指南
|
||||
|
||||
| 场景 | 推荐 |
|
||||
|------|------|
|
||||
| 功能开发(默认) | `/do` |
|
||||
| Bug 调查 + 修复 | `/omo` |
|
||||
| 大型企业项目 | `/bmad-pilot` |
|
||||
| 快速原型 | `/requirements-pilot` |
|
||||
| 简单任务 | `/code`, `/debug` |
|
||||
|
||||
## 后端 CLI 要求
|
||||
|
||||
| 后端 | 必需功能 |
|
||||
| 场景 | 推荐工作流 |
|
||||
|------|----------|
|
||||
| Codex | `codex e`, `--json`, `-C`, `resume` |
|
||||
| Claude | `--output-format stream-json`, `-r` |
|
||||
| Gemini | `-o stream-json`, `-y`, `-r` |
|
||||
| 带测试的新功能 | `/dev` |
|
||||
| 快速 bug 修复 | `/debug` 或 `/code` |
|
||||
| 大型多 Sprint 功能 | `/bmad-pilot` |
|
||||
| 原型或 POC | `/requirements-pilot` |
|
||||
| 代码审查 | `/review` |
|
||||
| 性能问题 | `/optimize` |
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
**Codex wrapper 未找到:**
|
||||
```bash
|
||||
# 选择:codeagent-wrapper
|
||||
npx github:cexll/myclaude
|
||||
# 检查 PATH
|
||||
echo $PATH | grep -q "$HOME/bin" || echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc
|
||||
|
||||
# 重新安装
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
**权限被拒绝:**
|
||||
```bash
|
||||
python3 install.py --install-dir ~/.claude --force
|
||||
```
|
||||
|
||||
**模块未加载:**
|
||||
```bash
|
||||
# 检查安装状态
|
||||
cat ~/.claude/installed_modules.json
|
||||
npx github:cexll/myclaude --force
|
||||
|
||||
# 重新安装特定模块
|
||||
python3 install.py --module dev --force
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
|------|----------|
|
||||
| "Unknown event format" | 日志显示问题,可忽略 |
|
||||
| Gemini 无法读取 .gitignore 文件 | 从 .gitignore 移除或使用其他后端 |
|
||||
| Codex 权限拒绝 | 在 ~/.codex/config.yaml 设置 `approval_policy = "never"` |
|
||||
|
||||
更多问题请访问 [GitHub Issues](https://github.com/cexll/myclaude/issues)。
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
AGPL-3.0 - 查看 [LICENSE](LICENSE)
|
||||
|
||||
### 商业授权
|
||||
|
||||
如需商业授权(无需遵守 AGPL 义务),请联系:evanxian9@gmail.com
|
||||
AGPL-3.0 License - 查看 [LICENSE](LICENSE)
|
||||
|
||||
## 支持
|
||||
|
||||
- [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||
- **问题反馈**: [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||
- **文档**: [docs/](docs/)
|
||||
|
||||
---
|
||||
|
||||
**Claude Code + Codex = 更好的开发** - 编排遇见执行。
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "bmad",
|
||||
"description": "Full BMAD agile workflow with role-based agents (PO, Architect, SM, Dev, QA) and interactive approval gates",
|
||||
"version": "5.6.1",
|
||||
"author": {
|
||||
"name": "cexll",
|
||||
"email": "cexll@cexll.com"
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
# bmad - BMAD Agile Workflow
|
||||
|
||||
Full enterprise agile methodology with 6 specialized agents, UltraThink analysis, and repository-aware development.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
python install.py --module bmad
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
/bmad-pilot <PROJECT_DESCRIPTION> [OPTIONS]
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--skip-tests` | Skip QA testing phase |
|
||||
| `--direct-dev` | Skip SM planning, go directly to development |
|
||||
| `--skip-scan` | Skip initial repository scanning |
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
| Phase | Agent | Deliverable | Description |
|
||||
|-------|-------|-------------|-------------|
|
||||
| 0 | Orchestrator | `00-repo-scan.md` | Repository scanning with UltraThink analysis |
|
||||
| 1 | Product Owner (PO) | `01-product-requirements.md` | PRD with 90+ quality score required |
|
||||
| 2 | Architect | `02-system-architecture.md` | Technical design with 90+ score required |
|
||||
| 3 | Scrum Master (SM) | `03-sprint-plan.md` | Sprint backlog with stories and estimates |
|
||||
| 4 | Developer | Implementation code | Multi-sprint implementation |
|
||||
| 4.5 | Reviewer | `04-dev-reviewed.md` | Code review (Pass/Pass with Risk/Fail) |
|
||||
| 5 | QA Engineer | Test suite | Comprehensive testing and validation |
|
||||
|
||||
## Agents
|
||||
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| `bmad-orchestrator` | Repository scanning, workflow coordination |
|
||||
| `bmad-po` | Requirements gathering, PRD creation |
|
||||
| `bmad-architect` | System design, technology decisions |
|
||||
| `bmad-sm` | Sprint planning, task breakdown |
|
||||
| `bmad-dev` | Code implementation |
|
||||
| `bmad-review` | Code review, quality assessment |
|
||||
| `bmad-qa` | Testing, validation |
|
||||
|
||||
## Approval Gates
|
||||
|
||||
Two mandatory stop points require explicit user approval:
|
||||
|
||||
1. **After PRD** (Phase 1 → 2): User must approve requirements before architecture
|
||||
2. **After Architecture** (Phase 2 → 3): User must approve design before implementation
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
.claude/specs/{feature_name}/
|
||||
├── 00-repo-scan.md
|
||||
├── 01-product-requirements.md
|
||||
├── 02-system-architecture.md
|
||||
├── 03-sprint-plan.md
|
||||
└── 04-dev-reviewed.md
|
||||
```
|
||||
|
||||
## UltraThink Methodology
|
||||
|
||||
Applied throughout the workflow for deep analysis:
|
||||
|
||||
1. **Hypothesis Generation** - Form hypotheses about the problem
|
||||
2. **Evidence Collection** - Gather evidence from codebase
|
||||
3. **Pattern Recognition** - Identify recurring patterns
|
||||
4. **Synthesis** - Create comprehensive understanding
|
||||
5. **Validation** - Cross-check findings
|
||||
|
||||
## Interactive Confirmation Flow
|
||||
|
||||
PO and Architect phases use iterative refinement:
|
||||
|
||||
1. Agent produces initial draft + quality score
|
||||
2. Orchestrator presents to user with clarification questions
|
||||
3. User provides responses
|
||||
4. Agent refines until quality >= 90
|
||||
5. User confirms to save deliverable
|
||||
|
||||
## When to Use
|
||||
|
||||
- Large multi-sprint features
|
||||
- Enterprise projects requiring documentation
|
||||
- Team coordination scenarios
|
||||
- Projects needing formal approval gates
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
agents/bmad/
|
||||
├── README.md
|
||||
├── commands/
|
||||
│ └── bmad-pilot.md
|
||||
└── agents/
|
||||
├── bmad-orchestrator.md
|
||||
├── bmad-po.md
|
||||
├── bmad-architect.md
|
||||
├── bmad-sm.md
|
||||
├── bmad-dev.md
|
||||
├── bmad-review.md
|
||||
└── bmad-qa.md
|
||||
```
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "essentials",
|
||||
"description": "Essential development commands for coding, debugging, testing, optimization, and documentation",
|
||||
"version": "5.6.1",
|
||||
"author": {
|
||||
"name": "cexll",
|
||||
"email": "cexll@cexll.com"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "requirements",
|
||||
"description": "Requirements-driven development workflow with quality gates for practical feature implementation",
|
||||
"version": "5.6.1",
|
||||
"author": {
|
||||
"name": "cexll",
|
||||
"email": "cexll@cexll.com"
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
# requirements - Requirements-Driven Workflow
|
||||
|
||||
Lightweight requirements-to-code pipeline with interactive quality gates.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
python install.py --module requirements
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
/requirements-pilot <FEATURE_DESCRIPTION> [OPTIONS]
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--skip-tests` | Skip testing phase entirely |
|
||||
| `--skip-scan` | Skip initial repository scanning |
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
| Phase | Description | Output |
|
||||
|-------|-------------|--------|
|
||||
| 0 | Repository scanning | `00-repository-context.md` |
|
||||
| 1 | Requirements confirmation | `requirements-confirm.md` (90+ score required) |
|
||||
| 2 | Implementation | Code + `requirements-spec.md` |
|
||||
|
||||
## Quality Scoring (100-point system)
|
||||
|
||||
| Category | Points | Focus |
|
||||
|----------|--------|-------|
|
||||
| Functional Clarity | 30 | Input/output specs, success criteria |
|
||||
| Technical Specificity | 25 | Integration points, constraints |
|
||||
| Implementation Completeness | 25 | Edge cases, error handling |
|
||||
| Business Context | 20 | User value, priority |
|
||||
|
||||
## Sub-Agents
|
||||
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| `requirements-generate` | Create technical specifications |
|
||||
| `requirements-code` | Implement functionality |
|
||||
| `requirements-review` | Code quality evaluation |
|
||||
| `requirements-testing` | Test case creation |
|
||||
|
||||
## Approval Gate
|
||||
|
||||
One mandatory stop point after Phase 1:
|
||||
- Requirements must achieve 90+ quality score
|
||||
- User must explicitly approve before implementation begins
|
||||
|
||||
## Testing Decision
|
||||
|
||||
After code review passes (≥90%):
|
||||
- `--skip-tests`: Complete without testing
|
||||
- No option: Interactive prompt with smart recommendations based on task complexity
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
.claude/specs/{feature_name}/
|
||||
├── 00-repository-context.md
|
||||
├── requirements-confirm.md
|
||||
└── requirements-spec.md
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
- Quick prototypes
|
||||
- Well-defined features
|
||||
- Smaller scope tasks
|
||||
- When full BMAD workflow is overkill
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
agents/requirements/
|
||||
├── README.md
|
||||
├── commands/
|
||||
│ └── requirements-pilot.md
|
||||
└── agents/
|
||||
├── requirements-generate.md
|
||||
├── requirements-code.md
|
||||
├── requirements-review.md
|
||||
└── requirements-testing.md
|
||||
```
|
||||
1122
bin/cli.js
1122
bin/cli.js
File diff suppressed because it is too large
Load Diff
37
bmad-agile-workflow/.claude-plugin/marketplace.json
Normal file
37
bmad-agile-workflow/.claude-plugin/marketplace.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "bmad-agile-workflow",
|
||||
"source": "./",
|
||||
"description": "Full BMAD agile workflow with role-based agents (PO, Architect, SM, Dev, QA) and interactive approval gates",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"bmad",
|
||||
"agile",
|
||||
"scrum",
|
||||
"product-owner",
|
||||
"architect",
|
||||
"developer",
|
||||
"qa",
|
||||
"workflow-orchestration"
|
||||
],
|
||||
"category": "workflows",
|
||||
"strict": false,
|
||||
"commands": [
|
||||
"./commands/bmad-pilot.md"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/bmad-po.md",
|
||||
"./agents/bmad-architect.md",
|
||||
"./agents/bmad-sm.md",
|
||||
"./agents/bmad-dev.md",
|
||||
"./agents/bmad-qa.md",
|
||||
"./agents/bmad-orchestrator.md",
|
||||
"./agents/bmad-review.md"
|
||||
]
|
||||
}
|
||||
47
codeagent-wrapper/.github/workflows/ci.yml
vendored
47
codeagent-wrapper/.github/workflows/ci.yml
vendored
@@ -1,47 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
go-version: ["1.21", "1.22"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
cache: true
|
||||
- name: Test
|
||||
run: make test
|
||||
- name: Build
|
||||
run: make build
|
||||
- name: Verify version
|
||||
run: ./codeagent-wrapper --version
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22"
|
||||
cache: true
|
||||
- name: Lint
|
||||
run: make lint
|
||||
|
||||
16
codeagent-wrapper/.gitignore
vendored
16
codeagent-wrapper/.gitignore
vendored
@@ -1,9 +1,6 @@
|
||||
# Build artifacts
|
||||
bin/
|
||||
codeagent
|
||||
codeagent.exe
|
||||
/codeagent-wrapper
|
||||
/codeagent-wrapper.exe
|
||||
codeagent-wrapper
|
||||
codeagent-wrapper.exe
|
||||
*.test
|
||||
|
||||
# Coverage reports
|
||||
@@ -12,12 +9,3 @@ coverage*.out
|
||||
cover.out
|
||||
cover_*.out
|
||||
coverage.html
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
GO ?= go
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
LDFLAGS := -ldflags "-X codeagent-wrapper/internal/app.version=$(VERSION)"
|
||||
|
||||
TOOLS_BIN := $(CURDIR)/bin
|
||||
TOOLCHAIN ?= go1.22.0
|
||||
GOLANGCI_LINT_VERSION := v1.56.2
|
||||
STATICCHECK_VERSION := v0.4.7
|
||||
|
||||
GOLANGCI_LINT := $(TOOLS_BIN)/golangci-lint
|
||||
STATICCHECK := $(TOOLS_BIN)/staticcheck
|
||||
|
||||
.PHONY: build test lint clean install
|
||||
|
||||
build:
|
||||
$(GO) build $(LDFLAGS) -o codeagent-wrapper ./cmd/codeagent-wrapper
|
||||
|
||||
test:
|
||||
$(GO) test ./...
|
||||
|
||||
$(GOLANGCI_LINT):
|
||||
@mkdir -p $(TOOLS_BIN)
|
||||
GOTOOLCHAIN=$(TOOLCHAIN) GOBIN=$(TOOLS_BIN) $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
|
||||
|
||||
$(STATICCHECK):
|
||||
@mkdir -p $(TOOLS_BIN)
|
||||
GOTOOLCHAIN=$(TOOLCHAIN) GOBIN=$(TOOLS_BIN) $(GO) install honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION)
|
||||
|
||||
lint: $(GOLANGCI_LINT) $(STATICCHECK)
|
||||
GOTOOLCHAIN=$(TOOLCHAIN) $(GOLANGCI_LINT) run ./...
|
||||
GOTOOLCHAIN=$(TOOLCHAIN) $(STATICCHECK) ./...
|
||||
|
||||
clean:
|
||||
@python3 -c 'import glob, os; paths=["codeagent","codeagent.exe","codeagent-wrapper","codeagent-wrapper.exe","coverage.out","cover.out","coverage.html"]; paths += glob.glob("coverage*.out") + glob.glob("cover_*.out") + glob.glob("*.test"); [os.remove(p) for p in paths if os.path.exists(p)]'
|
||||
|
||||
install:
|
||||
$(GO) install $(LDFLAGS) ./cmd/codeagent-wrapper
|
||||
@@ -1,157 +0,0 @@
|
||||
# codeagent-wrapper
|
||||
|
||||
`codeagent-wrapper` 是一个用 Go 编写的“多后端 AI 代码代理”命令行包装器:用统一的 CLI 入口封装不同的 AI 工具后端(Codex / Claude / Gemini / Opencode),并提供一致的参数、配置与会话恢复体验。
|
||||
|
||||
入口:`cmd/codeagent/main.go`(生成二进制名:`codeagent`)和 `cmd/codeagent-wrapper/main.go`(生成二进制名:`codeagent-wrapper`)。两者行为一致。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 多后端支持:`codex` / `claude` / `gemini` / `opencode`
|
||||
- 统一命令行:`codeagent [flags] <task>` / `codeagent resume <session_id> <task> [workdir]`
|
||||
- 自动 stdin:遇到换行/特殊字符/超长任务自动走 stdin,避免 shell quoting 地狱;也可显式使用 `-`
|
||||
- 配置合并:支持配置文件与 `CODEAGENT_*` 环境变量(viper)
|
||||
- Agent 预设:从 `~/.codeagent/models.json` 读取 backend/model/prompt 等预设
|
||||
- 并行执行:`--parallel` 从 stdin 读取多任务配置,支持依赖拓扑并发执行
|
||||
- 日志清理:`codeagent cleanup` 清理旧日志(日志写入系统临时目录)
|
||||
|
||||
## 安装
|
||||
|
||||
要求:Go 1.21+。
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
go install ./cmd/codeagent
|
||||
go install ./cmd/codeagent-wrapper
|
||||
```
|
||||
|
||||
安装后确认:
|
||||
|
||||
```bash
|
||||
codeagent version
|
||||
codeagent-wrapper version
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
最简单用法(默认后端:`codex`):
|
||||
|
||||
```bash
|
||||
codeagent "分析 internal/app/cli.go 的入口逻辑,给出改进建议"
|
||||
```
|
||||
|
||||
指定后端:
|
||||
|
||||
```bash
|
||||
codeagent --backend claude "解释 internal/executor/parallel_config.go 的并行配置格式"
|
||||
```
|
||||
|
||||
指定工作目录(第 2 个位置参数):
|
||||
|
||||
```bash
|
||||
codeagent "在当前 repo 下搜索潜在数据竞争" .
|
||||
```
|
||||
|
||||
显式从 stdin 读取 task(使用 `-`):
|
||||
|
||||
```bash
|
||||
cat task.txt | codeagent -
|
||||
```
|
||||
|
||||
恢复会话:
|
||||
|
||||
```bash
|
||||
codeagent resume <session_id> "继续上次任务"
|
||||
```
|
||||
|
||||
并行模式(从 stdin 读取任务配置;禁止位置参数):
|
||||
|
||||
```bash
|
||||
codeagent --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: t1
|
||||
workdir: .
|
||||
backend: codex
|
||||
---CONTENT---
|
||||
列出本项目的主要模块以及它们的职责。
|
||||
---TASK---
|
||||
id: t2
|
||||
dependencies: t1
|
||||
backend: claude
|
||||
---CONTENT---
|
||||
基于 t1 的结论,提出重构风险点与建议。
|
||||
EOF
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 配置文件
|
||||
|
||||
默认查找路径(当 `--config` 为空时):
|
||||
|
||||
- `$HOME/.codeagent/config.(yaml|yml|json|toml|...)`
|
||||
|
||||
示例(YAML):
|
||||
|
||||
```yaml
|
||||
backend: codex
|
||||
model: gpt-4.1
|
||||
skip-permissions: false
|
||||
```
|
||||
|
||||
也可以通过 `--config /path/to/config.yaml` 显式指定。
|
||||
|
||||
### 环境变量(`CODEAGENT_*`)
|
||||
|
||||
通过 viper 读取并自动映射 `-` 为 `_`,常用项:
|
||||
|
||||
- `CODEAGENT_BACKEND`(`codex|claude|gemini|opencode`)
|
||||
- `CODEAGENT_MODEL`
|
||||
- `CODEAGENT_AGENT`
|
||||
- `CODEAGENT_PROMPT_FILE`
|
||||
- `CODEAGENT_REASONING_EFFORT`
|
||||
- `CODEAGENT_SKIP_PERMISSIONS`
|
||||
- `CODEAGENT_FULL_OUTPUT`(并行模式 legacy 输出)
|
||||
- `CODEAGENT_MAX_PARALLEL_WORKERS`(0 表示不限制,上限 100)
|
||||
|
||||
### Agent 预设(`~/.codeagent/models.json`)
|
||||
|
||||
可在 `~/.codeagent/models.json` 定义 agent → backend/model/prompt 等映射,用 `--agent <name>` 选择:
|
||||
|
||||
```json
|
||||
{
|
||||
"default_backend": "opencode",
|
||||
"default_model": "opencode/grok-code",
|
||||
"agents": {
|
||||
"develop": {
|
||||
"backend": "codex",
|
||||
"model": "gpt-4.1",
|
||||
"prompt_file": "~/.codeagent/prompts/develop.md",
|
||||
"description": "Code development"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 支持的后端
|
||||
|
||||
该项目本身不内置模型能力,依赖你本机安装并可在 `PATH` 中找到对应 CLI:
|
||||
|
||||
- `codex`:执行 `codex e ...`(默认会添加 `--dangerously-bypass-approvals-and-sandbox`;如需关闭请设置 `CODEX_BYPASS_SANDBOX=false`)
|
||||
- `claude`:执行 `claude -p ... --output-format stream-json`(默认会跳过权限提示;如需开启请设置 `CODEAGENT_SKIP_PERMISSIONS=false`)
|
||||
- `gemini`:执行 `gemini ... -o stream-json`(可从 `~/.gemini/.env` 加载环境变量)
|
||||
- `opencode`:执行 `opencode run --format json`
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
make build
|
||||
make test
|
||||
make lint
|
||||
make clean
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
- macOS 下如果看到临时目录相关的 `permission denied`(例如临时可执行文件无法在 `/var/folders/.../T` 执行),可设置一个可执行的临时目录:`CODEAGENT_TMPDIR=$HOME/.codeagent/tmp`。
|
||||
- `claude` 后端的 `base_url/api_key`(来自 `~/.codeagent/models.json`)会注入到子进程环境变量:`ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY`。若 `base_url` 指向本地代理(如 `localhost:23001`),请确认代理进程在运行。
|
||||
78
codeagent-wrapper/backend.go
Normal file
78
codeagent-wrapper/backend.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
// Backend defines the contract for invoking different AI CLI backends.
|
||||
// Each backend is responsible for supplying the executable command and
|
||||
// building the argument list based on the wrapper config.
|
||||
type Backend interface {
|
||||
Name() string
|
||||
BuildArgs(cfg *Config, targetArg string) []string
|
||||
Command() string
|
||||
}
|
||||
|
||||
type CodexBackend struct{}
|
||||
|
||||
func (CodexBackend) Name() string { return "codex" }
|
||||
func (CodexBackend) Command() string {
|
||||
return "codex"
|
||||
}
|
||||
func (CodexBackend) BuildArgs(cfg *Config, targetArg string) []string {
|
||||
return buildCodexArgs(cfg, targetArg)
|
||||
}
|
||||
|
||||
type ClaudeBackend struct{}
|
||||
|
||||
func (ClaudeBackend) Name() string { return "claude" }
|
||||
func (ClaudeBackend) Command() string {
|
||||
return "claude"
|
||||
}
|
||||
func (ClaudeBackend) BuildArgs(cfg *Config, targetArg string) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
args := []string{"-p", "--dangerously-skip-permissions"}
|
||||
|
||||
// Only skip permissions when explicitly requested
|
||||
// if cfg.SkipPermissions {
|
||||
// args = append(args, "--dangerously-skip-permissions")
|
||||
// }
|
||||
|
||||
// Prevent infinite recursion: disable all setting sources (user, project, local)
|
||||
// This ensures a clean execution environment without CLAUDE.md or skills that would trigger codeagent
|
||||
args = append(args, "--setting-sources", "")
|
||||
|
||||
if cfg.Mode == "resume" {
|
||||
if cfg.SessionID != "" {
|
||||
// Claude CLI uses -r <session_id> for resume.
|
||||
args = append(args, "-r", cfg.SessionID)
|
||||
}
|
||||
}
|
||||
// Note: claude CLI doesn't support -C flag; workdir set via cmd.Dir
|
||||
|
||||
args = append(args, "--output-format", "stream-json", "--verbose", targetArg)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
type GeminiBackend struct{}
|
||||
|
||||
func (GeminiBackend) Name() string { return "gemini" }
|
||||
func (GeminiBackend) Command() string {
|
||||
return "gemini"
|
||||
}
|
||||
func (GeminiBackend) BuildArgs(cfg *Config, targetArg string) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
args := []string{"-o", "stream-json", "-y"}
|
||||
|
||||
if cfg.Mode == "resume" {
|
||||
if cfg.SessionID != "" {
|
||||
args = append(args, "-r", cfg.SessionID)
|
||||
}
|
||||
}
|
||||
// Note: gemini CLI doesn't support -C flag; workdir set via cmd.Dir
|
||||
|
||||
args = append(args, "-p", targetArg)
|
||||
|
||||
return args
|
||||
}
|
||||
122
codeagent-wrapper/backend_test.go
Normal file
122
codeagent-wrapper/backend_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) {
|
||||
backend := ClaudeBackend{}
|
||||
|
||||
t.Run("new mode uses workdir without skip by default", func(t *testing.T) {
|
||||
cfg := &Config{Mode: "new", WorkDir: "/repo"}
|
||||
got := backend.BuildArgs(cfg, "todo")
|
||||
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "todo"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new mode opt-in skip permissions with default workdir", func(t *testing.T) {
|
||||
cfg := &Config{Mode: "new", SkipPermissions: true}
|
||||
got := backend.BuildArgs(cfg, "-")
|
||||
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "-"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume mode uses session id and omits workdir", func(t *testing.T) {
|
||||
cfg := &Config{Mode: "resume", SessionID: "sid-123", WorkDir: "/ignored"}
|
||||
got := backend.BuildArgs(cfg, "resume-task")
|
||||
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume mode without session still returns base flags", func(t *testing.T) {
|
||||
cfg := &Config{Mode: "resume", WorkDir: "/ignored"}
|
||||
got := backend.BuildArgs(cfg, "follow-up")
|
||||
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "follow-up"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil config returns nil", func(t *testing.T) {
|
||||
if backend.BuildArgs(nil, "ignored") != nil {
|
||||
t.Fatalf("nil config should return nil args")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaudeBuildArgs_GeminiAndCodexModes(t *testing.T) {
|
||||
t.Run("gemini new mode defaults workdir", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &Config{Mode: "new", WorkDir: "/workspace"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"-o", "stream-json", "-y", "-p", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini resume mode uses session id", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &Config{Mode: "resume", SessionID: "sid-999"}
|
||||
got := backend.BuildArgs(cfg, "resume")
|
||||
want := []string{"-o", "stream-json", "-y", "-r", "sid-999", "-p", "resume"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini resume mode without session omits identifier", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &Config{Mode: "resume"}
|
||||
got := backend.BuildArgs(cfg, "resume")
|
||||
want := []string{"-o", "stream-json", "-y", "-p", "resume"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini nil config returns nil", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
if backend.BuildArgs(nil, "ignored") != nil {
|
||||
t.Fatalf("nil config should return nil args")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("codex build args passthrough remains intact", func(t *testing.T) {
|
||||
backend := CodexBackend{}
|
||||
cfg := &Config{Mode: "new", WorkDir: "/tmp"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"e", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaudeBuildArgs_BackendMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
backend Backend
|
||||
name string
|
||||
command string
|
||||
}{
|
||||
{backend: CodexBackend{}, name: "codex", command: "codex"},
|
||||
{backend: ClaudeBackend{}, name: "claude", command: "claude"},
|
||||
{backend: GeminiBackend{}, name: "gemini", command: "gemini"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := tt.backend.Name(); got != tt.name {
|
||||
t.Fatalf("Name() = %s, want %s", got, tt.name)
|
||||
}
|
||||
if got := tt.backend.Command(); got != tt.command {
|
||||
t.Fatalf("Command() = %s, want %s", got, tt.command)
|
||||
}
|
||||
}
|
||||
}
|
||||
39
codeagent-wrapper/bench_test.go
Normal file
39
codeagent-wrapper/bench_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// BenchmarkLoggerWrite 测试日志写入性能
|
||||
func BenchmarkLoggerWrite(b *testing.B) {
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
logger.Info("benchmark log message")
|
||||
}
|
||||
b.StopTimer()
|
||||
logger.Flush()
|
||||
}
|
||||
|
||||
// BenchmarkLoggerConcurrentWrite 测试并发日志写入性能
|
||||
func BenchmarkLoggerConcurrentWrite(b *testing.B) {
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
logger.Info("concurrent benchmark log message")
|
||||
}
|
||||
})
|
||||
b.StopTimer()
|
||||
logger.Flush()
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package main
|
||||
|
||||
import app "codeagent-wrapper/internal/app"
|
||||
|
||||
func main() {
|
||||
app.Run()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package wrapper
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -11,29 +11,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
func stripTimestampPrefix(line string) string {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "{") {
|
||||
var evt struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &evt); err == nil && evt.Message != "" {
|
||||
return evt.Message
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(line, "[") {
|
||||
return line
|
||||
}
|
||||
if idx := strings.Index(line, "] "); idx >= 0 {
|
||||
return line[idx+2:]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// TestConcurrentStressLogger 高并发压力测试
|
||||
func TestConcurrentStressLogger(t *testing.T) {
|
||||
if testing.Short() {
|
||||
@@ -100,8 +79,7 @@ func TestConcurrentStressLogger(t *testing.T) {
|
||||
// 验证日志格式(纯文本,无前缀)
|
||||
formatRE := regexp.MustCompile(`^goroutine-\d+-msg-\d+$`)
|
||||
for i, line := range lines[:min(10, len(lines))] {
|
||||
msg := stripTimestampPrefix(line)
|
||||
if !formatRE.MatchString(msg) {
|
||||
if !formatRE.MatchString(line) {
|
||||
t.Errorf("line %d has invalid format: %s", i, line)
|
||||
}
|
||||
}
|
||||
@@ -313,7 +291,7 @@ func TestLoggerOrderPreservation(t *testing.T) {
|
||||
sequences := make(map[int][]int) // goroutine ID -> sequence numbers
|
||||
|
||||
for scanner.Scan() {
|
||||
line := stripTimestampPrefix(scanner.Text())
|
||||
line := scanner.Text()
|
||||
var gid, seq int
|
||||
// Parse format: G0-SEQ0001 (without INFO: prefix)
|
||||
_, err := fmt.Sscanf(line, "G%d-SEQ%04d", &gid, &seq)
|
||||
273
codeagent-wrapper/config.go
Normal file
273
codeagent-wrapper/config.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds CLI configuration
|
||||
type Config struct {
|
||||
Mode string // "new" or "resume"
|
||||
Task string
|
||||
SessionID string
|
||||
WorkDir string
|
||||
ExplicitStdin bool
|
||||
Timeout int
|
||||
Backend string
|
||||
SkipPermissions bool
|
||||
MaxParallelWorkers int
|
||||
}
|
||||
|
||||
// ParallelConfig defines the JSON schema for parallel execution
|
||||
type ParallelConfig struct {
|
||||
Tasks []TaskSpec `json:"tasks"`
|
||||
GlobalBackend string `json:"backend,omitempty"`
|
||||
}
|
||||
|
||||
// TaskSpec describes an individual task entry in the parallel config
|
||||
type TaskSpec struct {
|
||||
ID string `json:"id"`
|
||||
Task string `json:"task"`
|
||||
WorkDir string `json:"workdir,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Backend string `json:"backend,omitempty"`
|
||||
Mode string `json:"-"`
|
||||
UseStdin bool `json:"-"`
|
||||
Context context.Context `json:"-"`
|
||||
}
|
||||
|
||||
// TaskResult captures the execution outcome of a task
|
||||
type TaskResult struct {
|
||||
TaskID string `json:"task_id"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Message string `json:"message"`
|
||||
SessionID string `json:"session_id"`
|
||||
Error string `json:"error"`
|
||||
LogPath string `json:"log_path"`
|
||||
sharedLog bool
|
||||
}
|
||||
|
||||
var backendRegistry = map[string]Backend{
|
||||
"codex": CodexBackend{},
|
||||
"claude": ClaudeBackend{},
|
||||
"gemini": GeminiBackend{},
|
||||
}
|
||||
|
||||
func selectBackend(name string) (Backend, error) {
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if key == "" {
|
||||
key = defaultBackendName
|
||||
}
|
||||
if backend, ok := backendRegistry[key]; ok {
|
||||
return backend, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported backend %q", name)
|
||||
}
|
||||
|
||||
func envFlagEnabled(key string) bool {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
val = strings.TrimSpace(strings.ToLower(val))
|
||||
switch val {
|
||||
case "", "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func parseBoolFlag(val string, defaultValue bool) bool {
|
||||
val = strings.TrimSpace(strings.ToLower(val))
|
||||
switch val {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
func parseParallelConfig(data []byte) (*ParallelConfig, error) {
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if len(trimmed) == 0 {
|
||||
return nil, fmt.Errorf("parallel config is empty")
|
||||
}
|
||||
|
||||
tasks := strings.Split(string(trimmed), "---TASK---")
|
||||
var cfg ParallelConfig
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
taskIndex := 0
|
||||
for _, taskBlock := range tasks {
|
||||
taskBlock = strings.TrimSpace(taskBlock)
|
||||
if taskBlock == "" {
|
||||
continue
|
||||
}
|
||||
taskIndex++
|
||||
|
||||
parts := strings.SplitN(taskBlock, "---CONTENT---", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("task block #%d missing ---CONTENT--- separator", taskIndex)
|
||||
}
|
||||
|
||||
meta := strings.TrimSpace(parts[0])
|
||||
content := strings.TrimSpace(parts[1])
|
||||
|
||||
task := TaskSpec{WorkDir: defaultWorkdir}
|
||||
for _, line := range strings.Split(meta, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(line, ":", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(kv[0])
|
||||
value := strings.TrimSpace(kv[1])
|
||||
|
||||
switch key {
|
||||
case "id":
|
||||
task.ID = value
|
||||
case "workdir":
|
||||
task.WorkDir = value
|
||||
case "session_id":
|
||||
task.SessionID = value
|
||||
task.Mode = "resume"
|
||||
case "backend":
|
||||
task.Backend = value
|
||||
case "dependencies":
|
||||
for _, dep := range strings.Split(value, ",") {
|
||||
dep = strings.TrimSpace(dep)
|
||||
if dep != "" {
|
||||
task.Dependencies = append(task.Dependencies, dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if task.Mode == "" {
|
||||
task.Mode = "new"
|
||||
}
|
||||
|
||||
if task.ID == "" {
|
||||
return nil, fmt.Errorf("task block #%d missing id field", taskIndex)
|
||||
}
|
||||
if content == "" {
|
||||
return nil, fmt.Errorf("task block #%d (%q) missing content", taskIndex, task.ID)
|
||||
}
|
||||
if _, exists := seen[task.ID]; exists {
|
||||
return nil, fmt.Errorf("task block #%d has duplicate id: %s", taskIndex, task.ID)
|
||||
}
|
||||
|
||||
task.Task = content
|
||||
cfg.Tasks = append(cfg.Tasks, task)
|
||||
seen[task.ID] = struct{}{}
|
||||
}
|
||||
|
||||
if len(cfg.Tasks) == 0 {
|
||||
return nil, fmt.Errorf("no tasks found")
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func parseArgs() (*Config, error) {
|
||||
args := os.Args[1:]
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("task required")
|
||||
}
|
||||
|
||||
backendName := defaultBackendName
|
||||
skipPermissions := envFlagEnabled("CODEAGENT_SKIP_PERMISSIONS")
|
||||
filtered := make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--backend":
|
||||
if i+1 >= len(args) {
|
||||
return nil, fmt.Errorf("--backend flag requires a value")
|
||||
}
|
||||
backendName = args[i+1]
|
||||
i++
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--backend="):
|
||||
value := strings.TrimPrefix(arg, "--backend=")
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("--backend flag requires a value")
|
||||
}
|
||||
backendName = value
|
||||
continue
|
||||
case arg == "--skip-permissions", arg == "--dangerously-skip-permissions":
|
||||
skipPermissions = true
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--skip-permissions="):
|
||||
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--skip-permissions="), skipPermissions)
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--dangerously-skip-permissions="):
|
||||
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--dangerously-skip-permissions="), skipPermissions)
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, arg)
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return nil, fmt.Errorf("task required")
|
||||
}
|
||||
args = filtered
|
||||
|
||||
cfg := &Config{WorkDir: defaultWorkdir, Backend: backendName, SkipPermissions: skipPermissions}
|
||||
cfg.MaxParallelWorkers = resolveMaxParallelWorkers()
|
||||
|
||||
if args[0] == "resume" {
|
||||
if len(args) < 3 {
|
||||
return nil, fmt.Errorf("resume mode requires: resume <session_id> <task>")
|
||||
}
|
||||
cfg.Mode = "resume"
|
||||
cfg.SessionID = args[1]
|
||||
cfg.Task = args[2]
|
||||
cfg.ExplicitStdin = (args[2] == "-")
|
||||
if len(args) > 3 {
|
||||
cfg.WorkDir = args[3]
|
||||
}
|
||||
} else {
|
||||
cfg.Mode = "new"
|
||||
cfg.Task = args[0]
|
||||
cfg.ExplicitStdin = (args[0] == "-")
|
||||
if len(args) > 1 {
|
||||
cfg.WorkDir = args[1]
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
const maxParallelWorkersLimit = 100
|
||||
|
||||
func resolveMaxParallelWorkers() int {
|
||||
raw := strings.TrimSpace(os.Getenv("CODEAGENT_MAX_PARALLEL_WORKERS"))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 0 {
|
||||
logWarn(fmt.Sprintf("Invalid CODEAGENT_MAX_PARALLEL_WORKERS=%q, falling back to unlimited", raw))
|
||||
return 0
|
||||
}
|
||||
|
||||
if value > maxParallelWorkersLimit {
|
||||
logWarn(fmt.Sprintf("CODEAGENT_MAX_PARALLEL_WORKERS=%d exceeds limit, capping at %d", value, maxParallelWorkersLimit))
|
||||
return maxParallelWorkersLimit
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
982
codeagent-wrapper/executor.go
Normal file
982
codeagent-wrapper/executor.go
Normal file
@@ -0,0 +1,982 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// commandRunner abstracts exec.Cmd for testability
|
||||
type commandRunner interface {
|
||||
Start() error
|
||||
Wait() error
|
||||
StdoutPipe() (io.ReadCloser, error)
|
||||
StdinPipe() (io.WriteCloser, error)
|
||||
SetStderr(io.Writer)
|
||||
SetDir(string)
|
||||
Process() processHandle
|
||||
}
|
||||
|
||||
// processHandle abstracts os.Process for testability
|
||||
type processHandle interface {
|
||||
Pid() int
|
||||
Kill() error
|
||||
Signal(os.Signal) error
|
||||
}
|
||||
|
||||
// realCmd implements commandRunner using exec.Cmd
|
||||
type realCmd struct {
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (r *realCmd) Start() error {
|
||||
if r.cmd == nil {
|
||||
return errors.New("command is nil")
|
||||
}
|
||||
return r.cmd.Start()
|
||||
}
|
||||
|
||||
func (r *realCmd) Wait() error {
|
||||
if r.cmd == nil {
|
||||
return errors.New("command is nil")
|
||||
}
|
||||
return r.cmd.Wait()
|
||||
}
|
||||
|
||||
func (r *realCmd) StdoutPipe() (io.ReadCloser, error) {
|
||||
if r.cmd == nil {
|
||||
return nil, errors.New("command is nil")
|
||||
}
|
||||
return r.cmd.StdoutPipe()
|
||||
}
|
||||
|
||||
func (r *realCmd) StdinPipe() (io.WriteCloser, error) {
|
||||
if r.cmd == nil {
|
||||
return nil, errors.New("command is nil")
|
||||
}
|
||||
return r.cmd.StdinPipe()
|
||||
}
|
||||
|
||||
func (r *realCmd) SetStderr(w io.Writer) {
|
||||
if r.cmd != nil {
|
||||
r.cmd.Stderr = w
|
||||
}
|
||||
}
|
||||
|
||||
func (r *realCmd) SetDir(dir string) {
|
||||
if r.cmd != nil {
|
||||
r.cmd.Dir = dir
|
||||
}
|
||||
}
|
||||
|
||||
func (r *realCmd) Process() processHandle {
|
||||
if r == nil || r.cmd == nil || r.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return &realProcess{proc: r.cmd.Process}
|
||||
}
|
||||
|
||||
// realProcess implements processHandle using os.Process
|
||||
type realProcess struct {
|
||||
proc *os.Process
|
||||
}
|
||||
|
||||
func (p *realProcess) Pid() int {
|
||||
if p == nil || p.proc == nil {
|
||||
return 0
|
||||
}
|
||||
return p.proc.Pid
|
||||
}
|
||||
|
||||
func (p *realProcess) Kill() error {
|
||||
if p == nil || p.proc == nil {
|
||||
return nil
|
||||
}
|
||||
return p.proc.Kill()
|
||||
}
|
||||
|
||||
func (p *realProcess) Signal(sig os.Signal) error {
|
||||
if p == nil || p.proc == nil {
|
||||
return nil
|
||||
}
|
||||
return p.proc.Signal(sig)
|
||||
}
|
||||
|
||||
// newCommandRunner creates a new commandRunner (test hook injection point)
|
||||
var newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &realCmd{cmd: commandContext(ctx, name, args...)}
|
||||
}
|
||||
|
||||
type parseResult struct {
|
||||
message string
|
||||
threadID string
|
||||
}
|
||||
|
||||
type taskLoggerContextKey struct{}
|
||||
|
||||
func withTaskLogger(ctx context.Context, logger *Logger) context.Context {
|
||||
if ctx == nil || logger == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, taskLoggerContextKey{}, logger)
|
||||
}
|
||||
|
||||
func taskLoggerFromContext(ctx context.Context) *Logger {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
logger, _ := ctx.Value(taskLoggerContextKey{}).(*Logger)
|
||||
return logger
|
||||
}
|
||||
|
||||
type taskLoggerHandle struct {
|
||||
logger *Logger
|
||||
path string
|
||||
shared bool
|
||||
closeFn func()
|
||||
}
|
||||
|
||||
func newTaskLoggerHandle(taskID string) taskLoggerHandle {
|
||||
taskLogger, err := NewLoggerWithSuffix(taskID)
|
||||
if err == nil {
|
||||
return taskLoggerHandle{
|
||||
logger: taskLogger,
|
||||
path: taskLogger.Path(),
|
||||
closeFn: func() { _ = taskLogger.Close() },
|
||||
}
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Failed to create task logger for %s: %v, using main logger", taskID, err)
|
||||
mainLogger := activeLogger()
|
||||
if mainLogger != nil {
|
||||
logWarn(msg)
|
||||
return taskLoggerHandle{
|
||||
logger: mainLogger,
|
||||
path: mainLogger.Path(),
|
||||
shared: true,
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, msg)
|
||||
return taskLoggerHandle{}
|
||||
}
|
||||
|
||||
// defaultRunCodexTaskFn is the default implementation of runCodexTaskFn (exposed for test reset)
|
||||
func defaultRunCodexTaskFn(task TaskSpec, timeout int) TaskResult {
|
||||
if task.WorkDir == "" {
|
||||
task.WorkDir = defaultWorkdir
|
||||
}
|
||||
if task.Mode == "" {
|
||||
task.Mode = "new"
|
||||
}
|
||||
if task.UseStdin || shouldUseStdin(task.Task, false) {
|
||||
task.UseStdin = true
|
||||
}
|
||||
|
||||
backendName := task.Backend
|
||||
if backendName == "" {
|
||||
backendName = defaultBackendName
|
||||
}
|
||||
|
||||
backend, err := selectBackendFn(backendName)
|
||||
if err != nil {
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: err.Error()}
|
||||
}
|
||||
task.Backend = backend.Name()
|
||||
|
||||
parentCtx := task.Context
|
||||
if parentCtx == nil {
|
||||
parentCtx = context.Background()
|
||||
}
|
||||
return runCodexTaskWithContext(parentCtx, task, backend, nil, false, true, timeout)
|
||||
}
|
||||
|
||||
var runCodexTaskFn = defaultRunCodexTaskFn
|
||||
|
||||
func topologicalSort(tasks []TaskSpec) ([][]TaskSpec, error) {
|
||||
idToTask := make(map[string]TaskSpec, len(tasks))
|
||||
indegree := make(map[string]int, len(tasks))
|
||||
adj := make(map[string][]string, len(tasks))
|
||||
|
||||
for _, task := range tasks {
|
||||
idToTask[task.ID] = task
|
||||
indegree[task.ID] = 0
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
for _, dep := range task.Dependencies {
|
||||
if _, ok := idToTask[dep]; !ok {
|
||||
return nil, fmt.Errorf("dependency %q not found for task %q", dep, task.ID)
|
||||
}
|
||||
indegree[task.ID]++
|
||||
adj[dep] = append(adj[dep], task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
queue := make([]string, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if indegree[task.ID] == 0 {
|
||||
queue = append(queue, task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
layers := make([][]TaskSpec, 0)
|
||||
processed := 0
|
||||
|
||||
for len(queue) > 0 {
|
||||
current := queue
|
||||
queue = nil
|
||||
layer := make([]TaskSpec, len(current))
|
||||
for i, id := range current {
|
||||
layer[i] = idToTask[id]
|
||||
processed++
|
||||
}
|
||||
layers = append(layers, layer)
|
||||
|
||||
next := make([]string, 0)
|
||||
for _, id := range current {
|
||||
for _, neighbor := range adj[id] {
|
||||
indegree[neighbor]--
|
||||
if indegree[neighbor] == 0 {
|
||||
next = append(next, neighbor)
|
||||
}
|
||||
}
|
||||
}
|
||||
queue = append(queue, next...)
|
||||
}
|
||||
|
||||
if processed != len(tasks) {
|
||||
cycleIDs := make([]string, 0)
|
||||
for id, deg := range indegree {
|
||||
if deg > 0 {
|
||||
cycleIDs = append(cycleIDs, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(cycleIDs)
|
||||
return nil, fmt.Errorf("cycle detected involving tasks: %s", strings.Join(cycleIDs, ","))
|
||||
}
|
||||
|
||||
return layers, nil
|
||||
}
|
||||
|
||||
func executeConcurrent(layers [][]TaskSpec, timeout int) []TaskResult {
|
||||
maxWorkers := resolveMaxParallelWorkers()
|
||||
return executeConcurrentWithContext(context.Background(), layers, timeout, maxWorkers)
|
||||
}
|
||||
|
||||
func executeConcurrentWithContext(parentCtx context.Context, layers [][]TaskSpec, timeout int, maxWorkers int) []TaskResult {
|
||||
totalTasks := 0
|
||||
for _, layer := range layers {
|
||||
totalTasks += len(layer)
|
||||
}
|
||||
|
||||
results := make([]TaskResult, 0, totalTasks)
|
||||
failed := make(map[string]TaskResult, totalTasks)
|
||||
resultsCh := make(chan TaskResult, totalTasks)
|
||||
|
||||
var startPrintMu sync.Mutex
|
||||
bannerPrinted := false
|
||||
|
||||
printTaskStart := func(taskID, logPath string, shared bool) {
|
||||
if logPath == "" {
|
||||
return
|
||||
}
|
||||
startPrintMu.Lock()
|
||||
if !bannerPrinted {
|
||||
fmt.Fprintln(os.Stderr, "=== Starting Parallel Execution ===")
|
||||
bannerPrinted = true
|
||||
}
|
||||
label := "Log"
|
||||
if shared {
|
||||
label = "Log (shared)"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Task %s: %s: %s\n", taskID, label, logPath)
|
||||
startPrintMu.Unlock()
|
||||
}
|
||||
|
||||
ctx := parentCtx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
workerLimit := maxWorkers
|
||||
if workerLimit < 0 {
|
||||
workerLimit = 0
|
||||
}
|
||||
|
||||
var sem chan struct{}
|
||||
if workerLimit > 0 {
|
||||
sem = make(chan struct{}, workerLimit)
|
||||
}
|
||||
|
||||
logConcurrencyPlanning(workerLimit, totalTasks)
|
||||
|
||||
acquireSlot := func() bool {
|
||||
if sem == nil {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
releaseSlot := func() {
|
||||
if sem == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-sem:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
var activeWorkers int64
|
||||
|
||||
for _, layer := range layers {
|
||||
var wg sync.WaitGroup
|
||||
executed := 0
|
||||
|
||||
for _, task := range layer {
|
||||
if skip, reason := shouldSkipTask(task, failed); skip {
|
||||
res := TaskResult{TaskID: task.ID, ExitCode: 1, Error: reason}
|
||||
results = append(results, res)
|
||||
failed[task.ID] = res
|
||||
continue
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
res := cancelledTaskResult(task.ID, ctx)
|
||||
results = append(results, res)
|
||||
failed[task.ID] = res
|
||||
continue
|
||||
}
|
||||
|
||||
executed++
|
||||
wg.Add(1)
|
||||
go func(ts TaskSpec) {
|
||||
defer wg.Done()
|
||||
var taskLogPath string
|
||||
handle := taskLoggerHandle{}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resultsCh <- TaskResult{TaskID: ts.ID, ExitCode: 1, Error: fmt.Sprintf("panic: %v", r), LogPath: taskLogPath, sharedLog: handle.shared}
|
||||
}
|
||||
}()
|
||||
|
||||
if !acquireSlot() {
|
||||
resultsCh <- cancelledTaskResult(ts.ID, ctx)
|
||||
return
|
||||
}
|
||||
defer releaseSlot()
|
||||
|
||||
current := atomic.AddInt64(&activeWorkers, 1)
|
||||
logConcurrencyState("start", ts.ID, int(current), workerLimit)
|
||||
defer func() {
|
||||
after := atomic.AddInt64(&activeWorkers, -1)
|
||||
logConcurrencyState("done", ts.ID, int(after), workerLimit)
|
||||
}()
|
||||
|
||||
handle = newTaskLoggerHandle(ts.ID)
|
||||
taskLogPath = handle.path
|
||||
if handle.closeFn != nil {
|
||||
defer handle.closeFn()
|
||||
}
|
||||
|
||||
taskCtx := ctx
|
||||
if handle.logger != nil {
|
||||
taskCtx = withTaskLogger(ctx, handle.logger)
|
||||
}
|
||||
ts.Context = taskCtx
|
||||
|
||||
printTaskStart(ts.ID, taskLogPath, handle.shared)
|
||||
|
||||
res := runCodexTaskFn(ts, timeout)
|
||||
if taskLogPath != "" {
|
||||
if res.LogPath == "" || (handle.shared && handle.logger != nil && res.LogPath == handle.logger.Path()) {
|
||||
res.LogPath = taskLogPath
|
||||
}
|
||||
}
|
||||
// 只有当最终的 LogPath 确实是共享 logger 的路径时才标记为 shared
|
||||
if handle.shared && handle.logger != nil && res.LogPath == handle.logger.Path() {
|
||||
res.sharedLog = true
|
||||
}
|
||||
resultsCh <- res
|
||||
}(task)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < executed; i++ {
|
||||
res := <-resultsCh
|
||||
results = append(results, res)
|
||||
if res.ExitCode != 0 || res.Error != "" {
|
||||
failed[res.TaskID] = res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func cancelledTaskResult(taskID string, ctx context.Context) TaskResult {
|
||||
exitCode := 130
|
||||
msg := "execution cancelled"
|
||||
if ctx != nil && errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
exitCode = 124
|
||||
msg = "execution timeout"
|
||||
}
|
||||
return TaskResult{TaskID: taskID, ExitCode: exitCode, Error: msg}
|
||||
}
|
||||
|
||||
func shouldSkipTask(task TaskSpec, failed map[string]TaskResult) (bool, string) {
|
||||
if len(task.Dependencies) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
var blocked []string
|
||||
for _, dep := range task.Dependencies {
|
||||
if _, ok := failed[dep]; ok {
|
||||
blocked = append(blocked, dep)
|
||||
}
|
||||
}
|
||||
|
||||
if len(blocked) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return true, fmt.Sprintf("skipped due to failed dependencies: %s", strings.Join(blocked, ","))
|
||||
}
|
||||
|
||||
func generateFinalOutput(results []TaskResult) string {
|
||||
var sb strings.Builder
|
||||
|
||||
success := 0
|
||||
failed := 0
|
||||
for _, res := range results {
|
||||
if res.ExitCode == 0 && res.Error == "" {
|
||||
success++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("=== Parallel Execution Summary ===\n"))
|
||||
sb.WriteString(fmt.Sprintf("Total: %d | Success: %d | Failed: %d\n\n", len(results), success, failed))
|
||||
|
||||
for _, res := range results {
|
||||
sb.WriteString(fmt.Sprintf("--- Task: %s ---\n", res.TaskID))
|
||||
if res.Error != "" {
|
||||
sb.WriteString(fmt.Sprintf("Status: FAILED (exit code %d)\nError: %s\n", res.ExitCode, res.Error))
|
||||
} else if res.ExitCode != 0 {
|
||||
sb.WriteString(fmt.Sprintf("Status: FAILED (exit code %d)\n", res.ExitCode))
|
||||
} else {
|
||||
sb.WriteString("Status: SUCCESS\n")
|
||||
}
|
||||
if res.SessionID != "" {
|
||||
sb.WriteString(fmt.Sprintf("Session: %s\n", res.SessionID))
|
||||
}
|
||||
if res.LogPath != "" {
|
||||
if res.sharedLog {
|
||||
sb.WriteString(fmt.Sprintf("Log: %s (shared)\n", res.LogPath))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("Log: %s\n", res.LogPath))
|
||||
}
|
||||
}
|
||||
if res.Message != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n%s\n", res.Message))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func buildCodexArgs(cfg *Config, targetArg string) []string {
|
||||
if cfg.Mode == "resume" {
|
||||
return []string{
|
||||
"e",
|
||||
"--skip-git-repo-check",
|
||||
"--json",
|
||||
"resume",
|
||||
cfg.SessionID,
|
||||
targetArg,
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
"e",
|
||||
"--skip-git-repo-check",
|
||||
"-C", cfg.WorkDir,
|
||||
"--json",
|
||||
targetArg,
|
||||
}
|
||||
}
|
||||
|
||||
func runCodexTask(taskSpec TaskSpec, silent bool, timeoutSec int) TaskResult {
|
||||
return runCodexTaskWithContext(context.Background(), taskSpec, nil, nil, false, silent, timeoutSec)
|
||||
}
|
||||
|
||||
func runCodexProcess(parentCtx context.Context, codexArgs []string, taskText string, useStdin bool, timeoutSec int) (message, threadID string, exitCode int) {
|
||||
res := runCodexTaskWithContext(parentCtx, TaskSpec{Task: taskText, WorkDir: defaultWorkdir, Mode: "new", UseStdin: useStdin}, nil, codexArgs, true, false, timeoutSec)
|
||||
return res.Message, res.SessionID, res.ExitCode
|
||||
}
|
||||
|
||||
func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backend Backend, customArgs []string, useCustomArgs bool, silent bool, timeoutSec int) TaskResult {
|
||||
if parentCtx == nil {
|
||||
parentCtx = taskSpec.Context
|
||||
}
|
||||
if parentCtx == nil {
|
||||
parentCtx = context.Background()
|
||||
}
|
||||
|
||||
result := TaskResult{TaskID: taskSpec.ID}
|
||||
injectedLogger := taskLoggerFromContext(parentCtx)
|
||||
logger := injectedLogger
|
||||
|
||||
cfg := &Config{
|
||||
Mode: taskSpec.Mode,
|
||||
Task: taskSpec.Task,
|
||||
SessionID: taskSpec.SessionID,
|
||||
WorkDir: taskSpec.WorkDir,
|
||||
Backend: defaultBackendName,
|
||||
}
|
||||
|
||||
commandName := codexCommand
|
||||
argsBuilder := buildCodexArgsFn
|
||||
if backend != nil {
|
||||
commandName = backend.Command()
|
||||
argsBuilder = backend.BuildArgs
|
||||
cfg.Backend = backend.Name()
|
||||
} else if taskSpec.Backend != "" {
|
||||
cfg.Backend = taskSpec.Backend
|
||||
} else if commandName != "" {
|
||||
cfg.Backend = commandName
|
||||
}
|
||||
|
||||
if cfg.Mode == "" {
|
||||
cfg.Mode = "new"
|
||||
}
|
||||
if cfg.WorkDir == "" {
|
||||
cfg.WorkDir = defaultWorkdir
|
||||
}
|
||||
|
||||
useStdin := taskSpec.UseStdin
|
||||
targetArg := taskSpec.Task
|
||||
if useStdin {
|
||||
targetArg = "-"
|
||||
}
|
||||
|
||||
var codexArgs []string
|
||||
if useCustomArgs {
|
||||
codexArgs = customArgs
|
||||
} else {
|
||||
codexArgs = argsBuilder(cfg, targetArg)
|
||||
}
|
||||
|
||||
prefixMsg := func(msg string) string {
|
||||
if taskSpec.ID == "" {
|
||||
return msg
|
||||
}
|
||||
return fmt.Sprintf("[Task: %s] %s", taskSpec.ID, msg)
|
||||
}
|
||||
|
||||
var logInfoFn func(string)
|
||||
var logWarnFn func(string)
|
||||
var logErrorFn func(string)
|
||||
|
||||
if silent {
|
||||
// Silent mode: only persist to file when available; avoid stderr noise.
|
||||
logInfoFn = func(msg string) {
|
||||
if logger != nil {
|
||||
logger.Info(prefixMsg(msg))
|
||||
}
|
||||
}
|
||||
logWarnFn = func(msg string) {
|
||||
if logger != nil {
|
||||
logger.Warn(prefixMsg(msg))
|
||||
}
|
||||
}
|
||||
logErrorFn = func(msg string) {
|
||||
if logger != nil {
|
||||
logger.Error(prefixMsg(msg))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logInfoFn = func(msg string) { logInfo(prefixMsg(msg)) }
|
||||
logWarnFn = func(msg string) { logWarn(prefixMsg(msg)) }
|
||||
logErrorFn = func(msg string) { logError(prefixMsg(msg)) }
|
||||
}
|
||||
|
||||
stderrBuf := &tailBuffer{limit: stderrCaptureLimit}
|
||||
|
||||
var stdoutLogger *logWriter
|
||||
var stderrLogger *logWriter
|
||||
|
||||
var tempLogger *Logger
|
||||
if logger == nil && silent && activeLogger() == nil {
|
||||
if l, err := NewLogger(); err == nil {
|
||||
setLogger(l)
|
||||
tempLogger = l
|
||||
logger = l
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if tempLogger != nil {
|
||||
_ = closeLogger()
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
if result.LogPath != "" || logger == nil {
|
||||
return
|
||||
}
|
||||
result.LogPath = logger.Path()
|
||||
}()
|
||||
if logger == nil {
|
||||
logger = activeLogger()
|
||||
}
|
||||
if logger != nil {
|
||||
result.LogPath = logger.Path()
|
||||
}
|
||||
|
||||
if !silent {
|
||||
// Note: Empty prefix ensures backend output is logged as-is without any wrapper format.
|
||||
// This preserves the original stdout/stderr content from codex/claude/gemini backends.
|
||||
// Trade-off: Reduces distinguishability between stdout/stderr in logs, but maintains
|
||||
// output fidelity which is critical for debugging backend-specific issues.
|
||||
stdoutLogger = newLogWriter("", codexLogLineLimit)
|
||||
stderrLogger = newLogWriter("", codexLogLineLimit)
|
||||
}
|
||||
|
||||
ctx := parentCtx
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
attachStderr := func(msg string) string {
|
||||
return fmt.Sprintf("%s; stderr: %s", msg, stderrBuf.String())
|
||||
}
|
||||
|
||||
cmd := newCommandRunner(ctx, commandName, codexArgs...)
|
||||
|
||||
// For backends that don't support -C flag (claude, gemini), set working directory via cmd.Dir
|
||||
// Codex passes workdir via -C flag, so we skip setting Dir for it to avoid conflicts
|
||||
if cfg.Mode != "resume" && commandName != "codex" && cfg.WorkDir != "" {
|
||||
cmd.SetDir(cfg.WorkDir)
|
||||
}
|
||||
|
||||
stderrWriters := []io.Writer{stderrBuf}
|
||||
if stderrLogger != nil {
|
||||
stderrWriters = append(stderrWriters, stderrLogger)
|
||||
}
|
||||
|
||||
// For gemini backend, filter noisy stderr output
|
||||
var stderrFilter *filteringWriter
|
||||
if !silent {
|
||||
stderrOut := io.Writer(os.Stderr)
|
||||
if cfg.Backend == "gemini" {
|
||||
stderrFilter = newFilteringWriter(os.Stderr, geminiNoisePatterns)
|
||||
stderrOut = stderrFilter
|
||||
defer stderrFilter.Flush()
|
||||
}
|
||||
stderrWriters = append([]io.Writer{stderrOut}, stderrWriters...)
|
||||
}
|
||||
if len(stderrWriters) == 1 {
|
||||
cmd.SetStderr(stderrWriters[0])
|
||||
} else {
|
||||
cmd.SetStderr(io.MultiWriter(stderrWriters...))
|
||||
}
|
||||
|
||||
var stdinPipe io.WriteCloser
|
||||
var err error
|
||||
if useStdin {
|
||||
stdinPipe, err = cmd.StdinPipe()
|
||||
if err != nil {
|
||||
logErrorFn("Failed to create stdin pipe: " + err.Error())
|
||||
result.ExitCode = 1
|
||||
result.Error = attachStderr("failed to create stdin pipe: " + err.Error())
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
logErrorFn("Failed to create stdout pipe: " + err.Error())
|
||||
result.ExitCode = 1
|
||||
result.Error = attachStderr("failed to create stdout pipe: " + err.Error())
|
||||
return result
|
||||
}
|
||||
|
||||
stdoutReader := io.Reader(stdout)
|
||||
if stdoutLogger != nil {
|
||||
stdoutReader = io.TeeReader(stdout, stdoutLogger)
|
||||
}
|
||||
|
||||
// Start parse goroutine BEFORE starting the command to avoid race condition
|
||||
// where fast-completing commands close stdout before parser starts reading
|
||||
messageSeen := make(chan struct{}, 1)
|
||||
parseCh := make(chan parseResult, 1)
|
||||
go func() {
|
||||
msg, tid := parseJSONStreamInternal(stdoutReader, logWarnFn, logInfoFn, func() {
|
||||
select {
|
||||
case messageSeen <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
parseCh <- parseResult{message: msg, threadID: tid}
|
||||
}()
|
||||
|
||||
logInfoFn(fmt.Sprintf("Starting %s with args: %s %s...", commandName, commandName, strings.Join(codexArgs[:min(5, len(codexArgs))], " ")))
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
if strings.Contains(err.Error(), "executable file not found") {
|
||||
msg := fmt.Sprintf("%s command not found in PATH", commandName)
|
||||
logErrorFn(msg)
|
||||
result.ExitCode = 127
|
||||
result.Error = attachStderr(msg)
|
||||
return result
|
||||
}
|
||||
logErrorFn("Failed to start " + commandName + ": " + err.Error())
|
||||
result.ExitCode = 1
|
||||
result.Error = attachStderr("failed to start " + commandName + ": " + err.Error())
|
||||
return result
|
||||
}
|
||||
|
||||
logInfoFn(fmt.Sprintf("Starting %s with PID: %d", commandName, cmd.Process().Pid()))
|
||||
if logger != nil {
|
||||
logInfoFn(fmt.Sprintf("Log capturing to: %s", logger.Path()))
|
||||
}
|
||||
|
||||
if useStdin && stdinPipe != nil {
|
||||
logInfoFn(fmt.Sprintf("Writing %d chars to stdin...", len(taskSpec.Task)))
|
||||
go func(data string) {
|
||||
defer stdinPipe.Close()
|
||||
_, _ = io.WriteString(stdinPipe, data)
|
||||
}(taskSpec.Task)
|
||||
logInfoFn("Stdin closed")
|
||||
}
|
||||
|
||||
waitCh := make(chan error, 1)
|
||||
go func() { waitCh <- cmd.Wait() }()
|
||||
|
||||
var waitErr error
|
||||
var forceKillTimer *forceKillTimer
|
||||
var ctxCancelled bool
|
||||
|
||||
select {
|
||||
case waitErr = <-waitCh:
|
||||
case <-ctx.Done():
|
||||
ctxCancelled = true
|
||||
logErrorFn(cancelReason(commandName, ctx))
|
||||
forceKillTimer = terminateCommandFn(cmd)
|
||||
waitErr = <-waitCh
|
||||
}
|
||||
|
||||
if forceKillTimer != nil {
|
||||
forceKillTimer.Stop()
|
||||
}
|
||||
|
||||
var parsed parseResult
|
||||
if ctxCancelled {
|
||||
closeWithReason(stdout, stdoutCloseReasonCtx)
|
||||
parsed = <-parseCh
|
||||
} else {
|
||||
drainTimer := time.NewTimer(stdoutDrainTimeout)
|
||||
defer drainTimer.Stop()
|
||||
|
||||
select {
|
||||
case parsed = <-parseCh:
|
||||
closeWithReason(stdout, stdoutCloseReasonWait)
|
||||
case <-messageSeen:
|
||||
closeWithReason(stdout, stdoutCloseReasonWait)
|
||||
parsed = <-parseCh
|
||||
case <-drainTimer.C:
|
||||
closeWithReason(stdout, stdoutCloseReasonDrain)
|
||||
parsed = <-parseCh
|
||||
}
|
||||
}
|
||||
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
if errors.Is(ctxErr, context.DeadlineExceeded) {
|
||||
result.ExitCode = 124
|
||||
result.Error = attachStderr(fmt.Sprintf("%s execution timeout", commandName))
|
||||
return result
|
||||
}
|
||||
result.ExitCode = 130
|
||||
result.Error = attachStderr("execution cancelled")
|
||||
return result
|
||||
}
|
||||
|
||||
if waitErr != nil {
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||
code := exitErr.ExitCode()
|
||||
logErrorFn(fmt.Sprintf("%s exited with status %d", commandName, code))
|
||||
result.ExitCode = code
|
||||
result.Error = attachStderr(fmt.Sprintf("%s exited with status %d", commandName, code))
|
||||
return result
|
||||
}
|
||||
logErrorFn(commandName + " error: " + waitErr.Error())
|
||||
result.ExitCode = 1
|
||||
result.Error = attachStderr(commandName + " error: " + waitErr.Error())
|
||||
return result
|
||||
}
|
||||
|
||||
message := parsed.message
|
||||
threadID := parsed.threadID
|
||||
if message == "" {
|
||||
logErrorFn(fmt.Sprintf("%s completed without agent_message output", commandName))
|
||||
result.ExitCode = 1
|
||||
result.Error = attachStderr(fmt.Sprintf("%s completed without agent_message output", commandName))
|
||||
return result
|
||||
}
|
||||
|
||||
if stdoutLogger != nil {
|
||||
stdoutLogger.Flush()
|
||||
}
|
||||
if stderrLogger != nil {
|
||||
stderrLogger.Flush()
|
||||
}
|
||||
|
||||
result.ExitCode = 0
|
||||
result.Message = message
|
||||
result.SessionID = threadID
|
||||
if result.LogPath == "" && injectedLogger != nil {
|
||||
result.LogPath = injectedLogger.Path()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func forwardSignals(ctx context.Context, cmd commandRunner, logErrorFn func(string)) {
|
||||
notify := signalNotifyFn
|
||||
stop := signalStopFn
|
||||
if notify == nil {
|
||||
notify = signal.Notify
|
||||
}
|
||||
if stop == nil {
|
||||
stop = signal.Stop
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
defer stop(sigCh)
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
logErrorFn(fmt.Sprintf("Received signal: %v", sig))
|
||||
if proc := cmd.Process(); proc != nil {
|
||||
_ = proc.Signal(syscall.SIGTERM)
|
||||
time.AfterFunc(time.Duration(forceKillDelay.Load())*time.Second, func() {
|
||||
if p := cmd.Process(); p != nil {
|
||||
_ = p.Kill()
|
||||
}
|
||||
})
|
||||
}
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func cancelReason(commandName string, ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return "Context cancelled"
|
||||
}
|
||||
|
||||
if commandName == "" {
|
||||
commandName = codexCommand
|
||||
}
|
||||
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return fmt.Sprintf("%s execution timeout", commandName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Execution cancelled, terminating %s process", commandName)
|
||||
}
|
||||
|
||||
type stdoutReasonCloser interface {
|
||||
CloseWithReason(string) error
|
||||
}
|
||||
|
||||
func closeWithReason(rc io.ReadCloser, reason string) {
|
||||
if rc == nil {
|
||||
return
|
||||
}
|
||||
if c, ok := rc.(stdoutReasonCloser); ok {
|
||||
_ = c.CloseWithReason(reason)
|
||||
return
|
||||
}
|
||||
_ = rc.Close()
|
||||
}
|
||||
|
||||
type forceKillTimer struct {
|
||||
timer *time.Timer
|
||||
done chan struct{}
|
||||
stopped atomic.Bool
|
||||
drained atomic.Bool
|
||||
}
|
||||
|
||||
func (t *forceKillTimer) Stop() {
|
||||
if t == nil || t.timer == nil {
|
||||
return
|
||||
}
|
||||
if !t.timer.Stop() {
|
||||
<-t.done
|
||||
t.drained.Store(true)
|
||||
}
|
||||
t.stopped.Store(true)
|
||||
}
|
||||
|
||||
func terminateCommand(cmd commandRunner) *forceKillTimer {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
proc := cmd.Process()
|
||||
if proc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = proc.Signal(syscall.SIGTERM)
|
||||
|
||||
done := make(chan struct{}, 1)
|
||||
timer := time.AfterFunc(time.Duration(forceKillDelay.Load())*time.Second, func() {
|
||||
if p := cmd.Process(); p != nil {
|
||||
_ = p.Kill()
|
||||
}
|
||||
close(done)
|
||||
})
|
||||
|
||||
return &forceKillTimer{timer: timer, done: done}
|
||||
}
|
||||
|
||||
func terminateProcess(cmd commandRunner) *time.Timer {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
proc := cmd.Process()
|
||||
if proc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = proc.Signal(syscall.SIGTERM)
|
||||
|
||||
return time.AfterFunc(time.Duration(forceKillDelay.Load())*time.Second, func() {
|
||||
if p := cmd.Process(); p != nil {
|
||||
_ = p.Kill()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package wrapper
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -10,15 +10,12 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
executor "codeagent-wrapper/internal/executor"
|
||||
)
|
||||
|
||||
var executorTestTaskCounter atomic.Int64
|
||||
@@ -34,12 +31,7 @@ type execFakeProcess struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (p *execFakeProcess) Pid() int {
|
||||
if runtime.GOOS == "windows" {
|
||||
return 0
|
||||
}
|
||||
return p.pid
|
||||
}
|
||||
func (p *execFakeProcess) Pid() int { return p.pid }
|
||||
func (p *execFakeProcess) Kill() error {
|
||||
p.killed.Add(1)
|
||||
return nil
|
||||
@@ -91,16 +83,13 @@ func (rc *reasonReadCloser) record(reason string) {
|
||||
|
||||
type execFakeRunner struct {
|
||||
stdout io.ReadCloser
|
||||
stderr io.ReadCloser
|
||||
process executor.ProcessHandle
|
||||
process processHandle
|
||||
stdin io.WriteCloser
|
||||
dir string
|
||||
env map[string]string
|
||||
waitErr error
|
||||
waitDelay time.Duration
|
||||
startErr error
|
||||
stdoutErr error
|
||||
stderrErr error
|
||||
stdinErr error
|
||||
allowNilProcess bool
|
||||
started atomic.Bool
|
||||
@@ -128,15 +117,6 @@ func (f *execFakeRunner) StdoutPipe() (io.ReadCloser, error) {
|
||||
}
|
||||
return f.stdout, nil
|
||||
}
|
||||
func (f *execFakeRunner) StderrPipe() (io.ReadCloser, error) {
|
||||
if f.stderrErr != nil {
|
||||
return nil, f.stderrErr
|
||||
}
|
||||
if f.stderr == nil {
|
||||
f.stderr = io.NopCloser(strings.NewReader(""))
|
||||
}
|
||||
return f.stderr, nil
|
||||
}
|
||||
func (f *execFakeRunner) StdinPipe() (io.WriteCloser, error) {
|
||||
if f.stdinErr != nil {
|
||||
return nil, f.stdinErr
|
||||
@@ -148,18 +128,7 @@ func (f *execFakeRunner) StdinPipe() (io.WriteCloser, error) {
|
||||
}
|
||||
func (f *execFakeRunner) SetStderr(io.Writer) {}
|
||||
func (f *execFakeRunner) SetDir(dir string) { f.dir = dir }
|
||||
func (f *execFakeRunner) SetEnv(env map[string]string) {
|
||||
if len(env) == 0 {
|
||||
return
|
||||
}
|
||||
if f.env == nil {
|
||||
f.env = make(map[string]string, len(env))
|
||||
}
|
||||
for k, v := range env {
|
||||
f.env[k] = v
|
||||
}
|
||||
}
|
||||
func (f *execFakeRunner) Process() executor.ProcessHandle {
|
||||
func (f *execFakeRunner) Process() processHandle {
|
||||
if f.process != nil {
|
||||
return f.process
|
||||
}
|
||||
@@ -169,32 +138,175 @@ func (f *execFakeRunner) Process() executor.ProcessHandle {
|
||||
return &execFakeProcess{pid: 1}
|
||||
}
|
||||
|
||||
func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
func TestExecutorHelperCoverage(t *testing.T) {
|
||||
t.Run("realCmdAndProcess", func(t *testing.T) {
|
||||
rc := &realCmd{}
|
||||
if err := rc.Start(); err == nil {
|
||||
t.Fatalf("expected error for nil command")
|
||||
}
|
||||
if err := rc.Wait(); err == nil {
|
||||
t.Fatalf("expected error for nil command")
|
||||
}
|
||||
if _, err := rc.StdoutPipe(); err == nil {
|
||||
t.Fatalf("expected error for nil command")
|
||||
}
|
||||
if _, err := rc.StdinPipe(); err == nil {
|
||||
t.Fatalf("expected error for nil command")
|
||||
}
|
||||
rc.SetStderr(io.Discard)
|
||||
if rc.Process() != nil {
|
||||
t.Fatalf("expected nil process")
|
||||
}
|
||||
rcWithCmd := &realCmd{cmd: &exec.Cmd{}}
|
||||
rcWithCmd.SetStderr(io.Discard)
|
||||
rcWithCmd.SetDir("/tmp")
|
||||
if rcWithCmd.cmd.Dir != "/tmp" {
|
||||
t.Fatalf("expected SetDir to set cmd.Dir, got %q", rcWithCmd.cmd.Dir)
|
||||
}
|
||||
echoCmd := exec.Command("echo", "ok")
|
||||
rcProc := &realCmd{cmd: echoCmd}
|
||||
stdoutPipe, err := rcProc.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("StdoutPipe error: %v", err)
|
||||
}
|
||||
stdinPipe, err := rcProc.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("StdinPipe error: %v", err)
|
||||
}
|
||||
rcProc.SetStderr(io.Discard)
|
||||
if err := rcProc.Start(); err != nil {
|
||||
t.Fatalf("Start failed: %v", err)
|
||||
}
|
||||
_, _ = stdinPipe.Write([]byte{})
|
||||
_ = stdinPipe.Close()
|
||||
procHandle := rcProc.Process()
|
||||
if procHandle == nil {
|
||||
t.Fatalf("expected process handle")
|
||||
}
|
||||
_ = procHandle.Signal(syscall.SIGTERM)
|
||||
_ = procHandle.Kill()
|
||||
_ = rcProc.Wait()
|
||||
_, _ = io.ReadAll(stdoutPipe)
|
||||
|
||||
t.Run("resumeMissingSessionID", func(t *testing.T) {
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
t.Fatalf("unexpected command execution for invalid resume config")
|
||||
return nil
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
rp := &realProcess{}
|
||||
if rp.Pid() != 0 {
|
||||
t.Fatalf("nil process should have pid 0")
|
||||
}
|
||||
if rp.Kill() != nil {
|
||||
t.Fatalf("nil process Kill should be nil")
|
||||
}
|
||||
if rp.Signal(syscall.SIGTERM) != nil {
|
||||
t.Fatalf("nil process Signal should be nil")
|
||||
}
|
||||
rpLive := &realProcess{proc: &os.Process{Pid: 99}}
|
||||
if rpLive.Pid() != 99 {
|
||||
t.Fatalf("expected pid 99, got %d", rpLive.Pid())
|
||||
}
|
||||
_ = rpLive.Kill()
|
||||
_ = rpLive.Signal(syscall.SIGTERM)
|
||||
})
|
||||
|
||||
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", Mode: "resume"}, nil, nil, false, false, 1)
|
||||
if res.ExitCode == 0 || !strings.Contains(res.Error, "session_id") {
|
||||
t.Fatalf("expected validation error, got %+v", res)
|
||||
t.Run("topologicalSortAndSkip", func(t *testing.T) {
|
||||
layers, err := topologicalSort([]TaskSpec{{ID: "root"}, {ID: "child", Dependencies: []string{"root"}}})
|
||||
if err != nil || len(layers) != 2 {
|
||||
t.Fatalf("unexpected topological sort result: layers=%d err=%v", len(layers), err)
|
||||
}
|
||||
if _, err := topologicalSort([]TaskSpec{{ID: "cycle", Dependencies: []string{"cycle"}}}); err == nil {
|
||||
t.Fatalf("expected cycle detection error")
|
||||
}
|
||||
|
||||
failed := map[string]TaskResult{"root": {ExitCode: 1}}
|
||||
if skip, _ := shouldSkipTask(TaskSpec{ID: "child", Dependencies: []string{"root"}}, failed); !skip {
|
||||
t.Fatalf("should skip when dependency failed")
|
||||
}
|
||||
if skip, _ := shouldSkipTask(TaskSpec{ID: "leaf"}, failed); skip {
|
||||
t.Fatalf("should not skip task without dependencies")
|
||||
}
|
||||
if skip, _ := shouldSkipTask(TaskSpec{ID: "child-ok", Dependencies: []string{"root"}}, map[string]TaskResult{}); skip {
|
||||
t.Fatalf("should not skip when dependencies succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cancelledTaskResult", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
res := cancelledTaskResult("t1", ctx)
|
||||
if res.ExitCode != 130 {
|
||||
t.Fatalf("expected cancel exit code, got %d", res.ExitCode)
|
||||
}
|
||||
|
||||
timeoutCtx, timeoutCancel := context.WithTimeout(context.Background(), 0)
|
||||
defer timeoutCancel()
|
||||
res = cancelledTaskResult("t2", timeoutCtx)
|
||||
if res.ExitCode != 124 {
|
||||
t.Fatalf("expected timeout exit code, got %d", res.ExitCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generateFinalOutputAndArgs", func(t *testing.T) {
|
||||
out := generateFinalOutput([]TaskResult{
|
||||
{TaskID: "ok", ExitCode: 0},
|
||||
{TaskID: "fail", ExitCode: 1, Error: "boom"},
|
||||
})
|
||||
if !strings.Contains(out, "ok") || !strings.Contains(out, "fail") {
|
||||
t.Fatalf("unexpected summary output: %s", out)
|
||||
}
|
||||
out = generateFinalOutput([]TaskResult{{TaskID: "rich", ExitCode: 0, SessionID: "sess", LogPath: "/tmp/log", Message: "hello"}})
|
||||
if !strings.Contains(out, "Session: sess") || !strings.Contains(out, "Log: /tmp/log") || !strings.Contains(out, "hello") {
|
||||
t.Fatalf("rich output missing fields: %s", out)
|
||||
}
|
||||
|
||||
args := buildCodexArgs(&Config{Mode: "new", WorkDir: "/tmp"}, "task")
|
||||
if len(args) == 0 || args[3] != "/tmp" {
|
||||
t.Fatalf("unexpected codex args: %+v", args)
|
||||
}
|
||||
args = buildCodexArgs(&Config{Mode: "resume", SessionID: "sess"}, "target")
|
||||
if args[3] != "resume" || args[4] != "sess" {
|
||||
t.Fatalf("unexpected resume args: %+v", args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("executeConcurrentWrapper", func(t *testing.T) {
|
||||
orig := runCodexTaskFn
|
||||
defer func() { runCodexTaskFn = orig }()
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "done"}
|
||||
}
|
||||
os.Setenv("CODEAGENT_MAX_PARALLEL_WORKERS", "1")
|
||||
defer os.Unsetenv("CODEAGENT_MAX_PARALLEL_WORKERS")
|
||||
|
||||
results := executeConcurrent([][]TaskSpec{{{ID: "wrap"}}}, 1)
|
||||
if len(results) != 1 || results[0].TaskID != "wrap" {
|
||||
t.Fatalf("unexpected wrapper results: %+v", results)
|
||||
}
|
||||
|
||||
unbounded := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: "unbounded"}}}, 1, 0)
|
||||
if len(unbounded) != 1 || unbounded[0].ExitCode != 0 {
|
||||
t.Fatalf("unexpected unbounded result: %+v", unbounded)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cancelled := executeConcurrentWithContext(ctx, [][]TaskSpec{{{ID: "cancel"}}}, 1, 1)
|
||||
if cancelled[0].ExitCode == 0 {
|
||||
t.Fatalf("expected cancelled result, got %+v", cancelled[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
origRunner := newCommandRunner
|
||||
defer func() { newCommandRunner = origRunner }()
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
var firstStdout *reasonReadCloser
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
rc := newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}`)
|
||||
if firstStdout == nil {
|
||||
firstStdout = rc
|
||||
}
|
||||
return &execFakeRunner{stdout: rc, process: &execFakeProcess{pid: 1234}}
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
}
|
||||
|
||||
res := runCodexTaskWithContext(context.Background(), TaskSpec{ID: "task-1", Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
|
||||
if res.Error != "" || res.Message != "hello" || res.ExitCode != 0 {
|
||||
@@ -224,18 +336,17 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("startErrors", func(t *testing.T) {
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{startErr: errors.New("executable file not found"), process: &execFakeProcess{pid: 1}}
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
}
|
||||
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
|
||||
if res.ExitCode != 127 {
|
||||
t.Fatalf("expected missing executable exit code, got %d", res.ExitCode)
|
||||
}
|
||||
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{startErr: errors.New("start failed"), process: &execFakeProcess{pid: 2}}
|
||||
})
|
||||
}
|
||||
res = runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
|
||||
if res.ExitCode == 0 {
|
||||
t.Fatalf("expected non-zero exit on start failure")
|
||||
@@ -243,14 +354,13 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("timeoutAndPipes", func(t *testing.T) {
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{
|
||||
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"slow"}}`),
|
||||
process: &execFakeProcess{pid: 5},
|
||||
waitDelay: 20 * time.Millisecond,
|
||||
}
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
}
|
||||
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", UseStdin: true}, nil, nil, false, false, 0)
|
||||
if res.ExitCode == 0 {
|
||||
t.Fatalf("expected timeout result, got %+v", res)
|
||||
@@ -258,18 +368,17 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("pipeErrors", func(t *testing.T) {
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{stdoutErr: errors.New("stdout fail"), process: &execFakeProcess{pid: 6}}
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
}
|
||||
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
|
||||
if res.ExitCode == 0 {
|
||||
t.Fatalf("expected failure on stdout pipe error")
|
||||
}
|
||||
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{stdinErr: errors.New("stdin fail"), process: &execFakeProcess{pid: 7}}
|
||||
})
|
||||
}
|
||||
res = runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", UseStdin: true}, nil, nil, false, false, 1)
|
||||
if res.ExitCode == 0 {
|
||||
t.Fatalf("expected failure on stdin pipe error")
|
||||
@@ -282,14 +391,13 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
if exitErr == nil {
|
||||
t.Fatalf("expected exec.ExitError")
|
||||
}
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{
|
||||
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ignored"}}`),
|
||||
process: &execFakeProcess{pid: 8},
|
||||
waitErr: exitErr,
|
||||
}
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
}
|
||||
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
|
||||
if res.ExitCode == 0 {
|
||||
t.Fatalf("expected non-zero exit on wait error")
|
||||
@@ -297,14 +405,13 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("contextCancelled", func(t *testing.T) {
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{
|
||||
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"cancel"}}`),
|
||||
process: &execFakeProcess{pid: 9},
|
||||
waitDelay: 10 * time.Millisecond,
|
||||
}
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
res := runCodexTaskWithContext(ctx, TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
|
||||
@@ -314,13 +421,12 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("silentLogger", func(t *testing.T) {
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{
|
||||
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"quiet"}}`),
|
||||
process: &execFakeProcess{pid: 10},
|
||||
}
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
}
|
||||
_ = closeLogger()
|
||||
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, true, 1)
|
||||
if res.ExitCode != 0 || res.LogPath == "" {
|
||||
@@ -330,13 +436,12 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("injectedLogger", func(t *testing.T) {
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{
|
||||
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"injected"}}`),
|
||||
process: &execFakeProcess{pid: 12},
|
||||
}
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
}
|
||||
_ = closeLogger()
|
||||
|
||||
injected, err := NewLoggerWithSuffix("executor-injected")
|
||||
@@ -348,7 +453,7 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
_ = os.Remove(injected.Path())
|
||||
}()
|
||||
|
||||
ctx := executor.WithTaskLogger(context.Background(), injected)
|
||||
ctx := withTaskLogger(context.Background(), injected)
|
||||
res := runCodexTaskWithContext(ctx, TaskSpec{ID: "task-injected", Task: "payload", WorkDir: "."}, nil, nil, false, true, 1)
|
||||
if res.ExitCode != 0 || res.LogPath != injected.Path() {
|
||||
t.Fatalf("expected injected logger path, got %+v", res)
|
||||
@@ -368,13 +473,12 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("contextLoggerWithoutParent", func(t *testing.T) {
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{
|
||||
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ctx"}}`),
|
||||
process: &execFakeProcess{pid: 14},
|
||||
}
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
}
|
||||
_ = closeLogger()
|
||||
|
||||
taskLogger, err := NewLoggerWithSuffix("executor-taskctx")
|
||||
@@ -386,8 +490,8 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
_ = os.Remove(taskLogger.Path())
|
||||
})
|
||||
|
||||
ctx := executor.WithTaskLogger(context.Background(), taskLogger)
|
||||
res := runCodexTaskWithContext(context.TODO(), TaskSpec{ID: "task-context", Task: "payload", WorkDir: ".", Context: ctx}, nil, nil, false, true, 1)
|
||||
ctx := withTaskLogger(context.Background(), taskLogger)
|
||||
res := runCodexTaskWithContext(nil, TaskSpec{ID: "task-context", Task: "payload", WorkDir: ".", Context: ctx}, nil, nil, false, true, 1)
|
||||
if res.ExitCode != 0 || res.LogPath != taskLogger.Path() {
|
||||
t.Fatalf("expected task logger to be reused from spec context, got %+v", res)
|
||||
}
|
||||
@@ -407,17 +511,16 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
|
||||
t.Run("backendSetsDirAndNilContext", func(t *testing.T) {
|
||||
var rc *execFakeRunner
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
rc = &execFakeRunner{
|
||||
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"backend"}}`),
|
||||
process: &execFakeProcess{pid: 13},
|
||||
}
|
||||
return rc
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
}
|
||||
|
||||
_ = closeLogger()
|
||||
res := runCodexTaskWithContext(context.TODO(), TaskSpec{ID: "task-backend", Task: "payload", WorkDir: "/tmp"}, ClaudeBackend{}, nil, false, false, 1)
|
||||
res := runCodexTaskWithContext(nil, TaskSpec{ID: "task-backend", Task: "payload", WorkDir: "/tmp"}, ClaudeBackend{}, nil, false, false, 1)
|
||||
if res.ExitCode != 0 || res.Message != "backend" {
|
||||
t.Fatalf("unexpected result: %+v", res)
|
||||
}
|
||||
@@ -426,36 +529,13 @@ func TestExecutorRunCodexTaskWithContext(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("claudeSkipPermissionsPropagatesFromTaskSpec", func(t *testing.T) {
|
||||
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
|
||||
var gotArgs []string
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
gotArgs = append([]string(nil), args...)
|
||||
return &execFakeRunner{
|
||||
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`),
|
||||
process: &execFakeProcess{pid: 15},
|
||||
}
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
|
||||
_ = closeLogger()
|
||||
res := runCodexTaskWithContext(context.Background(), TaskSpec{ID: "task-skip", Task: "payload", WorkDir: ".", SkipPermissions: true}, ClaudeBackend{}, nil, false, false, 1)
|
||||
if res.ExitCode != 0 || res.Error != "" {
|
||||
t.Fatalf("unexpected result: %+v", res)
|
||||
}
|
||||
if !slices.Contains(gotArgs, "--dangerously-skip-permissions") {
|
||||
t.Fatalf("expected --dangerously-skip-permissions in args, got %v", gotArgs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missingMessage", func(t *testing.T) {
|
||||
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{
|
||||
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"task","text":"noop"}}`),
|
||||
process: &execFakeProcess{pid: 11},
|
||||
}
|
||||
})
|
||||
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
|
||||
}
|
||||
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
|
||||
if res.ExitCode == 0 {
|
||||
t.Fatalf("expected failure when no agent_message returned")
|
||||
@@ -481,7 +561,7 @@ func TestExecutorParallelLogIsolation(t *testing.T) {
|
||||
|
||||
origRun := runCodexTaskFn
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
logger := executor.TaskLoggerFromContext(task.Context)
|
||||
logger := taskLoggerFromContext(task.Context)
|
||||
if logger == nil {
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing task logger"}
|
||||
}
|
||||
@@ -505,7 +585,7 @@ func TestExecutorParallelLogIsolation(t *testing.T) {
|
||||
os.Stderr = stderrW
|
||||
defer func() { os.Stderr = oldStderr }()
|
||||
|
||||
results := executeConcurrentWithContext(context.TODO(), [][]TaskSpec{{{ID: taskA}, {ID: taskB}}}, 1, -1)
|
||||
results := executeConcurrentWithContext(nil, [][]TaskSpec{{{ID: taskA}, {ID: taskB}}}, 1, -1)
|
||||
|
||||
_ = stderrW.Close()
|
||||
os.Stderr = oldStderr
|
||||
@@ -567,10 +647,11 @@ func TestExecutorParallelLogIsolation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConcurrentExecutorParallelLogIsolationAndClosure(t *testing.T) {
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{wrapperName}
|
||||
os.Args = []string{defaultWrapperName}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
mainLogger, err := NewLoggerWithSuffix("concurrent-main")
|
||||
@@ -616,7 +697,7 @@ func TestConcurrentExecutorParallelLogIsolationAndClosure(t *testing.T) {
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
readyCh <- struct{}{}
|
||||
|
||||
logger := executor.TaskLoggerFromContext(task.Context)
|
||||
logger := taskLoggerFromContext(task.Context)
|
||||
loggerCh <- taskLoggerInfo{taskID: task.ID, logger: logger}
|
||||
if logger == nil {
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing task logger"}
|
||||
@@ -703,9 +784,15 @@ func TestConcurrentExecutorParallelLogIsolationAndClosure(t *testing.T) {
|
||||
}
|
||||
|
||||
for taskID, logger := range loggers {
|
||||
if !logger.IsClosed() {
|
||||
if !logger.closed.Load() {
|
||||
t.Fatalf("expected task logger to be closed for %q", taskID)
|
||||
}
|
||||
if logger.file == nil {
|
||||
t.Fatalf("expected task logger file to be non-nil for %q", taskID)
|
||||
}
|
||||
if _, err := logger.file.Write([]byte("x")); err == nil {
|
||||
t.Fatalf("expected task logger file to be closed for %q", taskID)
|
||||
}
|
||||
}
|
||||
|
||||
mainLogger.Flush()
|
||||
@@ -775,10 +862,10 @@ func parseTaskIDFromLogLine(line string) (string, bool) {
|
||||
}
|
||||
|
||||
func TestExecutorTaskLoggerContext(t *testing.T) {
|
||||
if executor.TaskLoggerFromContext(context.TODO()) != nil {
|
||||
t.Fatalf("expected nil logger from TODO context")
|
||||
if taskLoggerFromContext(nil) != nil {
|
||||
t.Fatalf("expected nil logger from nil context")
|
||||
}
|
||||
if executor.TaskLoggerFromContext(context.Background()) != nil {
|
||||
if taskLoggerFromContext(context.Background()) != nil {
|
||||
t.Fatalf("expected nil logger when context has no logger")
|
||||
}
|
||||
|
||||
@@ -791,12 +878,12 @@ func TestExecutorTaskLoggerContext(t *testing.T) {
|
||||
_ = os.Remove(logger.Path())
|
||||
}()
|
||||
|
||||
ctx := executor.WithTaskLogger(context.Background(), logger)
|
||||
if got := executor.TaskLoggerFromContext(ctx); got != logger {
|
||||
ctx := withTaskLogger(context.Background(), logger)
|
||||
if got := taskLoggerFromContext(ctx); got != logger {
|
||||
t.Fatalf("expected logger roundtrip, got %v", got)
|
||||
}
|
||||
|
||||
if executor.TaskLoggerFromContext(executor.WithTaskLogger(context.Background(), nil)) != nil {
|
||||
if taskLoggerFromContext(withTaskLogger(context.Background(), nil)) != nil {
|
||||
t.Fatalf("expected nil logger when injected logger is nil")
|
||||
}
|
||||
}
|
||||
@@ -928,7 +1015,8 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
|
||||
t.Run("TestConcurrentTaskLoggerFailure", func(t *testing.T) {
|
||||
// Create a writable temp dir for the main logger, then flip TMPDIR to a read-only
|
||||
// location so task-specific loggers fail to open.
|
||||
writable := setTempDirEnv(t, t.TempDir())
|
||||
writable := t.TempDir()
|
||||
t.Setenv("TMPDIR", writable)
|
||||
|
||||
mainLogger, err := NewLoggerWithSuffix("shared-main")
|
||||
if err != nil {
|
||||
@@ -941,18 +1029,18 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
|
||||
_ = os.Remove(mainLogger.Path())
|
||||
})
|
||||
|
||||
notDir := filepath.Join(writable, "not-a-dir")
|
||||
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
noWrite := filepath.Join(writable, "ro")
|
||||
if err := os.Mkdir(noWrite, 0o500); err != nil {
|
||||
t.Fatalf("failed to create read-only temp dir: %v", err)
|
||||
}
|
||||
setTempDirEnv(t, notDir)
|
||||
t.Setenv("TMPDIR", noWrite)
|
||||
|
||||
taskA := nextExecutorTestTaskID("shared-a")
|
||||
taskB := nextExecutorTestTaskID("shared-b")
|
||||
|
||||
orig := runCodexTaskFn
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
logger := executor.TaskLoggerFromContext(task.Context)
|
||||
logger := taskLoggerFromContext(task.Context)
|
||||
if logger != mainLogger {
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "unexpected logger"}
|
||||
}
|
||||
@@ -986,15 +1074,17 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
|
||||
if res.LogPath != mainLogger.Path() {
|
||||
t.Fatalf("shared log path mismatch: got %q want %q", res.LogPath, mainLogger.Path())
|
||||
}
|
||||
if !res.sharedLog {
|
||||
t.Fatalf("expected sharedLog flag for %+v", res)
|
||||
}
|
||||
if !strings.Contains(stderrOut, "Log (shared)") {
|
||||
t.Fatalf("stderr missing shared marker: %s", stderrOut)
|
||||
}
|
||||
}
|
||||
|
||||
// Test full output mode for shared marker (summary mode doesn't show it)
|
||||
summary := generateFinalOutputWithMode(results, false)
|
||||
summary := generateFinalOutput(results)
|
||||
if !strings.Contains(summary, "(shared)") {
|
||||
t.Fatalf("full output missing shared marker: %s", summary)
|
||||
t.Fatalf("summary missing shared marker: %s", summary)
|
||||
}
|
||||
|
||||
mainLogger.Flush()
|
||||
@@ -1009,11 +1099,12 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("TestSanitizeTaskID", func(t *testing.T) {
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
|
||||
orig := runCodexTaskFn
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
logger := executor.TaskLoggerFromContext(task.Context)
|
||||
logger := taskLoggerFromContext(task.Context)
|
||||
if logger == nil {
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing logger"}
|
||||
}
|
||||
@@ -1051,14 +1142,7 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read log %q: %v", res.LogPath, err)
|
||||
}
|
||||
found := false
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.Contains(stripTimestampPrefix(line), "TASK="+res.TaskID) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if !strings.Contains(string(data), "TASK="+res.TaskID) {
|
||||
t.Fatalf("log for %q missing task marker, content: %s", res.TaskID, string(data))
|
||||
}
|
||||
_ = os.Remove(res.LogPath)
|
||||
@@ -1066,6 +1150,147 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecutorSignalAndTermination(t *testing.T) {
|
||||
forceKillDelay.Store(0)
|
||||
defer forceKillDelay.Store(5)
|
||||
|
||||
proc := &execFakeProcess{pid: 42}
|
||||
cmd := &execFakeRunner{process: proc}
|
||||
|
||||
origNotify := signalNotifyFn
|
||||
origStop := signalStopFn
|
||||
defer func() {
|
||||
signalNotifyFn = origNotify
|
||||
signalStopFn = origStop
|
||||
}()
|
||||
|
||||
signalNotifyFn = func(c chan<- os.Signal, sigs ...os.Signal) {
|
||||
go func() { c <- syscall.SIGINT }()
|
||||
}
|
||||
signalStopFn = func(c chan<- os.Signal) {}
|
||||
|
||||
forwardSignals(context.Background(), cmd, func(string) {})
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
proc.mu.Lock()
|
||||
signalled := len(proc.signals)
|
||||
proc.mu.Unlock()
|
||||
if signalled == 0 {
|
||||
t.Fatalf("process did not receive signal")
|
||||
}
|
||||
if proc.killed.Load() == 0 {
|
||||
t.Fatalf("process was not killed after signal")
|
||||
}
|
||||
|
||||
timer := terminateProcess(cmd)
|
||||
if timer == nil {
|
||||
t.Fatalf("terminateProcess returned nil timer")
|
||||
}
|
||||
timer.Stop()
|
||||
|
||||
ft := terminateCommand(cmd)
|
||||
if ft == nil {
|
||||
t.Fatalf("terminateCommand returned nil")
|
||||
}
|
||||
ft.Stop()
|
||||
|
||||
cmdKill := &execFakeRunner{process: &execFakeProcess{pid: 50}}
|
||||
ftKill := terminateCommand(cmdKill)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if p, ok := cmdKill.process.(*execFakeProcess); ok && p.killed.Load() == 0 {
|
||||
t.Fatalf("terminateCommand did not kill process")
|
||||
}
|
||||
ftKill.Stop()
|
||||
|
||||
cmdKill2 := &execFakeRunner{process: &execFakeProcess{pid: 51}}
|
||||
timer2 := terminateProcess(cmdKill2)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if p, ok := cmdKill2.process.(*execFakeProcess); ok && p.killed.Load() == 0 {
|
||||
t.Fatalf("terminateProcess did not kill process")
|
||||
}
|
||||
timer2.Stop()
|
||||
|
||||
if terminateCommand(nil) != nil {
|
||||
t.Fatalf("terminateCommand should return nil for nil cmd")
|
||||
}
|
||||
if terminateCommand(&execFakeRunner{allowNilProcess: true}) != nil {
|
||||
t.Fatalf("terminateCommand should return nil when process is nil")
|
||||
}
|
||||
if terminateProcess(nil) != nil {
|
||||
t.Fatalf("terminateProcess should return nil for nil cmd")
|
||||
}
|
||||
if terminateProcess(&execFakeRunner{allowNilProcess: true}) != nil {
|
||||
t.Fatalf("terminateProcess should return nil when process is nil")
|
||||
}
|
||||
|
||||
signalNotifyFn = func(c chan<- os.Signal, sigs ...os.Signal) {}
|
||||
ctxDone, cancelDone := context.WithCancel(context.Background())
|
||||
cancelDone()
|
||||
forwardSignals(ctxDone, &execFakeRunner{process: &execFakeProcess{pid: 70}}, func(string) {})
|
||||
}
|
||||
|
||||
func TestExecutorCancelReasonAndCloseWithReason(t *testing.T) {
|
||||
if reason := cancelReason("", nil); !strings.Contains(reason, "Context") {
|
||||
t.Fatalf("unexpected cancelReason for nil ctx: %s", reason)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 0)
|
||||
defer cancel()
|
||||
if !strings.Contains(cancelReason("cmd", ctx), "timeout") {
|
||||
t.Fatalf("expected timeout reason")
|
||||
}
|
||||
cancelCtx, cancelFn := context.WithCancel(context.Background())
|
||||
cancelFn()
|
||||
if !strings.Contains(cancelReason("cmd", cancelCtx), "Execution cancelled") {
|
||||
t.Fatalf("expected cancellation reason")
|
||||
}
|
||||
if !strings.Contains(cancelReason("", cancelCtx), "codex") {
|
||||
t.Fatalf("expected default command name in cancel reason")
|
||||
}
|
||||
|
||||
rc := &reasonReadCloser{r: strings.NewReader("data"), closedC: make(chan struct{}, 1)}
|
||||
closeWithReason(rc, "why")
|
||||
select {
|
||||
case <-rc.closedC:
|
||||
default:
|
||||
t.Fatalf("CloseWithReason was not called")
|
||||
}
|
||||
|
||||
plain := io.NopCloser(strings.NewReader("x"))
|
||||
closeWithReason(plain, "noop")
|
||||
closeWithReason(nil, "noop")
|
||||
}
|
||||
|
||||
func TestExecutorForceKillTimerStop(t *testing.T) {
|
||||
done := make(chan struct{}, 1)
|
||||
ft := &forceKillTimer{timer: time.AfterFunc(50*time.Millisecond, func() { done <- struct{}{} }), done: done}
|
||||
ft.Stop()
|
||||
|
||||
done2 := make(chan struct{}, 1)
|
||||
ft2 := &forceKillTimer{timer: time.AfterFunc(0, func() { done2 <- struct{}{} }), done: done2}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
ft2.Stop()
|
||||
|
||||
var nilTimer *forceKillTimer
|
||||
nilTimer.Stop()
|
||||
(&forceKillTimer{}).Stop()
|
||||
}
|
||||
|
||||
func TestExecutorForwardSignalsDefaults(t *testing.T) {
|
||||
origNotify := signalNotifyFn
|
||||
origStop := signalStopFn
|
||||
signalNotifyFn = nil
|
||||
signalStopFn = nil
|
||||
defer func() {
|
||||
signalNotifyFn = origNotify
|
||||
signalStopFn = origStop
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
forwardSignals(ctx, &execFakeRunner{process: &execFakeProcess{pid: 80}}, func(string) {})
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) {
|
||||
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
@@ -1078,7 +1303,8 @@ func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) {
|
||||
_ = devNull.Close()
|
||||
})
|
||||
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
|
||||
// Setup: 创建主 logger
|
||||
mainLogger, err := NewLoggerWithSuffix("shared-main")
|
||||
@@ -1094,11 +1320,11 @@ func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) {
|
||||
// 模拟场景:task logger 创建失败(通过设置只读的 TMPDIR),
|
||||
// 回退到主 logger(handle.shared=true),
|
||||
// 但 runCodexTaskFn 返回自定义的 LogPath(不等于主 logger 的路径)
|
||||
notDir := filepath.Join(tempDir, "not-a-dir")
|
||||
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
roDir := filepath.Join(tempDir, "ro")
|
||||
if err := os.Mkdir(roDir, 0o500); err != nil {
|
||||
t.Fatalf("failed to create read-only dir: %v", err)
|
||||
}
|
||||
setTempDirEnv(t, notDir)
|
||||
t.Setenv("TMPDIR", roDir)
|
||||
|
||||
orig := runCodexTaskFn
|
||||
customLogPath := "/custom/path/to.log"
|
||||
@@ -1120,9 +1346,10 @@ func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) {
|
||||
}
|
||||
|
||||
res := results[0]
|
||||
out := generateFinalOutputWithMode(results, false)
|
||||
if strings.Contains(out, "(shared)") {
|
||||
t.Fatalf("did not expect shared marker when LogPath differs from shared logger, got: %s", out)
|
||||
// 关键断言:即使 handle.shared=true(因为 task logger 创建失败),
|
||||
// 但因为 LogPath 不等于主 logger 的路径,sharedLog 应为 false
|
||||
if res.sharedLog {
|
||||
t.Fatalf("expected sharedLog=false when LogPath differs from shared logger, got true")
|
||||
}
|
||||
|
||||
// 验证 LogPath 确实是自定义的
|
||||
@@ -1,4 +1,4 @@
|
||||
package executor
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -18,12 +18,6 @@ var geminiNoisePatterns = []string{
|
||||
"YOLO mode is enabled",
|
||||
}
|
||||
|
||||
// codexNoisePatterns contains stderr patterns to filter for codex backend
|
||||
var codexNoisePatterns = []string{
|
||||
"ERROR codex_core::codex: needs_follow_up:",
|
||||
"ERROR codex_core::skills::loader:",
|
||||
}
|
||||
|
||||
// filteringWriter wraps an io.Writer and filters out lines matching patterns
|
||||
type filteringWriter struct {
|
||||
w io.Writer
|
||||
@@ -45,7 +39,7 @@ func (f *filteringWriter) Write(p []byte) (n int, err error) {
|
||||
break
|
||||
}
|
||||
if !f.shouldFilter(line) {
|
||||
_, _ = f.w.Write([]byte(line))
|
||||
f.w.Write([]byte(line))
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
@@ -65,7 +59,7 @@ func (f *filteringWriter) Flush() {
|
||||
if f.buf.Len() > 0 {
|
||||
remaining := f.buf.String()
|
||||
if !f.shouldFilter(remaining) {
|
||||
_, _ = f.w.Write([]byte(remaining))
|
||||
f.w.Write([]byte(remaining))
|
||||
}
|
||||
f.buf.Reset()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package executor
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -48,7 +48,7 @@ func TestFilteringWriter(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
fw := newFilteringWriter(&buf, tt.patterns)
|
||||
_, _ = fw.Write([]byte(tt.input))
|
||||
fw.Write([]byte(tt.input))
|
||||
fw.Flush()
|
||||
|
||||
if got := buf.String(); got != tt.want {
|
||||
@@ -63,8 +63,8 @@ func TestFilteringWriterPartialLines(t *testing.T) {
|
||||
fw := newFilteringWriter(&buf, geminiNoisePatterns)
|
||||
|
||||
// Write partial line
|
||||
_, _ = fw.Write([]byte("Hello "))
|
||||
_, _ = fw.Write([]byte("World\n"))
|
||||
fw.Write([]byte("Hello "))
|
||||
fw.Write([]byte("World\n"))
|
||||
fw.Flush()
|
||||
|
||||
if got := buf.String(); got != "Hello World\n" {
|
||||
@@ -1,43 +1,3 @@
|
||||
module codeagent-wrapper
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/spf13/viper v1.19.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -1,150 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
executor "codeagent-wrapper/internal/executor"
|
||||
)
|
||||
|
||||
func TestValidateAgentName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "simple", input: "develop", wantErr: false},
|
||||
{name: "upper", input: "ABC", wantErr: false},
|
||||
{name: "digits", input: "a1", wantErr: false},
|
||||
{name: "dash underscore", input: "a-b_c", wantErr: false},
|
||||
{name: "empty", input: "", wantErr: true},
|
||||
{name: "space", input: "a b", wantErr: true},
|
||||
{name: "slash", input: "a/b", wantErr: true},
|
||||
{name: "dotdot", input: "../evil", wantErr: true},
|
||||
{name: "unicode", input: "中文", wantErr: true},
|
||||
{name: "symbol", input: "a$b", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := config.ValidateAgentName(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateAgentName(%q) err=%v, wantErr=%v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArgs_InvalidAgentNameRejected(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
os.Args = []string{"codeagent-wrapper", "--agent", "../evil", "task"}
|
||||
if _, err := parseArgs(); err == nil {
|
||||
t.Fatalf("expected parseArgs to reject invalid agent name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseParallelConfig_InvalidAgentNameRejected(t *testing.T) {
|
||||
input := `---TASK---
|
||||
id: task-1
|
||||
agent: ../evil
|
||||
---CONTENT---
|
||||
do something`
|
||||
if _, err := parseParallelConfig([]byte(input)); err == nil {
|
||||
t.Fatalf("expected parseParallelConfig to reject invalid agent name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseParallelConfig_ResolvesAgentPromptFile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(config.ResetModelsConfigCacheForTest)
|
||||
config.ResetModelsConfigCacheForTest()
|
||||
|
||||
configDir := filepath.Join(home, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
||||
"default_backend": "codex",
|
||||
"default_model": "gpt-test",
|
||||
"agents": {
|
||||
"custom-agent": {
|
||||
"backend": "codex",
|
||||
"model": "gpt-test",
|
||||
"prompt_file": "~/.claude/prompt.md"
|
||||
}
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
input := `---TASK---
|
||||
id: task-1
|
||||
agent: custom-agent
|
||||
---CONTENT---
|
||||
do something`
|
||||
cfg, err := parseParallelConfig([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
|
||||
}
|
||||
if len(cfg.Tasks) != 1 {
|
||||
t.Fatalf("expected 1 task, got %d", len(cfg.Tasks))
|
||||
}
|
||||
if got := cfg.Tasks[0].PromptFile; got != "~/.claude/prompt.md" {
|
||||
t.Fatalf("PromptFile = %q, want %q", got, "~/.claude/prompt.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRunCodexTaskFn_AppliesAgentPromptFile(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
claudeDir := filepath.Join(home, ".claude")
|
||||
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(claudeDir, "prompt.md"), []byte("P\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
fake := newFakeCmd(fakeCmdConfig{
|
||||
StdoutPlan: []fakeStdoutEvent{
|
||||
{Data: `{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}` + "\n"},
|
||||
},
|
||||
WaitDelay: 2 * time.Millisecond,
|
||||
})
|
||||
|
||||
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { return fake })
|
||||
_ = executor.SetSelectBackendFn(func(name string) (Backend, error) {
|
||||
return testBackend{
|
||||
name: name,
|
||||
command: "fake-cmd",
|
||||
argsFn: func(cfg *Config, targetArg string) []string {
|
||||
return []string{targetArg}
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
res := defaultRunCodexTaskFn(TaskSpec{
|
||||
ID: "t",
|
||||
Task: "do",
|
||||
Backend: "codex",
|
||||
PromptFile: "~/.claude/prompt.md",
|
||||
}, 5)
|
||||
if res.ExitCode != 0 {
|
||||
t.Fatalf("unexpected result: %+v", res)
|
||||
}
|
||||
|
||||
want := "<agent-prompt>\nP\n</agent-prompt>\n\ndo"
|
||||
if got := fake.StdinContents(); got != want {
|
||||
t.Fatalf("stdin mismatch:\n got=%q\nwant=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
const (
|
||||
defaultWorkdir = "."
|
||||
defaultTimeout = 7200 // seconds (2 hours)
|
||||
defaultCoverageTarget = 90.0
|
||||
codexLogLineLimit = 1000
|
||||
stdinSpecialChars = "\n\\\"'`$"
|
||||
stderrCaptureLimit = 4 * 1024
|
||||
defaultBackendName = "codex"
|
||||
defaultCodexCommand = "codex"
|
||||
|
||||
// stdout close reasons
|
||||
stdoutCloseReasonWait = "wait-done"
|
||||
stdoutCloseReasonDrain = "drain-timeout"
|
||||
stdoutCloseReasonCtx = "context-cancel"
|
||||
stdoutDrainTimeout = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// Test hooks for dependency injection
|
||||
var (
|
||||
stdinReader io.Reader = os.Stdin
|
||||
isTerminalFn = defaultIsTerminal
|
||||
codexCommand = defaultCodexCommand
|
||||
cleanupHook func()
|
||||
startupCleanupAsync = true
|
||||
|
||||
buildCodexArgsFn = buildCodexArgs
|
||||
selectBackendFn = selectBackend
|
||||
cleanupLogsFn = cleanupOldLogs
|
||||
defaultBuildArgsFn = buildCodexArgs
|
||||
runTaskFn = runCodexTask
|
||||
exitFn = os.Exit
|
||||
)
|
||||
|
||||
func runStartupCleanup() {
|
||||
if cleanupLogsFn == nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logWarn(fmt.Sprintf("cleanupOldLogs panic: %v", r))
|
||||
}
|
||||
}()
|
||||
if _, err := cleanupLogsFn(); err != nil {
|
||||
logWarn(fmt.Sprintf("cleanupOldLogs error: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleStartupCleanup() {
|
||||
if !startupCleanupAsync {
|
||||
runStartupCleanup()
|
||||
return
|
||||
}
|
||||
if cleanupLogsFn == nil {
|
||||
return
|
||||
}
|
||||
fn := cleanupLogsFn
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logWarn(fmt.Sprintf("cleanupOldLogs panic: %v", r))
|
||||
}
|
||||
}()
|
||||
if _, err := fn(); err != nil {
|
||||
logWarn(fmt.Sprintf("cleanupOldLogs error: %v", err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func runCleanupMode() int {
|
||||
if cleanupLogsFn == nil {
|
||||
fmt.Fprintln(os.Stderr, "Cleanup failed: log cleanup function not configured")
|
||||
return 1
|
||||
}
|
||||
|
||||
stats, err := cleanupLogsFn()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cleanup failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Println("Cleanup completed")
|
||||
fmt.Printf("Files scanned: %d\n", stats.Scanned)
|
||||
fmt.Printf("Files deleted: %d\n", stats.Deleted)
|
||||
if len(stats.DeletedFiles) > 0 {
|
||||
for _, f := range stats.DeletedFiles {
|
||||
fmt.Printf(" - %s\n", f)
|
||||
}
|
||||
}
|
||||
fmt.Printf("Files kept: %d\n", stats.Kept)
|
||||
if len(stats.KeptFiles) > 0 {
|
||||
for _, f := range stats.KeptFiles {
|
||||
fmt.Printf(" - %s\n", f)
|
||||
}
|
||||
}
|
||||
if stats.Errors > 0 {
|
||||
fmt.Printf("Deletion errors: %d\n", stats.Errors)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func readAgentPromptFile(path string, allowOutsideClaudeDir bool) (string, error) {
|
||||
raw := strings.TrimSpace(path)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
expanded := raw
|
||||
if raw == "~" || strings.HasPrefix(raw, "~/") || strings.HasPrefix(raw, "~\\") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if raw == "~" {
|
||||
expanded = home
|
||||
} else {
|
||||
expanded = home + raw[1:]
|
||||
}
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(expanded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absPath = filepath.Clean(absPath)
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
if !allowOutsideClaudeDir {
|
||||
return "", err
|
||||
}
|
||||
logWarn(fmt.Sprintf("Failed to resolve home directory for prompt file validation: %v; proceeding without restriction", err))
|
||||
} else {
|
||||
allowedDirs := []string{
|
||||
filepath.Clean(filepath.Join(home, ".claude")),
|
||||
filepath.Clean(filepath.Join(home, ".codeagent", "agents")),
|
||||
}
|
||||
for i := range allowedDirs {
|
||||
allowedAbs, err := filepath.Abs(allowedDirs[i])
|
||||
if err == nil {
|
||||
allowedDirs[i] = filepath.Clean(allowedAbs)
|
||||
}
|
||||
}
|
||||
|
||||
isWithinDir := func(path, dir string) bool {
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel = filepath.Clean(rel)
|
||||
if rel == "." {
|
||||
return true
|
||||
}
|
||||
if rel == ".." {
|
||||
return false
|
||||
}
|
||||
prefix := ".." + string(os.PathSeparator)
|
||||
return !strings.HasPrefix(rel, prefix)
|
||||
}
|
||||
|
||||
if !allowOutsideClaudeDir {
|
||||
withinAllowed := false
|
||||
for _, dir := range allowedDirs {
|
||||
if isWithinDir(absPath, dir) {
|
||||
withinAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !withinAllowed {
|
||||
logWarn(fmt.Sprintf("Refusing to read prompt file outside allowed dirs (%s): %s", strings.Join(allowedDirs, ", "), absPath))
|
||||
return "", fmt.Errorf("prompt file must be under ~/.claude or ~/.codeagent/agents")
|
||||
}
|
||||
|
||||
resolvedPath, errPath := filepath.EvalSymlinks(absPath)
|
||||
if errPath == nil {
|
||||
resolvedPath = filepath.Clean(resolvedPath)
|
||||
resolvedAllowed := make([]string, 0, len(allowedDirs))
|
||||
for _, dir := range allowedDirs {
|
||||
resolvedBase, errBase := filepath.EvalSymlinks(dir)
|
||||
if errBase != nil {
|
||||
continue
|
||||
}
|
||||
resolvedAllowed = append(resolvedAllowed, filepath.Clean(resolvedBase))
|
||||
}
|
||||
if len(resolvedAllowed) > 0 {
|
||||
withinResolved := false
|
||||
for _, dir := range resolvedAllowed {
|
||||
if isWithinDir(resolvedPath, dir) {
|
||||
withinResolved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !withinResolved {
|
||||
logWarn(fmt.Sprintf("Refusing to read prompt file outside allowed dirs (%s) (resolved): %s", strings.Join(resolvedAllowed, ", "), resolvedPath))
|
||||
return "", fmt.Errorf("prompt file must be under ~/.claude or ~/.codeagent/agents")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withinAllowed := false
|
||||
for _, dir := range allowedDirs {
|
||||
if isWithinDir(absPath, dir) {
|
||||
withinAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !withinAllowed {
|
||||
logWarn(fmt.Sprintf("Reading prompt file outside allowed dirs (%s): %s", strings.Join(allowedDirs, ", "), absPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(string(data), "\r\n"), nil
|
||||
}
|
||||
|
||||
func wrapTaskWithAgentPrompt(prompt string, task string) string {
|
||||
return "<agent-prompt>\n" + prompt + "\n</agent-prompt>\n\n" + task
|
||||
}
|
||||
|
||||
func runCleanupHook() {
|
||||
if logger := activeLogger(); logger != nil {
|
||||
logger.Flush()
|
||||
}
|
||||
if cleanupHook != nil {
|
||||
cleanupHook()
|
||||
}
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
name := currentWrapperName()
|
||||
help := fmt.Sprintf(`%[1]s - Go wrapper for AI CLI backends
|
||||
|
||||
Usage:
|
||||
%[1]s "task" [workdir]
|
||||
%[1]s --backend claude "task" [workdir]
|
||||
%[1]s --prompt-file /path/to/prompt.md "task" [workdir]
|
||||
%[1]s - [workdir] Read task from stdin
|
||||
%[1]s resume <session_id> "task" [workdir]
|
||||
%[1]s resume <session_id> - [workdir]
|
||||
%[1]s --parallel Run tasks in parallel (config from stdin)
|
||||
%[1]s --parallel --full-output Run tasks in parallel with full output (legacy)
|
||||
%[1]s --version
|
||||
%[1]s --help
|
||||
|
||||
Parallel mode examples:
|
||||
%[1]s --parallel < tasks.txt
|
||||
echo '...' | %[1]s --parallel
|
||||
%[1]s --parallel --full-output < tasks.txt
|
||||
%[1]s --parallel <<'EOF'
|
||||
|
||||
Environment Variables:
|
||||
CODEX_TIMEOUT Timeout in milliseconds (default: 7200000)
|
||||
CODEAGENT_ASCII_MODE Use ASCII symbols instead of Unicode (PASS/WARN/FAIL)
|
||||
|
||||
Exit Codes:
|
||||
0 Success
|
||||
1 General error (missing args, no output)
|
||||
124 Timeout
|
||||
127 backend command not found
|
||||
130 Interrupted (Ctrl+C)
|
||||
* Passthrough from backend process`, name)
|
||||
fmt.Println(help)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import backend "codeagent-wrapper/internal/backend"
|
||||
|
||||
type Backend = backend.Backend
|
||||
type CodexBackend = backend.CodexBackend
|
||||
type ClaudeBackend = backend.ClaudeBackend
|
||||
type GeminiBackend = backend.GeminiBackend
|
||||
type OpencodeBackend = backend.OpencodeBackend
|
||||
@@ -1,7 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import backend "codeagent-wrapper/internal/backend"
|
||||
|
||||
func init() {
|
||||
backend.SetLogFuncs(logWarn, logError)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import backend "codeagent-wrapper/internal/backend"
|
||||
|
||||
func selectBackend(name string) (Backend, error) { return backend.Select(name) }
|
||||
@@ -1,116 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
)
|
||||
|
||||
var (
|
||||
benchCmdSink any
|
||||
benchConfigSink *Config
|
||||
benchMessageSink string
|
||||
benchThreadIDSink string
|
||||
)
|
||||
|
||||
// BenchmarkStartup_NewRootCommand measures CLI startup overhead (command+flags construction).
|
||||
func BenchmarkStartup_NewRootCommand(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
benchCmdSink = newRootCommand()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkConfigParse_ParseArgs measures config parsing from argv/env (steady-state).
|
||||
func BenchmarkConfigParse_ParseArgs(b *testing.B) {
|
||||
home := b.TempDir()
|
||||
b.Setenv("HOME", home)
|
||||
b.Setenv("USERPROFILE", home)
|
||||
|
||||
configDir := filepath.Join(home, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
||||
"agents": {
|
||||
"develop": { "backend": "codex", "model": "gpt-test" }
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
config.ResetModelsConfigCacheForTest()
|
||||
b.Cleanup(config.ResetModelsConfigCacheForTest)
|
||||
|
||||
origArgs := os.Args
|
||||
os.Args = []string{"codeagent-wrapper", "--agent", "develop", "task"}
|
||||
b.Cleanup(func() { os.Args = origArgs })
|
||||
|
||||
if _, err := parseArgs(); err != nil {
|
||||
b.Fatalf("warmup parseArgs() error: %v", err)
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
cfg, err := parseArgs()
|
||||
if err != nil {
|
||||
b.Fatalf("parseArgs() error: %v", err)
|
||||
}
|
||||
benchConfigSink = cfg
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkJSONParse_ParseJSONStreamInternal measures line-delimited JSON stream parsing.
|
||||
func BenchmarkJSONParse_ParseJSONStreamInternal(b *testing.B) {
|
||||
stream := []byte(
|
||||
`{"type":"thread.started","thread_id":"t"}` + "\n" +
|
||||
`{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}` + "\n" +
|
||||
`{"type":"thread.completed","thread_id":"t"}` + "\n",
|
||||
)
|
||||
b.SetBytes(int64(len(stream)))
|
||||
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
message, threadID := parseJSONStreamInternal(bytes.NewReader(stream), nil, nil, nil, nil)
|
||||
benchMessageSink = message
|
||||
benchThreadIDSink = threadID
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkLoggerWrite 测试日志写入性能
|
||||
func BenchmarkLoggerWrite(b *testing.B) {
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
logger.Info("benchmark log message")
|
||||
}
|
||||
b.StopTimer()
|
||||
logger.Flush()
|
||||
}
|
||||
|
||||
// BenchmarkLoggerConcurrentWrite 测试并发日志写入性能
|
||||
func BenchmarkLoggerConcurrentWrite(b *testing.B) {
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
logger.Info("concurrent benchmark log message")
|
||||
}
|
||||
})
|
||||
b.StopTimer()
|
||||
logger.Flush()
|
||||
}
|
||||
@@ -1,669 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type exitError struct {
|
||||
code int
|
||||
}
|
||||
|
||||
func (e exitError) Error() string {
|
||||
return fmt.Sprintf("exit %d", e.code)
|
||||
}
|
||||
|
||||
type cliOptions struct {
|
||||
Backend string
|
||||
Model string
|
||||
ReasoningEffort string
|
||||
Agent string
|
||||
PromptFile string
|
||||
SkipPermissions bool
|
||||
|
||||
Parallel bool
|
||||
FullOutput bool
|
||||
|
||||
Cleanup bool
|
||||
Version bool
|
||||
ConfigFile string
|
||||
}
|
||||
|
||||
func Main() {
|
||||
Run()
|
||||
}
|
||||
|
||||
// Run is the program entrypoint for cmd/codeagent/main.go.
|
||||
func Run() {
|
||||
exitFn(run())
|
||||
}
|
||||
|
||||
func run() int {
|
||||
cmd := newRootCommand()
|
||||
cmd.SetArgs(os.Args[1:])
|
||||
if err := cmd.Execute(); err != nil {
|
||||
var ee exitError
|
||||
if errors.As(err, &ee) {
|
||||
return ee.code
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func newRootCommand() *cobra.Command {
|
||||
name := currentWrapperName()
|
||||
opts := &cliOptions{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: fmt.Sprintf("%s [flags] <task>|resume <session_id> <task> [workdir]", name),
|
||||
Short: "Go wrapper for AI CLI backends",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if opts.Version {
|
||||
fmt.Printf("%s version %s\n", name, version)
|
||||
return nil
|
||||
}
|
||||
if opts.Cleanup {
|
||||
code := runCleanupMode()
|
||||
if code == 0 {
|
||||
return nil
|
||||
}
|
||||
return exitError{code: code}
|
||||
}
|
||||
|
||||
exitCode := runWithLoggerAndCleanup(func() int {
|
||||
v, err := config.NewViper(opts.ConfigFile)
|
||||
if err != nil {
|
||||
logError(err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
if opts.Parallel {
|
||||
return runParallelMode(cmd, args, opts, v, name)
|
||||
}
|
||||
|
||||
logInfo("Script started")
|
||||
|
||||
cfg, err := buildSingleConfig(cmd, args, os.Args[1:], opts, v)
|
||||
if err != nil {
|
||||
logError(err.Error())
|
||||
return 1
|
||||
}
|
||||
logInfo(fmt.Sprintf("Parsed args: mode=%s, task_len=%d, backend=%s", cfg.Mode, len(cfg.Task), cfg.Backend))
|
||||
return runSingleMode(cfg, name)
|
||||
})
|
||||
|
||||
if exitCode == 0 {
|
||||
return nil
|
||||
}
|
||||
return exitError{code: exitCode}
|
||||
},
|
||||
}
|
||||
cmd.CompletionOptions.DisableDefaultCmd = true
|
||||
|
||||
addRootFlags(cmd.Flags(), opts)
|
||||
cmd.AddCommand(newVersionCommand(name), newCleanupCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func addRootFlags(fs *pflag.FlagSet, opts *cliOptions) {
|
||||
fs.StringVar(&opts.ConfigFile, "config", "", "Config file path (default: $HOME/.codeagent/config.*)")
|
||||
fs.BoolVarP(&opts.Version, "version", "v", false, "Print version and exit")
|
||||
fs.BoolVar(&opts.Cleanup, "cleanup", false, "Clean up old logs and exit")
|
||||
|
||||
fs.BoolVar(&opts.Parallel, "parallel", false, "Run tasks in parallel (config from stdin)")
|
||||
fs.BoolVar(&opts.FullOutput, "full-output", false, "Parallel mode: include full task output (legacy)")
|
||||
|
||||
fs.StringVar(&opts.Backend, "backend", defaultBackendName, "Backend to use (codex, claude, gemini, opencode)")
|
||||
fs.StringVar(&opts.Model, "model", "", "Model override")
|
||||
fs.StringVar(&opts.ReasoningEffort, "reasoning-effort", "", "Reasoning effort (backend-specific)")
|
||||
fs.StringVar(&opts.Agent, "agent", "", "Agent preset name (from ~/.codeagent/models.json)")
|
||||
fs.StringVar(&opts.PromptFile, "prompt-file", "", "Prompt file path")
|
||||
|
||||
fs.BoolVar(&opts.SkipPermissions, "skip-permissions", false, "Skip permissions prompts (also via CODEAGENT_SKIP_PERMISSIONS)")
|
||||
fs.BoolVar(&opts.SkipPermissions, "dangerously-skip-permissions", false, "Alias for --skip-permissions")
|
||||
}
|
||||
|
||||
func newVersionCommand(name string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version and exit",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf("%s version %s\n", name, version)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newCleanupCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "cleanup",
|
||||
Short: "Clean up old logs and exit",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
code := runCleanupMode()
|
||||
if code == 0 {
|
||||
return nil
|
||||
}
|
||||
return exitError{code: code}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runWithLoggerAndCleanup(fn func() int) (exitCode int) {
|
||||
ensureExecutableTempDir()
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: failed to initialize logger: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
setLogger(logger)
|
||||
|
||||
defer func() {
|
||||
logger := activeLogger()
|
||||
if logger != nil {
|
||||
logger.Flush()
|
||||
}
|
||||
if err := closeLogger(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: failed to close logger: %v\n", err)
|
||||
}
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if exitCode != 0 {
|
||||
if entries := logger.ExtractRecentErrors(10); len(entries) > 0 {
|
||||
fmt.Fprintln(os.Stderr, "\n=== Recent Errors ===")
|
||||
for _, entry := range entries {
|
||||
fmt.Fprintln(os.Stderr, entry)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Log file: %s (deleted)\n", logger.Path())
|
||||
}
|
||||
}
|
||||
_ = logger.RemoveLogFile()
|
||||
}()
|
||||
defer runCleanupHook()
|
||||
|
||||
// Clean up stale logs from previous runs.
|
||||
scheduleStartupCleanup()
|
||||
|
||||
return fn()
|
||||
}
|
||||
|
||||
func parseArgs() (*Config, error) {
|
||||
opts := &cliOptions{}
|
||||
cmd := &cobra.Command{SilenceErrors: true, SilenceUsage: true, Args: cobra.ArbitraryArgs}
|
||||
addRootFlags(cmd.Flags(), opts)
|
||||
|
||||
rawArgv := os.Args[1:]
|
||||
if err := cmd.ParseFlags(rawArgv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := cmd.Flags().Args()
|
||||
|
||||
v, err := config.NewViper(opts.ConfigFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildSingleConfig(cmd, args, rawArgv, opts, v)
|
||||
}
|
||||
|
||||
func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts *cliOptions, v *viper.Viper) (*Config, error) {
|
||||
backendName := defaultBackendName
|
||||
model := ""
|
||||
reasoningEffort := ""
|
||||
agentName := ""
|
||||
promptFile := ""
|
||||
promptFileExplicit := false
|
||||
yolo := false
|
||||
|
||||
if cmd.Flags().Changed("agent") {
|
||||
agentName = strings.TrimSpace(opts.Agent)
|
||||
if agentName == "" {
|
||||
return nil, fmt.Errorf("--agent flag requires a value")
|
||||
}
|
||||
if err := config.ValidateAgentName(agentName); err != nil {
|
||||
return nil, fmt.Errorf("--agent flag invalid value: %w", err)
|
||||
}
|
||||
} else {
|
||||
agentName = strings.TrimSpace(v.GetString("agent"))
|
||||
if agentName != "" {
|
||||
if err := config.ValidateAgentName(agentName); err != nil {
|
||||
return nil, fmt.Errorf("--agent flag invalid value: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning string
|
||||
if agentName != "" {
|
||||
var resolvedYolo bool
|
||||
var err error
|
||||
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, _, _, resolvedYolo, err = config.ResolveAgentConfig(agentName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve agent %q: %w", agentName, err)
|
||||
}
|
||||
yolo = resolvedYolo
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("prompt-file") {
|
||||
promptFile = strings.TrimSpace(opts.PromptFile)
|
||||
if promptFile == "" {
|
||||
return nil, fmt.Errorf("--prompt-file flag requires a value")
|
||||
}
|
||||
promptFileExplicit = true
|
||||
} else if val := strings.TrimSpace(v.GetString("prompt-file")); val != "" {
|
||||
promptFile = val
|
||||
promptFileExplicit = true
|
||||
} else {
|
||||
promptFile = resolvedPromptFile
|
||||
}
|
||||
|
||||
agentFlagChanged := cmd.Flags().Changed("agent")
|
||||
backendFlagChanged := cmd.Flags().Changed("backend")
|
||||
if backendFlagChanged {
|
||||
backendName = strings.TrimSpace(opts.Backend)
|
||||
if backendName == "" {
|
||||
return nil, fmt.Errorf("--backend flag requires a value")
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case agentFlagChanged && backendFlagChanged && lastFlagIndex(rawArgv, "agent") > lastFlagIndex(rawArgv, "backend"):
|
||||
backendName = resolvedBackend
|
||||
case !backendFlagChanged && agentName != "":
|
||||
backendName = resolvedBackend
|
||||
case !backendFlagChanged:
|
||||
if val := strings.TrimSpace(v.GetString("backend")); val != "" {
|
||||
backendName = val
|
||||
}
|
||||
}
|
||||
|
||||
modelFlagChanged := cmd.Flags().Changed("model")
|
||||
if modelFlagChanged {
|
||||
model = strings.TrimSpace(opts.Model)
|
||||
if model == "" {
|
||||
return nil, fmt.Errorf("--model flag requires a value")
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case agentFlagChanged && modelFlagChanged && lastFlagIndex(rawArgv, "agent") > lastFlagIndex(rawArgv, "model"):
|
||||
model = strings.TrimSpace(resolvedModel)
|
||||
case !modelFlagChanged && agentName != "":
|
||||
model = strings.TrimSpace(resolvedModel)
|
||||
case !modelFlagChanged:
|
||||
model = strings.TrimSpace(v.GetString("model"))
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("reasoning-effort") {
|
||||
reasoningEffort = strings.TrimSpace(opts.ReasoningEffort)
|
||||
if reasoningEffort == "" {
|
||||
return nil, fmt.Errorf("--reasoning-effort flag requires a value")
|
||||
}
|
||||
} else if val := strings.TrimSpace(v.GetString("reasoning-effort")); val != "" {
|
||||
reasoningEffort = val
|
||||
} else if agentName != "" {
|
||||
reasoningEffort = strings.TrimSpace(resolvedReasoning)
|
||||
}
|
||||
|
||||
skipChanged := cmd.Flags().Changed("skip-permissions") || cmd.Flags().Changed("dangerously-skip-permissions")
|
||||
skipPermissions := false
|
||||
if skipChanged {
|
||||
skipPermissions = opts.SkipPermissions
|
||||
} else {
|
||||
skipPermissions = v.GetBool("skip-permissions")
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("task required")
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
WorkDir: defaultWorkdir,
|
||||
Backend: backendName,
|
||||
Agent: agentName,
|
||||
PromptFile: promptFile,
|
||||
PromptFileExplicit: promptFileExplicit,
|
||||
SkipPermissions: skipPermissions,
|
||||
Yolo: yolo,
|
||||
Model: model,
|
||||
ReasoningEffort: reasoningEffort,
|
||||
MaxParallelWorkers: config.ResolveMaxParallelWorkers(),
|
||||
}
|
||||
|
||||
if args[0] == "resume" {
|
||||
if len(args) < 3 {
|
||||
return nil, fmt.Errorf("resume mode requires: resume <session_id> <task>")
|
||||
}
|
||||
cfg.Mode = "resume"
|
||||
cfg.SessionID = strings.TrimSpace(args[1])
|
||||
if cfg.SessionID == "" {
|
||||
return nil, fmt.Errorf("resume mode requires non-empty session_id")
|
||||
}
|
||||
cfg.Task = args[2]
|
||||
cfg.ExplicitStdin = (args[2] == "-")
|
||||
if len(args) > 3 {
|
||||
if args[3] == "-" {
|
||||
return nil, fmt.Errorf("invalid workdir: '-' is not a valid directory path")
|
||||
}
|
||||
cfg.WorkDir = args[3]
|
||||
}
|
||||
} else {
|
||||
cfg.Mode = "new"
|
||||
cfg.Task = args[0]
|
||||
cfg.ExplicitStdin = (args[0] == "-")
|
||||
if len(args) > 1 {
|
||||
if args[1] == "-" {
|
||||
return nil, fmt.Errorf("invalid workdir: '-' is not a valid directory path")
|
||||
}
|
||||
cfg.WorkDir = args[1]
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func lastFlagIndex(argv []string, name string) int {
|
||||
if len(argv) == 0 {
|
||||
return -1
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return -1
|
||||
}
|
||||
|
||||
needle := "--" + name
|
||||
prefix := needle + "="
|
||||
last := -1
|
||||
for i, arg := range argv {
|
||||
if arg == needle || strings.HasPrefix(arg, prefix) {
|
||||
last = i
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func runParallelMode(cmd *cobra.Command, args []string, opts *cliOptions, v *viper.Viper, name string) int {
|
||||
if len(args) > 0 {
|
||||
fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; no positional arguments are allowed.")
|
||||
fmt.Fprintln(os.Stderr, "Usage examples:")
|
||||
fmt.Fprintf(os.Stderr, " %s --parallel < tasks.txt\n", name)
|
||||
fmt.Fprintf(os.Stderr, " echo '...' | %s --parallel\n", name)
|
||||
fmt.Fprintf(os.Stderr, " %s --parallel <<'EOF'\n", name)
|
||||
fmt.Fprintf(os.Stderr, " %s --parallel --full-output <<'EOF' # include full task output\n", name)
|
||||
return 1
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("agent") || cmd.Flags().Changed("prompt-file") || cmd.Flags().Changed("reasoning-effort") {
|
||||
fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; only --backend, --model, --full-output and --skip-permissions are allowed.")
|
||||
return 1
|
||||
}
|
||||
|
||||
backendName := defaultBackendName
|
||||
if cmd.Flags().Changed("backend") {
|
||||
backendName = strings.TrimSpace(opts.Backend)
|
||||
if backendName == "" {
|
||||
fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value")
|
||||
return 1
|
||||
}
|
||||
} else if val := strings.TrimSpace(v.GetString("backend")); val != "" {
|
||||
backendName = val
|
||||
}
|
||||
|
||||
model := ""
|
||||
if cmd.Flags().Changed("model") {
|
||||
model = strings.TrimSpace(opts.Model)
|
||||
if model == "" {
|
||||
fmt.Fprintln(os.Stderr, "ERROR: --model flag requires a value")
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
model = strings.TrimSpace(v.GetString("model"))
|
||||
}
|
||||
|
||||
fullOutput := opts.FullOutput
|
||||
if !cmd.Flags().Changed("full-output") && v.IsSet("full-output") {
|
||||
fullOutput = v.GetBool("full-output")
|
||||
}
|
||||
|
||||
skipChanged := cmd.Flags().Changed("skip-permissions") || cmd.Flags().Changed("dangerously-skip-permissions")
|
||||
skipPermissions := false
|
||||
if skipChanged {
|
||||
skipPermissions = opts.SkipPermissions
|
||||
} else {
|
||||
skipPermissions = v.GetBool("skip-permissions")
|
||||
}
|
||||
|
||||
backend, err := selectBackendFn(backendName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
backendName = backend.Name()
|
||||
|
||||
data, err := io.ReadAll(stdinReader)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: failed to read stdin: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
cfg, err := parseParallelConfig(data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
cfg.GlobalBackend = backendName
|
||||
model = strings.TrimSpace(model)
|
||||
for i := range cfg.Tasks {
|
||||
if strings.TrimSpace(cfg.Tasks[i].Backend) == "" {
|
||||
cfg.Tasks[i].Backend = backendName
|
||||
}
|
||||
if strings.TrimSpace(cfg.Tasks[i].Model) == "" && model != "" {
|
||||
cfg.Tasks[i].Model = model
|
||||
}
|
||||
cfg.Tasks[i].SkipPermissions = cfg.Tasks[i].SkipPermissions || skipPermissions
|
||||
}
|
||||
|
||||
timeoutSec := resolveTimeout()
|
||||
layers, err := topologicalSort(cfg.Tasks)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
results := executeConcurrent(layers, timeoutSec)
|
||||
|
||||
for i := range results {
|
||||
results[i].CoverageTarget = defaultCoverageTarget
|
||||
if results[i].Message == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
lines := strings.Split(results[i].Message, "\n")
|
||||
results[i].Coverage = extractCoverageFromLines(lines)
|
||||
results[i].CoverageNum = extractCoverageNum(results[i].Coverage)
|
||||
results[i].FilesChanged = extractFilesChangedFromLines(lines)
|
||||
results[i].TestsPassed, results[i].TestsFailed = extractTestResultsFromLines(lines)
|
||||
results[i].KeyOutput = extractKeyOutputFromLines(lines, 150)
|
||||
}
|
||||
|
||||
fmt.Println(generateFinalOutputWithMode(results, !fullOutput))
|
||||
|
||||
exitCode := 0
|
||||
for _, res := range results {
|
||||
if res.ExitCode != 0 {
|
||||
exitCode = res.ExitCode
|
||||
}
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
func runSingleMode(cfg *Config, name string) int {
|
||||
backend, err := selectBackendFn(cfg.Backend)
|
||||
if err != nil {
|
||||
logError(err.Error())
|
||||
return 1
|
||||
}
|
||||
cfg.Backend = backend.Name()
|
||||
|
||||
cmdInjected := codexCommand != defaultCodexCommand
|
||||
argsInjected := buildCodexArgsFn != nil && reflect.ValueOf(buildCodexArgsFn).Pointer() != reflect.ValueOf(defaultBuildArgsFn).Pointer()
|
||||
|
||||
if backend.Name() != defaultBackendName || !cmdInjected {
|
||||
codexCommand = backend.Command()
|
||||
}
|
||||
if backend.Name() != defaultBackendName || !argsInjected {
|
||||
buildCodexArgsFn = backend.BuildArgs
|
||||
}
|
||||
logInfo(fmt.Sprintf("Selected backend: %s", backend.Name()))
|
||||
|
||||
timeoutSec := resolveTimeout()
|
||||
logInfo(fmt.Sprintf("Timeout: %ds", timeoutSec))
|
||||
cfg.Timeout = timeoutSec
|
||||
|
||||
var taskText string
|
||||
var piped bool
|
||||
|
||||
if cfg.ExplicitStdin {
|
||||
logInfo("Explicit stdin mode: reading task from stdin")
|
||||
data, err := io.ReadAll(stdinReader)
|
||||
if err != nil {
|
||||
logError("Failed to read stdin: " + err.Error())
|
||||
return 1
|
||||
}
|
||||
taskText = string(data)
|
||||
if taskText == "" {
|
||||
logError("Explicit stdin mode requires task input from stdin")
|
||||
return 1
|
||||
}
|
||||
piped = !isTerminal()
|
||||
} else {
|
||||
pipedTask, err := readPipedTask()
|
||||
if err != nil {
|
||||
logError("Failed to read piped stdin: " + err.Error())
|
||||
return 1
|
||||
}
|
||||
piped = pipedTask != ""
|
||||
if piped {
|
||||
taskText = pipedTask
|
||||
} else {
|
||||
taskText = cfg.Task
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.PromptFile) != "" {
|
||||
prompt, err := readAgentPromptFile(cfg.PromptFile, cfg.PromptFileExplicit)
|
||||
if err != nil {
|
||||
logError("Failed to read prompt file: " + err.Error())
|
||||
return 1
|
||||
}
|
||||
taskText = wrapTaskWithAgentPrompt(prompt, taskText)
|
||||
}
|
||||
|
||||
useStdin := cfg.ExplicitStdin || shouldUseStdin(taskText, piped)
|
||||
|
||||
targetArg := taskText
|
||||
if useStdin {
|
||||
targetArg = "-"
|
||||
}
|
||||
codexArgs := buildCodexArgsFn(cfg, targetArg)
|
||||
|
||||
logger := activeLogger()
|
||||
if logger == nil {
|
||||
fmt.Fprintln(os.Stderr, "ERROR: logger is not initialized")
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[%s]\n", name)
|
||||
fmt.Fprintf(os.Stderr, " Backend: %s\n", cfg.Backend)
|
||||
fmt.Fprintf(os.Stderr, " Command: %s %s\n", codexCommand, strings.Join(codexArgs, " "))
|
||||
fmt.Fprintf(os.Stderr, " PID: %d\n", os.Getpid())
|
||||
fmt.Fprintf(os.Stderr, " Log: %s\n", logger.Path())
|
||||
|
||||
if useStdin {
|
||||
var reasons []string
|
||||
if piped {
|
||||
reasons = append(reasons, "piped input")
|
||||
}
|
||||
if cfg.ExplicitStdin {
|
||||
reasons = append(reasons, "explicit \"-\"")
|
||||
}
|
||||
if strings.Contains(taskText, "\n") {
|
||||
reasons = append(reasons, "newline")
|
||||
}
|
||||
if strings.Contains(taskText, "\\") {
|
||||
reasons = append(reasons, "backslash")
|
||||
}
|
||||
if strings.Contains(taskText, "\"") {
|
||||
reasons = append(reasons, "double-quote")
|
||||
}
|
||||
if strings.Contains(taskText, "'") {
|
||||
reasons = append(reasons, "single-quote")
|
||||
}
|
||||
if strings.Contains(taskText, "`") {
|
||||
reasons = append(reasons, "backtick")
|
||||
}
|
||||
if strings.Contains(taskText, "$") {
|
||||
reasons = append(reasons, "dollar")
|
||||
}
|
||||
if len(taskText) > 800 {
|
||||
reasons = append(reasons, "length>800")
|
||||
}
|
||||
if len(reasons) > 0 {
|
||||
logWarn(fmt.Sprintf("Using stdin mode for task due to: %s", strings.Join(reasons, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
logInfo(fmt.Sprintf("%s running...", cfg.Backend))
|
||||
|
||||
taskSpec := TaskSpec{
|
||||
Task: taskText,
|
||||
WorkDir: cfg.WorkDir,
|
||||
Mode: cfg.Mode,
|
||||
SessionID: cfg.SessionID,
|
||||
Backend: cfg.Backend,
|
||||
Model: cfg.Model,
|
||||
ReasoningEffort: cfg.ReasoningEffort,
|
||||
Agent: cfg.Agent,
|
||||
SkipPermissions: cfg.SkipPermissions,
|
||||
UseStdin: useStdin,
|
||||
}
|
||||
|
||||
result := runTaskFn(taskSpec, false, cfg.Timeout)
|
||||
|
||||
if result.ExitCode != 0 {
|
||||
return result.ExitCode
|
||||
}
|
||||
|
||||
// Validate that we got a meaningful output message
|
||||
if strings.TrimSpace(result.Message) == "" {
|
||||
logError(fmt.Sprintf("no output message: backend=%s returned empty result.Message with exit_code=0", cfg.Backend))
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Println(result.Message)
|
||||
if result.SessionID != "" {
|
||||
fmt.Printf("\n---\nSESSION_ID: %s\n", result.SessionID)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import config "codeagent-wrapper/internal/config"
|
||||
|
||||
// Keep the existing Config name throughout the codebase, but source the
|
||||
// implementation from internal/config.
|
||||
type Config = config.Config
|
||||
@@ -1,54 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
backend "codeagent-wrapper/internal/backend"
|
||||
config "codeagent-wrapper/internal/config"
|
||||
executor "codeagent-wrapper/internal/executor"
|
||||
)
|
||||
|
||||
// defaultRunCodexTaskFn is the default implementation of runCodexTaskFn (exposed for test reset).
|
||||
func defaultRunCodexTaskFn(task TaskSpec, timeout int) TaskResult {
|
||||
return executor.DefaultRunCodexTaskFn(task, timeout)
|
||||
}
|
||||
|
||||
var runCodexTaskFn = defaultRunCodexTaskFn
|
||||
|
||||
func topologicalSort(tasks []TaskSpec) ([][]TaskSpec, error) {
|
||||
return executor.TopologicalSort(tasks)
|
||||
}
|
||||
|
||||
func executeConcurrent(layers [][]TaskSpec, timeout int) []TaskResult {
|
||||
maxWorkers := config.ResolveMaxParallelWorkers()
|
||||
return executeConcurrentWithContext(context.Background(), layers, timeout, maxWorkers)
|
||||
}
|
||||
|
||||
func executeConcurrentWithContext(parentCtx context.Context, layers [][]TaskSpec, timeout int, maxWorkers int) []TaskResult {
|
||||
return executor.ExecuteConcurrentWithContext(parentCtx, layers, timeout, maxWorkers, runCodexTaskFn)
|
||||
}
|
||||
|
||||
func generateFinalOutput(results []TaskResult) string {
|
||||
return executor.GenerateFinalOutput(results)
|
||||
}
|
||||
|
||||
func generateFinalOutputWithMode(results []TaskResult, summaryOnly bool) string {
|
||||
return executor.GenerateFinalOutputWithMode(results, summaryOnly)
|
||||
}
|
||||
|
||||
func buildCodexArgs(cfg *Config, targetArg string) []string {
|
||||
return backend.BuildCodexArgs(cfg, targetArg)
|
||||
}
|
||||
|
||||
func runCodexTask(taskSpec TaskSpec, silent bool, timeoutSec int) TaskResult {
|
||||
return runCodexTaskWithContext(context.Background(), taskSpec, nil, nil, false, silent, timeoutSec)
|
||||
}
|
||||
|
||||
func runCodexProcess(parentCtx context.Context, codexArgs []string, taskText string, useStdin bool, timeoutSec int) (message, threadID string, exitCode int) {
|
||||
res := runCodexTaskWithContext(parentCtx, TaskSpec{Task: taskText, WorkDir: defaultWorkdir, Mode: "new", UseStdin: useStdin}, nil, codexArgs, true, false, timeoutSec)
|
||||
return res.Message, res.SessionID, res.ExitCode
|
||||
}
|
||||
|
||||
func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backend Backend, customArgs []string, useCustomArgs bool, silent bool, timeoutSec int) TaskResult {
|
||||
return executor.RunCodexTaskWithContext(parentCtx, taskSpec, backend, codexCommand, buildCodexArgsFn, customArgs, useCustomArgs, silent, timeoutSec)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import ilogger "codeagent-wrapper/internal/logger"
|
||||
|
||||
type Logger = ilogger.Logger
|
||||
type CleanupStats = ilogger.CleanupStats
|
||||
|
||||
func NewLogger() (*Logger, error) { return ilogger.NewLogger() }
|
||||
|
||||
func NewLoggerWithSuffix(suffix string) (*Logger, error) { return ilogger.NewLoggerWithSuffix(suffix) }
|
||||
|
||||
func setLogger(l *Logger) { ilogger.SetLogger(l) }
|
||||
|
||||
func closeLogger() error { return ilogger.CloseLogger() }
|
||||
|
||||
func activeLogger() *Logger { return ilogger.ActiveLogger() }
|
||||
|
||||
func logInfo(msg string) { ilogger.LogInfo(msg) }
|
||||
|
||||
func logWarn(msg string) { ilogger.LogWarn(msg) }
|
||||
|
||||
func logError(msg string) { ilogger.LogError(msg) }
|
||||
|
||||
func cleanupOldLogs() (CleanupStats, error) { return ilogger.CleanupOldLogs() }
|
||||
|
||||
func sanitizeLogSuffix(raw string) string { return ilogger.SanitizeLogSuffix(raw) }
|
||||
@@ -1,46 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseArgs_Workdir_OSPaths(t *testing.T) {
|
||||
oldArgv := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgv })
|
||||
|
||||
workdirs := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "windows drive forward slashes", path: "D:/repo/path"},
|
||||
{name: "windows drive backslashes", path: `C:\repo\path`},
|
||||
{name: "windows UNC", path: `\\server\share\repo`},
|
||||
{name: "unix absolute", path: "/home/user/repo"},
|
||||
{name: "relative", path: "./relative/repo"},
|
||||
}
|
||||
|
||||
for _, wd := range workdirs {
|
||||
t.Run("new mode: "+wd.name, func(t *testing.T) {
|
||||
os.Args = []string{"codeagent-wrapper", "task", wd.path}
|
||||
cfg, err := parseArgs()
|
||||
if err != nil {
|
||||
t.Fatalf("parseArgs() error: %v", err)
|
||||
}
|
||||
if cfg.Mode != "new" || cfg.Task != "task" || cfg.WorkDir != wd.path {
|
||||
t.Fatalf("cfg mismatch: got mode=%q task=%q workdir=%q, want mode=%q task=%q workdir=%q", cfg.Mode, cfg.Task, cfg.WorkDir, "new", "task", wd.path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume mode: "+wd.name, func(t *testing.T) {
|
||||
os.Args = []string{"codeagent-wrapper", "resume", "sid-1", "task", wd.path}
|
||||
cfg, err := parseArgs()
|
||||
if err != nil {
|
||||
t.Fatalf("parseArgs() error: %v", err)
|
||||
}
|
||||
if cfg.Mode != "resume" || cfg.SessionID != "sid-1" || cfg.Task != "task" || cfg.WorkDir != wd.path {
|
||||
t.Fatalf("cfg mismatch: got mode=%q sid=%q task=%q workdir=%q, want mode=%q sid=%q task=%q workdir=%q", cfg.Mode, cfg.SessionID, cfg.Task, cfg.WorkDir, "resume", "sid-1", "task", wd.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
executor "codeagent-wrapper/internal/executor"
|
||||
)
|
||||
|
||||
func parseParallelConfig(data []byte) (*ParallelConfig, error) {
|
||||
return executor.ParseParallelConfig(data)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
|
||||
parser "codeagent-wrapper/internal/parser"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
func parseJSONStream(r io.Reader) (message, threadID string) {
|
||||
return parseJSONStreamWithLog(r, logWarn, logInfo)
|
||||
}
|
||||
|
||||
func parseJSONStreamWithWarn(r io.Reader, warnFn func(string)) (message, threadID string) {
|
||||
return parseJSONStreamWithLog(r, warnFn, logInfo)
|
||||
}
|
||||
|
||||
func parseJSONStreamWithLog(r io.Reader, warnFn func(string), infoFn func(string)) (message, threadID string) {
|
||||
return parseJSONStreamInternal(r, warnFn, infoFn, nil, nil)
|
||||
}
|
||||
|
||||
func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(string), onMessage func(), onComplete func()) (message, threadID string) {
|
||||
return parser.ParseJSONStreamInternal(r, warnFn, infoFn, onMessage, onComplete)
|
||||
}
|
||||
|
||||
func hasKey(m map[string]json.RawMessage, key string) bool { return parser.HasKey(m, key) }
|
||||
|
||||
func discardInvalidJSON(decoder *json.Decoder, reader *bufio.Reader) (*bufio.Reader, error) {
|
||||
return parser.DiscardInvalidJSON(decoder, reader)
|
||||
}
|
||||
|
||||
func normalizeText(text interface{}) string { return parser.NormalizeText(text) }
|
||||
@@ -1,119 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunSingleMode_UseStdin_TargetArgAndTaskText(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogger(): %v", err)
|
||||
}
|
||||
setLogger(logger)
|
||||
t.Cleanup(func() { _ = closeLogger() })
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
cfgTask string
|
||||
explicit bool
|
||||
stdinData string
|
||||
isTerminal bool
|
||||
|
||||
wantUseStdin bool
|
||||
wantTarget string
|
||||
wantTaskText string
|
||||
}
|
||||
|
||||
longTask := strings.Repeat("a", 801)
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
name: "piped input forces stdin mode",
|
||||
cfgTask: "cli-task",
|
||||
stdinData: "piped task text",
|
||||
isTerminal: false,
|
||||
wantUseStdin: true,
|
||||
wantTarget: "-",
|
||||
wantTaskText: "piped task text",
|
||||
},
|
||||
{
|
||||
name: "explicit dash forces stdin mode",
|
||||
cfgTask: "-",
|
||||
explicit: true,
|
||||
stdinData: "explicit task text",
|
||||
isTerminal: true,
|
||||
wantUseStdin: true,
|
||||
wantTarget: "-",
|
||||
wantTaskText: "explicit task text",
|
||||
},
|
||||
{
|
||||
name: "special char backslash forces stdin mode",
|
||||
cfgTask: `C:\repo\file.go`,
|
||||
isTerminal: true,
|
||||
wantUseStdin: true,
|
||||
wantTarget: "-",
|
||||
wantTaskText: `C:\repo\file.go`,
|
||||
},
|
||||
{
|
||||
name: "length>800 forces stdin mode",
|
||||
cfgTask: longTask,
|
||||
isTerminal: true,
|
||||
wantUseStdin: true,
|
||||
wantTarget: "-",
|
||||
wantTaskText: longTask,
|
||||
},
|
||||
{
|
||||
name: "simple task uses argv target",
|
||||
cfgTask: "analyze code",
|
||||
isTerminal: true,
|
||||
wantUseStdin: false,
|
||||
wantTarget: "analyze code",
|
||||
wantTaskText: "analyze code",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotTarget string
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string {
|
||||
gotTarget = targetArg
|
||||
return []string{targetArg}
|
||||
}
|
||||
|
||||
var gotTask TaskSpec
|
||||
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
|
||||
gotTask = task
|
||||
return TaskResult{ExitCode: 0, Message: "ok"}
|
||||
}
|
||||
|
||||
stdinReader = strings.NewReader(tt.stdinData)
|
||||
isTerminalFn = func() bool { return tt.isTerminal }
|
||||
|
||||
cfg := &Config{
|
||||
Mode: "new",
|
||||
Task: tt.cfgTask,
|
||||
WorkDir: defaultWorkdir,
|
||||
Backend: defaultBackendName,
|
||||
ExplicitStdin: tt.explicit,
|
||||
}
|
||||
|
||||
if code := runSingleMode(cfg, "codeagent-wrapper"); code != 0 {
|
||||
t.Fatalf("runSingleMode() = %d, want 0", code)
|
||||
}
|
||||
|
||||
if gotTarget != tt.wantTarget {
|
||||
t.Fatalf("targetArg = %q, want %q", gotTarget, tt.wantTarget)
|
||||
}
|
||||
if gotTask.UseStdin != tt.wantUseStdin {
|
||||
t.Fatalf("taskSpec.UseStdin = %v, want %v", gotTask.UseStdin, tt.wantUseStdin)
|
||||
}
|
||||
if gotTask.Task != tt.wantTaskText {
|
||||
t.Fatalf("taskSpec.Task = %q, want %q", gotTask.Task, tt.wantTaskText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import executor "codeagent-wrapper/internal/executor"
|
||||
|
||||
// Type aliases to keep existing names in the wrapper package.
|
||||
type ParallelConfig = executor.ParallelConfig
|
||||
type TaskSpec = executor.TaskSpec
|
||||
type TaskResult = executor.TaskResult
|
||||
@@ -1,30 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultIsTerminalCoverage(t *testing.T) {
|
||||
oldStdin := os.Stdin
|
||||
t.Cleanup(func() { os.Stdin = oldStdin })
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "stdin-*")
|
||||
if err != nil {
|
||||
t.Fatalf("os.CreateTemp() error = %v", err)
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
os.Stdin = f
|
||||
if got := defaultIsTerminal(); got {
|
||||
t.Fatalf("defaultIsTerminal() = %v, want false for regular file", got)
|
||||
}
|
||||
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
os.Stdin = f
|
||||
if got := defaultIsTerminal(); !got {
|
||||
t.Fatalf("defaultIsTerminal() = %v, want true when Stat fails", got)
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const tmpDirEnvOverrideKey = "CODEAGENT_TMPDIR"
|
||||
|
||||
var tmpDirExecutableCheckFn = canExecuteInDir
|
||||
|
||||
func ensureExecutableTempDir() {
|
||||
// Windows doesn't execute scripts via shebang, and os.TempDir semantics differ.
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
|
||||
if override := strings.TrimSpace(os.Getenv(tmpDirEnvOverrideKey)); override != "" {
|
||||
if resolved, err := resolvePathWithTilde(override); err == nil {
|
||||
if err := os.MkdirAll(resolved, 0o700); err == nil {
|
||||
if ok, _ := tmpDirExecutableCheckFn(resolved); ok {
|
||||
setTempEnv(resolved)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Invalid override should not block execution; fall back to default behavior.
|
||||
}
|
||||
|
||||
current := currentTempDirFromEnv()
|
||||
if current == "" {
|
||||
current = "/tmp"
|
||||
}
|
||||
|
||||
ok, _ := tmpDirExecutableCheckFn(current)
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
|
||||
fallback := defaultFallbackTempDir()
|
||||
if fallback == "" {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(fallback, 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
if ok, _ := tmpDirExecutableCheckFn(fallback); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
setTempEnv(fallback)
|
||||
fmt.Fprintf(os.Stderr, "INFO: temp dir is not executable; set TMPDIR=%s\n", fallback)
|
||||
}
|
||||
|
||||
func setTempEnv(dir string) {
|
||||
_ = os.Setenv("TMPDIR", dir)
|
||||
_ = os.Setenv("TMP", dir)
|
||||
_ = os.Setenv("TEMP", dir)
|
||||
}
|
||||
|
||||
func defaultFallbackTempDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(filepath.Join(home, ".codeagent", "tmp"))
|
||||
}
|
||||
|
||||
func currentTempDirFromEnv() string {
|
||||
for _, k := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolvePathWithTilde(p string) (string, error) {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
|
||||
if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, "~\\") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
if err == nil {
|
||||
err = errors.New("empty home directory")
|
||||
}
|
||||
return "", fmt.Errorf("resolve ~: %w", err)
|
||||
}
|
||||
if p == "~" {
|
||||
return home, nil
|
||||
}
|
||||
return filepath.Clean(home + p[1:]), nil
|
||||
}
|
||||
|
||||
return filepath.Clean(p), nil
|
||||
}
|
||||
|
||||
func canExecuteInDir(dir string) (bool, error) {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return false, errors.New("empty dir")
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp(dir, "codeagent-tmp-exec-*")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
path := f.Name()
|
||||
defer func() { _ = os.Remove(path) }()
|
||||
|
||||
if _, err := f.WriteString("#!/bin/sh\nexit 0\n"); err != nil {
|
||||
_ = f.Close()
|
||||
return false, err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.Chmod(path, 0o700); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := exec.Command(path).Run(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureExecutableTempDir_Override(t *testing.T) {
|
||||
restore := captureTempEnv()
|
||||
t.Cleanup(restore)
|
||||
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("USERPROFILE", os.Getenv("HOME"))
|
||||
|
||||
orig := tmpDirExecutableCheckFn
|
||||
tmpDirExecutableCheckFn = func(string) (bool, error) { return true, nil }
|
||||
t.Cleanup(func() { tmpDirExecutableCheckFn = orig })
|
||||
|
||||
override := filepath.Join(t.TempDir(), "mytmp")
|
||||
t.Setenv(tmpDirEnvOverrideKey, override)
|
||||
|
||||
ensureExecutableTempDir()
|
||||
|
||||
if got := os.Getenv("TMPDIR"); got != override {
|
||||
t.Fatalf("TMPDIR=%q, want %q", got, override)
|
||||
}
|
||||
if got := os.Getenv("TMP"); got != override {
|
||||
t.Fatalf("TMP=%q, want %q", got, override)
|
||||
}
|
||||
if got := os.Getenv("TEMP"); got != override {
|
||||
t.Fatalf("TEMP=%q, want %q", got, override)
|
||||
}
|
||||
if st, err := os.Stat(override); err != nil || !st.IsDir() {
|
||||
t.Fatalf("override dir not created: stat=%v err=%v", st, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureExecutableTempDir_FallbackWhenCurrentNotExecutable(t *testing.T) {
|
||||
restore := captureTempEnv()
|
||||
t.Cleanup(restore)
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
cur := filepath.Join(t.TempDir(), "cur-tmp")
|
||||
if err := os.MkdirAll(cur, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("TMPDIR", cur)
|
||||
|
||||
fallback := filepath.Join(home, ".codeagent", "tmp")
|
||||
|
||||
orig := tmpDirExecutableCheckFn
|
||||
tmpDirExecutableCheckFn = func(dir string) (bool, error) {
|
||||
if filepath.Clean(dir) == filepath.Clean(cur) {
|
||||
return false, nil
|
||||
}
|
||||
if filepath.Clean(dir) == filepath.Clean(fallback) {
|
||||
return true, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
t.Cleanup(func() { tmpDirExecutableCheckFn = orig })
|
||||
|
||||
ensureExecutableTempDir()
|
||||
|
||||
if got := os.Getenv("TMPDIR"); filepath.Clean(got) != filepath.Clean(fallback) {
|
||||
t.Fatalf("TMPDIR=%q, want %q", got, fallback)
|
||||
}
|
||||
if st, err := os.Stat(fallback); err != nil || !st.IsDir() {
|
||||
t.Fatalf("fallback dir not created: stat=%v err=%v", st, err)
|
||||
}
|
||||
}
|
||||
|
||||
func captureTempEnv() func() {
|
||||
type entry struct {
|
||||
set bool
|
||||
val string
|
||||
}
|
||||
snapshot := make(map[string]entry, 3)
|
||||
for _, k := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||
v, ok := os.LookupEnv(k)
|
||||
snapshot[k] = entry{set: ok, val: v}
|
||||
}
|
||||
return func() {
|
||||
for k, e := range snapshot {
|
||||
if !e.set {
|
||||
_ = os.Unsetenv(k)
|
||||
continue
|
||||
}
|
||||
_ = os.Setenv(k, e.val)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
utils "codeagent-wrapper/internal/utils"
|
||||
)
|
||||
|
||||
func resolveTimeout() int {
|
||||
raw := os.Getenv("CODEX_TIMEOUT")
|
||||
if raw == "" {
|
||||
return defaultTimeout
|
||||
}
|
||||
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
logWarn(fmt.Sprintf("Invalid CODEX_TIMEOUT '%s', falling back to %ds", raw, defaultTimeout))
|
||||
return defaultTimeout
|
||||
}
|
||||
|
||||
if parsed > 10000 {
|
||||
return parsed / 1000
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func readPipedTask() (string, error) {
|
||||
if isTerminal() {
|
||||
logInfo("Stdin is tty, skipping pipe read")
|
||||
return "", nil
|
||||
}
|
||||
logInfo("Reading from stdin pipe...")
|
||||
data, err := io.ReadAll(stdinReader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read stdin: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
logInfo("Stdin pipe returned empty data")
|
||||
return "", nil
|
||||
}
|
||||
logInfo(fmt.Sprintf("Read %d bytes from stdin pipe", len(data)))
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func shouldUseStdin(taskText string, piped bool) bool {
|
||||
if piped {
|
||||
return true
|
||||
}
|
||||
if len(taskText) > 800 {
|
||||
return true
|
||||
}
|
||||
return strings.ContainsAny(taskText, stdinSpecialChars)
|
||||
}
|
||||
|
||||
func defaultIsTerminal() bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
func isTerminal() bool {
|
||||
return isTerminalFn()
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
type logWriter struct {
|
||||
prefix string
|
||||
maxLen int
|
||||
buf bytes.Buffer
|
||||
dropped bool
|
||||
}
|
||||
|
||||
func newLogWriter(prefix string, maxLen int) *logWriter {
|
||||
if maxLen <= 0 {
|
||||
maxLen = codexLogLineLimit
|
||||
}
|
||||
return &logWriter{prefix: prefix, maxLen: maxLen}
|
||||
}
|
||||
|
||||
func (lw *logWriter) Write(p []byte) (int, error) {
|
||||
if lw == nil {
|
||||
return len(p), nil
|
||||
}
|
||||
total := len(p)
|
||||
for len(p) > 0 {
|
||||
if idx := bytes.IndexByte(p, '\n'); idx >= 0 {
|
||||
lw.writeLimited(p[:idx])
|
||||
lw.logLine(true)
|
||||
p = p[idx+1:]
|
||||
continue
|
||||
}
|
||||
lw.writeLimited(p)
|
||||
break
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (lw *logWriter) Flush() {
|
||||
if lw == nil || lw.buf.Len() == 0 {
|
||||
return
|
||||
}
|
||||
lw.logLine(false)
|
||||
}
|
||||
|
||||
func (lw *logWriter) logLine(force bool) {
|
||||
if lw == nil {
|
||||
return
|
||||
}
|
||||
line := lw.buf.String()
|
||||
dropped := lw.dropped
|
||||
lw.dropped = false
|
||||
lw.buf.Reset()
|
||||
if line == "" && !force {
|
||||
return
|
||||
}
|
||||
if lw.maxLen > 0 {
|
||||
if dropped {
|
||||
if lw.maxLen > 3 {
|
||||
line = line[:min(len(line), lw.maxLen-3)] + "..."
|
||||
} else {
|
||||
line = line[:min(len(line), lw.maxLen)]
|
||||
}
|
||||
} else if len(line) > lw.maxLen {
|
||||
cutoff := lw.maxLen
|
||||
if cutoff > 3 {
|
||||
line = line[:cutoff-3] + "..."
|
||||
} else {
|
||||
line = line[:cutoff]
|
||||
}
|
||||
}
|
||||
}
|
||||
logInfo(lw.prefix + line)
|
||||
}
|
||||
|
||||
func (lw *logWriter) writeLimited(p []byte) {
|
||||
if lw == nil || len(p) == 0 {
|
||||
return
|
||||
}
|
||||
if lw.maxLen <= 0 {
|
||||
lw.buf.Write(p)
|
||||
return
|
||||
}
|
||||
|
||||
remaining := lw.maxLen - lw.buf.Len()
|
||||
if remaining <= 0 {
|
||||
lw.dropped = true
|
||||
return
|
||||
}
|
||||
if len(p) <= remaining {
|
||||
lw.buf.Write(p)
|
||||
return
|
||||
}
|
||||
lw.buf.Write(p[:remaining])
|
||||
lw.dropped = true
|
||||
}
|
||||
|
||||
type tailBuffer struct {
|
||||
limit int
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (b *tailBuffer) Write(p []byte) (int, error) {
|
||||
if b.limit <= 0 {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
if len(p) >= b.limit {
|
||||
b.data = append(b.data[:0], p[len(p)-b.limit:]...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
total := len(b.data) + len(p)
|
||||
if total <= b.limit {
|
||||
b.data = append(b.data, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
overflow := total - b.limit
|
||||
b.data = append(b.data[overflow:], p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (b *tailBuffer) String() string {
|
||||
return string(b.data)
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
return utils.Truncate(s, maxLen)
|
||||
}
|
||||
|
||||
// safeTruncate safely truncates string to maxLen, avoiding panic and UTF-8 corruption.
|
||||
func safeTruncate(s string, maxLen int) string {
|
||||
return utils.SafeTruncate(s, maxLen)
|
||||
}
|
||||
|
||||
// sanitizeOutput removes ANSI escape sequences and control characters.
|
||||
func sanitizeOutput(s string) string {
|
||||
return utils.SanitizeOutput(s)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
return utils.Min(a, b)
|
||||
}
|
||||
|
||||
func hello() string {
|
||||
return "hello world"
|
||||
}
|
||||
|
||||
func greet(name string) string {
|
||||
return "hello " + name
|
||||
}
|
||||
|
||||
func farewell(name string) string {
|
||||
return "goodbye " + name
|
||||
}
|
||||
|
||||
// extractCoverageFromLines extracts coverage from pre-split lines.
|
||||
func extractCoverageFromLines(lines []string) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
end := len(lines)
|
||||
for end > 0 && strings.TrimSpace(lines[end-1]) == "" {
|
||||
end--
|
||||
}
|
||||
|
||||
if end == 1 {
|
||||
trimmed := strings.TrimSpace(lines[0])
|
||||
if strings.HasSuffix(trimmed, "%") {
|
||||
if num, err := strconv.ParseFloat(strings.TrimSuffix(trimmed, "%"), 64); err == nil && num >= 0 && num <= 100 {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
coverageKeywords := []string{"file", "stmt", "branch", "line", "coverage", "total"}
|
||||
|
||||
for _, line := range lines[:end] {
|
||||
lower := strings.ToLower(line)
|
||||
|
||||
hasKeyword := false
|
||||
tokens := strings.FieldsFunc(lower, func(r rune) bool { return r < 'a' || r > 'z' })
|
||||
for _, token := range tokens {
|
||||
for _, kw := range coverageKeywords {
|
||||
if strings.HasPrefix(token, kw) {
|
||||
hasKeyword = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasKeyword {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasKeyword {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(line, "%") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract percentage pattern: number followed by %
|
||||
for i := 0; i < len(line); i++ {
|
||||
if line[i] == '%' && i > 0 {
|
||||
// Walk back to find the number
|
||||
j := i - 1
|
||||
for j >= 0 && (line[j] == '.' || (line[j] >= '0' && line[j] <= '9')) {
|
||||
j--
|
||||
}
|
||||
if j < i-1 {
|
||||
numStr := line[j+1 : i]
|
||||
// Validate it's a reasonable percentage
|
||||
if num, err := strconv.ParseFloat(numStr, 64); err == nil && num >= 0 && num <= 100 {
|
||||
return numStr + "%"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractCoverage extracts coverage percentage from task output
|
||||
// Supports common formats: "Coverage: 92%", "92% coverage", "coverage 92%", "TOTAL 92%"
|
||||
func extractCoverage(message string) string {
|
||||
if message == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return extractCoverageFromLines(strings.Split(message, "\n"))
|
||||
}
|
||||
|
||||
// extractCoverageNum extracts coverage as a numeric value for comparison
|
||||
func extractCoverageNum(coverage string) float64 {
|
||||
if coverage == "" {
|
||||
return 0
|
||||
}
|
||||
// Remove % sign and parse
|
||||
numStr := strings.TrimSuffix(coverage, "%")
|
||||
if num, err := strconv.ParseFloat(numStr, 64); err == nil {
|
||||
return num
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// extractFilesChangedFromLines extracts files from pre-split lines.
|
||||
func extractFilesChangedFromLines(lines []string) []string {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var files []string
|
||||
seen := make(map[string]bool)
|
||||
exts := []string{".ts", ".tsx", ".js", ".jsx", ".go", ".py", ".rs", ".java", ".vue", ".css", ".scss", ".md", ".json", ".yaml", ".yml", ".toml"}
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Pattern 1: "Modified: path/to/file.ts" or "Created: path/to/file.ts"
|
||||
matchedPrefix := false
|
||||
for _, prefix := range []string{"Modified:", "Created:", "Updated:", "Edited:", "Wrote:", "Changed:"} {
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
file := strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
||||
file = strings.Trim(file, "`\"'()[],:")
|
||||
file = strings.TrimPrefix(file, "@")
|
||||
if file != "" && !seen[file] {
|
||||
files = append(files, file)
|
||||
seen[file] = true
|
||||
}
|
||||
matchedPrefix = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matchedPrefix {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pattern 2: Tokens that look like file paths (allow root files, strip @ prefix).
|
||||
parts := strings.Fields(line)
|
||||
for _, part := range parts {
|
||||
part = strings.Trim(part, "`\"'()[],:")
|
||||
part = strings.TrimPrefix(part, "@")
|
||||
for _, ext := range exts {
|
||||
if strings.HasSuffix(part, ext) && !seen[part] {
|
||||
files = append(files, part)
|
||||
seen[part] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to first 10 files to avoid bloat
|
||||
if len(files) > 10 {
|
||||
files = files[:10]
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// extractFilesChanged extracts list of changed files from task output
|
||||
// Looks for common patterns like "Modified: file.ts", "Created: file.ts", file paths in output
|
||||
func extractFilesChanged(message string) []string {
|
||||
if message == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return extractFilesChangedFromLines(strings.Split(message, "\n"))
|
||||
}
|
||||
|
||||
// extractTestResultsFromLines extracts test results from pre-split lines.
|
||||
func extractTestResultsFromLines(lines []string) (passed, failed int) {
|
||||
if len(lines) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Common patterns:
|
||||
// pytest: "12 passed, 2 failed"
|
||||
// jest: "Tests: 2 failed, 12 passed"
|
||||
// go: "ok ... 12 tests"
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.ToLower(line)
|
||||
|
||||
// Look for test result lines
|
||||
if !strings.Contains(line, "pass") && !strings.Contains(line, "fail") && !strings.Contains(line, "test") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract numbers near "passed" or "pass"
|
||||
if idx := strings.Index(line, "pass"); idx != -1 {
|
||||
// Look for number before "pass"
|
||||
num := extractNumberBefore(line, idx)
|
||||
if num > 0 {
|
||||
passed = num
|
||||
}
|
||||
}
|
||||
|
||||
// Extract numbers near "failed" or "fail"
|
||||
if idx := strings.Index(line, "fail"); idx != -1 {
|
||||
num := extractNumberBefore(line, idx)
|
||||
if num > 0 {
|
||||
failed = num
|
||||
}
|
||||
}
|
||||
|
||||
// go test style: "ok ... 12 tests"
|
||||
if passed == 0 {
|
||||
if idx := strings.Index(line, "test"); idx != -1 {
|
||||
num := extractNumberBefore(line, idx)
|
||||
if num > 0 {
|
||||
passed = num
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found both, stop
|
||||
if passed > 0 && failed > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return passed, failed
|
||||
}
|
||||
|
||||
// extractTestResults extracts test pass/fail counts from task output
|
||||
func extractTestResults(message string) (passed, failed int) {
|
||||
if message == "" {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
return extractTestResultsFromLines(strings.Split(message, "\n"))
|
||||
}
|
||||
|
||||
// extractNumberBefore extracts a number that appears before the given index
|
||||
func extractNumberBefore(s string, idx int) int {
|
||||
if idx <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Walk backwards to find digits
|
||||
end := idx - 1
|
||||
for end >= 0 && (s[end] == ' ' || s[end] == ':' || s[end] == ',') {
|
||||
end--
|
||||
}
|
||||
if end < 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
start := end
|
||||
for start >= 0 && s[start] >= '0' && s[start] <= '9' {
|
||||
start--
|
||||
}
|
||||
start++
|
||||
|
||||
if start > end {
|
||||
return 0
|
||||
}
|
||||
|
||||
numStr := s[start : end+1]
|
||||
if num, err := strconv.Atoi(numStr); err == nil {
|
||||
return num
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// extractKeyOutputFromLines extracts key output from pre-split lines.
|
||||
func extractKeyOutputFromLines(lines []string, maxLen int) string {
|
||||
if len(lines) == 0 || maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Priority 1: Look for explicit summary lines
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
lower := strings.ToLower(line)
|
||||
if strings.HasPrefix(lower, "summary:") || strings.HasPrefix(lower, "completed:") ||
|
||||
strings.HasPrefix(lower, "implemented:") || strings.HasPrefix(lower, "added:") ||
|
||||
strings.HasPrefix(lower, "created:") || strings.HasPrefix(lower, "fixed:") {
|
||||
content := line
|
||||
for _, prefix := range []string{"Summary:", "Completed:", "Implemented:", "Added:", "Created:", "Fixed:",
|
||||
"summary:", "completed:", "implemented:", "added:", "created:", "fixed:"} {
|
||||
content = strings.TrimPrefix(content, prefix)
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if len(content) > 0 {
|
||||
return safeTruncate(content, maxLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: First meaningful line (skip noise)
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "```") || strings.HasPrefix(line, "---") ||
|
||||
strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
// Skip very short lines (likely headers or markers)
|
||||
if len(line) < 20 {
|
||||
continue
|
||||
}
|
||||
return safeTruncate(line, maxLen)
|
||||
}
|
||||
|
||||
// Fallback: truncate entire message
|
||||
clean := strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
return safeTruncate(clean, maxLen)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractCoverage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"bare int", "92%", "92%"},
|
||||
{"bare float", "92.5%", "92.5%"},
|
||||
{"coverage prefix", "coverage: 92%", "92%"},
|
||||
{"total prefix", "TOTAL 92%", "92%"},
|
||||
{"all files", "All files 92%", "92%"},
|
||||
{"empty", "", ""},
|
||||
{"no number", "coverage: N/A", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractCoverage(tt.in); got != tt.want {
|
||||
t.Fatalf("extractCoverage(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTestResults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
wantPassed int
|
||||
wantFailed int
|
||||
}{
|
||||
{"pytest one line", "12 passed, 2 failed", 12, 2},
|
||||
{"pytest split lines", "12 passed\n2 failed", 12, 2},
|
||||
{"jest format", "Tests: 2 failed, 12 passed, 14 total", 12, 2},
|
||||
{"go test style count", "ok\texample.com/foo\t0.12s\t12 tests", 12, 0},
|
||||
{"zero counts", "0 passed, 0 failed", 0, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
passed, failed := extractTestResults(tt.in)
|
||||
if passed != tt.wantPassed || failed != tt.wantFailed {
|
||||
t.Fatalf("extractTestResults(%q) = (%d, %d), want (%d, %d)", tt.in, passed, failed, tt.wantPassed, tt.wantFailed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFilesChanged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"root file", "Modified: main.go\n", []string{"main.go"}},
|
||||
{"path file", "Created: codeagent-wrapper/utils.go\n", []string{"codeagent-wrapper/utils.go"}},
|
||||
{"at prefix", "Updated: @codeagent-wrapper/main.go\n", []string{"codeagent-wrapper/main.go"}},
|
||||
{"token scan", "Files: @main.go, @codeagent-wrapper/utils.go\n", []string{"main.go", "codeagent-wrapper/utils.go"}},
|
||||
{"space path", "Modified: dir/with space/file.go\n", []string{"dir/with space/file.go"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractFilesChanged(tt.in); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("extractFilesChanged(%q) = %#v, want %#v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("limits to first 10", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for i := 0; i < 12; i++ {
|
||||
fmt.Fprintf(&b, "Modified: file%d.go\n", i)
|
||||
}
|
||||
got := extractFilesChanged(b.String())
|
||||
if len(got) != 10 {
|
||||
t.Fatalf("len(files)=%d, want 10: %#v", len(got), got)
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
want := fmt.Sprintf("file%d.go", i)
|
||||
if got[i] != want {
|
||||
t.Fatalf("files[%d]=%q, want %q", i, got[i], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSafeTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"empty", "", 4, ""},
|
||||
{"zero maxLen", "hello", 0, ""},
|
||||
{"one rune", "你好", 1, "你"},
|
||||
{"two runes no truncate", "你好", 2, "你好"},
|
||||
{"three runes no truncate", "你好", 3, "你好"},
|
||||
{"two runes truncates long", "你好世界", 2, "你"},
|
||||
{"three runes truncates long", "你好世界", 3, "你"},
|
||||
{"four with ellipsis", "你好世界啊", 4, "你..."},
|
||||
{"emoji", "🙂🙂🙂🙂🙂", 4, "🙂..."},
|
||||
{"no truncate", "你好世界", 4, "你好世界"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := safeTruncate(tt.in, tt.maxLen); got != tt.want {
|
||||
t.Fatalf("safeTruncate(%q, %d) = %q, want %q", tt.in, tt.maxLen, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"ansi", "\x1b[31mred\x1b[0m", "red"},
|
||||
{"control chars", "a\x07b\r\nc\t", "ab\nc\t"},
|
||||
{"normal", "hello\nworld\t!", "hello\nworld\t!"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sanitizeOutput(tt.in); got != tt.want {
|
||||
t.Fatalf("sanitizeOutput(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package wrapper
|
||||
|
||||
import ilogger "codeagent-wrapper/internal/logger"
|
||||
|
||||
const wrapperName = ilogger.WrapperName
|
||||
|
||||
func currentWrapperName() string { return ilogger.CurrentWrapperName() }
|
||||
|
||||
func primaryLogPrefix() string { return ilogger.PrimaryLogPrefix() }
|
||||
@@ -1,33 +0,0 @@
|
||||
package backend
|
||||
|
||||
import config "codeagent-wrapper/internal/config"
|
||||
|
||||
// Backend defines the contract for invoking different AI CLI backends.
|
||||
// Each backend is responsible for supplying the executable command and
|
||||
// building the argument list based on the wrapper config.
|
||||
type Backend interface {
|
||||
Name() string
|
||||
BuildArgs(cfg *config.Config, targetArg string) []string
|
||||
Command() string
|
||||
Env(baseURL, apiKey string) map[string]string
|
||||
}
|
||||
|
||||
var (
|
||||
logWarnFn = func(string) {}
|
||||
logErrorFn = func(string) {}
|
||||
)
|
||||
|
||||
// SetLogFuncs configures optional logging hooks used by some backends.
|
||||
// Callers can safely pass nil to disable the hook.
|
||||
func SetLogFuncs(warnFn, errorFn func(string)) {
|
||||
if warnFn != nil {
|
||||
logWarnFn = warnFn
|
||||
} else {
|
||||
logWarnFn = func(string) {}
|
||||
}
|
||||
if errorFn != nil {
|
||||
logErrorFn = errorFn
|
||||
} else {
|
||||
logErrorFn = func(string) {}
|
||||
}
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) {
|
||||
backend := ClaudeBackend{}
|
||||
|
||||
t.Run("new mode omits skip-permissions when env disabled", func(t *testing.T) {
|
||||
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
|
||||
cfg := &config.Config{Mode: "new", WorkDir: "/repo"}
|
||||
got := backend.BuildArgs(cfg, "todo")
|
||||
want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "todo"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new mode includes skip-permissions by default", func(t *testing.T) {
|
||||
cfg := &config.Config{Mode: "new", SkipPermissions: false}
|
||||
got := backend.BuildArgs(cfg, "-")
|
||||
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "-"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume mode includes session id", func(t *testing.T) {
|
||||
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
|
||||
cfg := &config.Config{Mode: "resume", SessionID: "sid-123", WorkDir: "/ignored"}
|
||||
got := backend.BuildArgs(cfg, "resume-task")
|
||||
want := []string{"-p", "--setting-sources", "", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume mode without session still returns base flags", func(t *testing.T) {
|
||||
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
|
||||
cfg := &config.Config{Mode: "resume", WorkDir: "/ignored"}
|
||||
got := backend.BuildArgs(cfg, "follow-up")
|
||||
want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "follow-up"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume mode can opt-in skip permissions", func(t *testing.T) {
|
||||
cfg := &config.Config{Mode: "resume", SessionID: "sid-123", SkipPermissions: true}
|
||||
got := backend.BuildArgs(cfg, "resume-task")
|
||||
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil config returns nil", func(t *testing.T) {
|
||||
if backend.BuildArgs(nil, "ignored") != nil {
|
||||
t.Fatalf("nil config should return nil args")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackendBuildArgs_Model(t *testing.T) {
|
||||
t.Run("claude includes --model when set", func(t *testing.T) {
|
||||
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
|
||||
backend := ClaudeBackend{}
|
||||
cfg := &config.Config{Mode: "new", Model: "opus"}
|
||||
got := backend.BuildArgs(cfg, "todo")
|
||||
want := []string{"-p", "--setting-sources", "", "--model", "opus", "--output-format", "stream-json", "--verbose", "todo"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini includes -m when set", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &config.Config{Mode: "new", Model: "gemini-3-pro-preview"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"-o", "stream-json", "-y", "-m", "gemini-3-pro-preview", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("codex includes --model when set", func(t *testing.T) {
|
||||
const key = "CODEX_BYPASS_SANDBOX"
|
||||
t.Setenv(key, "false")
|
||||
|
||||
backend := CodexBackend{}
|
||||
cfg := &config.Config{Mode: "new", WorkDir: "/tmp", Model: "o3"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"e", "--model", "o3", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaudeBuildArgs_GeminiAndCodexModes(t *testing.T) {
|
||||
t.Run("gemini new mode defaults workdir", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &config.Config{Mode: "new", WorkDir: "/workspace"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"-o", "stream-json", "-y", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini resume mode uses session id", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &config.Config{Mode: "resume", SessionID: "sid-999"}
|
||||
got := backend.BuildArgs(cfg, "resume")
|
||||
want := []string{"-o", "stream-json", "-y", "-r", "sid-999", "resume"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini resume mode without session omits identifier", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &config.Config{Mode: "resume"}
|
||||
got := backend.BuildArgs(cfg, "resume")
|
||||
want := []string{"-o", "stream-json", "-y", "resume"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini nil config returns nil", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
if backend.BuildArgs(nil, "ignored") != nil {
|
||||
t.Fatalf("nil config should return nil args")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini stdin mode uses -p flag", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &config.Config{Mode: "new"}
|
||||
got := backend.BuildArgs(cfg, "-")
|
||||
want := []string{"-o", "stream-json", "-y", "-p", "-"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("codex build args omits bypass flag by default", func(t *testing.T) {
|
||||
const key = "CODEX_BYPASS_SANDBOX"
|
||||
t.Setenv(key, "false")
|
||||
|
||||
backend := CodexBackend{}
|
||||
cfg := &config.Config{Mode: "new", WorkDir: "/tmp"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"e", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("codex build args includes bypass flag when enabled", func(t *testing.T) {
|
||||
const key = "CODEX_BYPASS_SANDBOX"
|
||||
t.Setenv(key, "true")
|
||||
|
||||
backend := CodexBackend{}
|
||||
cfg := &config.Config{Mode: "new", WorkDir: "/tmp"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"e", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaudeBuildArgs_BackendMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
backend Backend
|
||||
name string
|
||||
command string
|
||||
}{
|
||||
{backend: CodexBackend{}, name: "codex", command: "codex"},
|
||||
{backend: ClaudeBackend{}, name: "claude", command: "claude"},
|
||||
{backend: GeminiBackend{}, name: "gemini", command: "gemini"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := tt.backend.Name(); got != tt.name {
|
||||
t.Fatalf("Name() = %s, want %s", got, tt.name)
|
||||
}
|
||||
if got := tt.backend.Command(); got != tt.command {
|
||||
t.Fatalf("Command() = %s, want %s", got, tt.command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMinimalEnvSettings(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
t.Run("missing file returns empty", func(t *testing.T) {
|
||||
if got := LoadMinimalEnvSettings(); len(got) != 0 {
|
||||
t.Fatalf("got %v, want empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid env returns string map", func(t *testing.T) {
|
||||
dir := filepath.Join(home, ".claude")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
data := []byte(`{"env":{"ANTHROPIC_API_KEY":"secret","FOO":"bar"}}`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got := LoadMinimalEnvSettings()
|
||||
if got["ANTHROPIC_API_KEY"] != "secret" || got["FOO"] != "bar" {
|
||||
t.Fatalf("got %v, want keys present", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-string values are ignored", func(t *testing.T) {
|
||||
dir := filepath.Join(home, ".claude")
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
data := []byte(`{"env":{"GOOD":"ok","BAD":123,"ALSO_BAD":true}}`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got := LoadMinimalEnvSettings()
|
||||
if got["GOOD"] != "ok" {
|
||||
t.Fatalf("got %v, want GOOD=ok", got)
|
||||
}
|
||||
if _, ok := got["BAD"]; ok {
|
||||
t.Fatalf("got %v, want BAD omitted", got)
|
||||
}
|
||||
if _, ok := got["ALSO_BAD"]; ok {
|
||||
t.Fatalf("got %v, want ALSO_BAD omitted", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized file returns empty", func(t *testing.T) {
|
||||
dir := filepath.Join(home, ".claude")
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
data := bytes.Repeat([]byte("a"), MaxClaudeSettingsBytes+1)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if got := LoadMinimalEnvSettings(); len(got) != 0 {
|
||||
t.Fatalf("got %v, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpencodeBackend_BuildArgs(t *testing.T) {
|
||||
backend := OpencodeBackend{}
|
||||
|
||||
t.Run("basic", func(t *testing.T) {
|
||||
cfg := &config.Config{Mode: "new"}
|
||||
got := backend.BuildArgs(cfg, "hello")
|
||||
want := []string{"run", "--format", "json", "hello"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with model", func(t *testing.T) {
|
||||
cfg := &config.Config{Mode: "new", Model: "opencode/grok-code"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"run", "-m", "opencode/grok-code", "--format", "json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume mode", func(t *testing.T) {
|
||||
cfg := &config.Config{Mode: "resume", SessionID: "ses_123", Model: "opencode/grok-code"}
|
||||
got := backend.BuildArgs(cfg, "follow-up")
|
||||
want := []string{"run", "-m", "opencode/grok-code", "-s", "ses_123", "--format", "json", "follow-up"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume without session", func(t *testing.T) {
|
||||
cfg := &config.Config{Mode: "resume"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"run", "--format", "json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stdin mode omits dash", func(t *testing.T) {
|
||||
cfg := &config.Config{Mode: "new"}
|
||||
got := backend.BuildArgs(cfg, "-")
|
||||
want := []string{"run", "--format", "json"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpencodeBackend_Interface(t *testing.T) {
|
||||
backend := OpencodeBackend{}
|
||||
|
||||
if backend.Name() != "opencode" {
|
||||
t.Errorf("Name() = %q, want %q", backend.Name(), "opencode")
|
||||
}
|
||||
if backend.Command() != "opencode" {
|
||||
t.Errorf("Command() = %q, want %q", backend.Command(), "opencode")
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
type ClaudeBackend struct{}
|
||||
|
||||
func (ClaudeBackend) Name() string { return "claude" }
|
||||
func (ClaudeBackend) Command() string { return "claude" }
|
||||
func (ClaudeBackend) Env(baseURL, apiKey string) map[string]string {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if baseURL == "" && apiKey == "" {
|
||||
return nil
|
||||
}
|
||||
env := make(map[string]string, 2)
|
||||
if baseURL != "" {
|
||||
env["ANTHROPIC_BASE_URL"] = baseURL
|
||||
}
|
||||
if apiKey != "" {
|
||||
// Claude Code CLI uses ANTHROPIC_API_KEY for API-key based auth.
|
||||
env["ANTHROPIC_API_KEY"] = apiKey
|
||||
}
|
||||
return env
|
||||
}
|
||||
func (ClaudeBackend) BuildArgs(cfg *config.Config, targetArg string) []string {
|
||||
return buildClaudeArgs(cfg, targetArg)
|
||||
}
|
||||
|
||||
const MaxClaudeSettingsBytes = 1 << 20 // 1MB
|
||||
|
||||
type MinimalClaudeSettings struct {
|
||||
Env map[string]string
|
||||
Model string
|
||||
}
|
||||
|
||||
// LoadMinimalClaudeSettings 从 ~/.claude/settings.json 只提取安全的最小子集:
|
||||
// - env: 只接受字符串类型的值
|
||||
// - model: 只接受字符串类型的值
|
||||
// 文件缺失/解析失败/超限都返回空。
|
||||
func LoadMinimalClaudeSettings() MinimalClaudeSettings {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return MinimalClaudeSettings{}
|
||||
}
|
||||
|
||||
claudeDir := filepath.Clean(filepath.Join(home, ".claude"))
|
||||
settingPath := filepath.Clean(filepath.Join(claudeDir, "settings.json"))
|
||||
rel, err := filepath.Rel(claudeDir, settingPath)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return MinimalClaudeSettings{}
|
||||
}
|
||||
|
||||
info, err := os.Stat(settingPath)
|
||||
if err != nil || info.Size() > MaxClaudeSettingsBytes {
|
||||
return MinimalClaudeSettings{}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(settingPath) // #nosec G304 -- path is fixed under user home and validated to stay within claudeDir
|
||||
if err != nil {
|
||||
return MinimalClaudeSettings{}
|
||||
}
|
||||
|
||||
var cfg struct {
|
||||
Env map[string]any `json:"env"`
|
||||
Model any `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return MinimalClaudeSettings{}
|
||||
}
|
||||
|
||||
out := MinimalClaudeSettings{}
|
||||
|
||||
if model, ok := cfg.Model.(string); ok {
|
||||
out.Model = strings.TrimSpace(model)
|
||||
}
|
||||
|
||||
if len(cfg.Env) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
env := make(map[string]string, len(cfg.Env))
|
||||
for k, v := range cfg.Env {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
env[k] = s
|
||||
}
|
||||
if len(env) == 0 {
|
||||
return out
|
||||
}
|
||||
out.Env = env
|
||||
return out
|
||||
}
|
||||
|
||||
func LoadMinimalEnvSettings() map[string]string {
|
||||
settings := LoadMinimalClaudeSettings()
|
||||
if len(settings.Env) == 0 {
|
||||
return nil
|
||||
}
|
||||
return settings.Env
|
||||
}
|
||||
|
||||
func buildClaudeArgs(cfg *config.Config, targetArg string) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
args := []string{"-p"}
|
||||
// Default to skip permissions unless CODEAGENT_SKIP_PERMISSIONS=false
|
||||
if cfg.SkipPermissions || cfg.Yolo || config.EnvFlagDefaultTrue("CODEAGENT_SKIP_PERMISSIONS") {
|
||||
args = append(args, "--dangerously-skip-permissions")
|
||||
}
|
||||
|
||||
// Prevent infinite recursion: disable all setting sources (user, project, local)
|
||||
// This ensures a clean execution environment without CLAUDE.md or skills that would trigger codeagent
|
||||
args = append(args, "--setting-sources", "")
|
||||
|
||||
if model := strings.TrimSpace(cfg.Model); model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
|
||||
if cfg.Mode == "resume" {
|
||||
if cfg.SessionID != "" {
|
||||
// Claude CLI uses -r <session_id> for resume.
|
||||
args = append(args, "-r", cfg.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, "--output-format", "stream-json", "--verbose", targetArg)
|
||||
|
||||
return args
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
)
|
||||
|
||||
type CodexBackend struct{}
|
||||
|
||||
func (CodexBackend) Name() string { return "codex" }
|
||||
func (CodexBackend) Command() string { return "codex" }
|
||||
func (CodexBackend) Env(baseURL, apiKey string) map[string]string {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if baseURL == "" && apiKey == "" {
|
||||
return nil
|
||||
}
|
||||
env := make(map[string]string, 2)
|
||||
if baseURL != "" {
|
||||
env["OPENAI_BASE_URL"] = baseURL
|
||||
}
|
||||
if apiKey != "" {
|
||||
env["OPENAI_API_KEY"] = apiKey
|
||||
}
|
||||
return env
|
||||
}
|
||||
func (CodexBackend) BuildArgs(cfg *config.Config, targetArg string) []string {
|
||||
return BuildCodexArgs(cfg, targetArg)
|
||||
}
|
||||
|
||||
func BuildCodexArgs(cfg *config.Config, targetArg string) []string {
|
||||
if cfg == nil {
|
||||
panic("buildCodexArgs: nil config")
|
||||
}
|
||||
|
||||
var resumeSessionID string
|
||||
isResume := cfg.Mode == "resume"
|
||||
if isResume {
|
||||
resumeSessionID = strings.TrimSpace(cfg.SessionID)
|
||||
if resumeSessionID == "" {
|
||||
logErrorFn("invalid config: resume mode requires non-empty session_id")
|
||||
isResume = false
|
||||
}
|
||||
}
|
||||
|
||||
args := []string{"e"}
|
||||
|
||||
// Default to bypass sandbox unless CODEX_BYPASS_SANDBOX=false
|
||||
if cfg.Yolo || config.EnvFlagDefaultTrue("CODEX_BYPASS_SANDBOX") {
|
||||
logWarnFn("YOLO mode or CODEX_BYPASS_SANDBOX enabled: running without approval/sandbox protection")
|
||||
args = append(args, "--dangerously-bypass-approvals-and-sandbox")
|
||||
}
|
||||
|
||||
if model := strings.TrimSpace(cfg.Model); model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
|
||||
if reasoningEffort := strings.TrimSpace(cfg.ReasoningEffort); reasoningEffort != "" {
|
||||
args = append(args, "-c", "model_reasoning_effort="+reasoningEffort)
|
||||
}
|
||||
|
||||
args = append(args, "--skip-git-repo-check")
|
||||
|
||||
if isResume {
|
||||
return append(args,
|
||||
"--json",
|
||||
"resume",
|
||||
resumeSessionID,
|
||||
targetArg,
|
||||
)
|
||||
}
|
||||
|
||||
return append(args,
|
||||
"-C", cfg.WorkDir,
|
||||
"--json",
|
||||
targetArg,
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func TestBuildCodexArgs_Workdir_OSPaths(t *testing.T) {
|
||||
t.Setenv("CODEX_BYPASS_SANDBOX", "false")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
workdir string
|
||||
}{
|
||||
{name: "windows drive forward slashes", workdir: "D:/repo/path"},
|
||||
{name: "windows drive backslashes", workdir: `C:\repo\path`},
|
||||
{name: "windows UNC", workdir: `\\server\share\repo`},
|
||||
{name: "unix absolute", workdir: "/home/user/repo"},
|
||||
{name: "relative", workdir: "./relative/repo"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &config.Config{Mode: "new", WorkDir: tt.workdir}
|
||||
got := BuildCodexArgs(cfg, "task")
|
||||
want := []string{"e", "--skip-git-repo-check", "-C", tt.workdir, "--json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("BuildCodexArgs() = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("new mode stdin target uses dash", func(t *testing.T) {
|
||||
cfg := &config.Config{Mode: "new", WorkDir: `C:\repo\path`}
|
||||
got := BuildCodexArgs(cfg, "-")
|
||||
want := []string{"e", "--skip-git-repo-check", "-C", `C:\repo\path`, "--json", "-"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("BuildCodexArgs() = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildCodexArgs_ResumeMode_OmitsWorkdir(t *testing.T) {
|
||||
t.Setenv("CODEX_BYPASS_SANDBOX", "false")
|
||||
|
||||
cfg := &config.Config{Mode: "resume", SessionID: "sid-123", WorkDir: `C:\repo\path`}
|
||||
got := BuildCodexArgs(cfg, "-")
|
||||
want := []string{"e", "--skip-git-repo-check", "--json", "resume", "sid-123", "-"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("BuildCodexArgs() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
)
|
||||
|
||||
type GeminiBackend struct{}
|
||||
|
||||
func (GeminiBackend) Name() string { return "gemini" }
|
||||
func (GeminiBackend) Command() string { return "gemini" }
|
||||
func (GeminiBackend) Env(baseURL, apiKey string) map[string]string {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
if baseURL == "" && apiKey == "" {
|
||||
return nil
|
||||
}
|
||||
env := make(map[string]string, 2)
|
||||
if baseURL != "" {
|
||||
env["GOOGLE_GEMINI_BASE_URL"] = baseURL
|
||||
}
|
||||
if apiKey != "" {
|
||||
env["GEMINI_API_KEY"] = apiKey
|
||||
}
|
||||
return env
|
||||
}
|
||||
func (GeminiBackend) BuildArgs(cfg *config.Config, targetArg string) []string {
|
||||
return buildGeminiArgs(cfg, targetArg)
|
||||
}
|
||||
|
||||
// LoadGeminiEnv loads environment variables from ~/.gemini/.env
|
||||
// Supports GEMINI_API_KEY, GEMINI_MODEL, GOOGLE_GEMINI_BASE_URL
|
||||
// Also sets GEMINI_API_KEY_AUTH_MECHANISM=bearer for third-party API compatibility
|
||||
func LoadGeminiEnv() map[string]string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
envDir := filepath.Clean(filepath.Join(home, ".gemini"))
|
||||
envPath := filepath.Clean(filepath.Join(envDir, ".env"))
|
||||
rel, err := filepath.Rel(envDir, envPath)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(envPath) // #nosec G304 -- path is fixed under user home and validated to stay within envDir
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
env := make(map[string]string)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
idx := strings.IndexByte(line, '=')
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
if key != "" && value != "" {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Set bearer auth mechanism for third-party API compatibility
|
||||
if _, ok := env["GEMINI_API_KEY"]; ok {
|
||||
if _, hasAuth := env["GEMINI_API_KEY_AUTH_MECHANISM"]; !hasAuth {
|
||||
env["GEMINI_API_KEY_AUTH_MECHANISM"] = "bearer"
|
||||
}
|
||||
}
|
||||
|
||||
if len(env) == 0 {
|
||||
return nil
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func buildGeminiArgs(cfg *config.Config, targetArg string) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
args := []string{"-o", "stream-json", "-y"}
|
||||
|
||||
if model := strings.TrimSpace(cfg.Model); model != "" {
|
||||
args = append(args, "-m", model)
|
||||
}
|
||||
|
||||
if cfg.Mode == "resume" {
|
||||
if cfg.SessionID != "" {
|
||||
args = append(args, "-r", cfg.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// Use positional argument instead of deprecated -p flag.
|
||||
// For stdin mode ("-"), use -p to read from stdin.
|
||||
if targetArg == "-" {
|
||||
args = append(args, "-p", targetArg)
|
||||
} else {
|
||||
args = append(args, targetArg)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
)
|
||||
|
||||
type OpencodeBackend struct{}
|
||||
|
||||
func (OpencodeBackend) Name() string { return "opencode" }
|
||||
func (OpencodeBackend) Command() string { return "opencode" }
|
||||
func (OpencodeBackend) Env(baseURL, apiKey string) map[string]string { return nil }
|
||||
func (OpencodeBackend) BuildArgs(cfg *config.Config, targetArg string) []string {
|
||||
args := []string{"run"}
|
||||
if cfg != nil {
|
||||
if model := strings.TrimSpace(cfg.Model); model != "" {
|
||||
args = append(args, "-m", model)
|
||||
}
|
||||
if cfg.Mode == "resume" && cfg.SessionID != "" {
|
||||
args = append(args, "-s", cfg.SessionID)
|
||||
}
|
||||
}
|
||||
args = append(args, "--format", "json")
|
||||
if targetArg != "-" {
|
||||
args = append(args, targetArg)
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var registry = map[string]Backend{
|
||||
"codex": CodexBackend{},
|
||||
"claude": ClaudeBackend{},
|
||||
"gemini": GeminiBackend{},
|
||||
"opencode": OpencodeBackend{},
|
||||
}
|
||||
|
||||
// Registry exposes the available backends. Intended for internal inspection/tests.
|
||||
func Registry() map[string]Backend {
|
||||
return registry
|
||||
}
|
||||
|
||||
func Select(name string) (Backend, error) {
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if key == "" {
|
||||
key = "codex"
|
||||
}
|
||||
if backend, ok := registry[key]; ok {
|
||||
return backend, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported backend %q", name)
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
type BackendConfig struct {
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
}
|
||||
|
||||
type AgentModelConfig struct {
|
||||
Backend string `json:"backend"`
|
||||
Model string `json:"model"`
|
||||
PromptFile string `json:"prompt_file,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Yolo bool `json:"yolo,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
}
|
||||
|
||||
type ModelsConfig struct {
|
||||
DefaultBackend string `json:"default_backend"`
|
||||
DefaultModel string `json:"default_model"`
|
||||
Agents map[string]AgentModelConfig `json:"agents"`
|
||||
Backends map[string]BackendConfig `json:"backends,omitempty"`
|
||||
}
|
||||
|
||||
var defaultModelsConfig = ModelsConfig{}
|
||||
|
||||
const modelsConfigTildePath = "~/.codeagent/models.json"
|
||||
|
||||
const modelsConfigExample = `{
|
||||
"default_backend": "codex",
|
||||
"default_model": "gpt-4.1",
|
||||
"backends": {
|
||||
"codex": { "api_key": "..." },
|
||||
"claude": { "api_key": "..." }
|
||||
},
|
||||
"agents": {
|
||||
"develop": {
|
||||
"backend": "codex",
|
||||
"model": "gpt-4.1",
|
||||
"prompt_file": "~/.codeagent/prompts/develop.md",
|
||||
"reasoning": "high",
|
||||
"yolo": true
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
var (
|
||||
modelsConfigOnce sync.Once
|
||||
modelsConfigCached *ModelsConfig
|
||||
modelsConfigErr error
|
||||
)
|
||||
|
||||
func modelsConfig() (*ModelsConfig, error) {
|
||||
modelsConfigOnce.Do(func() {
|
||||
modelsConfigCached, modelsConfigErr = loadModelsConfig()
|
||||
})
|
||||
return modelsConfigCached, modelsConfigErr
|
||||
}
|
||||
|
||||
func modelsConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return "", fmt.Errorf("failed to resolve user home directory: %w", err)
|
||||
}
|
||||
|
||||
configDir := filepath.Clean(filepath.Join(home, ".codeagent"))
|
||||
configPath := filepath.Clean(filepath.Join(configDir, "models.json"))
|
||||
rel, err := filepath.Rel(configDir, configPath)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("refusing to read models config outside %s: %s", configDir, configPath)
|
||||
}
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
func modelsConfigHint(configPath string) string {
|
||||
configPath = strings.TrimSpace(configPath)
|
||||
if configPath == "" {
|
||||
return fmt.Sprintf("Create %s with e.g.:\n%s", modelsConfigTildePath, modelsConfigExample)
|
||||
}
|
||||
return fmt.Sprintf("Create %s (resolved to %s) with e.g.:\n%s", modelsConfigTildePath, configPath, modelsConfigExample)
|
||||
}
|
||||
|
||||
func loadModelsConfig() (*ModelsConfig, error) {
|
||||
configPath, err := modelsConfigPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w\n\n%s", err, modelsConfigHint(""))
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath) // #nosec G304 -- path is fixed under user home and validated to stay within configDir
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("models config not found: %s\n\n%s", configPath, modelsConfigHint(configPath))
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath))
|
||||
}
|
||||
|
||||
var cfg ModelsConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath))
|
||||
}
|
||||
|
||||
cfg.DefaultBackend = strings.TrimSpace(cfg.DefaultBackend)
|
||||
cfg.DefaultModel = strings.TrimSpace(cfg.DefaultModel)
|
||||
|
||||
// Normalize backend keys so lookups can be case-insensitive.
|
||||
if len(cfg.Backends) > 0 {
|
||||
normalized := make(map[string]BackendConfig, len(cfg.Backends))
|
||||
for k, v := range cfg.Backends {
|
||||
key := strings.ToLower(strings.TrimSpace(k))
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
normalized[key] = v
|
||||
}
|
||||
if len(normalized) > 0 {
|
||||
cfg.Backends = normalized
|
||||
} else {
|
||||
cfg.Backends = nil
|
||||
}
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func LoadDynamicAgent(name string) (AgentModelConfig, bool) {
|
||||
if err := ValidateAgentName(name); err != nil {
|
||||
return AgentModelConfig{}, false
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return AgentModelConfig{}, false
|
||||
}
|
||||
|
||||
absPath := filepath.Join(home, ".codeagent", "agents", name+".md")
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil || info.IsDir() {
|
||||
return AgentModelConfig{}, false
|
||||
}
|
||||
|
||||
return AgentModelConfig{PromptFile: "~/.codeagent/agents/" + name + ".md"}, true
|
||||
}
|
||||
|
||||
func ResolveBackendConfig(backendName string) (baseURL, apiKey string) {
|
||||
cfg, err := modelsConfig()
|
||||
if err != nil || cfg == nil {
|
||||
return "", ""
|
||||
}
|
||||
resolved := resolveBackendConfig(cfg, backendName)
|
||||
return strings.TrimSpace(resolved.BaseURL), strings.TrimSpace(resolved.APIKey)
|
||||
}
|
||||
|
||||
func resolveBackendConfig(cfg *ModelsConfig, backendName string) BackendConfig {
|
||||
if cfg == nil || len(cfg.Backends) == 0 {
|
||||
return BackendConfig{}
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(backendName))
|
||||
if key == "" {
|
||||
key = strings.ToLower(strings.TrimSpace(cfg.DefaultBackend))
|
||||
}
|
||||
if key == "" {
|
||||
return BackendConfig{}
|
||||
}
|
||||
if backend, ok := cfg.Backends[key]; ok {
|
||||
return backend
|
||||
}
|
||||
return BackendConfig{}
|
||||
}
|
||||
|
||||
func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, err error) {
|
||||
if err := ValidateAgentName(agentName); err != nil {
|
||||
return "", "", "", "", "", "", false, err
|
||||
}
|
||||
|
||||
cfg, err := modelsConfig()
|
||||
if err != nil {
|
||||
return "", "", "", "", "", "", false, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return "", "", "", "", "", "", false, fmt.Errorf("models config is nil\n\n%s", modelsConfigHint(""))
|
||||
}
|
||||
|
||||
if agent, ok := cfg.Agents[agentName]; ok {
|
||||
backend = strings.TrimSpace(agent.Backend)
|
||||
if backend == "" {
|
||||
backend = strings.TrimSpace(cfg.DefaultBackend)
|
||||
if backend == "" {
|
||||
configPath, pathErr := modelsConfigPath()
|
||||
if pathErr != nil {
|
||||
return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(""))
|
||||
}
|
||||
return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(configPath))
|
||||
}
|
||||
}
|
||||
backendCfg := resolveBackendConfig(cfg, backend)
|
||||
|
||||
baseURL = strings.TrimSpace(agent.BaseURL)
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimSpace(backendCfg.BaseURL)
|
||||
}
|
||||
apiKey = strings.TrimSpace(agent.APIKey)
|
||||
if apiKey == "" {
|
||||
apiKey = strings.TrimSpace(backendCfg.APIKey)
|
||||
}
|
||||
|
||||
model = strings.TrimSpace(agent.Model)
|
||||
if model == "" {
|
||||
configPath, pathErr := modelsConfigPath()
|
||||
if pathErr != nil {
|
||||
return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint(""))
|
||||
}
|
||||
return "", "", "", "", "", "", false, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint(configPath))
|
||||
}
|
||||
return backend, model, agent.PromptFile, agent.Reasoning, baseURL, apiKey, agent.Yolo, nil
|
||||
}
|
||||
|
||||
if dynamic, ok := LoadDynamicAgent(agentName); ok {
|
||||
backend = strings.TrimSpace(cfg.DefaultBackend)
|
||||
model = strings.TrimSpace(cfg.DefaultModel)
|
||||
configPath, pathErr := modelsConfigPath()
|
||||
if backend == "" || model == "" {
|
||||
if pathErr != nil {
|
||||
return "", "", "", "", "", "", false, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(""))
|
||||
}
|
||||
return "", "", "", "", "", "", false, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(configPath))
|
||||
}
|
||||
backendCfg := resolveBackendConfig(cfg, backend)
|
||||
baseURL = strings.TrimSpace(backendCfg.BaseURL)
|
||||
apiKey = strings.TrimSpace(backendCfg.APIKey)
|
||||
return backend, model, dynamic.PromptFile, "", baseURL, apiKey, false, nil
|
||||
}
|
||||
|
||||
configPath, pathErr := modelsConfigPath()
|
||||
if pathErr != nil {
|
||||
return "", "", "", "", "", "", false, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(""))
|
||||
}
|
||||
return "", "", "", "", "", "", false, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(configPath))
|
||||
}
|
||||
|
||||
func ResolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, err error) {
|
||||
return resolveAgentConfig(agentName)
|
||||
}
|
||||
|
||||
func ResetModelsConfigCacheForTest() {
|
||||
modelsConfigCached = nil
|
||||
modelsConfigErr = nil
|
||||
modelsConfigOnce = sync.Once{}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAgentConfig_NoConfig_ReturnsHelpfulError(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
_, _, _, _, _, _, _, err := ResolveAgentConfig("develop")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, modelsConfigTildePath) {
|
||||
t.Fatalf("error should mention %s, got: %s", modelsConfigTildePath, msg)
|
||||
}
|
||||
if !strings.Contains(msg, filepath.Join(home, ".codeagent", "models.json")) {
|
||||
t.Fatalf("error should mention resolved config path, got: %s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "\"agents\"") {
|
||||
t.Fatalf("error should include example config, got: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsConfig_NoFile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
_, err := loadModelsConfig()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsConfig_WithFile(t *testing.T) {
|
||||
// Create temp dir and config file
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configContent := `{
|
||||
"default_backend": "claude",
|
||||
"default_model": "claude-opus-4",
|
||||
"backends": {
|
||||
"Claude": {
|
||||
"base_url": "https://backend.example",
|
||||
"api_key": "backend-key"
|
||||
},
|
||||
"codex": {
|
||||
"base_url": "https://openai.example",
|
||||
"api_key": "openai-key"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"custom-agent": {
|
||||
"backend": "codex",
|
||||
"model": "gpt-4o",
|
||||
"description": "Custom agent",
|
||||
"base_url": "https://agent.example",
|
||||
"api_key": "agent-key"
|
||||
}
|
||||
}
|
||||
}`
|
||||
configPath := filepath.Join(configDir, "models.json")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("HOME", tmpDir)
|
||||
t.Setenv("USERPROFILE", tmpDir)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
cfg, err := loadModelsConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadModelsConfig: %v", err)
|
||||
}
|
||||
|
||||
if cfg.DefaultBackend != "claude" {
|
||||
t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "claude")
|
||||
}
|
||||
if cfg.DefaultModel != "claude-opus-4" {
|
||||
t.Errorf("DefaultModel = %q, want %q", cfg.DefaultModel, "claude-opus-4")
|
||||
}
|
||||
|
||||
// Check custom agent
|
||||
if agent, ok := cfg.Agents["custom-agent"]; !ok {
|
||||
t.Error("custom-agent not found")
|
||||
} else {
|
||||
if agent.Backend != "codex" {
|
||||
t.Errorf("custom-agent.Backend = %q, want %q", agent.Backend, "codex")
|
||||
}
|
||||
if agent.Model != "gpt-4o" {
|
||||
t.Errorf("custom-agent.Model = %q, want %q", agent.Model, "gpt-4o")
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := cfg.Agents["oracle"]; ok {
|
||||
t.Error("oracle should not be present without explicit config")
|
||||
}
|
||||
|
||||
baseURL, apiKey := ResolveBackendConfig("claude")
|
||||
if baseURL != "https://backend.example" {
|
||||
t.Errorf("ResolveBackendConfig(baseURL) = %q, want %q", baseURL, "https://backend.example")
|
||||
}
|
||||
if apiKey != "backend-key" {
|
||||
t.Errorf("ResolveBackendConfig(apiKey) = %q, want %q", apiKey, "backend-key")
|
||||
}
|
||||
|
||||
backend, model, _, _, agentBaseURL, agentAPIKey, _, err := ResolveAgentConfig("custom-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAgentConfig(custom-agent): %v", err)
|
||||
}
|
||||
if backend != "codex" {
|
||||
t.Errorf("ResolveAgentConfig(backend) = %q, want %q", backend, "codex")
|
||||
}
|
||||
if model != "gpt-4o" {
|
||||
t.Errorf("ResolveAgentConfig(model) = %q, want %q", model, "gpt-4o")
|
||||
}
|
||||
if agentBaseURL != "https://agent.example" {
|
||||
t.Errorf("ResolveAgentConfig(baseURL) = %q, want %q", agentBaseURL, "https://agent.example")
|
||||
}
|
||||
if agentAPIKey != "agent-key" {
|
||||
t.Errorf("ResolveAgentConfig(apiKey) = %q, want %q", agentAPIKey, "agent-key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentConfig_DynamicAgent(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
agentDir := filepath.Join(home, ".codeagent", "agents")
|
||||
if err := os.MkdirAll(agentDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentDir, "sarsh.md"), []byte("prompt\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
configDir := filepath.Join(home, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
||||
"default_backend": "codex",
|
||||
"default_model": "gpt-test"
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
backend, model, promptFile, _, _, _, _, err := ResolveAgentConfig("sarsh")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAgentConfig(sarsh): %v", err)
|
||||
}
|
||||
if backend != "codex" {
|
||||
t.Errorf("backend = %q, want %q", backend, "codex")
|
||||
}
|
||||
if model != "gpt-test" {
|
||||
t.Errorf("model = %q, want %q", model, "gpt-test")
|
||||
}
|
||||
if promptFile != "~/.codeagent/agents/sarsh.md" {
|
||||
t.Errorf("promptFile = %q, want %q", promptFile, "~/.codeagent/agents/sarsh.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsConfig_InvalidJSON(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write invalid JSON
|
||||
configPath := filepath.Join(configDir, "models.json")
|
||||
if err := os.WriteFile(configPath, []byte("invalid json {"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("HOME", tmpDir)
|
||||
t.Setenv("USERPROFILE", tmpDir)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
_, err := loadModelsConfig()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentConfig_UnknownAgent_ReturnsError(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
configDir := filepath.Join(home, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
||||
"default_backend": "codex",
|
||||
"default_model": "gpt-test",
|
||||
"agents": {
|
||||
"develop": { "backend": "codex", "model": "gpt-test" }
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
_, _, _, _, _, _, _, err := ResolveAgentConfig("unknown-agent")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown-agent") {
|
||||
t.Fatalf("error should mention agent name, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentConfig_EmptyModel_ReturnsError(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
configDir := filepath.Join(home, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
||||
"agents": {
|
||||
"bad-agent": { "backend": "codex", "model": " " }
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
_, _, _, _, _, _, _, err := ResolveAgentConfig("bad-agent")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "empty model") {
|
||||
t.Fatalf("error should mention empty model, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds CLI configuration.
|
||||
type Config struct {
|
||||
Mode string // "new" or "resume"
|
||||
Task string
|
||||
SessionID string
|
||||
WorkDir string
|
||||
Model string
|
||||
ReasoningEffort string
|
||||
ExplicitStdin bool
|
||||
Timeout int
|
||||
Backend string
|
||||
Agent string
|
||||
PromptFile string
|
||||
PromptFileExplicit bool
|
||||
SkipPermissions bool
|
||||
Yolo bool
|
||||
MaxParallelWorkers int
|
||||
}
|
||||
|
||||
// EnvFlagEnabled returns true when the environment variable exists and is not
|
||||
// explicitly set to a falsey value ("0/false/no/off").
|
||||
func EnvFlagEnabled(key string) bool {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
val = strings.TrimSpace(strings.ToLower(val))
|
||||
switch val {
|
||||
case "", "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func ParseBoolFlag(val string, defaultValue bool) bool {
|
||||
val = strings.TrimSpace(strings.ToLower(val))
|
||||
switch val {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// EnvFlagDefaultTrue returns true unless the env var is explicitly set to
|
||||
// false/0/no/off.
|
||||
func EnvFlagDefaultTrue(key string) bool {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return ParseBoolFlag(val, true)
|
||||
}
|
||||
|
||||
func ValidateAgentName(name string) error {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("agent name is empty")
|
||||
}
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '-', r == '_':
|
||||
default:
|
||||
return fmt.Errorf("agent name %q contains invalid character %q", name, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxParallelWorkersLimit = 100
|
||||
|
||||
// ResolveMaxParallelWorkers reads CODEAGENT_MAX_PARALLEL_WORKERS. It returns 0
|
||||
// for "unlimited".
|
||||
func ResolveMaxParallelWorkers() int {
|
||||
raw := strings.TrimSpace(os.Getenv("CODEAGENT_MAX_PARALLEL_WORKERS"))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > maxParallelWorkersLimit {
|
||||
return maxParallelWorkersLimit
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// NewViper returns a viper instance configured for CODEAGENT_* environment
|
||||
// variables and an optional config file.
|
||||
//
|
||||
// Search order when configFile is empty:
|
||||
// - $HOME/.codeagent/config.(yaml|yml|json|toml|...)
|
||||
func NewViper(configFile string) (*viper.Viper, error) {
|
||||
v := viper.New()
|
||||
v.SetEnvPrefix("CODEAGENT")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
if strings.TrimSpace(configFile) != "" {
|
||||
v.SetConfigFile(configFile)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
v.SetConfigName("config")
|
||||
v.AddConfigPath(filepath.Join(home, ".codeagent"))
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
if errors.As(err, ¬Found) {
|
||||
return v, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
backend "codeagent-wrapper/internal/backend"
|
||||
config "codeagent-wrapper/internal/config"
|
||||
)
|
||||
|
||||
// TestEnvInjectionWithAgent tests the full flow of env injection with agent config
|
||||
func TestEnvInjectionWithAgent(t *testing.T) {
|
||||
// Setup temp config
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write test config with agent that has base_url and api_key
|
||||
configContent := `{
|
||||
"default_backend": "codex",
|
||||
"agents": {
|
||||
"test-agent": {
|
||||
"backend": "claude",
|
||||
"model": "test-model",
|
||||
"base_url": "https://test.api.com",
|
||||
"api_key": "test-api-key-12345678"
|
||||
}
|
||||
}
|
||||
}`
|
||||
configPath := filepath.Join(configDir, "models.json")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("HOME", tmpDir)
|
||||
t.Setenv("USERPROFILE", tmpDir)
|
||||
|
||||
// Reset config cache
|
||||
config.ResetModelsConfigCacheForTest()
|
||||
defer config.ResetModelsConfigCacheForTest()
|
||||
|
||||
// Test ResolveAgentConfig
|
||||
agentBackend, model, _, _, baseURL, apiKey, _, err := config.ResolveAgentConfig("test-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAgentConfig: %v", err)
|
||||
}
|
||||
t.Logf("ResolveAgentConfig: backend=%q, model=%q, baseURL=%q, apiKey=%q",
|
||||
agentBackend, model, baseURL, apiKey)
|
||||
|
||||
if agentBackend != "claude" {
|
||||
t.Errorf("expected backend 'claude', got %q", agentBackend)
|
||||
}
|
||||
if baseURL != "https://test.api.com" {
|
||||
t.Errorf("expected baseURL 'https://test.api.com', got %q", baseURL)
|
||||
}
|
||||
if apiKey != "test-api-key-12345678" {
|
||||
t.Errorf("expected apiKey 'test-api-key-12345678', got %q", apiKey)
|
||||
}
|
||||
|
||||
// Test Backend.Env
|
||||
b := backend.ClaudeBackend{}
|
||||
env := b.Env(baseURL, apiKey)
|
||||
t.Logf("Backend.Env: %v", env)
|
||||
|
||||
if env == nil {
|
||||
t.Fatal("expected non-nil env from Backend.Env")
|
||||
}
|
||||
if env["ANTHROPIC_BASE_URL"] != baseURL {
|
||||
t.Errorf("expected ANTHROPIC_BASE_URL=%q, got %q", baseURL, env["ANTHROPIC_BASE_URL"])
|
||||
}
|
||||
if env["ANTHROPIC_API_KEY"] != apiKey {
|
||||
t.Errorf("expected ANTHROPIC_API_KEY=%q, got %q", apiKey, env["ANTHROPIC_API_KEY"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvInjectionLogic tests the exact logic used in executor
|
||||
func TestEnvInjectionLogic(t *testing.T) {
|
||||
// Setup temp config
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configContent := `{
|
||||
"default_backend": "codex",
|
||||
"agents": {
|
||||
"explore": {
|
||||
"backend": "claude",
|
||||
"model": "MiniMax-M2.1",
|
||||
"base_url": "https://api.minimaxi.com/anthropic",
|
||||
"api_key": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test"
|
||||
}
|
||||
}
|
||||
}`
|
||||
configPath := filepath.Join(configDir, "models.json")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("HOME", tmpDir)
|
||||
t.Setenv("USERPROFILE", tmpDir)
|
||||
|
||||
config.ResetModelsConfigCacheForTest()
|
||||
defer config.ResetModelsConfigCacheForTest()
|
||||
|
||||
// Simulate the executor logic
|
||||
cfgBackend := "claude" // This should come from taskSpec.Backend
|
||||
agentName := "explore"
|
||||
|
||||
// Step 1: Get backend config (usually empty for claude without global config)
|
||||
baseURL, apiKey := config.ResolveBackendConfig(cfgBackend)
|
||||
t.Logf("Step 1 - ResolveBackendConfig(%q): baseURL=%q, apiKey=%q", cfgBackend, baseURL, apiKey)
|
||||
|
||||
// Step 2: If agent specified, get agent config
|
||||
if agentName != "" {
|
||||
agentBackend, _, _, _, agentBaseURL, agentAPIKey, _, err := config.ResolveAgentConfig(agentName)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAgentConfig(%q): %v", agentName, err)
|
||||
}
|
||||
t.Logf("Step 2 - ResolveAgentConfig(%q): backend=%q, baseURL=%q, apiKey=%q",
|
||||
agentName, agentBackend, agentBaseURL, agentAPIKey)
|
||||
|
||||
// Step 3: Check if agent backend matches cfg backend
|
||||
if strings.EqualFold(strings.TrimSpace(agentBackend), strings.TrimSpace(cfgBackend)) {
|
||||
baseURL, apiKey = agentBaseURL, agentAPIKey
|
||||
t.Logf("Step 3 - Backend match! Using agent config: baseURL=%q, apiKey=%q", baseURL, apiKey)
|
||||
} else {
|
||||
t.Logf("Step 3 - Backend mismatch: agent=%q, cfg=%q", agentBackend, cfgBackend)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Get env vars from backend
|
||||
b := backend.ClaudeBackend{}
|
||||
injected := b.Env(baseURL, apiKey)
|
||||
t.Logf("Step 4 - Backend.Env: %v", injected)
|
||||
|
||||
// Verify
|
||||
if len(injected) == 0 {
|
||||
t.Fatal("Expected env vars to be injected, got none")
|
||||
}
|
||||
|
||||
expectedURL := "https://api.minimaxi.com/anthropic"
|
||||
if injected["ANTHROPIC_BASE_URL"] != expectedURL {
|
||||
t.Errorf("ANTHROPIC_BASE_URL: expected %q, got %q", expectedURL, injected["ANTHROPIC_BASE_URL"])
|
||||
}
|
||||
|
||||
if _, ok := injected["ANTHROPIC_API_KEY"]; !ok {
|
||||
t.Error("ANTHROPIC_API_KEY not set")
|
||||
}
|
||||
|
||||
// Step 5: Test masking
|
||||
for k, v := range injected {
|
||||
masked := maskSensitiveValue(k, v)
|
||||
t.Logf("Step 5 - Env log: %s=%s", k, masked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskSpecBackendPropagation tests that taskSpec.Backend is properly used
|
||||
func TestTaskSpecBackendPropagation(t *testing.T) {
|
||||
// Simulate what happens in RunCodexTaskWithContext
|
||||
taskSpec := TaskSpec{
|
||||
ID: "test",
|
||||
Task: "hello",
|
||||
Backend: "claude",
|
||||
Agent: "explore",
|
||||
}
|
||||
|
||||
// This is the logic from executor.go lines 889-916
|
||||
cfg := &config.Config{
|
||||
Mode: "new",
|
||||
Task: taskSpec.Task,
|
||||
Backend: "codex", // default
|
||||
}
|
||||
|
||||
var backend Backend = nil // nil in single mode
|
||||
commandName := "codex" // default
|
||||
|
||||
if backend != nil {
|
||||
cfg.Backend = backend.Name()
|
||||
} else if taskSpec.Backend != "" {
|
||||
cfg.Backend = taskSpec.Backend
|
||||
} else if commandName != "" {
|
||||
cfg.Backend = commandName
|
||||
}
|
||||
|
||||
t.Logf("taskSpec.Backend=%q, cfg.Backend=%q", taskSpec.Backend, cfg.Backend)
|
||||
|
||||
if cfg.Backend != "claude" {
|
||||
t.Errorf("expected cfg.Backend='claude', got %q", cfg.Backend)
|
||||
}
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
backend "codeagent-wrapper/internal/backend"
|
||||
)
|
||||
|
||||
func TestMaskSensitiveValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "API_KEY with long value",
|
||||
key: "ANTHROPIC_API_KEY",
|
||||
value: "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expected: "sk-a****xxxx",
|
||||
},
|
||||
{
|
||||
name: "api_key lowercase",
|
||||
key: "api_key",
|
||||
value: "abcdefghijklmnop",
|
||||
expected: "abcd****mnop",
|
||||
},
|
||||
{
|
||||
name: "AUTH_TOKEN",
|
||||
key: "AUTH_TOKEN",
|
||||
value: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
expected: "eyJh****VCJ9",
|
||||
},
|
||||
{
|
||||
name: "SECRET",
|
||||
key: "MY_SECRET",
|
||||
value: "super-secret-value-12345",
|
||||
expected: "supe****2345",
|
||||
},
|
||||
{
|
||||
name: "short key value (8 chars)",
|
||||
key: "API_KEY",
|
||||
value: "12345678",
|
||||
expected: "****",
|
||||
},
|
||||
{
|
||||
name: "very short key value",
|
||||
key: "API_KEY",
|
||||
value: "abc",
|
||||
expected: "****",
|
||||
},
|
||||
{
|
||||
name: "empty key value",
|
||||
key: "API_KEY",
|
||||
value: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "non-sensitive BASE_URL",
|
||||
key: "ANTHROPIC_BASE_URL",
|
||||
value: "https://api.anthropic.com",
|
||||
expected: "https://api.anthropic.com",
|
||||
},
|
||||
{
|
||||
name: "non-sensitive MODEL",
|
||||
key: "MODEL",
|
||||
value: "claude-3-opus",
|
||||
expected: "claude-3-opus",
|
||||
},
|
||||
{
|
||||
name: "case insensitive - Key",
|
||||
key: "My_Key",
|
||||
value: "1234567890abcdef",
|
||||
expected: "1234****cdef",
|
||||
},
|
||||
{
|
||||
name: "case insensitive - TOKEN",
|
||||
key: "ACCESS_TOKEN",
|
||||
value: "access123456789",
|
||||
expected: "acce****6789",
|
||||
},
|
||||
{
|
||||
name: "partial match - apikey",
|
||||
key: "MYAPIKEY",
|
||||
value: "1234567890",
|
||||
expected: "1234****7890",
|
||||
},
|
||||
{
|
||||
name: "partial match - secretvalue",
|
||||
key: "SECRETVALUE",
|
||||
value: "abcdefghij",
|
||||
expected: "abcd****ghij",
|
||||
},
|
||||
{
|
||||
name: "9 char value (just above threshold)",
|
||||
key: "API_KEY",
|
||||
value: "123456789",
|
||||
expected: "1234****6789",
|
||||
},
|
||||
{
|
||||
name: "exactly 8 char value (at threshold)",
|
||||
key: "API_KEY",
|
||||
value: "12345678",
|
||||
expected: "****",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := maskSensitiveValue(tt.key, tt.value)
|
||||
if result != tt.expected {
|
||||
t.Errorf("maskSensitiveValue(%q, %q) = %q, want %q", tt.key, tt.value, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskSensitiveValue_NoLeakage(t *testing.T) {
|
||||
// Ensure sensitive values are never fully exposed
|
||||
sensitiveKeys := []string{"API_KEY", "api_key", "AUTH_TOKEN", "SECRET", "access_token", "MYAPIKEY"}
|
||||
longValue := "this-is-a-very-long-secret-value-that-should-be-masked"
|
||||
|
||||
for _, key := range sensitiveKeys {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
masked := maskSensitiveValue(key, longValue)
|
||||
// Should not contain the full value
|
||||
if masked == longValue {
|
||||
t.Errorf("key %q: value was not masked", key)
|
||||
}
|
||||
// Should contain mask marker
|
||||
if !strings.Contains(masked, "****") {
|
||||
t.Errorf("key %q: masked value %q does not contain ****", key, masked)
|
||||
}
|
||||
// First 4 chars should be visible
|
||||
if !strings.HasPrefix(masked, longValue[:4]) {
|
||||
t.Errorf("key %q: masked value should start with first 4 chars", key)
|
||||
}
|
||||
// Last 4 chars should be visible
|
||||
if !strings.HasSuffix(masked, longValue[len(longValue)-4:]) {
|
||||
t.Errorf("key %q: masked value should end with last 4 chars", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskSensitiveValue_NonSensitivePassthrough(t *testing.T) {
|
||||
// Non-sensitive keys should pass through unchanged
|
||||
nonSensitiveKeys := []string{
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"BASE_URL",
|
||||
"MODEL",
|
||||
"BACKEND",
|
||||
"WORKDIR",
|
||||
"HOME",
|
||||
"PATH",
|
||||
}
|
||||
value := "any-value-here-12345"
|
||||
|
||||
for _, key := range nonSensitiveKeys {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
result := maskSensitiveValue(key, value)
|
||||
if result != value {
|
||||
t.Errorf("key %q: expected passthrough but got %q", key, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaudeBackendEnv tests that ClaudeBackend.Env returns correct env vars
|
||||
func TestClaudeBackendEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
apiKey string
|
||||
expectKeys []string
|
||||
expectNil bool
|
||||
}{
|
||||
{
|
||||
name: "both base_url and api_key",
|
||||
baseURL: "https://api.custom.com",
|
||||
apiKey: "sk-test-key-12345",
|
||||
expectKeys: []string{"ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"},
|
||||
},
|
||||
{
|
||||
name: "only base_url",
|
||||
baseURL: "https://api.custom.com",
|
||||
apiKey: "",
|
||||
expectKeys: []string{"ANTHROPIC_BASE_URL"},
|
||||
},
|
||||
{
|
||||
name: "only api_key",
|
||||
baseURL: "",
|
||||
apiKey: "sk-test-key-12345",
|
||||
expectKeys: []string{"ANTHROPIC_API_KEY"},
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
expectNil: true,
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
baseURL: " ",
|
||||
apiKey: " ",
|
||||
expectNil: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := backend.ClaudeBackend{}
|
||||
env := b.Env(tt.baseURL, tt.apiKey)
|
||||
|
||||
if tt.expectNil {
|
||||
if env != nil {
|
||||
t.Errorf("expected nil env, got %v", env)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if env == nil {
|
||||
t.Fatal("expected non-nil env")
|
||||
}
|
||||
|
||||
for _, key := range tt.expectKeys {
|
||||
if _, ok := env[key]; !ok {
|
||||
t.Errorf("expected key %q in env", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify values are correct
|
||||
if tt.baseURL != "" && strings.TrimSpace(tt.baseURL) != "" {
|
||||
if env["ANTHROPIC_BASE_URL"] != strings.TrimSpace(tt.baseURL) {
|
||||
t.Errorf("ANTHROPIC_BASE_URL = %q, want %q", env["ANTHROPIC_BASE_URL"], strings.TrimSpace(tt.baseURL))
|
||||
}
|
||||
}
|
||||
if tt.apiKey != "" && strings.TrimSpace(tt.apiKey) != "" {
|
||||
if env["ANTHROPIC_API_KEY"] != strings.TrimSpace(tt.apiKey) {
|
||||
t.Errorf("ANTHROPIC_API_KEY = %q, want %q", env["ANTHROPIC_API_KEY"], strings.TrimSpace(tt.apiKey))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvLoggingIntegration tests that env vars are properly masked in logs
|
||||
func TestEnvLoggingIntegration(t *testing.T) {
|
||||
b := backend.ClaudeBackend{}
|
||||
baseURL := "https://api.minimaxi.com/anthropic"
|
||||
apiKey := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.longjwttoken"
|
||||
|
||||
env := b.Env(baseURL, apiKey)
|
||||
if env == nil {
|
||||
t.Fatal("expected non-nil env")
|
||||
}
|
||||
|
||||
// Verify that when we log these values, sensitive ones are masked
|
||||
for k, v := range env {
|
||||
masked := maskSensitiveValue(k, v)
|
||||
|
||||
if k == "ANTHROPIC_BASE_URL" {
|
||||
// URL should not be masked
|
||||
if masked != v {
|
||||
t.Errorf("BASE_URL should not be masked: got %q, want %q", masked, v)
|
||||
}
|
||||
}
|
||||
|
||||
if k == "ANTHROPIC_API_KEY" {
|
||||
// API key should be masked
|
||||
if masked == v {
|
||||
t.Errorf("API_KEY should be masked, but got original value")
|
||||
}
|
||||
if !strings.Contains(masked, "****") {
|
||||
t.Errorf("masked API_KEY should contain ****: got %q", masked)
|
||||
}
|
||||
// Should still show first 4 and last 4 chars
|
||||
if !strings.HasPrefix(masked, v[:4]) {
|
||||
t.Errorf("masked value should start with first 4 chars of original")
|
||||
}
|
||||
if !strings.HasSuffix(masked, v[len(v)-4:]) {
|
||||
t.Errorf("masked value should end with last 4 chars of original")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiBackendEnv tests GeminiBackend.Env for comparison
|
||||
func TestGeminiBackendEnv(t *testing.T) {
|
||||
b := backend.GeminiBackend{}
|
||||
env := b.Env("https://custom.api", "gemini-api-key-12345")
|
||||
|
||||
if env == nil {
|
||||
t.Fatal("expected non-nil env")
|
||||
}
|
||||
|
||||
// Check that GEMINI env vars are set
|
||||
if _, ok := env["GOOGLE_GEMINI_BASE_URL"]; !ok {
|
||||
t.Error("expected GOOGLE_GEMINI_BASE_URL in env")
|
||||
}
|
||||
if _, ok := env["GEMINI_API_KEY"]; !ok {
|
||||
t.Error("expected GEMINI_API_KEY in env")
|
||||
}
|
||||
|
||||
// Verify masking works for Gemini keys too
|
||||
for k, v := range env {
|
||||
masked := maskSensitiveValue(k, v)
|
||||
if strings.Contains(strings.ToLower(k), "key") {
|
||||
if masked == v && len(v) > 0 {
|
||||
t.Errorf("key %q should be masked", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodexBackendEnv tests CodexBackend.Env
|
||||
func TestCodexBackendEnv(t *testing.T) {
|
||||
b := backend.CodexBackend{}
|
||||
env := b.Env("https://custom.api", "codex-api-key-12345")
|
||||
|
||||
if env == nil {
|
||||
t.Fatal("expected non-nil env for codex")
|
||||
}
|
||||
|
||||
// Check for OPENAI env vars
|
||||
if _, ok := env["OPENAI_BASE_URL"]; !ok {
|
||||
t.Error("expected OPENAI_BASE_URL in env")
|
||||
}
|
||||
if _, ok := env["OPENAI_API_KEY"]; !ok {
|
||||
t.Error("expected OPENAI_API_KEY in env")
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
)
|
||||
|
||||
type fakeCmd struct {
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
func (f *fakeCmd) Start() error { return nil }
|
||||
func (f *fakeCmd) Wait() error { return nil }
|
||||
func (f *fakeCmd) StdoutPipe() (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), nil
|
||||
}
|
||||
func (f *fakeCmd) StderrPipe() (io.ReadCloser, error) {
|
||||
return nil, errors.New("fake stderr pipe error")
|
||||
}
|
||||
func (f *fakeCmd) StdinPipe() (io.WriteCloser, error) {
|
||||
return nil, errors.New("fake stdin pipe error")
|
||||
}
|
||||
func (f *fakeCmd) SetStderr(io.Writer) {}
|
||||
func (f *fakeCmd) SetDir(string) {}
|
||||
func (f *fakeCmd) SetEnv(env map[string]string) {
|
||||
if len(env) == 0 {
|
||||
return
|
||||
}
|
||||
if f.env == nil {
|
||||
f.env = make(map[string]string, len(env))
|
||||
}
|
||||
for k, v := range env {
|
||||
f.env[k] = v
|
||||
}
|
||||
}
|
||||
func (f *fakeCmd) Process() processHandle { return nil }
|
||||
|
||||
func TestEnvInjection_LogsToStderrAndMasksKey(t *testing.T) {
|
||||
// Arrange ~/.codeagent/models.json via HOME override.
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const baseURL = "https://api.minimaxi.com/anthropic"
|
||||
const apiKey = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test"
|
||||
models := `{
|
||||
"agents": {
|
||||
"explore": {
|
||||
"backend": "claude",
|
||||
"model": "MiniMax-M2.1",
|
||||
"base_url": "` + baseURL + `",
|
||||
"api_key": "` + apiKey + `"
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(models), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("HOME", tmpDir)
|
||||
t.Setenv("USERPROFILE", tmpDir)
|
||||
|
||||
config.ResetModelsConfigCacheForTest()
|
||||
defer config.ResetModelsConfigCacheForTest()
|
||||
|
||||
// Capture stderr (RunCodexTaskWithContext prints env injection lines there).
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldStderr := os.Stderr
|
||||
os.Stderr = w
|
||||
defer func() { os.Stderr = oldStderr }()
|
||||
|
||||
readDone := make(chan string, 1)
|
||||
go func() {
|
||||
defer r.Close()
|
||||
b, _ := io.ReadAll(r)
|
||||
readDone <- string(b)
|
||||
}()
|
||||
|
||||
var cmd *fakeCmd
|
||||
restoreRunner := SetNewCommandRunner(func(ctx context.Context, name string, args ...string) CommandRunner {
|
||||
cmd = &fakeCmd{}
|
||||
return cmd
|
||||
})
|
||||
defer restoreRunner()
|
||||
|
||||
// Act: force an early return right after env injection by making StderrPipe fail.
|
||||
_ = RunCodexTaskWithContext(
|
||||
context.Background(),
|
||||
TaskSpec{Task: "hi", WorkDir: ".", Backend: "claude", Agent: "explore"},
|
||||
nil,
|
||||
"claude",
|
||||
nil,
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
)
|
||||
|
||||
_ = w.Close()
|
||||
got := <-readDone
|
||||
|
||||
// Assert: env was injected into the command and logging is present with masking.
|
||||
if cmd == nil || cmd.env == nil {
|
||||
t.Fatalf("expected cmd env to be set, got cmd=%v env=%v", cmd, nil)
|
||||
}
|
||||
if cmd.env["ANTHROPIC_BASE_URL"] != baseURL {
|
||||
t.Fatalf("ANTHROPIC_BASE_URL=%q, want %q", cmd.env["ANTHROPIC_BASE_URL"], baseURL)
|
||||
}
|
||||
if cmd.env["ANTHROPIC_API_KEY"] != apiKey {
|
||||
t.Fatalf("ANTHROPIC_API_KEY=%q, want %q", cmd.env["ANTHROPIC_API_KEY"], apiKey)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, "Env: ANTHROPIC_BASE_URL="+baseURL) {
|
||||
t.Fatalf("stderr missing base URL env log; stderr=%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Env: ANTHROPIC_API_KEY=eyJh****test") {
|
||||
t.Fatalf("stderr missing masked API key log; stderr=%q", got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,138 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func ParseParallelConfig(data []byte) (*ParallelConfig, error) {
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if len(trimmed) == 0 {
|
||||
return nil, fmt.Errorf("parallel config is empty")
|
||||
}
|
||||
|
||||
tasks := strings.Split(string(trimmed), "---TASK---")
|
||||
var cfg ParallelConfig
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
taskIndex := 0
|
||||
for _, taskBlock := range tasks {
|
||||
taskBlock = strings.TrimSpace(taskBlock)
|
||||
if taskBlock == "" {
|
||||
continue
|
||||
}
|
||||
taskIndex++
|
||||
|
||||
parts := strings.SplitN(taskBlock, "---CONTENT---", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("task block #%d missing ---CONTENT--- separator", taskIndex)
|
||||
}
|
||||
|
||||
meta := strings.TrimSpace(parts[0])
|
||||
content := strings.TrimSpace(parts[1])
|
||||
|
||||
task := TaskSpec{WorkDir: defaultWorkdir}
|
||||
agentSpecified := false
|
||||
for _, line := range strings.Split(meta, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(line, ":", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(kv[0])
|
||||
value := strings.TrimSpace(kv[1])
|
||||
|
||||
switch key {
|
||||
case "id":
|
||||
task.ID = value
|
||||
case "workdir":
|
||||
// Validate workdir: "-" is not a valid directory
|
||||
if value == "-" {
|
||||
return nil, fmt.Errorf("task block #%d has invalid workdir: '-' is not a valid directory path", taskIndex)
|
||||
}
|
||||
task.WorkDir = value
|
||||
case "session_id":
|
||||
task.SessionID = value
|
||||
task.Mode = "resume"
|
||||
case "backend":
|
||||
task.Backend = value
|
||||
case "model":
|
||||
task.Model = value
|
||||
case "reasoning_effort":
|
||||
task.ReasoningEffort = value
|
||||
case "agent":
|
||||
agentSpecified = true
|
||||
task.Agent = value
|
||||
case "skip_permissions", "skip-permissions":
|
||||
if value == "" {
|
||||
task.SkipPermissions = true
|
||||
continue
|
||||
}
|
||||
task.SkipPermissions = config.ParseBoolFlag(value, false)
|
||||
case "dependencies":
|
||||
for _, dep := range strings.Split(value, ",") {
|
||||
dep = strings.TrimSpace(dep)
|
||||
if dep != "" {
|
||||
task.Dependencies = append(task.Dependencies, dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if task.Mode == "" {
|
||||
task.Mode = "new"
|
||||
}
|
||||
|
||||
if agentSpecified {
|
||||
if strings.TrimSpace(task.Agent) == "" {
|
||||
return nil, fmt.Errorf("task block #%d has empty agent field", taskIndex)
|
||||
}
|
||||
if err := config.ValidateAgentName(task.Agent); err != nil {
|
||||
return nil, fmt.Errorf("task block #%d invalid agent name: %w", taskIndex, err)
|
||||
}
|
||||
backend, model, promptFile, reasoning, _, _, _, err := config.ResolveAgentConfig(task.Agent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("task block #%d failed to resolve agent %q: %w", taskIndex, task.Agent, err)
|
||||
}
|
||||
if task.Backend == "" {
|
||||
task.Backend = backend
|
||||
}
|
||||
if task.Model == "" {
|
||||
task.Model = model
|
||||
}
|
||||
if task.ReasoningEffort == "" {
|
||||
task.ReasoningEffort = reasoning
|
||||
}
|
||||
task.PromptFile = promptFile
|
||||
}
|
||||
|
||||
if task.ID == "" {
|
||||
return nil, fmt.Errorf("task block #%d missing id field", taskIndex)
|
||||
}
|
||||
if content == "" {
|
||||
return nil, fmt.Errorf("task block #%d (%q) missing content", taskIndex, task.ID)
|
||||
}
|
||||
if task.Mode == "resume" && strings.TrimSpace(task.SessionID) == "" {
|
||||
return nil, fmt.Errorf("task block #%d (%q) has empty session_id", taskIndex, task.ID)
|
||||
}
|
||||
if _, exists := seen[task.ID]; exists {
|
||||
return nil, fmt.Errorf("task block #%d has duplicate id: %s", taskIndex, task.ID)
|
||||
}
|
||||
|
||||
task.Task = content
|
||||
cfg.Tasks = append(cfg.Tasks, task)
|
||||
seen[task.ID] = struct{}{}
|
||||
}
|
||||
|
||||
if len(cfg.Tasks) == 0 {
|
||||
return nil, fmt.Errorf("no tasks found")
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ReadAgentPromptFile(path string, allowOutsideClaudeDir bool) (string, error) {
|
||||
raw := strings.TrimSpace(path)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
expanded := raw
|
||||
if raw == "~" || strings.HasPrefix(raw, "~/") || strings.HasPrefix(raw, "~\\") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if raw == "~" {
|
||||
expanded = home
|
||||
} else {
|
||||
expanded = home + raw[1:]
|
||||
}
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(expanded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absPath = filepath.Clean(absPath)
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
if !allowOutsideClaudeDir {
|
||||
return "", err
|
||||
}
|
||||
logWarn(fmt.Sprintf("Failed to resolve home directory for prompt file validation: %v; proceeding without restriction", err))
|
||||
} else {
|
||||
allowedDirs := []string{
|
||||
filepath.Clean(filepath.Join(home, ".claude")),
|
||||
filepath.Clean(filepath.Join(home, ".codeagent", "agents")),
|
||||
}
|
||||
for i := range allowedDirs {
|
||||
allowedAbs, err := filepath.Abs(allowedDirs[i])
|
||||
if err == nil {
|
||||
allowedDirs[i] = filepath.Clean(allowedAbs)
|
||||
}
|
||||
}
|
||||
|
||||
isWithinDir := func(path, dir string) bool {
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel = filepath.Clean(rel)
|
||||
if rel == "." {
|
||||
return true
|
||||
}
|
||||
if rel == ".." {
|
||||
return false
|
||||
}
|
||||
prefix := ".." + string(os.PathSeparator)
|
||||
return !strings.HasPrefix(rel, prefix)
|
||||
}
|
||||
|
||||
if !allowOutsideClaudeDir {
|
||||
withinAllowed := false
|
||||
for _, dir := range allowedDirs {
|
||||
if isWithinDir(absPath, dir) {
|
||||
withinAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !withinAllowed {
|
||||
logWarn(fmt.Sprintf("Refusing to read prompt file outside allowed dirs (%s): %s", strings.Join(allowedDirs, ", "), absPath))
|
||||
return "", fmt.Errorf("prompt file must be under ~/.claude or ~/.codeagent/agents")
|
||||
}
|
||||
|
||||
resolvedPath, errPath := filepath.EvalSymlinks(absPath)
|
||||
if errPath == nil {
|
||||
resolvedPath = filepath.Clean(resolvedPath)
|
||||
resolvedAllowed := make([]string, 0, len(allowedDirs))
|
||||
for _, dir := range allowedDirs {
|
||||
resolvedBase, errBase := filepath.EvalSymlinks(dir)
|
||||
if errBase != nil {
|
||||
continue
|
||||
}
|
||||
resolvedAllowed = append(resolvedAllowed, filepath.Clean(resolvedBase))
|
||||
}
|
||||
if len(resolvedAllowed) > 0 {
|
||||
withinResolved := false
|
||||
for _, dir := range resolvedAllowed {
|
||||
if isWithinDir(resolvedPath, dir) {
|
||||
withinResolved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !withinResolved {
|
||||
logWarn(fmt.Sprintf("Refusing to read prompt file outside allowed dirs (%s) (resolved): %s", strings.Join(resolvedAllowed, ", "), resolvedPath))
|
||||
return "", fmt.Errorf("prompt file must be under ~/.claude or ~/.codeagent/agents")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withinAllowed := false
|
||||
for _, dir := range allowedDirs {
|
||||
if isWithinDir(absPath, dir) {
|
||||
withinAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !withinAllowed {
|
||||
logWarn(fmt.Sprintf("Reading prompt file outside allowed dirs (%s): %s", strings.Join(allowedDirs, ", "), absPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(string(data), "\r\n"), nil
|
||||
}
|
||||
|
||||
func WrapTaskWithAgentPrompt(prompt string, task string) string {
|
||||
return "<agent-prompt>\n" + prompt + "\n</agent-prompt>\n\n" + task
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWrapTaskWithAgentPrompt(t *testing.T) {
|
||||
got := WrapTaskWithAgentPrompt("P", "do")
|
||||
want := "<agent-prompt>\nP\n</agent-prompt>\n\ndo"
|
||||
if got != want {
|
||||
t.Fatalf("wrapTaskWithAgentPrompt mismatch:\n got=%q\nwant=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_EmptyPath(t *testing.T) {
|
||||
for _, allowOutside := range []bool{false, true} {
|
||||
got, err := ReadAgentPromptFile(" ", allowOutside)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error (allowOutside=%v): %v", allowOutside, err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty result (allowOutside=%v), got %q", allowOutside, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_ExplicitAbsolutePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "prompt.md")
|
||||
if err := os.WriteFile(path, []byte("LINE1\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got, err := ReadAgentPromptFile(path, true)
|
||||
if err != nil {
|
||||
t.Fatalf("readAgentPromptFile error: %v", err)
|
||||
}
|
||||
if got != "LINE1" {
|
||||
t.Fatalf("got %q, want %q", got, "LINE1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_ExplicitTildeExpansion(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
path := filepath.Join(home, "prompt.md")
|
||||
if err := os.WriteFile(path, []byte("P\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got, err := ReadAgentPromptFile("~/prompt.md", true)
|
||||
if err != nil {
|
||||
t.Fatalf("readAgentPromptFile error: %v", err)
|
||||
}
|
||||
if got != "P" {
|
||||
t.Fatalf("got %q, want %q", got, "P")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_RestrictedAllowsClaudeDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
claudeDir := filepath.Join(home, ".claude")
|
||||
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
path := filepath.Join(claudeDir, "prompt.md")
|
||||
if err := os.WriteFile(path, []byte("OK\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got, err := ReadAgentPromptFile("~/.claude/prompt.md", false)
|
||||
if err != nil {
|
||||
t.Fatalf("readAgentPromptFile error: %v", err)
|
||||
}
|
||||
if got != "OK" {
|
||||
t.Fatalf("got %q, want %q", got, "OK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_RestrictedAllowsCodeagentAgentsDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
agentDir := filepath.Join(home, ".codeagent", "agents")
|
||||
if err := os.MkdirAll(agentDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
path := filepath.Join(agentDir, "sarsh.md")
|
||||
if err := os.WriteFile(path, []byte("OK\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got, err := ReadAgentPromptFile("~/.codeagent/agents/sarsh.md", false)
|
||||
if err != nil {
|
||||
t.Fatalf("readAgentPromptFile error: %v", err)
|
||||
}
|
||||
if got != "OK" {
|
||||
t.Fatalf("got %q, want %q", got, "OK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_RestrictedRejectsOutsideClaudeDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
path := filepath.Join(home, "prompt.md")
|
||||
if err := os.WriteFile(path, []byte("NO\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ReadAgentPromptFile("~/prompt.md", false); err == nil {
|
||||
t.Fatalf("expected error for prompt file outside ~/.claude, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_RestrictedRejectsTraversal(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
path := filepath.Join(home, "secret.md")
|
||||
if err := os.WriteFile(path, []byte("SECRET\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ReadAgentPromptFile("~/.claude/../secret.md", false); err == nil {
|
||||
t.Fatalf("expected traversal to be rejected, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_NotFound(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
claudeDir := filepath.Join(home, ".claude")
|
||||
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
_, err := ReadAgentPromptFile("~/.claude/missing.md", false)
|
||||
if err == nil || !os.IsNotExist(err) {
|
||||
t.Fatalf("expected not-exist error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_PermissionDenied(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("chmod-based permission test is not reliable on Windows")
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
claudeDir := filepath.Join(home, ".claude")
|
||||
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
path := filepath.Join(claudeDir, "private.md")
|
||||
if err := os.WriteFile(path, []byte("PRIVATE\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o000); err != nil {
|
||||
t.Fatalf("Chmod: %v", err)
|
||||
}
|
||||
|
||||
_, err := ReadAgentPromptFile("~/.claude/private.md", false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected permission error, got nil")
|
||||
}
|
||||
if !os.IsPermission(err) && !strings.Contains(strings.ToLower(err.Error()), "permission") {
|
||||
t.Fatalf("expected permission denied, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package executor
|
||||
|
||||
import "strings"
|
||||
|
||||
// extractCoverageGap extracts what's missing from coverage reports.
|
||||
func extractCoverageGap(message string) string {
|
||||
if message == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lower := strings.ToLower(message)
|
||||
lines := strings.Split(message, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
lineLower := strings.ToLower(line)
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
if strings.Contains(lineLower, "uncovered") ||
|
||||
strings.Contains(lineLower, "not covered") ||
|
||||
strings.Contains(lineLower, "missing coverage") ||
|
||||
strings.Contains(lineLower, "lines not covered") {
|
||||
if len(line) > 100 {
|
||||
return line[:97] + "..."
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
if strings.Contains(lineLower, "branch") && strings.Contains(lineLower, "not taken") {
|
||||
if len(line) > 100 {
|
||||
return line[:97] + "..."
|
||||
}
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(lower, "function") && strings.Contains(lower, "0%") {
|
||||
for _, line := range lines {
|
||||
if strings.Contains(strings.ToLower(line), "0%") && strings.Contains(line, "function") {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) > 100 {
|
||||
return line[:97] + "..."
|
||||
}
|
||||
return line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractErrorDetail extracts meaningful error context from task output.
|
||||
func extractErrorDetail(message string, maxLen int) string {
|
||||
if message == "" || maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(message, "\n")
|
||||
var errorLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
lower := strings.ToLower(line)
|
||||
|
||||
if strings.HasPrefix(line, "at ") && strings.Contains(line, "(") {
|
||||
if len(errorLines) > 0 && strings.HasPrefix(strings.ToLower(errorLines[len(errorLines)-1]), "at ") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(lower, "error") ||
|
||||
strings.Contains(lower, "fail") ||
|
||||
strings.Contains(lower, "exception") ||
|
||||
strings.Contains(lower, "assert") ||
|
||||
strings.Contains(lower, "expected") ||
|
||||
strings.Contains(lower, "timeout") ||
|
||||
strings.Contains(lower, "not found") ||
|
||||
strings.Contains(lower, "cannot") ||
|
||||
strings.Contains(lower, "undefined") ||
|
||||
strings.HasPrefix(line, "FAIL") ||
|
||||
strings.HasPrefix(line, "●") {
|
||||
errorLines = append(errorLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errorLines) == 0 {
|
||||
start := len(lines) - 5
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
for _, line := range lines[start:] {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
errorLines = append(errorLines, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.Join(errorLines, " | ")
|
||||
return safeTruncate(result, maxLen)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//go:build unix || darwin || linux
|
||||
// +build unix darwin linux
|
||||
|
||||
package executor
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// sendTermSignal sends SIGTERM for graceful shutdown on Unix.
|
||||
func sendTermSignal(proc processHandle) error {
|
||||
if proc == nil {
|
||||
return nil
|
||||
}
|
||||
return proc.Signal(syscall.SIGTERM)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package executor
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sendTermSignal on Windows directly kills the process.
|
||||
// SIGTERM is not supported on Windows.
|
||||
func sendTermSignal(proc processHandle) error {
|
||||
if proc == nil {
|
||||
return nil
|
||||
}
|
||||
pid := proc.Pid()
|
||||
if pid > 0 {
|
||||
// Kill the whole process tree to avoid leaving inheriting child processes around.
|
||||
// This also helps prevent exec.Cmd.Wait() from blocking on stderr/stdout pipes held open by children.
|
||||
taskkill := "taskkill"
|
||||
if root := os.Getenv("SystemRoot"); root != "" {
|
||||
taskkill = filepath.Join(root, "System32", "taskkill.exe")
|
||||
}
|
||||
cmd := exec.Command(taskkill, "/PID", strconv.Itoa(pid), "/T", "/F")
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = io.Discard
|
||||
if err := cmd.Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := killProcessTree(pid); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return proc.Kill()
|
||||
}
|
||||
|
||||
func killProcessTree(pid int) error {
|
||||
if pid <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
wmic := "wmic"
|
||||
if root := os.Getenv("SystemRoot"); root != "" {
|
||||
wmic = filepath.Join(root, "System32", "wbem", "WMIC.exe")
|
||||
}
|
||||
|
||||
queryChildren := "(ParentProcessId=" + strconv.Itoa(pid) + ")"
|
||||
listCmd := exec.Command(wmic, "process", "where", queryChildren, "get", "ProcessId", "/VALUE")
|
||||
listCmd.Stderr = io.Discard
|
||||
out, err := listCmd.Output()
|
||||
if err == nil {
|
||||
for _, childPID := range parseWMICPIDs(out) {
|
||||
_ = killProcessTree(childPID)
|
||||
}
|
||||
}
|
||||
|
||||
querySelf := "(ProcessId=" + strconv.Itoa(pid) + ")"
|
||||
termCmd := exec.Command(wmic, "process", "where", querySelf, "call", "terminate")
|
||||
termCmd.Stdout = io.Discard
|
||||
termCmd.Stderr = io.Discard
|
||||
if termErr := termCmd.Run(); termErr != nil && err == nil {
|
||||
err = termErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func parseWMICPIDs(out []byte) []int {
|
||||
const prefix = "ProcessId="
|
||||
var pids []int
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, prefix)))
|
||||
if err != nil || n <= 0 {
|
||||
continue
|
||||
}
|
||||
pids = append(pids, n)
|
||||
}
|
||||
return pids
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package executor
|
||||
|
||||
import "strings"
|
||||
|
||||
const stdinSpecialChars = "\n\\\"'`$"
|
||||
|
||||
func ShouldUseStdin(taskText string, piped bool) bool {
|
||||
if piped {
|
||||
return true
|
||||
}
|
||||
if len(taskText) > 800 {
|
||||
return true
|
||||
}
|
||||
return strings.ContainsAny(taskText, stdinSpecialChars)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package executor
|
||||
|
||||
import "context"
|
||||
|
||||
// ParallelConfig defines the JSON schema for parallel execution.
|
||||
type ParallelConfig struct {
|
||||
Tasks []TaskSpec `json:"tasks"`
|
||||
GlobalBackend string `json:"backend,omitempty"`
|
||||
}
|
||||
|
||||
// TaskSpec describes an individual task entry in the parallel config.
|
||||
type TaskSpec struct {
|
||||
ID string `json:"id"`
|
||||
Task string `json:"task"`
|
||||
WorkDir string `json:"workdir,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Backend string `json:"backend,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
PromptFile string `json:"prompt_file,omitempty"`
|
||||
SkipPermissions bool `json:"skip_permissions,omitempty"`
|
||||
Mode string `json:"-"`
|
||||
UseStdin bool `json:"-"`
|
||||
Context context.Context `json:"-"`
|
||||
}
|
||||
|
||||
// TaskResult captures the execution outcome of a task.
|
||||
type TaskResult struct {
|
||||
TaskID string `json:"task_id"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Message string `json:"message"`
|
||||
SessionID string `json:"session_id"`
|
||||
Error string `json:"error"`
|
||||
LogPath string `json:"log_path"`
|
||||
// Structured report fields
|
||||
Coverage string `json:"coverage,omitempty"` // extracted coverage percentage (e.g., "92%")
|
||||
CoverageNum float64 `json:"coverage_num,omitempty"` // numeric coverage for comparison
|
||||
CoverageTarget float64 `json:"coverage_target,omitempty"` // target coverage (default 90)
|
||||
FilesChanged []string `json:"files_changed,omitempty"` // list of changed files
|
||||
KeyOutput string `json:"key_output,omitempty"` // brief summary of what was done
|
||||
TestsPassed int `json:"tests_passed,omitempty"` // number of tests passed
|
||||
TestsFailed int `json:"tests_failed,omitempty"` // number of tests failed
|
||||
sharedLog bool
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
|
||||
backend "codeagent-wrapper/internal/backend"
|
||||
)
|
||||
|
||||
type CommandRunner = commandRunner
|
||||
type ProcessHandle = processHandle
|
||||
|
||||
func SetForceKillDelay(seconds int32) (restore func()) {
|
||||
prev := forceKillDelay.Load()
|
||||
forceKillDelay.Store(seconds)
|
||||
return func() { forceKillDelay.Store(prev) }
|
||||
}
|
||||
|
||||
func SetSelectBackendFn(fn func(string) (Backend, error)) (restore func()) {
|
||||
prev := selectBackendFn
|
||||
if fn != nil {
|
||||
selectBackendFn = fn
|
||||
} else {
|
||||
selectBackendFn = backend.Select
|
||||
}
|
||||
return func() { selectBackendFn = prev }
|
||||
}
|
||||
|
||||
func SetCommandContextFn(fn func(context.Context, string, ...string) *exec.Cmd) (restore func()) {
|
||||
prev := commandContext
|
||||
if fn != nil {
|
||||
commandContext = fn
|
||||
} else {
|
||||
commandContext = exec.CommandContext
|
||||
}
|
||||
return func() { commandContext = prev }
|
||||
}
|
||||
|
||||
func SetNewCommandRunner(fn func(context.Context, string, ...string) CommandRunner) (restore func()) {
|
||||
prev := newCommandRunner
|
||||
if fn != nil {
|
||||
newCommandRunner = fn
|
||||
} else {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &realCmd{cmd: commandContext(ctx, name, args...)}
|
||||
}
|
||||
}
|
||||
return func() { newCommandRunner = prev }
|
||||
}
|
||||
|
||||
func WithTaskLogger(ctx context.Context, logger *Logger) context.Context {
|
||||
return withTaskLogger(ctx, logger)
|
||||
}
|
||||
|
||||
func TaskLoggerFromContext(ctx context.Context) *Logger {
|
||||
return taskLoggerFromContext(ctx)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package logger
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
var loggerPtr atomic.Pointer[Logger]
|
||||
|
||||
func setLogger(l *Logger) {
|
||||
loggerPtr.Store(l)
|
||||
}
|
||||
|
||||
func closeLogger() error {
|
||||
logger := loggerPtr.Swap(nil)
|
||||
if logger == nil {
|
||||
return nil
|
||||
}
|
||||
return logger.Close()
|
||||
}
|
||||
|
||||
func activeLogger() *Logger {
|
||||
return loggerPtr.Load()
|
||||
}
|
||||
|
||||
func logDebug(msg string) {
|
||||
if logger := activeLogger(); logger != nil {
|
||||
logger.Debug(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func logInfo(msg string) {
|
||||
if logger := activeLogger(); logger != nil {
|
||||
logger.Info(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func logWarn(msg string) {
|
||||
if logger := activeLogger(); logger != nil {
|
||||
logger.Warn(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func logError(msg string) {
|
||||
if logger := activeLogger(); logger != nil {
|
||||
logger.Error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func SetLogger(l *Logger) { setLogger(l) }
|
||||
|
||||
func CloseLogger() error { return closeLogger() }
|
||||
|
||||
func ActiveLogger() *Logger { return activeLogger() }
|
||||
|
||||
func LogInfo(msg string) { logInfo(msg) }
|
||||
|
||||
func LogDebug(msg string) { logDebug(msg) }
|
||||
|
||||
func LogWarn(msg string) { logWarn(msg) }
|
||||
|
||||
func LogError(msg string) { logError(msg) }
|
||||
@@ -1,63 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/process"
|
||||
)
|
||||
|
||||
func pidToInt32(pid int) (int32, bool) {
|
||||
if pid <= 0 || pid > math.MaxInt32 {
|
||||
return 0, false
|
||||
}
|
||||
return int32(pid), true
|
||||
}
|
||||
|
||||
// isProcessRunning reports whether a process with the given pid appears to be running.
|
||||
// It is intentionally conservative on errors to avoid deleting logs for live processes.
|
||||
func isProcessRunning(pid int) bool {
|
||||
pid32, ok := pidToInt32(pid)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
exists, err := process.PidExists(pid32)
|
||||
if err == nil {
|
||||
return exists
|
||||
}
|
||||
|
||||
// If we can positively identify that the process doesn't exist, report false.
|
||||
if errors.Is(err, process.ErrorProcessNotRunning) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Permission/inspection failures: assume it's running to be safe.
|
||||
return true
|
||||
}
|
||||
|
||||
// getProcessStartTime returns the start time of a process.
|
||||
// Returns zero time if the start time cannot be determined.
|
||||
func getProcessStartTime(pid int) time.Time {
|
||||
pid32, ok := pidToInt32(pid)
|
||||
if !ok {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
proc, err := process.NewProcess(pid32)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
ms, err := proc.CreateTime()
|
||||
if err != nil || ms <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
return time.UnixMilli(ms)
|
||||
}
|
||||
|
||||
func IsProcessRunning(pid int) bool { return isProcessRunning(pid) }
|
||||
|
||||
func GetProcessStartTime(pid int) time.Time { return getProcessStartTime(pid) }
|
||||
@@ -1,112 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIsProcessRunning(t *testing.T) {
|
||||
t.Run("boundary values", func(t *testing.T) {
|
||||
if isProcessRunning(0) {
|
||||
t.Fatalf("pid 0 should never be treated as running")
|
||||
}
|
||||
if isProcessRunning(-1) {
|
||||
t.Fatalf("negative pid should never be treated as running")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pid out of int32 range", func(t *testing.T) {
|
||||
if strconv.IntSize <= 32 {
|
||||
t.Skip("int cannot represent values above int32 range")
|
||||
}
|
||||
|
||||
pid := int(int64(math.MaxInt32) + 1)
|
||||
if isProcessRunning(pid) {
|
||||
t.Fatalf("expected pid %d (out of int32 range) to be treated as not running", pid)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("current process", func(t *testing.T) {
|
||||
if !isProcessRunning(os.Getpid()) {
|
||||
t.Fatalf("expected current process (pid=%d) to be running", os.Getpid())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fake pid", func(t *testing.T) {
|
||||
const nonexistentPID = 1 << 30
|
||||
if isProcessRunning(nonexistentPID) {
|
||||
t.Fatalf("expected pid %d to be reported as not running", nonexistentPID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("terminated process", func(t *testing.T) {
|
||||
pid := exitedProcessPID(t)
|
||||
if isProcessRunning(pid) {
|
||||
t.Fatalf("expected exited child process (pid=%d) to be reported as not running", pid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func exitedProcessPID(t *testing.T) int {
|
||||
t.Helper()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("cmd", "/c", "exit 0")
|
||||
} else {
|
||||
cmd = exec.Command("sh", "-c", "exit 0")
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("failed to start helper process: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("helper process did not exit cleanly: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return pid
|
||||
}
|
||||
|
||||
func TestGetProcessStartTimeReadsProcStat(t *testing.T) {
|
||||
start := getProcessStartTime(os.Getpid())
|
||||
if start.IsZero() {
|
||||
t.Fatalf("expected non-zero start time for current process")
|
||||
}
|
||||
if start.After(time.Now().Add(5 * time.Second)) {
|
||||
t.Fatalf("start time is unexpectedly in the future: %v", start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProcessStartTimeInvalidData(t *testing.T) {
|
||||
if !getProcessStartTime(0).IsZero() {
|
||||
t.Fatalf("expected zero time for pid 0")
|
||||
}
|
||||
if !getProcessStartTime(-1).IsZero() {
|
||||
t.Fatalf("expected zero time for negative pid")
|
||||
}
|
||||
if !getProcessStartTime(1 << 30).IsZero() {
|
||||
t.Fatalf("expected zero time for non-existent pid")
|
||||
}
|
||||
if strconv.IntSize > 32 {
|
||||
pid := int(int64(math.MaxInt32) + 1)
|
||||
if !getProcessStartTime(pid).IsZero() {
|
||||
t.Fatalf("expected zero time for pid %d (out of int32 range)", pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBootTimeParsesBtime(t *testing.T) {
|
||||
t.Skip("legacy boot-time probing removed; start time now uses gopsutil")
|
||||
}
|
||||
|
||||
func TestGetBootTimeInvalidData(t *testing.T) {
|
||||
t.Skip("legacy boot-time probing removed; start time now uses gopsutil")
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func SetProcessRunningCheck(fn func(int) bool) (restore func()) {
|
||||
prev := processRunningCheck
|
||||
if fn != nil {
|
||||
processRunningCheck = fn
|
||||
} else {
|
||||
processRunningCheck = isProcessRunning
|
||||
}
|
||||
return func() { processRunningCheck = prev }
|
||||
}
|
||||
|
||||
func SetProcessStartTimeFn(fn func(int) time.Time) (restore func()) {
|
||||
prev := processStartTimeFn
|
||||
if fn != nil {
|
||||
processStartTimeFn = fn
|
||||
} else {
|
||||
processStartTimeFn = getProcessStartTime
|
||||
}
|
||||
return func() { processStartTimeFn = prev }
|
||||
}
|
||||
|
||||
func SetRemoveLogFileFn(fn func(string) error) (restore func()) {
|
||||
prev := removeLogFileFn
|
||||
if fn != nil {
|
||||
removeLogFileFn = fn
|
||||
} else {
|
||||
removeLogFileFn = os.Remove
|
||||
}
|
||||
return func() { removeLogFileFn = prev }
|
||||
}
|
||||
|
||||
func SetGlobLogFilesFn(fn func(string) ([]string, error)) (restore func()) {
|
||||
prev := globLogFiles
|
||||
if fn != nil {
|
||||
globLogFiles = fn
|
||||
} else {
|
||||
globLogFiles = filepath.Glob
|
||||
}
|
||||
return func() { globLogFiles = prev }
|
||||
}
|
||||
|
||||
func SetFileStatFn(fn func(string) (os.FileInfo, error)) (restore func()) {
|
||||
prev := fileStatFn
|
||||
if fn != nil {
|
||||
fileStatFn = fn
|
||||
} else {
|
||||
fileStatFn = os.Lstat
|
||||
}
|
||||
return func() { fileStatFn = prev }
|
||||
}
|
||||
|
||||
func SetEvalSymlinksFn(fn func(string) (string, error)) (restore func()) {
|
||||
prev := evalSymlinksFn
|
||||
if fn != nil {
|
||||
evalSymlinksFn = fn
|
||||
} else {
|
||||
evalSymlinksFn = filepath.EvalSymlinks
|
||||
}
|
||||
return func() { evalSymlinksFn = prev }
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package logger
|
||||
|
||||
// WrapperName is the fixed name for this tool.
|
||||
const WrapperName = "codeagent-wrapper"
|
||||
|
||||
// CurrentWrapperName returns the wrapper name (always "codeagent-wrapper").
|
||||
func CurrentWrapperName() string { return WrapperName }
|
||||
|
||||
// LogPrefixes returns the log file name prefixes to look for.
|
||||
func LogPrefixes() []string { return []string{WrapperName} }
|
||||
|
||||
// PrimaryLogPrefix returns the preferred filename prefix for log files.
|
||||
func PrimaryLogPrefix() string { return WrapperName }
|
||||
@@ -1,74 +0,0 @@
|
||||
package parser
|
||||
|
||||
import "github.com/goccy/go-json"
|
||||
|
||||
// JSONEvent represents a Codex JSON output event.
|
||||
type JSONEvent struct {
|
||||
Type string `json:"type"`
|
||||
ThreadID string `json:"thread_id,omitempty"`
|
||||
Item *EventItem `json:"item,omitempty"`
|
||||
}
|
||||
|
||||
// EventItem represents the item field in a JSON event.
|
||||
type EventItem struct {
|
||||
Type string `json:"type"`
|
||||
Text interface{} `json:"text"`
|
||||
}
|
||||
|
||||
// ClaudeEvent for Claude stream-json format.
|
||||
type ClaudeEvent struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// GeminiEvent for Gemini stream-json format.
|
||||
type GeminiEvent struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Delta bool `json:"delta,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// UnifiedEvent combines all backend event formats into a single structure
|
||||
// to avoid multiple JSON unmarshal operations per event.
|
||||
type UnifiedEvent struct {
|
||||
// Common fields
|
||||
Type string `json:"type"`
|
||||
|
||||
// Codex-specific fields
|
||||
ThreadID string `json:"thread_id,omitempty"`
|
||||
Item json.RawMessage `json:"item,omitempty"` // Lazy parse
|
||||
|
||||
// Claude-specific fields
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
|
||||
// Gemini-specific fields
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Delta *bool `json:"delta,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
// Opencode-specific fields (camelCase sessionID)
|
||||
OpencodeSessionID string `json:"sessionID,omitempty"`
|
||||
Part json.RawMessage `json:"part,omitempty"`
|
||||
}
|
||||
|
||||
// OpencodePart represents the part field in opencode events.
|
||||
type OpencodePart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
SessionID string `json:"sessionID,omitempty"`
|
||||
}
|
||||
|
||||
// ItemContent represents the parsed item.text field for Codex events.
|
||||
type ItemContent struct {
|
||||
Type string `json:"type"`
|
||||
Text interface{} `json:"text"`
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseJSONStream_Opencode(t *testing.T) {
|
||||
input := `{"type":"step_start","timestamp":1768187730683,"sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","part":{"id":"prt_bb0339afa001NTqoJ2NS8x91zP","sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","messageID":"msg_bb033866f0011oZxTqvfy0TKtS","type":"step-start","snapshot":"904f0fd58c125b79e60f0993e38f9d9f6200bf47"}}
|
||||
{"type":"text","timestamp":1768187744432,"sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","part":{"id":"prt_bb0339cb5001QDd0Lh0PzFZpa3","sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","messageID":"msg_bb033866f0011oZxTqvfy0TKtS","type":"text","text":"Hello from opencode"}}
|
||||
{"type":"step_finish","timestamp":1768187744471,"sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","part":{"id":"prt_bb033d0af0019VRZzpO2OVW1na","sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","messageID":"msg_bb033866f0011oZxTqvfy0TKtS","type":"step-finish","reason":"stop","snapshot":"904f0fd58c125b79e60f0993e38f9d9f6200bf47","cost":0}}`
|
||||
|
||||
message, threadID := ParseJSONStreamInternal(strings.NewReader(input), nil, nil, nil, nil)
|
||||
|
||||
if threadID != "ses_44fced3c7ffe83sZpzY1rlQka3" {
|
||||
t.Errorf("threadID = %q, want %q", threadID, "ses_44fced3c7ffe83sZpzY1rlQka3")
|
||||
}
|
||||
if message != "Hello from opencode" {
|
||||
t.Errorf("message = %q, want %q", message, "Hello from opencode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONStream_Opencode_MultipleTextEvents(t *testing.T) {
|
||||
input := `{"type":"text","sessionID":"ses_123","part":{"type":"text","text":"Part 1"}}
|
||||
{"type":"text","sessionID":"ses_123","part":{"type":"text","text":" Part 2"}}
|
||||
{"type":"step_finish","sessionID":"ses_123","part":{"type":"step-finish","reason":"stop"}}`
|
||||
|
||||
message, threadID := ParseJSONStreamInternal(strings.NewReader(input), nil, nil, nil, nil)
|
||||
|
||||
if threadID != "ses_123" {
|
||||
t.Errorf("threadID = %q, want %q", threadID, "ses_123")
|
||||
}
|
||||
if message != "Part 1 Part 2" {
|
||||
t.Errorf("message = %q, want %q", message, "Part 1 Part 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONStream_Opencode_NoStopReason(t *testing.T) {
|
||||
input := `{"type":"text","sessionID":"ses_456","part":{"type":"text","text":"Content"}}
|
||||
{"type":"step_finish","sessionID":"ses_456","part":{"type":"step-finish","reason":"tool-calls"}}`
|
||||
|
||||
message, threadID := ParseJSONStreamInternal(strings.NewReader(input), nil, nil, nil, nil)
|
||||
|
||||
if threadID != "ses_456" {
|
||||
t.Errorf("threadID = %q, want %q", threadID, "ses_456")
|
||||
}
|
||||
if message != "Content" {
|
||||
t.Errorf("message = %q, want %q", message, "Content")
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBackendParseJSONStream_UnknownEventsAreSilent(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
`{"type":"turn.started"}`,
|
||||
`{"type":"assistant","text":"hi"}`,
|
||||
`{"type":"user","text":"yo"}`,
|
||||
`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`,
|
||||
}, "\n")
|
||||
|
||||
var infos []string
|
||||
infoFn := func(msg string) { infos = append(infos, msg) }
|
||||
|
||||
message, threadID := ParseJSONStreamInternal(strings.NewReader(input), nil, infoFn, nil, nil)
|
||||
if message != "ok" {
|
||||
t.Fatalf("message=%q, want %q (infos=%v)", message, "ok", infos)
|
||||
}
|
||||
if threadID != "" {
|
||||
t.Fatalf("threadID=%q, want empty (infos=%v)", threadID, infos)
|
||||
}
|
||||
|
||||
for _, msg := range infos {
|
||||
if strings.Contains(msg, "Agent event:") {
|
||||
t.Fatalf("unexpected log for unknown event: %q", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package parser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTruncateBytes(t *testing.T) {
|
||||
if got := TruncateBytes([]byte("abc"), 3); got != "abc" {
|
||||
t.Fatalf("TruncateBytes() = %q, want %q", got, "abc")
|
||||
}
|
||||
if got := TruncateBytes([]byte("abcd"), 3); got != "abc..." {
|
||||
t.Fatalf("TruncateBytes() = %q, want %q", got, "abc...")
|
||||
}
|
||||
if got := TruncateBytes([]byte("abcd"), -1); got != "" {
|
||||
t.Fatalf("TruncateBytes() = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package utils
|
||||
|
||||
func Min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b int
|
||||
want int
|
||||
}{
|
||||
{"a less than b", 1, 2, 1},
|
||||
{"b less than a", 5, 3, 3},
|
||||
{"equal values", 7, 7, 7},
|
||||
{"negative a", -5, 3, -5},
|
||||
{"negative b", 5, -3, -3},
|
||||
{"both negative", -5, -3, -5},
|
||||
{"zero and positive", 0, 5, 0},
|
||||
{"zero and negative", 0, -5, -5},
|
||||
{"large values", 1000000, 999999, 999999},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Min(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("Min(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMin(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
Min(i, i+1)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package utils
|
||||
|
||||
import "strings"
|
||||
|
||||
func Truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen < 0 {
|
||||
return ""
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// SafeTruncate safely truncates string to maxLen, avoiding panic and UTF-8 corruption.
|
||||
func SafeTruncate(s string, maxLen int) string {
|
||||
if maxLen <= 0 || s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
|
||||
if maxLen < 4 {
|
||||
return string(runes[:1])
|
||||
}
|
||||
|
||||
cutoff := maxLen - 3
|
||||
if cutoff <= 0 {
|
||||
return string(runes[:1])
|
||||
}
|
||||
if len(runes) <= cutoff {
|
||||
return s
|
||||
}
|
||||
return string(runes[:cutoff]) + "..."
|
||||
}
|
||||
|
||||
// SanitizeOutput removes ANSI escape sequences and control characters.
|
||||
func SanitizeOutput(s string) string {
|
||||
var result strings.Builder
|
||||
inEscape := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' {
|
||||
inEscape = true
|
||||
i++ // skip '['
|
||||
continue
|
||||
}
|
||||
if inEscape {
|
||||
if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') {
|
||||
inEscape = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Keep printable chars and common whitespace.
|
||||
if s[i] >= 32 || s[i] == '\n' || s[i] == '\t' {
|
||||
result.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"empty string", "", 10, ""},
|
||||
{"short string", "hello", 10, "hello"},
|
||||
{"exact length", "hello", 5, "hello"},
|
||||
{"needs truncation", "hello world", 5, "hello..."},
|
||||
{"zero maxLen", "hello", 0, "..."},
|
||||
{"negative maxLen", "hello", -1, ""},
|
||||
{"maxLen 1", "hello", 1, "h..."},
|
||||
{"unicode bytes truncate", "你好世界", 10, "你好世\xe7..."}, // Truncate works on bytes, not runes
|
||||
{"mixed truncate", "hello世界abc", 7, "hello\xe4\xb8..."}, // byte-based truncation
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Truncate(tt.s, tt.maxLen)
|
||||
if got != tt.want {
|
||||
t.Errorf("Truncate(%q, %d) = %q, want %q", tt.s, tt.maxLen, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"empty string", "", 10, ""},
|
||||
{"zero maxLen", "hello", 0, ""},
|
||||
{"negative maxLen", "hello", -1, ""},
|
||||
{"short string", "hello", 10, "hello"},
|
||||
{"exact length", "hello", 5, "hello"},
|
||||
{"needs truncation", "hello world", 8, "hello..."},
|
||||
{"maxLen 1", "hello", 1, "h"},
|
||||
{"maxLen 2", "hello", 2, "h"},
|
||||
{"maxLen 3", "hello", 3, "h"},
|
||||
{"maxLen 4", "hello", 4, "h..."},
|
||||
{"unicode preserved", "你好世界", 10, "你好世界"},
|
||||
{"unicode exact", "你好世界", 4, "你好世界"},
|
||||
{"unicode truncate", "你好世界test", 6, "你好世..."},
|
||||
{"mixed unicode", "ab你好cd", 5, "ab..."},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SafeTruncate(tt.s, tt.maxLen)
|
||||
if got != tt.want {
|
||||
t.Errorf("SafeTruncate(%q, %d) = %q, want %q", tt.s, tt.maxLen, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
want string
|
||||
}{
|
||||
{"empty string", "", ""},
|
||||
{"plain text", "hello world", "hello world"},
|
||||
{"with newline", "hello\nworld", "hello\nworld"},
|
||||
{"with tab", "hello\tworld", "hello\tworld"},
|
||||
{"ANSI color red", "\x1b[31mred\x1b[0m", "red"},
|
||||
{"ANSI bold", "\x1b[1mbold\x1b[0m", "bold"},
|
||||
{"ANSI complex", "\x1b[1;31;40mtext\x1b[0m", "text"},
|
||||
{"control chars", "hello\x00\x01\x02world", "helloworld"},
|
||||
{"mixed ANSI and control", "\x1b[32m\x00ok\x1b[0m", "ok"},
|
||||
{"multiple ANSI sequences", "\x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m", "red green"},
|
||||
{"incomplete escape", "\x1b[", ""},
|
||||
{"escape without bracket", "\x1bA", "A"},
|
||||
{"cursor movement", "\x1b[2Aup\x1b[2Bdown", "updown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SanitizeOutput(tt.s)
|
||||
if got != tt.want {
|
||||
t.Errorf("SanitizeOutput(%q) = %q, want %q", tt.s, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTruncate(b *testing.B) {
|
||||
s := strings.Repeat("hello world ", 100)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
Truncate(s, 50)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSafeTruncate(b *testing.B) {
|
||||
s := strings.Repeat("你好世界", 100)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
SafeTruncate(s, 50)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSanitizeOutput(b *testing.B) {
|
||||
s := strings.Repeat("\x1b[31mred\x1b[0m text ", 50)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
SanitizeOutput(s)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package executor
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
)
|
||||
|
||||
func TestLogWriterWriteLimitsBuffer(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogger error: %v", err)
|
||||
}
|
||||
setLogger(logger)
|
||||
t.Cleanup(func() { _ = closeLogger() })
|
||||
defer closeLogger()
|
||||
|
||||
lw := newLogWriter("P:", 10)
|
||||
_, _ = lw.Write([]byte(strings.Repeat("a", 100)))
|
||||
@@ -34,3 +36,4 @@ func TestLogWriterWriteLimitsBuffer(t *testing.T) {
|
||||
t.Fatalf("log output missing truncated entry, got %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user