mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-06 02:34:09 +08:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
326ad85c74 | ||
|
|
e66bec0083 | ||
|
|
eb066395c2 | ||
|
|
b49dad842a | ||
|
|
d98086c661 | ||
|
|
0420646258 | ||
|
|
19a8d8e922 | ||
|
|
669b1d82ce | ||
|
|
a21c31fd89 | ||
|
|
773f133111 | ||
|
|
4f5d24531c | ||
|
|
cc24d43c8b | ||
|
|
27d4ac8afd | ||
|
|
2e5d12570d | ||
|
|
7c89c40e8f | ||
|
|
fa617d1599 | ||
|
|
90c630e30e | ||
|
|
25bbbc32a7 | ||
|
|
d8304bf2b9 | ||
|
|
7240e08900 | ||
|
|
e122d8ff25 | ||
|
|
6985a30a6a | ||
|
|
dd4c12b8e2 | ||
|
|
a88315d92d | ||
|
|
d1f13b3379 | ||
|
|
5d362852ab | ||
|
|
238c7b9a13 | ||
|
|
0986fa82ee | ||
|
|
a989ce343c | ||
|
|
abe0839249 | ||
|
|
d75c973f32 | ||
|
|
e7f329940b | ||
|
|
0fc5eaaa2d | ||
|
|
420eb857ff | ||
|
|
661656c587 | ||
|
|
ed4b088631 | ||
|
|
55a574280a | ||
|
|
8f05626075 | ||
|
|
4395c5785d | ||
|
|
b0d7a09ff2 | ||
|
|
f7aeaa5c7e | ||
|
|
c8f75faf84 | ||
|
|
b8b06257ff | ||
|
|
369a3319f9 | ||
|
|
75f08ab81f | ||
|
|
23282ef460 | ||
|
|
c7cb28a1da | ||
|
|
0a4982e96d | ||
|
|
17e52d78d2 | ||
|
|
55246ce9c4 | ||
|
|
890fec81bf | ||
|
|
81f298c2ea | ||
|
|
8ea6d10be5 | ||
|
|
bdf62d0f1c | ||
|
|
40e2d00d35 | ||
|
|
13465b12e5 | ||
|
|
cf93a0ada9 | ||
|
|
b81953a1d7 | ||
|
|
1d2f28101a | ||
|
|
81e95777a8 | ||
|
|
993249acb1 | ||
|
|
0d28e70026 | ||
|
|
7560ce1976 | ||
|
|
683d18e6bb | ||
|
|
a7147f692c | ||
|
|
b71d74f01f | ||
|
|
af1c860f54 | ||
|
|
70b1896011 | ||
|
|
3fd3c67749 | ||
|
|
156a072a0b | ||
|
|
0ceb819419 | ||
|
|
4d69c8aef1 | ||
|
|
eec844d850 | ||
|
|
1f42bcc1c6 | ||
|
|
0f359b048f | ||
|
|
4e2df6a80e | ||
|
|
a30f434b5d | ||
|
|
41f4e21268 | ||
|
|
a67aa00c9a | ||
|
|
d61a0f9ffd | ||
|
|
fe5508228f | ||
|
|
50093036c3 | ||
|
|
0cae0ede08 | ||
|
|
4613b57240 | ||
|
|
7535a7b101 | ||
|
|
f6bb97eba9 | ||
|
|
78a411462b | ||
|
|
9471a981e3 | ||
|
|
3d27d44676 | ||
|
|
6a66c9741f | ||
|
|
a09c103cfb | ||
|
|
1dec763e26 | ||
|
|
f57ea2df59 | ||
|
|
d215c33549 | ||
|
|
b3f8fcfea6 | ||
|
|
806bb04a35 | ||
|
|
b1156038de | ||
|
|
0c93bbe574 | ||
|
|
6f4f4e701b | ||
|
|
ff301507fe | ||
|
|
93b72eba42 | ||
|
|
b01758e7e1 | ||
|
|
c51b38c671 | ||
|
|
b227fee225 | ||
|
|
2b7569335b |
@@ -1,209 +1,54 @@
|
||||
{
|
||||
"name": "claude-code-dev-workflows",
|
||||
"$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",
|
||||
"owner": {
|
||||
"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"
|
||||
"name": "cexll",
|
||||
"email": "evanxian9@gmail.com"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"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": "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": "bmad-agile-workflow",
|
||||
"source": "./bmad-agile-workflow/",
|
||||
"name": "dev",
|
||||
"description": "Lightweight development workflow with requirements clarification, parallel codex execution, and mandatory 90% test coverage",
|
||||
"version": "5.6.1",
|
||||
"source": "./dev-workflow",
|
||||
"category": "development"
|
||||
},
|
||||
{
|
||||
"name": "requirements",
|
||||
"description": "Requirements-driven development workflow with quality gates for practical feature implementation",
|
||||
"version": "5.6.1",
|
||||
"source": "./requirements-driven-workflow",
|
||||
"category": "development"
|
||||
},
|
||||
{
|
||||
"name": "bmad",
|
||||
"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"
|
||||
]
|
||||
"version": "5.6.1",
|
||||
"source": "./bmad-agile-workflow",
|
||||
"category": "development"
|
||||
},
|
||||
{
|
||||
"name": "development-essentials",
|
||||
"source": "./development-essentials/",
|
||||
"name": "dev-kit",
|
||||
"description": "Essential development commands for coding, debugging, testing, optimization, and documentation",
|
||||
"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"
|
||||
]
|
||||
"version": "5.6.1",
|
||||
"source": "./development-essentials",
|
||||
"category": "productivity"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
22
.gitattributes
vendored
Normal file
22
.gitattributes
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Ensure shell scripts always use LF line endings on all platforms
|
||||
*.sh text eol=lf
|
||||
|
||||
# Ensure Python files use LF line endings
|
||||
*.py text eol=lf
|
||||
|
||||
# Auto-detect text files and normalize line endings to LF
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicitly declare files that should always be treated as binary
|
||||
*.exe binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.mov binary
|
||||
*.mp4 binary
|
||||
*.mp3 binary
|
||||
*.zip binary
|
||||
*.gz binary
|
||||
*.tar binary
|
||||
32
.github/workflows/release.yml
vendored
32
.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} .
|
||||
go build -ldflags="-s -w -X main.version=${VERSION}" -o ${OUTPUT_NAME} ./cmd/codeagent
|
||||
chmod +x ${OUTPUT_NAME}
|
||||
echo "artifact_path=codeagent-wrapper/${OUTPUT_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -104,38 +104,10 @@ jobs:
|
||||
cp install.sh install.bat release/
|
||||
ls -la release/
|
||||
|
||||
- name: Extract release notes from CHANGELOG
|
||||
id: extract_notes
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
|
||||
# Extract version section from CHANGELOG.md
|
||||
awk -v ver="$VERSION" '
|
||||
/^## [0-9]+\.[0-9]+\.[0-9]+ - / {
|
||||
if (found) exit
|
||||
if ($2 == ver) {
|
||||
found = 1
|
||||
next
|
||||
}
|
||||
}
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md > release_notes.md
|
||||
|
||||
# Fallback to auto-generated if extraction failed
|
||||
if [ ! -s release_notes.md ]; then
|
||||
echo "⚠️ No release notes found in CHANGELOG.md for version $VERSION" > 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
|
||||
|
||||
cat release_notes.md
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/*
|
||||
body_path: release_notes.md
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,10 @@
|
||||
.claude/
|
||||
.claude-trace
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
.venv
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
.coverage
|
||||
coverage.out
|
||||
references
|
||||
|
||||
831
CHANGELOG.md
831
CHANGELOG.md
@@ -1,129 +1,772 @@
|
||||
# Changelog
|
||||
|
||||
## 5.2.0 - 2025-12-13
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
### 🚀 Core Features
|
||||
## [5.6.4] - 2026-01-15
|
||||
|
||||
#### Skills System Enhancements
|
||||
- **New Skills**: Added `codeagent`, `product-requirements`, `prototype-prompt-generator` to `skill-rules.json`
|
||||
- **Auto-Activation**: Skills automatically trigger based on keyword/pattern matching via hooks
|
||||
- **Backward Compatibility**: Retained `skills/codex/SKILL.md` for existing workflows
|
||||
### 🚀 Features
|
||||
|
||||
#### Multi-Backend Support (codeagent-wrapper)
|
||||
- **Renamed**: `codex-wrapper` → `codeagent-wrapper` with pluggable backend architecture
|
||||
- **Three Backends**: Codex (default), Claude, Gemini via `--backend` flag
|
||||
- **Smart Parser**: Auto-detects backend JSON stream formats
|
||||
- **Session Resume**: All backends support `-r <session_id>` cross-session resume
|
||||
- **Parallel Execution**: DAG task scheduling with global and per-task backend configuration
|
||||
- **Concurrency Control**: `CODEAGENT_MAX_PARALLEL_WORKERS` env var limits concurrent tasks (max 100)
|
||||
- **Test Coverage**: 93.4% (backend.go 100%, config.go 97.8%, executor.go 96.4%)
|
||||
- add reasoning effort config for codex backend
|
||||
- default to skip-permissions and bypass-sandbox
|
||||
- add multi-agent support with yolo mode
|
||||
- add omo module for multi-agent orchestration
|
||||
- add intelligent backend selection based on task complexity (#61)
|
||||
- v5.4.0 structured execution report (#94)
|
||||
- add millisecond-precision timestamps to all log entries (#91)
|
||||
- skill-install install script and security scan
|
||||
- add uninstall scripts with selective module removal
|
||||
|
||||
#### Dev Workflow
|
||||
- **`/dev`**: 6-step minimal dev workflow with mandatory 90% test coverage
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
#### Hooks System
|
||||
- **UserPromptSubmit**: Auto-activate skills based on context
|
||||
- **PostToolUse**: Auto-validation/formatting after tool execution
|
||||
- **Stop**: Cleanup and reporting on session end
|
||||
- **Examples**: Skill auto-activation, pre-commit checks
|
||||
- filter codex stderr noise logs
|
||||
- use config override for codex reasoning effort
|
||||
- propagate SkipPermissions to parallel tasks (#113)
|
||||
- add timeout for Windows process termination
|
||||
- reject dash as workdir parameter (#118)
|
||||
- add sleep in fake script to prevent CI race condition
|
||||
- fix gemini env load
|
||||
- fix omo
|
||||
- fix codeagent skill TaskOutput
|
||||
- 修复 Gemini init 事件 session_id 未提取的问题 (#111)
|
||||
- Windows 后端退出:taskkill 结束进程树 + turn.completed 支持 (#108)
|
||||
- support model parameter for all backends, auto-inject from settings (#105)
|
||||
- replace setx with reg add to avoid 1024-char PATH truncation (#101)
|
||||
- 移除未知事件格式的日志噪声 (#96)
|
||||
- prevent duplicate PATH entries on reinstall (#95)
|
||||
- Minor issues #12 and #13 - ASCII mode and performance optimization
|
||||
- correct settings.json filename and bump version to v5.2.8
|
||||
- allow claude backend to read env from setting.json while preventing recursion (#92)
|
||||
- comprehensive security and quality improvements for PR #85 & #87 (#90)
|
||||
- Improve backend termination after message and extend timeout (#86)
|
||||
- Parser重复解析优化 + 严重bug修复 + PR #86兼容性 (#88)
|
||||
- filter noisy stderr output from gemini backend (#83)
|
||||
- 修復 wsl install.sh 格式問題 (#78)
|
||||
- 修复多 backend 并行日志 PID 混乱并移除包装格式 (#74) (#76)
|
||||
|
||||
#### Skills System
|
||||
- **Auto-Activation**: `skill-rules.json` regex trigger rules
|
||||
- **codeagent skill**: Multi-backend wrapper integration
|
||||
- **Modular Design**: Easy to extend with custom skills
|
||||
### 🚜 Refactor
|
||||
|
||||
#### Installation System Enhancements
|
||||
- **`merge_json` operation**: Auto-merge `settings.json` configuration
|
||||
- **Modular Installation**: `python3 install.py --module dev`
|
||||
- **Verbose Logging**: `--verbose/-v` enables terminal real-time output
|
||||
- **Streaming Output**: `op_run_command` streams bash script execution
|
||||
- **Configuration Cleanup**: Removed deprecated `gh` module from `config.json`
|
||||
- remove sisyphus agent and unused code
|
||||
- streamline agent documentation and remove sisyphus
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- `docs/architecture.md` (21KB): Architecture overview with ASCII diagrams
|
||||
- `docs/CODEAGENT-WRAPPER.md` (9KB): Complete usage guide
|
||||
- `docs/HOOKS.md` (4KB): Customization guide
|
||||
- `README.md`: Added documentation index, corrected default backend description
|
||||
- add OmO workflow to README and fix plugin marketplace structure
|
||||
- update FAQ for default bypass/skip-permissions behavior
|
||||
- 添加 FAQ 常见问题章节
|
||||
- update troubleshooting with idempotent PATH commands (#95)
|
||||
|
||||
### 🔧 Important Fixes
|
||||
### 💼 Other
|
||||
|
||||
#### codeagent-wrapper
|
||||
- Fixed Claude/Gemini backend `-C` (workdir) and `-r` (resume) parameter support (codeagent-wrapper/backend.go:80-120)
|
||||
- Corrected Claude backend permission flag logic `if cfg.SkipPermissions` (codeagent-wrapper/backend.go:95)
|
||||
- Fixed parallel mode startup banner duplication (codeagent-wrapper/main.go:184-194 removed)
|
||||
- Extract and display recent errors on abnormal exit `Logger.ExtractRecentErrors()` (codeagent-wrapper/logger.go:156)
|
||||
- Added task block index to parallel config error messages (codeagent-wrapper/config.go:245)
|
||||
- Refactored signal handling logic to avoid duplicate nil checks (codeagent-wrapper/main.go:290-305)
|
||||
- Removed binary artifacts from tracking (codeagent-wrapper, *.test, coverage.out)
|
||||
- add test-cases skill
|
||||
- add browser skill
|
||||
- BMADh和Requirements-Driven支持根据语义生成对应的文档 (#82)
|
||||
- update all readme
|
||||
|
||||
#### Installation Scripts
|
||||
- Fixed issue #55: `op_run_command` uses Popen + selectors for real-time streaming output
|
||||
- Fixed issue #56: Display recent errors instead of entire log
|
||||
- Changed module list header from "Enabled" to "Default" to avoid ambiguity
|
||||
## [5.2.4] - 2025-12-16
|
||||
|
||||
#### CI/CD
|
||||
- Removed `.claude/` config file validation step (.github/workflows/ci.yml:45)
|
||||
- Updated version test case from 5.1.0 → 5.2.0 (codeagent-wrapper/main_test.go:23)
|
||||
|
||||
#### Commands & Documentation
|
||||
- Reverted `skills/codex/SKILL.md` to `codex-wrapper` for backward compatibility
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
#### dev-workflow
|
||||
- Replaced Codex skill → codeagent skill throughout
|
||||
- Added UI auto-detection: backend tasks use codex, UI tasks use gemini
|
||||
- Corrected agent name: `develop-doc-generator` → `dev-plan-generator`
|
||||
|
||||
### ⚙️ Configuration & Environment Variables
|
||||
- integrate git-cliff for automated changelog generation
|
||||
|
||||
#### New Environment Variables
|
||||
- `CODEAGENT_SKIP_PERMISSIONS`: Control permission check behavior
|
||||
- Claude backend defaults to `--dangerously-skip-permissions` enabled, set to `true` to disable
|
||||
- Codex/Gemini backends default to permission checks enabled, set to `true` to skip
|
||||
- `CODEAGENT_MAX_PARALLEL_WORKERS`: Parallel task concurrency limit (default: unlimited, recommended: 8, max: 100)
|
||||
- bump version to 5.2.4
|
||||
|
||||
#### Configuration Files
|
||||
- `config.schema.json`: Added `op_merge_json` schema validation
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
### ⚠️ Breaking Changes
|
||||
|
||||
**codex-wrapper → codeagent-wrapper rename**
|
||||
- 防止 Claude backend 无限递归调用
|
||||
|
||||
**Migration**:
|
||||
```bash
|
||||
python3 install.py --module dev --force
|
||||
```
|
||||
- isolate log files per task in parallel mode
|
||||
|
||||
**Backward Compatibility**: `codex-wrapper/main.go` provides compatibility entry point
|
||||
### 💼 Other
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
```bash
|
||||
# Install dev module
|
||||
python3 install.py --module dev
|
||||
- Merge pull request #70 from cexll/fix/prevent-codeagent-infinite-recursion
|
||||
|
||||
# List all modules
|
||||
python3 install.py --list-modules
|
||||
- Merge pull request #69 from cexll/myclaude-master-20251215-073053-338465000
|
||||
|
||||
# Verbose logging mode
|
||||
python3 install.py --module dev --verbose
|
||||
```
|
||||
- update CHANGELOG.md
|
||||
|
||||
### 🧪 Test Results
|
||||
- Merge pull request #65 from cexll/fix/issue-64-buffer-overflow
|
||||
|
||||
✅ **All tests passing**
|
||||
- Overall coverage: 93.4%
|
||||
- Security scan: 0 issues (gosec)
|
||||
- Linting: Pass
|
||||
## [5.2.3] - 2025-12-15
|
||||
|
||||
### 📄 Related PRs & Issues
|
||||
|
||||
- PR #53: Enterprise Workflow with Multi-Backend Support
|
||||
- Issue #55: Installation script execution not visible
|
||||
- Issue #56: Unfriendly error logging on abnormal exit
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
### 👥 Contributors
|
||||
|
||||
- Claude Sonnet 4.5
|
||||
- Claude Opus 4.5
|
||||
- SWE-Agent-Bot
|
||||
- 修复 bufio.Scanner token too long 错误 ([#64](https://github.com/cexll/myclaude/issues/64))
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- change version
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
|
||||
- 同步测试中的版本号至 5.2.3
|
||||
|
||||
## [5.2.2] - 2025-12-13
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
|
||||
- Bump version and clean up documentation
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix codeagent backend claude no auto
|
||||
|
||||
- fix install.py dev fail
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
|
||||
- Fix tests for ClaudeBackend default --dangerously-skip-permissions
|
||||
|
||||
## [5.2.1] - 2025-12-13
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix codeagent claude and gemini root dir
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update readme
|
||||
|
||||
## [5.2.0] - 2025-12-13
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
|
||||
- Update CHANGELOG and remove deprecated test files
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix race condition in stdout parsing
|
||||
|
||||
- add worker limit cap and remove legacy alias
|
||||
|
||||
- use -r flag for gemini backend resume
|
||||
|
||||
- clarify module list shows default state not enabled
|
||||
|
||||
- use -r flag for claude backend resume
|
||||
|
||||
- remove binary artifacts and improve error messages
|
||||
|
||||
- 异常退出时显示最近错误信息
|
||||
|
||||
- op_run_command 实时流式输出
|
||||
|
||||
- 修复权限标志逻辑和版本号测试
|
||||
|
||||
- 重构信号处理逻辑避免重复 nil 检查
|
||||
|
||||
- 移除 .claude 配置文件验证步骤
|
||||
|
||||
- 修复并行执行启动横幅重复打印问题
|
||||
|
||||
- 修复master合并后的编译和测试问题
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge rc/5.2 into master: v5.2.0 release improvements
|
||||
|
||||
- Merge pull request #53 from cexll/rc/5.2
|
||||
|
||||
- remove docs
|
||||
|
||||
- remove docs
|
||||
|
||||
- add prototype prompt skill
|
||||
|
||||
- add prd skill
|
||||
|
||||
- update memory claude
|
||||
|
||||
- remove command gh flow
|
||||
|
||||
- update license
|
||||
|
||||
- Merge branch 'master' into rc/5.2
|
||||
|
||||
- Merge pull request #52 from cexll/fix/parallel-log-path-on-startup
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- remove GitHub workflow related content
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- Complete skills system integration and config cleanup
|
||||
|
||||
- Improve release notes and installation scripts
|
||||
|
||||
- 添加终端日志输出和 verbose 模式
|
||||
|
||||
- 完整多后端支持与安全优化
|
||||
|
||||
- 替换 Codex 为 codeagent 并添加 UI 自动检测
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
|
||||
- 调整文件命名和技能定义
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
|
||||
- 添加 ExtractRecentErrors 单元测试
|
||||
|
||||
## [5.1.4] - 2025-12-09
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 任务启动时立即返回日志文件路径以支持实时调试
|
||||
|
||||
## [5.1.3] - 2025-12-08
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- resolve CI timing race in TestFakeCmdInfra
|
||||
|
||||
## [5.1.2] - 2025-12-08
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 修复channel同步竞态条件和死锁问题
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #51 from cexll/fix/channel-sync-race-conditions
|
||||
|
||||
- change codex-wrapper version
|
||||
|
||||
## [5.1.1] - 2025-12-08
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 增强日志清理的安全性和可靠性
|
||||
|
||||
- resolve data race on forceKillDelay with atomic operations
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #49 from cexll/freespace8/master
|
||||
|
||||
- resolve signal handling conflict preserving testability and Windows support
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
|
||||
- 补充测试覆盖提升至 89.3%
|
||||
|
||||
## [5.1.0] - 2025-12-07
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #45 from Michaelxwb/master
|
||||
|
||||
- 修改windows安装说明
|
||||
|
||||
- 修改打包脚本
|
||||
|
||||
- 支持windows系统的安装
|
||||
|
||||
- Merge pull request #1 from Michaelxwb/feature-win
|
||||
|
||||
- 支持window
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- 添加启动时清理日志的功能和--cleanup标志支持
|
||||
|
||||
- implement enterprise workflow with multi-backend support
|
||||
|
||||
## [5.0.0] - 2025-12-05
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
|
||||
- clarify unit-test coverage levels in requirement questions
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- defer startup log until args parsed
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge branch 'master' of github.com:cexll/myclaude
|
||||
|
||||
- Merge pull request #43 from gurdasnijor/smithery/add-badge
|
||||
|
||||
- Add Smithery badge
|
||||
|
||||
- Merge pull request #42 from freespace8/master
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- rewrite documentation for v5.0 modular architecture
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- feat install.py
|
||||
|
||||
- implement modular installation system
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
|
||||
- remove deprecated plugin modules
|
||||
|
||||
## [4.8.2] - 2025-12-02
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- skip signal test in CI environment
|
||||
|
||||
- make forceKillDelay testable to prevent signal test timeout
|
||||
|
||||
- correct Go version in go.mod from 1.25.3 to 1.21
|
||||
|
||||
- fix codex wrapper async log
|
||||
|
||||
- capture and include stderr in error messages
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #41 from cexll/fix-async-log
|
||||
|
||||
- remove test case 90
|
||||
|
||||
- optimize codex-wrapper
|
||||
|
||||
- Merge branch 'master' into fix-async-log
|
||||
|
||||
## [4.8.1] - 2025-12-01
|
||||
|
||||
|
||||
### 🎨 Styling
|
||||
|
||||
|
||||
- replace emoji with text labels
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- improve --parallel parameter validation and docs
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- remove codex-wrapper bin
|
||||
|
||||
## [4.8.0] - 2025-11-30
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update codex skill dependencies
|
||||
|
||||
## [4.7.3] - 2025-11-29
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 保留日志文件以便程序退出后调试并完善日志输出功能
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #34 from cexll/cce-worktree-master-20251129-111802-997076000
|
||||
|
||||
- update CLAUDE.md and codex skill
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- improve codex skill parameter best practices
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- add session resume support and improve output format
|
||||
|
||||
- add parallel execution support to codex-wrapper
|
||||
|
||||
- add async logging to temp file with lifecycle management
|
||||
|
||||
## [4.7.2] - 2025-11-28
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- improve buffer size and streamline message extraction
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #32 from freespace8/master
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
|
||||
- 增加对超大单行文本和非字符串文本的处理测试
|
||||
|
||||
## [4.7.1] - 2025-11-27
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- optimize dev pipline
|
||||
|
||||
- Merge feat/codex-wrapper: fix repository URLs
|
||||
|
||||
## [4.7] - 2025-11-27
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- update repository URLs to cexll/myclaude
|
||||
|
||||
## [4.7-alpha1] - 2025-11-27
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix marketplace schema validation error in dev-workflow plugin
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #29 from cexll/feat/codex-wrapper
|
||||
|
||||
- Add codex-wrapper Go implementation
|
||||
|
||||
- update readme
|
||||
|
||||
- update readme
|
||||
|
||||
## [4.6] - 2025-11-25
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update dev workflow
|
||||
|
||||
- update dev workflow
|
||||
|
||||
## [4.5] - 2025-11-25
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix codex skill eof
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update dev workflow plugin
|
||||
|
||||
- update readme
|
||||
|
||||
## [4.4] - 2025-11-22
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix codex skill timeout and add more log
|
||||
|
||||
- fix codex skill
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update gemini skills
|
||||
|
||||
- update dev workflow
|
||||
|
||||
- update codex skills model config
|
||||
|
||||
- Merge branch 'master' of github.com:cexll/myclaude
|
||||
|
||||
- Merge pull request #24 from cexll/swe-agent/23-1763544297
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- 支持通过环境变量配置 skills 模型
|
||||
|
||||
## [4.3] - 2025-11-19
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix codex skills running
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update skills plugin
|
||||
|
||||
- update gemini
|
||||
|
||||
- update doc
|
||||
|
||||
- Add Gemini CLI integration skill
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- feat simple dev workflow
|
||||
|
||||
## [4.2.2] - 2025-11-15
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update codex skills
|
||||
|
||||
## [4.2.1] - 2025-11-14
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #21 from Tshoiasc/master
|
||||
|
||||
- Merge branch 'master' into master
|
||||
|
||||
- Change default model to gpt-5.1-codex
|
||||
|
||||
- Enhance codex.py to auto-detect long inputs and switch to stdin mode, improving handling of shell argument issues. Updated build_codex_args to support stdin and added relevant logging for task length warnings.
|
||||
|
||||
## [4.2] - 2025-11-13
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix codex.py wsl run err
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- optimize codex skills
|
||||
|
||||
- Merge branch 'master' of github.com:cexll/myclaude
|
||||
|
||||
- Rename SKILLS.md to SKILL.md
|
||||
|
||||
- optimize codex skills
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- feat codex skills
|
||||
|
||||
## [4.1] - 2025-11-04
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update enhance-prompt.md response
|
||||
|
||||
- update readme
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- 新增 /enhance-prompt 命令并更新所有 README 文档
|
||||
|
||||
## [4.0] - 2025-10-22
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix skills format
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge branch 'master' of github.com:cexll/myclaude
|
||||
|
||||
- Merge pull request #18 from cexll/swe-agent/17-1760969135
|
||||
|
||||
- update requirements clarity
|
||||
|
||||
- update .gitignore
|
||||
|
||||
- Fix #17: Update root marketplace.json to use skills array
|
||||
|
||||
- Fix #17: Convert requirements-clarity to correct plugin directory format
|
||||
|
||||
- Fix #17: Convert requirements-clarity to correct plugin directory format
|
||||
|
||||
- Convert requirements-clarity to plugin format with English prompts
|
||||
|
||||
- Translate requirements-clarity skill to English for plugin compatibility
|
||||
|
||||
- Add requirements-clarity Claude Skill
|
||||
|
||||
- Add requirements clarification command
|
||||
|
||||
- update
|
||||
|
||||
## [3.5] - 2025-10-20
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #15 from cexll/swe-agent/13-1760944712
|
||||
|
||||
- Fix #13: Clean up redundant README files
|
||||
|
||||
- Optimize README structure - Solution A (modular)
|
||||
|
||||
- Merge pull request #14 from cexll/swe-agent/12-1760944588
|
||||
|
||||
- Fix #12: Update Makefile install paths for new directory structure
|
||||
|
||||
## [3.4] - 2025-10-20
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #11 from cexll/swe-agent/10-1760752533
|
||||
|
||||
- Fix marketplace metadata references
|
||||
|
||||
- Fix plugin configuration: rename to marketplace.json and update repository URLs
|
||||
|
||||
- Fix #10: Restructure plugin directories to ensure proper command isolation
|
||||
|
||||
## [3.3] - 2025-10-15
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Update README-zh.md
|
||||
|
||||
- Update README.md
|
||||
|
||||
- Update marketplace.json
|
||||
|
||||
- Update Chinese README with v3.2 plugin system documentation
|
||||
|
||||
- Update README with v3.2 plugin system documentation
|
||||
|
||||
## [3.2] - 2025-10-10
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Add Claude Code plugin system support
|
||||
|
||||
- update readme
|
||||
|
||||
- Add Makefile for quick deployment and update READMEs
|
||||
|
||||
## [3.1] - 2025-09-17
|
||||
|
||||
|
||||
### ◀️ Revert
|
||||
|
||||
|
||||
- revert
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fixed bmad-orchestrator not fund
|
||||
|
||||
- fix bmad
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update bmad review with codex support
|
||||
|
||||
- 优化 BMAD 工作流和代理配置
|
||||
|
||||
- update gpt5
|
||||
|
||||
- support bmad output-style
|
||||
|
||||
- update bmad user guide
|
||||
|
||||
- update bmad readme
|
||||
|
||||
- optimize requirements pilot
|
||||
|
||||
- add use gpt5 codex
|
||||
|
||||
- add bmad pilot
|
||||
|
||||
- sync READMEs with actual commands/agents; remove nonexistent commands; enhance requirements-pilot with testing decision gate and options.
|
||||
|
||||
- Update Chinese README and requirements-pilot command to align with latest workflow
|
||||
|
||||
- update readme
|
||||
|
||||
- update agent
|
||||
|
||||
- update bugfix sub agents
|
||||
|
||||
- Update ask support KISS YAGNI SOLID
|
||||
|
||||
- Add comprehensive documentation and multi-agent workflow system
|
||||
|
||||
- update commands
|
||||
<!-- generated by git-cliff -->
|
||||
|
||||
17
Makefile
17
Makefile
@@ -1,7 +1,7 @@
|
||||
# Claude Code Multi-Agent Workflow System Makefile
|
||||
# Quick deployment for BMAD and Requirements workflows
|
||||
|
||||
.PHONY: help install deploy-bmad deploy-requirements deploy-essentials deploy-advanced deploy-all deploy-commands deploy-agents clean test
|
||||
.PHONY: help install deploy-bmad deploy-requirements deploy-essentials deploy-advanced deploy-all deploy-commands deploy-agents clean test changelog
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -22,6 +22,7 @@ help:
|
||||
@echo " deploy-all - Deploy everything (commands + agents)"
|
||||
@echo " test-bmad - Test BMAD workflow with sample"
|
||||
@echo " test-requirements - Test Requirements workflow with sample"
|
||||
@echo " changelog - Update CHANGELOG.md using git-cliff"
|
||||
@echo " clean - Clean generated artifacts"
|
||||
@echo " help - Show this help message"
|
||||
|
||||
@@ -145,3 +146,17 @@ all: deploy-all
|
||||
version:
|
||||
@echo "Claude Code Multi-Agent Workflow System v3.1"
|
||||
@echo "BMAD + Requirements-Driven Development"
|
||||
|
||||
# Update CHANGELOG.md using git-cliff
|
||||
changelog:
|
||||
@echo "📝 Updating CHANGELOG.md with git-cliff..."
|
||||
@if ! command -v git-cliff > /dev/null 2>&1; then \
|
||||
echo "❌ git-cliff not found. Installing via Homebrew..."; \
|
||||
brew install git-cliff; \
|
||||
fi
|
||||
@git-cliff -o CHANGELOG.md
|
||||
@echo "✅ CHANGELOG.md updated successfully!"
|
||||
@echo ""
|
||||
@echo "Preview the changes:"
|
||||
@echo " git diff CHANGELOG.md"
|
||||
|
||||
|
||||
363
README.md
363
README.md
@@ -1,29 +1,15 @@
|
||||
[中文](README_CN.md) [English](README.md)
|
||||
|
||||
# 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)
|
||||
> AI-powered development automation with multi-backend execution (Codex/Claude/Gemini/OpenCode)
|
||||
|
||||
## 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)
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/cexll/myclaude.git
|
||||
@@ -31,111 +17,23 @@ cd myclaude
|
||||
python3 install.py --install-dir ~/.claude
|
||||
```
|
||||
|
||||
## Workflows Overview
|
||||
## Modules Overview
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
| Module | Description | Documentation |
|
||||
|--------|-------------|---------------|
|
||||
| [do](skills/do/README.md) | **Recommended** - 7-phase feature development with codeagent orchestration | `/do` command |
|
||||
| [dev](dev-workflow/README.md) | Lightweight dev workflow with Codex integration | `/dev` command |
|
||||
| [omo](skills/omo/README.md) | Multi-agent orchestration with intelligent routing | `/omo` command |
|
||||
| [bmad](bmad-agile-workflow/README.md) | BMAD agile workflow with 6 specialized agents | `/bmad-pilot` command |
|
||||
| [requirements](requirements-driven-workflow/README.md) | Lightweight requirements-to-code pipeline | `/requirements-pilot` command |
|
||||
| [essentials](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 |
|
||||
|
||||
## Installation
|
||||
|
||||
### Modular Installation (Recommended)
|
||||
|
||||
```bash
|
||||
# Install all enabled modules (dev + essentials by default)
|
||||
# Install all enabled modules
|
||||
python3 install.py --install-dir ~/.claude
|
||||
|
||||
# Install specific module
|
||||
@@ -144,147 +42,72 @@ python3 install.py --module dev
|
||||
# List available modules
|
||||
python3 install.py --list-modules
|
||||
|
||||
# Force overwrite existing files
|
||||
# Force overwrite
|
||||
python3 install.py --force
|
||||
```
|
||||
|
||||
### 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 |
|
||||
|
||||
### 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:
|
||||
Edit `config.json` to enable/disable modules:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"install_dir": "~/.claude",
|
||||
"modules": {
|
||||
"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"}
|
||||
]
|
||||
}
|
||||
"dev": { "enabled": true },
|
||||
"bmad": { "enabled": false },
|
||||
"requirements": { "enabled": false },
|
||||
"essentials": { "enabled": false },
|
||||
"omo": { "enabled": false },
|
||||
"sparv": { "enabled": false },
|
||||
"do": { "enabled": false },
|
||||
"course": { "enabled": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**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 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` |
|
||||
| Scenario | Recommended |
|
||||
|----------|-------------|
|
||||
| Feature development (default) | `/do` |
|
||||
| Lightweight feature | `/dev` |
|
||||
| Bug investigation + fix | `/omo` |
|
||||
| Large enterprise project | `/bmad-pilot` |
|
||||
| Quick prototype | `/requirements-pilot` |
|
||||
| Simple task | `/code`, `/debug` |
|
||||
|
||||
---
|
||||
## 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 Guide](docs/CODEAGENT-WRAPPER.md)
|
||||
- [Hooks Documentation](docs/HOOKS.md)
|
||||
- [codeagent-wrapper](codeagent-wrapper/README.md)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -292,52 +115,38 @@ setx PATH "%USERPROFILE%\bin;%PATH%"
|
||||
|
||||
**Codex wrapper not found:**
|
||||
```bash
|
||||
# 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
|
||||
|
||||
# Reinstall specific module
|
||||
python3 install.py --module dev --force
|
||||
python3 install.py --module <name> --force
|
||||
```
|
||||
|
||||
---
|
||||
**Backend CLI errors:**
|
||||
```bash
|
||||
which codex && codex --version
|
||||
which claude && claude --version
|
||||
which gemini && gemini --version
|
||||
```
|
||||
|
||||
## Documentation
|
||||
## FAQ
|
||||
|
||||
### Core Guides
|
||||
- **[Architecture Overview](docs/architecture.md)** - System architecture and component design
|
||||
- **[Codeagent-Wrapper Guide](docs/CODEAGENT-WRAPPER.md)** - Multi-backend execution wrapper
|
||||
- **[GitHub Workflow Guide](docs/GITHUB-WORKFLOW.md)** - Issue-to-PR automation
|
||||
- **[Hooks Documentation](docs/HOOKS.md)** - Custom hooks and automation
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| "Unknown event format" | Logging display issue, can be ignored |
|
||||
| Gemini can't read .gitignore files | Remove from .gitignore or use different backend |
|
||||
| `/dev` slow | Check logs, try faster model, use single repo |
|
||||
| Codex permission denied | Set `approval_policy = "never"` in ~/.codex/config.yaml |
|
||||
|
||||
### Additional Resources
|
||||
- **[Enterprise Workflow Ideas](docs/enterprise-workflow-ideas.md)** - Advanced patterns and best practices
|
||||
- **[Installation Log](install.log)** - Installation history and troubleshooting
|
||||
|
||||
---
|
||||
See [GitHub Issues](https://github.com/cexll/myclaude/issues) for more.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0 License - see [LICENSE](LICENSE)
|
||||
AGPL-3.0 - see [LICENSE](LICENSE)
|
||||
|
||||
## Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||
- **Documentation**: [docs/](docs/)
|
||||
|
||||
---
|
||||
|
||||
**Claude Code + Codex = Better Development** - Orchestration meets execution.
|
||||
- [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||
- [Documentation](docs/)
|
||||
|
||||
374
README_CN.md
374
README_CN.md
@@ -2,25 +2,11 @@
|
||||
|
||||
[](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)
|
||||
> AI 驱动的开发自动化 - 多后端执行架构 (Codex/Claude/Gemini/OpenCode)
|
||||
|
||||
## 核心概念:多后端架构
|
||||
|
||||
本系统采用**双智能体架构**与可插拔 AI 后端:
|
||||
|
||||
| 角色 | 智能体 | 职责 |
|
||||
|------|-------|------|
|
||||
| **编排者** | Claude Code | 规划、上下文收集、验证、用户交互 |
|
||||
| **执行者** | codeagent-wrapper | 代码编辑、测试执行(Codex/Claude/Gemini 后端)|
|
||||
|
||||
**为什么分离?**
|
||||
- Claude Code 擅长理解上下文和编排复杂工作流
|
||||
- 专业后端(Codex 擅长代码、Claude 擅长推理、Gemini 擅长原型)专注执行
|
||||
- 通过 `--backend codex|claude|gemini` 匹配模型与任务
|
||||
|
||||
## 快速开始(windows上请在Powershell中执行)
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone https://github.com/cexll/myclaude.git
|
||||
@@ -28,37 +14,125 @@ cd myclaude
|
||||
python3 install.py --install-dir ~/.claude
|
||||
```
|
||||
|
||||
## 工作流概览
|
||||
## 模块概览
|
||||
|
||||
### 1. Dev 工作流(推荐)
|
||||
| 模块 | 描述 | 文档 |
|
||||
|------|------|------|
|
||||
| [do](skills/do/README.md) | **推荐** - 7 阶段功能开发 + codeagent 编排 | `/do` 命令 |
|
||||
| [dev](dev-workflow/README.md) | 轻量级开发工作流 + Codex 集成 | `/dev` 命令 |
|
||||
| [omo](skills/omo/README.md) | 多智能体编排 + 智能路由 | `/omo` 命令 |
|
||||
| [bmad](bmad-agile-workflow/README.md) | BMAD 敏捷工作流 + 6 个专业智能体 | `/bmad-pilot` 命令 |
|
||||
| [requirements](requirements-driven-workflow/README.md) | 轻量级需求到代码流水线 | `/requirements-pilot` 命令 |
|
||||
| [essentials](development-essentials/README.md) | 核心开发命令和工具 | `/code`, `/debug` 等 |
|
||||
| [sparv](skills/sparv/README.md) | SPARV 工作流 (Specify→Plan→Act→Review→Vault) | `/sparv` 命令 |
|
||||
| course | 课程开发(组合 dev + product-requirements + test-cases) | 组合模块 |
|
||||
|
||||
**大多数开发任务的首选工作流。**
|
||||
## 核心架构
|
||||
|
||||
| 角色 | 智能体 | 职责 |
|
||||
|------|-------|------|
|
||||
| **编排者** | Claude Code | 规划、上下文收集、验证 |
|
||||
| **执行者** | codeagent-wrapper | 代码编辑、测试执行(Codex/Claude/Gemini/OpenCode 后端)|
|
||||
|
||||
## 工作流详解
|
||||
|
||||
### do 工作流(推荐)
|
||||
|
||||
7 阶段功能开发,通过 codeagent-wrapper 编排多个智能体。**大多数功能开发任务的首选工作流。**
|
||||
|
||||
```bash
|
||||
/do "添加用户登录功能"
|
||||
```
|
||||
|
||||
**7 阶段:**
|
||||
| 阶段 | 名称 | 目标 |
|
||||
|------|------|------|
|
||||
| 1 | Discovery | 理解需求 |
|
||||
| 2 | Exploration | 映射代码库模式 |
|
||||
| 3 | Clarification | 解决歧义(**强制**)|
|
||||
| 4 | Architecture | 设计实现方案 |
|
||||
| 5 | Implementation | 构建功能(**需审批**)|
|
||||
| 6 | Review | 捕获缺陷 |
|
||||
| 7 | Summary | 记录结果 |
|
||||
|
||||
**智能体:**
|
||||
- `code-explorer` - 代码追踪、架构映射
|
||||
- `code-architect` - 设计方案、文件规划
|
||||
- `code-reviewer` - 代码审查、简化建议
|
||||
- `develop` - 实现代码、运行测试
|
||||
|
||||
---
|
||||
|
||||
### Dev 工作流
|
||||
|
||||
轻量级开发工作流,适合简单功能开发。
|
||||
|
||||
```bash
|
||||
/dev "实现 JWT 用户认证"
|
||||
```
|
||||
|
||||
**6 步流程:**
|
||||
1. **需求澄清** - 交互式问答明确范围
|
||||
2. **Codex 深度分析** - 代码库探索和架构决策
|
||||
3. **开发计划生成** - 结构化任务分解和测试要求
|
||||
4. **并行执行** - Codex 并发执行任务
|
||||
5. **覆盖率验证** - 强制 ≥90% 测试覆盖率
|
||||
6. **完成总结** - 文件变更和覆盖率报告
|
||||
|
||||
**核心特性:**
|
||||
- Claude Code 编排,Codex 执行所有代码变更
|
||||
- 自动任务并行化提升速度
|
||||
- 强制 90% 测试覆盖率门禁
|
||||
- 失败自动回滚
|
||||
|
||||
**适用场景:** 功能开发、重构、带测试的 bug 修复
|
||||
1. 需求澄清 - 交互式问答
|
||||
2. Codex 深度分析 - 代码库探索
|
||||
3. 开发计划生成 - 结构化任务分解
|
||||
4. 并行执行 - Codex 并发执行
|
||||
5. 覆盖率验证 - 强制 ≥90%
|
||||
6. 完成总结 - 报告生成
|
||||
|
||||
---
|
||||
|
||||
### 2. BMAD 敏捷工作流
|
||||
### OmO 多智能体编排器
|
||||
|
||||
**包含 6 个专业智能体的完整企业敏捷方法论。**
|
||||
基于风险信号智能路由任务到专业智能体。
|
||||
|
||||
```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 个专业智能体。
|
||||
|
||||
```bash
|
||||
/bmad-pilot "构建电商结账系统"
|
||||
@@ -69,43 +143,36 @@ python3 install.py --install-dir ~/.claude
|
||||
|-------|------|
|
||||
| Product Owner | 需求与用户故事 |
|
||||
| Architect | 系统设计与技术决策 |
|
||||
| Tech Lead | Sprint 规划与任务分解 |
|
||||
| Scrum Master | Sprint 规划与任务分解 |
|
||||
| Developer | 实现 |
|
||||
| Code Reviewer | 质量保证 |
|
||||
| QA Engineer | 测试与验证 |
|
||||
|
||||
**流程:**
|
||||
```
|
||||
需求 → 架构 → Sprint计划 → 开发 → 审查 → QA
|
||||
↓ ↓ ↓ ↓ ↓ ↓
|
||||
PRD.md DESIGN.md SPRINT.md Code REVIEW.md TEST.md
|
||||
```
|
||||
|
||||
**适用场景:** 大型功能、团队协作、企业项目
|
||||
**审批门:**
|
||||
- PRD 完成后(90+ 分)需用户审批
|
||||
- 架构完成后(90+ 分)需用户审批
|
||||
|
||||
---
|
||||
|
||||
### 3. 需求驱动工作流
|
||||
### 需求驱动工作流
|
||||
|
||||
**轻量级需求到代码流水线。**
|
||||
轻量级需求到代码流水线。
|
||||
|
||||
```bash
|
||||
/requirements-pilot "实现 API 限流"
|
||||
```
|
||||
|
||||
**流程:**
|
||||
1. 带质量评分的需求生成
|
||||
2. 实现规划
|
||||
3. 代码生成
|
||||
4. 审查和测试
|
||||
|
||||
**适用场景:** 快速原型、明确定义的功能
|
||||
**100 分质量评分:**
|
||||
- 功能清晰度:30 分
|
||||
- 技术具体性:25 分
|
||||
- 实现完整性:25 分
|
||||
- 业务上下文:20 分
|
||||
|
||||
---
|
||||
|
||||
### 4. 开发基础命令
|
||||
### 开发基础命令
|
||||
|
||||
**日常编码任务的直接命令。**
|
||||
日常编码任务的直接命令。
|
||||
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
@@ -117,16 +184,12 @@ PRD.md DESIGN.md SPRINT.md Code REVIEW.md TEST.md
|
||||
| `/refactor` | 代码重构 |
|
||||
| `/docs` | 编写文档 |
|
||||
|
||||
**适用场景:** 快速任务,无需工作流开销
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
### 模块化安装(推荐)
|
||||
|
||||
```bash
|
||||
# 安装所有启用的模块(默认:dev + essentials)
|
||||
# 安装所有启用的模块
|
||||
python3 install.py --install-dir ~/.claude
|
||||
|
||||
# 安装特定模块
|
||||
@@ -135,186 +198,77 @@ python3 install.py --module dev
|
||||
# 列出可用模块
|
||||
python3 install.py --list-modules
|
||||
|
||||
# 强制覆盖现有文件
|
||||
# 强制覆盖
|
||||
python3 install.py --force
|
||||
```
|
||||
|
||||
### 可用模块
|
||||
### 模块配置
|
||||
|
||||
| 模块 | 默认 | 描述 |
|
||||
|------|------|------|
|
||||
| `dev` | ✓ 启用 | Dev 工作流 + Codex 集成 |
|
||||
| `essentials` | ✓ 启用 | 核心开发命令 |
|
||||
| `bmad` | 禁用 | 完整 BMAD 敏捷工作流 |
|
||||
| `requirements` | 禁用 | 需求驱动工作流 |
|
||||
|
||||
### 安装内容
|
||||
|
||||
```
|
||||
~/.claude/
|
||||
├── CLAUDE.md # 核心指令和角色定义
|
||||
├── commands/ # 斜杠命令 (/dev, /code 等)
|
||||
├── agents/ # 智能体定义
|
||||
├── skills/
|
||||
│ └── codex/
|
||||
│ └── SKILL.md # Codex 集成技能
|
||||
└── installed_modules.json # 安装状态
|
||||
```
|
||||
|
||||
### 配置
|
||||
|
||||
编辑 `config.json` 自定义:
|
||||
编辑 `config.json` 启用/禁用模块:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"install_dir": "~/.claude",
|
||||
"modules": {
|
||||
"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"}
|
||||
]
|
||||
}
|
||||
"dev": { "enabled": true },
|
||||
"bmad": { "enabled": false },
|
||||
"requirements": { "enabled": false },
|
||||
"essentials": { "enabled": false },
|
||||
"omo": { "enabled": false },
|
||||
"sparv": { "enabled": false },
|
||||
"do": { "enabled": false },
|
||||
"course": { "enabled": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**操作类型:**
|
||||
| 类型 | 描述 |
|
||||
|------|------|
|
||||
| `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%"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流选择指南
|
||||
|
||||
| 场景 | 推荐工作流 |
|
||||
|------|----------|
|
||||
| 带测试的新功能 | `/dev` |
|
||||
| 快速 bug 修复 | `/debug` 或 `/code` |
|
||||
| 大型多 Sprint 功能 | `/bmad-pilot` |
|
||||
| 原型或 POC | `/requirements-pilot` |
|
||||
| 代码审查 | `/review` |
|
||||
| 性能问题 | `/optimize` |
|
||||
| 场景 | 推荐 |
|
||||
|------|------|
|
||||
| 功能开发(默认) | `/do` |
|
||||
| 轻量级功能 | `/dev` |
|
||||
| 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` |
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
**Codex wrapper 未找到:**
|
||||
```bash
|
||||
# 检查 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
|
||||
|
||||
# 重新安装特定模块
|
||||
python3 install.py --module dev --force
|
||||
python3 install.py --module <name> --force
|
||||
```
|
||||
|
||||
---
|
||||
## FAQ
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
|------|----------|
|
||||
| "Unknown event format" | 日志显示问题,可忽略 |
|
||||
| Gemini 无法读取 .gitignore 文件 | 从 .gitignore 移除或使用其他后端 |
|
||||
| `/dev` 执行慢 | 检查日志,尝试更快模型,使用单一仓库 |
|
||||
| Codex 权限拒绝 | 在 ~/.codex/config.yaml 设置 `approval_policy = "never"` |
|
||||
|
||||
更多问题请访问 [GitHub Issues](https://github.com/cexll/myclaude/issues)。
|
||||
|
||||
## 许可证
|
||||
|
||||
AGPL-3.0 License - 查看 [LICENSE](LICENSE)
|
||||
AGPL-3.0 - 查看 [LICENSE](LICENSE)
|
||||
|
||||
## 支持
|
||||
|
||||
- **问题反馈**: [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||
- **文档**: [docs/](docs/)
|
||||
|
||||
---
|
||||
|
||||
**Claude Code + Codex = 更好的开发** - 编排遇见执行。
|
||||
- [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||
- [文档](docs/)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
9
bmad-agile-workflow/.claude-plugin/plugin.json
Normal file
9
bmad-agile-workflow/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
109
bmad-agile-workflow/README.md
Normal file
109
bmad-agile-workflow/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# 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
|
||||
|
||||
```
|
||||
bmad-agile-workflow/
|
||||
├── 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
|
||||
```
|
||||
@@ -427,6 +427,10 @@ Generate architecture document at `./.claude/specs/{feature_name}/02-system-arch
|
||||
|
||||
## Important Behaviors
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, REST, GraphQL, JWT, RBAC, etc.) in English; translate explanatory text only.
|
||||
|
||||
### DO:
|
||||
- Start by reviewing and referencing the PRD
|
||||
- Present initial architecture based on requirements
|
||||
|
||||
@@ -419,6 +419,10 @@ logger.info('User created', {
|
||||
|
||||
## Important Implementation Rules
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, CRUD, JWT, SQL, etc.) in English; translate explanatory text only.
|
||||
|
||||
### DO:
|
||||
- Follow architecture specifications exactly
|
||||
- Implement all acceptance criteria from PRD
|
||||
|
||||
@@ -22,6 +22,10 @@ You are the BMAD Orchestrator. Your core focus is repository analysis, workflow
|
||||
- Consistency: ensure conventions and patterns discovered in scan are preserved downstream
|
||||
- Explicit handoffs: clearly document assumptions, risks, and integration points for other agents
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, PRD, Sprint, etc.) in English; translate explanatory text only.
|
||||
|
||||
## UltraThink Repository Scan
|
||||
|
||||
When asked to analyze the repository, follow this structure and return a clear, actionable summary.
|
||||
|
||||
@@ -313,6 +313,10 @@ Generate PRD at `./.claude/specs/{feature_name}/01-product-requirements.md`:
|
||||
|
||||
## Important Behaviors
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, Sprint, PRD, KPI, MVP, etc.) in English; translate explanatory text only.
|
||||
|
||||
### DO:
|
||||
- Start immediately with greeting and initial understanding
|
||||
- Show quality scores transparently
|
||||
|
||||
@@ -478,6 +478,10 @@ module.exports = {
|
||||
|
||||
## Important Testing Rules
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, E2E, CI/CD, Mock, etc.) in English; translate explanatory text only.
|
||||
|
||||
### DO:
|
||||
- Test all acceptance criteria from PRD
|
||||
- Cover happy path, edge cases, and error scenarios
|
||||
|
||||
@@ -45,3 +45,7 @@ You are an independent code review agent responsible for conducting reviews betw
|
||||
- Focus on actionable findings
|
||||
- Provide specific QA guidance
|
||||
- Use clear, parseable output format
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, PRD, Sprint, etc.) in English; translate explanatory text only.
|
||||
|
||||
@@ -351,6 +351,10 @@ So that [benefit]
|
||||
|
||||
## Important Behaviors
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (Sprint, Epic, Story, Backlog, Velocity, etc.) in English; translate explanatory text only.
|
||||
|
||||
### DO:
|
||||
- Read both PRD and Architecture documents thoroughly
|
||||
- Create comprehensive task breakdown
|
||||
|
||||
72
cliff.toml
Normal file
72
cliff.toml
Normal file
@@ -0,0 +1,72 @@
|
||||
# git-cliff configuration file
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
# changelog header
|
||||
header = """
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
"""
|
||||
# template for the changelog body
|
||||
body = """
|
||||
{% if version %}
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}
|
||||
## Unreleased
|
||||
{% endif %}
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group }}
|
||||
|
||||
{% for commit in commits %}
|
||||
- {{ commit.message | split(pat="\n") | first }}
|
||||
{% endfor -%}
|
||||
{% endfor -%}
|
||||
"""
|
||||
# remove the leading and trailing whitespace from the template
|
||||
trim = true
|
||||
# changelog footer
|
||||
footer = """
|
||||
<!-- generated by git-cliff -->
|
||||
"""
|
||||
|
||||
[git]
|
||||
# parse the commits based on https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# filter out the commits that are not conventional
|
||||
filter_unconventional = false
|
||||
# process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
# regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/cexll/myclaude/issues/${2}))" },
|
||||
]
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "🚀 Features" },
|
||||
{ message = "^fix", group = "🐛 Bug Fixes" },
|
||||
{ message = "^doc", group = "📚 Documentation" },
|
||||
{ message = "^perf", group = "⚡ Performance" },
|
||||
{ message = "^refactor", group = "🚜 Refactor" },
|
||||
{ message = "^style", group = "🎨 Styling" },
|
||||
{ message = "^test", group = "🧪 Testing" },
|
||||
{ message = "^chore\\(release\\):", skip = true },
|
||||
{ message = "^chore", group = "⚙️ Miscellaneous Tasks" },
|
||||
{ body = ".*security", group = "🛡️ Security" },
|
||||
{ message = "^revert", group = "◀️ Revert" },
|
||||
{ message = ".*", group = "💼 Other" },
|
||||
]
|
||||
# protect breaking changes from being skipped due to matching a skipping commit_parser
|
||||
protect_breaking_commits = false
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = false
|
||||
# glob pattern for matching git tags
|
||||
tag_pattern = "v[0-9]*"
|
||||
# regex for skipping tags
|
||||
skip_tags = "v0.1.0-beta.1"
|
||||
# regex for ignoring tags
|
||||
ignore_tags = ""
|
||||
# sort the tags topologically
|
||||
topo_order = false
|
||||
# sort the commits inside sections by oldest/newest order
|
||||
sort_commits = "newest"
|
||||
39
codeagent-wrapper/.github/workflows/ci.yml
vendored
Normal file
39
codeagent-wrapper/.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
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
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
cache: true
|
||||
- name: Test
|
||||
run: make test
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22"
|
||||
cache: true
|
||||
- name: Lint
|
||||
run: make lint
|
||||
|
||||
12
codeagent-wrapper/.gitignore
vendored
12
codeagent-wrapper/.gitignore
vendored
@@ -1,4 +1,7 @@
|
||||
# Build artifacts
|
||||
bin/
|
||||
codeagent
|
||||
codeagent.exe
|
||||
codeagent-wrapper
|
||||
codeagent-wrapper.exe
|
||||
*.test
|
||||
@@ -9,3 +12,12 @@ coverage*.out
|
||||
cover.out
|
||||
cover_*.out
|
||||
coverage.html
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
37
codeagent-wrapper/Makefile
Normal file
37
codeagent-wrapper/Makefile
Normal file
@@ -0,0 +1,37 @@
|
||||
GO ?= go
|
||||
|
||||
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 -o codeagent ./cmd/codeagent
|
||||
$(GO) build -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 ./cmd/codeagent
|
||||
$(GO) install ./cmd/codeagent-wrapper
|
||||
152
codeagent-wrapper/README.md
Normal file
152
codeagent-wrapper/README.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,86 +0,0 @@
|
||||
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"}
|
||||
|
||||
// Only skip permissions when explicitly requested
|
||||
if cfg.SkipPermissions {
|
||||
args = append(args, "--dangerously-skip-permissions")
|
||||
}
|
||||
|
||||
workdir := cfg.WorkDir
|
||||
if workdir == "" {
|
||||
workdir = defaultWorkdir
|
||||
}
|
||||
|
||||
if cfg.Mode == "resume" {
|
||||
if cfg.SessionID != "" {
|
||||
// Claude CLI uses -r <session_id> for resume.
|
||||
args = append(args, "-r", cfg.SessionID)
|
||||
}
|
||||
} else {
|
||||
args = append(args, "-C", workdir)
|
||||
}
|
||||
|
||||
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"}
|
||||
|
||||
workdir := cfg.WorkDir
|
||||
if workdir == "" {
|
||||
workdir = defaultWorkdir
|
||||
}
|
||||
|
||||
if cfg.Mode == "resume" {
|
||||
if cfg.SessionID != "" {
|
||||
args = append(args, "-r", cfg.SessionID)
|
||||
}
|
||||
} else {
|
||||
args = append(args, "-C", workdir)
|
||||
}
|
||||
|
||||
args = append(args, "-p", targetArg)
|
||||
|
||||
return args
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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", "-C", "/repo", "--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", "-C", defaultWorkdir, "--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", "-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", "--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", "-C", "/workspace", "-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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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()
|
||||
}
|
||||
7
codeagent-wrapper/cmd/codeagent/main.go
Normal file
7
codeagent-wrapper/cmd/codeagent/main.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import app "codeagent-wrapper/internal/app"
|
||||
|
||||
func main() {
|
||||
app.Run()
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,871 +0,0 @@
|
||||
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)
|
||||
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) 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
|
||||
}
|
||||
|
||||
var runCodexTaskFn = func(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)
|
||||
}
|
||||
|
||||
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 string) {
|
||||
logger := activeLogger()
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
path := logger.Path()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
startPrintMu.Lock()
|
||||
if !bannerPrinted {
|
||||
fmt.Fprintln(os.Stderr, "=== Starting Parallel Execution ===")
|
||||
bannerPrinted = true
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Task %s: Log: %s\n", taskID, path)
|
||||
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()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resultsCh <- TaskResult{TaskID: ts.ID, ExitCode: 1, Error: fmt.Sprintf("panic: %v", r)}
|
||||
}
|
||||
}()
|
||||
|
||||
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)
|
||||
}()
|
||||
|
||||
ts.Context = ctx
|
||||
printTaskStart(ts.ID)
|
||||
resultsCh <- runCodexTaskFn(ts, timeout)
|
||||
}(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 != "" {
|
||||
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 {
|
||||
result := TaskResult{TaskID: taskSpec.ID}
|
||||
setLogPath := func() {
|
||||
if result.LogPath != "" {
|
||||
return
|
||||
}
|
||||
if logger := activeLogger(); logger != nil {
|
||||
result.LogPath = logger.Path()
|
||||
}
|
||||
}
|
||||
|
||||
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 := activeLogger(); logger != nil {
|
||||
logger.Info(prefixMsg(msg))
|
||||
}
|
||||
}
|
||||
logWarnFn = func(msg string) {
|
||||
if logger := activeLogger(); logger != nil {
|
||||
logger.Warn(prefixMsg(msg))
|
||||
}
|
||||
}
|
||||
logErrorFn = func(msg string) {
|
||||
if logger := activeLogger(); 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 silent && activeLogger() == nil {
|
||||
if l, err := NewLogger(); err == nil {
|
||||
setLogger(l)
|
||||
tempLogger = l
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if tempLogger != nil {
|
||||
_ = closeLogger()
|
||||
}
|
||||
}()
|
||||
defer setLogPath()
|
||||
if logger := activeLogger(); logger != nil {
|
||||
result.LogPath = logger.Path()
|
||||
}
|
||||
|
||||
if !silent {
|
||||
stdoutLogger = newLogWriter("CODEX_STDOUT: ", codexLogLineLimit)
|
||||
stderrLogger = newLogWriter("CODEX_STDERR: ", codexLogLineLimit)
|
||||
}
|
||||
|
||||
ctx := parentCtx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
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...)
|
||||
|
||||
stderrWriters := []io.Writer{stderrBuf}
|
||||
if stderrLogger != nil {
|
||||
stderrWriters = append(stderrWriters, stderrLogger)
|
||||
}
|
||||
if !silent {
|
||||
stderrWriters = append([]io.Writer{os.Stderr}, 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 := activeLogger(); 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 logger := activeLogger(); logger != nil {
|
||||
result.LogPath = logger.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,576 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type execFakeProcess struct {
|
||||
pid int
|
||||
signals []os.Signal
|
||||
killed atomic.Int32
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (p *execFakeProcess) Pid() int { return p.pid }
|
||||
func (p *execFakeProcess) Kill() error {
|
||||
p.killed.Add(1)
|
||||
return nil
|
||||
}
|
||||
func (p *execFakeProcess) Signal(sig os.Signal) error {
|
||||
p.mu.Lock()
|
||||
p.signals = append(p.signals, sig)
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
type writeCloserStub struct {
|
||||
bytes.Buffer
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (w *writeCloserStub) Close() error {
|
||||
w.closed.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
type reasonReadCloser struct {
|
||||
r io.Reader
|
||||
closed []string
|
||||
mu sync.Mutex
|
||||
closedC chan struct{}
|
||||
}
|
||||
|
||||
func newReasonReadCloser(data string) *reasonReadCloser {
|
||||
return &reasonReadCloser{r: strings.NewReader(data), closedC: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (rc *reasonReadCloser) Read(p []byte) (int, error) { return rc.r.Read(p) }
|
||||
func (rc *reasonReadCloser) Close() error { rc.record("close"); return nil }
|
||||
func (rc *reasonReadCloser) CloseWithReason(reason string) error {
|
||||
rc.record(reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rc *reasonReadCloser) record(reason string) {
|
||||
rc.mu.Lock()
|
||||
rc.closed = append(rc.closed, reason)
|
||||
rc.mu.Unlock()
|
||||
select {
|
||||
case rc.closedC <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
type execFakeRunner struct {
|
||||
stdout io.ReadCloser
|
||||
process processHandle
|
||||
stdin io.WriteCloser
|
||||
waitErr error
|
||||
waitDelay time.Duration
|
||||
startErr error
|
||||
stdoutErr error
|
||||
stdinErr error
|
||||
allowNilProcess bool
|
||||
started atomic.Bool
|
||||
}
|
||||
|
||||
func (f *execFakeRunner) Start() error {
|
||||
if f.startErr != nil {
|
||||
return f.startErr
|
||||
}
|
||||
f.started.Store(true)
|
||||
return nil
|
||||
}
|
||||
func (f *execFakeRunner) Wait() error {
|
||||
if f.waitDelay > 0 {
|
||||
time.Sleep(f.waitDelay)
|
||||
}
|
||||
return f.waitErr
|
||||
}
|
||||
func (f *execFakeRunner) StdoutPipe() (io.ReadCloser, error) {
|
||||
if f.stdoutErr != nil {
|
||||
return nil, f.stdoutErr
|
||||
}
|
||||
if f.stdout == nil {
|
||||
f.stdout = io.NopCloser(strings.NewReader(""))
|
||||
}
|
||||
return f.stdout, nil
|
||||
}
|
||||
func (f *execFakeRunner) StdinPipe() (io.WriteCloser, error) {
|
||||
if f.stdinErr != nil {
|
||||
return nil, f.stdinErr
|
||||
}
|
||||
if f.stdin != nil {
|
||||
return f.stdin, nil
|
||||
}
|
||||
return &writeCloserStub{}, nil
|
||||
}
|
||||
func (f *execFakeRunner) SetStderr(io.Writer) {}
|
||||
func (f *execFakeRunner) Process() processHandle {
|
||||
if f.process != nil {
|
||||
return f.process
|
||||
}
|
||||
if f.allowNilProcess {
|
||||
return nil
|
||||
}
|
||||
return &execFakeProcess{pid: 1}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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
|
||||
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}}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("unexpected result: %+v", res)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-firstStdout.closedC:
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatalf("stdout not closed with reason")
|
||||
}
|
||||
|
||||
orig := runCodexTaskFn
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "ok"}
|
||||
}
|
||||
t.Cleanup(func() { runCodexTaskFn = orig })
|
||||
|
||||
if res := runCodexTask(TaskSpec{Task: "task-text", WorkDir: "."}, true, 1); res.ExitCode != 0 {
|
||||
t.Fatalf("runCodexTask failed: %+v", res)
|
||||
}
|
||||
|
||||
msg, threadID, code := runCodexProcess(context.Background(), []string{"arg"}, "content", false, 1)
|
||||
if code != 0 || msg == "" {
|
||||
t.Fatalf("runCodexProcess unexpected result: msg=%q code=%d threadID=%s", msg, code, threadID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("startErrors", func(t *testing.T) {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{startErr: errors.New("executable file not found"), process: &execFakeProcess{pid: 1}}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("timeoutAndPipes", func(t *testing.T) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pipeErrors", func(t *testing.T) {
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return &execFakeRunner{stdoutErr: errors.New("stdout fail"), process: &execFakeProcess{pid: 6}}
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("waitExitError", func(t *testing.T) {
|
||||
err := exec.Command("false").Run()
|
||||
exitErr, _ := err.(*exec.ExitError)
|
||||
if exitErr == nil {
|
||||
t.Fatalf("expected exec.ExitError")
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contextCancelled", func(t *testing.T) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
res := runCodexTaskWithContext(ctx, TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
|
||||
if res.ExitCode == 0 {
|
||||
t.Fatalf("expected cancellation result")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("silentLogger", func(t *testing.T) {
|
||||
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},
|
||||
}
|
||||
}
|
||||
_ = closeLogger()
|
||||
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, true, 1)
|
||||
if res.ExitCode != 0 || res.LogPath == "" {
|
||||
t.Fatalf("expected success with temp logger, got %+v", res)
|
||||
}
|
||||
_ = closeLogger()
|
||||
})
|
||||
|
||||
t.Run("missingMessage", func(t *testing.T) {
|
||||
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},
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,3 +1,43 @@
|
||||
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
|
||||
)
|
||||
|
||||
117
codeagent-wrapper/go.sum
Normal file
117
codeagent-wrapper/go.sum
Normal file
@@ -0,0 +1,117 @@
|
||||
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=
|
||||
150
codeagent-wrapper/internal/app/agent_validation_test.go
Normal file
150
codeagent-wrapper/internal/app/agent_validation_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
278
codeagent-wrapper/internal/app/app.go
Normal file
278
codeagent-wrapper/internal/app/app.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
version = "6.1.2"
|
||||
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 = 100 * 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)
|
||||
}
|
||||
9
codeagent-wrapper/internal/app/backend.go
Normal file
9
codeagent-wrapper/internal/app/backend.go
Normal file
@@ -0,0 +1,9 @@
|
||||
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
|
||||
7
codeagent-wrapper/internal/app/backend_init.go
Normal file
7
codeagent-wrapper/internal/app/backend_init.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package wrapper
|
||||
|
||||
import backend "codeagent-wrapper/internal/backend"
|
||||
|
||||
func init() {
|
||||
backend.SetLogFuncs(logWarn, logError)
|
||||
}
|
||||
5
codeagent-wrapper/internal/app/backend_registry.go
Normal file
5
codeagent-wrapper/internal/app/backend_registry.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package wrapper
|
||||
|
||||
import backend "codeagent-wrapper/internal/backend"
|
||||
|
||||
func selectBackend(name string) (Backend, error) { return backend.Select(name) }
|
||||
103
codeagent-wrapper/internal/app/bench_test.go
Normal file
103
codeagent-wrapper/internal/app/bench_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"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)
|
||||
|
||||
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()
|
||||
}
|
||||
664
codeagent-wrapper/internal/app/cli.go
Normal file
664
codeagent-wrapper/internal/app/cli.go
Normal file
@@ -0,0 +1,664 @@
|
||||
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) {
|
||||
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
|
||||
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, _, _, resolvedYolo = config.ResolveAgentConfig(agentName)
|
||||
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,4 +1,4 @@
|
||||
package main
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -11,8 +11,29 @@ 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() {
|
||||
@@ -76,10 +97,11 @@ func TestConcurrentStressLogger(t *testing.T) {
|
||||
t.Logf("Successfully wrote %d/%d logs (%.1f%%)",
|
||||
actualCount, totalExpected, float64(actualCount)/float64(totalExpected)*100)
|
||||
|
||||
// 验证日志格式
|
||||
formatRE := regexp.MustCompile(`^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] \[PID:\d+\] INFO: goroutine-`)
|
||||
// 验证日志格式(纯文本,无前缀)
|
||||
formatRE := regexp.MustCompile(`^goroutine-\d+-msg-\d+$`)
|
||||
for i, line := range lines[:min(10, len(lines))] {
|
||||
if !formatRE.MatchString(line) {
|
||||
msg := stripTimestampPrefix(line)
|
||||
if !formatRE.MatchString(msg) {
|
||||
t.Errorf("line %d has invalid format: %s", i, line)
|
||||
}
|
||||
}
|
||||
@@ -291,18 +313,15 @@ func TestLoggerOrderPreservation(t *testing.T) {
|
||||
sequences := make(map[int][]int) // goroutine ID -> sequence numbers
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
line := stripTimestampPrefix(scanner.Text())
|
||||
var gid, seq int
|
||||
parts := strings.SplitN(line, " INFO: ", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Errorf("invalid log format: %s", line)
|
||||
// Parse format: G0-SEQ0001 (without INFO: prefix)
|
||||
_, err := fmt.Sscanf(line, "G%d-SEQ%04d", &gid, &seq)
|
||||
if err != nil {
|
||||
t.Errorf("invalid log format: %s (error: %v)", line, err)
|
||||
continue
|
||||
}
|
||||
if _, err := fmt.Sscanf(parts[1], "G%d-SEQ%d", &gid, &seq); err == nil {
|
||||
sequences[gid] = append(sequences[gid], seq)
|
||||
} else {
|
||||
t.Errorf("failed to parse sequence from line: %s", line)
|
||||
}
|
||||
sequences[gid] = append(sequences[gid], seq)
|
||||
}
|
||||
|
||||
// 验证每个 goroutine 内部顺序
|
||||
7
codeagent-wrapper/internal/app/config_alias.go
Normal file
7
codeagent-wrapper/internal/app/config_alias.go
Normal file
@@ -0,0 +1,7 @@
|
||||
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
|
||||
54
codeagent-wrapper/internal/app/executor_alias.go
Normal file
54
codeagent-wrapper/internal/app/executor_alias.go
Normal file
@@ -0,0 +1,54 @@
|
||||
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)
|
||||
}
|
||||
1136
codeagent-wrapper/internal/app/executor_concurrent_test.go
Normal file
1136
codeagent-wrapper/internal/app/executor_concurrent_test.go
Normal file
File diff suppressed because it is too large
Load Diff
26
codeagent-wrapper/internal/app/logger.go
Normal file
26
codeagent-wrapper/internal/app/logger.go
Normal file
@@ -0,0 +1,26 @@
|
||||
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,7 +1,8 @@
|
||||
package main
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"codeagent-wrapper/internal/logger"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -36,7 +37,9 @@ func captureStdout(t *testing.T, fn func()) string {
|
||||
os.Stdout = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
t.Fatalf("io.Copy() error = %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
@@ -46,33 +49,127 @@ func parseIntegrationOutput(t *testing.T, out string) integrationOutput {
|
||||
|
||||
lines := strings.Split(out, "\n")
|
||||
var currentTask *TaskResult
|
||||
inTaskResults := false
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Total:") {
|
||||
|
||||
// Parse new format header: "X tasks | Y passed | Z failed"
|
||||
if strings.Contains(line, "tasks |") && strings.Contains(line, "passed |") {
|
||||
parts := strings.Split(line, "|")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if strings.HasSuffix(p, "tasks") {
|
||||
if _, err := fmt.Sscanf(p, "%d tasks", &payload.Summary.Total); err != nil {
|
||||
t.Fatalf("failed to parse total tasks from %q: %v", p, err)
|
||||
}
|
||||
} else if strings.HasSuffix(p, "passed") {
|
||||
if _, err := fmt.Sscanf(p, "%d passed", &payload.Summary.Success); err != nil {
|
||||
t.Fatalf("failed to parse passed tasks from %q: %v", p, err)
|
||||
}
|
||||
} else if strings.HasSuffix(p, "failed") {
|
||||
if _, err := fmt.Sscanf(p, "%d failed", &payload.Summary.Failed); err != nil {
|
||||
t.Fatalf("failed to parse failed tasks from %q: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(line, "Total:") {
|
||||
// Legacy format: "Total: X | Success: Y | Failed: Z"
|
||||
parts := strings.Split(line, "|")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if strings.HasPrefix(p, "Total:") {
|
||||
fmt.Sscanf(p, "Total: %d", &payload.Summary.Total)
|
||||
if _, err := fmt.Sscanf(p, "Total: %d", &payload.Summary.Total); err != nil {
|
||||
t.Fatalf("failed to parse total tasks from %q: %v", p, err)
|
||||
}
|
||||
} else if strings.HasPrefix(p, "Success:") {
|
||||
fmt.Sscanf(p, "Success: %d", &payload.Summary.Success)
|
||||
if _, err := fmt.Sscanf(p, "Success: %d", &payload.Summary.Success); err != nil {
|
||||
t.Fatalf("failed to parse passed tasks from %q: %v", p, err)
|
||||
}
|
||||
} else if strings.HasPrefix(p, "Failed:") {
|
||||
fmt.Sscanf(p, "Failed: %d", &payload.Summary.Failed)
|
||||
if _, err := fmt.Sscanf(p, "Failed: %d", &payload.Summary.Failed); err != nil {
|
||||
t.Fatalf("failed to parse failed tasks from %q: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if line == "## Task Results" {
|
||||
inTaskResults = true
|
||||
} else if line == "## Summary" {
|
||||
// End of task results section
|
||||
if currentTask != nil {
|
||||
payload.Results = append(payload.Results, *currentTask)
|
||||
currentTask = nil
|
||||
}
|
||||
inTaskResults = false
|
||||
} else if inTaskResults && strings.HasPrefix(line, "### ") {
|
||||
// New task: ### task-id ✓ 92% or ### task-id PASS 92% (ASCII mode)
|
||||
if currentTask != nil {
|
||||
payload.Results = append(payload.Results, *currentTask)
|
||||
}
|
||||
currentTask = &TaskResult{}
|
||||
|
||||
taskLine := strings.TrimPrefix(line, "### ")
|
||||
parseMarker := func(marker string, exitCode int) bool {
|
||||
needle := " " + marker
|
||||
if !strings.Contains(taskLine, needle) {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(taskLine, needle)
|
||||
currentTask.TaskID = strings.TrimSpace(parts[0])
|
||||
currentTask.ExitCode = exitCode
|
||||
if exitCode == 0 && len(parts) > 1 {
|
||||
coveragePart := strings.TrimSpace(parts[1])
|
||||
if strings.HasSuffix(coveragePart, "%") {
|
||||
currentTask.Coverage = coveragePart
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
switch {
|
||||
case parseMarker("✓", 0), parseMarker("PASS", 0):
|
||||
// ok
|
||||
case parseMarker("⚠️", 0), parseMarker("WARN", 0):
|
||||
// warning
|
||||
case parseMarker("✗", 1), parseMarker("FAIL", 1):
|
||||
// fail
|
||||
default:
|
||||
currentTask.TaskID = taskLine
|
||||
}
|
||||
} else if currentTask != nil && inTaskResults {
|
||||
// Parse task details
|
||||
if strings.HasPrefix(line, "Exit code:") {
|
||||
if _, err := fmt.Sscanf(line, "Exit code: %d", ¤tTask.ExitCode); err != nil {
|
||||
t.Fatalf("failed to parse exit code from %q: %v", line, err)
|
||||
}
|
||||
} else if strings.HasPrefix(line, "Error:") {
|
||||
currentTask.Error = strings.TrimPrefix(line, "Error: ")
|
||||
} else if strings.HasPrefix(line, "Log:") {
|
||||
currentTask.LogPath = strings.TrimSpace(strings.TrimPrefix(line, "Log:"))
|
||||
} else if strings.HasPrefix(line, "Did:") {
|
||||
currentTask.KeyOutput = strings.TrimSpace(strings.TrimPrefix(line, "Did:"))
|
||||
} else if strings.HasPrefix(line, "Detail:") {
|
||||
// Error detail for failed tasks
|
||||
if currentTask.Message == "" {
|
||||
currentTask.Message = strings.TrimSpace(strings.TrimPrefix(line, "Detail:"))
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(line, "--- Task:") {
|
||||
// Legacy full output format
|
||||
if currentTask != nil {
|
||||
payload.Results = append(payload.Results, *currentTask)
|
||||
}
|
||||
currentTask = &TaskResult{}
|
||||
currentTask.TaskID = strings.TrimSuffix(strings.TrimPrefix(line, "--- Task: "), " ---")
|
||||
} else if currentTask != nil {
|
||||
} else if currentTask != nil && !inTaskResults {
|
||||
// Legacy format parsing
|
||||
if strings.HasPrefix(line, "Status: SUCCESS") {
|
||||
currentTask.ExitCode = 0
|
||||
} else if strings.HasPrefix(line, "Status: FAILED") {
|
||||
if strings.Contains(line, "exit code") {
|
||||
fmt.Sscanf(line, "Status: FAILED (exit code %d)", ¤tTask.ExitCode)
|
||||
if _, err := fmt.Sscanf(line, "Status: FAILED (exit code %d)", ¤tTask.ExitCode); err != nil {
|
||||
t.Fatalf("failed to parse exit code from %q: %v", line, err)
|
||||
}
|
||||
} else {
|
||||
currentTask.ExitCode = 1
|
||||
}
|
||||
@@ -82,15 +179,11 @@ func parseIntegrationOutput(t *testing.T, out string) integrationOutput {
|
||||
currentTask.SessionID = strings.TrimPrefix(line, "Session: ")
|
||||
} else if strings.HasPrefix(line, "Log:") {
|
||||
currentTask.LogPath = strings.TrimSpace(strings.TrimPrefix(line, "Log:"))
|
||||
} else if line != "" && !strings.HasPrefix(line, "===") && !strings.HasPrefix(line, "---") {
|
||||
if currentTask.Message != "" {
|
||||
currentTask.Message += "\n"
|
||||
}
|
||||
currentTask.Message += line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle last task
|
||||
if currentTask != nil {
|
||||
payload.Results = append(payload.Results, *currentTask)
|
||||
}
|
||||
@@ -98,32 +191,6 @@ func parseIntegrationOutput(t *testing.T, out string) integrationOutput {
|
||||
return payload
|
||||
}
|
||||
|
||||
func extractTaskBlock(t *testing.T, output, taskID string) string {
|
||||
t.Helper()
|
||||
header := fmt.Sprintf("--- Task: %s ---", taskID)
|
||||
lines := strings.Split(output, "\n")
|
||||
var block []string
|
||||
collecting := false
|
||||
for _, raw := range lines {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if !collecting {
|
||||
if trimmed == header {
|
||||
collecting = true
|
||||
block = append(block, trimmed)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "--- Task: ") && trimmed != header {
|
||||
break
|
||||
}
|
||||
block = append(block, trimmed)
|
||||
}
|
||||
if len(block) == 0 {
|
||||
t.Fatalf("task block %s not found in output:\n%s", taskID, output)
|
||||
}
|
||||
return strings.Join(block, "\n")
|
||||
}
|
||||
|
||||
func findResultByID(t *testing.T, payload integrationOutput, id string) TaskResult {
|
||||
t.Helper()
|
||||
for _, res := range payload.Results {
|
||||
@@ -135,6 +202,37 @@ func findResultByID(t *testing.T, payload integrationOutput, id string) TaskResu
|
||||
return TaskResult{}
|
||||
}
|
||||
|
||||
func setTempDirEnv(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
resolved := dir
|
||||
if eval, err := filepath.EvalSymlinks(dir); err == nil {
|
||||
resolved = eval
|
||||
}
|
||||
t.Setenv("TMPDIR", resolved)
|
||||
t.Setenv("TEMP", resolved)
|
||||
t.Setenv("TMP", resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
func createTempLog(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create temp log %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func stubProcessRunning(t *testing.T, fn func(int) bool) {
|
||||
t.Helper()
|
||||
t.Cleanup(logger.SetProcessRunningCheck(fn))
|
||||
}
|
||||
|
||||
func stubProcessStartTime(t *testing.T, fn func(int) time.Time) {
|
||||
t.Helper()
|
||||
t.Cleanup(logger.SetProcessStartTimeFn(fn))
|
||||
}
|
||||
|
||||
func TestRunParallelEndToEnd_OrderAndConcurrency(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
origRun := runCodexTaskFn
|
||||
@@ -320,7 +418,7 @@ id: beta
|
||||
---CONTENT---
|
||||
task-beta`
|
||||
stdinReader = bytes.NewReader([]byte(input))
|
||||
os.Args = []string{"codex-wrapper", "--parallel"}
|
||||
os.Args = []string{"codeagent-wrapper", "--parallel"}
|
||||
|
||||
var exitCode int
|
||||
output := captureStdout(t, func() {
|
||||
@@ -343,9 +441,10 @@ task-beta`
|
||||
}
|
||||
|
||||
for _, id := range []string{"alpha", "beta"} {
|
||||
want := fmt.Sprintf("Log: %s", logPathFor(id))
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("parallel output missing %q for %s:\n%s", want, id, output)
|
||||
// Summary mode shows log paths in table format, not "Log: xxx"
|
||||
logPath := logPathFor(id)
|
||||
if !strings.Contains(output, logPath) {
|
||||
t.Fatalf("parallel output missing log path %q for %s:\n%s", logPath, id, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,9 +471,9 @@ id: d
|
||||
---CONTENT---
|
||||
ok-d`
|
||||
stdinReader = bytes.NewReader([]byte(input))
|
||||
os.Args = []string{"codex-wrapper", "--parallel"}
|
||||
os.Args = []string{"codeagent-wrapper", "--parallel"}
|
||||
|
||||
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
|
||||
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
||||
|
||||
origRun := runCodexTaskFn
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
@@ -426,10 +525,11 @@ ok-d`
|
||||
t.Fatalf("expected startup banner in stderr, got:\n%s", stderrOut)
|
||||
}
|
||||
|
||||
// After parallel log isolation fix, each task has its own log file
|
||||
expectedLines := map[string]struct{}{
|
||||
fmt.Sprintf("Task a: Log: %s", expectedLog): {},
|
||||
fmt.Sprintf("Task b: Log: %s", expectedLog): {},
|
||||
fmt.Sprintf("Task d: Log: %s", expectedLog): {},
|
||||
fmt.Sprintf("Task a: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d-a.log", os.Getpid()))): {},
|
||||
fmt.Sprintf("Task b: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d-b.log", os.Getpid()))): {},
|
||||
fmt.Sprintf("Task d: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d-d.log", os.Getpid()))): {},
|
||||
}
|
||||
|
||||
if len(taskLines) != len(expectedLines) {
|
||||
@@ -447,7 +547,7 @@ func TestRunNonParallelOutputsIncludeLogPathsIntegration(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
os.Args = []string{"codex-wrapper", "integration-log-check"}
|
||||
os.Args = []string{"codeagent-wrapper", "integration-log-check"}
|
||||
stdinReader = strings.NewReader("")
|
||||
isTerminalFn = func() bool { return true }
|
||||
codexCommand = "echo"
|
||||
@@ -465,7 +565,7 @@ func TestRunNonParallelOutputsIncludeLogPathsIntegration(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("run() exit=%d, want 0", exitCode)
|
||||
}
|
||||
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
|
||||
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
||||
wantLine := fmt.Sprintf("Log: %s", expectedLog)
|
||||
if !strings.Contains(stderr, wantLine) {
|
||||
t.Fatalf("stderr missing %q, got: %q", wantLine, stderr)
|
||||
@@ -549,16 +649,16 @@ ok-e`
|
||||
if resD.LogPath != logPathFor("D") || resE.LogPath != logPathFor("E") {
|
||||
t.Fatalf("expected log paths for D/E, got D=%q E=%q", resD.LogPath, resE.LogPath)
|
||||
}
|
||||
// Summary mode shows log paths in table, verify they appear in output
|
||||
for _, id := range []string{"A", "D", "E"} {
|
||||
block := extractTaskBlock(t, output, id)
|
||||
want := fmt.Sprintf("Log: %s", logPathFor(id))
|
||||
if !strings.Contains(block, want) {
|
||||
t.Fatalf("task %s block missing %q:\n%s", id, want, block)
|
||||
logPath := logPathFor(id)
|
||||
if !strings.Contains(output, logPath) {
|
||||
t.Fatalf("task %s log path %q not found in output:\n%s", id, logPath, output)
|
||||
}
|
||||
}
|
||||
blockB := extractTaskBlock(t, output, "B")
|
||||
if strings.Contains(blockB, "Log:") {
|
||||
t.Fatalf("skipped task B should not emit a log line:\n%s", blockB)
|
||||
// Task B was skipped, should have "-" or empty log path in table
|
||||
if resB.LogPath != "" {
|
||||
t.Fatalf("skipped task B should have empty log path, got %q", resB.LogPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,7 +668,6 @@ func TestRunParallelTimeoutPropagation(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
runCodexTaskFn = origRun
|
||||
resetTestHooks()
|
||||
os.Unsetenv("CODEX_TIMEOUT")
|
||||
})
|
||||
|
||||
var receivedTimeout int
|
||||
@@ -577,7 +676,7 @@ func TestRunParallelTimeoutPropagation(t *testing.T) {
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 124, Error: "timeout"}
|
||||
}
|
||||
|
||||
os.Setenv("CODEX_TIMEOUT", "1")
|
||||
t.Setenv("CODEX_TIMEOUT", "1")
|
||||
input := `---TASK---
|
||||
id: T
|
||||
---CONTENT---
|
||||
@@ -647,11 +746,11 @@ func TestRunStartupCleanupRemovesOrphansEndToEnd(t *testing.T) {
|
||||
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
orphanA := createTempLog(t, tempDir, "codex-wrapper-5001.log")
|
||||
orphanB := createTempLog(t, tempDir, "codex-wrapper-5002-extra.log")
|
||||
orphanC := createTempLog(t, tempDir, "codex-wrapper-5003-suffix.log")
|
||||
orphanA := createTempLog(t, tempDir, "codeagent-wrapper-5001.log")
|
||||
orphanB := createTempLog(t, tempDir, "codeagent-wrapper-5002-extra.log")
|
||||
orphanC := createTempLog(t, tempDir, "codeagent-wrapper-5003-suffix.log")
|
||||
runningPID := 81234
|
||||
runningLog := createTempLog(t, tempDir, fmt.Sprintf("codex-wrapper-%d.log", runningPID))
|
||||
runningLog := createTempLog(t, tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", runningPID))
|
||||
unrelated := createTempLog(t, tempDir, "wrapper.log")
|
||||
|
||||
stubProcessRunning(t, func(pid int) bool {
|
||||
@@ -667,7 +766,7 @@ func TestRunStartupCleanupRemovesOrphansEndToEnd(t *testing.T) {
|
||||
codexCommand = createFakeCodexScript(t, "tid-startup", "ok")
|
||||
stdinReader = strings.NewReader("")
|
||||
isTerminalFn = func() bool { return true }
|
||||
os.Args = []string{"codex-wrapper", "task"}
|
||||
os.Args = []string{"codeagent-wrapper", "task"}
|
||||
|
||||
if exit := run(); exit != 0 {
|
||||
t.Fatalf("run() exit=%d, want 0", exit)
|
||||
@@ -693,7 +792,7 @@ func TestRunStartupCleanupConcurrentWrappers(t *testing.T) {
|
||||
|
||||
const totalLogs = 40
|
||||
for i := 0; i < totalLogs; i++ {
|
||||
createTempLog(t, tempDir, fmt.Sprintf("codex-wrapper-%d.log", 9000+i))
|
||||
createTempLog(t, tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", 9000+i))
|
||||
}
|
||||
|
||||
stubProcessRunning(t, func(pid int) bool {
|
||||
@@ -717,7 +816,7 @@ func TestRunStartupCleanupConcurrentWrappers(t *testing.T) {
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
matches, err := filepath.Glob(filepath.Join(tempDir, "codex-wrapper-*.log"))
|
||||
matches, err := filepath.Glob(filepath.Join(tempDir, "codeagent-wrapper-*.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob error: %v", err)
|
||||
}
|
||||
@@ -731,9 +830,9 @@ func TestRunCleanupFlagEndToEnd_Success(t *testing.T) {
|
||||
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
staleA := createTempLog(t, tempDir, "codex-wrapper-2100.log")
|
||||
staleB := createTempLog(t, tempDir, "codex-wrapper-2200-extra.log")
|
||||
keeper := createTempLog(t, tempDir, "codex-wrapper-2300.log")
|
||||
staleA := createTempLog(t, tempDir, "codeagent-wrapper-2100.log")
|
||||
staleB := createTempLog(t, tempDir, "codeagent-wrapper-2200-extra.log")
|
||||
keeper := createTempLog(t, tempDir, "codeagent-wrapper-2300.log")
|
||||
|
||||
stubProcessRunning(t, func(pid int) bool {
|
||||
return pid == 2300 || pid == os.Getpid()
|
||||
@@ -745,7 +844,7 @@ func TestRunCleanupFlagEndToEnd_Success(t *testing.T) {
|
||||
return time.Time{}
|
||||
})
|
||||
|
||||
os.Args = []string{"codex-wrapper", "--cleanup"}
|
||||
os.Args = []string{"codeagent-wrapper", "--cleanup"}
|
||||
|
||||
var exitCode int
|
||||
output := captureStdout(t, func() {
|
||||
@@ -769,10 +868,10 @@ func TestRunCleanupFlagEndToEnd_Success(t *testing.T) {
|
||||
if !strings.Contains(output, "Files kept: 1") {
|
||||
t.Fatalf("missing 'Files kept: 1' in output: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "codex-wrapper-2100.log") || !strings.Contains(output, "codex-wrapper-2200-extra.log") {
|
||||
if !strings.Contains(output, "codeagent-wrapper-2100.log") || !strings.Contains(output, "codeagent-wrapper-2200-extra.log") {
|
||||
t.Fatalf("missing deleted file names in output: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "codex-wrapper-2300.log") {
|
||||
if !strings.Contains(output, "codeagent-wrapper-2300.log") {
|
||||
t.Fatalf("missing kept file names in output: %q", output)
|
||||
}
|
||||
|
||||
@@ -785,7 +884,7 @@ func TestRunCleanupFlagEndToEnd_Success(t *testing.T) {
|
||||
t.Fatalf("expected kept log to remain, err=%v", err)
|
||||
}
|
||||
|
||||
currentLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
|
||||
currentLog := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
||||
if _, err := os.Stat(currentLog); err == nil {
|
||||
t.Fatalf("cleanup mode should not create new log file %s", currentLog)
|
||||
} else if !os.IsNotExist(err) {
|
||||
@@ -804,7 +903,7 @@ func TestRunCleanupFlagEndToEnd_FailureDoesNotAffectStartup(t *testing.T) {
|
||||
return CleanupStats{Scanned: 1}, fmt.Errorf("permission denied")
|
||||
}
|
||||
|
||||
os.Args = []string{"codex-wrapper", "--cleanup"}
|
||||
os.Args = []string{"codeagent-wrapper", "--cleanup"}
|
||||
|
||||
var exitCode int
|
||||
errOutput := captureStderr(t, func() {
|
||||
@@ -821,7 +920,7 @@ func TestRunCleanupFlagEndToEnd_FailureDoesNotAffectStartup(t *testing.T) {
|
||||
t.Fatalf("cleanup called %d times, want 1", calls)
|
||||
}
|
||||
|
||||
currentLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
|
||||
currentLog := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
||||
if _, err := os.Stat(currentLog); err == nil {
|
||||
t.Fatalf("cleanup failure should not create new log file %s", currentLog)
|
||||
} else if !os.IsNotExist(err) {
|
||||
@@ -834,7 +933,7 @@ func TestRunCleanupFlagEndToEnd_FailureDoesNotAffectStartup(t *testing.T) {
|
||||
codexCommand = createFakeCodexScript(t, "tid-cleanup-e2e", "ok")
|
||||
stdinReader = strings.NewReader("")
|
||||
isTerminalFn = func() bool { return true }
|
||||
os.Args = []string{"codex-wrapper", "post-cleanup task"}
|
||||
os.Args = []string{"codeagent-wrapper", "post-cleanup task"}
|
||||
|
||||
var normalExit int
|
||||
normalOutput := captureStdout(t, func() {
|
||||
File diff suppressed because it is too large
Load Diff
9
codeagent-wrapper/internal/app/parallel_config.go
Normal file
9
codeagent-wrapper/internal/app/parallel_config.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
executor "codeagent-wrapper/internal/executor"
|
||||
)
|
||||
|
||||
func parseParallelConfig(data []byte) (*ParallelConfig, error) {
|
||||
return executor.ParseParallelConfig(data)
|
||||
}
|
||||
34
codeagent-wrapper/internal/app/parser.go
Normal file
34
codeagent-wrapper/internal/app/parser.go
Normal file
@@ -0,0 +1,34 @@
|
||||
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) }
|
||||
8
codeagent-wrapper/internal/app/task_types.go
Normal file
8
codeagent-wrapper/internal/app/task_types.go
Normal file
@@ -0,0 +1,8 @@
|
||||
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
|
||||
30
codeagent-wrapper/internal/app/terminal_test.go
Normal file
30
codeagent-wrapper/internal/app/terminal_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
523
codeagent-wrapper/internal/app/utils.go
Normal file
523
codeagent-wrapper/internal/app/utils.go
Normal file
@@ -0,0 +1,523 @@
|
||||
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)
|
||||
}
|
||||
143
codeagent-wrapper/internal/app/utils_test.go
Normal file
143
codeagent-wrapper/internal/app/utils_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
9
codeagent-wrapper/internal/app/wrapper_name.go
Normal file
9
codeagent-wrapper/internal/app/wrapper_name.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package wrapper
|
||||
|
||||
import ilogger "codeagent-wrapper/internal/logger"
|
||||
|
||||
const wrapperName = ilogger.WrapperName
|
||||
|
||||
func currentWrapperName() string { return ilogger.CurrentWrapperName() }
|
||||
|
||||
func primaryLogPrefix() string { return ilogger.PrimaryLogPrefix() }
|
||||
33
codeagent-wrapper/internal/backend/backend.go
Normal file
33
codeagent-wrapper/internal/backend/backend.go
Normal file
@@ -0,0 +1,33 @@
|
||||
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) {}
|
||||
}
|
||||
}
|
||||
322
codeagent-wrapper/internal/backend/backend_test.go
Normal file
322
codeagent-wrapper/internal/backend/backend_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
139
codeagent-wrapper/internal/backend/claude.go
Normal file
139
codeagent-wrapper/internal/backend/claude.go
Normal file
@@ -0,0 +1,139 @@
|
||||
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 != "" {
|
||||
env["ANTHROPIC_AUTH_TOKEN"] = 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
|
||||
}
|
||||
79
codeagent-wrapper/internal/backend/codex.go
Normal file
79
codeagent-wrapper/internal/backend/codex.go
Normal file
@@ -0,0 +1,79 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
110
codeagent-wrapper/internal/backend/gemini.go
Normal file
110
codeagent-wrapper/internal/backend/gemini.go
Normal file
@@ -0,0 +1,110 @@
|
||||
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
|
||||
}
|
||||
29
codeagent-wrapper/internal/backend/opencode.go
Normal file
29
codeagent-wrapper/internal/backend/opencode.go
Normal file
@@ -0,0 +1,29 @@
|
||||
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
|
||||
}
|
||||
29
codeagent-wrapper/internal/backend/registry.go
Normal file
29
codeagent-wrapper/internal/backend/registry.go
Normal file
@@ -0,0 +1,29 @@
|
||||
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)
|
||||
}
|
||||
220
codeagent-wrapper/internal/config/agent.go
Normal file
220
codeagent-wrapper/internal/config/agent.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
ilogger "codeagent-wrapper/internal/logger"
|
||||
|
||||
"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{
|
||||
DefaultBackend: "opencode",
|
||||
DefaultModel: "opencode/grok-code",
|
||||
Agents: map[string]AgentModelConfig{
|
||||
"oracle": {Backend: "claude", Model: "claude-opus-4-5-20251101", PromptFile: "~/.claude/skills/omo/references/oracle.md", Description: "Technical advisor"},
|
||||
"librarian": {Backend: "claude", Model: "claude-sonnet-4-5-20250929", PromptFile: "~/.claude/skills/omo/references/librarian.md", Description: "Researcher"},
|
||||
"explore": {Backend: "opencode", Model: "opencode/grok-code", PromptFile: "~/.claude/skills/omo/references/explore.md", Description: "Code search"},
|
||||
"develop": {Backend: "codex", Model: "", PromptFile: "~/.claude/skills/omo/references/develop.md", Description: "Code development"},
|
||||
"frontend-ui-ux-engineer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md", Description: "Frontend engineer"},
|
||||
"document-writer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/document-writer.md", Description: "Documentation"},
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
modelsConfigOnce sync.Once
|
||||
modelsConfigCached *ModelsConfig
|
||||
)
|
||||
|
||||
func modelsConfig() *ModelsConfig {
|
||||
modelsConfigOnce.Do(func() {
|
||||
modelsConfigCached = loadModelsConfig()
|
||||
})
|
||||
if modelsConfigCached == nil {
|
||||
return &defaultModelsConfig
|
||||
}
|
||||
return modelsConfigCached
|
||||
}
|
||||
|
||||
func loadModelsConfig() *ModelsConfig {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
ilogger.LogWarn(fmt.Sprintf("Failed to resolve home directory for models config: %v; using defaults", err))
|
||||
return &defaultModelsConfig
|
||||
}
|
||||
|
||||
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 &defaultModelsConfig
|
||||
}
|
||||
|
||||
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) {
|
||||
ilogger.LogWarn(fmt.Sprintf("Failed to read models config %s: %v; using defaults", configPath, err))
|
||||
}
|
||||
return &defaultModelsConfig
|
||||
}
|
||||
|
||||
var cfg ModelsConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
ilogger.LogWarn(fmt.Sprintf("Failed to parse models config %s: %v; using defaults", configPath, err))
|
||||
return &defaultModelsConfig
|
||||
}
|
||||
|
||||
cfg.DefaultBackend = strings.TrimSpace(cfg.DefaultBackend)
|
||||
if cfg.DefaultBackend == "" {
|
||||
cfg.DefaultBackend = defaultModelsConfig.DefaultBackend
|
||||
}
|
||||
cfg.DefaultModel = strings.TrimSpace(cfg.DefaultModel)
|
||||
if cfg.DefaultModel == "" {
|
||||
cfg.DefaultModel = defaultModelsConfig.DefaultModel
|
||||
}
|
||||
|
||||
// Merge with defaults
|
||||
for name, agent := range defaultModelsConfig.Agents {
|
||||
if _, exists := cfg.Agents[name]; !exists {
|
||||
if cfg.Agents == nil {
|
||||
cfg.Agents = make(map[string]AgentModelConfig)
|
||||
}
|
||||
cfg.Agents[name] = agent
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 := modelsConfig()
|
||||
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) {
|
||||
cfg := modelsConfig()
|
||||
if agent, ok := cfg.Agents[agentName]; ok {
|
||||
backend = strings.TrimSpace(agent.Backend)
|
||||
if backend == "" {
|
||||
backend = cfg.DefaultBackend
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
return backend, strings.TrimSpace(agent.Model), agent.PromptFile, agent.Reasoning, baseURL, apiKey, agent.Yolo
|
||||
}
|
||||
|
||||
if dynamic, ok := LoadDynamicAgent(agentName); ok {
|
||||
backend = cfg.DefaultBackend
|
||||
model = cfg.DefaultModel
|
||||
backendCfg := resolveBackendConfig(cfg, backend)
|
||||
baseURL = strings.TrimSpace(backendCfg.BaseURL)
|
||||
apiKey = strings.TrimSpace(backendCfg.APIKey)
|
||||
return backend, model, dynamic.PromptFile, "", baseURL, apiKey, false
|
||||
}
|
||||
|
||||
backend = cfg.DefaultBackend
|
||||
model = cfg.DefaultModel
|
||||
backendCfg := resolveBackendConfig(cfg, backend)
|
||||
baseURL = strings.TrimSpace(backendCfg.BaseURL)
|
||||
apiKey = strings.TrimSpace(backendCfg.APIKey)
|
||||
return backend, model, "", "", baseURL, apiKey, false
|
||||
}
|
||||
|
||||
func ResolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool) {
|
||||
return resolveAgentConfig(agentName)
|
||||
}
|
||||
|
||||
func ResetModelsConfigCacheForTest() {
|
||||
modelsConfigCached = nil
|
||||
modelsConfigOnce = sync.Once{}
|
||||
}
|
||||
221
codeagent-wrapper/internal/config/agent_config_test.go
Normal file
221
codeagent-wrapper/internal/config/agent_config_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAgentConfig_Defaults(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
// Test that default agents resolve correctly without config file
|
||||
tests := []struct {
|
||||
agent string
|
||||
wantBackend string
|
||||
wantModel string
|
||||
wantPromptFile string
|
||||
}{
|
||||
{"oracle", "claude", "claude-opus-4-5-20251101", "~/.claude/skills/omo/references/oracle.md"},
|
||||
{"librarian", "claude", "claude-sonnet-4-5-20250929", "~/.claude/skills/omo/references/librarian.md"},
|
||||
{"explore", "opencode", "opencode/grok-code", "~/.claude/skills/omo/references/explore.md"},
|
||||
{"frontend-ui-ux-engineer", "gemini", "", "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md"},
|
||||
{"document-writer", "gemini", "", "~/.claude/skills/omo/references/document-writer.md"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.agent, func(t *testing.T) {
|
||||
backend, model, promptFile, _, _, _, _ := resolveAgentConfig(tt.agent)
|
||||
if backend != tt.wantBackend {
|
||||
t.Errorf("backend = %q, want %q", backend, tt.wantBackend)
|
||||
}
|
||||
if model != tt.wantModel {
|
||||
t.Errorf("model = %q, want %q", model, tt.wantModel)
|
||||
}
|
||||
if promptFile != tt.wantPromptFile {
|
||||
t.Errorf("promptFile = %q, want %q", promptFile, tt.wantPromptFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentConfig_UnknownAgent(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
backend, model, promptFile, _, _, _, _ := resolveAgentConfig("unknown-agent")
|
||||
if backend != "opencode" {
|
||||
t.Errorf("unknown agent backend = %q, want %q", backend, "opencode")
|
||||
}
|
||||
if model != "opencode/grok-code" {
|
||||
t.Errorf("unknown agent model = %q, want %q", model, "opencode/grok-code")
|
||||
}
|
||||
if promptFile != "" {
|
||||
t.Errorf("unknown agent promptFile = %q, want empty", promptFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsConfig_NoFile(t *testing.T) {
|
||||
home := "/nonexistent/path/that/does/not/exist"
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
cfg := loadModelsConfig()
|
||||
if cfg.DefaultBackend != "opencode" {
|
||||
t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "opencode")
|
||||
}
|
||||
if len(cfg.Agents) != 6 {
|
||||
t.Errorf("len(Agents) = %d, want 6", len(cfg.Agents))
|
||||
}
|
||||
}
|
||||
|
||||
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 := loadModelsConfig()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// Check that defaults are merged
|
||||
if _, ok := cfg.Agents["oracle"]; !ok {
|
||||
t.Error("default agent oracle should be merged")
|
||||
}
|
||||
|
||||
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, _ := ResolveAgentConfig("custom-agent")
|
||||
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)
|
||||
}
|
||||
|
||||
backend, model, promptFile, _, _, _, _ := resolveAgentConfig("sarsh")
|
||||
if backend != "opencode" {
|
||||
t.Errorf("backend = %q, want %q", backend, "opencode")
|
||||
}
|
||||
if model != "opencode/grok-code" {
|
||||
t.Errorf("model = %q, want %q", model, "opencode/grok-code")
|
||||
}
|
||||
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()
|
||||
|
||||
cfg := loadModelsConfig()
|
||||
// Should fall back to defaults
|
||||
if cfg.DefaultBackend != "opencode" {
|
||||
t.Errorf("invalid JSON should fallback, got DefaultBackend = %q", cfg.DefaultBackend)
|
||||
}
|
||||
}
|
||||
102
codeagent-wrapper/internal/config/config.go
Normal file
102
codeagent-wrapper/internal/config/config.go
Normal file
@@ -0,0 +1,102 @@
|
||||
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
|
||||
}
|
||||
47
codeagent-wrapper/internal/config/viper.go
Normal file
47
codeagent-wrapper/internal/config/viper.go
Normal file
@@ -0,0 +1,47 @@
|
||||
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
|
||||
}
|
||||
193
codeagent-wrapper/internal/executor/env_inject_test.go
Normal file
193
codeagent-wrapper/internal/executor/env_inject_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// Override HOME to use temp dir
|
||||
oldHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", oldHome)
|
||||
|
||||
// Reset config cache
|
||||
config.ResetModelsConfigCacheForTest()
|
||||
defer config.ResetModelsConfigCacheForTest()
|
||||
|
||||
// Test ResolveAgentConfig
|
||||
agentBackend, model, _, _, baseURL, apiKey, _ := config.ResolveAgentConfig("test-agent")
|
||||
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_AUTH_TOKEN"] != apiKey {
|
||||
t.Errorf("expected ANTHROPIC_AUTH_TOKEN=%q, got %q", apiKey, env["ANTHROPIC_AUTH_TOKEN"])
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
oldHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", oldHome)
|
||||
|
||||
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, _ := config.ResolveAgentConfig(agentName)
|
||||
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_AUTH_TOKEN"]; !ok {
|
||||
t.Error("ANTHROPIC_AUTH_TOKEN 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)
|
||||
}
|
||||
}
|
||||
333
codeagent-wrapper/internal/executor/env_logging_test.go
Normal file
333
codeagent-wrapper/internal/executor/env_logging_test.go
Normal file
@@ -0,0 +1,333 @@
|
||||
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_AUTH_TOKEN",
|
||||
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_AUTH_TOKEN"},
|
||||
},
|
||||
{
|
||||
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_AUTH_TOKEN"},
|
||||
},
|
||||
{
|
||||
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_AUTH_TOKEN"] != strings.TrimSpace(tt.apiKey) {
|
||||
t.Errorf("ANTHROPIC_AUTH_TOKEN = %q, want %q", env["ANTHROPIC_AUTH_TOKEN"], 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_AUTH_TOKEN" {
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
133
codeagent-wrapper/internal/executor/env_stderr_test.go
Normal file
133
codeagent-wrapper/internal/executor/env_stderr_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
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)
|
||||
}
|
||||
|
||||
oldHome := os.Getenv("HOME")
|
||||
if err := os.Setenv("HOME", tmpDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
||||
|
||||
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_AUTH_TOKEN"] != apiKey {
|
||||
t.Fatalf("ANTHROPIC_AUTH_TOKEN=%q, want %q", cmd.env["ANTHROPIC_AUTH_TOKEN"], 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_AUTH_TOKEN=eyJh****test") {
|
||||
t.Fatalf("stderr missing masked API key log; stderr=%q", got)
|
||||
}
|
||||
}
|
||||
1473
codeagent-wrapper/internal/executor/executor.go
Normal file
1473
codeagent-wrapper/internal/executor/executor.go
Normal file
File diff suppressed because it is too large
Load Diff
72
codeagent-wrapper/internal/executor/filter.go
Normal file
72
codeagent-wrapper/internal/executor/filter.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// geminiNoisePatterns contains stderr patterns to filter for gemini backend
|
||||
var geminiNoisePatterns = []string{
|
||||
"[STARTUP]",
|
||||
"Session cleanup disabled",
|
||||
"Warning:",
|
||||
"(node:",
|
||||
"(Use `node --trace-warnings",
|
||||
"Loaded cached credentials",
|
||||
"Loading extension:",
|
||||
"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
|
||||
patterns []string
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func newFilteringWriter(w io.Writer, patterns []string) *filteringWriter {
|
||||
return &filteringWriter{w: w, patterns: patterns}
|
||||
}
|
||||
|
||||
func (f *filteringWriter) Write(p []byte) (n int, err error) {
|
||||
f.buf.Write(p)
|
||||
for {
|
||||
line, err := f.buf.ReadString('\n')
|
||||
if err != nil {
|
||||
// incomplete line, put it back
|
||||
f.buf.WriteString(line)
|
||||
break
|
||||
}
|
||||
if !f.shouldFilter(line) {
|
||||
_, _ = f.w.Write([]byte(line))
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (f *filteringWriter) shouldFilter(line string) bool {
|
||||
for _, pattern := range f.patterns {
|
||||
if strings.Contains(line, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Flush writes any remaining buffered content
|
||||
func (f *filteringWriter) Flush() {
|
||||
if f.buf.Len() > 0 {
|
||||
remaining := f.buf.String()
|
||||
if !f.shouldFilter(remaining) {
|
||||
_, _ = f.w.Write([]byte(remaining))
|
||||
}
|
||||
f.buf.Reset()
|
||||
}
|
||||
}
|
||||
73
codeagent-wrapper/internal/executor/filter_test.go
Normal file
73
codeagent-wrapper/internal/executor/filter_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFilteringWriter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
patterns []string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "filter STARTUP lines",
|
||||
patterns: geminiNoisePatterns,
|
||||
input: "[STARTUP] Recording metric\nHello World\n[STARTUP] Another line\n",
|
||||
want: "Hello World\n",
|
||||
},
|
||||
{
|
||||
name: "filter Warning lines",
|
||||
patterns: geminiNoisePatterns,
|
||||
input: "Warning: something bad\nActual output\n",
|
||||
want: "Actual output\n",
|
||||
},
|
||||
{
|
||||
name: "filter multiple patterns",
|
||||
patterns: geminiNoisePatterns,
|
||||
input: "YOLO mode is enabled\nSession cleanup disabled\nReal content\nLoading extension: foo\n",
|
||||
want: "Real content\n",
|
||||
},
|
||||
{
|
||||
name: "no filtering needed",
|
||||
patterns: geminiNoisePatterns,
|
||||
input: "Line 1\nLine 2\nLine 3\n",
|
||||
want: "Line 1\nLine 2\nLine 3\n",
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
patterns: geminiNoisePatterns,
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
fw := newFilteringWriter(&buf, tt.patterns)
|
||||
_, _ = fw.Write([]byte(tt.input))
|
||||
fw.Flush()
|
||||
|
||||
if got := buf.String(); got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteringWriterPartialLines(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
fw := newFilteringWriter(&buf, geminiNoisePatterns)
|
||||
|
||||
// Write partial line
|
||||
_, _ = fw.Write([]byte("Hello "))
|
||||
_, _ = fw.Write([]byte("World\n"))
|
||||
fw.Flush()
|
||||
|
||||
if got := buf.String(); got != "Hello World\n" {
|
||||
t.Errorf("got %q, want %q", got, "Hello World\n")
|
||||
}
|
||||
}
|
||||
124
codeagent-wrapper/internal/executor/log_helpers.go
Normal file
124
codeagent-wrapper/internal/executor/log_helpers.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package executor
|
||||
|
||||
import "bytes"
|
||||
|
||||
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)
|
||||
}
|
||||
36
codeagent-wrapper/internal/executor/log_writer_limit_test.go
Normal file
36
codeagent-wrapper/internal/executor/log_writer_limit_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLogWriterWriteLimitsBuffer(t *testing.T) {
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogger error: %v", err)
|
||||
}
|
||||
setLogger(logger)
|
||||
t.Cleanup(func() { _ = closeLogger() })
|
||||
|
||||
lw := newLogWriter("P:", 10)
|
||||
_, _ = lw.Write([]byte(strings.Repeat("a", 100)))
|
||||
|
||||
if lw.buf.Len() != 10 {
|
||||
t.Fatalf("logWriter buffer len=%d, want %d", lw.buf.Len(), 10)
|
||||
}
|
||||
if !lw.dropped {
|
||||
t.Fatalf("expected logWriter to drop overlong line bytes")
|
||||
}
|
||||
|
||||
lw.Flush()
|
||||
logger.Flush()
|
||||
data, err := os.ReadFile(logger.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile error: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "P:aaaaaaa...") {
|
||||
t.Fatalf("log output missing truncated entry, got %q", string(data))
|
||||
}
|
||||
}
|
||||
135
codeagent-wrapper/internal/executor/parallel_config.go
Normal file
135
codeagent-wrapper/internal/executor/parallel_config.go
Normal file
@@ -0,0 +1,135 @@
|
||||
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, _, _, _ := config.ResolveAgentConfig(task.Agent)
|
||||
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
|
||||
}
|
||||
130
codeagent-wrapper/internal/executor/prompt.go
Normal file
130
codeagent-wrapper/internal/executor/prompt.go
Normal file
@@ -0,0 +1,130 @@
|
||||
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
|
||||
}
|
||||
186
codeagent-wrapper/internal/executor/prompt_file_test.go
Normal file
186
codeagent-wrapper/internal/executor/prompt_file_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
104
codeagent-wrapper/internal/executor/report_helpers.go
Normal file
104
codeagent-wrapper/internal/executor/report_helpers.go
Normal file
@@ -0,0 +1,104 @@
|
||||
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)
|
||||
}
|
||||
16
codeagent-wrapper/internal/executor/signal_unix.go
Normal file
16
codeagent-wrapper/internal/executor/signal_unix.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//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)
|
||||
}
|
||||
87
codeagent-wrapper/internal/executor/signal_windows.go
Normal file
87
codeagent-wrapper/internal/executor/signal_windows.go
Normal file
@@ -0,0 +1,87 @@
|
||||
//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
|
||||
}
|
||||
15
codeagent-wrapper/internal/executor/stdin.go
Normal file
15
codeagent-wrapper/internal/executor/stdin.go
Normal file
@@ -0,0 +1,15 @@
|
||||
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)
|
||||
}
|
||||
46
codeagent-wrapper/internal/executor/task_types.go
Normal file
46
codeagent-wrapper/internal/executor/task_types.go
Normal file
@@ -0,0 +1,46 @@
|
||||
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
|
||||
}
|
||||
57
codeagent-wrapper/internal/executor/testhooks.go
Normal file
57
codeagent-wrapper/internal/executor/testhooks.go
Normal file
@@ -0,0 +1,57 @@
|
||||
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)
|
||||
}
|
||||
59
codeagent-wrapper/internal/logger/active.go
Normal file
59
codeagent-wrapper/internal/logger/active.go
Normal file
@@ -0,0 +1,59 @@
|
||||
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,10 +1,11 @@
|
||||
package main
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -12,28 +13,35 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// Logger writes log messages asynchronously to a temp file.
|
||||
// It is intentionally minimal: a buffered channel + single worker goroutine
|
||||
// to avoid contention while keeping ordering guarantees.
|
||||
type Logger struct {
|
||||
path string
|
||||
file *os.File
|
||||
writer *bufio.Writer
|
||||
ch chan logEntry
|
||||
flushReq chan chan struct{}
|
||||
done chan struct{}
|
||||
closed atomic.Bool
|
||||
closeOnce sync.Once
|
||||
workerWG sync.WaitGroup
|
||||
pendingWG sync.WaitGroup
|
||||
flushMu sync.Mutex
|
||||
path string
|
||||
file *os.File
|
||||
writer *bufio.Writer
|
||||
zlogger zerolog.Logger
|
||||
ch chan logEntry
|
||||
flushReq chan chan struct{}
|
||||
done chan struct{}
|
||||
closed atomic.Bool
|
||||
closeOnce sync.Once
|
||||
workerWG sync.WaitGroup
|
||||
pendingWG sync.WaitGroup
|
||||
flushMu sync.Mutex
|
||||
workerErr error
|
||||
errorEntries []string // Cache of recent ERROR/WARN entries
|
||||
errorMu sync.Mutex
|
||||
}
|
||||
|
||||
type logEntry struct {
|
||||
level string
|
||||
msg string
|
||||
msg string
|
||||
level zerolog.Level
|
||||
isError bool // true for ERROR or WARN levels
|
||||
}
|
||||
|
||||
// CleanupStats captures the outcome of a cleanupOldLogs run.
|
||||
@@ -55,6 +63,10 @@ var (
|
||||
evalSymlinksFn = filepath.EvalSymlinks
|
||||
)
|
||||
|
||||
const maxLogSuffixLen = 64
|
||||
|
||||
var logSuffixCounter atomic.Uint64
|
||||
|
||||
// NewLogger creates the async logger and starts the worker goroutine.
|
||||
// The log file is created under os.TempDir() using the required naming scheme.
|
||||
func NewLogger() (*Logger, error) {
|
||||
@@ -64,14 +76,23 @@ func NewLogger() (*Logger, error) {
|
||||
// NewLoggerWithSuffix creates a logger with an optional suffix in the filename.
|
||||
// Useful for tests that need isolated log files within the same process.
|
||||
func NewLoggerWithSuffix(suffix string) (*Logger, error) {
|
||||
filename := fmt.Sprintf("%s-%d", primaryLogPrefix(), os.Getpid())
|
||||
pid := os.Getpid()
|
||||
filename := fmt.Sprintf("%s-%d", PrimaryLogPrefix(), pid)
|
||||
var safeSuffix string
|
||||
if suffix != "" {
|
||||
filename += "-" + suffix
|
||||
safeSuffix = sanitizeLogSuffix(suffix)
|
||||
}
|
||||
if safeSuffix != "" {
|
||||
filename += "-" + safeSuffix
|
||||
}
|
||||
filename += ".log"
|
||||
|
||||
path := filepath.Clean(filepath.Join(os.TempDir(), filename))
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -86,12 +107,81 @@ func NewLoggerWithSuffix(suffix string) (*Logger, error) {
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
l.zlogger = zerolog.New(l.writer).With().Timestamp().Logger()
|
||||
|
||||
l.workerWG.Add(1)
|
||||
go l.run()
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func sanitizeLogSuffix(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return fallbackLogSuffix()
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
changed := false
|
||||
for _, r := range trimmed {
|
||||
if isSafeLogRune(r) {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
changed = true
|
||||
b.WriteByte('-')
|
||||
}
|
||||
if b.Len() >= maxLogSuffixLen {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sanitized := strings.Trim(b.String(), "-.")
|
||||
if sanitized != b.String() {
|
||||
changed = true // Mark if trim removed any characters
|
||||
}
|
||||
if sanitized == "" {
|
||||
return fallbackLogSuffix()
|
||||
}
|
||||
|
||||
if changed || len(sanitized) > maxLogSuffixLen {
|
||||
hash := crc32.ChecksumIEEE([]byte(trimmed))
|
||||
hashStr := fmt.Sprintf("%x", hash)
|
||||
|
||||
maxPrefix := maxLogSuffixLen - len(hashStr) - 1
|
||||
if maxPrefix < 1 {
|
||||
maxPrefix = 1
|
||||
}
|
||||
if len(sanitized) > maxPrefix {
|
||||
sanitized = sanitized[:maxPrefix]
|
||||
}
|
||||
|
||||
sanitized = fmt.Sprintf("%s-%s", sanitized, hashStr)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func fallbackLogSuffix() string {
|
||||
next := logSuffixCounter.Add(1)
|
||||
return fmt.Sprintf("task-%d", next)
|
||||
}
|
||||
|
||||
func isSafeLogRune(r rune) bool {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
return true
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return true
|
||||
case r >= '0' && r <= '9':
|
||||
return true
|
||||
case r == '-', r == '_', r == '.':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Path returns the underlying log file path (useful for tests/inspection).
|
||||
func (l *Logger) Path() string {
|
||||
if l == nil {
|
||||
@@ -100,22 +190,30 @@ func (l *Logger) Path() string {
|
||||
return l.path
|
||||
}
|
||||
|
||||
func (l *Logger) IsClosed() bool {
|
||||
if l == nil {
|
||||
return true
|
||||
}
|
||||
return l.closed.Load()
|
||||
}
|
||||
|
||||
// Info logs at INFO level.
|
||||
func (l *Logger) Info(msg string) { l.log("INFO", msg) }
|
||||
func (l *Logger) Info(msg string) { l.logWithLevel(zerolog.InfoLevel, msg) }
|
||||
|
||||
// Warn logs at WARN level.
|
||||
func (l *Logger) Warn(msg string) { l.log("WARN", msg) }
|
||||
func (l *Logger) Warn(msg string) { l.logWithLevel(zerolog.WarnLevel, msg) }
|
||||
|
||||
// Debug logs at DEBUG level.
|
||||
func (l *Logger) Debug(msg string) { l.log("DEBUG", msg) }
|
||||
func (l *Logger) Debug(msg string) { l.logWithLevel(zerolog.DebugLevel, msg) }
|
||||
|
||||
// Error logs at ERROR level.
|
||||
func (l *Logger) Error(msg string) { l.log("ERROR", msg) }
|
||||
func (l *Logger) Error(msg string) { l.logWithLevel(zerolog.ErrorLevel, msg) }
|
||||
|
||||
// Close stops the worker and syncs the log file.
|
||||
// Close signals the worker to flush and close the log file.
|
||||
// The log file is NOT removed, allowing inspection after program exit.
|
||||
// It is safe to call multiple times.
|
||||
// Returns after a 5-second timeout if worker doesn't stop gracefully.
|
||||
// Waits up to CODEAGENT_LOGGER_CLOSE_TIMEOUT_MS (default: 5000) for shutdown; set to 0 to wait indefinitely.
|
||||
// Returns an error if shutdown doesn't complete within the timeout.
|
||||
func (l *Logger) Close() error {
|
||||
if l == nil {
|
||||
return nil
|
||||
@@ -126,42 +224,51 @@ func (l *Logger) Close() error {
|
||||
l.closeOnce.Do(func() {
|
||||
l.closed.Store(true)
|
||||
close(l.done)
|
||||
close(l.ch)
|
||||
|
||||
// Wait for worker with timeout
|
||||
timeout := loggerCloseTimeout()
|
||||
workerDone := make(chan struct{})
|
||||
go func() {
|
||||
l.workerWG.Wait()
|
||||
close(workerDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-workerDone:
|
||||
// Worker stopped gracefully
|
||||
case <-time.After(5 * time.Second):
|
||||
// Worker timeout - proceed with cleanup anyway
|
||||
closeErr = fmt.Errorf("logger worker timeout during close")
|
||||
if timeout > 0 {
|
||||
select {
|
||||
case <-workerDone:
|
||||
// Worker stopped gracefully
|
||||
case <-time.After(timeout):
|
||||
closeErr = fmt.Errorf("logger worker timeout during close")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
<-workerDone
|
||||
}
|
||||
|
||||
if err := l.writer.Flush(); err != nil && closeErr == nil {
|
||||
closeErr = err
|
||||
if l.workerErr != nil && closeErr == nil {
|
||||
closeErr = l.workerErr
|
||||
}
|
||||
|
||||
if err := l.file.Sync(); err != nil && closeErr == nil {
|
||||
closeErr = err
|
||||
}
|
||||
|
||||
if err := l.file.Close(); err != nil && closeErr == nil {
|
||||
closeErr = err
|
||||
}
|
||||
|
||||
// Log file is kept for debugging - NOT removed
|
||||
// Users can manually clean up /tmp/<wrapper>-*.log files
|
||||
})
|
||||
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func loggerCloseTimeout() time.Duration {
|
||||
const defaultTimeout = 5 * time.Second
|
||||
|
||||
raw := strings.TrimSpace(os.Getenv("CODEAGENT_LOGGER_CLOSE_TIMEOUT_MS"))
|
||||
if raw == "" {
|
||||
return defaultTimeout
|
||||
}
|
||||
ms, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return defaultTimeout
|
||||
}
|
||||
if ms <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
|
||||
// RemoveLogFile removes the log file. Should only be called after Close().
|
||||
func (l *Logger) RemoveLogFile() error {
|
||||
if l == nil {
|
||||
@@ -170,34 +277,29 @@ func (l *Logger) RemoveLogFile() error {
|
||||
return os.Remove(l.path)
|
||||
}
|
||||
|
||||
// ExtractRecentErrors reads the log file and returns the most recent ERROR and WARN entries.
|
||||
// ExtractRecentErrors returns the most recent ERROR and WARN entries from memory cache.
|
||||
// Returns up to maxEntries entries in chronological order.
|
||||
func (l *Logger) ExtractRecentErrors(maxEntries int) []string {
|
||||
if l == nil || l.path == "" {
|
||||
if l == nil || maxEntries <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(l.path)
|
||||
if err != nil {
|
||||
l.errorMu.Lock()
|
||||
defer l.errorMu.Unlock()
|
||||
|
||||
if len(l.errorEntries) == 0 {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var entries []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.Contains(line, "] ERROR:") || strings.Contains(line, "] WARN:") {
|
||||
entries = append(entries, line)
|
||||
}
|
||||
// Return last N entries
|
||||
start := 0
|
||||
if len(l.errorEntries) > maxEntries {
|
||||
start = len(l.errorEntries) - maxEntries
|
||||
}
|
||||
|
||||
// Keep only the last maxEntries
|
||||
if len(entries) > maxEntries {
|
||||
entries = entries[len(entries)-maxEntries:]
|
||||
}
|
||||
|
||||
return entries
|
||||
result := make([]string, len(l.errorEntries)-start)
|
||||
copy(result, l.errorEntries[start:])
|
||||
return result
|
||||
}
|
||||
|
||||
// Flush waits for all pending log entries to be written. Primarily for tests.
|
||||
@@ -246,7 +348,7 @@ func (l *Logger) Flush() {
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) log(level, msg string) {
|
||||
func (l *Logger) logWithLevel(entryLevel zerolog.Level, msg string) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
@@ -254,7 +356,8 @@ func (l *Logger) log(level, msg string) {
|
||||
return
|
||||
}
|
||||
|
||||
entry := logEntry{level: level, msg: msg}
|
||||
isError := entryLevel == zerolog.WarnLevel || entryLevel == zerolog.ErrorLevel
|
||||
entry := logEntry{msg: msg, level: entryLevel, isError: isError}
|
||||
l.flushMu.Lock()
|
||||
l.pendingWG.Add(1)
|
||||
l.flushMu.Unlock()
|
||||
@@ -275,18 +378,42 @@ func (l *Logger) run() {
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
writeEntry := func(entry logEntry) {
|
||||
l.zlogger.WithLevel(entry.level).Msg(entry.msg)
|
||||
|
||||
// Cache error/warn entries in memory for fast extraction
|
||||
if entry.isError {
|
||||
l.errorMu.Lock()
|
||||
l.errorEntries = append(l.errorEntries, entry.msg)
|
||||
if len(l.errorEntries) > 100 { // Keep last 100
|
||||
l.errorEntries = l.errorEntries[1:]
|
||||
}
|
||||
l.errorMu.Unlock()
|
||||
}
|
||||
|
||||
l.pendingWG.Done()
|
||||
}
|
||||
|
||||
finalize := func() {
|
||||
if err := l.writer.Flush(); err != nil && l.workerErr == nil {
|
||||
l.workerErr = err
|
||||
}
|
||||
if err := l.file.Sync(); err != nil && l.workerErr == nil {
|
||||
l.workerErr = err
|
||||
}
|
||||
if err := l.file.Close(); err != nil && l.workerErr == nil {
|
||||
l.workerErr = err
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case entry, ok := <-l.ch:
|
||||
if !ok {
|
||||
// Channel closed, final flush
|
||||
_ = l.writer.Flush()
|
||||
finalize()
|
||||
return
|
||||
}
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
|
||||
pid := os.Getpid()
|
||||
fmt.Fprintf(l.writer, "[%s] [PID:%d] %s: %s\n", timestamp, pid, entry.level, entry.msg)
|
||||
l.pendingWG.Done()
|
||||
writeEntry(entry)
|
||||
|
||||
case <-ticker.C:
|
||||
_ = l.writer.Flush()
|
||||
@@ -296,6 +423,21 @@ func (l *Logger) run() {
|
||||
_ = l.writer.Flush()
|
||||
_ = l.file.Sync()
|
||||
close(flushDone)
|
||||
|
||||
case <-l.done:
|
||||
for {
|
||||
select {
|
||||
case entry, ok := <-l.ch:
|
||||
if !ok {
|
||||
finalize()
|
||||
return
|
||||
}
|
||||
writeEntry(entry)
|
||||
default:
|
||||
finalize()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,10 +451,7 @@ func cleanupOldLogs() (CleanupStats, error) {
|
||||
var stats CleanupStats
|
||||
tempDir := os.TempDir()
|
||||
|
||||
prefixes := logPrefixes()
|
||||
if len(prefixes) == 0 {
|
||||
prefixes = []string{defaultWrapperName}
|
||||
}
|
||||
prefixes := LogPrefixes()
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var matches []string
|
||||
@@ -343,7 +482,8 @@ func cleanupOldLogs() (CleanupStats, error) {
|
||||
stats.Kept++
|
||||
stats.KeptFiles = append(stats.KeptFiles, filename)
|
||||
if reason != "" {
|
||||
logWarn(fmt.Sprintf("cleanupOldLogs: skipping %s: %s", filename, reason))
|
||||
// Use Debug level to avoid polluting Recent Errors with cleanup noise
|
||||
logDebug(fmt.Sprintf("cleanupOldLogs: skipping %s: %s", filename, reason))
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -461,10 +601,7 @@ func isPIDReused(logPath string, pid int) bool {
|
||||
if procStartTime.IsZero() {
|
||||
// Can't determine process start time
|
||||
// Check if file is very old (>7 days), likely from a dead process
|
||||
if time.Since(fileModTime) > 7*24*time.Hour {
|
||||
return true // File is old enough to be from a different process
|
||||
}
|
||||
return false // Be conservative for recent files
|
||||
return time.Since(fileModTime) > 7*24*time.Hour
|
||||
}
|
||||
|
||||
// If the log file was modified before the process started, PID was reused
|
||||
@@ -474,10 +611,7 @@ func isPIDReused(logPath string, pid int) bool {
|
||||
|
||||
func parsePIDFromLog(path string) (int, bool) {
|
||||
name := filepath.Base(path)
|
||||
prefixes := logPrefixes()
|
||||
if len(prefixes) == 0 {
|
||||
prefixes = []string{defaultWrapperName}
|
||||
}
|
||||
prefixes := LogPrefixes()
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
prefixWithDash := fmt.Sprintf("%s-", prefix)
|
||||
@@ -531,3 +665,19 @@ func renderWorkerLimit(limit int) string {
|
||||
}
|
||||
return strconv.Itoa(limit)
|
||||
}
|
||||
|
||||
func CleanupOldLogs() (CleanupStats, error) { return cleanupOldLogs() }
|
||||
|
||||
func IsUnsafeFile(path string, tempDir string) (bool, string) { return isUnsafeFile(path, tempDir) }
|
||||
|
||||
func IsPIDReused(logPath string, pid int) bool { return isPIDReused(logPath, pid) }
|
||||
|
||||
func ParsePIDFromLog(path string) (int, bool) { return parsePIDFromLog(path) }
|
||||
|
||||
func LogConcurrencyPlanning(limit, total int) { logConcurrencyPlanning(limit, total) }
|
||||
|
||||
func LogConcurrencyState(event, taskID string, active, limit int) {
|
||||
logConcurrencyState(event, taskID, active, limit)
|
||||
}
|
||||
|
||||
func SanitizeLogSuffix(raw string) string { return sanitizeLogSuffix(raw) }
|
||||
@@ -0,0 +1,158 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoggerNilReceiverNoop(t *testing.T) {
|
||||
var logger *Logger
|
||||
logger.Info("info")
|
||||
logger.Warn("warn")
|
||||
logger.Debug("debug")
|
||||
logger.Error("error")
|
||||
logger.Flush()
|
||||
if err := logger.Close(); err != nil {
|
||||
t.Fatalf("Close() on nil logger should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerConcurrencyLogHelpers(t *testing.T) {
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
logger, err := NewLoggerWithSuffix("concurrency")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithSuffix error: %v", err)
|
||||
}
|
||||
setLogger(logger)
|
||||
defer func() { _ = closeLogger() }()
|
||||
|
||||
logConcurrencyPlanning(0, 2)
|
||||
logConcurrencyPlanning(3, 2)
|
||||
logConcurrencyState("start", "task-1", 1, 0)
|
||||
logConcurrencyState("done", "task-1", 0, 3)
|
||||
logger.Flush()
|
||||
|
||||
data, err := os.ReadFile(logger.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read log file: %v", err)
|
||||
}
|
||||
output := string(data)
|
||||
|
||||
checks := []string{
|
||||
"parallel: worker_limit=unbounded total_tasks=2",
|
||||
"parallel: worker_limit=3 total_tasks=2",
|
||||
"parallel: start task=task-1 active=1 limit=unbounded",
|
||||
"parallel: done task=task-1 active=0 limit=3",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(output, c) {
|
||||
t.Fatalf("log output missing %q, got: %s", c, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerConcurrencyLogHelpersNoopWithoutActiveLogger(t *testing.T) {
|
||||
_ = closeLogger()
|
||||
logConcurrencyPlanning(1, 1)
|
||||
logConcurrencyState("start", "task-1", 0, 1)
|
||||
}
|
||||
|
||||
func TestLoggerCleanupOldLogsSkipsUnsafeAndHandlesAlreadyDeleted(t *testing.T) {
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
unsafePath := createTempLog(t, tempDir, fmt.Sprintf("%s-%d.log", PrimaryLogPrefix(), 222))
|
||||
orphanPath := createTempLog(t, tempDir, fmt.Sprintf("%s-%d.log", PrimaryLogPrefix(), 111))
|
||||
|
||||
stubFileStat(t, func(path string) (os.FileInfo, error) {
|
||||
if path == unsafePath {
|
||||
return fakeFileInfo{mode: os.ModeSymlink}, nil
|
||||
}
|
||||
return os.Lstat(path)
|
||||
})
|
||||
|
||||
stubProcessRunning(t, func(pid int) bool {
|
||||
if pid == 111 {
|
||||
_ = os.Remove(orphanPath)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
stats, err := cleanupOldLogs()
|
||||
if err != nil {
|
||||
t.Fatalf("cleanupOldLogs() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if stats.Scanned != 2 {
|
||||
t.Fatalf("scanned = %d, want %d", stats.Scanned, 2)
|
||||
}
|
||||
if stats.Deleted != 0 {
|
||||
t.Fatalf("deleted = %d, want %d", stats.Deleted, 0)
|
||||
}
|
||||
if stats.Kept != 2 {
|
||||
t.Fatalf("kept = %d, want %d", stats.Kept, 2)
|
||||
}
|
||||
if stats.Errors != 0 {
|
||||
t.Fatalf("errors = %d, want %d", stats.Errors, 0)
|
||||
}
|
||||
|
||||
hasSkip := false
|
||||
hasAlreadyDeleted := false
|
||||
for _, name := range stats.KeptFiles {
|
||||
if strings.Contains(name, "already deleted") {
|
||||
hasAlreadyDeleted = true
|
||||
}
|
||||
if strings.Contains(name, filepath.Base(unsafePath)) {
|
||||
hasSkip = true
|
||||
}
|
||||
}
|
||||
if !hasSkip {
|
||||
t.Fatalf("expected kept files to include unsafe log %q, got %+v", filepath.Base(unsafePath), stats.KeptFiles)
|
||||
}
|
||||
if !hasAlreadyDeleted {
|
||||
t.Fatalf("expected kept files to include already deleted marker, got %+v", stats.KeptFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerIsUnsafeFileErrorPaths(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
t.Run("stat ErrNotExist", func(t *testing.T) {
|
||||
stubFileStat(t, func(string) (os.FileInfo, error) {
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
|
||||
unsafe, reason := isUnsafeFile("missing.log", tempDir)
|
||||
if !unsafe || reason != "" {
|
||||
t.Fatalf("expected missing file to be skipped silently, got unsafe=%v reason=%q", unsafe, reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stat error", func(t *testing.T) {
|
||||
stubFileStat(t, func(string) (os.FileInfo, error) {
|
||||
return nil, fmt.Errorf("boom")
|
||||
})
|
||||
|
||||
unsafe, reason := isUnsafeFile("broken.log", tempDir)
|
||||
if !unsafe || !strings.Contains(reason, "stat failed") {
|
||||
t.Fatalf("expected stat failure to be unsafe, got unsafe=%v reason=%q", unsafe, reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EvalSymlinks error", func(t *testing.T) {
|
||||
stubFileStat(t, func(string) (os.FileInfo, error) {
|
||||
return fakeFileInfo{}, nil
|
||||
})
|
||||
stubEvalSymlinks(t, func(string) (string, error) {
|
||||
return "", fmt.Errorf("resolve failed")
|
||||
})
|
||||
|
||||
unsafe, reason := isUnsafeFile("cannot-resolve.log", tempDir)
|
||||
if !unsafe || !strings.Contains(reason, "path resolution failed") {
|
||||
t.Fatalf("expected resolution failure to be unsafe, got unsafe=%v reason=%q", unsafe, reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
115
codeagent-wrapper/internal/logger/logger_suffix_test.go
Normal file
115
codeagent-wrapper/internal/logger/logger_suffix_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoggerWithSuffixNamingAndIsolation(t *testing.T) {
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
taskA := "task-1"
|
||||
taskB := "task-2"
|
||||
|
||||
loggerA, err := NewLoggerWithSuffix(taskA)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithSuffix(%q) error = %v", taskA, err)
|
||||
}
|
||||
defer loggerA.Close()
|
||||
|
||||
loggerB, err := NewLoggerWithSuffix(taskB)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithSuffix(%q) error = %v", taskB, err)
|
||||
}
|
||||
defer loggerB.Close()
|
||||
|
||||
wantA := filepath.Join(tempDir, fmt.Sprintf("%s-%d-%s.log", PrimaryLogPrefix(), os.Getpid(), taskA))
|
||||
if loggerA.Path() != wantA {
|
||||
t.Fatalf("loggerA path = %q, want %q", loggerA.Path(), wantA)
|
||||
}
|
||||
|
||||
wantB := filepath.Join(tempDir, fmt.Sprintf("%s-%d-%s.log", PrimaryLogPrefix(), os.Getpid(), taskB))
|
||||
if loggerB.Path() != wantB {
|
||||
t.Fatalf("loggerB path = %q, want %q", loggerB.Path(), wantB)
|
||||
}
|
||||
|
||||
if loggerA.Path() == loggerB.Path() {
|
||||
t.Fatalf("expected different log files, got %q", loggerA.Path())
|
||||
}
|
||||
|
||||
loggerA.Info("from taskA")
|
||||
loggerB.Info("from taskB")
|
||||
loggerA.Flush()
|
||||
loggerB.Flush()
|
||||
|
||||
dataA, err := os.ReadFile(loggerA.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read loggerA file: %v", err)
|
||||
}
|
||||
dataB, err := os.ReadFile(loggerB.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read loggerB file: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(dataA), "from taskA") {
|
||||
t.Fatalf("loggerA missing its message, got: %q", string(dataA))
|
||||
}
|
||||
if strings.Contains(string(dataA), "from taskB") {
|
||||
t.Fatalf("loggerA contains loggerB message, got: %q", string(dataA))
|
||||
}
|
||||
if !strings.Contains(string(dataB), "from taskB") {
|
||||
t.Fatalf("loggerB missing its message, got: %q", string(dataB))
|
||||
}
|
||||
if strings.Contains(string(dataB), "from taskA") {
|
||||
t.Fatalf("loggerB contains loggerA message, got: %q", string(dataB))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerWithSuffixReturnsErrorWhenTempDirNotWritable(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
noWrite := filepath.Join(base, "ro")
|
||||
if err := os.Mkdir(noWrite, 0o500); err != nil {
|
||||
t.Fatalf("failed to create read-only temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(noWrite, 0o700) })
|
||||
setTempDirEnv(t, noWrite)
|
||||
|
||||
logger, err := NewLoggerWithSuffix("task-err")
|
||||
if err == nil {
|
||||
_ = logger.Close()
|
||||
t.Fatalf("expected error when temp dir is not writable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerWithSuffixSanitizesUnsafeSuffix(t *testing.T) {
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
raw := "../bad id/with?chars"
|
||||
safe := sanitizeLogSuffix(raw)
|
||||
if safe == "" {
|
||||
t.Fatalf("sanitizeLogSuffix returned empty string")
|
||||
}
|
||||
if strings.ContainsAny(safe, "/\\") {
|
||||
t.Fatalf("sanitized suffix should not contain path separators, got %q", safe)
|
||||
}
|
||||
|
||||
logger, err := NewLoggerWithSuffix(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithSuffix(%q) error = %v", raw, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = logger.Close()
|
||||
_ = os.Remove(logger.Path())
|
||||
})
|
||||
|
||||
wantBase := fmt.Sprintf("%s-%d-%s.log", PrimaryLogPrefix(), os.Getpid(), safe)
|
||||
if gotBase := filepath.Base(logger.Path()); gotBase != wantBase {
|
||||
t.Fatalf("log filename = %q, want %q", gotBase, wantBase)
|
||||
}
|
||||
if dir := filepath.Dir(logger.Path()); dir != tempDir {
|
||||
t.Fatalf("logger path dir = %q, want %q", dir, tempDir)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -26,7 +25,7 @@ func compareCleanupStats(got, want CleanupStats) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func TestRunLoggerCreatesFileWithPID(t *testing.T) {
|
||||
func TestLoggerCreatesFileWithPID(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
|
||||
@@ -46,7 +45,7 @@ func TestRunLoggerCreatesFileWithPID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLoggerWritesLevels(t *testing.T) {
|
||||
func TestLoggerWritesLevels(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
|
||||
@@ -69,7 +68,7 @@ func TestRunLoggerWritesLevels(t *testing.T) {
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
checks := []string{"INFO: info message", "WARN: warn message", "DEBUG: debug message", "ERROR: error message"}
|
||||
checks := []string{"info message", "warn message", "debug message", "error message"}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(content, c) {
|
||||
t.Fatalf("log file missing entry %q, content: %s", c, content)
|
||||
@@ -77,7 +76,7 @@ func TestRunLoggerWritesLevels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLoggerCloseRemovesFileAndStopsWorker(t *testing.T) {
|
||||
func TestLoggerCloseStopsWorkerAndKeepsFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
|
||||
@@ -102,21 +101,9 @@ func TestRunLoggerCloseRemovesFileAndStopsWorker(t *testing.T) {
|
||||
|
||||
// Clean up manually for test
|
||||
defer os.Remove(logPath)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
logger.workerWG.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatalf("worker goroutine did not exit after Close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLoggerConcurrentWritesSafe(t *testing.T) {
|
||||
func TestLoggerConcurrentWritesSafe(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
|
||||
@@ -165,50 +152,13 @@ func TestRunLoggerConcurrentWritesSafe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLoggerTerminateProcessActive(t *testing.T) {
|
||||
cmd := exec.Command("sleep", "5")
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Skipf("cannot start sleep command: %v", err)
|
||||
}
|
||||
|
||||
timer := terminateProcess(&realCmd{cmd: cmd})
|
||||
if timer == nil {
|
||||
t.Fatalf("terminateProcess returned nil timer for active process")
|
||||
}
|
||||
defer timer.Stop()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatalf("process not terminated promptly")
|
||||
case <-done:
|
||||
}
|
||||
|
||||
// Force the timer callback to run immediately to cover the kill branch.
|
||||
timer.Reset(0)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestRunTerminateProcessNil(t *testing.T) {
|
||||
if timer := terminateProcess(nil); timer != nil {
|
||||
t.Fatalf("terminateProcess(nil) should return nil timer")
|
||||
}
|
||||
if timer := terminateProcess(&realCmd{cmd: &exec.Cmd{}}); timer != nil {
|
||||
t.Fatalf("terminateProcess with nil process should return nil timer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCleanupOldLogsRemovesOrphans(t *testing.T) {
|
||||
func TestLoggerCleanupOldLogsRemovesOrphans(t *testing.T) {
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
orphan1 := createTempLog(t, tempDir, "codex-wrapper-111.log")
|
||||
orphan2 := createTempLog(t, tempDir, "codex-wrapper-222-suffix.log")
|
||||
running1 := createTempLog(t, tempDir, "codex-wrapper-333.log")
|
||||
running2 := createTempLog(t, tempDir, "codex-wrapper-444-extra-info.log")
|
||||
orphan1 := createTempLog(t, tempDir, "codeagent-wrapper-111.log")
|
||||
orphan2 := createTempLog(t, tempDir, "codeagent-wrapper-222-suffix.log")
|
||||
running1 := createTempLog(t, tempDir, "codeagent-wrapper-333.log")
|
||||
running2 := createTempLog(t, tempDir, "codeagent-wrapper-444-extra-info.log")
|
||||
untouched := createTempLog(t, tempDir, "unrelated.log")
|
||||
|
||||
runningPIDs := map[int]bool{333: true, 444: true}
|
||||
@@ -252,19 +202,19 @@ func TestRunCleanupOldLogsRemovesOrphans(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCleanupOldLogsHandlesInvalidNamesAndErrors(t *testing.T) {
|
||||
func TestLoggerCleanupOldLogsHandlesInvalidNamesAndErrors(t *testing.T) {
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
invalid := []string{
|
||||
"codex-wrapper-.log",
|
||||
"codex-wrapper.log",
|
||||
"codex-wrapper-foo-bar.txt",
|
||||
"codeagent-wrapper-.log",
|
||||
"codeagent-wrapper.log",
|
||||
"codeagent-wrapper-foo-bar.txt",
|
||||
"not-a-codex.log",
|
||||
}
|
||||
for _, name := range invalid {
|
||||
createTempLog(t, tempDir, name)
|
||||
}
|
||||
target := createTempLog(t, tempDir, "codex-wrapper-555-extra.log")
|
||||
target := createTempLog(t, tempDir, "codeagent-wrapper-555-extra.log")
|
||||
|
||||
var checked []int
|
||||
stubProcessRunning(t, func(pid int) bool {
|
||||
@@ -310,7 +260,7 @@ func TestRunCleanupOldLogsHandlesInvalidNamesAndErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCleanupOldLogsHandlesGlobFailures(t *testing.T) {
|
||||
func TestLoggerCleanupOldLogsHandlesGlobFailures(t *testing.T) {
|
||||
stubProcessRunning(t, func(pid int) bool {
|
||||
t.Fatalf("process check should not run when glob fails")
|
||||
return false
|
||||
@@ -336,7 +286,7 @@ func TestRunCleanupOldLogsHandlesGlobFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCleanupOldLogsEmptyDirectoryStats(t *testing.T) {
|
||||
func TestLoggerCleanupOldLogsEmptyDirectoryStats(t *testing.T) {
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
stubProcessRunning(t, func(int) bool {
|
||||
@@ -356,12 +306,12 @@ func TestRunCleanupOldLogsEmptyDirectoryStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCleanupOldLogsHandlesTempDirPermissionErrors(t *testing.T) {
|
||||
func TestLoggerCleanupOldLogsHandlesTempDirPermissionErrors(t *testing.T) {
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
paths := []string{
|
||||
createTempLog(t, tempDir, "codex-wrapper-6100.log"),
|
||||
createTempLog(t, tempDir, "codex-wrapper-6101.log"),
|
||||
createTempLog(t, tempDir, "codeagent-wrapper-6100.log"),
|
||||
createTempLog(t, tempDir, "codeagent-wrapper-6101.log"),
|
||||
}
|
||||
|
||||
stubProcessRunning(t, func(int) bool { return false })
|
||||
@@ -396,11 +346,11 @@ func TestRunCleanupOldLogsHandlesTempDirPermissionErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCleanupOldLogsHandlesPermissionDeniedFile(t *testing.T) {
|
||||
func TestLoggerCleanupOldLogsHandlesPermissionDeniedFile(t *testing.T) {
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
protected := createTempLog(t, tempDir, "codex-wrapper-6200.log")
|
||||
deletable := createTempLog(t, tempDir, "codex-wrapper-6201.log")
|
||||
protected := createTempLog(t, tempDir, "codeagent-wrapper-6200.log")
|
||||
deletable := createTempLog(t, tempDir, "codeagent-wrapper-6201.log")
|
||||
|
||||
stubProcessRunning(t, func(int) bool { return false })
|
||||
stubProcessStartTime(t, func(int) time.Time { return time.Time{} })
|
||||
@@ -433,13 +383,13 @@ func TestRunCleanupOldLogsHandlesPermissionDeniedFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCleanupOldLogsPerformanceBound(t *testing.T) {
|
||||
func TestLoggerCleanupOldLogsPerformanceBound(t *testing.T) {
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
const fileCount = 400
|
||||
fakePaths := make([]string, fileCount)
|
||||
for i := 0; i < fileCount; i++ {
|
||||
name := fmt.Sprintf("codex-wrapper-%d.log", 10000+i)
|
||||
name := fmt.Sprintf("codeagent-wrapper-%d.log", 10000+i)
|
||||
fakePaths[i] = createTempLog(t, tempDir, name)
|
||||
}
|
||||
|
||||
@@ -476,21 +426,11 @@ func TestRunCleanupOldLogsPerformanceBound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCleanupOldLogsCoverageSuite(t *testing.T) {
|
||||
TestBackendParseJSONStream_CoverageSuite(t)
|
||||
}
|
||||
|
||||
// Reuse the existing coverage suite so the focused TestLogger run still exercises
|
||||
// the rest of the codebase and keeps coverage high.
|
||||
func TestRunLoggerCoverageSuite(t *testing.T) {
|
||||
TestBackendParseJSONStream_CoverageSuite(t)
|
||||
}
|
||||
|
||||
func TestRunCleanupOldLogsKeepsCurrentProcessLog(t *testing.T) {
|
||||
func TestLoggerCleanupOldLogsKeepsCurrentProcessLog(t *testing.T) {
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
currentPID := os.Getpid()
|
||||
currentLog := createTempLog(t, tempDir, fmt.Sprintf("codex-wrapper-%d.log", currentPID))
|
||||
currentLog := createTempLog(t, tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", currentPID))
|
||||
|
||||
stubProcessRunning(t, func(pid int) bool {
|
||||
if pid != currentPID {
|
||||
@@ -518,7 +458,7 @@ func TestRunCleanupOldLogsKeepsCurrentProcessLog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPIDReusedScenarios(t *testing.T) {
|
||||
func TestLoggerIsPIDReusedScenarios(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -552,7 +492,7 @@ func TestIsPIDReusedScenarios(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUnsafeFileSecurityChecks(t *testing.T) {
|
||||
func TestLoggerIsUnsafeFileSecurityChecks(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
absTempDir, err := filepath.Abs(tempDir)
|
||||
if err != nil {
|
||||
@@ -566,7 +506,7 @@ func TestIsUnsafeFileSecurityChecks(t *testing.T) {
|
||||
stubEvalSymlinks(t, func(path string) (string, error) {
|
||||
return filepath.Join(absTempDir, filepath.Base(path)), nil
|
||||
})
|
||||
unsafe, reason := isUnsafeFile(filepath.Join(absTempDir, "codex-wrapper-1.log"), tempDir)
|
||||
unsafe, reason := isUnsafeFile(filepath.Join(absTempDir, "codeagent-wrapper-1.log"), tempDir)
|
||||
if !unsafe || reason != "refusing to delete symlink" {
|
||||
t.Fatalf("expected symlink to be rejected, got unsafe=%v reason=%q", unsafe, reason)
|
||||
}
|
||||
@@ -592,26 +532,32 @@ func TestIsUnsafeFileSecurityChecks(t *testing.T) {
|
||||
})
|
||||
otherDir := t.TempDir()
|
||||
stubEvalSymlinks(t, func(string) (string, error) {
|
||||
return filepath.Join(otherDir, "codex-wrapper-9.log"), nil
|
||||
return filepath.Join(otherDir, "codeagent-wrapper-9.log"), nil
|
||||
})
|
||||
unsafe, reason := isUnsafeFile(filepath.Join(otherDir, "codex-wrapper-9.log"), tempDir)
|
||||
unsafe, reason := isUnsafeFile(filepath.Join(otherDir, "codeagent-wrapper-9.log"), tempDir)
|
||||
if !unsafe || reason != "file is outside tempDir" {
|
||||
t.Fatalf("expected outside file to be rejected, got unsafe=%v reason=%q", unsafe, reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunLoggerPathAndRemove(t *testing.T) {
|
||||
func TestLoggerPathAndRemove(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
path := filepath.Join(tempDir, "sample.log")
|
||||
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
|
||||
logger, err := NewLoggerWithSuffix("sample")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
|
||||
}
|
||||
path := logger.Path()
|
||||
if path == "" {
|
||||
_ = logger.Close()
|
||||
t.Fatalf("logger.Path() returned empty path")
|
||||
}
|
||||
if err := logger.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
|
||||
logger := &Logger{path: path}
|
||||
if got := logger.Path(); got != path {
|
||||
t.Fatalf("Path() = %q, want %q", got, path)
|
||||
}
|
||||
if err := logger.RemoveLogFile(); err != nil {
|
||||
t.Fatalf("RemoveLogFile() error = %v", err)
|
||||
}
|
||||
@@ -628,45 +574,20 @@ func TestRunLoggerPathAndRemove(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLoggerInternalLog(t *testing.T) {
|
||||
logger := &Logger{
|
||||
ch: make(chan logEntry, 1),
|
||||
done: make(chan struct{}),
|
||||
pendingWG: sync.WaitGroup{},
|
||||
}
|
||||
|
||||
done := make(chan logEntry, 1)
|
||||
go func() {
|
||||
entry := <-logger.ch
|
||||
logger.pendingWG.Done()
|
||||
done <- entry
|
||||
}()
|
||||
|
||||
logger.log("INFO", "hello")
|
||||
entry := <-done
|
||||
if entry.level != "INFO" || entry.msg != "hello" {
|
||||
t.Fatalf("unexpected entry %+v", entry)
|
||||
}
|
||||
|
||||
logger.closed.Store(true)
|
||||
logger.log("INFO", "ignored")
|
||||
close(logger.done)
|
||||
}
|
||||
|
||||
func TestRunParsePIDFromLog(t *testing.T) {
|
||||
func TestLoggerParsePIDFromLog(t *testing.T) {
|
||||
hugePID := strconv.FormatInt(math.MaxInt64, 10) + "0"
|
||||
tests := []struct {
|
||||
name string
|
||||
pid int
|
||||
ok bool
|
||||
}{
|
||||
{"codex-wrapper-123.log", 123, true},
|
||||
{"codex-wrapper-999-extra.log", 999, true},
|
||||
{"codex-wrapper-.log", 0, false},
|
||||
{"codeagent-wrapper-123.log", 123, true},
|
||||
{"codeagent-wrapper-999-extra.log", 999, true},
|
||||
{"codeagent-wrapper-.log", 0, false},
|
||||
{"invalid-name.log", 0, false},
|
||||
{"codex-wrapper--5.log", 0, false},
|
||||
{"codex-wrapper-0.log", 0, false},
|
||||
{fmt.Sprintf("codex-wrapper-%s.log", hugePID), 0, false},
|
||||
{"codeagent-wrapper--5.log", 0, false},
|
||||
{"codeagent-wrapper-0.log", 0, false},
|
||||
{fmt.Sprintf("codeagent-wrapper-%s.log", hugePID), 0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -705,56 +626,32 @@ func setTempDirEnv(t *testing.T, dir string) string {
|
||||
|
||||
func stubProcessRunning(t *testing.T, fn func(int) bool) {
|
||||
t.Helper()
|
||||
original := processRunningCheck
|
||||
processRunningCheck = fn
|
||||
t.Cleanup(func() {
|
||||
processRunningCheck = original
|
||||
})
|
||||
t.Cleanup(SetProcessRunningCheck(fn))
|
||||
}
|
||||
|
||||
func stubProcessStartTime(t *testing.T, fn func(int) time.Time) {
|
||||
t.Helper()
|
||||
original := processStartTimeFn
|
||||
processStartTimeFn = fn
|
||||
t.Cleanup(func() {
|
||||
processStartTimeFn = original
|
||||
})
|
||||
t.Cleanup(SetProcessStartTimeFn(fn))
|
||||
}
|
||||
|
||||
func stubRemoveLogFile(t *testing.T, fn func(string) error) {
|
||||
t.Helper()
|
||||
original := removeLogFileFn
|
||||
removeLogFileFn = fn
|
||||
t.Cleanup(func() {
|
||||
removeLogFileFn = original
|
||||
})
|
||||
t.Cleanup(SetRemoveLogFileFn(fn))
|
||||
}
|
||||
|
||||
func stubGlobLogFiles(t *testing.T, fn func(string) ([]string, error)) {
|
||||
t.Helper()
|
||||
original := globLogFiles
|
||||
globLogFiles = fn
|
||||
t.Cleanup(func() {
|
||||
globLogFiles = original
|
||||
})
|
||||
t.Cleanup(SetGlobLogFilesFn(fn))
|
||||
}
|
||||
|
||||
func stubFileStat(t *testing.T, fn func(string) (os.FileInfo, error)) {
|
||||
t.Helper()
|
||||
original := fileStatFn
|
||||
fileStatFn = fn
|
||||
t.Cleanup(func() {
|
||||
fileStatFn = original
|
||||
})
|
||||
t.Cleanup(SetFileStatFn(fn))
|
||||
}
|
||||
|
||||
func stubEvalSymlinks(t *testing.T, fn func(string) (string, error)) {
|
||||
t.Helper()
|
||||
original := evalSymlinksFn
|
||||
evalSymlinksFn = fn
|
||||
t.Cleanup(func() {
|
||||
evalSymlinksFn = original
|
||||
})
|
||||
t.Cleanup(SetEvalSymlinksFn(fn))
|
||||
}
|
||||
|
||||
type fakeFileInfo struct {
|
||||
@@ -769,69 +666,93 @@ func (f fakeFileInfo) ModTime() time.Time { return f.modTime }
|
||||
func (f fakeFileInfo) IsDir() bool { return false }
|
||||
func (f fakeFileInfo) Sys() interface{} { return nil }
|
||||
|
||||
func TestExtractRecentErrors(t *testing.T) {
|
||||
func TestLoggerExtractRecentErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
logs []struct{ level, msg string }
|
||||
maxEntries int
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty log",
|
||||
content: "",
|
||||
logs: nil,
|
||||
maxEntries: 10,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no errors",
|
||||
content: `[2025-01-01 12:00:00.000] [PID:123] INFO: started
|
||||
[2025-01-01 12:00:01.000] [PID:123] DEBUG: processing`,
|
||||
logs: []struct{ level, msg string }{
|
||||
{"INFO", "started"},
|
||||
{"DEBUG", "processing"},
|
||||
},
|
||||
maxEntries: 10,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single error",
|
||||
content: `[2025-01-01 12:00:00.000] [PID:123] INFO: started
|
||||
[2025-01-01 12:00:01.000] [PID:123] ERROR: something failed`,
|
||||
logs: []struct{ level, msg string }{
|
||||
{"INFO", "started"},
|
||||
{"ERROR", "something failed"},
|
||||
},
|
||||
maxEntries: 10,
|
||||
want: []string{"[2025-01-01 12:00:01.000] [PID:123] ERROR: something failed"},
|
||||
want: []string{"something failed"},
|
||||
},
|
||||
{
|
||||
name: "error and warn",
|
||||
content: `[2025-01-01 12:00:00.000] [PID:123] INFO: started
|
||||
[2025-01-01 12:00:01.000] [PID:123] WARN: warning message
|
||||
[2025-01-01 12:00:02.000] [PID:123] ERROR: error message`,
|
||||
logs: []struct{ level, msg string }{
|
||||
{"INFO", "started"},
|
||||
{"WARN", "warning message"},
|
||||
{"ERROR", "error message"},
|
||||
},
|
||||
maxEntries: 10,
|
||||
want: []string{
|
||||
"[2025-01-01 12:00:01.000] [PID:123] WARN: warning message",
|
||||
"[2025-01-01 12:00:02.000] [PID:123] ERROR: error message",
|
||||
"warning message",
|
||||
"error message",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "truncate to max",
|
||||
content: `[2025-01-01 12:00:00.000] [PID:123] ERROR: error 1
|
||||
[2025-01-01 12:00:01.000] [PID:123] ERROR: error 2
|
||||
[2025-01-01 12:00:02.000] [PID:123] ERROR: error 3
|
||||
[2025-01-01 12:00:03.000] [PID:123] ERROR: error 4
|
||||
[2025-01-01 12:00:04.000] [PID:123] ERROR: error 5`,
|
||||
logs: []struct{ level, msg string }{
|
||||
{"ERROR", "error 1"},
|
||||
{"ERROR", "error 2"},
|
||||
{"ERROR", "error 3"},
|
||||
{"ERROR", "error 4"},
|
||||
{"ERROR", "error 5"},
|
||||
},
|
||||
maxEntries: 3,
|
||||
want: []string{
|
||||
"[2025-01-01 12:00:02.000] [PID:123] ERROR: error 3",
|
||||
"[2025-01-01 12:00:03.000] [PID:123] ERROR: error 4",
|
||||
"[2025-01-01 12:00:04.000] [PID:123] ERROR: error 5",
|
||||
"error 3",
|
||||
"error 4",
|
||||
"error 5",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
logPath := filepath.Join(tempDir, "test.log")
|
||||
if err := os.WriteFile(logPath, []byte(tt.content), 0o644); err != nil {
|
||||
t.Fatalf("failed to write test log: %v", err)
|
||||
logger, err := NewLoggerWithSuffix("extract-test")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
|
||||
}
|
||||
defer logger.Close()
|
||||
defer func() { _ = logger.RemoveLogFile() }()
|
||||
|
||||
// Write logs using logger methods
|
||||
for _, entry := range tt.logs {
|
||||
switch entry.level {
|
||||
case "INFO":
|
||||
logger.Info(entry.msg)
|
||||
case "WARN":
|
||||
logger.Warn(entry.msg)
|
||||
case "ERROR":
|
||||
logger.Error(entry.msg)
|
||||
case "DEBUG":
|
||||
logger.Debug(entry.msg)
|
||||
}
|
||||
}
|
||||
|
||||
logger := &Logger{path: logPath}
|
||||
logger.Flush()
|
||||
|
||||
got := logger.ExtractRecentErrors(tt.maxEntries)
|
||||
|
||||
if len(got) != len(tt.want) {
|
||||
@@ -846,23 +767,137 @@ func TestExtractRecentErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRecentErrorsNilLogger(t *testing.T) {
|
||||
func TestLoggerExtractRecentErrorsNilLogger(t *testing.T) {
|
||||
var logger *Logger
|
||||
if got := logger.ExtractRecentErrors(10); got != nil {
|
||||
t.Fatalf("nil logger ExtractRecentErrors() should return nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRecentErrorsEmptyPath(t *testing.T) {
|
||||
logger := &Logger{path: ""}
|
||||
func TestLoggerExtractRecentErrorsEmptyPath(t *testing.T) {
|
||||
logger := &Logger{}
|
||||
if got := logger.ExtractRecentErrors(10); got != nil {
|
||||
t.Fatalf("empty path ExtractRecentErrors() should return nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRecentErrorsFileNotExist(t *testing.T) {
|
||||
logger := &Logger{path: "/nonexistent/path/to/log.log"}
|
||||
func TestLoggerExtractRecentErrorsFileNotExist(t *testing.T) {
|
||||
logger := &Logger{}
|
||||
if got := logger.ExtractRecentErrors(10); got != nil {
|
||||
t.Fatalf("nonexistent file ExtractRecentErrors() should return nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLogSuffixNoDuplicates(t *testing.T) {
|
||||
testCases := []string{
|
||||
"task",
|
||||
"task.",
|
||||
".task",
|
||||
"-task",
|
||||
"task-",
|
||||
"--task--",
|
||||
"..task..",
|
||||
}
|
||||
|
||||
seen := make(map[string]string)
|
||||
for _, input := range testCases {
|
||||
result := sanitizeLogSuffix(input)
|
||||
if result == "" {
|
||||
t.Fatalf("sanitizeLogSuffix(%q) returned empty string", input)
|
||||
}
|
||||
|
||||
if prev, exists := seen[result]; exists {
|
||||
t.Fatalf("collision detected: %q and %q both produce %q", input, prev, result)
|
||||
}
|
||||
seen[result] = input
|
||||
|
||||
// Verify result is safe for file names
|
||||
if strings.ContainsAny(result, "/\\:*?\"<>|") {
|
||||
t.Fatalf("sanitizeLogSuffix(%q) = %q contains unsafe characters", input, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRecentErrorsBoundaryCheck(t *testing.T) {
|
||||
logger, err := NewLoggerWithSuffix("boundary-test")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
|
||||
}
|
||||
defer logger.Close()
|
||||
defer func() { _ = logger.RemoveLogFile() }()
|
||||
|
||||
// Write some errors
|
||||
logger.Error("error 1")
|
||||
logger.Warn("warn 1")
|
||||
logger.Error("error 2")
|
||||
logger.Flush()
|
||||
|
||||
// Test zero
|
||||
result := logger.ExtractRecentErrors(0)
|
||||
if result != nil {
|
||||
t.Fatalf("ExtractRecentErrors(0) should return nil, got %v", result)
|
||||
}
|
||||
|
||||
// Test negative
|
||||
result = logger.ExtractRecentErrors(-5)
|
||||
if result != nil {
|
||||
t.Fatalf("ExtractRecentErrors(-5) should return nil, got %v", result)
|
||||
}
|
||||
|
||||
// Test positive still works
|
||||
result = logger.ExtractRecentErrors(10)
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("ExtractRecentErrors(10) expected 3 entries, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorEntriesMaxLimit(t *testing.T) {
|
||||
logger, err := NewLoggerWithSuffix("max-limit-test")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
|
||||
}
|
||||
defer logger.Close()
|
||||
defer func() { _ = logger.RemoveLogFile() }()
|
||||
|
||||
// Write 150 error/warn entries
|
||||
for i := 1; i <= 150; i++ {
|
||||
if i%2 == 0 {
|
||||
logger.Error(fmt.Sprintf("error-%03d", i))
|
||||
} else {
|
||||
logger.Warn(fmt.Sprintf("warn-%03d", i))
|
||||
}
|
||||
}
|
||||
logger.Flush()
|
||||
|
||||
// Extract all cached errors
|
||||
result := logger.ExtractRecentErrors(200) // Request more than cache size
|
||||
|
||||
// Should only have last 100 entries (entries 51-150 in sequence)
|
||||
if len(result) != 100 {
|
||||
t.Fatalf("expected 100 cached entries, got %d", len(result))
|
||||
}
|
||||
|
||||
// Verify entries are the last 100 (entries 51-150)
|
||||
if !strings.Contains(result[0], "051") {
|
||||
t.Fatalf("first cached entry should be entry 51, got: %s", result[0])
|
||||
}
|
||||
if !strings.Contains(result[99], "150") {
|
||||
t.Fatalf("last cached entry should be entry 150, got: %s", result[99])
|
||||
}
|
||||
|
||||
// Verify order is preserved - simplified logic
|
||||
for i := 0; i < len(result)-1; i++ {
|
||||
expectedNum := 51 + i
|
||||
nextNum := 51 + i + 1
|
||||
|
||||
expectedEntry := fmt.Sprintf("%03d", expectedNum)
|
||||
nextEntry := fmt.Sprintf("%03d", nextNum)
|
||||
|
||||
if !strings.Contains(result[i], expectedEntry) {
|
||||
t.Fatalf("entry at index %d should contain %s, got: %s", i, expectedEntry, result[i])
|
||||
}
|
||||
if !strings.Contains(result[i+1], nextEntry) {
|
||||
t.Fatalf("entry at index %d should contain %s, got: %s", i+1, nextEntry, result[i+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
63
codeagent-wrapper/internal/logger/process_check.go
Normal file
63
codeagent-wrapper/internal/logger/process_check.go
Normal file
@@ -0,0 +1,63 @@
|
||||
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) }
|
||||
112
codeagent-wrapper/internal/logger/process_check_test.go
Normal file
112
codeagent-wrapper/internal/logger/process_check_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
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")
|
||||
}
|
||||
67
codeagent-wrapper/internal/logger/testhooks.go
Normal file
67
codeagent-wrapper/internal/logger/testhooks.go
Normal file
@@ -0,0 +1,67 @@
|
||||
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 }
|
||||
}
|
||||
13
codeagent-wrapper/internal/logger/wrapper_name.go
Normal file
13
codeagent-wrapper/internal/logger/wrapper_name.go
Normal file
@@ -0,0 +1,13 @@
|
||||
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 }
|
||||
74
codeagent-wrapper/internal/parser/event.go
Normal file
74
codeagent-wrapper/internal/parser/event.go
Normal file
@@ -0,0 +1,74 @@
|
||||
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"`
|
||||
}
|
||||
436
codeagent-wrapper/internal/parser/parser.go
Normal file
436
codeagent-wrapper/internal/parser/parser.go
Normal file
@@ -0,0 +1,436 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
const (
|
||||
jsonLineReaderSize = 64 * 1024
|
||||
jsonLineMaxBytes = 10 * 1024 * 1024
|
||||
jsonLinePreviewBytes = 256
|
||||
)
|
||||
|
||||
type lineScratch struct {
|
||||
buf []byte
|
||||
preview []byte
|
||||
}
|
||||
|
||||
const maxPooledLineScratchCap = 1 << 20 // 1 MiB
|
||||
|
||||
var lineScratchPool = sync.Pool{
|
||||
New: func() any {
|
||||
return &lineScratch{
|
||||
buf: make([]byte, 0, jsonLineReaderSize),
|
||||
preview: make([]byte, 0, jsonLinePreviewBytes),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func ParseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(string), onMessage func(), onComplete func()) (message, threadID string) {
|
||||
reader := bufio.NewReaderSize(r, jsonLineReaderSize)
|
||||
scratch := lineScratchPool.Get().(*lineScratch)
|
||||
if scratch.buf == nil {
|
||||
scratch.buf = make([]byte, 0, jsonLineReaderSize)
|
||||
} else {
|
||||
scratch.buf = scratch.buf[:0]
|
||||
}
|
||||
if scratch.preview == nil {
|
||||
scratch.preview = make([]byte, 0, jsonLinePreviewBytes)
|
||||
} else {
|
||||
scratch.preview = scratch.preview[:0]
|
||||
}
|
||||
defer func() {
|
||||
if cap(scratch.buf) > maxPooledLineScratchCap {
|
||||
scratch.buf = nil
|
||||
} else if scratch.buf != nil {
|
||||
scratch.buf = scratch.buf[:0]
|
||||
}
|
||||
if cap(scratch.preview) > jsonLinePreviewBytes*4 {
|
||||
scratch.preview = nil
|
||||
} else if scratch.preview != nil {
|
||||
scratch.preview = scratch.preview[:0]
|
||||
}
|
||||
lineScratchPool.Put(scratch)
|
||||
}()
|
||||
|
||||
if warnFn == nil {
|
||||
warnFn = func(string) {}
|
||||
}
|
||||
if infoFn == nil {
|
||||
infoFn = func(string) {}
|
||||
}
|
||||
|
||||
notifyMessage := func() {
|
||||
if onMessage != nil {
|
||||
onMessage()
|
||||
}
|
||||
}
|
||||
|
||||
notifyComplete := func() {
|
||||
if onComplete != nil {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
totalEvents := 0
|
||||
|
||||
var (
|
||||
codexMessage string
|
||||
claudeMessage string
|
||||
geminiBuffer strings.Builder
|
||||
opencodeMessage strings.Builder
|
||||
)
|
||||
|
||||
for {
|
||||
line, tooLong, err := readLineWithLimit(reader, jsonLineMaxBytes, jsonLinePreviewBytes, scratch)
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
warnFn("Read stdout error: " + err.Error())
|
||||
break
|
||||
}
|
||||
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
totalEvents++
|
||||
|
||||
if tooLong {
|
||||
warnFn(fmt.Sprintf("Skipped overlong JSON line (> %d bytes): %s", jsonLineMaxBytes, TruncateBytes(line, 100)))
|
||||
continue
|
||||
}
|
||||
|
||||
// Single unmarshal for all backend types
|
||||
var event UnifiedEvent
|
||||
if err := json.Unmarshal(line, &event); err != nil {
|
||||
warnFn(fmt.Sprintf("Failed to parse event: %s", TruncateBytes(line, 100)))
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect backend type by field presence
|
||||
isCodex := event.ThreadID != ""
|
||||
if !isCodex && len(event.Item) > 0 {
|
||||
var itemHeader struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal(event.Item, &itemHeader) == nil && itemHeader.Type != "" {
|
||||
isCodex = true
|
||||
}
|
||||
}
|
||||
// Codex-specific event types without thread_id or item
|
||||
if !isCodex && (event.Type == "turn.started" || event.Type == "turn.completed") {
|
||||
isCodex = true
|
||||
}
|
||||
isClaude := event.Subtype != "" || event.Result != ""
|
||||
if !isClaude && event.Type == "result" && event.SessionID != "" && event.Status == "" {
|
||||
isClaude = true
|
||||
}
|
||||
isGemini := (event.Type == "init" && event.SessionID != "") || event.Role != "" || event.Delta != nil || event.Status != ""
|
||||
isOpencode := event.OpencodeSessionID != "" && len(event.Part) > 0
|
||||
|
||||
// Handle Opencode events first (most specific detection)
|
||||
if isOpencode {
|
||||
if threadID == "" {
|
||||
threadID = event.OpencodeSessionID
|
||||
}
|
||||
|
||||
var part OpencodePart
|
||||
if err := json.Unmarshal(event.Part, &part); err != nil {
|
||||
warnFn(fmt.Sprintf("Failed to parse opencode part: %s", err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract sessionID from part if available
|
||||
if part.SessionID != "" && threadID == "" {
|
||||
threadID = part.SessionID
|
||||
}
|
||||
|
||||
infoFn(fmt.Sprintf("Parsed Opencode event #%d type=%s part_type=%s", totalEvents, event.Type, part.Type))
|
||||
|
||||
if event.Type == "text" && part.Text != "" {
|
||||
opencodeMessage.WriteString(part.Text)
|
||||
notifyMessage()
|
||||
}
|
||||
|
||||
if part.Type == "step-finish" && part.Reason == "stop" {
|
||||
notifyComplete()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle Codex events
|
||||
if isCodex {
|
||||
var details []string
|
||||
if event.ThreadID != "" {
|
||||
details = append(details, fmt.Sprintf("thread_id=%s", event.ThreadID))
|
||||
}
|
||||
|
||||
if len(details) > 0 {
|
||||
infoFn(fmt.Sprintf("Parsed event #%d type=%s (%s)", totalEvents, event.Type, strings.Join(details, ", ")))
|
||||
} else {
|
||||
infoFn(fmt.Sprintf("Parsed event #%d type=%s", totalEvents, event.Type))
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "thread.started":
|
||||
threadID = event.ThreadID
|
||||
infoFn(fmt.Sprintf("thread.started event thread_id=%s", threadID))
|
||||
|
||||
case "thread.completed":
|
||||
if event.ThreadID != "" && threadID == "" {
|
||||
threadID = event.ThreadID
|
||||
}
|
||||
infoFn(fmt.Sprintf("thread.completed event thread_id=%s", event.ThreadID))
|
||||
notifyComplete()
|
||||
|
||||
case "turn.completed":
|
||||
infoFn("turn.completed event")
|
||||
notifyComplete()
|
||||
|
||||
case "item.completed":
|
||||
var itemType string
|
||||
if len(event.Item) > 0 {
|
||||
var itemHeader struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(event.Item, &itemHeader); err == nil {
|
||||
itemType = itemHeader.Type
|
||||
}
|
||||
}
|
||||
|
||||
if itemType == "agent_message" && len(event.Item) > 0 {
|
||||
// Lazy parse: only parse item content when needed
|
||||
var item ItemContent
|
||||
if err := json.Unmarshal(event.Item, &item); err == nil {
|
||||
normalized := NormalizeText(item.Text)
|
||||
infoFn(fmt.Sprintf("item.completed event item_type=%s message_len=%d", itemType, len(normalized)))
|
||||
if normalized != "" {
|
||||
codexMessage = normalized
|
||||
notifyMessage()
|
||||
}
|
||||
} else {
|
||||
warnFn(fmt.Sprintf("Failed to parse item content: %s", err.Error()))
|
||||
}
|
||||
} else {
|
||||
infoFn(fmt.Sprintf("item.completed event item_type=%s", itemType))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle Claude events
|
||||
if isClaude {
|
||||
if event.SessionID != "" && threadID == "" {
|
||||
threadID = event.SessionID
|
||||
}
|
||||
|
||||
infoFn(fmt.Sprintf("Parsed Claude event #%d type=%s subtype=%s result_len=%d", totalEvents, event.Type, event.Subtype, len(event.Result)))
|
||||
|
||||
if event.Result != "" {
|
||||
claudeMessage = event.Result
|
||||
notifyMessage()
|
||||
}
|
||||
|
||||
if event.Type == "result" {
|
||||
notifyComplete()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle Gemini events
|
||||
if isGemini {
|
||||
if event.SessionID != "" && threadID == "" {
|
||||
threadID = event.SessionID
|
||||
}
|
||||
|
||||
if event.Content != "" {
|
||||
geminiBuffer.WriteString(event.Content)
|
||||
}
|
||||
|
||||
if event.Status != "" {
|
||||
notifyMessage()
|
||||
|
||||
if event.Type == "result" && (event.Status == "success" || event.Status == "error" || event.Status == "complete" || event.Status == "failed") {
|
||||
notifyComplete()
|
||||
}
|
||||
}
|
||||
|
||||
delta := false
|
||||
if event.Delta != nil {
|
||||
delta = *event.Delta
|
||||
}
|
||||
|
||||
infoFn(fmt.Sprintf("Parsed Gemini event #%d type=%s role=%s delta=%t status=%s content_len=%d", totalEvents, event.Type, event.Role, delta, event.Status, len(event.Content)))
|
||||
continue
|
||||
}
|
||||
|
||||
// Unknown event format from other backends (turn.started/assistant/user); ignore.
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case opencodeMessage.Len() > 0:
|
||||
message = opencodeMessage.String()
|
||||
case geminiBuffer.Len() > 0:
|
||||
message = geminiBuffer.String()
|
||||
case claudeMessage != "":
|
||||
message = claudeMessage
|
||||
default:
|
||||
message = codexMessage
|
||||
}
|
||||
|
||||
infoFn(fmt.Sprintf("parseJSONStream completed: events=%d, message_len=%d, thread_id_found=%t", totalEvents, len(message), threadID != ""))
|
||||
return message, threadID
|
||||
}
|
||||
|
||||
func HasKey(m map[string]json.RawMessage, key string) bool {
|
||||
_, ok := m[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func DiscardInvalidJSON(decoder *json.Decoder, reader *bufio.Reader) (*bufio.Reader, error) {
|
||||
var buffered bytes.Buffer
|
||||
|
||||
if decoder != nil {
|
||||
if buf := decoder.Buffered(); buf != nil {
|
||||
_, _ = buffered.ReadFrom(buf)
|
||||
}
|
||||
}
|
||||
|
||||
line, err := reader.ReadBytes('\n')
|
||||
buffered.Write(line)
|
||||
|
||||
data := buffered.Bytes()
|
||||
newline := bytes.IndexByte(data, '\n')
|
||||
if newline == -1 {
|
||||
return reader, err
|
||||
}
|
||||
|
||||
remaining := data[newline+1:]
|
||||
if len(remaining) == 0 {
|
||||
return reader, err
|
||||
}
|
||||
|
||||
return bufio.NewReader(io.MultiReader(bytes.NewReader(remaining), reader)), err
|
||||
}
|
||||
|
||||
func readLineWithLimit(r *bufio.Reader, maxBytes int, previewBytes int, scratch *lineScratch) (line []byte, tooLong bool, err error) {
|
||||
if r == nil {
|
||||
return nil, false, errors.New("reader is nil")
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
return nil, false, errors.New("maxBytes must be > 0")
|
||||
}
|
||||
if previewBytes < 0 {
|
||||
previewBytes = 0
|
||||
}
|
||||
|
||||
part, isPrefix, err := r.ReadLine()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if !isPrefix {
|
||||
if len(part) > maxBytes {
|
||||
return part[:min(len(part), previewBytes)], true, nil
|
||||
}
|
||||
return part, false, nil
|
||||
}
|
||||
|
||||
if scratch == nil {
|
||||
scratch = &lineScratch{}
|
||||
}
|
||||
if scratch.preview == nil {
|
||||
scratch.preview = make([]byte, 0, min(previewBytes, len(part)))
|
||||
}
|
||||
if scratch.buf == nil {
|
||||
scratch.buf = make([]byte, 0, min(maxBytes, len(part)*2))
|
||||
}
|
||||
|
||||
preview := scratch.preview[:0]
|
||||
if previewBytes > 0 {
|
||||
preview = append(preview, part[:min(previewBytes, len(part))]...)
|
||||
}
|
||||
|
||||
buf := scratch.buf[:0]
|
||||
total := 0
|
||||
if len(part) > maxBytes {
|
||||
tooLong = true
|
||||
} else {
|
||||
buf = append(buf, part...)
|
||||
total = len(part)
|
||||
}
|
||||
|
||||
for isPrefix {
|
||||
part, isPrefix, err = r.ReadLine()
|
||||
if err != nil {
|
||||
return nil, tooLong, err
|
||||
}
|
||||
|
||||
if previewBytes > 0 && len(preview) < previewBytes {
|
||||
preview = append(preview, part[:min(previewBytes-len(preview), len(part))]...)
|
||||
}
|
||||
|
||||
if !tooLong {
|
||||
if total+len(part) > maxBytes {
|
||||
tooLong = true
|
||||
continue
|
||||
}
|
||||
buf = append(buf, part...)
|
||||
total += len(part)
|
||||
}
|
||||
}
|
||||
|
||||
if tooLong {
|
||||
scratch.preview = preview
|
||||
scratch.buf = buf
|
||||
return preview, true, nil
|
||||
}
|
||||
scratch.preview = preview
|
||||
scratch.buf = buf
|
||||
return buf, false, nil
|
||||
}
|
||||
|
||||
func TruncateBytes(b []byte, maxLen int) string {
|
||||
if len(b) <= maxLen {
|
||||
return string(b)
|
||||
}
|
||||
if maxLen < 0 {
|
||||
return ""
|
||||
}
|
||||
return string(b[:maxLen]) + "..."
|
||||
}
|
||||
|
||||
func NormalizeText(text interface{}) string {
|
||||
switch v := text.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []interface{}:
|
||||
var sb strings.Builder
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
sb.WriteString(s)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
50
codeagent-wrapper/internal/parser/parser_opencode_test.go
Normal file
50
codeagent-wrapper/internal/parser/parser_opencode_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseJSONStream_SkipsOverlongLineAndContinues(t *testing.T) {
|
||||
// Exceed the 10MB bufio.Scanner limit in parseJSONStreamInternal.
|
||||
tooLong := strings.Repeat("a", 11*1024*1024)
|
||||
|
||||
input := strings.Join([]string{
|
||||
`{"type":"item.completed","item":{"type":"other_type","text":"` + tooLong + `"}}`,
|
||||
`{"type":"thread.started","thread_id":"t-1"}`,
|
||||
`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`,
|
||||
}, "\n")
|
||||
|
||||
var warns []string
|
||||
warnFn := func(msg string) { warns = append(warns, msg) }
|
||||
|
||||
gotMessage, gotThreadID := ParseJSONStreamInternal(strings.NewReader(input), warnFn, nil, nil, nil)
|
||||
if gotMessage != "ok" {
|
||||
t.Fatalf("message=%q, want %q (warns=%v)", gotMessage, "ok", warns)
|
||||
}
|
||||
if gotThreadID != "t-1" {
|
||||
t.Fatalf("threadID=%q, want %q (warns=%v)", gotThreadID, "t-1", warns)
|
||||
}
|
||||
if len(warns) == 0 || !strings.Contains(warns[0], "Skipped overlong JSON line") {
|
||||
t.Fatalf("expected warning about overlong JSON line, got %v", warns)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
15
codeagent-wrapper/internal/parser/truncate_bytes_test.go
Normal file
15
codeagent-wrapper/internal/parser/truncate_bytes_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
8
codeagent-wrapper/internal/utils/math.go
Normal file
8
codeagent-wrapper/internal/utils/math.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package utils
|
||||
|
||||
func Min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
36
codeagent-wrapper/internal/utils/math_test.go
Normal file
36
codeagent-wrapper/internal/utils/math_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
62
codeagent-wrapper/internal/utils/strings.go
Normal file
62
codeagent-wrapper/internal/utils/strings.go
Normal file
@@ -0,0 +1,62 @@
|
||||
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()
|
||||
}
|
||||
122
codeagent-wrapper/internal/utils/strings_test.go
Normal file
122
codeagent-wrapper/internal/utils/strings_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user